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