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:
2026-09-26 15:12:43 +03:00
parent 8e096ca6b2
commit cd1d662572
23 changed files with 2878 additions and 18 deletions
+13 -2
View File
@@ -16,6 +16,8 @@ import (
"glchat/internal/crypto"
"glchat/internal/permissions"
"glchat/internal/store"
"github.com/go-webauthn/webauthn/webauthn"
)
var (
@@ -54,6 +56,10 @@ type Service struct {
policy *passwordPolicy
cfg Config
logger *slog.Logger
// webauthn — RP-конфигурация passkeys (nil, если домен не годится для RP ID).
webauthn *webauthn.WebAuthn
// ceremonies — начатые церемонии WebAuthn (в памяти, одноразовые).
ceremonies *ceremonyStore
}
type instanceSettings interface {
@@ -102,7 +108,7 @@ func New(ctx context.Context, cfg config.Config, st *store.Store, logger *slog.L
sessionTTL = store.SessionTTL
}
return &Service{
service := &Service{
store: st,
hasher: hasher,
masterKey: masterKey,
@@ -111,7 +117,12 @@ func New(ctx context.Context, cfg config.Config, st *store.Store, logger *slog.L
policy: policy,
cfg: Config{SessionTTL: sessionTTL, MaxLoginFails: 5, LockoutWindow: 15 * time.Minute},
logger: logger,
}, nil
ceremonies: newCeremonyStore(),
}
// Passkeys требуют корректного RP ID: для IP-адреса (стенд без домена)
// браузеры WebAuthn не работают, поэтому такой инстанс просто живёт без них.
service.webauthn = newWebAuthn(cfg, logger)
return service, nil
}
// argon2ParamsFromConfig проверяет границы параметров Argon2id из env.