ci / api / api (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 4m6s
ci / db / db (push) Successful in 1m15s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m19s
ci / ts / ts (packages + web) (push) Successful in 2m34s
release / desktop (Linux и Windows в релиз) (push) Successful in 14m27s
release / prune (старые файлы и прогоны) (push) Successful in 6s
650 lines
21 KiB
Go
650 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,
|
|
ManagesUpdates: u.ManagesUpdates || u.Role == RoleAdmin,
|
|
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
|
|
}
|