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
+20
View File
@@ -61,6 +61,26 @@ func newAPIError(err error) apiError {
case errors.Is(err, auth.ErrNoTOTPSecret):
candidate.Status, candidate.Code = http.StatusBadRequest, "auth.2fa_not_configured"
candidate.Message = "two-factor authentication is not configured"
// Passkeys (WebAuthn), Фаза 7: клиент различает «не поддерживается»,
// «церемония истекла» и «подпись не сошлась» (AGENT.md 8.5).
case errors.Is(err, auth.ErrPasskeysDisabled):
candidate.Status, candidate.Code = http.StatusNotImplemented, "auth.passkey_unsupported"
candidate.Message = "passkeys are not available on this instance"
case errors.Is(err, auth.ErrPasskeyCeremony):
candidate.Status, candidate.Code = http.StatusBadRequest, "auth.passkey_challenge_invalid"
candidate.Message = "passkey challenge is unknown, expired or already used"
case errors.Is(err, auth.ErrPasskeyVerification):
candidate.Status, candidate.Code = http.StatusUnauthorized, "auth.passkey_verification_failed"
candidate.Message = "passkey verification failed"
case errors.Is(err, auth.ErrPasskeyUnknown):
candidate.Status, candidate.Code = http.StatusNotFound, "auth.passkey_unknown"
candidate.Message = "passkey is not registered for this account"
case errors.Is(err, auth.ErrPasskeyNameInvalid):
candidate.Status, candidate.Code = http.StatusUnprocessableEntity, "auth.passkey_name_invalid"
candidate.Message = "passkey name must be 1-64 characters"
case errors.Is(err, auth.ErrPasskeyLimit):
candidate.Status, candidate.Code = http.StatusConflict, "auth.passkey_limit"
candidate.Message = "passkey limit reached for this account"
case errors.Is(err, store.ErrNotFound):
candidate.Status, candidate.Code, candidate.Message = http.StatusNotFound, "not_found", "resource not found"
case errors.Is(err, store.ErrConflict):