feat(api): sign-in, refresh rotation and bootstrap admin (M1)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m15s
ci / api / api (push) Successful in 1m31s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 39s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 26s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m15s
ci / api / api (push) Successful in 1m31s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 39s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 26s
- users, refresh_tokens and audit_log schema (migration 00002) - argon2id passwords with rehash on login, password policy, lockout with exponential back-off and a per-IP rate limit in Redis - Ed25519 JWT access tokens with kid-based key rotation; rotating refresh tokens with reuse detection (cookie for web, body for Android) - bootstrap admin on first start; must_change_credentials and must_change_password gate every other endpoint - /auth/login, /auth/refresh, /auth/logout(-all), /me, /me/password, /me/bootstrap-credentials, /me/sessions; server-info reports bootstrap_required - role policy from docs/auth-and-roles.md with table tests - problem+json errors with field errors; ru/en translations of the codes - install.sh and update.sh generate JWT_PRIVATE_KEY Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b6def39e29
commit
ed4a492dba
37 files changed
+5826
-49
No files matched your search
@@ -0,0 +1,647 @@
|
||||
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,
|
||||
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",
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user