feat(auth): вход по ключу доступа (passkeys, WebAuthn)
Фаза 7, AGENT.md 7.1: регистрация ключа в настройках безопасности с обязательным step-up, вход по ключу без пароля (в т.ч. без ввода почты — обнаруживаемый ключ), несколько ключей на аккаунт, отзыв и переименование, события безопасности и аудит. Сервер: github.com/go-webauthn/webauthn (BSD-3-Clause), RP ID и Origin берутся из конфига домена; если домен — IP-адрес (стенд без домена), passkeys честно выключены (auth.passkey_unsupported). Церемонии живут в памяти процесса 5 минут и одноразовые: повторная отправка challenge отклоняется. Миграция 00018 пересобирает неиспользуемую таблицу webauthn_credentials под полную запись credential в JSON. Новые ручки входа ограничены по IP (10/мин). Клиент: тонкая обёртка над navigator.credentials без тяжёлых SDK, раздел «Ключи доступа» в настройках безопасности и кнопка «Войти по ключу» на экране входа; понятные сообщения для браузеров без поддержки WebAuthn и при отмене диалога. Тесты: Go — регистрация/вход с эмулятором аутентификатора (реальная проверка подписи P-256), отказ при чужом challenge, одноразовость церемонии, обязательный step-up при управлении ключами, запрет входа забаненному, ограничение allowCredentials при входе с почтой, лимит и валидация имени; web — 12 vitest с моком navigator.credentials; Playwright — живой сценарий с виртуальным аутентификатором Chromium.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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'))
|
||||
);
|
||||
@@ -48,7 +48,7 @@ func New(cfg config.Config) Response {
|
||||
VoiceURL: cfg.LiveKitURL,
|
||||
WebPushEnabled: false,
|
||||
OAuthEnabled: false,
|
||||
PasskeysEnabled: false,
|
||||
PasskeysEnabled: cfg.WebAuthnEnabled(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user