cd1d662572
Фаза 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.
329 lines
15 KiB
Go
329 lines
15 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// beginPasskeyRegistration проходит шаг step-up + begin и возвращает options.
|
|
func beginPasskeyRegistration(t *testing.T, srv *Server, cookie *http.Cookie) registrationOptions {
|
|
t.Helper()
|
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/passkeys/register/begin",
|
|
`{"password":"correct-horse-battery"}`, cookie)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("register/begin = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var options registrationOptions
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &options); err != nil {
|
|
t.Fatalf("decode options: %v", err)
|
|
}
|
|
if options.Options.PublicKey.Challenge == "" || options.Ceremony == "" {
|
|
t.Fatalf("options have no challenge or ceremony: %s", rec.Body.String())
|
|
}
|
|
if options.Options.PublicKey.RP.ID != srv.cfg.Domain {
|
|
t.Fatalf("rp id = %q, want %q", options.Options.PublicKey.RP.ID, srv.cfg.Domain)
|
|
}
|
|
return options
|
|
}
|
|
|
|
// registerPasskey регистрирует ключ эмулятором и возвращает его идентификатор.
|
|
func registerPasskey(t *testing.T, srv *Server, cookie *http.Cookie, authenticator *fakeAuthenticator, name string) (string, registrationOptions) {
|
|
t.Helper()
|
|
options := beginPasskeyRegistration(t, srv, cookie)
|
|
handle := decodeBase64URL(t, options.Options.PublicKey.User.ID)
|
|
credential := authenticator.createResponse(t, options.Options.PublicKey.Challenge, handle)
|
|
body := mustJSON(t, map[string]any{
|
|
"ceremony": options.Ceremony,
|
|
"name": name,
|
|
"credential": credential,
|
|
"step_up_password": "correct-horse-battery",
|
|
})
|
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/passkeys/register/finish", body, cookie)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("register/finish = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var payload struct {
|
|
Passkey struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
} `json:"passkey"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode passkey: %v", err)
|
|
}
|
|
if payload.Passkey.ID == "" || payload.Passkey.Name != name {
|
|
t.Fatalf("unexpected passkey payload: %s", rec.Body.String())
|
|
}
|
|
return payload.Passkey.ID, options
|
|
}
|
|
|
|
// TestPasskeyRegistrationAndLogin — happy path (AGENT.md 7.1, Фаза 7):
|
|
// регистрация ключа, вход по нему без пароля, обновление счётчика подписей.
|
|
func TestPasskeyRegistrationAndLogin(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
if !srv.auth.PasskeysEnabled() {
|
|
t.Fatal("passkeys must be enabled for the test domain")
|
|
}
|
|
cookie := registerAndLogin(t, srv, "passkey_user", "passkey@example.com")
|
|
authenticator := newFakeAuthenticator(t, srv.cfg.Domain, "https://"+srv.cfg.Domain)
|
|
|
|
passkeyID, options := registerPasskey(t, srv, cookie, authenticator, "MacBook Touch ID")
|
|
if options.ExpiresInSec <= 0 {
|
|
t.Fatal("ceremony must have a positive TTL")
|
|
}
|
|
|
|
// Ключ виден в списке и в событиях безопасности.
|
|
list := doJSON(t, srv, http.MethodGet, "/api/v1/auth/passkeys", "", cookie)
|
|
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), "MacBook Touch ID") {
|
|
t.Fatalf("GET /auth/passkeys = %d, body = %s", list.Code, list.Body.String())
|
|
}
|
|
events := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/security-events", "", cookie)
|
|
if !strings.Contains(events.Body.String(), "passkey_added") {
|
|
t.Fatalf("security events have no passkey_added: %s", events.Body.String())
|
|
}
|
|
|
|
// Вход по ключу: begin без email (обнаруживаемый ключ) + finish.
|
|
begin := doJSON(t, srv, http.MethodPost, "/api/v1/auth/passkeys/login/begin", "")
|
|
if begin.Code != http.StatusOK {
|
|
t.Fatalf("login/begin = %d, body = %s", begin.Code, begin.Body.String())
|
|
}
|
|
var assertion assertionOptions
|
|
if err := json.Unmarshal(begin.Body.Bytes(), &assertion); err != nil {
|
|
t.Fatalf("decode assertion options: %v", err)
|
|
}
|
|
credential := authenticator.assertResponse(t, assertion.Options.PublicKey.Challenge)
|
|
finish := doJSON(t, srv, http.MethodPost, "/api/v1/auth/passkeys/login/finish",
|
|
mustJSON(t, map[string]any{"ceremony": assertion.Ceremony, "credential": credential}))
|
|
if finish.Code != http.StatusOK {
|
|
t.Fatalf("login/finish = %d, body = %s", finish.Code, finish.Body.String())
|
|
}
|
|
cookies := finish.Result().Cookies()
|
|
if len(cookies) == 0 || cookies[0].Name != sessionCookieName {
|
|
t.Fatalf("passkey login must set a session cookie: %v", cookies)
|
|
}
|
|
// Сессия рабочая.
|
|
me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookies[0])
|
|
if me.Code != http.StatusOK || !strings.Contains(me.Body.String(), "passkey_user") {
|
|
t.Fatalf("GET /users/@me with passkey session = %d, body = %s", me.Code, me.Body.String())
|
|
}
|
|
|
|
// Вход попал в события безопасности, счётчик подписей обновился.
|
|
events = doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/security-events", "", cookie)
|
|
if !strings.Contains(events.Body.String(), "passkey_login") {
|
|
t.Fatalf("security events have no passkey_login: %s", events.Body.String())
|
|
}
|
|
listed := doJSON(t, srv, http.MethodGet, "/api/v1/auth/passkeys", "", cookie)
|
|
var payload struct {
|
|
Passkeys []struct {
|
|
ID string `json:"id"`
|
|
SignCount int64 `json:"sign_count"`
|
|
LastUsed string `json:"last_used_at"`
|
|
} `json:"passkeys"`
|
|
}
|
|
if err := json.Unmarshal(listed.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode passkeys: %v", err)
|
|
}
|
|
if len(payload.Passkeys) != 1 || payload.Passkeys[0].ID != passkeyID {
|
|
t.Fatalf("unexpected passkeys list: %s", listed.Body.String())
|
|
}
|
|
if payload.Passkeys[0].SignCount != 1 || payload.Passkeys[0].LastUsed == "" {
|
|
t.Fatalf("sign count / last used not updated: %s", listed.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestPasskeyLoginRejectsWrongChallenge — подпись над другим challenge
|
|
// отклоняется, повторная отправка той же церемонии тоже (AGENTS.md 7.1).
|
|
func TestPasskeyLoginRejectsWrongChallenge(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
cookie := registerAndLogin(t, srv, "passkey_challenge", "passkey-challenge@example.com")
|
|
authenticator := newFakeAuthenticator(t, srv.cfg.Domain, "https://"+srv.cfg.Domain)
|
|
registerPasskey(t, srv, cookie, authenticator, "Ключ")
|
|
|
|
begin := doJSON(t, srv, http.MethodPost, "/api/v1/auth/passkeys/login/begin", "")
|
|
var assertion assertionOptions
|
|
if err := json.Unmarshal(begin.Body.Bytes(), &assertion); err != nil {
|
|
t.Fatalf("decode assertion options: %v", err)
|
|
}
|
|
// Подписываем чужой challenge: подпись валидна, но challenge не совпадает.
|
|
foreign := authenticator.assertResponse(t, encodeBase64URL([]byte("another-challenge-value-1234")))
|
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/passkeys/login/finish",
|
|
mustJSON(t, map[string]any{"ceremony": assertion.Ceremony, "credential": foreign}))
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("wrong challenge = %d, want 401, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
if code := errorCodeOf(t, rec); code != "auth.passkey_verification_failed" {
|
|
t.Fatalf("error code = %q", code)
|
|
}
|
|
|
|
// Церемония одноразовая: повторный finish с тем же id отклоняется.
|
|
correct := authenticator.assertResponse(t, assertion.Options.PublicKey.Challenge)
|
|
rec = doJSON(t, srv, http.MethodPost, "/api/v1/auth/passkeys/login/finish",
|
|
mustJSON(t, map[string]any{"ceremony": assertion.Ceremony, "credential": correct}))
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("reused ceremony = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
if code := errorCodeOf(t, rec); code != "auth.passkey_challenge_invalid" {
|
|
t.Fatalf("error code = %q, want auth.passkey_challenge_invalid", code)
|
|
}
|
|
}
|
|
|
|
// TestPasskeyManagementRequiresStepUp — управление ключами требует свежего
|
|
// подтверждения личности, регистрация невозможна без него (AGENT.md 7.1).
|
|
func TestPasskeyManagementRequiresStepUp(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
cookie := registerAndLogin(t, srv, "passkey_stepup", "passkey-stepup@example.com")
|
|
authenticator := newFakeAuthenticator(t, srv.cfg.Domain, "https://"+srv.cfg.Domain)
|
|
|
|
// Без step-up begin отклоняется.
|
|
denied := doJSON(t, srv, http.MethodPost, "/api/v1/auth/passkeys/register/begin", `{"password":""}`, cookie)
|
|
if denied.Code != http.StatusForbidden {
|
|
t.Fatalf("begin without step-up = %d, want 403, body = %s", denied.Code, denied.Body.String())
|
|
}
|
|
if code := errorCodeOf(t, denied); code != "auth.step_up_required" {
|
|
t.Fatalf("error code = %q", code)
|
|
}
|
|
|
|
// С неверным паролем — тоже.
|
|
denied = doJSON(t, srv, http.MethodPost, "/api/v1/auth/passkeys/register/begin",
|
|
`{"password":"wrong-password"}`, cookie)
|
|
if denied.Code != http.StatusForbidden {
|
|
t.Fatalf("begin with wrong password = %d, want 403", denied.Code)
|
|
}
|
|
|
|
passkeyID, _ := registerPasskey(t, srv, cookie, authenticator, "Ключ")
|
|
|
|
// Удаление чужим пользователем невозможно.
|
|
other := registerAndLogin(t, srv, "passkey_other", "passkey-other@example.com")
|
|
stepUp(t, srv, other)
|
|
foreign := doJSON(t, srv, http.MethodDelete, "/api/v1/auth/passkeys/"+passkeyID, "", other)
|
|
if foreign.Code != http.StatusNotFound {
|
|
t.Fatalf("delete foreign passkey = %d, want 404, body = %s", foreign.Code, foreign.Body.String())
|
|
}
|
|
|
|
// Свой ключ удаляется после step-up и больше не работает для входа.
|
|
stepUp(t, srv, cookie)
|
|
deleted := doJSON(t, srv, http.MethodDelete, "/api/v1/auth/passkeys/"+passkeyID, "", cookie)
|
|
if deleted.Code != http.StatusOK {
|
|
t.Fatalf("delete passkey = %d, body = %s", deleted.Code, deleted.Body.String())
|
|
}
|
|
begin := doJSON(t, srv, http.MethodPost, "/api/v1/auth/passkeys/login/begin", "")
|
|
var assertion assertionOptions
|
|
if err := json.Unmarshal(begin.Body.Bytes(), &assertion); err != nil {
|
|
t.Fatalf("decode assertion options: %v", err)
|
|
}
|
|
credential := authenticator.assertResponse(t, assertion.Options.PublicKey.Challenge)
|
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/passkeys/login/finish",
|
|
mustJSON(t, map[string]any{"ceremony": assertion.Ceremony, "credential": credential}))
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("login with revoked passkey = %d, want 404, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestPasskeyLoginBannedUserRejected — бан инстанса запрещает и вход по ключу
|
|
// (AGENT.md 7.18).
|
|
func TestPasskeyLoginBannedUserRejected(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
cookie := registerAndLogin(t, srv, "passkey_banned", "passkey-banned@example.com")
|
|
authenticator := newFakeAuthenticator(t, srv.cfg.Domain, "https://"+srv.cfg.Domain)
|
|
registerPasskey(t, srv, cookie, authenticator, "Ключ")
|
|
|
|
user, err := srv.auth.UserByEmail(t.Context(), "passkey-banned@example.com")
|
|
if err != nil {
|
|
t.Fatalf("find user: %v", err)
|
|
}
|
|
if err := srv.store.BanInstanceUser(t.Context(), user.ID, user.ID, "тест"); err != nil {
|
|
t.Fatalf("ban user: %v", err)
|
|
}
|
|
|
|
begin := doJSON(t, srv, http.MethodPost, "/api/v1/auth/passkeys/login/begin", "")
|
|
var assertion assertionOptions
|
|
if err := json.Unmarshal(begin.Body.Bytes(), &assertion); err != nil {
|
|
t.Fatalf("decode assertion options: %v", err)
|
|
}
|
|
credential := authenticator.assertResponse(t, assertion.Options.PublicKey.Challenge)
|
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/passkeys/login/finish",
|
|
mustJSON(t, map[string]any{"ceremony": assertion.Ceremony, "credential": credential}))
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("banned passkey login = %d, want 403, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
if code := errorCodeOf(t, rec); code != "user.banned" {
|
|
t.Fatalf("error code = %q, want user.banned", code)
|
|
}
|
|
}
|
|
|
|
// TestPasskeyLoginByEmailRestrictsCredentials — вход с email ограничивает
|
|
// список ключей конкретным аккаунтом.
|
|
func TestPasskeyLoginByEmailRestrictsCredentials(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
cookie := registerAndLogin(t, srv, "passkey_email", "passkey-email@example.com")
|
|
authenticator := newFakeAuthenticator(t, srv.cfg.Domain, "https://"+srv.cfg.Domain)
|
|
registerPasskey(t, srv, cookie, authenticator, "Ключ")
|
|
|
|
begin := doJSON(t, srv, http.MethodPost, "/api/v1/auth/passkeys/login/begin",
|
|
`{"email":"passkey-email@example.com"}`)
|
|
if begin.Code != http.StatusOK {
|
|
t.Fatalf("login/begin by email = %d, body = %s", begin.Code, begin.Body.String())
|
|
}
|
|
var payload struct {
|
|
Options struct {
|
|
PublicKey struct {
|
|
Challenge string `json:"challenge"`
|
|
AllowCredentials []struct {
|
|
ID string `json:"id"`
|
|
} `json:"allowCredentials"`
|
|
} `json:"publicKey"`
|
|
} `json:"options"`
|
|
Ceremony string `json:"ceremony"`
|
|
}
|
|
if err := json.Unmarshal(begin.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode options: %v", err)
|
|
}
|
|
if len(payload.Options.PublicKey.AllowCredentials) != 1 {
|
|
t.Fatalf("allowCredentials = %s, want one credential", begin.Body.String())
|
|
}
|
|
if payload.Options.PublicKey.AllowCredentials[0].ID != encodeBase64URL(authenticator.credentialID) {
|
|
t.Fatalf("allowCredentials id mismatch: %s", begin.Body.String())
|
|
}
|
|
credential := authenticator.assertResponse(t, payload.Options.PublicKey.Challenge)
|
|
finish := doJSON(t, srv, http.MethodPost, "/api/v1/auth/passkeys/login/finish",
|
|
mustJSON(t, map[string]any{"ceremony": payload.Ceremony, "credential": credential}))
|
|
if finish.Code != http.StatusOK {
|
|
t.Fatalf("login/finish by email = %d, body = %s", finish.Code, finish.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestPasskeyRolesAndLimits — лимит числа ключей и валидация имени.
|
|
func TestPasskeyLimitAndName(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
cookie := registerAndLogin(t, srv, "passkey_limit", "passkey-limit@example.com")
|
|
stepUp(t, srv, cookie)
|
|
|
|
// Пустое имя отклоняется.
|
|
options := beginPasskeyRegistration(t, srv, cookie)
|
|
authenticator := newFakeAuthenticator(t, srv.cfg.Domain, "https://"+srv.cfg.Domain)
|
|
handle := decodeBase64URL(t, options.Options.PublicKey.User.ID)
|
|
credential := authenticator.createResponse(t, options.Options.PublicKey.Challenge, handle)
|
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/passkeys/register/finish",
|
|
mustJSON(t, map[string]any{
|
|
"ceremony": options.Ceremony, "name": " ", "credential": credential,
|
|
"step_up_password": "correct-horse-battery",
|
|
}), cookie)
|
|
if rec.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("empty name = %d, want 422, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
if code := errorCodeOf(t, rec); code != "auth.passkey_name_invalid" {
|
|
t.Fatalf("error code = %q", code)
|
|
}
|
|
}
|
|
|
|
// stepUp выполняет подтверждение личности для последующих чувствительных ручек.
|
|
func stepUp(t *testing.T, srv *Server, cookie *http.Cookie) {
|
|
t.Helper()
|
|
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/step-up", `{"password":"correct-horse-battery"}`, cookie)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("step-up = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|