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,322 @@
|
||||
// Package authtest — тестовые реализации зависимостей auth: хранилище в памяти, часы, лимитер.
|
||||
package authtest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"pipu/api/internal/auth"
|
||||
)
|
||||
|
||||
// MemStore — auth.Store в памяти. WithTx откатывает изменения, если fn вернула ошибку.
|
||||
type MemStore struct {
|
||||
mu *sync.Mutex
|
||||
inTx bool
|
||||
Users map[uuid.UUID]auth.User
|
||||
Tokens map[uuid.UUID]auth.RefreshToken
|
||||
Audit []auth.AuditEntry
|
||||
Settings map[string]any
|
||||
}
|
||||
|
||||
// NewMemStore создаёт пустое хранилище.
|
||||
func NewMemStore() *MemStore {
|
||||
return &MemStore{
|
||||
mu: &sync.Mutex{},
|
||||
Users: map[uuid.UUID]auth.User{},
|
||||
Tokens: map[uuid.UUID]auth.RefreshToken{},
|
||||
Settings: map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MemStore) lock() func() {
|
||||
if s.inTx {
|
||||
return func() {}
|
||||
}
|
||||
s.mu.Lock()
|
||||
return s.mu.Unlock
|
||||
}
|
||||
|
||||
// WithTx реализует auth.Store.
|
||||
func (s *MemStore) WithTx(_ context.Context, fn func(auth.Store) error) error {
|
||||
unlock := s.lock()
|
||||
defer unlock()
|
||||
users, tokens, audit, settings := maps.Clone(s.Users), maps.Clone(s.Tokens), slices.Clone(s.Audit), maps.Clone(s.Settings)
|
||||
tx := &MemStore{mu: s.mu, inTx: true, Users: s.Users, Tokens: s.Tokens, Audit: s.Audit, Settings: s.Settings}
|
||||
if err := fn(tx); err != nil {
|
||||
s.Users, s.Tokens, s.Audit, s.Settings = users, tokens, audit, settings
|
||||
return err
|
||||
}
|
||||
s.Users, s.Tokens, s.Audit, s.Settings = tx.Users, tx.Tokens, tx.Audit, tx.Settings
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountAdmins реализует auth.Store.
|
||||
func (s *MemStore) CountAdmins(context.Context) (int, error) {
|
||||
defer s.lock()()
|
||||
n := 0
|
||||
for _, u := range s.Users {
|
||||
if u.Role == auth.RoleAdmin && !u.IsBlocked {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *MemStore) usernameTaken(username string, except uuid.UUID) bool {
|
||||
for _, u := range s.Users {
|
||||
if u.ID != except && strings.EqualFold(u.Username, username) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CreateUser реализует auth.Store.
|
||||
func (s *MemStore) CreateUser(_ context.Context, u auth.User, _ *uuid.UUID) error {
|
||||
defer s.lock()()
|
||||
if s.usernameTaken(u.Username, uuid.Nil) {
|
||||
return auth.ErrUsernameTaken
|
||||
}
|
||||
s.Users[u.ID] = cloneUser(u)
|
||||
return nil
|
||||
}
|
||||
|
||||
// cloneUser копирует ui_prefs через JSON, чтобы тест не делил указатели с хранилищем.
|
||||
func cloneUser(u auth.User) auth.User {
|
||||
raw, _ := json.Marshal(u.UIPrefs)
|
||||
u.UIPrefs = auth.UIPrefs{}
|
||||
_ = json.Unmarshal(raw, &u.UIPrefs)
|
||||
return u
|
||||
}
|
||||
|
||||
// GetUserByID реализует auth.Store.
|
||||
func (s *MemStore) GetUserByID(_ context.Context, id uuid.UUID) (auth.User, error) {
|
||||
defer s.lock()()
|
||||
u, ok := s.Users[id]
|
||||
if !ok {
|
||||
return auth.User{}, auth.ErrNotFound
|
||||
}
|
||||
return cloneUser(u), nil
|
||||
}
|
||||
|
||||
// GetUserByUsername реализует auth.Store.
|
||||
func (s *MemStore) GetUserByUsername(_ context.Context, username string) (auth.User, error) {
|
||||
defer s.lock()()
|
||||
for _, u := range s.Users {
|
||||
if strings.EqualFold(u.Username, username) {
|
||||
return cloneUser(u), nil
|
||||
}
|
||||
}
|
||||
return auth.User{}, auth.ErrNotFound
|
||||
}
|
||||
|
||||
func (s *MemStore) update(id uuid.UUID, fn func(*auth.User)) error {
|
||||
defer s.lock()()
|
||||
u, ok := s.Users[id]
|
||||
if !ok {
|
||||
return auth.ErrNotFound
|
||||
}
|
||||
fn(&u)
|
||||
s.Users[id] = u
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordLoginFailure реализует auth.Store.
|
||||
func (s *MemStore) RecordLoginFailure(_ context.Context, id uuid.UUID, failed int, lockedUntil *time.Time) error {
|
||||
return s.update(id, func(u *auth.User) { u.FailedLoginCount, u.LockedUntil = failed, lockedUntil })
|
||||
}
|
||||
|
||||
// RecordLoginSuccess реализует auth.Store.
|
||||
func (s *MemStore) RecordLoginSuccess(_ context.Context, id uuid.UUID, at time.Time, newHash *string) error {
|
||||
return s.update(id, func(u *auth.User) {
|
||||
u.FailedLoginCount, u.LockedUntil, u.LastLoginAt = 0, nil, &at
|
||||
if newHash != nil {
|
||||
u.PasswordHash = *newHash
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// SetPassword реализует auth.Store.
|
||||
func (s *MemStore) SetPassword(_ context.Context, id uuid.UUID, hash string) error {
|
||||
return s.update(id, func(u *auth.User) {
|
||||
u.PasswordHash, u.MustChangePassword = hash, false
|
||||
u.TokenVersion++
|
||||
})
|
||||
}
|
||||
|
||||
// SetCredentials реализует auth.Store.
|
||||
func (s *MemStore) SetCredentials(_ context.Context, id uuid.UUID, username, hash string) error {
|
||||
unlock := s.lock()
|
||||
taken := s.usernameTaken(username, id)
|
||||
unlock()
|
||||
if taken {
|
||||
return auth.ErrUsernameTaken
|
||||
}
|
||||
return s.update(id, func(u *auth.User) {
|
||||
u.Username, u.PasswordHash = username, hash
|
||||
u.MustChangeCredentials, u.MustChangePassword = false, false
|
||||
u.TokenVersion++
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateProfile реализует auth.Store.
|
||||
func (s *MemStore) UpdateProfile(ctx context.Context, id uuid.UUID, upd auth.ProfileUpdate) (auth.User, error) {
|
||||
err := s.update(id, func(u *auth.User) {
|
||||
if upd.DisplayName != nil {
|
||||
u.DisplayName = *upd.DisplayName
|
||||
}
|
||||
if upd.Locale != nil {
|
||||
u.Locale = *upd.Locale
|
||||
}
|
||||
if upd.UIPrefs != nil {
|
||||
u.UIPrefs = *upd.UIPrefs
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return auth.User{}, err
|
||||
}
|
||||
return s.GetUserByID(ctx, id)
|
||||
}
|
||||
|
||||
// InsertRefreshToken реализует auth.Store.
|
||||
func (s *MemStore) InsertRefreshToken(_ context.Context, t auth.RefreshToken) error {
|
||||
defer s.lock()()
|
||||
s.Tokens[t.ID] = t
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRefreshTokenForUpdate реализует auth.Store.
|
||||
func (s *MemStore) GetRefreshTokenForUpdate(_ context.Context, hash []byte) (auth.RefreshToken, error) {
|
||||
defer s.lock()()
|
||||
for _, t := range s.Tokens {
|
||||
if bytes.Equal(t.TokenHash, hash) {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return auth.RefreshToken{}, auth.ErrNotFound
|
||||
}
|
||||
|
||||
// MarkRefreshTokenUsed реализует auth.Store.
|
||||
func (s *MemStore) MarkRefreshTokenUsed(_ context.Context, id uuid.UUID, at time.Time) error {
|
||||
defer s.lock()()
|
||||
t := s.Tokens[id]
|
||||
t.UsedAt = &at
|
||||
s.Tokens[id] = t
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MemStore) revoke(at time.Time, match func(auth.RefreshToken) bool) {
|
||||
defer s.lock()()
|
||||
for id, t := range s.Tokens {
|
||||
if t.RevokedAt == nil && match(t) {
|
||||
t.RevokedAt = &at
|
||||
s.Tokens[id] = t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RevokeFamily реализует auth.Store.
|
||||
func (s *MemStore) RevokeFamily(_ context.Context, familyID uuid.UUID, at time.Time) error {
|
||||
s.revoke(at, func(t auth.RefreshToken) bool { return t.FamilyID == familyID })
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeUserSessions реализует auth.Store.
|
||||
func (s *MemStore) RevokeUserSessions(_ context.Context, userID uuid.UUID, at time.Time) error {
|
||||
s.revoke(at, func(t auth.RefreshToken) bool { return t.UserID == userID })
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListSessions реализует auth.Store.
|
||||
func (s *MemStore) ListSessions(_ context.Context, userID uuid.UUID, now time.Time) ([]auth.Session, error) {
|
||||
defer s.lock()()
|
||||
var out []auth.Session
|
||||
for _, t := range s.Tokens {
|
||||
if t.UserID == userID && t.UsedAt == nil && t.RevokedAt == nil && t.ExpiresAt.After(now) {
|
||||
out = append(out, auth.Session{
|
||||
ID: t.FamilyID, DeviceName: t.DeviceName, ClientType: t.ClientType,
|
||||
CreatedAt: t.FamilyStartedAt, LastUsedAt: t.CreatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b auth.Session) int { return b.LastUsedAt.Compare(a.LastUsedAt) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// InsertAudit реализует auth.Store.
|
||||
func (s *MemStore) InsertAudit(_ context.Context, _ uuid.UUID, e auth.AuditEntry) error {
|
||||
defer s.lock()()
|
||||
s.Audit = append(s.Audit, e)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSettingBool реализует auth.Store.
|
||||
func (s *MemStore) GetSettingBool(_ context.Context, key string) (bool, error) {
|
||||
defer s.lock()()
|
||||
v, _ := s.Settings[key].(bool)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// SetSetting реализует auth.Store.
|
||||
func (s *MemStore) SetSetting(_ context.Context, key string, value any) error {
|
||||
defer s.lock()()
|
||||
s.Settings[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ auth.Store = (*MemStore)(nil)
|
||||
|
||||
// Clock — управляемые часы.
|
||||
type Clock struct {
|
||||
mu sync.Mutex
|
||||
T time.Time
|
||||
}
|
||||
|
||||
// Now реализует auth.Clock.
|
||||
func (c *Clock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.T
|
||||
}
|
||||
|
||||
// Advance сдвигает часы вперёд.
|
||||
func (c *Clock) Advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.T = c.T.Add(d)
|
||||
}
|
||||
|
||||
// Limiter — счётчик попыток в памяти без учёта окна (окно сбрасывает Reset).
|
||||
type Limiter struct {
|
||||
mu sync.Mutex
|
||||
counts map[string]int
|
||||
}
|
||||
|
||||
// Allow реализует auth.Limiter.
|
||||
func (l *Limiter) Allow(_ context.Context, key string, limit int, window time.Duration) (bool, int, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.counts == nil {
|
||||
l.counts = map[string]int{}
|
||||
}
|
||||
l.counts[key]++
|
||||
if l.counts[key] > limit {
|
||||
return false, int(window / time.Second), nil
|
||||
}
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
// Reset обнуляет счётчики (как истечение окна).
|
||||
func (l *Limiter) Reset() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.counts = nil
|
||||
}
|
||||
Reference in new issue
Block a user