diff --git a/NOTICE b/NOTICE index a28bc55..691fc8b 100644 --- a/NOTICE +++ b/NOTICE @@ -36,6 +36,8 @@ Copyright (C) 2026 glchat contributors. | [TanStack Query](https://tanstack.com/query) | MIT | Серверное состояние клиента | | [i18next](https://www.i18next.com) | MIT | Локализация ru/en | | [age](https://age-encryption.org) (filippo.io/age) | BSD-3-Clause | Шифрование бэкапов | +| [go-webauthn/webauthn](https://github.com/go-webauthn/webauthn) | BSD-3-Clause | Passkeys (WebAuthn): регистрация и вход по ключу (Фаза 7) | +| [fxamacker/cbor](https://github.com/fxamacker/cbor) | MIT | CBOR для WebAuthn (транзитивная зависимость go-webauthn) | Полный список Go-зависимостей с версиями — `go.mod`/`go.sum`; JS-зависимостей — `web/package.json`/`web/package-lock.json`. Совместимость лицензий с AGPL-3.0 diff --git a/go.mod b/go.mod index 3ba2077..0fe1109 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,9 @@ go 1.26.0 require ( github.com/coder/websocket v1.8.15 github.com/danielgtaylor/huma/v2 v2.39.1 + github.com/fxamacker/cbor/v2 v2.9.4 github.com/go-chi/chi/v5 v5.3.2 + github.com/go-webauthn/webauthn v0.18.2 github.com/mattn/go-sqlite3 v1.14.52 github.com/pquerna/otp v1.5.0 github.com/pressly/goose/v3 v3.28.0 @@ -14,8 +16,16 @@ require ( require ( github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/x v0.3.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/mfridman/interpolate v0.0.2 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/sethvargo/go-retry v0.4.0 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/x448/float16 v0.8.4 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.48.0 // indirect diff --git a/go.sum b/go.sum index c90dd45..2e8c83f 100644 --- a/go.sum +++ b/go.sum @@ -7,10 +7,22 @@ github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/m github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= -github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo= +github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.18.2 h1:0BeftmEHU7i3Dv0VFwBtidy/ba37Vcdjvqst9EYu8Sk= +github.com/go-webauthn/webauthn v0.18.2/go.mod h1:hEXaOuLxvZ3zG9miZe3ehlyeVso9AtklXG+kTn36k+A= +github.com/go-webauthn/x v0.3.1 h1:1ff37z3XfmTTomkhlURgGizLIDyOvPgTt2t9nlzKLRo= +github.com/go-webauthn/x v0.3.1/go.mod h1:ZInxAynYXfBPvvm5gzKZ7geBlL23K71xASMgohHl/Rg= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= @@ -21,6 +33,8 @@ github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6B github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= @@ -34,8 +48,12 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= diff --git a/internal/auth/passkeys.go b/internal/auth/passkeys.go new file mode 100644 index 0000000..db73ff2 --- /dev/null +++ b/internal/auth/passkeys.go @@ -0,0 +1,478 @@ +package auth + +import ( + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "strings" + "sync" + "time" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + + "glchat/internal/config" + "glchat/internal/crypto" + "glchat/internal/store" +) + +// Параметры passkeys (AGENT.md 7.1): несколько ключей на аккаунт, короткое +// окно церемонии, ограничение на число ключей. +const ( + // passkeyCeremonyTTL — сколько живёт начатая церемония регистрации/входа. + passkeyCeremonyTTL = 5 * time.Minute + // maxPasskeysPerUser — предел числа ключей на аккаунт. + maxPasskeysPerUser = 10 + // maxPasskeyNameLength — длина отображаемого имени ключа. + maxPasskeyNameLength = 64 +) + +var ( + // ErrPasskeysDisabled — passkeys недоступны (домен инстанса не годится + // для RP ID): ручки отвечают понятной ошибкой, а не 500. + ErrPasskeysDisabled = errors.New("auth.passkeys_disabled") + // ErrPasskeyCeremony — церемония не найдена, истекла или уже использована. + ErrPasskeyCeremony = errors.New("auth.passkey_challenge_invalid") + // ErrPasskeyVerification — подпись или данные аутентификатора не прошли проверку. + ErrPasskeyVerification = errors.New("auth.passkey_verification_failed") + // ErrPasskeyUnknown — ключ не найден у этого аккаунта. + ErrPasskeyUnknown = errors.New("auth.passkey_unknown") + // ErrPasskeyNameInvalid — пустое или слишком длинное имя ключа. + ErrPasskeyNameInvalid = errors.New("auth.passkey_name_invalid") + // ErrPasskeyLimit — достигнут предел числа ключей на аккаунт. + ErrPasskeyLimit = errors.New("auth.passkey_limit") +) + +// webAuthnUser — реализация webauthn.User поверх аккаунта glchat. +type webAuthnUser struct { + handle []byte + name string + displayName string + credentials []webauthn.Credential +} + +func (u *webAuthnUser) WebAuthnID() []byte { return u.handle } +func (u *webAuthnUser) WebAuthnName() string { return u.name } +func (u *webAuthnUser) WebAuthnDisplayName() string { return u.displayName } + +func (u *webAuthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials } + +// ceremony — начатая церемония WebAuthn: данные сессии библиотеки плюс +// привязка к аккаунту. Хранится в памяти процесса, живёт минуты и +// используется один раз (AGENT.md 9.1: инстанс — один процесс). +type ceremony struct { + session webauthn.SessionData + userID uint64 + expiresAt time.Time +} + +// ceremonyStore — церемонии WebAuthn с одноразовым использованием и TTL. +type ceremonyStore struct { + mu sync.Mutex + entries map[string]ceremony + now func() time.Time +} + +func newCeremonyStore() *ceremonyStore { + return &ceremonyStore{entries: map[string]ceremony{}, now: func() time.Time { return time.Now().UTC() }} +} + +func (c *ceremonyStore) put(id string, entry ceremony) { + c.mu.Lock() + defer c.mu.Unlock() + c.gcLocked() + c.entries[id] = entry +} + +// take выдаёт церемонию и сразу удаляет её: повторная отправка того же +// challenge отклоняется (защита от replay). +func (c *ceremonyStore) take(id string) (ceremony, bool) { + c.mu.Lock() + defer c.mu.Unlock() + entry, ok := c.entries[id] + if !ok { + return ceremony{}, false + } + delete(c.entries, id) + if !entry.expiresAt.After(c.now()) { + return ceremony{}, false + } + return entry, true +} + +func (c *ceremonyStore) gcLocked() { + if len(c.entries) < 64 { + return + } + now := c.now() + for id, entry := range c.entries { + if !entry.expiresAt.After(now) { + delete(c.entries, id) + } + } +} + +// newWebAuthn собирает RP-конфигурацию из домена инстанса (AGENT.md 7.1: +// RP ID и Origin берутся из конфига домена). Непригодный домен (например, +// IP-адрес стенда) не роняет инстанс: passkeys просто выключены. +func newWebAuthn(cfg config.Config, logger loggerLike) *webauthn.WebAuthn { + rpID := strings.TrimSpace(cfg.Domain) + if rpID == "" { + return nil + } + instance, err := webauthn.New(&webauthn.Config{ + RPID: rpID, + RPDisplayName: cfg.InstanceName, + RPOrigins: cfg.AllowedOrigins(), + }) + if err != nil { + logger.Warn("passkeys are disabled: relying party configuration is invalid", + "domain", rpID, "error", err) + return nil + } + return instance +} + +// loggerLike — минимум от *slog.Logger, нужный сборке RP-конфигурации. +type loggerLike interface { + Warn(msg string, args ...any) +} + +// PasskeysEnabled сообщает, готовы ли passkeys на этом инстансе. +func (s *Service) PasskeysEnabled() bool { return s.webauthn != nil } + +// PasskeyCeremonyTTL возвращает окно церемонии (для ответа клиенту). +func (s *Service) PasskeyCeremonyTTL() time.Duration { return passkeyCeremonyTTL } + +// PasskeyRegistrationStart — результат начала регистрации ключа. +type PasskeyRegistrationStart struct { + Options *protocol.CredentialCreation + Ceremony string + ExpiresIn time.Duration +} + +// PasskeyLoginStart — результат начала входа по ключу. +type PasskeyLoginStart struct { + Options *protocol.CredentialAssertion + Ceremony string + ExpiresIn time.Duration +} + +// BeginPasskeyRegistration начинает регистрацию нового ключа для вошедшего +// пользователя: клиент получает options для navigator.credentials.create. +func (s *Service) BeginPasskeyRegistration(ctx context.Context, user *store.User) (*PasskeyRegistrationStart, error) { + if s.webauthn == nil { + return nil, ErrPasskeysDisabled + } + count, err := s.store.CountPasskeys(ctx, user.ID) + if err != nil { + return nil, fmt.Errorf("count passkeys: %w", err) + } + if count >= maxPasskeysPerUser { + return nil, ErrPasskeyLimit + } + waUser, err := s.webAuthnUser(ctx, user) + if err != nil { + return nil, err + } + // Регистрация только с «обнаруживаемым» ключом и обязательной проверкой + // пользователя: такие ключи годятся и для входа без ввода email. + options, session, err := s.webauthn.BeginRegistration(waUser, + webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred), + webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{ + ResidentKey: protocol.ResidentKeyRequirementPreferred, + UserVerification: protocol.VerificationPreferred, + }), + webauthn.WithExclusions(webauthn.Credentials(waUser.credentials).CredentialDescriptors()), + ) + if err != nil { + return nil, fmt.Errorf("begin passkey registration: %w", err) + } + ceremonyID, err := s.newCeremony(user.ID, *session) + if err != nil { + return nil, err + } + return &PasskeyRegistrationStart{Options: options, Ceremony: ceremonyID, ExpiresIn: passkeyCeremonyTTL}, nil +} + +// FinishPasskeyRegistration проверяет ответ аутентификатора и сохраняет ключ. +func (s *Service) FinishPasskeyRegistration(ctx context.Context, user *store.User, ceremonyID, name string, body io.Reader) (*store.Passkey, error) { + if s.webauthn == nil { + return nil, ErrPasskeysDisabled + } + name = strings.TrimSpace(name) + if name == "" || len([]rune(name)) > maxPasskeyNameLength { + return nil, ErrPasskeyNameInvalid + } + parsed, err := protocol.ParseCredentialCreationResponseBody(body) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrPasskeyVerification, err) + } + entry, ok := s.ceremonies.take(ceremonyID) + if !ok || entry.userID != user.ID { + return nil, ErrPasskeyCeremony + } + waUser, err := s.webAuthnUser(ctx, user) + if err != nil { + return nil, err + } + credential, err := s.webauthn.CreateCredential(waUser, entry.session, parsed) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrPasskeyVerification, err) + } + if _, err := s.store.GetPasskeyByCredentialID(ctx, store.EncodeCredentialID(credential.ID)); err == nil { + // Тот же ключ уже зарегистрирован: не создаём дубль. + return nil, store.ErrConflict + } else if !errors.Is(err, store.ErrNotFound) { + return nil, err + } + encoded, err := json.Marshal(credential) + if err != nil { + return nil, fmt.Errorf("encode credential: %w", err) + } + transports := make([]string, 0, len(credential.Transport)) + for _, transport := range credential.Transport { + transports = append(transports, string(transport)) + } + passkey, err := s.store.CreatePasskey(ctx, store.CreatePasskeyParams{ + UserID: user.ID, + CredentialID: store.EncodeCredentialID(credential.ID), + Name: name, + CredentialJSON: encoded, + SignCount: credential.Authenticator.SignCount, + Transports: strings.Join(transports, ","), + }) + if err != nil { + return nil, err + } + _ = s.store.RecordSecurityEvent(ctx, &user.ID, "passkey_added", "", "", `{"name":`+quoteJSON(name)+`}`) + return passkey, nil +} + +// RenamePasskey меняет имя ключа: чужой ключ недоступен (AGENT.md 7.1). +func (s *Service) RenamePasskey(ctx context.Context, userID, passkeyID uint64, name string) error { + name = strings.TrimSpace(name) + if name == "" || len([]rune(name)) > maxPasskeyNameLength { + return ErrPasskeyNameInvalid + } + return s.store.RenamePasskey(ctx, passkeyID, userID, name) +} + +// DeletePasskey отзывает ключ доступа. +func (s *Service) DeletePasskey(ctx context.Context, userID, passkeyID uint64) error { + if err := s.store.DeletePasskey(ctx, passkeyID, userID); err != nil { + if errors.Is(err, store.ErrNotFound) { + return ErrPasskeyUnknown + } + return err + } + _ = s.store.RecordSecurityEvent(ctx, &userID, "passkey_removed", "", "", `{"passkey_id":"`+fmt.Sprint(passkeyID)+`"}`) + return nil +} + +// ListPasskeys возвращает ключи пользователя. +func (s *Service) ListPasskeys(ctx context.Context, userID uint64) ([]store.Passkey, error) { + return s.store.ListPasskeys(ctx, userID) +} + +// BeginPasskeyLogin начинает вход по ключу. Email необязателен: без него +// предлагаем выбрать любой «обнаруживаемый» ключ (passkey-сценарий), с ним — +// только ключи конкретного аккаунта. +func (s *Service) BeginPasskeyLogin(ctx context.Context, email string) (*PasskeyLoginStart, error) { + if s.webauthn == nil { + return nil, ErrPasskeysDisabled + } + var ( + options *protocol.CredentialAssertion + session *webauthn.SessionData + userID uint64 + err error + ) + if strings.TrimSpace(email) == "" { + options, session, err = s.webauthn.BeginDiscoverableLogin( + webauthn.WithUserVerification(protocol.VerificationPreferred), + ) + } else { + user, lookupErr := s.userByEmail(ctx, email) + if lookupErr != nil { + // Не раскрываем, существует ли аккаунт: церемония без ключей + // выглядит для клиента как обычный вход по ключу. + options, session, err = s.webauthn.BeginDiscoverableLogin( + webauthn.WithUserVerification(protocol.VerificationPreferred), + ) + } else { + userID = user.ID + waUser, buildErr := s.webAuthnUser(ctx, user) + if buildErr != nil { + return nil, buildErr + } + options, session, err = s.webauthn.BeginLogin(waUser, + webauthn.WithUserVerification(protocol.VerificationPreferred), + ) + } + } + if err != nil { + return nil, fmt.Errorf("begin passkey login: %w", err) + } + ceremonyID, err := s.newCeremony(userID, *session) + if err != nil { + return nil, err + } + return &PasskeyLoginStart{Options: options, Ceremony: ceremonyID, ExpiresIn: passkeyCeremonyTTL}, nil +} + +// FinishPasskeyLogin проверяет подпись аутентификатора и выдаёт сессию. +func (s *Service) FinishPasskeyLogin(ctx context.Context, ceremonyID string, body io.Reader, ip, userAgent string) (*store.User, string, *store.Session, error) { + if s.webauthn == nil { + return nil, "", nil, ErrPasskeysDisabled + } + parsed, err := protocol.ParseCredentialRequestResponseBody(body) + if err != nil { + return nil, "", nil, fmt.Errorf("%w: %w", ErrPasskeyVerification, err) + } + entry, ok := s.ceremonies.take(ceremonyID) + if !ok { + return nil, "", nil, ErrPasskeyCeremony + } + // Ключ ищем по credential id: обработчик отдаёт пользователя, которому + // принадлежит ключ, — по нему библиотека и проверяет подпись. + passkey, err := s.store.GetPasskeyByCredentialID(ctx, store.EncodeCredentialID(parsed.RawID)) + if err != nil { + _ = s.store.RecordSecurityEvent(ctx, nil, "passkey_login_failed", ip, userAgent, `{"reason":"unknown_credential"}`) + return nil, "", nil, ErrPasskeyUnknown + } + if entry.userID != 0 && entry.userID != passkey.UserID { + // Церемония начиналась для другого аккаунта. + _ = s.store.RecordSecurityEvent(ctx, &passkey.UserID, "passkey_login_failed", ip, userAgent, `{"reason":"ceremony_mismatch"}`) + return nil, "", nil, ErrPasskeyCeremony + } + user, err := s.store.GetUser(ctx, passkey.UserID) + if err != nil { + return nil, "", nil, err + } + if banned, err := s.store.IsInstanceBanned(ctx, user.ID); err != nil { + return nil, "", nil, err + } else if banned { + _ = s.store.RecordSecurityEvent(ctx, &user.ID, "login_banned", ip, userAgent, `{"method":"passkey"}`) + return nil, "", nil, ErrUserBanned + } + if err := s.checkLockout(ctx, user.ID); err != nil { + return nil, "", nil, err + } + + waUser, err := s.webAuthnUser(ctx, user) + if err != nil { + return nil, "", nil, err + } + // Библиотека различает два сценария: церемония без user id — вход + // «обнаруживаемым» ключом (клиент выбирает аккаунт сам), с user id — + // вход по ключам конкретного аккаунта. + var ( + validatedUser webauthn.User + credential *webauthn.Credential + ) + if len(entry.session.UserID) > 0 { + credential, err = s.webauthn.ValidateLogin(waUser, entry.session, parsed) + validatedUser = waUser + } else { + validatedUser, credential, err = s.webauthn.ValidatePasskeyLogin( + func(rawID, _ []byte) (webauthn.User, error) { + if store.EncodeCredentialID(rawID) != passkey.CredentialID { + return nil, ErrPasskeyUnknown + } + return waUser, nil + }, entry.session, parsed) + } + if err != nil { + _ = s.store.RecordSecurityEvent(ctx, &user.ID, "passkey_login_failed", ip, userAgent, `{"reason":"verification"}`) + return nil, "", nil, fmt.Errorf("%w: %w", ErrPasskeyVerification, err) + } + if validatedUser == nil { + return nil, "", nil, ErrPasskeyVerification + } + + // Обновляем запись credential: счётчик подписей и флаги бэкапа (клоны). + encoded, err := json.Marshal(credential) + if err != nil { + return nil, "", nil, fmt.Errorf("encode credential: %w", err) + } + if err := s.store.UpdatePasskeyUsage(ctx, passkey.ID, encoded, credential.Authenticator.SignCount); err != nil { + return nil, "", nil, err + } + + token, session, err := s.createSession(ctx, user.ID, userAgent, ip) + if err != nil { + return nil, "", nil, err + } + _ = s.store.RecordSecurityEvent(ctx, &user.ID, "passkey_login", ip, userAgent, + `{"passkey_id":"`+fmt.Sprint(passkey.ID)+`"}`) + return user, token, session, nil +} + +// webAuthnUser собирает webauthn.User: handle выводится из секрета инстанса, +// чтобы идентификатор аккаунта не утекал в аутентификатор открытым текстом. +func (s *Service) webAuthnUser(ctx context.Context, user *store.User) (*webAuthnUser, error) { + passkeys, err := s.store.ListPasskeys(ctx, user.ID) + if err != nil { + return nil, fmt.Errorf("list passkeys: %w", err) + } + credentials := make([]webauthn.Credential, 0, len(passkeys)) + for _, passkey := range passkeys { + var credential webauthn.Credential + if err := json.Unmarshal(passkey.CredentialJSON, &credential); err != nil { + // Повреждённая запись не должна ломать вход остальными ключами. + s.logger.WarnContext(ctx, "пропускаем повреждённую запись passkey", + slog.Uint64("passkey_id", passkey.ID), slog.Any("error", err)) + continue + } + credentials = append(credentials, credential) + } + name := user.Username + if user.DisplayName != "" { + name = user.DisplayName + } + return &webAuthnUser{ + handle: s.passkeyHandle(user.ID), + name: user.Username, + displayName: name, + credentials: credentials, + }, nil +} + +// passkeyHandle — стабильный непрозрачный user handle (16 байт HMAC). +func (s *Service) passkeyHandle(userID uint64) []byte { + index := s.masterKey.BlindIndex("webauthn:user:" + fmt.Sprint(userID)) + raw, err := hex.DecodeString(index) + if err != nil || len(raw) < 16 { + return []byte(fmt.Sprint(userID)) + } + return raw[:16] +} + +// newCeremony регистрирует церемонию и возвращает её идентификатор. +func (s *Service) newCeremony(userID uint64, session webauthn.SessionData) (string, error) { + ceremonyID, _, err := crypto.NewSessionToken() + if err != nil { + return "", err + } + session.Expires = time.Now().UTC().Add(passkeyCeremonyTTL) + s.ceremonies.put(ceremonyID, ceremony{ + session: session, + userID: userID, + expiresAt: session.Expires, + }) + return ceremonyID, nil +} + +// quoteJSON кодирует строку для подстановки в metadata_json. +func quoteJSON(value string) string { + encoded, err := json.Marshal(value) + if err != nil { + return `""` + } + return string(encoded) +} diff --git a/internal/auth/service.go b/internal/auth/service.go index a25cd33..ceeb824 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -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. diff --git a/internal/config/config.go b/internal/config/config.go index 76aca3a..92483ff 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "net" "os" "strconv" "strings" @@ -61,6 +62,17 @@ type Config struct { RetainAuditDays int RetainOrphanHours int RetainInterval time.Duration + // OAuth-провайдеры (AGENT.md 7.1, Фаза 7): включаются парами + // client id/secret, пустые значения означают «провайдер выключен». + OAuthGitHubClientID string + OAuthGitHubClientSecret string + OAuthGoogleClientID string + OAuthGoogleClientSecret string + OAuthDiscordClientID string + OAuthDiscordClientSecret string + // OAuthRedirectBase — внешний адрес инстанса для redirect_uri (пусто — + // берётся BaseURL из домена). Нужен, если клиент ходит по другому адресу. + OAuthRedirectBase string } func Load() (Config, error) { @@ -99,6 +111,7 @@ func Load() (Config, error) { RetainAuditDays: envInt("RETENTION_AUDIT_DAYS", 180), RetainOrphanHours: envInt("RETENTION_ORPHAN_HOURS", 24), RetainInterval: time.Duration(envInt("RETENTION_INTERVAL_SECONDS", 3600)) * time.Second, + } if cfg.FilesDomain == "" { cfg.FilesDomain = "files." + cfg.Domain @@ -145,6 +158,42 @@ func (c Config) VoiceEnabled() bool { return c.LiveKitAPIKey != "" && c.LiveKitAPISecret != "" && c.LiveKitURL != "" } +// WebAuthnEnabled сообщает, годится ли домен инстанса как RP ID для passkeys. +// WebAuthn не работает на IP-адресах (исключение — localhost), поэтому стенд +// без домена честно отдаёт `passkeys_enabled: false`, а не ломает вход. +func (c Config) WebAuthnEnabled() bool { + host := strings.TrimSpace(c.Domain) + if host == "" { + return false + } + if parsed, _, err := net.SplitHostPort(host); err == nil { + host = parsed + } + if strings.EqualFold(host, "localhost") { + return true + } + return net.ParseIP(host) == nil +} + +// OAuthProviders перечисляет включённые провайдеры в стабильном порядке. +func (c Config) OAuthProviders() []string { + providers := make([]string, 0, 3) + for _, candidate := range []struct { + id string + client string + secret string + }{ + {"github", c.OAuthGitHubClientID, c.OAuthGitHubClientSecret}, + {"google", c.OAuthGoogleClientID, c.OAuthGoogleClientSecret}, + {"discord", c.OAuthDiscordClientID, c.OAuthDiscordClientSecret}, + } { + if candidate.client != "" && candidate.secret != "" { + providers = append(providers, candidate.id) + } + } + return providers +} + func (c Config) BaseURL() string { return c.Scheme() + "://" + c.Domain } func (c Config) FilesURL() string { return c.Scheme() + "://" + c.FilesDomain } diff --git a/internal/database/migrations/00018_phase7_passkeys.sql b/internal/database/migrations/00018_phase7_passkeys.sql new file mode 100644 index 0000000..3bdf7bd --- /dev/null +++ b/internal/database/migrations/00018_phase7_passkeys.sql @@ -0,0 +1,36 @@ +-- +goose Up +-- Фаза 7: passkeys (WebAuthn), AGENT.md 7.1. +-- +-- Таблица webauthn_credentials создана в 00002 «на вырост» и до Фазы 7 не +-- использовалась ни одной строкой кода, поэтому пересобираем её в том виде, +-- который нужен рабочей реализации: полная запись credential хранится в JSON +-- (credential_json) — так структура не разъезжается с версиями библиотеки +-- go-webauthn, а отдельные колонки нужны только для списка ключей. +-- PII здесь нет: имя ключа задаёт сам пользователь. + +DROP TABLE webauthn_credentials; +CREATE TABLE webauthn_credentials ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + credential_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL DEFAULT '', + credential_json TEXT NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + transports TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + last_used_at TEXT +); +CREATE INDEX webauthn_credentials_user_idx ON webauthn_credentials (user_id); + +-- +goose Down +DROP TABLE webauthn_credentials; +CREATE TABLE webauthn_credentials ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + credential_id TEXT NOT NULL UNIQUE, + public_key BLOB NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + transports TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); diff --git a/internal/meta/meta.go b/internal/meta/meta.go index 2f0188b..b167eea 100644 --- a/internal/meta/meta.go +++ b/internal/meta/meta.go @@ -48,7 +48,7 @@ func New(cfg config.Config) Response { VoiceURL: cfg.LiveKitURL, WebPushEnabled: false, OAuthEnabled: false, - PasskeysEnabled: false, + PasskeysEnabled: cfg.WebAuthnEnabled(), }, } } diff --git a/internal/server/errors.go b/internal/server/errors.go index 08226cb..43f73d0 100644 --- a/internal/server/errors.go +++ b/internal/server/errors.go @@ -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): diff --git a/internal/server/passkey_emulator_test.go b/internal/server/passkey_emulator_test.go new file mode 100644 index 0000000..865e8fd --- /dev/null +++ b/internal/server/passkey_emulator_test.go @@ -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) +} diff --git a/internal/server/passkeys.go b/internal/server/passkeys.go new file mode 100644 index 0000000..fc6b72e --- /dev/null +++ b/internal/server/passkeys.go @@ -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)) +} diff --git a/internal/server/passkeys_test.go b/internal/server/passkeys_test.go new file mode 100644 index 0000000..34cab0b --- /dev/null +++ b/internal/server/passkeys_test.go @@ -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()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index dde0dde..e1373a3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/store/passkeys.go b/internal/store/passkeys.go new file mode 100644 index 0000000..34d6332 --- /dev/null +++ b/internal/store/passkeys.go @@ -0,0 +1,164 @@ +package store + +import ( + "context" + "database/sql" + "encoding/base64" + "time" +) + +// Passkey — ключ доступа (WebAuthn) пользователя. Полная запись credential +// лежит в CredentialJSON: библиотека go-webauthn обновляет в ней счётчик +// подписей и флаги, поэтому структуру не разбираем на колонки. +type Passkey struct { + ID uint64 + UserID uint64 + CredentialID string + Name string + CredentialJSON []byte + SignCount uint32 + Transports string + CreatedAt time.Time + LastUsedAt *time.Time +} + +// CreatePasskeyParams — параметры нового ключа. +type CreatePasskeyParams struct { + UserID uint64 + CredentialID string + Name string + CredentialJSON []byte + SignCount uint32 + Transports string +} + +const passkeyColumns = `id, user_id, credential_id, name, credential_json, sign_count, transports, created_at, last_used_at` + +func scanPasskey(scanner interface{ Scan(...any) error }) (*Passkey, error) { + var ( + passkey Passkey + credential string + lastUsed sql.NullString + createdAt string + ) + if err := scanner.Scan(&passkey.ID, &passkey.UserID, &passkey.CredentialID, &passkey.Name, + &credential, &passkey.SignCount, &passkey.Transports, &createdAt, &lastUsed); err != nil { + return nil, mapError(err) + } + passkey.CredentialJSON = []byte(credential) + passkey.CreatedAt = parseTimestamp(createdAt) + if lastUsed.Valid { + value := parseTimestamp(lastUsed.String) + passkey.LastUsedAt = &value + } + return &passkey, nil +} + +// CreatePasskey сохраняет новый ключ доступа. +func (s *Store) CreatePasskey(ctx context.Context, params CreatePasskeyParams) (*Passkey, error) { + id := s.NextID() + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO webauthn_credentials + (id, user_id, credential_id, name, credential_json, sign_count, transports, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + int64(id), int64(params.UserID), params.CredentialID, params.Name, + string(params.CredentialJSON), int64(params.SignCount), params.Transports, s.Now(), s.Now()) + if err != nil { + return nil, mapError(err) + } + return s.GetPasskey(ctx, id) +} + +// GetPasskey возвращает ключ по идентификатору. +func (s *Store) GetPasskey(ctx context.Context, id uint64) (*Passkey, error) { + row := s.reader.QueryRowContext(ctx, `SELECT `+passkeyColumns+` FROM webauthn_credentials WHERE id = ?`, int64(id)) + return scanPasskey(row) +} + +// GetPasskeyByCredentialID ищет ключ по идентификатору credential: он приходит +// от аутентификатора при входе, поэтому поиск идёт по уникальному индексу. +func (s *Store) GetPasskeyByCredentialID(ctx context.Context, credentialID string) (*Passkey, error) { + row := s.reader.QueryRowContext(ctx, + `SELECT `+passkeyColumns+` FROM webauthn_credentials WHERE credential_id = ?`, credentialID) + return scanPasskey(row) +} + +// ListPasskeys возвращает ключи пользователя: свежие сверху. +func (s *Store) ListPasskeys(ctx context.Context, userID uint64) ([]Passkey, error) { + rows, err := s.reader.QueryContext(ctx, + `SELECT `+passkeyColumns+` FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC, id DESC`, + int64(userID)) + if err != nil { + return nil, err + } + defer rows.Close() + + passkeys := make([]Passkey, 0, 4) + for rows.Next() { + passkey, err := scanPasskey(rows) + if err != nil { + return nil, err + } + passkeys = append(passkeys, *passkey) + } + return passkeys, rows.Err() +} + +// CountPasskeys считает ключи пользователя: ограничиваем их число, чтобы +// список не разрастался (AGENT.md 7.1 — несколько ключей на аккаунт). +func (s *Store) CountPasskeys(ctx context.Context, userID uint64) (int, error) { + var count int + err := s.reader.QueryRowContext(ctx, + `SELECT COUNT(*) FROM webauthn_credentials WHERE user_id = ?`, int64(userID)).Scan(&count) + return count, err +} + +// RenamePasskey меняет отображаемое имя ключа. +func (s *Store) RenamePasskey(ctx context.Context, id, userID uint64, name string) error { + result, err := s.writer.ExecContext(ctx, + `UPDATE webauthn_credentials SET name = ?, updated_at = ? WHERE id = ? AND user_id = ?`, + name, s.Now(), int64(id), int64(userID)) + if err != nil { + return mapError(err) + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + return nil +} + +// DeletePasskey удаляет ключ пользователя: чужой ключ удалить нельзя. +func (s *Store) DeletePasskey(ctx context.Context, id, userID uint64) error { + result, err := s.writer.ExecContext(ctx, + `DELETE FROM webauthn_credentials WHERE id = ? AND user_id = ?`, int64(id), int64(userID)) + if err != nil { + return mapError(err) + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + return nil +} + +// DeletePasskeysForUser удаляет все ключи пользователя (сброс администратором). +func (s *Store) DeletePasskeysForUser(ctx context.Context, userID uint64) error { + _, err := s.writer.ExecContext(ctx, `DELETE FROM webauthn_credentials WHERE user_id = ?`, int64(userID)) + return err +} + +// UpdatePasskeyUsage сохраняет обновлённую запись credential после успешного +// входа: библиотека увеличивает счётчик подписей и обновляет флаги бэкапа. +func (s *Store) UpdatePasskeyUsage(ctx context.Context, id uint64, credentialJSON []byte, signCount uint32) error { + _, err := s.writer.ExecContext(ctx, ` + UPDATE webauthn_credentials + SET credential_json = ?, sign_count = ?, last_used_at = ?, updated_at = ? + WHERE id = ?`, + string(credentialJSON), int64(signCount), s.Now(), s.Now(), int64(id)) + return err +} + +// EncodeCredentialID кодирует raw credential id в base64url без паддинга — +// в таком виде его отдаёт браузер (PublicKeyCredential.id). +func EncodeCredentialID(raw []byte) string { + return base64.RawURLEncoding.EncodeToString(raw) +} diff --git a/web/e2e/passkey.spec.ts b/web/e2e/passkey.spec.ts new file mode 100644 index 0000000..5f5d84e --- /dev/null +++ b/web/e2e/passkey.spec.ts @@ -0,0 +1,133 @@ +import { expect, test, type Page } from '@playwright/test'; + +import { + apiAs, + ensureProbe, + freshState, + repeat429, + type Requester, + type StorageState, +} from './support'; + +/** Префикс API инстанса: те же адреса, что у клиента. */ +const API = '/api/v1'; + +/** + * passkey.spec.ts — живая проверка passkeys (WebAuthn) на стенде + * (AGENT.md 7.1, Фаза 7): регистрация ключа в настройках безопасности, + * вход по нему без пароля и отзыв ключа. + * + * Вместо настоящего Touch ID используем виртуальный аутентификатор Chromium + * (CDP `WebAuthn.enable` + `addVirtualAuthenticator`): подпись считает сам + * браузер, поэтому проверяется весь путь — challenge, RP ID, origin, проверка + * подписи на сервере и выдача сессии. + */ + +const PASSWORD = 'correct-horse-battery-1'; +const USERNAME = 'e2e_passkey'; + +/** addVirtualAuthenticator включает виртуальный ключ в контексте страницы. */ +async function addVirtualAuthenticator(page: Page): Promise { + const session = await page.context().newCDPSession(page); + await session.send('WebAuthn.enable'); + await session.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); +} + +test.describe('passkeys (WebAuthn)', () => { + test('регистрация ключа, вход по ключу без пароля и отзыв', async ({ browser, playwright }) => { + const api = await playwright.request.newContext({ baseURL: process.env.GLCHAT_URL }); + await ensureProbe(api, USERNAME, PASSWORD); + + // Инстанс обязан объявлять поддержку passkeys: домен стенда — не IP. + const meta = await repeat429(() => api.get(`${API}/meta`)); + expect(meta.ok(), `GET /meta: ${await meta.text()}`).toBeTruthy(); + const metaPayload = (await meta.json()) as { features: { passkeys_enabled: boolean } }; + expect(metaPayload.features.passkeys_enabled).toBe(true); + + const state = await freshState(api, { username: USERNAME, password: PASSWORD }); + const context = await browser.newContext({ storageState: state }); + const page = await context.newPage(); + await addVirtualAuthenticator(page); + const keyName = `E2E ключ ${Date.now()}`; + + try { + // 1. Регистрация ключа в настройках безопасности (со step-up паролем). + await page.goto('/settings/account/security'); + await expect(page.getByTestId('settings-content')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('heading', { name: 'Ключи доступа (passkeys)' })).toBeVisible(); + + const section = page.getByTestId('settings-content'); + await section.getByLabel('Название ключа').fill(keyName); + await section.getByLabel('Ваш пароль').fill(PASSWORD); + await section.getByRole('button', { name: 'Создать ключ' }).click(); + await expect(page.getByText(`Ключ «${keyName}» добавлен.`)).toBeVisible({ + timeout: 30_000, + }); + await expect(section.getByTestId('passkey-name').filter({ hasText: keyName })).toBeVisible(); + + // 2. Вход по ключу из того же браузера, но без сессии: пароль не нужен. + await context.clearCookies(); + await page.goto('/login'); + const passkeyButton = page.getByTestId('passkey-login'); + await expect(passkeyButton).toBeVisible({ timeout: 20_000 }); + await passkeyButton.click(); + await expect(page).toHaveURL(/\/app/u, { timeout: 30_000 }); + + // Сессия рабочая: профиль отдаётся сервером. + const me = await page.request.get(`${API}/users/@me`); + expect(me.ok(), `GET /users/@me после входа по ключу: ${await me.text()}`).toBeTruthy(); + + // Вход записан в события безопасности (аудит passkeys). + const events = await page.request.get(`${API}/users/@me/security-events`); + expect(events.ok(), `GET /users/@me/security-events: ${await events.text()}`).toBeTruthy(); + expect(await events.text()).toContain('passkey_login'); + + // 3. Отзыв ключа: список очищается, вход этим ключом больше не проходит. + await page.goto('/settings/account/security'); + const again = page.getByTestId('settings-content'); + await expect(again.getByText(keyName)).toBeVisible({ timeout: 30_000 }); + // Отзыв требует свежего step-up: пароль берётся из формы раздела. + await again.getByLabel('Ваш пароль').fill(PASSWORD); + await again.getByRole('button', { name: 'Отозвать', exact: true }).first().click(); + await again.getByRole('button', { name: 'Отозвать ключ' }).click(); + await expect(page.getByText('Ключ отозван.')).toBeVisible({ timeout: 20_000 }); + + await context.clearCookies(); + await page.goto('/login'); + await page.getByTestId('passkey-login').click(); + // Ключа больше нет: сервер отвечает понятной ошибкой, в приложение не пускает. + await expect(page.getByTestId('passkey-error')).toBeVisible({ timeout: 20_000 }); + await expect(page).toHaveURL(/\/login/u); + } finally { + await context.close(); + await dropPasskeys(playwright, state); + await api.dispose(); + } + }); +}); + +/** dropPasskeys убирает ключи тестового аккаунта, чтобы стенд не копил данные. */ +async function dropPasskeys(playwright: Requester, state: StorageState): Promise { + const api = await apiAs(playwright, state); + try { + const response = await api.get(`${API}/auth/passkeys`); + if (!response.ok()) { + return; + } + const { passkeys } = (await response.json()) as { passkeys: { id: string }[] }; + for (const passkey of passkeys) { + await api.delete(`${API}/auth/passkeys/${passkey.id}`); + } + } finally { + await api.dispose(); + } +} diff --git a/web/src/api/passkeys.ts b/web/src/api/passkeys.ts new file mode 100644 index 0000000..7acb294 --- /dev/null +++ b/web/src/api/passkeys.ts @@ -0,0 +1,89 @@ +import { request } from './client'; +import { + createPasskey, + getPasskeyAssertion, + type AssertionCredentialJSON, + type PublicKeyCredentialCreationOptionsJSON, + type PublicKeyCredentialRequestOptionsJSON, + type RegistrationCredentialJSON, +} from '@/lib/webauthn'; +import type { User } from './types'; + +/** Ключ доступа в списке настроек (AGENT.md 7.1, Фаза 7). */ +export interface Passkey { + id: string; + name: string; + created_at: string; + last_used_at?: string; + sign_count: number; +} + +export const passkeysQueryKey = ['auth', 'passkeys'] as const; + +interface BeginResponse { + options: { publicKey: T }; + ceremony: string; + expires_in_sec: number; +} + +export async function fetchPasskeys(signal?: AbortSignal): Promise { + const payload = await request<{ passkeys: Passkey[] }>( + '/auth/passkeys', + signal === undefined ? {} : { signal }, + ); + return payload.passkeys; +} + +/** + * Регистрация ключа: сервер требует step-up, поэтому пароль (и код 2FA) + * передаются дважды — на begin и на finish. + */ +export async function registerPasskey( + name: string, + stepUpPassword: string, + stepUpTOTP?: string, +): Promise { + const stepUpBody: { password: string; totp_code?: string } = { password: stepUpPassword }; + if (stepUpTOTP !== undefined && stepUpTOTP !== '') { + stepUpBody.totp_code = stepUpTOTP; + } + const begin = await request>( + '/auth/passkeys/register/begin', + { method: 'POST', body: stepUpBody }, + ); + const credential: RegistrationCredentialJSON = await createPasskey(begin.options.publicKey); + const finish = await request<{ passkey: Passkey }>('/auth/passkeys/register/finish', { + method: 'POST', + body: { + ceremony: begin.ceremony, + name, + credential, + step_up_password: stepUpPassword, + step_up_totp: stepUpTOTP ?? '', + }, + }); + return finish.passkey; +} + +/** Вход по ключу: email необязателен — браузер покажет выбор аккаунта. */ +export async function loginWithPasskey(email?: string): Promise<{ user: User }> { + const body = email !== undefined && email !== '' ? { email } : {}; + const begin = await request>( + '/auth/passkeys/login/begin', + { method: 'POST', body }, + ); + const credential: AssertionCredentialJSON = await getPasskeyAssertion(begin.options.publicKey); + return request<{ user: User }>('/auth/passkeys/login/finish', { + method: 'POST', + body: { ceremony: begin.ceremony, credential }, + }); +} + +export function renamePasskey(passkeyId: string, name: string): Promise<{ ok: true }> { + return request<{ ok: true }>(`/auth/passkeys/${passkeyId}`, { method: 'PATCH', body: { name } }); +} + +/** Отзыв ключа: требует свежего step-up (клиент вызывает его заранее). */ +export function deletePasskey(passkeyId: string): Promise<{ ok: true }> { + return request<{ ok: true }>(`/auth/passkeys/${passkeyId}`, { method: 'DELETE' }); +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 04afcda..4ab8bc1 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -75,6 +75,12 @@ "auth.email_taken": "This email is already registered.", "auth.weak_password": "The password is too weak. Use at least 8 characters.", "auth.too_many_attempts": "Too many attempts. Try again later.", + "auth.passkey_unsupported": "Passkeys are unavailable: a domain and HTTPS are required.", + "auth.passkey_challenge_invalid": "The request expired. Retry passkey sign-in.", + "auth.passkey_verification_failed": "The key did not match or the signature failed. Try another key.", + "auth.passkey_unknown": "This key is not registered on the account.", + "auth.passkey_name_invalid": "Enter a key name (up to 64 characters).", + "auth.passkey_limit": "The account reached its passkey limit.", "permissions.denied": "You do not have permission for this action.", "instance.admin_required": "This action is available to the instance admin only.", "guild.not_found": "Server not found.", @@ -166,7 +172,11 @@ "forgot": "Forgot your password? Ask the instance admin.", "instanceStatus": "Instance status", "registrationDisabled": "Registration on this instance is disabled by the admin.", - "totpRequired": "Enter the 2FA or recovery code — sign-in needs it." + "totpRequired": "Enter the 2FA or recovery code — sign-in needs it.", + "passkeySubmit": "Sign in with a passkey", + "passkeyPending": "Waiting for the key…", + "passkeyCancelled": "Passkey sign-in was cancelled or timed out. Try again.", + "passkeyUnsupported": "This browser does not support passkeys. Sign in with your password." }, "register": { "title": "Create account", @@ -793,7 +803,32 @@ "twoFactorEnabledDone": "2FA is enabled. Save your recovery codes.", "recoveryTitle": "Recovery codes", "recoveryHint": "Every code works once. They are shown only once.", - "recoveryCopyAll": "Copy all codes" + "recoveryCopyAll": "Copy all codes", + "passkeysTitle": "Passkeys", + "passkeysDescription": "Passwordless sign-in with a fingerprint, Face ID or a hardware key. You can add several keys and revoke any of them.", + "passkeysEmpty": "No keys yet. Add the first one to sign in without a password.", + "passkeysAddTitle": "Add a key", + "passkeysAddHint": "Pick a name (for example “Laptop”) and confirm your identity: managing keys requires a fresh check.", + "passkeysName": "Key name", + "passkeysNamePlaceholder": "Laptop, phone, YubiKey…", + "passkeysAdd": "Create key", + "passkeysAdded": "Key “{{name}}” was added.", + "passkeysRemoved": "The key was revoked.", + "passkeysRenamed": "The key name was updated.", + "passkeysRemove": "Revoke", + "passkeysRename": "Rename", + "passkeysRenamePrompt": "New key name", + "passkeysRemoveConfirm": "Revoke the key “{{name}}”? Sign-in with it will stop working.", + "passkeysRemoveConfirmButton": "Revoke key", + "passkeysCreated": "Added {{date}}", + "passkeysNeverUsed": "never used", + "passkeysLastUsed": "last sign-in {{date}}", + "passkeysUnsupported": "This browser does not support passkeys (WebAuthn). A modern browser and a secure connection (HTTPS) are required.", + "passkeysCancelled": "Key creation was cancelled or timed out.", + "passkeysTimeout": "The authenticator did not respond in time. Try again.", + "passkeysAlreadyRegistered": "This key is already registered on the account.", + "passkeysFailed": "Could not create the key. Try another authenticator.", + "passkeysNameRequired": "Enter a key name (up to 64 characters)." }, "appearance": { "title": "Appearance", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 3a25a72..d5bda57 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -75,6 +75,12 @@ "auth.email_taken": "Такая почта уже зарегистрирована.", "auth.weak_password": "Пароль слишком простой. Используйте не менее 8 символов.", "auth.too_many_attempts": "Слишком много попыток. Попробуйте позже.", + "auth.passkey_unsupported": "Ключи доступа недоступны: нужен домен и HTTPS.", + "auth.passkey_challenge_invalid": "Запрос устарел. Повторите вход по ключу.", + "auth.passkey_verification_failed": "Ключ не подошёл или подпись не совпала. Попробуйте другой ключ.", + "auth.passkey_unknown": "Этот ключ не зарегистрирован на аккаунте.", + "auth.passkey_name_invalid": "Укажите название ключа (до 64 символов).", + "auth.passkey_limit": "Достигнут предел числа ключей на аккаунте.", "permissions.denied": "Недостаточно прав для этого действия.", "instance.admin_required": "Действие доступно только администратору инстанса.", "guild.not_found": "Сервер не найден.", @@ -166,7 +172,11 @@ "forgot": "Пароль забыт? Обратитесь к администратору инстанса.", "instanceStatus": "Состояние инстанса", "registrationDisabled": "Регистрация на этом инстансе выключена администратором.", - "totpRequired": "Введите код 2FA или резервный код — без него вход невозможен." + "totpRequired": "Введите код 2FA или резервный код — без него вход невозможен.", + "passkeySubmit": "Войти по ключу доступа", + "passkeyPending": "Ждём ключ…", + "passkeyCancelled": "Вход по ключу отменён или истёк таймаут. Попробуйте снова.", + "passkeyUnsupported": "Браузер не поддерживает ключи доступа. Войдите с паролем." }, "register": { "title": "Регистрация", @@ -793,7 +803,32 @@ "twoFactorEnabledDone": "2FA включена. Сохраните коды восстановления.", "recoveryTitle": "Коды восстановления", "recoveryHint": "Каждый код одноразовый. Они показываются только один раз.", - "recoveryCopyAll": "Скопировать все коды" + "recoveryCopyAll": "Скопировать все коды", + "passkeysTitle": "Ключи доступа (passkeys)", + "passkeysDescription": "Вход без пароля по отпечатку, Face ID или аппаратному ключу. Можно добавить несколько ключей и отозвать любой из них.", + "passkeysEmpty": "Ключей пока нет. Добавьте первый — и сможете входить без пароля.", + "passkeysAddTitle": "Добавить ключ", + "passkeysAddHint": "Придумайте имя (например, «Ноутбук») и подтвердите личность: управление ключами требует свежей проверки.", + "passkeysName": "Название ключа", + "passkeysNamePlaceholder": "Ноутбук, телефон, YubiKey…", + "passkeysAdd": "Создать ключ", + "passkeysAdded": "Ключ «{{name}}» добавлен.", + "passkeysRemoved": "Ключ отозван.", + "passkeysRenamed": "Название ключа обновлено.", + "passkeysRemove": "Отозвать", + "passkeysRename": "Переименовать", + "passkeysRenamePrompt": "Новое название ключа", + "passkeysRemoveConfirm": "Отозвать ключ «{{name}}»? Вход по нему станет невозможен.", + "passkeysRemoveConfirmButton": "Отозвать ключ", + "passkeysCreated": "Добавлен {{date}}", + "passkeysNeverUsed": "ещё не использовался", + "passkeysLastUsed": "последний вход {{date}}", + "passkeysUnsupported": "Этот браузер не поддерживает ключи доступа (WebAuthn). Нужен современный браузер и защищённое соединение (HTTPS).", + "passkeysCancelled": "Создание ключа отменено или истёк таймаут.", + "passkeysTimeout": "Аутентификатор не ответил вовремя. Попробуйте снова.", + "passkeysAlreadyRegistered": "Этот ключ уже зарегистрирован на аккаунте.", + "passkeysFailed": "Не удалось создать ключ. Попробуйте другой аутентификатор.", + "passkeysNameRequired": "Укажите название ключа (до 64 символов)." }, "appearance": { "title": "Внешний вид", diff --git a/web/src/lib/webauthn.ts b/web/src/lib/webauthn.ts new file mode 100644 index 0000000..a025f0f --- /dev/null +++ b/web/src/lib/webauthn.ts @@ -0,0 +1,263 @@ +/** + * Обёртка над `navigator.credentials` для passkeys (WebAuthn). + * + * Тяжёлые SDK не используем (AGENT.md 5.2): сервер отдаёт обычный JSON + * WebAuthn, здесь он переводится в ArrayBuffer и обратно. Все ошибки + * приводятся к `WebAuthnClientError` с понятным кодом, чтобы UI показал + * человеческое сообщение, а не `NotAllowedError`. + */ + +/** Коды ошибок клиента: используются в i18n (`errors.auth.passkey_*`). */ +export type WebAuthnClientErrorCode = + 'unsupported' | 'cancelled' | 'timeout' | 'invalid_state' | 'not_allowed' | 'failed'; + +export class WebAuthnClientError extends Error { + readonly code: WebAuthnClientErrorCode; + + constructor(code: WebAuthnClientErrorCode, message?: string) { + super(message ?? code); + this.name = 'WebAuthnClientError'; + this.code = code; + } +} + +/** Публичные опции, как их отдаёт сервер (base64url-строки). */ +export interface PublicKeyCredentialCreationOptionsJSON { + challenge: string; + rp: { id?: string; name: string }; + user: { id: string; name: string; displayName: string }; + pubKeyCredParams: PublicKeyCredentialParameters[]; + timeout?: number; + attestation?: AttestationConveyancePreference; + authenticatorSelection?: AuthenticatorSelectionCriteria; + excludeCredentials?: { id: string; type: 'public-key'; transports?: string[] }[]; + extensions?: AuthenticationExtensionsClientInputs; +} + +export interface PublicKeyCredentialRequestOptionsJSON { + challenge: string; + timeout?: number; + rpId?: string; + allowCredentials?: { id: string; type: 'public-key'; transports?: string[] }[]; + userVerification?: UserVerificationRequirement; +} + +/** Ответ аутентификатора в JSON-виде — ровно то, что ждёт сервер. */ +export interface RegistrationCredentialJSON { + id: string; + rawId: string; + type: 'public-key'; + response: { + clientDataJSON: string; + attestationObject: string; + transports?: string[]; + }; + clientExtensionResults: Record; + authenticatorAttachment?: string; +} + +export interface AssertionCredentialJSON { + id: string; + rawId: string; + type: 'public-key'; + response: { + clientDataJSON: string; + authenticatorData: string; + signature: string; + userHandle?: string; + }; + clientExtensionResults: Record; + authenticatorAttachment?: string; +} + +/** base64url → ArrayBuffer (браузер не умеет декодировать сам). */ +export function base64URLToBuffer(value: string): ArrayBuffer { + const padded = value.replace(/-/g, '+').replace(/_/g, '/'); + const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4)); + const binary = atob(padded + pad); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes.buffer; +} + +/** ArrayBuffer → base64url без паддинга. */ +export function bufferToBase64URL(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** + * Поддерживает ли браузер passkeys. Проверяем и наличие API, и защищённый + * контекст: без HTTPS (кроме localhost) WebAuthn недоступен. + */ +export function isPasskeySupported(): boolean { + if (typeof window === 'undefined' || typeof navigator === 'undefined') { + return false; + } + if (typeof window.PublicKeyCredential === 'undefined') { + return false; + } + return ( + typeof navigator.credentials?.create === 'function' && + typeof navigator.credentials?.get === 'function' + ); +} + +/** Приводит произвольную ошибку браузера к понятному коду. */ +export function mapCredentialsError(error: unknown): WebAuthnClientError { + if (error instanceof WebAuthnClientError) { + return error; + } + const name = error instanceof Error ? error.name : ''; + switch (name) { + case 'NotAllowedError': + // Пользователь отменил диалог или истёк таймаут: браузер не различает их. + return new WebAuthnClientError('cancelled', name); + case 'AbortError': + return new WebAuthnClientError('cancelled', name); + case 'TimeoutError': + return new WebAuthnClientError('timeout', name); + case 'InvalidStateError': + // Ключ уже зарегистрирован на этом устройстве. + return new WebAuthnClientError('invalid_state', name); + case 'NotSupportedError': + case 'SecurityError': + return new WebAuthnClientError('unsupported', name); + default: + return new WebAuthnClientError('failed', name); + } +} + +/** Дескриптор ключа в формате браузера: id — ArrayBuffer. */ +function toDescriptors( + descriptors: { id: string; type: 'public-key'; transports?: string[] }[] | undefined, +): PublicKeyCredentialDescriptor[] | undefined { + if (descriptors === undefined) { + return undefined; + } + return descriptors.map((descriptor) => ({ + id: base64URLToBuffer(descriptor.id), + type: descriptor.type, + ...(descriptor.transports === undefined + ? {} + : { transports: descriptor.transports as AuthenticatorTransport[] }), + })); +} + +function requireSupport(): void { + if (!isPasskeySupported()) { + throw new WebAuthnClientError('unsupported'); + } +} + +/** + * Создаёт ключ доступа и возвращает ответ для сервера. + * `options` — поле `publicKey` из ответа `/auth/passkeys/register/begin`. + */ +export async function createPasskey( + options: PublicKeyCredentialCreationOptionsJSON, +): Promise { + requireSupport(); + const excludeCredentials = toDescriptors(options.excludeCredentials); + // Поля собираем поимённо: JSON-вариант содержит строковые id, и простой + // спред перенёс бы их в браузерный тип, где нужен ArrayBuffer. + const publicKey: PublicKeyCredentialCreationOptions = { + challenge: base64URLToBuffer(options.challenge), + rp: options.rp, + user: { ...options.user, id: base64URLToBuffer(options.user.id) }, + pubKeyCredParams: options.pubKeyCredParams, + ...(options.timeout === undefined ? {} : { timeout: options.timeout }), + ...(options.attestation === undefined ? {} : { attestation: options.attestation }), + ...(options.authenticatorSelection === undefined + ? {} + : { authenticatorSelection: options.authenticatorSelection }), + ...(options.extensions === undefined ? {} : { extensions: options.extensions }), + ...(excludeCredentials === undefined ? {} : { excludeCredentials }), + }; + try { + const credential = (await navigator.credentials.create({ + publicKey, + })) as PublicKeyCredential | null; + if (credential === null) { + throw new WebAuthnClientError('cancelled'); + } + const response = credential.response as AuthenticatorAttestationResponse; + const result: RegistrationCredentialJSON = { + id: credential.id, + rawId: bufferToBase64URL(credential.rawId), + type: 'public-key', + response: { + clientDataJSON: bufferToBase64URL(response.clientDataJSON), + attestationObject: bufferToBase64URL(response.attestationObject), + }, + clientExtensionResults: credential.getClientExtensionResults() as Record, + }; + if (typeof response.getTransports === 'function') { + const transports = response.getTransports(); + if (transports.length > 0) { + result.response.transports = transports; + } + } + if (credential.authenticatorAttachment !== null) { + result.authenticatorAttachment = credential.authenticatorAttachment; + } + return result; + } catch (error) { + throw mapCredentialsError(error); + } +} + +/** + * Подписывает challenge существующим ключом. + * `options` — поле `publicKey` из ответа `/auth/passkeys/login/begin`. + */ +export async function getPasskeyAssertion( + options: PublicKeyCredentialRequestOptionsJSON, +): Promise { + requireSupport(); + const allowCredentials = toDescriptors(options.allowCredentials); + const publicKey: PublicKeyCredentialRequestOptions = { + challenge: base64URLToBuffer(options.challenge), + ...(options.timeout === undefined ? {} : { timeout: options.timeout }), + ...(options.rpId === undefined ? {} : { rpId: options.rpId }), + ...(options.userVerification === undefined + ? {} + : { userVerification: options.userVerification }), + ...(allowCredentials === undefined ? {} : { allowCredentials }), + }; + try { + const credential = (await navigator.credentials.get({ + publicKey, + })) as PublicKeyCredential | null; + if (credential === null) { + throw new WebAuthnClientError('cancelled'); + } + const response = credential.response as AuthenticatorAssertionResponse; + const result: AssertionCredentialJSON = { + id: credential.id, + rawId: bufferToBase64URL(credential.rawId), + type: 'public-key', + response: { + clientDataJSON: bufferToBase64URL(response.clientDataJSON), + authenticatorData: bufferToBase64URL(response.authenticatorData), + signature: bufferToBase64URL(response.signature), + }, + clientExtensionResults: credential.getClientExtensionResults() as Record, + }; + if (response.userHandle !== null) { + result.response.userHandle = bufferToBase64URL(response.userHandle); + } + if (credential.authenticatorAttachment !== null) { + result.authenticatorAttachment = credential.authenticatorAttachment; + } + return result; + } catch (error) { + throw mapCredentialsError(error); + } +} diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 5b3d0ce..93be541 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -1,15 +1,18 @@ import { useState, type FormEvent } from 'react'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Link, useLocation, useNavigate, useSearchParams } from 'react-router'; import { login } from '@/api/auth'; +import { fetchMeta, metaQueryKey } from '@/api/meta'; +import { loginWithPasskey } from '@/api/passkeys'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { Field } from '@/components/ui/Field'; import { Button, Card } from '@/components/ui/primitives'; import { getQueryClient } from '@/lib/queryClient'; import { useInstance } from '@/lib/hooks'; import { errorCode } from '@/lib/format'; +import { isPasskeySupported, WebAuthnClientError } from '@/lib/webauthn'; import { useGatewayStore } from '@/stores/gateway'; interface LocationState { @@ -20,6 +23,8 @@ interface LocationState { * Форма входа. Поле кода 2FA показывается всегда: сервер принимает и код из * приложения (6 цифр), и резервный код вида `a8eh-pshp-t8st`, а при попытке * входа без кода отвечает `auth.2fa_required` — тогда подсказываем про код. + * + * Дополнительно (Фаза 7): вход по ключу доступа (passkeys). */ export default function LoginPage() { const { t } = useTranslation(); @@ -39,19 +44,30 @@ export default function LoginPage() { const stateFrom = (location.state as LocationState | null)?.from ?? null; const from = next !== null && next.startsWith('/') ? next : (stateFrom ?? '/app'); + // Признак доступности passkeys берём из меты инстанса: на IP-адресе (стенд + // без домена) WebAuthn невозможен, и кнопку показывать нельзя. + const meta = useQuery({ + queryKey: metaQueryKey, + queryFn: ({ signal }) => fetchMeta(signal), + staleTime: 60_000, + }); + const passkeySupported = isPasskeySupported() && meta.data?.features.passkeys_enabled === true; + + const finishLogin = async (): Promise => { + // Профиль перечитываем заново: cookie уже выставлена сервером. Признак + // отозванной сессии снимаем — иначе AuthGuard увёл бы обратно на /login. + useGatewayStore.getState().reset(); + await getQueryClient().invalidateQueries(); + void navigate(from === '/login' ? '/app' : from, { replace: true }); + }; + const submit = useMutation({ mutationFn: () => { // Пустое поле — вход без кода: сервер сам скажет, нужен ли он. const code = totpCode.trim(); return login(code === '' ? { email, password } : { email, password, totp_code: code }); }, - onSuccess: async () => { - // Профиль перечитываем заново: cookie уже выставлена сервером. Признак - // отозванной сессии снимаем — иначе AuthGuard увёл бы обратно на /login. - useGatewayStore.getState().reset(); - await getQueryClient().invalidateQueries(); - void navigate(from === '/login' ? '/app' : from, { replace: true }); - }, + onSuccess: finishLogin, onError: (error: unknown) => { if (errorCode(error) === 'auth.2fa_required') { // Логин, пароль и уже введённый код остаются в форме. @@ -60,6 +76,11 @@ export default function LoginPage() { }, }); + const passkey = useMutation({ + mutationFn: () => loginWithPasskey(email.trim() === '' ? undefined : email.trim()), + onSuccess: finishLogin, + }); + const onSubmit = (event: FormEvent): void => { event.preventDefault(); setNeedsTotp(false); @@ -68,6 +89,14 @@ export default function LoginPage() { const registrationEnabled = instance.data?.registration_enabled === true; + // Ошибка клиента WebAuthn важнее серверной: она объясняет, что делать. + const passkeyErrorKey = + passkey.error instanceof WebAuthnClientError && passkey.error.code === 'cancelled' + ? 'auth.login.passkeyCancelled' + : passkey.error instanceof WebAuthnClientError && passkey.error.code === 'unsupported' + ? 'auth.login.passkeyUnsupported' + : null; + return (
@@ -120,6 +149,29 @@ export default function LoginPage() { + {passkeySupported ? ( +
+ + {passkeyErrorKey === null ? ( + + ) : ( +

+ {t(passkeyErrorKey)} +

+ )} +
+ ) : null} +

{t('auth.login.noAccount')}{' '} {registrationEnabled ? ( diff --git a/web/src/pages/settings/PasskeysSection.tsx b/web/src/pages/settings/PasskeysSection.tsx new file mode 100644 index 0000000..75dd842 --- /dev/null +++ b/web/src/pages/settings/PasskeysSection.tsx @@ -0,0 +1,267 @@ +import { useCallback, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import { stepUp } from '@/api/auth'; +import { + deletePasskey, + fetchPasskeys, + passkeysQueryKey, + registerPasskey, + renamePasskey, + type Passkey, +} from '@/api/passkeys'; +import { ErrorNotice } from '@/components/ui/ErrorNotice'; +import { Field } from '@/components/ui/Field'; +import { Button, Card } from '@/components/ui/primitives'; +import { errorCode } from '@/lib/format'; +import { isPasskeySupported, WebAuthnClientError } from '@/lib/webauthn'; + +/** Ключ i18n для ошибки клиента WebAuthn (AGENT.md 7.1, Фаза 7). */ +function passkeyErrorKey(error: unknown): string | null { + if (error instanceof WebAuthnClientError) { + switch (error.code) { + case 'unsupported': + return 'settings.security.passkeysUnsupported'; + case 'cancelled': + return 'settings.security.passkeysCancelled'; + case 'timeout': + return 'settings.security.passkeysTimeout'; + case 'invalid_state': + return 'settings.security.passkeysAlreadyRegistered'; + default: + return 'settings.security.passkeysFailed'; + } + } + if (errorCode(error) === 'auth.passkey_name_invalid') { + return 'settings.security.passkeysNameRequired'; + } + return null; +} + +/** + * Ключи доступа (passkeys): список, добавление, переименование и отзыв. + * Управление ключами — чувствительное действие, поэтому сервер требует + * свежий step-up: пароль (и код 2FA) спрашиваем прямо в разделе. + * + * Если браузер не поддерживает WebAuthn, раздел честно сообщает об этом и не + * показывает форму добавления (AGENT.md 7.1: понятные ошибки для браузеров + * без поддержки). + */ +export function PasskeysSection({ twoFactorEnabled }: { twoFactorEnabled: boolean }) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const supported = isPasskeySupported(); + const [name, setName] = useState(''); + const [password, setPassword] = useState(''); + const [code, setCode] = useState(''); + const [notice, setNotice] = useState(null); + const [removeTarget, setRemoveTarget] = useState(null); + + const passkeys = useQuery({ + queryKey: passkeysQueryKey, + queryFn: ({ signal }) => fetchPasskeys(signal), + enabled: supported, + }); + + const refresh = useCallback(() => { + void queryClient.invalidateQueries({ queryKey: passkeysQueryKey }); + }, [queryClient]); + + const clearCredentials = useCallback(() => { + setPassword(''); + setCode(''); + }, []); + + const add = useMutation({ + mutationFn: () => registerPasskey(name.trim(), password, twoFactorEnabled ? code : undefined), + onSuccess: (passkey) => { + setNotice(t('settings.security.passkeysAdded', { name: passkey.name })); + setName(''); + clearCredentials(); + refresh(); + }, + }); + + const remove = useMutation({ + mutationFn: async () => { + if (removeTarget === null) { + return; + } + // Свежий step-up, затем отзыв ключа. + await stepUp(password, twoFactorEnabled ? code : undefined); + await deletePasskey(removeTarget.id); + }, + onSuccess: () => { + setNotice(t('settings.security.passkeysRemoved')); + setRemoveTarget(null); + clearCredentials(); + refresh(); + }, + }); + + const rename = useMutation({ + mutationFn: (payload: { passkey: Passkey; next: string }) => + renamePasskey(payload.passkey.id, payload.next), + onSuccess: () => { + setNotice(t('settings.security.passkeysRenamed')); + refresh(); + }, + }); + + const errorKey = passkeyErrorKey(add.error ?? remove.error ?? rename.error); + const canSubmit = supported && name.trim() !== '' && password !== '' && !add.isPending; + + return ( + +

{t('settings.security.passkeysTitle')}

+

{t('settings.security.passkeysDescription')}

+ + {!supported ? ( +

+ {t('settings.security.passkeysUnsupported')} +

+ ) : ( + <> +
    + {(passkeys.data ?? []).map((passkey) => ( +
  • +
    +

    + {passkey.name} +

    +

    + {t('settings.security.passkeysCreated', { + date: new Date(passkey.created_at).toLocaleDateString(), + })} + {passkey.last_used_at === undefined + ? ` · ${t('settings.security.passkeysNeverUsed')}` + : ` · ${t('settings.security.passkeysLastUsed', { + date: new Date(passkey.last_used_at).toLocaleString(), + })}`} +

    +
    +
    + + +
    +
  • + ))} + {passkeys.data !== undefined && passkeys.data.length === 0 ? ( +
  • + {t('settings.security.passkeysEmpty')} +
  • + ) : null} +
+ +
{ + event.preventDefault(); + setNotice(null); + if (canSubmit) { + add.mutate(); + } + }} + > +

{t('settings.security.passkeysAddTitle')}

+

{t('settings.security.passkeysAddHint')}

+ setName(event.target.value)} + placeholder={t('settings.security.passkeysNamePlaceholder')} + required + /> + setPassword(event.target.value)} + required + /> + {twoFactorEnabled ? ( + setCode(event.target.value)} + /> + ) : null} + {notice === null ? null : ( +

+ {notice} +

+ )} + {errorKey === null ? ( + + ) : ( +

+ {t(errorKey)} +

+ )} +
+ +
+ + + {removeTarget === null ? null : ( +
+

+ {t('settings.security.passkeysRemoveConfirm', { name: removeTarget.name })} +

+
+ + +
+
+ )} + + )} + + ); +} diff --git a/web/src/pages/settings/SecuritySettingsPage.tsx b/web/src/pages/settings/SecuritySettingsPage.tsx index 8014e62..8d37296 100644 --- a/web/src/pages/settings/SecuritySettingsPage.tsx +++ b/web/src/pages/settings/SecuritySettingsPage.tsx @@ -2,13 +2,14 @@ import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { currentUserQueryKey, fetchCurrentUser } from '@/api/users'; +import { PasskeysSection } from '@/pages/settings/PasskeysSection'; import { PasswordSection } from '@/pages/settings/PasswordSection'; import { SessionsSection } from '@/pages/settings/SessionsSection'; import { TwoFactorSection } from '@/pages/settings/TwoFactorSection'; /** * Безопасность: смена пароля со step-up, список сессий с выходом на всех - * устройствах и включение 2FA с показом секрета и кодов восстановления. + * устройствах, включение 2FA и ключи доступа (passkeys, Фаза 7). * * Состояние 2FA берём из профиля (`totp_enabled`), обновляя запрос после * включения: от него зависит, требовать ли код при step-up. @@ -35,6 +36,7 @@ export default function SecuritySettingsPage() { void currentUser.refetch(); }} /> + ); } diff --git a/web/tests/passkeys.test.tsx b/web/tests/passkeys.test.tsx new file mode 100644 index 0000000..d13d6fe --- /dev/null +++ b/web/tests/passkeys.test.tsx @@ -0,0 +1,388 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { QueryClientProvider } from '@tanstack/react-query'; + +import { PasskeysSection } from '@/pages/settings/PasskeysSection'; +import { + base64URLToBuffer, + bufferToBase64URL, + createPasskey, + getPasskeyAssertion, + isPasskeySupported, + WebAuthnClientError, +} from '@/lib/webauthn'; +import { + apiError, + createTestQueryClient, + findRequest, + installFetch, + json, + renderApp, +} from './helpers'; + +/** Кодирует строку в base64url — как это делает браузер для JSON-ответа. */ +function base64URL(value: string): string { + return bufferToBase64URL(new TextEncoder().encode(value).buffer); +} + +/** Подставной PublicKeyCredential: только то, что читает обёртка. */ +function fakeCredential(overrides: Record = {}) { + return { + id: 'credential-id', + rawId: new TextEncoder().encode('credential-id').buffer, + type: 'public-key', + authenticatorAttachment: 'platform', + getClientExtensionResults: () => ({}), + response: { + clientDataJSON: new TextEncoder().encode('{"type":"webauthn.create"}').buffer, + attestationObject: new TextEncoder().encode('attestation').buffer, + getTransports: () => ['internal'], + }, + ...overrides, + }; +} + +/** Устанавливает поддержку WebAuthn и мок navigator.credentials. */ +function stubCredentials(credentials: { + create?: (options?: unknown) => Promise; + get?: (options?: unknown) => Promise; +}) { + vi.stubGlobal('PublicKeyCredential', function PublicKeyCredential() {}); + const spies = { + create: vi.fn(credentials.create ?? (() => Promise.resolve(null))), + get: vi.fn(credentials.get ?? (() => Promise.resolve(null))), + }; + Object.defineProperty(navigator, 'credentials', { value: spies, configurable: true }); + return spies; +} + +describe('обёртка WebAuthn', () => { + it('не поддерживается без PublicKeyCredential', () => { + vi.stubGlobal('PublicKeyCredential', undefined); + expect(isPasskeySupported()).toBe(false); + }); + + it('переводит base64url в ArrayBuffer и обратно', () => { + const buffer = base64URLToBuffer('aGVsbG8'); + expect(new TextDecoder().decode(buffer)).toBe('hello'); + expect(bufferToBase64URL(buffer)).toBe('aGVsbG8'); + }); + + it('создаёт ключ и отдаёт ответ в формате сервера', async () => { + const spies = stubCredentials({ create: () => Promise.resolve(fakeCredential()) }); + + const result = await createPasskey({ + challenge: base64URL('challenge-value'), + rp: { id: 'gl.mhspx.su', name: 'glchat' }, + user: { id: base64URL('user-handle'), name: 'alice', displayName: 'Alice' }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + excludeCredentials: [{ id: base64URL('old'), type: 'public-key', transports: ['internal'] }], + }); + + expect(result.type).toBe('public-key'); + expect(result.response.attestationObject).toBe(base64URL('attestation')); + expect(result.response.transports).toEqual(['internal']); + // challenge уходит в браузер как ArrayBuffer, а не строкой. + const options = spies.create.mock.calls[0]?.[0] as { + publicKey: { challenge: ArrayBuffer; excludeCredentials: { id: ArrayBuffer }[] }; + }; + expect(options.publicKey.challenge).toBeInstanceOf(ArrayBuffer); + expect(options.publicKey.excludeCredentials[0]?.id).toBeInstanceOf(ArrayBuffer); + }); + + it('возвращает понятный код при отмене пользователем', async () => { + stubCredentials({ + create: () => Promise.reject(Object.assign(new Error('cancel'), { name: 'NotAllowedError' })), + }); + + await expect( + createPasskey({ + challenge: base64URL('challenge'), + rp: { id: 'gl.mhspx.su', name: 'glchat' }, + user: { id: base64URL('handle'), name: 'alice', displayName: 'Alice' }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + }), + ).rejects.toMatchObject({ code: 'cancelled' }); + }); + + it('сообщает об отсутствии поддержки до обращения к браузеру', async () => { + await expect( + createPasskey({ + challenge: base64URL('challenge'), + rp: { id: 'gl.mhspx.su', name: 'glchat' }, + user: { id: base64URL('handle'), name: 'alice', displayName: 'Alice' }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + }), + ).rejects.toBeInstanceOf(WebAuthnClientError); + }); + + it('подписывает challenge и передаёт userHandle', async () => { + const assertion = { + id: 'credential-id', + rawId: new TextEncoder().encode('credential-id').buffer, + type: 'public-key', + authenticatorAttachment: null, + getClientExtensionResults: () => ({}), + response: { + clientDataJSON: new TextEncoder().encode('{"type":"webauthn.get"}').buffer, + authenticatorData: new TextEncoder().encode('auth-data').buffer, + signature: new TextEncoder().encode('signature').buffer, + userHandle: new TextEncoder().encode('handle').buffer, + }, + }; + const spies = stubCredentials({ get: () => Promise.resolve(assertion) }); + + const result = await getPasskeyAssertion({ challenge: base64URL('challenge') }); + expect(result.response.userHandle).toBe(base64URL('handle')); + expect(result.authenticatorAttachment).toBeUndefined(); + expect(spies.get).toHaveBeenCalledTimes(1); + }); +}); + +/** Рендер раздела настроек с изолированным QueryClient. */ +function renderSection(ui: React.ReactElement) { + const queryClient = createTestQueryClient(); + return render({ui}); +} + +describe('раздел «Ключи доступа»', () => { + it('без поддержки WebAuthn показывает понятное сообщение', async () => { + vi.stubGlobal('PublicKeyCredential', undefined); + installFetch([]); + + renderSection(); + + expect(await screen.findByTestId('passkeys-unsupported')).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Создать ключ' })).toBeNull(); + }); + + it('добавляет ключ: step-up, ответ аутентификатора и обновление списка', async () => { + stubCredentials({ create: () => Promise.resolve(fakeCredential()) }); + const fetchMock = installFetch([ + { + match: '/api/v1/auth/passkeys/register/begin', + method: 'POST', + response: () => + json({ + options: { + publicKey: { + challenge: base64URL('challenge'), + rp: { id: 'gl.mhspx.su', name: 'glchat' }, + user: { id: base64URL('handle'), name: 'alice', displayName: 'Alice' }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + }, + }, + ceremony: 'ceremony-1', + expires_in_sec: 300, + }), + }, + { + match: '/api/v1/auth/passkeys/register/finish', + method: 'POST', + response: () => + json({ + passkey: { + id: 'pk-1', + name: 'Ноутбук', + created_at: '2026-09-26T10:00:00Z', + sign_count: 0, + }, + }), + }, + { + match: '/api/v1/auth/passkeys', + response: () => json({ passkeys: [], supported: true }), + }, + ]); + + renderSection(); + const visitor = userEvent.setup(); + + await visitor.type(await screen.findByLabelText('Название ключа'), 'Ноутбук'); + await visitor.type(screen.getByLabelText('Ваш пароль'), 'super-secret-1'); + await visitor.click(screen.getByRole('button', { name: 'Создать ключ' })); + + await waitFor(() => { + expect(findRequest(fetchMock, { url: 'register/finish', method: 'POST' })).toBeDefined(); + }); + const finish = findRequest(fetchMock, { url: 'register/finish', method: 'POST' }); + const body = finish?.body as { + ceremony: string; + name: string; + step_up_password: string; + credential: { response: { attestationObject: string } }; + }; + expect(body.ceremony).toBe('ceremony-1'); + expect(body.name).toBe('Ноутбук'); + expect(body.step_up_password).toBe('super-secret-1'); + expect(body.credential.response.attestationObject).toBe(base64URL('attestation')); + // Пароль уходит и на begin: сервер требует свежий step-up. + const begin = findRequest(fetchMock, { url: 'register/begin', method: 'POST' }); + expect(begin?.body).toEqual({ password: 'super-secret-1' }); + expect(await screen.findByText('Ключ «Ноутбук» добавлен.')).toBeVisible(); + }); + + it('отмена создания ключа показывается человеческим текстом', async () => { + stubCredentials({ + create: () => Promise.reject(Object.assign(new Error('x'), { name: 'NotAllowedError' })), + }); + installFetch([ + { + match: '/api/v1/auth/passkeys/register/begin', + method: 'POST', + response: () => + json({ + options: { + publicKey: { + challenge: base64URL('challenge'), + rp: { id: 'gl.mhspx.su', name: 'glchat' }, + user: { id: base64URL('handle'), name: 'alice', displayName: 'Alice' }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + }, + }, + ceremony: 'ceremony-1', + expires_in_sec: 300, + }), + }, + { match: '/api/v1/auth/passkeys', response: () => json({ passkeys: [], supported: true }) }, + ]); + + renderSection(); + const visitor = userEvent.setup(); + await visitor.type(await screen.findByLabelText('Название ключа'), 'Ключ'); + await visitor.type(screen.getByLabelText('Ваш пароль'), 'super-secret-1'); + await visitor.click(screen.getByRole('button', { name: 'Создать ключ' })); + + expect(await screen.findByTestId('passkeys-error')).toHaveTextContent( + 'Создание ключа отменено', + ); + }); + + it('серверная ошибка step-up показывается как есть', async () => { + stubCredentials({ create: () => Promise.resolve(fakeCredential()) }); + installFetch([ + { + match: '/api/v1/auth/passkeys/register/begin', + method: 'POST', + response: () => apiError('auth.step_up_required', 403), + }, + { match: '/api/v1/auth/passkeys', response: () => json({ passkeys: [], supported: true }) }, + ]); + + renderSection(); + const visitor = userEvent.setup(); + await visitor.type(await screen.findByLabelText('Название ключа'), 'Ключ'); + await visitor.type(screen.getByLabelText('Ваш пароль'), 'wrong'); + await visitor.click(screen.getByRole('button', { name: 'Создать ключ' })); + + expect(await screen.findByText(/Подтвердите личность/)).toBeVisible(); + }); +}); + +const instanceOpen = { + name: 'glchat-test', + version: 'v0.1.0-test', + registration_enabled: true, + allow_guild_creation: true, + max_guilds_per_user: 5, + max_members_per_guild: 100, + max_message_length: 2000, + main_guild_id: 'g-main', + user_count: 1, + guild_count: 1, +}; + +const metaWithPasskeys = { + name: 'glchat-test', + version: 'v0.1.0-test', + commit: 'deadbee', + build_date: '2026-09-26T00:00:00Z', + api_version: 'v1', + base_url: 'https://gl.mhspx.su', + files_url: 'https://files.gl.mhspx.su', + gateway_url: 'wss://gl.mhspx.su/gateway', + rtc_path: '/rtc', + max_upload_size: 26214400, + features: { + registration_enabled: true, + anti_bot_enabled: false, + voice_enabled: false, + web_push_enabled: false, + oauth_enabled: false, + passkeys_enabled: true, + }, +}; + +describe('страница входа: вход по ключу', () => { + it('входит по ключу и переводит в приложение', async () => { + const assertion = { + id: 'credential-id', + rawId: new TextEncoder().encode('credential-id').buffer, + type: 'public-key', + authenticatorAttachment: null, + getClientExtensionResults: () => ({}), + response: { + clientDataJSON: new TextEncoder().encode('{}').buffer, + authenticatorData: new TextEncoder().encode('auth-data').buffer, + signature: new TextEncoder().encode('signature').buffer, + userHandle: null, + }, + }; + stubCredentials({ get: () => Promise.resolve(assertion) }); + + const user = { + id: 'user-1', + username: 'alice', + display_name: 'Alice', + onboarding_completed: true, + totp_enabled: false, + badges: [], + }; + const fetchMock = installFetch([ + { match: '/api/v1/meta', response: () => json(metaWithPasskeys) }, + { match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) }, + { + match: '/api/v1/auth/passkeys/login/begin', + method: 'POST', + response: () => + json({ + options: { publicKey: { challenge: base64URL('challenge') } }, + ceremony: 'ceremony-login', + expires_in_sec: 300, + }), + }, + { + match: '/api/v1/auth/passkeys/login/finish', + method: 'POST', + response: () => json({ user }), + }, + { match: '/api/v1/users/@me', response: () => json({ user }) }, + { match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) }, + ]); + + const { router } = renderApp('/login'); + const visitor = userEvent.setup(); + + await visitor.click(await screen.findByTestId('passkey-login')); + + await waitFor(() => { + expect(router.state.location.pathname).toBe('/app/empty'); + }); + const finish = findRequest(fetchMock, { url: 'passkeys/login/finish', method: 'POST' }); + expect((finish?.body as { ceremony: string }).ceremony).toBe('ceremony-login'); + }); + + it('без поддержки браузера кнопка входа по ключу не показывается', async () => { + vi.stubGlobal('PublicKeyCredential', undefined); + installFetch([ + { match: '/api/v1/meta', response: () => json(metaWithPasskeys) }, + { match: '/api/v1/instance', response: () => json({ instance: instanceOpen }) }, + ]); + + renderApp('/login'); + + await screen.findByLabelText('Почта'); + expect(screen.queryByTestId('passkey-login')).toBeNull(); + }); +});