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.
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user