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):
+215
View File
@@ -0,0 +1,215 @@
package server
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"testing"
"github.com/fxamacker/cbor/v2"
)
// fakeAuthenticator — эмулятор аппаратного ключа для тестов: генерирует
// P-256 ключ, собирает attestationObject формата none и подписывает assertion.
// Так проверяется настоящая криптографическая проверка библиотеки, а не мок.
type fakeAuthenticator struct {
key *ecdsa.PrivateKey
credentialID []byte
handle []byte
signCount uint32
rpID string
origin string
// cloneWarning выставляет флаг BackupState: проверка клонов не должна
// ломать вход, но счётчик подписей обязан сохраняться.
backupEligible bool
}
func newFakeAuthenticator(t *testing.T, rpID, origin string) *fakeAuthenticator {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate authenticator key: %v", err)
}
credentialID := make([]byte, 32)
if _, err := rand.Read(credentialID); err != nil {
t.Fatalf("generate credential id: %v", err)
}
return &fakeAuthenticator{key: key, credentialID: credentialID, rpID: rpID, origin: origin}
}
// registrationOptions — то, что сервер вернул в /register/begin.
type registrationOptions struct {
Options struct {
PublicKey struct {
Challenge string `json:"challenge"`
RP struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"rp"`
User struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
} `json:"user"`
} `json:"publicKey"`
} `json:"options"`
Ceremony string `json:"ceremony"`
ExpiresInSec int `json:"expires_in_sec"`
}
// assertionOptions — то, что сервер вернул в /login/begin.
type assertionOptions struct {
Options struct {
PublicKey struct {
Challenge string `json:"challenge"`
} `json:"publicKey"`
} `json:"options"`
Ceremony string `json:"ceremony"`
}
func decodeBase64URL(t *testing.T, value string) []byte {
t.Helper()
raw, err := base64.RawURLEncoding.DecodeString(value)
if err != nil {
t.Fatalf("decode base64url %q: %v", value, err)
}
return raw
}
func encodeBase64URL(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) }
// createResponse собирает ответ на регистрацию: JSON, который браузер отдаёт
// в navigator.credentials.create → сервер.
func (a *fakeAuthenticator) createResponse(t *testing.T, challenge string, userHandle []byte) map[string]any {
t.Helper()
a.handle = userHandle
clientData := map[string]any{
"type": "webauthn.create",
"challenge": challenge,
"origin": a.origin,
"crossOrigin": false,
}
clientDataJSON, err := json.Marshal(clientData)
if err != nil {
t.Fatalf("marshal client data: %v", err)
}
authData := a.authData(t, true)
attestation, err := cbor.Marshal(map[string]any{
"fmt": "none",
"attStmt": map[string]any{},
"authData": authData,
})
if err != nil {
t.Fatalf("marshal attestation object: %v", err)
}
return map[string]any{
"id": encodeBase64URL(a.credentialID),
"rawId": encodeBase64URL(a.credentialID),
"type": "public-key",
"response": map[string]any{
"clientDataJSON": encodeBase64URL(clientDataJSON),
"attestationObject": encodeBase64URL(attestation),
"transports": []string{"internal", "hybrid"},
},
"clientExtensionResults": map[string]any{},
}
}
// assertResponse собирает ответ на вход: подпись над authData||SHA256(clientDataJSON).
func (a *fakeAuthenticator) assertResponse(t *testing.T, challenge string) map[string]any {
t.Helper()
clientData := map[string]any{
"type": "webauthn.get",
"challenge": challenge,
"origin": a.origin,
"crossOrigin": false,
}
clientDataJSON, err := json.Marshal(clientData)
if err != nil {
t.Fatalf("marshal client data: %v", err)
}
a.signCount++
authData := a.authData(t, false)
clientHash := sha256.Sum256(clientDataJSON)
signed := append(append([]byte{}, authData...), clientHash[:]...)
digest := sha256.Sum256(signed)
signature, err := ecdsa.SignASN1(rand.Reader, a.key, digest[:])
if err != nil {
t.Fatalf("sign assertion: %v", err)
}
return map[string]any{
"id": encodeBase64URL(a.credentialID),
"rawId": encodeBase64URL(a.credentialID),
"type": "public-key",
"response": map[string]any{
"clientDataJSON": encodeBase64URL(clientDataJSON),
"authenticatorData": encodeBase64URL(authData),
"signature": encodeBase64URL(signature),
"userHandle": encodeBase64URL(a.handle),
},
"clientExtensionResults": map[string]any{},
}
}
// authData собирает authenticatorData: rpIdHash, флаги, счётчик и (при
// регистрации) attestedCredentialData с CBOR-ключом COSE EC2.
func (a *fakeAuthenticator) authData(t *testing.T, attestation bool) []byte {
t.Helper()
rpHash := sha256.Sum256([]byte(a.rpID))
flags := byte(0x01) // UP: пользователь присутствовал
flags |= 0x04 // UV: пользователь проверен
if attestation {
flags |= 0x40 // AT: есть attestedCredentialData
}
if a.backupEligible {
flags |= 0x08 // BE
flags |= 0x10 // BS
}
buffer := &bytes.Buffer{}
buffer.Write(rpHash[:])
buffer.WriteByte(flags)
_ = binary.Write(buffer, binary.BigEndian, a.signCount)
if !attestation {
return buffer.Bytes()
}
buffer.Write(make([]byte, 16))
credentialIDLength := make([]byte, 2)
binary.BigEndian.PutUint16(credentialIDLength, uint16(len(a.credentialID)))
buffer.Write(credentialIDLength)
buffer.Write(a.credentialID)
// PublicKey.Bytes() — несжатая точка: 0x04 || X || Y (Go 1.25+).
point, err := a.key.PublicKey.Bytes()
if err != nil {
t.Fatalf("encode public key: %v", err)
}
if len(point) != 65 {
t.Fatalf("unexpected public key encoding: %d bytes", len(point))
}
coseKey, err := cbor.Marshal(map[int]any{
1: 2, // kty: EC2
3: -7, // alg: ES256
-1: 1, // crv: P-256
-2: point[1:33],
-3: point[33:65],
})
if err != nil {
t.Fatalf("marshal cose key: %v", err)
}
buffer.Write(coseKey)
return buffer.Bytes()
}
// mustJSON кодирует значение в JSON-строку для запроса.
func mustJSON(t *testing.T, value any) string {
t.Helper()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
return string(encoded)
}
+258
View File
@@ -0,0 +1,258 @@
package server
import (
"bytes"
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"glchat/internal/auth"
"glchat/internal/httpx"
"glchat/internal/store"
)
// registerPasskeyRoutes вешает ручки passkeys (AGENT.md 7.1, Фаза 7).
// Вход по ключу — публичные ручки с отдельным лимитом по IP, управление
// ключами требует сессии и свежего step-up.
func (s *Server) registerPasskeyRoutes(router chi.Router) {
loginLimit := s.passkeyLimiter.Middleware(func(r *http.Request) string {
return httpx.ClientIP(r, nil)
})
router.With(loginLimit).Post("/auth/passkeys/login/begin", s.handlePasskeyLoginBegin)
router.With(loginLimit).Post("/auth/passkeys/login/finish", s.handlePasskeyLoginFinish)
router.Get("/auth/passkeys", s.handleListPasskeys)
router.Post("/auth/passkeys/register/begin", s.handlePasskeyRegisterBegin)
router.Post("/auth/passkeys/register/finish", s.handlePasskeyRegisterFinish)
router.Patch("/auth/passkeys/{passkey_id}", s.handleRenamePasskey)
router.Delete("/auth/passkeys/{passkey_id}", s.handleDeletePasskey)
}
type passkeyPayload struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
LastUsedAt string `json:"last_used_at,omitempty"`
SignCount int64 `json:"sign_count"`
}
// handleListPasskeys отдаёт ключи текущего пользователя.
func (s *Server) handleListPasskeys(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
passkeys, err := s.auth.ListPasskeys(r.Context(), user.ID)
if err != nil {
writeAPIError(w, err)
return
}
payload := make([]passkeyPayload, 0, len(passkeys))
for _, passkey := range passkeys {
item := passkeyPayload{
ID: formatSnowflake(passkey.ID),
Name: passkey.Name,
CreatedAt: passkey.CreatedAt.UTC().Format(time.RFC3339),
SignCount: int64(passkey.SignCount),
}
if passkey.LastUsedAt != nil {
item.LastUsedAt = passkey.LastUsedAt.UTC().Format(time.RFC3339)
}
payload = append(payload, item)
}
writeJSON(w, map[string]any{
"passkeys": payload,
"supported": s.auth.PasskeysEnabled(),
})
}
type passkeyRegisterFinishRequest struct {
Ceremony string `json:"ceremony"`
Name string `json:"name"`
// Credential — ответ navigator.credentials.create (WebAuthn JSON).
Credential json.RawMessage `json:"credential"`
// StepUp подтверждает личность: управление ключами — чувствительное
// действие (AGENT.md 7.1).
StepUpPassword string `json:"step_up_password"`
StepUpTOTP string `json:"step_up_totp"`
}
// handlePasskeyRegisterBegin начинает регистрацию ключа: нужен step-up.
func (s *Server) handlePasskeyRegisterBegin(w http.ResponseWriter, r *http.Request) {
user, session, ok := s.authenticate(w, r)
if !ok {
return
}
var request stepUpRequest
if !decodeBody(w, r, &request) {
return
}
if err := s.auth.RequireStepUp(r.Context(), user, session, request.Password, request.TOTPCode); err != nil {
writeAPIError(w, err)
return
}
start, err := s.auth.BeginPasskeyRegistration(r.Context(), user)
if err != nil {
writeAPIError(w, err)
return
}
writeJSON(w, map[string]any{
"options": start.Options,
"ceremony": start.Ceremony,
"expires_in_sec": int(start.ExpiresIn.Seconds()),
})
}
// handlePasskeyRegisterFinish проверяет ответ аутентификатора и сохраняет ключ.
func (s *Server) handlePasskeyRegisterFinish(w http.ResponseWriter, r *http.Request) {
user, session, ok := s.authenticate(w, r)
if !ok {
return
}
var request passkeyRegisterFinishRequest
if !decodeBody(w, r, &request) {
return
}
// Step-up проверяем ещё раз: между begin и finish сессия могла «остыть».
if err := s.auth.RequireStepUp(r.Context(), user, session, request.StepUpPassword, request.StepUpTOTP); err != nil {
writeAPIError(w, err)
return
}
if len(request.Credential) == 0 {
writeAPIError(w, auth.ErrPasskeyVerification)
return
}
passkey, err := s.auth.FinishPasskeyRegistration(r.Context(), user, request.Ceremony, request.Name,
bytes.NewReader(request.Credential))
if err != nil {
writeAPIError(w, err)
return
}
if err := s.store.RecordAudit(r.Context(), store.AuditParams{
ActorID: &user.ID,
Action: "user.passkey_add",
TargetType: "user",
TargetID: &user.ID,
Changes: map[string]any{"passkey_id": formatSnowflake(passkey.ID), "name": passkey.Name},
}); err != nil {
s.logger.WarnContext(r.Context(), "не удалось записать добавление passkey в аудит", slog.Any("error", err))
}
writeJSON(w, map[string]any{"passkey": passkeyPayload{
ID: formatSnowflake(passkey.ID),
Name: passkey.Name,
CreatedAt: passkey.CreatedAt.UTC().Format(time.RFC3339),
SignCount: int64(passkey.SignCount),
}})
}
func (s *Server) handleRenamePasskey(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
id, err := parseID("passkey_id", chi.URLParam(r, "passkey_id"))
if err != nil {
writeHumaAPIError(w, err)
return
}
var request struct {
Name string `json:"name"`
}
if !decodeBody(w, r, &request) {
return
}
if err := s.auth.RenamePasskey(r.Context(), user.ID, id, request.Name); err != nil {
writeAPIError(w, err)
return
}
writeJSON(w, map[string]any{"ok": true})
}
// handleDeletePasskey отзывает ключ: требуется свежий step-up (тело у DELETE
// не читаем — клиент сначала вызывает /auth/step-up, как и у других ручек).
func (s *Server) handleDeletePasskey(w http.ResponseWriter, r *http.Request) {
user, session, ok := s.authenticate(w, r)
if !ok {
return
}
if err := s.auth.RequireStepUp(r.Context(), user, session, "", ""); err != nil {
writeAPIError(w, err)
return
}
id, err := parseID("passkey_id", chi.URLParam(r, "passkey_id"))
if err != nil {
writeHumaAPIError(w, err)
return
}
if err := s.auth.DeletePasskey(r.Context(), user.ID, id); err != nil {
writeAPIError(w, err)
return
}
if err := s.store.RecordAudit(r.Context(), store.AuditParams{
ActorID: &user.ID,
Action: "user.passkey_remove",
TargetType: "user",
TargetID: &user.ID,
Changes: map[string]any{"passkey_id": formatSnowflake(id)},
}); err != nil {
s.logger.WarnContext(r.Context(), "не удалось записать удаление passkey в аудит", slog.Any("error", err))
}
writeJSON(w, map[string]any{"ok": true})
}
// handlePasskeyLoginBegin начинает вход по ключу: email необязателен.
func (s *Server) handlePasskeyLoginBegin(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
writeAPIError(w, auth.ErrSessionExpired)
return
}
var request struct {
Email string `json:"email"`
}
// Пустое тело — тоже валидный запрос (вход без email).
if r.Body != nil && r.ContentLength != 0 {
if !decodeBody(w, r, &request) {
return
}
}
start, err := s.auth.BeginPasskeyLogin(r.Context(), request.Email)
if err != nil {
writeAPIError(w, err)
return
}
writeJSON(w, map[string]any{
"options": start.Options,
"ceremony": start.Ceremony,
"expires_in_sec": int(start.ExpiresIn.Seconds()),
})
}
// handlePasskeyLoginFinish проверяет подпись и выдаёт сессию.
func (s *Server) handlePasskeyLoginFinish(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
writeAPIError(w, auth.ErrSessionExpired)
return
}
var request struct {
Ceremony string `json:"ceremony"`
Credential json.RawMessage `json:"credential"`
}
if !decodeBody(w, r, &request) {
return
}
if len(request.Credential) == 0 {
writeAPIError(w, auth.ErrPasskeyVerification)
return
}
user, token, session, err := s.auth.FinishPasskeyLogin(r.Context(), request.Ceremony,
bytes.NewReader(request.Credential), httpx.ClientIPFromContext(r.Context()),
httpx.UserAgentFromContext(r.Context()))
if err != nil {
writeAPIError(w, err)
return
}
http.SetCookie(w, s.sessionCookie(token, session.ExpiresAt))
writeJSON(w, s.withTOTPState(r.Context(), user))
}
+328
View File
@@ -0,0 +1,328 @@
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())
}
}
+7
View File
@@ -67,6 +67,9 @@ type Server struct {
uploadDailyLimiter *httpx.RateLimiter
// reactionLimiter — 20 реакций в минуту на пользователя.
reactionLimiter *httpx.RateLimiter
// passkeyLimiter ограничивает новые ручки входа (Фаза 7): 10 запросов в
// минуту на IP — с запасом на пару begin/finish.
passkeyLimiter *httpx.RateLimiter
// slowmode — время последней отправки в комнату для режима медленной
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
slowmodeMu sync.Mutex
@@ -124,6 +127,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
uploadLimiter: httpx.NewRateLimiter(10, 10),
uploadDailyLimiter: httpx.NewRateLimiterWindow(100, 24*time.Hour, 100),
reactionLimiter: httpx.NewRateLimiter(20, 20),
passkeyLimiter: httpx.NewRateLimiterWindow(10, time.Minute, 10),
slowmode: map[string]time.Time{},
presence: map[uint64]time.Time{},
webhookSeen: map[string]time.Time{},
@@ -153,6 +157,9 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
s.api = s.registerAPI(apiRouter)
s.registerMetaRoutes(s.api)
s.registerAuthRoutes(apiRouter)
if deps.Auth != nil {
s.registerPasskeyRoutes(apiRouter)
}
if deps.Store != nil {
s.registerUserRoutes(s.api)
s.registerGuildRoutes(s.api)