package store import ( "context" "database/sql" "encoding/base64" "time" ) // Passkey — ключ доступа (WebAuthn) пользователя. Полная запись credential // лежит в CredentialJSON: библиотека go-webauthn обновляет в ней счётчик // подписей и флаги, поэтому структуру не разбираем на колонки. type Passkey struct { ID uint64 UserID uint64 CredentialID string Name string CredentialJSON []byte SignCount uint32 Transports string CreatedAt time.Time LastUsedAt *time.Time } // CreatePasskeyParams — параметры нового ключа. type CreatePasskeyParams struct { UserID uint64 CredentialID string Name string CredentialJSON []byte SignCount uint32 Transports string } const passkeyColumns = `id, user_id, credential_id, name, credential_json, sign_count, transports, created_at, last_used_at` func scanPasskey(scanner interface{ Scan(...any) error }) (*Passkey, error) { var ( passkey Passkey credential string lastUsed sql.NullString createdAt string ) if err := scanner.Scan(&passkey.ID, &passkey.UserID, &passkey.CredentialID, &passkey.Name, &credential, &passkey.SignCount, &passkey.Transports, &createdAt, &lastUsed); err != nil { return nil, mapError(err) } passkey.CredentialJSON = []byte(credential) passkey.CreatedAt = parseTimestamp(createdAt) if lastUsed.Valid { value := parseTimestamp(lastUsed.String) passkey.LastUsedAt = &value } return &passkey, nil } // CreatePasskey сохраняет новый ключ доступа. func (s *Store) CreatePasskey(ctx context.Context, params CreatePasskeyParams) (*Passkey, error) { id := s.NextID() _, err := s.writer.ExecContext(ctx, ` INSERT INTO webauthn_credentials (id, user_id, credential_id, name, credential_json, sign_count, transports, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, int64(id), int64(params.UserID), params.CredentialID, params.Name, string(params.CredentialJSON), int64(params.SignCount), params.Transports, s.Now(), s.Now()) if err != nil { return nil, mapError(err) } return s.GetPasskey(ctx, id) } // GetPasskey возвращает ключ по идентификатору. func (s *Store) GetPasskey(ctx context.Context, id uint64) (*Passkey, error) { row := s.reader.QueryRowContext(ctx, `SELECT `+passkeyColumns+` FROM webauthn_credentials WHERE id = ?`, int64(id)) return scanPasskey(row) } // GetPasskeyByCredentialID ищет ключ по идентификатору credential: он приходит // от аутентификатора при входе, поэтому поиск идёт по уникальному индексу. func (s *Store) GetPasskeyByCredentialID(ctx context.Context, credentialID string) (*Passkey, error) { row := s.reader.QueryRowContext(ctx, `SELECT `+passkeyColumns+` FROM webauthn_credentials WHERE credential_id = ?`, credentialID) return scanPasskey(row) } // ListPasskeys возвращает ключи пользователя: свежие сверху. func (s *Store) ListPasskeys(ctx context.Context, userID uint64) ([]Passkey, error) { rows, err := s.reader.QueryContext(ctx, `SELECT `+passkeyColumns+` FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC, id DESC`, int64(userID)) if err != nil { return nil, err } defer rows.Close() passkeys := make([]Passkey, 0, 4) for rows.Next() { passkey, err := scanPasskey(rows) if err != nil { return nil, err } passkeys = append(passkeys, *passkey) } return passkeys, rows.Err() } // CountPasskeys считает ключи пользователя: ограничиваем их число, чтобы // список не разрастался (AGENT.md 7.1 — несколько ключей на аккаунт). func (s *Store) CountPasskeys(ctx context.Context, userID uint64) (int, error) { var count int err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM webauthn_credentials WHERE user_id = ?`, int64(userID)).Scan(&count) return count, err } // RenamePasskey меняет отображаемое имя ключа. func (s *Store) RenamePasskey(ctx context.Context, id, userID uint64, name string) error { result, err := s.writer.ExecContext(ctx, `UPDATE webauthn_credentials SET name = ?, updated_at = ? WHERE id = ? AND user_id = ?`, name, s.Now(), int64(id), int64(userID)) if err != nil { return mapError(err) } if affected, err := result.RowsAffected(); err == nil && affected == 0 { return ErrNotFound } return nil } // DeletePasskey удаляет ключ пользователя: чужой ключ удалить нельзя. func (s *Store) DeletePasskey(ctx context.Context, id, userID uint64) error { result, err := s.writer.ExecContext(ctx, `DELETE FROM webauthn_credentials WHERE id = ? AND user_id = ?`, int64(id), int64(userID)) if err != nil { return mapError(err) } if affected, err := result.RowsAffected(); err == nil && affected == 0 { return ErrNotFound } return nil } // DeletePasskeysForUser удаляет все ключи пользователя (сброс администратором). func (s *Store) DeletePasskeysForUser(ctx context.Context, userID uint64) error { _, err := s.writer.ExecContext(ctx, `DELETE FROM webauthn_credentials WHERE user_id = ?`, int64(userID)) return err } // UpdatePasskeyUsage сохраняет обновлённую запись credential после успешного // входа: библиотека увеличивает счётчик подписей и обновляет флаги бэкапа. func (s *Store) UpdatePasskeyUsage(ctx context.Context, id uint64, credentialJSON []byte, signCount uint32) error { _, err := s.writer.ExecContext(ctx, ` UPDATE webauthn_credentials SET credential_json = ?, sign_count = ?, last_used_at = ?, updated_at = ? WHERE id = ?`, string(credentialJSON), int64(signCount), s.Now(), s.Now(), int64(id)) return err } // EncodeCredentialID кодирует raw credential id в base64url без паддинга — // в таком виде его отдаёт браузер (PublicKeyCredential.id). func EncodeCredentialID(raw []byte) string { return base64.RawURLEncoding.EncodeToString(raw) }