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