package store import ( "context" "database/sql" "encoding/json" "time" ) // TOTPSecret — секрет второго фактора: в БД лежит зашифрованным (AGENT.md 9.2), // резервные коды хранятся только хэшами. type TOTPSecret struct { UserID uint64 SecretEncrypted string Enabled bool RecoveryCodeHashs []string ConfirmedAt *time.Time CreatedAt time.Time } // UpsertTOTPSecret создаёт или заменяет неподтверждённый секрет. func (s *Store) UpsertTOTPSecret(ctx context.Context, userID uint64, secretEncrypted string) error { _, err := s.writer.ExecContext(ctx, ` INSERT INTO totp_secrets (user_id, secret_encrypted, enabled, recovery_codes_json, created_at) VALUES (?, ?, 0, '[]', ?) ON CONFLICT (user_id) DO UPDATE SET secret_encrypted = excluded.secret_encrypted, enabled = 0, recovery_codes_json = '[]', confirmed_at = NULL, created_at = excluded.created_at`, int64(userID), secretEncrypted, s.Now()) return err } func (s *Store) GetTOTPSecret(ctx context.Context, userID uint64) (*TOTPSecret, error) { var ( secret TOTPSecret enabled int codes string confirmedAt sql.NullString createdAt string ) err := s.reader.QueryRowContext(ctx, ` SELECT user_id, secret_encrypted, enabled, recovery_codes_json, confirmed_at, created_at FROM totp_secrets WHERE user_id = ?`, int64(userID)). Scan(&secret.UserID, &secret.SecretEncrypted, &enabled, &codes, &confirmedAt, &createdAt) if err != nil { return nil, mapError(err) } secret.Enabled = enabled == 1 if err := json.Unmarshal([]byte(codes), &secret.RecoveryCodeHashs); err != nil { secret.RecoveryCodeHashs = nil } if confirmedAt.Valid { value := parseTimestamp(confirmedAt.String) secret.ConfirmedAt = &value } secret.CreatedAt = parseTimestamp(createdAt) return &secret, nil } // EnableTOTP подтверждает секрет и сохраняет хэши резервных кодов. func (s *Store) EnableTOTP(ctx context.Context, userID uint64, recoveryCodeHashes []string) error { encoded, err := json.Marshal(recoveryCodeHashes) if err != nil { return err } result, err := s.writer.ExecContext(ctx, ` UPDATE totp_secrets SET enabled = 1, recovery_codes_json = ?, confirmed_at = ? WHERE user_id = ?`, string(encoded), s.Now(), int64(userID)) if err != nil { return err } if affected, err := result.RowsAffected(); err == nil && affected == 0 { return ErrNotFound } return nil } // ConsumeRecoveryCode удаляет использованный резервный код. func (s *Store) ConsumeRecoveryCode(ctx context.Context, userID uint64, remaining []string) error { encoded, err := json.Marshal(remaining) if err != nil { return err } _, err = s.writer.ExecContext(ctx, `UPDATE totp_secrets SET recovery_codes_json = ? WHERE user_id = ?`, string(encoded), int64(userID)) return err } func (s *Store) DeleteTOTPSecret(ctx context.Context, userID uint64) error { _, err := s.writer.ExecContext(ctx, `DELETE FROM totp_secrets WHERE user_id = ?`, int64(userID)) return err } // RecordSecurityEvent пишет событие безопасности (AGENT.md 6.1). func (s *Store) RecordSecurityEvent(ctx context.Context, userID *uint64, eventType, ip, userAgent, metadata string) error { var userValue any if userID != nil { userValue = int64(*userID) } if metadata == "" { metadata = "{}" } _, err := s.writer.ExecContext(ctx, ` INSERT INTO security_events (id, user_id, type, ip, user_agent, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, int64(s.NextID()), userValue, eventType, ip, userAgent, metadata, s.Now()) return err } // ListSecurityEvents возвращает последние события пользователя. func (s *Store) ListSecurityEvents(ctx context.Context, userID uint64, limit int) ([]SecurityEvent, error) { if limit <= 0 || limit > 100 { limit = 20 } rows, err := s.reader.QueryContext(ctx, ` SELECT id, user_id, type, ip, user_agent, metadata_json, created_at FROM security_events WHERE user_id = ? ORDER BY id DESC LIMIT ?`, int64(userID), limit) if err != nil { return nil, err } defer rows.Close() events := make([]SecurityEvent, 0, limit) for rows.Next() { var ( event SecurityEvent userValue sql.NullInt64 createdAt string metadataRaw string ) if err := rows.Scan(&event.ID, &userValue, &event.Type, &event.IP, &event.UserAgent, &metadataRaw, &createdAt); err != nil { return nil, err } if userValue.Valid { event.UserID = uint64(userValue.Int64) } event.Metadata = json.RawMessage(metadataRaw) event.CreatedAt = parseTimestamp(createdAt) events = append(events, event) } return events, rows.Err() } type SecurityEvent struct { ID uint64 UserID uint64 Type string IP string UserAgent string Metadata json.RawMessage CreatedAt time.Time }