package auth import ( "context" "errors" "fmt" "log/slog" "strings" "time" "unicode/utf8" "github.com/google/uuid" "pipu/api/internal/platform/apperr" ) // Коды ошибок аутентификации. const ( CodeInvalidCredentials = "auth.invalid_credentials" CodeTooManyAttempts = "auth.too_many_attempts" CodeUserBlocked = "auth.user_blocked" CodeUnauthorized = "auth.unauthorized" CodeTokenExpired = "auth.token_expired" CodeTokenInvalid = "auth.token_invalid" CodeTokenRevoked = "auth.token_revoked" CodeRefreshInvalid = "auth.refresh_invalid" CodeRefreshReused = "auth.refresh_reused" CodeRefreshRace = "auth.refresh_race" CodeCredentialsChangeRequired = "auth.credentials_change_required" CodePasswordChangeRequired = "auth.password_change_required" CodeCurrentPasswordRequired = "auth.current_password_required" CodeCurrentPasswordInvalid = "auth.current_password_invalid" CodeBootstrapNotRequired = "auth.bootstrap_not_required" CodeSessionNotFound = "auth.session_not_found" CodeUsernameTaken = "validation.username_taken" ) // Ключ app_settings: первый вход администратора завершён. const settingBootstrapCompleted = "bootstrap.completed" // Config — параметры сервиса. type Config struct { RefreshTTL time.Duration // скользящий срок refresh-токена RefreshMaxAge time.Duration // абсолютный срок семьи токенов // RefreshRaceWindow — сколько после ротации повтор старого токена считается гонкой // (две вкладки обновили токен одновременно), а не кражей. RefreshRaceWindow time.Duration LockThreshold int // неудачных входов до блокировки LockBase time.Duration // первая блокировка, дальше удваивается LockMax time.Duration LoginLimitPerUser int // попыток в минуту на IP + логин LoginLimitPerIP int // попыток в минуту на IP BootstrapUsername string BootstrapPassword string // пусто — сгенерировать SupportedLocales []string } // DefaultConfig — значения из docs/auth-and-roles.md. func DefaultConfig() Config { return Config{ RefreshTTL: 30 * 24 * time.Hour, RefreshMaxAge: 180 * 24 * time.Hour, RefreshRaceWindow: 10 * time.Second, LockThreshold: 5, LockBase: 5 * time.Minute, LockMax: time.Hour, LoginLimitPerUser: 5, LoginLimitPerIP: 30, BootstrapUsername: "admin", SupportedLocales: []string{"ru", "en"}, } } // Service — бизнес-логика входа, токенов и профиля. type Service struct { store Store hasher *Hasher tokens *TokenIssuer limiter Limiter clock Clock ids IDGen log *slog.Logger cfg Config // dummyHash проверяется для несуществующих логинов, чтобы время ответа не выдавало их отсутствие. dummyHash string } // NewService создаёт сервис. func NewService(store Store, hasher *Hasher, tokens *TokenIssuer, limiter Limiter, clock Clock, ids IDGen, log *slog.Logger, cfg Config, ) (*Service, error) { if log == nil { log = slog.New(slog.DiscardHandler) } dummy, err := hasher.Hash("pipu-dummy-password") if err != nil { return nil, err } return &Service{ store: store, hasher: hasher, tokens: tokens, limiter: limiter, clock: clock, ids: ids, log: log, cfg: cfg, dummyHash: dummy, }, nil } // Tokens — результат входа или ротации. type Tokens struct { Access string ExpiresIn time.Duration Refresh string ClientType ClientType User User } // LoginInput — данные формы входа. type LoginInput struct { Username string Password string ClientType ClientType DeviceName string IP string } func invalidCredentials() error { return apperr.Unauthorized(CodeInvalidCredentials, "invalid username or password") } // Login проверяет логин и пароль и открывает новую сессию. func (s *Service) Login(ctx context.Context, in LoginInput) (Tokens, error) { if !in.ClientType.Valid() { return Tokens{}, apperr.Validation("client_type", "validation.client_type_invalid", "unknown client type", nil) } username := NormalizeUsername(in.Username) if err := s.checkRateLimit(ctx, in.IP, username); err != nil { return Tokens{}, err } if len(in.Password) > MaxPassword*4 { return Tokens{}, invalidCredentials() } u, err := s.store.GetUserByUsername(ctx, username) if errors.Is(err, ErrNotFound) { _, _, _ = s.hasher.Verify(in.Password, s.dummyHash) return Tokens{}, invalidCredentials() } if err != nil { return Tokens{}, err } now := s.clock.Now() if u.LockedUntil != nil && now.Before(*u.LockedUntil) { _, _, _ = s.hasher.Verify(in.Password, s.dummyHash) return Tokens{}, invalidCredentials() } ok, rehash, err := s.hasher.Verify(in.Password, u.PasswordHash) if err != nil { return Tokens{}, err } if !ok { failed := u.FailedLoginCount + 1 var lockedUntil *time.Time if failed >= s.cfg.LockThreshold { until := now.Add(s.lockDuration(failed)) lockedUntil = &until } if err := s.store.RecordLoginFailure(ctx, u.ID, failed, lockedUntil); err != nil { return Tokens{}, err } return Tokens{}, invalidCredentials() } if u.IsBlocked { return Tokens{}, apperr.Forbidden(CodeUserBlocked, "user is blocked") } var newHash *string if rehash { h, err := s.hasher.Hash(in.Password) if err != nil { return Tokens{}, err } newHash = &h } var out Tokens err = s.store.WithTx(ctx, func(tx Store) error { if err := tx.RecordLoginSuccess(ctx, u.ID, now, newHash); err != nil { return err } out, err = s.openSession(ctx, tx, u, in.ClientType, in.DeviceName) return err }) return out, err } // lockDuration — блокировка после failed неудачных попыток: LockBase, затем вдвое больше за каждую // следующую неудачу, но не более LockMax. func (s *Service) lockDuration(failed int) time.Duration { d := s.cfg.LockBase for i := s.cfg.LockThreshold; i < failed && d < s.cfg.LockMax; i++ { d *= 2 } return min(d, s.cfg.LockMax) } func (s *Service) checkRateLimit(ctx context.Context, ip, username string) error { if s.limiter == nil { return nil } checks := []struct { key string limit int }{ {"login:ip:" + ip, s.cfg.LoginLimitPerIP}, {"login:user:" + ip + ":" + username, s.cfg.LoginLimitPerUser}, } for _, c := range checks { ok, retry, err := s.limiter.Allow(ctx, c.key, c.limit, time.Minute) if err != nil { // Redis недоступен — не запираем всех снаружи: остаётся блокировка учётки по счётчику в БД. s.log.WarnContext(ctx, "login rate limiter unavailable", "err", err) return nil } if !ok { return apperr.TooManyRequests(CodeTooManyAttempts, retry) } } return nil } // openSession создаёт новую семью refresh-токенов и выдаёт пару. func (s *Service) openSession(ctx context.Context, st Store, u User, ct ClientType, device string) (Tokens, error) { now := s.clock.Now() return s.issue(ctx, st, u, RefreshToken{ FamilyID: s.ids.New(), FamilyStartedAt: now, DeviceName: deviceName(device), ClientType: ct, }) } // issue записывает новый refresh-токен семьи prev и подписывает access-токен. func (s *Service) issue(ctx context.Context, st Store, u User, prev RefreshToken) (Tokens, error) { now := s.clock.Now() refresh, hash, err := NewRefreshToken() if err != nil { return Tokens{}, err } expires := now.Add(s.cfg.RefreshTTL) if hardLimit := prev.FamilyStartedAt.Add(s.cfg.RefreshMaxAge); expires.After(hardLimit) { expires = hardLimit } rt := RefreshToken{ ID: s.ids.New(), UserID: u.ID, FamilyID: prev.FamilyID, FamilyStartedAt: prev.FamilyStartedAt, TokenHash: hash, DeviceName: prev.DeviceName, ClientType: prev.ClientType, CreatedAt: now, ExpiresAt: expires, } if err := st.InsertRefreshToken(ctx, rt); err != nil { return Tokens{}, err } access, err := s.tokens.Issue(u.ID, u.Role, rt.FamilyID, u.TokenVersion) if err != nil { return Tokens{}, err } return Tokens{Access: access, ExpiresIn: s.tokens.TTL(), Refresh: refresh, ClientType: rt.ClientType, User: u}, nil } func deviceName(s string) *string { s = strings.TrimSpace(s) if s == "" { return nil } if utf8.RuneCountInString(s) > 100 { s = string([]rune(s)[:100]) } return &s } // Refresh ротирует refresh-токен: старый помечается использованным, выдаётся новый в той же семье. func (s *Service) Refresh(ctx context.Context, token string) (Tokens, error) { if token == "" { return Tokens{}, apperr.Unauthorized(CodeRefreshInvalid, "refresh token missing") } now := s.clock.Now() var ( out Tokens reused bool familyID uuid.UUID ) err := s.store.WithTx(ctx, func(tx Store) error { rt, err := tx.GetRefreshTokenForUpdate(ctx, HashRefreshToken(token)) if errors.Is(err, ErrNotFound) { return apperr.Unauthorized(CodeRefreshInvalid, "unknown refresh token") } if err != nil { return err } if rt.RevokedAt != nil { return apperr.Unauthorized(CodeRefreshInvalid, "refresh token revoked") } if rt.UsedAt != nil { if now.Sub(*rt.UsedAt) <= s.cfg.RefreshRaceWindow { return apperr.Unauthorized(CodeRefreshRace, "refresh token was just rotated") } reused, familyID = true, rt.FamilyID return nil } if !now.Before(rt.ExpiresAt) { return apperr.Unauthorized(CodeRefreshInvalid, "refresh token expired") } u, err := tx.GetUserByID(ctx, rt.UserID) if err != nil { return err } if u.IsBlocked { return apperr.Forbidden(CodeUserBlocked, "user is blocked") } if err := tx.MarkRefreshTokenUsed(ctx, rt.ID, now); err != nil { return err } out, err = s.issue(ctx, tx, u, rt) return err }) if err != nil { return Tokens{}, err } if reused { // Токен уже обменяли — его украли или скопировали. Отзываем всю семью вне транзакции чтения. s.log.WarnContext(ctx, "refresh token reuse detected, family revoked", "family_id", familyID) if err := s.store.RevokeFamily(ctx, familyID, now); err != nil { return Tokens{}, err } return Tokens{}, apperr.Unauthorized(CodeRefreshReused, "refresh token reuse") } return out, nil } // Authenticate проверяет access-токен и актуальное состояние пользователя. func (s *Service) Authenticate(ctx context.Context, access string) (Principal, error) { claims, err := s.tokens.Parse(access) switch { case errors.Is(err, ErrTokenExpired): return Principal{}, apperr.Unauthorized(CodeTokenExpired, "access token expired") case err != nil: return Principal{}, apperr.Unauthorized(CodeTokenInvalid, "access token invalid") } u, err := s.store.GetUserByID(ctx, claims.UserID) if errors.Is(err, ErrNotFound) { return Principal{}, apperr.Unauthorized(CodeTokenRevoked, "user not found") } if err != nil { return Principal{}, err } if u.IsBlocked { return Principal{}, apperr.Unauthorized(CodeUserBlocked, "user is blocked") } if u.TokenVersion != claims.TokenVersion { return Principal{}, apperr.Unauthorized(CodeTokenRevoked, "token version changed") } return Principal{ UserID: u.ID, Role: u.Role, ReviewsUploads: u.ReviewsUploads, SessionID: claims.SessionID, MustChangePassword: u.MustChangePassword, MustChangeCredentials: u.MustChangeCredentials, }, nil } // Logout отзывает текущую сессию. func (s *Service) Logout(ctx context.Context, p Principal) error { return s.store.RevokeFamily(ctx, p.SessionID, s.clock.Now()) } // LogoutAll отзывает все сессии пользователя. func (s *Service) LogoutAll(ctx context.Context, p Principal) error { return s.store.RevokeUserSessions(ctx, p.UserID, s.clock.Now()) } // Me возвращает профиль текущего пользователя. func (s *Service) Me(ctx context.Context, p Principal) (User, error) { return s.store.GetUserByID(ctx, p.UserID) } // MePatch — изменения профиля из PATCH /me. nil — не менять; DisplayName "" — сбросить. type MePatch struct { DisplayName *string Locale *string UIPrefs *UIPrefs } // UpdateMe меняет имя, язык и настройки интерфейса. func (s *Service) UpdateMe(ctx context.Context, p Principal, patch MePatch) (User, error) { var upd ProfileUpdate if patch.DisplayName != nil { name := strings.TrimSpace(*patch.DisplayName) if utf8.RuneCountInString(name) > MaxDisplayName { return User{}, apperr.Validation("display_name", CodeDisplayNameTooLong, "display name too long", map[string]any{"max": MaxDisplayName}) } var v *string if name != "" { v = &name } upd.DisplayName = &v } if patch.Locale != nil { if !contains(s.cfg.SupportedLocales, *patch.Locale) { return User{}, apperr.Validation("locale", CodeLocaleUnsupported, "unsupported locale", map[string]any{"supported": s.cfg.SupportedLocales}) } upd.Locale = patch.Locale } if patch.UIPrefs != nil { if err := ValidateUIPrefs(*patch.UIPrefs); err != nil { return User{}, err } u, err := s.store.GetUserByID(ctx, p.UserID) if err != nil { return User{}, err } merged := MergeUIPrefs(u.UIPrefs, *patch.UIPrefs) upd.UIPrefs = &merged } return s.store.UpdateProfile(ctx, p.UserID, upd) } func contains(list []string, v string) bool { for _, x := range list { if x == v { return true } } return false } // ChangePassword меняет пароль, отзывает все сессии и открывает новую на текущем устройстве. // current не нужен, если пароль временный (must_change_password). func (s *Service) ChangePassword(ctx context.Context, p Principal, current *string, newPassword string) (Tokens, error) { u, err := s.store.GetUserByID(ctx, p.UserID) if err != nil { return Tokens{}, err } if u.MustChangeCredentials { return Tokens{}, apperr.Forbidden(CodeCredentialsChangeRequired, "use bootstrap credentials endpoint") } if !u.MustChangePassword { if current == nil || *current == "" { return Tokens{}, apperr.Validation("current_password", CodeCurrentPasswordRequired, "current password required", nil) } ok, _, err := s.hasher.Verify(*current, u.PasswordHash) if err != nil { return Tokens{}, err } if !ok { return Tokens{}, apperr.Validation("current_password", CodeCurrentPasswordInvalid, "current password is wrong", nil) } } if err := s.checkNewPassword(u, u.Username, newPassword); err != nil { return Tokens{}, err } hash, err := s.hasher.Hash(newPassword) if err != nil { return Tokens{}, err } return s.replaceSessions(ctx, p, u, func(tx Store) error { return tx.SetPassword(ctx, u.ID, hash) }, nil) } // checkNewPassword — политика паролей плюс запрет оставить прежний пароль. func (s *Service) checkNewPassword(u User, username, newPassword string) error { if err := ValidatePassword("new_password", newPassword, username, u.Role); err != nil { return err } same, _, err := s.hasher.Verify(newPassword, u.PasswordHash) if err != nil { return err } if same { return apperr.Validation("new_password", CodePasswordUnchanged, "new password equals current", nil) } return nil } // replaceSessions в одной транзакции применяет change, отзывает все сессии пользователя // и открывает новую с тем же устройством, что у текущей. func (s *Service) replaceSessions(ctx context.Context, p Principal, u User, change func(Store) error, audit *AuditEntry, ) (Tokens, error) { now := s.clock.Now() var out Tokens err := s.store.WithTx(ctx, func(tx Store) error { sessions, err := tx.ListSessions(ctx, u.ID, now) if err != nil { return err } ct, device := ClientWeb, "" for _, ss := range sessions { if ss.ID == p.SessionID { ct = ss.ClientType if ss.DeviceName != nil { device = *ss.DeviceName } } } if err := change(tx); err != nil { return err } if err := tx.RevokeUserSessions(ctx, u.ID, now); err != nil { return err } if audit != nil { if err := tx.InsertAudit(ctx, s.ids.New(), *audit); err != nil { return err } } // Перечитываем: change увеличил token_version, новый access-токен должен нести его. fresh, err := tx.GetUserByID(ctx, u.ID) if err != nil { return err } out, err = s.openSession(ctx, tx, fresh, ct, device) return err }) return out, err } // SetBootstrapCredentials — первый вход администратора: новые логин и пароль. func (s *Service) SetBootstrapCredentials(ctx context.Context, p Principal, newUsername, newPassword string) (Tokens, error) { u, err := s.store.GetUserByID(ctx, p.UserID) if err != nil { return Tokens{}, err } if !u.MustChangeCredentials { return Tokens{}, apperr.Conflict(CodeBootstrapNotRequired, "credentials already changed") } username := NormalizeUsername(newUsername) if err := ValidateUsername("new_username", username); err != nil { return Tokens{}, err } if err := s.checkNewPassword(u, username, newPassword); err != nil { return Tokens{}, err } hash, err := s.hasher.Hash(newPassword) if err != nil { return Tokens{}, err } audit := &AuditEntry{ ActorID: &u.ID, Action: "user.bootstrap_credentials", TargetType: "user", TargetID: &u.ID, Payload: map[string]any{"username": username}, } out, err := s.replaceSessions(ctx, p, u, func(tx Store) error { if err := tx.SetCredentials(ctx, u.ID, username, hash); err != nil { return err } return tx.SetSetting(ctx, settingBootstrapCompleted, true) }, audit) if errors.Is(err, ErrUsernameTaken) { return Tokens{}, apperr.Validation("new_username", CodeUsernameTaken, "username is taken", nil) } return out, err } // ListSessions — активные сессии пользователя, новые сверху. func (s *Service) ListSessions(ctx context.Context, p Principal) ([]Session, error) { return s.store.ListSessions(ctx, p.UserID, s.clock.Now()) } // RevokeSession завершает одну свою сессию. func (s *Service) RevokeSession(ctx context.Context, p Principal, id uuid.UUID) error { now := s.clock.Now() sessions, err := s.store.ListSessions(ctx, p.UserID, now) if err != nil { return err } for _, ss := range sessions { if ss.ID == id { return s.store.RevokeFamily(ctx, id, now) } } return apperr.NotFound(CodeSessionNotFound, "session not found") } // BootstrapRequired — первый вход администратора ещё не завершён. func (s *Service) BootstrapRequired(ctx context.Context) (bool, error) { done, err := s.store.GetSettingBool(ctx, settingBootstrapCompleted) return !done, err } // Bootstrap создаёт первого администратора, если его нет и первый запуск не завершён. // Сгенерированный пароль печатается в лог один раз. func (s *Service) Bootstrap(ctx context.Context) error { done, err := s.store.GetSettingBool(ctx, settingBootstrapCompleted) if err != nil || done { return err } admins, err := s.store.CountAdmins(ctx) if err != nil || admins > 0 { return err } username := NormalizeUsername(s.cfg.BootstrapUsername) if err := ValidateUsername("BOOTSTRAP_ADMIN_USERNAME", username); err != nil { return fmt.Errorf("auth: bootstrap: %w", err) } password, generated := s.cfg.BootstrapPassword, false if password == "" { if password, err = GeneratePassword(20); err != nil { return err } generated = true } hash, err := s.hasher.Hash(password) if err != nil { return err } u := User{ ID: s.ids.New(), Username: username, PasswordHash: hash, Role: RoleAdmin, Locale: "ru", ReviewsUploads: true, MustChangeCredentials: true, CreatedAt: s.clock.Now(), } err = s.store.WithTx(ctx, func(tx Store) error { if err := tx.CreateUser(ctx, u, nil); err != nil { return err } return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{ Action: "user.bootstrap_create", TargetType: "user", TargetID: &u.ID, Payload: map[string]any{"username": username}, }) }) if err != nil { return fmt.Errorf("auth: bootstrap: %w", err) } if generated { // Единственное место, где пароль попадает в лог: админ увидит его в `docker compose logs api`. s.log.Warn("bootstrap admin created: sign in and change the credentials", "username", username, "password", password) } else { s.log.Warn("bootstrap admin created with BOOTSTRAP_ADMIN_PASSWORD: sign in and change the credentials", "username", username) } return nil }