cd1d662572
Фаза 7, AGENT.md 7.1: регистрация ключа в настройках безопасности с обязательным step-up, вход по ключу без пароля (в т.ч. без ввода почты — обнаруживаемый ключ), несколько ключей на аккаунт, отзыв и переименование, события безопасности и аудит. Сервер: github.com/go-webauthn/webauthn (BSD-3-Clause), RP ID и Origin берутся из конфига домена; если домен — IP-адрес (стенд без домена), passkeys честно выключены (auth.passkey_unsupported). Церемонии живут в памяти процесса 5 минут и одноразовые: повторная отправка challenge отклоняется. Миграция 00018 пересобирает неиспользуемую таблицу webauthn_credentials под полную запись credential в JSON. Новые ручки входа ограничены по IP (10/мин). Клиент: тонкая обёртка над navigator.credentials без тяжёлых SDK, раздел «Ключи доступа» в настройках безопасности и кнопка «Войти по ключу» на экране входа; понятные сообщения для браузеров без поддержки WebAuthn и при отмене диалога. Тесты: Go — регистрация/вход с эмулятором аутентификатора (реальная проверка подписи P-256), отказ при чужом challenge, одноразовость церемонии, обязательный step-up при управлении ключами, запрет входа забаненному, ограничение allowCredentials при входе с почтой, лимит и валидация имени; web — 12 vitest с моком navigator.credentials; Playwright — живой сценарий с виртуальным аутентификатором Chromium.
479 lines
18 KiB
Go
479 lines
18 KiB
Go
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)
|
|
}
|