// Package auth реализует аккаунты, аутентификацию и сессии (AGENT.md 7.1): // пароли Argon2id с pepper, шифрование PII, blind index по email, ротация // сессий, TOTP и step-up. package auth import ( "context" "errors" "fmt" "log/slog" "strconv" "strings" "time" "glchat/internal/config" "glchat/internal/crypto" "glchat/internal/permissions" "glchat/internal/store" "github.com/go-webauthn/webauthn/webauthn" ) var ( ErrInvalidCredentials = errors.New("auth.invalid_credentials") ErrEmailTaken = errors.New("auth.email_taken") ErrUsernameTaken = errors.New("auth.username_taken") ErrRegistrationOff = errors.New("auth.registration_disabled") ErrTOTPRequired = errors.New("auth.2fa_required") ErrTOTPInvalid = errors.New("auth.totp_invalid") ErrSessionExpired = errors.New("auth.session_expired") ErrStepUpRequired = errors.New("auth.step_up_required") ErrWeakPassword = errors.New("auth.weak_password") ErrInvalidUsername = errors.New("auth.invalid_username") ErrInstanceAdminTOTP = errors.New("auth.instance_admin_requires_2fa") ErrNoTOTPSecret = errors.New("auth.totp_not_configured") ErrTOTPAlreadyEnabled = errors.New("auth.totp_already_enabled") // ErrUserBanned — глобальный бан инстанса (AGENT.md 7.18): вход запрещён, // действующие сессии недействительны. ErrUserBanned = errors.New("user.banned") ) // Config — параметры сервиса аутентификации. type Config struct { SessionTTL time.Duration MaxLoginFails int LockoutWindow time.Duration } // Service — операции с аккаунтами и сессиями. type Service struct { store *store.Store hasher *crypto.PasswordHasher masterKey *crypto.MasterKey totpHasher *crypto.PasswordHasher settings instanceSettings policy *passwordPolicy cfg Config logger *slog.Logger // webauthn — RP-конфигурация passkeys (nil, если домен не годится для RP ID). webauthn *webauthn.WebAuthn // ceremonies — начатые церемонии WebAuthn (в памяти, одноразовые). ceremonies *ceremonyStore // oauth — включённые провайдеры (client id/secret из env), oauthRedirectBase — // внешний адрес инстанса для redirect_uri, stateKey — ключ подписи state. oauth map[string]oauthClientCredentials oauthRedirectBase string stateKey string } type instanceSettings interface { InstanceSettings(ctx context.Context) (*store.InstanceSettings, error) } // New собирает сервис из конфигурации: pepper и master key обязательны // (AGENT.md 9.2 — без них инстанс не должен подниматься). func New(ctx context.Context, cfg config.Config, st *store.Store, logger *slog.Logger) (*Service, error) { if cfg.SessionPepper == "" { return nil, fmt.Errorf("SESSION_PEPPER is required") } if cfg.MasterKey == "" { return nil, fmt.Errorf("MASTER_KEY is required") } masterKey, err := crypto.ParseMasterKey(cfg.MasterKey) if err != nil { return nil, fmt.Errorf("parse MASTER_KEY: %w", err) } argonParams, err := argon2ParamsFromConfig(cfg) if err != nil { return nil, err } hasher, err := crypto.NewPasswordHasher([]byte(cfg.SessionPepper), argonParams) if err != nil { return nil, fmt.Errorf("create password hasher: %w", err) } totpKey := cfg.TOTPEncryptionKey if totpKey == "" { totpKey = cfg.SessionPepper } totpHasher, err := crypto.NewPasswordHasher([]byte(totpKey), crypto.Argon2Params{ Memory: 8192, Iterations: 1, Parallelism: 1, SaltLength: 16, KeyLength: 32, }) if err != nil { return nil, fmt.Errorf("create totp hasher: %w", err) } policy, err := newPasswordPolicy() if err != nil { return nil, err } sessionTTL := time.Duration(cfg.SessionTTLHours) * time.Hour if sessionTTL <= 0 { sessionTTL = store.SessionTTL } // OAuth-провайдеры и RP-конфигурация passkeys: включаются конфигом, при // отсутствии настроек соответствующие ручки отвечают понятной ошибкой. oauthClients, oauthRedirectBase := oauthFromConfig(cfg, cfg.BaseURL()) service := &Service{ store: st, hasher: hasher, masterKey: masterKey, totpHasher: totpHasher, settings: st, policy: policy, cfg: Config{SessionTTL: sessionTTL, MaxLoginFails: 5, LockoutWindow: 15 * time.Minute}, logger: logger, // state OAuth подписывается секретом сессий: отдельного секрета не нужно. oauth: oauthClients, oauthRedirectBase: oauthRedirectBase, stateKey: cfg.SessionPepper, ceremonies: newCeremonyStore(), } // Passkeys требуют корректного RP ID: для IP-адреса (стенд без домена) // браузеры WebAuthn не работают, поэтому такой инстанс просто живёт без них. service.webauthn = newWebAuthn(cfg, logger) return service, nil } // argon2ParamsFromConfig проверяет границы параметров Argon2id из env. func argon2ParamsFromConfig(cfg config.Config) (crypto.Argon2Params, error) { params := crypto.Argon2Params{SaltLength: 16, KeyLength: 32} switch { case cfg.Argon2MemoryKiB < 1024 || cfg.Argon2MemoryKiB > 4194304: return params, fmt.Errorf("ARGON2_MEMORY_KIB must be between 1024 and 4194304") case cfg.Argon2Iterations < 1 || cfg.Argon2Iterations > 10: return params, fmt.Errorf("ARGON2_ITERATIONS must be between 1 and 10") case cfg.Argon2Parallelism < 1 || cfg.Argon2Parallelism > 255: return params, fmt.Errorf("ARGON2_PARALLELISM must be between 1 and 255") } params.Memory = uint32(cfg.Argon2MemoryKiB) params.Iterations = uint32(cfg.Argon2Iterations) params.Parallelism = uint8(cfg.Argon2Parallelism) return params, nil } // SessionTTL возвращает срок жизни сессии. func (s *Service) SessionTTL() time.Duration { return s.cfg.SessionTTL } // HashToken возвращает хэш токена для поиска сессии. func (s *Service) HashToken(token string) string { return crypto.HashToken(token) } type RegisterInput struct { Username string DisplayName string Email string Password string Locale string IP string UserAgent string } // Register создаёт аккаунт, шифрует email, проверяет пароль и сразу выдаёт // сессию (AGENT.md 7.1: регистрация без подтверждения письма). func (s *Service) Register(ctx context.Context, in RegisterInput) (*store.User, string, *store.Session, error) { settings, err := s.settings.InstanceSettings(ctx) if err != nil { return nil, "", nil, fmt.Errorf("load instance settings: %w", err) } if !settings.RegistrationEnabled { return nil, "", nil, ErrRegistrationOff } username := strings.TrimSpace(in.Username) if err := crypto.ValidateUsername(username); err != nil { return nil, "", nil, ErrInvalidUsername } if err := s.policy.validate(in.Password); err != nil { return nil, "", nil, ErrWeakPassword } email := crypto.NormalizeEmail(in.Email) if err := validateEmail(email); err != nil { return nil, "", nil, err } passwordHash, err := s.hasher.Hash(in.Password) if err != nil { return nil, "", nil, fmt.Errorf("hash password: %w", err) } userID := s.store.NextID() emailEncrypted, err := s.encryptEmail(userID, email) if err != nil { return nil, "", nil, err } emailIndex := s.masterKey.BlindIndex(email) user, err := s.store.CreateUser(ctx, store.CreateUserParams{ ID: userID, Username: username, DisplayName: defaultDisplayName(in.DisplayName, username), EmailEnc: emailEncrypted, EmailIndex: emailIndex, PasswordHash: passwordHash, Locale: in.Locale, }) if err != nil { if errors.Is(err, store.ErrConflict) { // Различаем причины конфликта, не раскрывая лишнего наружу. if existing, lookupErr := s.store.GetUserByUsername(ctx, username); lookupErr == nil && existing != nil { return nil, "", nil, ErrUsernameTaken } return nil, "", nil, ErrEmailTaken } return nil, "", nil, fmt.Errorf("create user: %w", err) } // Новый пользователь попадает в пустой список серверов: вход только по // приглашению или созданием своего сервера (решение пользователя, 2026-09-20). token, session, err := s.createSession(ctx, user.ID, in.UserAgent, in.IP) if err != nil { return nil, "", nil, err } _ = s.store.RecordSecurityEvent(ctx, &user.ID, "register", in.IP, in.UserAgent, "") return user, token, session, nil } // CreateUserByOperator создаёт аккаунт по команде обслуживания (CLI // create-user): регистрация на инстансе может быть выключена, а сессия // создателю не нужна — он действует от имени администратора. Используется в // том числе для подготовки нагрузки (AGENT.md 11.4). func (s *Service) CreateUserByOperator(ctx context.Context, in RegisterInput) (*store.User, error) { username := strings.TrimSpace(in.Username) if err := crypto.ValidateUsername(username); err != nil { return nil, ErrInvalidUsername } if err := s.policy.validate(in.Password); err != nil { return nil, ErrWeakPassword } email := crypto.NormalizeEmail(in.Email) if err := validateEmail(email); err != nil { return nil, err } passwordHash, err := s.hasher.Hash(in.Password) if err != nil { return nil, fmt.Errorf("hash password: %w", err) } userID := s.store.NextID() emailEncrypted, err := s.encryptEmail(userID, email) if err != nil { return nil, err } user, err := s.store.CreateUser(ctx, store.CreateUserParams{ ID: userID, Username: username, DisplayName: defaultDisplayName(in.DisplayName, username), EmailEnc: emailEncrypted, EmailIndex: s.masterKey.BlindIndex(email), PasswordHash: passwordHash, Locale: in.Locale, }) if err != nil { if errors.Is(err, store.ErrConflict) { if existing, lookupErr := s.store.GetUserByUsername(ctx, username); lookupErr == nil && existing != nil { return nil, ErrUsernameTaken } return nil, ErrEmailTaken } return nil, fmt.Errorf("create user: %w", err) } _ = s.store.RecordSecurityEvent(ctx, &user.ID, "created_by_operator", "", "", "") return user, nil } // IssueSessionForOperator выдаёт сессию по команде обслуживания: нужна для // нагрузочного теста (AGENT.md 11.4), где сотни клиентов не могут пройти вход // через /auth/login — лимит попыток по IP рассчитан на защиту от перебора. func (s *Service) IssueSessionForOperator(ctx context.Context, userID uint64, userAgent string) (string, error) { token, _, err := s.createSession(ctx, userID, userAgent, "") return token, err } // UserByEmail ищет аккаунт по email через blind index (без расшифровки БД). func (s *Service) UserByEmail(ctx context.Context, email string) (*store.User, error) { return s.userByEmail(ctx, email) } // SetPassword меняет пароль без проверки текущего: используется установщиком // и CLI-командой reset-password, где пароль задаёт администратор инстанса. func (s *Service) SetPassword(ctx context.Context, userID uint64, password string) error { if err := s.policy.validate(password); err != nil { return ErrWeakPassword } hash, err := s.hasher.Hash(password) if err != nil { return fmt.Errorf("hash password: %w", err) } if err := s.store.UpdateUserPassword(ctx, userID, hash); err != nil { return err } // Смена пароля администратором отзывает все сессии пользователя. if err := s.store.DeleteSessionsForUser(ctx, userID); err != nil { return err } return s.store.RecordSecurityEvent(ctx, &userID, "password_change", "", "", `{"by":"admin"}`) } // RegisterAdmin создаёт аккаунт администратора инстанса в обход флага // регистрации: bootstrap выполняется установщиком (AGENT.md 10.3). func (s *Service) RegisterAdmin(ctx context.Context, in RegisterInput) (*store.User, string, *store.Session, error) { username := strings.TrimSpace(in.Username) if err := crypto.ValidateUsername(username); err != nil { return nil, "", nil, ErrInvalidUsername } if err := s.policy.validate(in.Password); err != nil { return nil, "", nil, ErrWeakPassword } email := crypto.NormalizeEmail(in.Email) if err := validateEmail(email); err != nil { return nil, "", nil, err } passwordHash, err := s.hasher.Hash(in.Password) if err != nil { return nil, "", nil, fmt.Errorf("hash password: %w", err) } userID := s.store.NextID() encrypted, err := s.encryptEmail(userID, email) if err != nil { return nil, "", nil, err } user, err := s.store.CreateUser(ctx, store.CreateUserParams{ ID: userID, Username: username, DisplayName: defaultDisplayName(in.DisplayName, username), EmailEnc: encrypted, EmailIndex: s.masterKey.BlindIndex(email), PasswordHash: passwordHash, Locale: in.Locale, }) if err != nil { return nil, "", nil, err } if err := s.store.SetInstanceAdmin(ctx, user.ID, true); err != nil { return nil, "", nil, err } if err := s.store.SetUserBadges(ctx, user.ID, []string{"instance_admin"}); err != nil { s.logger.WarnContext(ctx, "failed to set admin badge", slog.Any("error", err)) } token, session, err := s.createSession(ctx, user.ID, in.UserAgent, in.IP) if err != nil { return nil, "", nil, err } _ = s.store.RecordSecurityEvent(ctx, &user.ID, "bootstrap_admin", in.IP, in.UserAgent, "") user, err = s.store.GetUser(ctx, user.ID) if err != nil { return nil, "", nil, err } return user, token, session, nil } type LoginInput struct { Email string Password string TOTPCode string IP string UserAgent string } // Login проверяет пароль, при включённом TOTP требует код, ротирует токен // сессии (защита от session fixation) и пишет событие безопасности. func (s *Service) Login(ctx context.Context, in LoginInput) (*store.User, string, *store.Session, error) { user, err := s.userByEmail(ctx, in.Email) if err != nil { // Одинаковый ответ для «нет пользователя» и «неверный пароль». return nil, "", nil, ErrInvalidCredentials } if err := s.checkLockout(ctx, user.ID); err != nil { return nil, "", nil, err } // Глобальный бан инстанса проверяется до пароля: причина отказа важнее // (AGENT.md 7.18). Бан инстанс-админа невозможен, поэтому порядок безопасен. 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", in.IP, in.UserAgent, "") return nil, "", nil, ErrUserBanned } if err := s.hasher.Verify(in.Password, user.PasswordHash); err != nil { _ = s.store.RecordSecurityEvent(ctx, &user.ID, "login_failed", in.IP, in.UserAgent, "") return nil, "", nil, ErrInvalidCredentials } totp, err := s.store.GetTOTPSecret(ctx, user.ID) switch { case err == nil && totp.Enabled: if in.TOTPCode == "" { return nil, "", nil, ErrTOTPRequired } if err := s.verifyTOTP(ctx, totp, in.TOTPCode); err != nil { _ = s.store.RecordSecurityEvent(ctx, &user.ID, "2fa_failed", in.IP, in.UserAgent, "") return nil, "", nil, err } case errors.Is(err, store.ErrNotFound) && user.IsInstanceAdmin: // Инстанс-админ обязан иметь 2FA (AGENT.md 7.19). return nil, "", nil, ErrInstanceAdminTOTP } token, session, err := s.createSession(ctx, user.ID, in.UserAgent, in.IP) if err != nil { return nil, "", nil, err } _ = s.store.RecordSecurityEvent(ctx, &user.ID, "login", in.IP, in.UserAgent, "") return user, token, session, nil } // ResolveSession проверяет токен: возвращает пользователя и сессию. func (s *Service) ResolveSession(ctx context.Context, token string) (*store.User, *store.Session, error) { if token == "" { return nil, nil, ErrSessionExpired } session, err := s.store.GetSessionByTokenHash(ctx, crypto.HashToken(token)) if err != nil { return nil, nil, ErrSessionExpired } if !session.ExpiresAt.After(time.Now().UTC()) { _ = s.store.DeleteSession(ctx, session.ID) return nil, nil, ErrSessionExpired } user, err := s.store.GetUser(ctx, session.UserID) if err != nil { return nil, nil, ErrSessionExpired } // Бан инстанса обрывает уже выданные сессии: сессию удаляем, чтобы клиент // не возвращался к ней повторно (AGENT.md 7.18). if user.BannedAt != nil { _ = s.store.DeleteSession(ctx, session.ID) return nil, nil, ErrUserBanned } _ = s.store.TouchSession(ctx, session.ID) return user, session, nil } // RotateSession выдаёт новый токен для существующей сессии (AGENT.md 7.1). func (s *Service) RotateSession(ctx context.Context, sessionID uint64) (string, error) { token, hash, err := crypto.NewSessionToken() if err != nil { return "", err } if err := s.store.RotateSession(ctx, sessionID, hash); err != nil { return "", err } return token, nil } func (s *Service) Logout(ctx context.Context, sessionID uint64) error { return s.store.DeleteSession(ctx, sessionID) } // LogoutAll отзывает все сессии пользователя («выйти везде»). func (s *Service) LogoutAll(ctx context.Context, userID uint64) error { return s.store.DeleteSessionsForUser(ctx, userID) } // RequireStepUp проверяет свежесть аутентификации для чувствительных действий. func (s *Service) RequireStepUp(ctx context.Context, user *store.User, session *store.Session, password, totpCode string) error { if session.SteppedUp(time.Now().UTC()) { return nil } if err := s.hasher.Verify(password, user.PasswordHash); err != nil { return ErrStepUpRequired } totp, err := s.store.GetTOTPSecret(ctx, user.ID) if err == nil && totp.Enabled { if totpCode == "" { return ErrTOTPRequired } if err := s.verifyTOTP(ctx, totp, totpCode); err != nil { return err } } if err := s.store.MarkSteppedUp(ctx, session.ID); err != nil { return err } // Обновляем копию сессии в памяти: повторная проверка в том же обработчике // не должна снова требовать step-up. if refreshed, err := s.store.GetSessionByTokenHash(ctx, session.TokenHash); err == nil { *session = *refreshed } return nil } // ChangePassword меняет пароль и отзывает все остальные сессии. func (s *Service) ChangePassword(ctx context.Context, userID uint64, currentSessionID uint64, currentPassword, newPassword string) error { user, err := s.store.GetUser(ctx, userID) if err != nil { return err } if err := s.hasher.Verify(currentPassword, user.PasswordHash); err != nil { return ErrInvalidCredentials } if err := s.policy.validate(newPassword); err != nil { return ErrWeakPassword } hash, err := s.hasher.Hash(newPassword) if err != nil { return fmt.Errorf("hash password: %w", err) } if err := s.store.UpdateUserPassword(ctx, userID, hash); err != nil { return err } // Смена пароля отзывает все сессии, кроме текущей (AGENT.md 7.1). if err := s.store.DeleteOtherSessions(ctx, userID, currentSessionID); err != nil { return err } _ = s.store.RecordSecurityEvent(ctx, &userID, "password_change", "", "", "") return nil } // Email возвращает расшифрованный email пользователя. func (s *Service) Email(ctx context.Context, userID uint64) (string, error) { encrypted, err := s.store.EncryptedEmail(ctx, userID) if err != nil { return "", err } payload, err := s.masterKey.Decrypt(encrypted) if err != nil { return "", fmt.Errorf("decrypt email: %w", err) } prefix := "u" + strconv.FormatUint(userID, 10) + ":" if !strings.HasPrefix(payload, prefix) { return "", fmt.Errorf("email payload is bound to another account") } return strings.TrimPrefix(payload, prefix), nil } func (s *Service) userByEmail(ctx context.Context, email string) (*store.User, error) { index := s.masterKey.BlindIndex(crypto.NormalizeEmail(email)) return s.store.GetUserByEmailIndex(ctx, index) } func (s *Service) createSession(ctx context.Context, userID uint64, userAgent, ip string) (string, *store.Session, error) { token, hash, err := crypto.NewSessionToken() if err != nil { return "", nil, err } session, err := s.store.CreateSession(ctx, userID, store.CreateSessionParams{ TokenHash: hash, UserAgent: truncate(userAgent, 256), IP: ip, TTL: s.cfg.SessionTTL, }) if err != nil { return "", nil, fmt.Errorf("create session: %w", err) } return token, session, nil } // checkLockout защищает от перебора: серия неудач блокирует попытки на окно. func (s *Service) checkLockout(ctx context.Context, userID uint64) error { events, err := s.store.ListSecurityEvents(ctx, userID, 20) if err != nil { // Без истории событий блокировку посчитать нельзя: безопаснее пропустить // проверку, чем запереть пользователя из-за сбоя чтения. s.logger.WarnContext(ctx, "cannot read security events for lockout check", slog.Uint64("user_id", userID), slog.Any("error", err)) return nil } failures := 0 cutoff := time.Now().UTC().Add(-s.cfg.LockoutWindow) for _, event := range events { if event.Type != "login_failed" { continue } if event.CreatedAt.Before(cutoff) { break } failures++ } if failures >= s.cfg.MaxLoginFails { return ErrInvalidCredentials } return nil } // PermissionsGuild вычисляет права пользователя на сервере (обёртка для API). func (s *Service) PermissionsGuild(resolved permissions.Resolved, permission permissions.Permission) error { if resolved.Has(permission) { return nil } return permissions.ErrDenied } func defaultDisplayName(displayName, username string) string { if strings.TrimSpace(displayName) == "" { return username } return truncate(displayName, 64) } func truncate(value string, limit int) string { if len(value) <= limit { return value } return value[:limit] } func validateEmail(email string) error { at := strings.LastIndex(email, "@") if at <= 0 || at == len(email)-1 || !strings.Contains(email[at+1:], ".") { return fmt.Errorf("auth.invalid_email") } if len(email) > 254 { return fmt.Errorf("auth.invalid_email") } return nil } // encryptEmail привязывает шифротекст к идентификатору пользователя: // скопированное значение не расшифруется под другим аккаунтом. func (s *Service) encryptEmail(userID uint64, email string) (string, error) { payload := "u" + strconv.FormatUint(userID, 10) + ":" + email return s.masterKey.Encrypt(payload) }