Files
glchat/internal/auth/passkeys.go
T

479 lines
18 KiB
Go
Raw Normal View History

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