Files
pipu-music/services/api/internal/auth/service.go
T
mrleo1nidandClaude Opus 5.5 74da457362 feat(auth): make upload review a separate grant instead of an admin privilege
Reviewing the upload queue is now a per-user flag (reviews_uploads), not part
of the admin role: several people can hold it, a moderator can have it, and an
admin may not. Only admins grant or revoke it; existing admins keep it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:07:05 +03:00

649 lines
21 KiB
Go

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
}