Files
glchat/internal/store/passkeys.go
T
grendervill cd1d662572 feat(auth): вход по ключу доступа (passkeys, WebAuthn)
Фаза 7, AGENT.md 7.1: регистрация ключа в настройках безопасности с
обязательным step-up, вход по ключу без пароля (в т.ч. без ввода почты —
обнаруживаемый ключ), несколько ключей на аккаунт, отзыв и переименование,
события безопасности и аудит.

Сервер: github.com/go-webauthn/webauthn (BSD-3-Clause), RP ID и Origin
берутся из конфига домена; если домен — IP-адрес (стенд без домена),
passkeys честно выключены (auth.passkey_unsupported). Церемонии живут в
памяти процесса 5 минут и одноразовые: повторная отправка challenge
отклоняется. Миграция 00018 пересобирает неиспользуемую таблицу
webauthn_credentials под полную запись credential в JSON. Новые ручки
входа ограничены по IP (10/мин).

Клиент: тонкая обёртка над navigator.credentials без тяжёлых SDK,
раздел «Ключи доступа» в настройках безопасности и кнопка «Войти по ключу»
на экране входа; понятные сообщения для браузеров без поддержки WebAuthn
и при отмене диалога.

Тесты: Go — регистрация/вход с эмулятором аутентификатора (реальная
проверка подписи P-256), отказ при чужом challenge, одноразовость
церемонии, обязательный step-up при управлении ключами, запрет входа
забаненному, ограничение allowCredentials при входе с почтой, лимит и
валидация имени; web — 12 vitest с моком navigator.credentials;
Playwright — живой сценарий с виртуальным аутентификатором Chromium.
2026-09-26 15:12:43 +03:00

165 lines
6.3 KiB
Go

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)
}