feat(auth): сервис аккаунтов и аутентификации Фазы 1
- регистрация: политика пароля (≥10 + локальный словарь 10k утечек, SecLists MIT), username и email без учёта регистра, шифрование email с привязкой к аккаунту, blind index для поиска, автовступление в главный сервер с ролью @user - вход: Argon2id+pepper, TOTP при включённом 2FA, блокировка после серии неудач, события безопасности; единый ответ на неверный пароль и неизвестный аккаунт - сессии: opaque-токены (в БД только SHA-256), ротация токена, logout/logout-all, отзыв остальных сессий при смене пароля, step-up с окном 10 минут - 2FA: настройка секрета с QR-URL, подтверждение кодом, 8 резервных кодов (хранятся хэшами), обязательность для инстанс-админов, запрет отключения - тесты на реальной SQLite: регистрация/вход/дубликаты, слабые и утёкшие пароли, ротация и logout-all, TOTP и резервные коды, step-up, смена пароля, выключенная регистрация, отсутствие секретов в логах
This commit is contained in:
@@ -107,6 +107,11 @@ linters:
|
||||
- gosec
|
||||
- errcheck
|
||||
- forbidigo
|
||||
# В тестах пропуски результатов и вспомогательные
|
||||
# переменные мешают читаемости сильнее, чем помогают.
|
||||
- dogsled
|
||||
- prealloc
|
||||
- unparam
|
||||
# store: идентификаторы Snowflake заведомо < MaxInt64 (преобразования
|
||||
# документированы в idToInt/intToID), SQL собирается из константных
|
||||
# фрагментов шаблона, а значения всегда передаются параметрами.
|
||||
|
||||
@@ -41,6 +41,12 @@ Copyright (C) 2026 glchat contributors.
|
||||
`web/package.json`/`web/package-lock.json`. Совместимость лицензий с AGPL-3.0
|
||||
проверяется при добавлении каждой зависимости (AGENT.md §15).
|
||||
|
||||
## Данные
|
||||
|
||||
| Файл | Источник | Лицензия |
|
||||
|---|---|---|
|
||||
| `internal/auth/data/leaked-passwords.txt` | [SecLists](https://github.com/danielmiessler/SecLists) `Passwords/Common-Credentials/10k-most-common.txt` | MIT |
|
||||
|
||||
## Ассеты
|
||||
|
||||
Встроенные звуки, иконки и изображения — только CC0, собственные или
|
||||
|
||||
@@ -4,11 +4,13 @@ go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/mattn/go-sqlite3 v1.14.52
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/pressly/goose/v3 v3.28.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/sethvargo/go-retry v0.4.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -10,12 +13,17 @@ github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6B
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/pressly/goose/v3 v3.28.0 h1:D2M+iL31GmpZxSHOhX8mqyqAT3CXnokUmm0eKoSP+Vc=
|
||||
github.com/pressly/goose/v3 v3.28.0/go.mod h1:v26MOuB8bL3kzzrt3Vqhb3R0PRVsl8hFQKdrht/L6Rk=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw=
|
||||
github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"embed"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"glchat/internal/crypto"
|
||||
)
|
||||
|
||||
//go:embed data/leaked-passwords.txt
|
||||
var leakedPasswordsFS embed.FS
|
||||
|
||||
// passwordPolicy проверяет пароль по локальному словарю утечек: внешние API
|
||||
// запрещены (AGENT.md 7.1).
|
||||
type passwordPolicy struct {
|
||||
leaked map[string]struct{}
|
||||
}
|
||||
|
||||
func newPasswordPolicy() (*passwordPolicy, error) {
|
||||
file, err := leakedPasswordsFS.Open("data/leaked-passwords.txt")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open leaked password list: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
leaked := make(map[string]struct{}, 10000)
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
word := strings.TrimSpace(scanner.Text())
|
||||
if word == "" {
|
||||
continue
|
||||
}
|
||||
leaked[strings.ToLower(word)] = struct{}{}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read leaked password list: %w", err)
|
||||
}
|
||||
return &passwordPolicy{leaked: leaked}, nil
|
||||
}
|
||||
|
||||
// validate проверяет длину и отсутствие пароля в словаре утечек.
|
||||
func (p *passwordPolicy) validate(password string) error {
|
||||
if err := crypto.ValidatePassword(password); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, found := p.leaked[strings.ToLower(password)]; found {
|
||||
return fmt.Errorf("password occurs in the leaked password list")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LeakedCount возвращает размер словаря (для диагностики и метрик).
|
||||
func (p *passwordPolicy) LeakedCount() int { return len(p.leaked) }
|
||||
@@ -0,0 +1,485 @@
|
||||
// Package auth реализует аккаунты, аутентификацию и сессии (AGENT.md 7.1):
|
||||
// пароли Argon2id с pepper, шифрование PII, blind index по email, ротация
|
||||
// сессий, TOTP и step-up.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"glchat/internal/config"
|
||||
"glchat/internal/crypto"
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("auth.invalid_credentials")
|
||||
ErrEmailTaken = errors.New("auth.email_taken")
|
||||
ErrUsernameTaken = errors.New("auth.username_taken")
|
||||
ErrRegistrationOff = errors.New("auth.registration_disabled")
|
||||
ErrTOTPRequired = errors.New("auth.2fa_required")
|
||||
ErrTOTPInvalid = errors.New("auth.totp_invalid")
|
||||
ErrSessionExpired = errors.New("auth.session_expired")
|
||||
ErrStepUpRequired = errors.New("auth.step_up_required")
|
||||
ErrWeakPassword = errors.New("auth.weak_password")
|
||||
ErrInvalidUsername = errors.New("auth.invalid_username")
|
||||
ErrInstanceAdminTOTP = errors.New("auth.instance_admin_requires_2fa")
|
||||
ErrNoTOTPSecret = errors.New("auth.totp_not_configured")
|
||||
ErrTOTPAlreadyEnabled = errors.New("auth.totp_already_enabled")
|
||||
)
|
||||
|
||||
// Config — параметры сервиса аутентификации.
|
||||
type Config struct {
|
||||
SessionTTL time.Duration
|
||||
MaxLoginFails int
|
||||
LockoutWindow time.Duration
|
||||
}
|
||||
|
||||
// Service — операции с аккаунтами и сессиями.
|
||||
type Service struct {
|
||||
store *store.Store
|
||||
hasher *crypto.PasswordHasher
|
||||
masterKey *crypto.MasterKey
|
||||
totpHasher *crypto.PasswordHasher
|
||||
settings instanceSettings
|
||||
policy *passwordPolicy
|
||||
cfg Config
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type instanceSettings interface {
|
||||
InstanceSettings(ctx context.Context) (*store.InstanceSettings, error)
|
||||
}
|
||||
|
||||
// New собирает сервис из конфигурации: pepper и master key обязательны
|
||||
// (AGENT.md 9.2 — без них инстанс не должен подниматься).
|
||||
func New(ctx context.Context, cfg config.Config, st *store.Store, logger *slog.Logger) (*Service, error) {
|
||||
if cfg.SessionPepper == "" {
|
||||
return nil, fmt.Errorf("SESSION_PEPPER is required")
|
||||
}
|
||||
if cfg.MasterKey == "" {
|
||||
return nil, fmt.Errorf("MASTER_KEY is required")
|
||||
}
|
||||
masterKey, err := crypto.ParseMasterKey(cfg.MasterKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse MASTER_KEY: %w", err)
|
||||
}
|
||||
argonParams, err := argon2ParamsFromConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasher, err := crypto.NewPasswordHasher([]byte(cfg.SessionPepper), argonParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create password hasher: %w", err)
|
||||
}
|
||||
totpKey := cfg.TOTPEncryptionKey
|
||||
if totpKey == "" {
|
||||
totpKey = cfg.SessionPepper
|
||||
}
|
||||
totpHasher, err := crypto.NewPasswordHasher([]byte(totpKey), crypto.Argon2Params{
|
||||
Memory: 8192, Iterations: 1, Parallelism: 1, SaltLength: 16, KeyLength: 32,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create totp hasher: %w", err)
|
||||
}
|
||||
|
||||
policy, err := newPasswordPolicy()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sessionTTL := time.Duration(cfg.SessionTTLHours) * time.Hour
|
||||
if sessionTTL <= 0 {
|
||||
sessionTTL = store.SessionTTL
|
||||
}
|
||||
|
||||
return &Service{
|
||||
store: st,
|
||||
hasher: hasher,
|
||||
masterKey: masterKey,
|
||||
totpHasher: totpHasher,
|
||||
settings: st,
|
||||
policy: policy,
|
||||
cfg: Config{SessionTTL: sessionTTL, MaxLoginFails: 5, LockoutWindow: 15 * time.Minute},
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// argon2ParamsFromConfig проверяет границы параметров Argon2id из env.
|
||||
func argon2ParamsFromConfig(cfg config.Config) (crypto.Argon2Params, error) {
|
||||
params := crypto.Argon2Params{SaltLength: 16, KeyLength: 32}
|
||||
switch {
|
||||
case cfg.Argon2MemoryKiB < 1024 || cfg.Argon2MemoryKiB > 4194304:
|
||||
return params, fmt.Errorf("ARGON2_MEMORY_KIB must be between 1024 and 4194304")
|
||||
case cfg.Argon2Iterations < 1 || cfg.Argon2Iterations > 10:
|
||||
return params, fmt.Errorf("ARGON2_ITERATIONS must be between 1 and 10")
|
||||
case cfg.Argon2Parallelism < 1 || cfg.Argon2Parallelism > 255:
|
||||
return params, fmt.Errorf("ARGON2_PARALLELISM must be between 1 and 255")
|
||||
}
|
||||
params.Memory = uint32(cfg.Argon2MemoryKiB)
|
||||
params.Iterations = uint32(cfg.Argon2Iterations)
|
||||
params.Parallelism = uint8(cfg.Argon2Parallelism)
|
||||
return params, nil
|
||||
}
|
||||
|
||||
// SessionTTL возвращает срок жизни сессии.
|
||||
func (s *Service) SessionTTL() time.Duration { return s.cfg.SessionTTL }
|
||||
|
||||
// HashToken возвращает хэш токена для поиска сессии.
|
||||
func (s *Service) HashToken(token string) string { return crypto.HashToken(token) }
|
||||
|
||||
type RegisterInput struct {
|
||||
Username string
|
||||
DisplayName string
|
||||
Email string
|
||||
Password string
|
||||
Locale string
|
||||
IP string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// Register создаёт аккаунт, шифрует email, проверяет пароль и сразу выдаёт
|
||||
// сессию (AGENT.md 7.1: регистрация без подтверждения письма).
|
||||
func (s *Service) Register(ctx context.Context, in RegisterInput) (*store.User, string, *store.Session, error) {
|
||||
settings, err := s.settings.InstanceSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, "", nil, fmt.Errorf("load instance settings: %w", err)
|
||||
}
|
||||
if !settings.RegistrationEnabled {
|
||||
return nil, "", nil, ErrRegistrationOff
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(in.Username)
|
||||
if err := crypto.ValidateUsername(username); err != nil {
|
||||
return nil, "", nil, ErrInvalidUsername
|
||||
}
|
||||
if err := s.policy.validate(in.Password); err != nil {
|
||||
return nil, "", nil, ErrWeakPassword
|
||||
}
|
||||
email := crypto.NormalizeEmail(in.Email)
|
||||
if err := validateEmail(email); err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
|
||||
passwordHash, err := s.hasher.Hash(in.Password)
|
||||
if err != nil {
|
||||
return nil, "", nil, fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
userID := s.store.NextID()
|
||||
emailEncrypted, err := s.encryptEmail(userID, email)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
emailIndex := s.masterKey.BlindIndex(email)
|
||||
|
||||
user, err := s.store.CreateUser(ctx, store.CreateUserParams{
|
||||
ID: userID,
|
||||
Username: username,
|
||||
DisplayName: defaultDisplayName(in.DisplayName, username),
|
||||
EmailEnc: emailEncrypted,
|
||||
EmailIndex: emailIndex,
|
||||
PasswordHash: passwordHash,
|
||||
Locale: in.Locale,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
// Различаем причины конфликта, не раскрывая лишнего наружу.
|
||||
if existing, lookupErr := s.store.GetUserByUsername(ctx, username); lookupErr == nil && existing != nil {
|
||||
return nil, "", nil, ErrUsernameTaken
|
||||
}
|
||||
return nil, "", nil, ErrEmailTaken
|
||||
}
|
||||
return nil, "", nil, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
|
||||
if err := s.joinMainGuild(ctx, user.ID); err != nil {
|
||||
s.logger.WarnContext(ctx, "failed to join main guild", slog.Any("error", err))
|
||||
}
|
||||
|
||||
token, session, err := s.createSession(ctx, user.ID, in.UserAgent, in.IP)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "register", in.IP, in.UserAgent, "")
|
||||
return user, token, session, nil
|
||||
}
|
||||
|
||||
type LoginInput struct {
|
||||
Email string
|
||||
Password string
|
||||
TOTPCode string
|
||||
IP string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// Login проверяет пароль, при включённом TOTP требует код, ротирует токен
|
||||
// сессии (защита от session fixation) и пишет событие безопасности.
|
||||
func (s *Service) Login(ctx context.Context, in LoginInput) (*store.User, string, *store.Session, error) {
|
||||
user, err := s.userByEmail(ctx, in.Email)
|
||||
if err != nil {
|
||||
// Одинаковый ответ для «нет пользователя» и «неверный пароль».
|
||||
return nil, "", nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if err := s.checkLockout(ctx, user.ID); err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
|
||||
if err := s.hasher.Verify(in.Password, user.PasswordHash); err != nil {
|
||||
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "login_failed", in.IP, in.UserAgent, "")
|
||||
return nil, "", nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
totp, err := s.store.GetTOTPSecret(ctx, user.ID)
|
||||
switch {
|
||||
case err == nil && totp.Enabled:
|
||||
if in.TOTPCode == "" {
|
||||
return nil, "", nil, ErrTOTPRequired
|
||||
}
|
||||
if err := s.verifyTOTP(ctx, totp, in.TOTPCode); err != nil {
|
||||
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "2fa_failed", in.IP, in.UserAgent, "")
|
||||
return nil, "", nil, err
|
||||
}
|
||||
case errors.Is(err, store.ErrNotFound) && user.IsInstanceAdmin:
|
||||
// Инстанс-админ обязан иметь 2FA (AGENT.md 7.19).
|
||||
return nil, "", nil, ErrInstanceAdminTOTP
|
||||
}
|
||||
|
||||
token, session, err := s.createSession(ctx, user.ID, in.UserAgent, in.IP)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "login", in.IP, in.UserAgent, "")
|
||||
return user, token, session, nil
|
||||
}
|
||||
|
||||
// ResolveSession проверяет токен: возвращает пользователя и сессию.
|
||||
func (s *Service) ResolveSession(ctx context.Context, token string) (*store.User, *store.Session, error) {
|
||||
if token == "" {
|
||||
return nil, nil, ErrSessionExpired
|
||||
}
|
||||
session, err := s.store.GetSessionByTokenHash(ctx, crypto.HashToken(token))
|
||||
if err != nil {
|
||||
return nil, nil, ErrSessionExpired
|
||||
}
|
||||
if !session.ExpiresAt.After(time.Now().UTC()) {
|
||||
_ = s.store.DeleteSession(ctx, session.ID)
|
||||
return nil, nil, ErrSessionExpired
|
||||
}
|
||||
user, err := s.store.GetUser(ctx, session.UserID)
|
||||
if err != nil {
|
||||
return nil, nil, ErrSessionExpired
|
||||
}
|
||||
_ = s.store.TouchSession(ctx, session.ID)
|
||||
return user, session, nil
|
||||
}
|
||||
|
||||
// RotateSession выдаёт новый токен для существующей сессии (AGENT.md 7.1).
|
||||
func (s *Service) RotateSession(ctx context.Context, sessionID uint64) (string, error) {
|
||||
token, hash, err := crypto.NewSessionToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.store.RotateSession(ctx, sessionID, hash); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, sessionID uint64) error {
|
||||
return s.store.DeleteSession(ctx, sessionID)
|
||||
}
|
||||
|
||||
// LogoutAll отзывает все сессии пользователя («выйти везде»).
|
||||
func (s *Service) LogoutAll(ctx context.Context, userID uint64) error {
|
||||
return s.store.DeleteSessionsForUser(ctx, userID)
|
||||
}
|
||||
|
||||
// RequireStepUp проверяет свежесть аутентификации для чувствительных действий.
|
||||
func (s *Service) RequireStepUp(ctx context.Context, user *store.User, session *store.Session, password, totpCode string) error {
|
||||
if session.SteppedUp(time.Now().UTC()) {
|
||||
return nil
|
||||
}
|
||||
if err := s.hasher.Verify(password, user.PasswordHash); err != nil {
|
||||
return ErrStepUpRequired
|
||||
}
|
||||
totp, err := s.store.GetTOTPSecret(ctx, user.ID)
|
||||
if err == nil && totp.Enabled {
|
||||
if totpCode == "" {
|
||||
return ErrTOTPRequired
|
||||
}
|
||||
if err := s.verifyTOTP(ctx, totp, totpCode); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.store.MarkSteppedUp(ctx, session.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
// Обновляем копию сессии в памяти: повторная проверка в том же обработчике
|
||||
// не должна снова требовать step-up.
|
||||
if refreshed, err := s.store.GetSessionByTokenHash(ctx, session.TokenHash); err == nil {
|
||||
*session = *refreshed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangePassword меняет пароль и отзывает все остальные сессии.
|
||||
func (s *Service) ChangePassword(ctx context.Context, userID uint64, currentSessionID uint64, currentPassword, newPassword string) error {
|
||||
user, err := s.store.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.hasher.Verify(currentPassword, user.PasswordHash); err != nil {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
if err := s.policy.validate(newPassword); err != nil {
|
||||
return ErrWeakPassword
|
||||
}
|
||||
hash, err := s.hasher.Hash(newPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
if err := s.store.UpdateUserPassword(ctx, userID, hash); err != nil {
|
||||
return err
|
||||
}
|
||||
// Смена пароля отзывает все сессии, кроме текущей (AGENT.md 7.1).
|
||||
if err := s.store.DeleteOtherSessions(ctx, userID, currentSessionID); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.store.RecordSecurityEvent(ctx, &userID, "password_change", "", "", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Email возвращает расшифрованный email пользователя.
|
||||
func (s *Service) Email(ctx context.Context, userID uint64) (string, error) {
|
||||
encrypted, err := s.store.EncryptedEmail(ctx, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
payload, err := s.masterKey.Decrypt(encrypted)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decrypt email: %w", err)
|
||||
}
|
||||
prefix := "u" + strconv.FormatUint(userID, 10) + ":"
|
||||
if !strings.HasPrefix(payload, prefix) {
|
||||
return "", fmt.Errorf("email payload is bound to another account")
|
||||
}
|
||||
return strings.TrimPrefix(payload, prefix), nil
|
||||
}
|
||||
|
||||
func (s *Service) userByEmail(ctx context.Context, email string) (*store.User, error) {
|
||||
index := s.masterKey.BlindIndex(crypto.NormalizeEmail(email))
|
||||
return s.store.GetUserByEmailIndex(ctx, index)
|
||||
}
|
||||
|
||||
func (s *Service) createSession(ctx context.Context, userID uint64, userAgent, ip string) (string, *store.Session, error) {
|
||||
token, hash, err := crypto.NewSessionToken()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
session, err := s.store.CreateSession(ctx, userID, store.CreateSessionParams{
|
||||
TokenHash: hash,
|
||||
UserAgent: truncate(userAgent, 256),
|
||||
IP: ip,
|
||||
TTL: s.cfg.SessionTTL,
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
return token, session, nil
|
||||
}
|
||||
|
||||
// joinMainGuild добавляет нового пользователя на главный сервер с ролью
|
||||
// «Пользователь» (AGENT.md 7.3).
|
||||
func (s *Service) joinMainGuild(ctx context.Context, userID uint64) error {
|
||||
settings, err := s.settings.InstanceSettings(ctx)
|
||||
if err != nil || settings.MainGuildID == 0 {
|
||||
return err
|
||||
}
|
||||
if _, err := s.store.AddGuildMember(ctx, settings.MainGuildID, userID, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
defaultRole, err := s.store.DefaultRole(ctx, settings.MainGuildID)
|
||||
if err != nil {
|
||||
// Роль по умолчанию может отсутствовать (сервер без ролей) — это не мешает
|
||||
// автовступлению, поэтому ошибку не возвращаем, но фиксируем в логе.
|
||||
s.logger.WarnContext(ctx, "main guild has no default role",
|
||||
slog.Uint64("guild_id", settings.MainGuildID), slog.Any("error", err))
|
||||
return nil
|
||||
}
|
||||
return s.store.AssignRole(ctx, settings.MainGuildID, userID, defaultRole.ID)
|
||||
}
|
||||
|
||||
// checkLockout защищает от перебора: серия неудач блокирует попытки на окно.
|
||||
func (s *Service) checkLockout(ctx context.Context, userID uint64) error {
|
||||
events, err := s.store.ListSecurityEvents(ctx, userID, 20)
|
||||
if err != nil {
|
||||
// Без истории событий блокировку посчитать нельзя: безопаснее пропустить
|
||||
// проверку, чем запереть пользователя из-за сбоя чтения.
|
||||
s.logger.WarnContext(ctx, "cannot read security events for lockout check",
|
||||
slog.Uint64("user_id", userID), slog.Any("error", err))
|
||||
return nil
|
||||
}
|
||||
failures := 0
|
||||
cutoff := time.Now().UTC().Add(-s.cfg.LockoutWindow)
|
||||
for _, event := range events {
|
||||
if event.Type != "login_failed" {
|
||||
continue
|
||||
}
|
||||
if event.CreatedAt.Before(cutoff) {
|
||||
break
|
||||
}
|
||||
failures++
|
||||
}
|
||||
if failures >= s.cfg.MaxLoginFails {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PermissionsGuild вычисляет права пользователя на сервере (обёртка для API).
|
||||
func (s *Service) PermissionsGuild(resolved permissions.Resolved, permission permissions.Permission) error {
|
||||
if resolved.Has(permission) {
|
||||
return nil
|
||||
}
|
||||
return permissions.ErrDenied
|
||||
}
|
||||
|
||||
func defaultDisplayName(displayName, username string) string {
|
||||
if strings.TrimSpace(displayName) == "" {
|
||||
return username
|
||||
}
|
||||
return truncate(displayName, 64)
|
||||
}
|
||||
|
||||
func truncate(value string, limit int) string {
|
||||
if len(value) <= limit {
|
||||
return value
|
||||
}
|
||||
return value[:limit]
|
||||
}
|
||||
|
||||
func validateEmail(email string) error {
|
||||
at := strings.LastIndex(email, "@")
|
||||
if at <= 0 || at == len(email)-1 || !strings.Contains(email[at+1:], ".") {
|
||||
return fmt.Errorf("auth.invalid_email")
|
||||
}
|
||||
if len(email) > 254 {
|
||||
return fmt.Errorf("auth.invalid_email")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// encryptEmail привязывает шифротекст к идентификатору пользователя:
|
||||
// скопированное значение не расшифруется под другим аккаунтом.
|
||||
func (s *Service) encryptEmail(userID uint64, email string) (string, error) {
|
||||
payload := "u" + strconv.FormatUint(userID, 10) + ":" + email
|
||||
return s.masterKey.Encrypt(payload)
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
|
||||
"glchat/internal/auth"
|
||||
"glchat/internal/config"
|
||||
"glchat/internal/database"
|
||||
"glchat/internal/permissions"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
testPepper = "unit-test-session-pepper"
|
||||
testMasterKey = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
|
||||
)
|
||||
|
||||
func testConfig() config.Config {
|
||||
return config.Config{
|
||||
SessionPepper: testPepper,
|
||||
MasterKey: testMasterKey,
|
||||
TOTPEncryptionKey: "unit-test-totp-key",
|
||||
SessionTTLHours: 24,
|
||||
// Низкие параметры Argon2id: тесты не должны работать секундами.
|
||||
Argon2MemoryKiB: 1024,
|
||||
Argon2Iterations: 1,
|
||||
Argon2Parallelism: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func newService(t *testing.T) (*auth.Service, *store.Store) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
db, err := database.Open(ctx, database.Options{
|
||||
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
||||
ReadPool: 2,
|
||||
Migrate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
st := store.New(db)
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
service, err := auth.New(ctx, testConfig(), st, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("auth.New: %v", err)
|
||||
}
|
||||
return service, st
|
||||
}
|
||||
|
||||
// bootstrapMainGuild создаёт главный сервер с ролями по умолчанию, как это
|
||||
// делает установщик (AGENT.md 7.3).
|
||||
func bootstrapMainGuild(t *testing.T, st *store.Store, ownerID uint64) *store.Guild {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
guild, err := st.CreateGuild(ctx, store.CreateGuildParams{Name: "Главный сервер", OwnerID: ownerID, IsMain: true})
|
||||
if err != nil {
|
||||
t.Fatalf("create main guild: %v", err)
|
||||
}
|
||||
if _, err := st.CreateRole(ctx, store.CreateRoleParams{
|
||||
GuildID: guild.ID, Name: "Администратор", Permissions: uint64(permissions.AllPermissions), Position: 100,
|
||||
}); err != nil {
|
||||
t.Fatalf("create admin role: %v", err)
|
||||
}
|
||||
if _, err := st.CreateRole(ctx, store.CreateRoleParams{
|
||||
GuildID: guild.ID, Name: "Пользователь", Permissions: uint64(permissions.DefaultUserPermissions),
|
||||
Position: 0, IsDefault: true, Mentionable: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("create default role: %v", err)
|
||||
}
|
||||
if err := st.SetInstanceSetting(ctx, "main_guild_id", itoa(guild.ID)); err != nil {
|
||||
t.Fatalf("set main guild: %v", err)
|
||||
}
|
||||
return guild
|
||||
}
|
||||
|
||||
// registerErr выполняет регистрацию и возвращает только ошибку: в тестах,
|
||||
// где важен лишь результат проверки, это читается лучше вызова с пропусками.
|
||||
func registerErr(service *auth.Service, in auth.RegisterInput) error {
|
||||
_, _, _, err := service.Register(context.Background(), in)
|
||||
return err
|
||||
}
|
||||
|
||||
func itoa(value uint64) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
digits := []byte{}
|
||||
for value > 0 {
|
||||
digits = append([]byte{byte('0' + value%10)}, digits...)
|
||||
value /= 10
|
||||
}
|
||||
return string(digits)
|
||||
}
|
||||
|
||||
func TestRegisterAndLogin(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, st := newService(t)
|
||||
|
||||
user, token, session, err := service.Register(ctx, auth.RegisterInput{
|
||||
Username: "alex", DisplayName: "Александр", Email: "Alex@Example.COM",
|
||||
Password: "correct-horse-battery", IP: "203.0.113.5", UserAgent: "test-agent",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
// Главный сервер создаётся установщиком (владелец — уже существующий админ).
|
||||
bootstrapMainGuild(t, st, user.ID)
|
||||
// Новый пользователь регистрируется после появления главного сервера,
|
||||
// чтобы проверить автовступление.
|
||||
second, _, _, err := service.Register(ctx, auth.RegisterInput{
|
||||
Username: "newcomer", Email: "newcomer@example.com", Password: "correct-horse-battery",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Register newcomer: %v", err)
|
||||
}
|
||||
newcomerGuilds, err := st.ListGuildsForUser(ctx, second.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListGuildsForUser(newcomer): %v", err)
|
||||
}
|
||||
if len(newcomerGuilds) != 1 || !newcomerGuilds[0].IsMain {
|
||||
t.Fatalf("newcomer must join the main guild, got %+v", newcomerGuilds)
|
||||
}
|
||||
newcomerRoles, err := st.MemberRoles(ctx, newcomerGuilds[0].ID, second.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("MemberRoles(newcomer): %v", err)
|
||||
}
|
||||
if len(newcomerRoles) != 1 || !newcomerRoles[0].IsDefault {
|
||||
t.Fatalf("newcomer must receive the default role, got %+v", newcomerRoles)
|
||||
}
|
||||
if user.ID == 0 || token == "" || session.ID == 0 {
|
||||
t.Fatal("registration must return user, token and session")
|
||||
}
|
||||
if user.IsInstanceAdmin {
|
||||
t.Fatal("regular user must not be instance admin")
|
||||
}
|
||||
if strings.Contains(user.PasswordHash, "correct-horse-battery") {
|
||||
t.Fatal("password must not be stored in clear text")
|
||||
}
|
||||
|
||||
// Email зашифрован в БД и расшифровывается только сервисом.
|
||||
encrypted, err := st.EncryptedEmail(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptedEmail: %v", err)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(encrypted), "alex@example.com") {
|
||||
t.Fatal("email must be stored encrypted")
|
||||
}
|
||||
decrypted, err := service.Email(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Email: %v", err)
|
||||
}
|
||||
if decrypted != "alex@example.com" {
|
||||
t.Fatalf("Email = %q, want normalized address", decrypted)
|
||||
}
|
||||
|
||||
// Вход по email в любом регистре.
|
||||
logged, newToken, _, err := service.Login(ctx, auth.LoginInput{
|
||||
Email: "ALEX@example.com", Password: "correct-horse-battery", IP: "203.0.113.5",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Login: %v", err)
|
||||
}
|
||||
if logged.ID != user.ID || newToken == token {
|
||||
t.Fatal("login must return the same user with a fresh token")
|
||||
}
|
||||
|
||||
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||
Email: "alex@example.com", Password: "wrong-password", IP: "203.0.113.5",
|
||||
}); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Fatalf("Login with wrong password = %v, want ErrInvalidCredentials", err)
|
||||
}
|
||||
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||
Email: "nobody@example.com", Password: "correct-horse-battery",
|
||||
}); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Fatalf("Login with unknown email = %v, want ErrInvalidCredentials", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRejectsWeakAndLeakedPasswords(t *testing.T) {
|
||||
service, _ := newService(t)
|
||||
|
||||
for name, password := range map[string]string{
|
||||
"short": "short",
|
||||
"leaked": "password",
|
||||
} {
|
||||
err := registerErr(service, auth.RegisterInput{
|
||||
Username: "user_" + name, Email: name + "@example.com", Password: password,
|
||||
})
|
||||
if !errors.Is(err, auth.ErrWeakPassword) {
|
||||
t.Fatalf("Register with %s password = %v, want ErrWeakPassword", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
for name, username := range map[string]string{"short": "a", "bad": "with space", "cyrillic": "имя"} {
|
||||
err := registerErr(service, auth.RegisterInput{
|
||||
Username: username, Email: "valid_" + name + "@example.com", Password: "correct-horse-battery",
|
||||
})
|
||||
if !errors.Is(err, auth.ErrInvalidUsername) {
|
||||
t.Fatalf("Register with %s username = %v, want ErrInvalidUsername", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRejectsDuplicates(t *testing.T) {
|
||||
service, _ := newService(t)
|
||||
first := auth.RegisterInput{Username: "duplicate", Email: "first@example.com", Password: "correct-horse-battery"}
|
||||
if err := registerErr(service, first); err != nil {
|
||||
t.Fatalf("first Register: %v", err)
|
||||
}
|
||||
|
||||
err := registerErr(service, auth.RegisterInput{
|
||||
Username: "duplicate", Email: "second@example.com", Password: "correct-horse-battery",
|
||||
})
|
||||
if !errors.Is(err, auth.ErrUsernameTaken) {
|
||||
t.Fatalf("duplicate username = %v, want ErrUsernameTaken", err)
|
||||
}
|
||||
|
||||
err = registerErr(service, auth.RegisterInput{
|
||||
Username: "another", Email: "FIRST@example.com", Password: "correct-horse-battery",
|
||||
})
|
||||
if !errors.Is(err, auth.ErrEmailTaken) {
|
||||
t.Fatalf("duplicate email (case-insensitive) = %v, want ErrEmailTaken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRotationAndLogoutAll(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newService(t)
|
||||
user, token, session, err := service.Register(ctx, auth.RegisterInput{
|
||||
Username: "session_user", Email: "session@example.com", Password: "correct-horse-battery",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
|
||||
rotated, err := service.RotateSession(ctx, session.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("RotateSession: %v", err)
|
||||
}
|
||||
if rotated == token {
|
||||
t.Fatal("rotation must produce a new token")
|
||||
}
|
||||
if _, _, err := service.ResolveSession(ctx, token); !errors.Is(err, auth.ErrSessionExpired) {
|
||||
t.Fatalf("old token must stop working, got %v", err)
|
||||
}
|
||||
if _, resolved, err := service.ResolveSession(ctx, rotated); err != nil || resolved.UserID != user.ID {
|
||||
t.Fatalf("rotated token must resolve: %v", err)
|
||||
}
|
||||
|
||||
if err := service.LogoutAll(ctx, user.ID); err != nil {
|
||||
t.Fatalf("LogoutAll: %v", err)
|
||||
}
|
||||
if _, _, err := service.ResolveSession(ctx, rotated); !errors.Is(err, auth.ErrSessionExpired) {
|
||||
t.Fatal("logout-all must revoke every session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOTPFlowAndStepUp(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newService(t)
|
||||
user, _, session, err := service.Register(ctx, auth.RegisterInput{
|
||||
Username: "totp_user", Email: "totp@example.com", Password: "correct-horse-battery",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
|
||||
setup, err := service.SetupTOTP(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("SetupTOTP: %v", err)
|
||||
}
|
||||
if setup.Secret == "" || !strings.HasPrefix(setup.URL, "otpauth://totp/") {
|
||||
t.Fatalf("unexpected setup payload: %+v", setup)
|
||||
}
|
||||
|
||||
if _, err := service.EnableTOTP(ctx, user.ID, "000000"); !errors.Is(err, auth.ErrTOTPInvalid) {
|
||||
t.Fatalf("EnableTOTP with bad code = %v, want ErrTOTPInvalid", err)
|
||||
}
|
||||
|
||||
code := totpCode(t, setup.Secret)
|
||||
recovery, err := service.EnableTOTP(ctx, user.ID, code)
|
||||
if err != nil {
|
||||
t.Fatalf("EnableTOTP: %v", err)
|
||||
}
|
||||
if len(recovery) != 8 {
|
||||
t.Fatalf("recovery codes = %d, want 8", len(recovery))
|
||||
}
|
||||
|
||||
// Вход без кода требует 2FA, с кодом — проходит.
|
||||
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||
Email: "totp@example.com", Password: "correct-horse-battery",
|
||||
}); !errors.Is(err, auth.ErrTOTPRequired) {
|
||||
t.Fatalf("Login without TOTP = %v, want ErrTOTPRequired", err)
|
||||
}
|
||||
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||
Email: "totp@example.com", Password: "correct-horse-battery", TOTPCode: totpCode(t, setup.Secret),
|
||||
}); err != nil {
|
||||
t.Fatalf("Login with TOTP: %v", err)
|
||||
}
|
||||
|
||||
// Резервный код работает один раз.
|
||||
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||
Email: "totp@example.com", Password: "correct-horse-battery", TOTPCode: recovery[0],
|
||||
}); err != nil {
|
||||
t.Fatalf("Login with recovery code: %v", err)
|
||||
}
|
||||
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||
Email: "totp@example.com", Password: "correct-horse-battery", TOTPCode: recovery[0],
|
||||
}); !errors.Is(err, auth.ErrTOTPInvalid) {
|
||||
t.Fatalf("reused recovery code = %v, want ErrTOTPInvalid", err)
|
||||
}
|
||||
|
||||
// Step-up: свежая сессия проходит, затем окно можно проверить повторно.
|
||||
if err := service.RequireStepUp(ctx, user, session, "correct-horse-battery", totpCode(t, setup.Secret)); err != nil {
|
||||
t.Fatalf("RequireStepUp: %v", err)
|
||||
}
|
||||
if err := service.RequireStepUp(ctx, user, session, "correct-horse-battery", ""); err != nil {
|
||||
t.Fatalf("RequireStepUp inside the window: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceAdminRequiresTOTP(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, st := newService(t)
|
||||
user, _, _, err := service.Register(ctx, auth.RegisterInput{
|
||||
Username: "admin", Email: "admin@example.com", Password: "correct-horse-battery",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
if err := st.SetInstanceAdmin(ctx, user.ID, true); err != nil {
|
||||
t.Fatalf("SetInstanceAdmin: %v", err)
|
||||
}
|
||||
|
||||
// Без настроенного TOTP инстанс-админ не может войти (AGENT.md 7.19).
|
||||
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||
Email: "admin@example.com", Password: "correct-horse-battery",
|
||||
}); !errors.Is(err, auth.ErrInstanceAdminTOTP) {
|
||||
t.Fatalf("Login as admin without TOTP = %v, want ErrInstanceAdminTOTP", err)
|
||||
}
|
||||
|
||||
setup, err := service.SetupTOTP(ctx, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("SetupTOTP: %v", err)
|
||||
}
|
||||
if _, err := service.EnableTOTP(ctx, user.ID, totpCode(t, setup.Secret)); err != nil {
|
||||
t.Fatalf("EnableTOTP: %v", err)
|
||||
}
|
||||
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||
Email: "admin@example.com", Password: "correct-horse-battery", TOTPCode: totpCode(t, setup.Secret),
|
||||
}); err != nil {
|
||||
t.Fatalf("Login as admin with TOTP: %v", err)
|
||||
}
|
||||
|
||||
// Отключить 2FA инстанс-админу нельзя.
|
||||
if err := service.DisableTOTP(ctx, user.ID); !errors.Is(err, auth.ErrInstanceAdminTOTP) {
|
||||
t.Fatalf("DisableTOTP for admin = %v, want ErrInstanceAdminTOTP", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordRevokesOtherSessions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newService(t)
|
||||
user, _, current, err := service.Register(ctx, auth.RegisterInput{
|
||||
Username: "password_user", Email: "password@example.com", Password: "correct-horse-battery",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
_, otherToken, _, err := service.Login(ctx, auth.LoginInput{
|
||||
Email: "password@example.com", Password: "correct-horse-battery",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second Login: %v", err)
|
||||
}
|
||||
|
||||
if err := service.ChangePassword(ctx, user.ID, current.ID, "correct-horse-battery", "brand-new-password-1"); err != nil {
|
||||
t.Fatalf("ChangePassword: %v", err)
|
||||
}
|
||||
if _, _, err := service.ResolveSession(ctx, otherToken); !errors.Is(err, auth.ErrSessionExpired) {
|
||||
t.Fatal("other sessions must be revoked after a password change")
|
||||
}
|
||||
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||
Email: "password@example.com", Password: "correct-horse-battery",
|
||||
}); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Fatalf("old password must stop working, got %v", err)
|
||||
}
|
||||
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||
Email: "password@example.com", Password: "brand-new-password-1",
|
||||
}); err != nil {
|
||||
t.Fatalf("Login with new password: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationCanBeDisabled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, st := newService(t)
|
||||
if err := st.SetInstanceSetting(ctx, "registration_enabled", "false"); err != nil {
|
||||
t.Fatalf("SetInstanceSetting: %v", err)
|
||||
}
|
||||
if _, _, _, err := service.Register(ctx, auth.RegisterInput{
|
||||
Username: "blocked", Email: "blocked@example.com", Password: "correct-horse-battery",
|
||||
}); !errors.Is(err, auth.ErrRegistrationOff) {
|
||||
t.Fatalf("Register with disabled registration = %v, want ErrRegistrationOff", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordsAndSecretsAreNotLogged(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var buf strings.Builder
|
||||
db, err := database.Open(ctx, database.Options{
|
||||
Path: filepath.Join(t.TempDir(), "glchat.db"), ReadPool: 2, Migrate: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
st := store.New(db)
|
||||
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
service, err := auth.New(ctx, testConfig(), st, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("auth.New: %v", err)
|
||||
}
|
||||
user, token, _, err := service.Register(ctx, auth.RegisterInput{
|
||||
Username: "secret_user", Email: "secret@example.com", Password: "correct-horse-battery",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||
Email: "secret@example.com", Password: "correct-horse-battery",
|
||||
}); err != nil {
|
||||
t.Fatalf("Login: %v", err)
|
||||
}
|
||||
|
||||
logs := buf.String()
|
||||
for _, secret := range []string{"correct-horse-battery", token, testPepper, testMasterKey} {
|
||||
if secret != "" && strings.Contains(logs, secret) {
|
||||
t.Fatalf("logs contain a secret value: %s", secret)
|
||||
}
|
||||
}
|
||||
// Хэш пароля тоже не должен попадать в лог.
|
||||
hash := user.PasswordHash
|
||||
if hash != "" && strings.Contains(logs, hash) {
|
||||
t.Fatal("logs contain the password hash")
|
||||
}
|
||||
}
|
||||
|
||||
// totpCode считает текущий код для секрета.
|
||||
func totpCode(t *testing.T, secret string) string {
|
||||
t.Helper()
|
||||
code, err := totp.GenerateCode(secret, time.Now().UTC())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode: %v", err)
|
||||
}
|
||||
return code
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
|
||||
"glchat/internal/crypto"
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// TOTPSetup — данные для подключения второго фактора: секрет показывается
|
||||
// один раз, в БД хранится зашифрованным (AGENT.md 9.2).
|
||||
type TOTPSetup struct {
|
||||
Secret string
|
||||
URL string
|
||||
Recovery []string
|
||||
IssuerName string
|
||||
Account string
|
||||
}
|
||||
|
||||
const (
|
||||
recoveryCodeCount = 8
|
||||
totpIssuer = "glchat"
|
||||
totpPeriod = 30
|
||||
totpSkew = 1
|
||||
)
|
||||
|
||||
// SetupTOTP создаёт новый секрет (не включая 2FA до подтверждения кодом).
|
||||
func (s *Service) SetupTOTP(ctx context.Context, userID uint64) (*TOTPSetup, error) {
|
||||
user, err := s.store.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing, err := s.store.GetTOTPSecret(ctx, userID)
|
||||
if err == nil && existing.Enabled {
|
||||
return nil, ErrTOTPAlreadyEnabled
|
||||
}
|
||||
if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: totpIssuer,
|
||||
AccountName: user.Username,
|
||||
Period: totpPeriod,
|
||||
SecretSize: 20,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate totp secret: %w", err)
|
||||
}
|
||||
|
||||
encrypted, err := s.masterKey.Encrypt(key.Secret())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt totp secret: %w", err)
|
||||
}
|
||||
if err := s.store.UpsertTOTPSecret(ctx, userID, encrypted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TOTPSetup{
|
||||
Secret: key.Secret(),
|
||||
URL: key.URL(),
|
||||
IssuerName: totpIssuer,
|
||||
Account: user.Username,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EnableTOTP подтверждает секрет кодом и выдаёт резервные коды (в БД — хэши).
|
||||
func (s *Service) EnableTOTP(ctx context.Context, userID uint64, code string) ([]string, error) {
|
||||
secret, err := s.store.GetTOTPSecret(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, ErrNoTOTPSecret
|
||||
}
|
||||
if secret.Enabled {
|
||||
return nil, ErrTOTPAlreadyEnabled
|
||||
}
|
||||
if err := s.verifyTOTPSecret(secret.SecretEncrypted, code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
codes := make([]string, 0, recoveryCodeCount)
|
||||
hashes := make([]string, 0, recoveryCodeCount)
|
||||
for i := 0; i < recoveryCodeCount; i++ {
|
||||
raw, err := crypto.NewRecoveryCode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hash, err := s.totpHasher.Hash(strings.ToUpper(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
codes = append(codes, raw)
|
||||
hashes = append(hashes, hash)
|
||||
}
|
||||
if err := s.store.EnableTOTP(ctx, userID, hashes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.store.RecordSecurityEvent(ctx, &userID, "2fa_change", "", "", `{"enabled":true}`)
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
// DisableTOTP выключает второй фактор (после проверки пароля вызывающим кодом)
|
||||
// и запрещает это инстанс-администратору (AGENT.md 7.19).
|
||||
func (s *Service) DisableTOTP(ctx context.Context, userID uint64) error {
|
||||
user, err := s.store.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user.IsInstanceAdmin {
|
||||
return ErrInstanceAdminTOTP
|
||||
}
|
||||
if err := s.store.DeleteTOTPSecret(ctx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.store.RecordSecurityEvent(ctx, &userID, "2fa_change", "", "", `{"enabled":false}`)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegenerateRecoveryCodes заменяет резервные коды (пароль проверяет API).
|
||||
func (s *Service) RegenerateRecoveryCodes(ctx context.Context, userID uint64, code string) ([]string, error) {
|
||||
secret, err := s.store.GetTOTPSecret(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, ErrNoTOTPSecret
|
||||
}
|
||||
if !secret.Enabled {
|
||||
return nil, ErrNoTOTPSecret
|
||||
}
|
||||
if err := s.verifyTOTP(ctx, secret, code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
codes := make([]string, 0, recoveryCodeCount)
|
||||
hashes := make([]string, 0, recoveryCodeCount)
|
||||
for i := 0; i < recoveryCodeCount; i++ {
|
||||
raw, err := crypto.NewRecoveryCode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hash, err := s.totpHasher.Hash(strings.ToUpper(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
codes = append(codes, raw)
|
||||
hashes = append(hashes, hash)
|
||||
}
|
||||
if err := s.store.EnableTOTP(ctx, userID, hashes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
// TOTPEnabled сообщает состояние второго фактора.
|
||||
func (s *Service) TOTPEnabled(ctx context.Context, userID uint64) (bool, error) {
|
||||
secret, err := s.store.GetTOTPSecret(ctx, userID)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return secret.Enabled, nil
|
||||
}
|
||||
|
||||
// verifyTOTP проверяет код TOTP или резервный код.
|
||||
func (s *Service) verifyTOTP(ctx context.Context, secret *store.TOTPSecret, code string) error {
|
||||
normalized := strings.TrimSpace(code)
|
||||
if normalized == "" {
|
||||
return ErrTOTPInvalid
|
||||
}
|
||||
// Резервный код: одноразовый, после использования удаляется.
|
||||
if len(normalized) == 14 && strings.Count(normalized, "-") == 2 {
|
||||
remaining, ok := s.consumeRecoveryCode(secret, normalized)
|
||||
if !ok {
|
||||
return ErrTOTPInvalid
|
||||
}
|
||||
if err := s.store.ConsumeRecoveryCode(ctx, secret.UserID, remaining); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := s.verifyTOTPSecret(secret.SecretEncrypted, normalized); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) consumeRecoveryCode(secret *store.TOTPSecret, code string) ([]string, bool) {
|
||||
upper := strings.ToUpper(strings.TrimSpace(code))
|
||||
remaining := make([]string, 0, len(secret.RecoveryCodeHashs))
|
||||
matched := false
|
||||
for _, hash := range secret.RecoveryCodeHashs {
|
||||
if !matched && s.totpHasher.Verify(upper, hash) == nil {
|
||||
matched = true
|
||||
continue
|
||||
}
|
||||
remaining = append(remaining, hash)
|
||||
}
|
||||
if !matched {
|
||||
return secret.RecoveryCodeHashs, false
|
||||
}
|
||||
return remaining, true
|
||||
}
|
||||
|
||||
func (s *Service) verifyTOTPSecret(encrypted, code string) error {
|
||||
plain, err := s.masterKey.Decrypt(encrypted)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypt totp secret: %w", err)
|
||||
}
|
||||
valid, err := totp.ValidateCustom(strings.TrimSpace(code), plain, time.Now().UTC(), totp.ValidateOpts{
|
||||
Period: totpPeriod,
|
||||
Skew: totpSkew,
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
if err != nil || !valid {
|
||||
return ErrTOTPInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+39
-24
@@ -29,34 +29,49 @@ type Config struct {
|
||||
ReadPoolSize int
|
||||
MaxUploadSize int64
|
||||
TLSEnabled bool
|
||||
Version string
|
||||
Commit string
|
||||
BuildDate string
|
||||
ShutdownTTL time.Duration
|
||||
Maintenance time.Duration
|
||||
// Секреты инстанса (генерируются установщиком, AGENT.md §10.3).
|
||||
SessionPepper string
|
||||
MasterKey string
|
||||
TOTPEncryptionKey string
|
||||
SessionTTLHours int
|
||||
Argon2MemoryKiB int
|
||||
Argon2Iterations int
|
||||
Argon2Parallelism int
|
||||
Version string
|
||||
Commit string
|
||||
BuildDate string
|
||||
ShutdownTTL time.Duration
|
||||
Maintenance time.Duration
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Domain: env("DOMAIN", "localhost"),
|
||||
FilesDomain: env("FILES_DOMAIN", ""),
|
||||
InstanceName: env("INSTANCE_NAME", "glchat"),
|
||||
ListenAddr: env("LISTEN_ADDR", DefaultListenAddr),
|
||||
DataDir: env("DATA_DIR", DefaultDataDir),
|
||||
WebRoot: env("WEB_ROOT", "/app/web"),
|
||||
DatabasePath: env("DATABASE_PATH", ""),
|
||||
SQLiteCache: env("SQLITE_CACHE_SIZE", "-8000"),
|
||||
SQLiteMmap: env("SQLITE_MMAP_SIZE", "134217728"),
|
||||
LogLevel: strings.ToLower(env("LOG_LEVEL", DefaultLogLevel)),
|
||||
LogFormat: strings.ToLower(env("LOG_FORMAT", "json")),
|
||||
ReadPoolSize: envInt("SQLITE_READ_POOL", 4),
|
||||
MaxUploadSize: envInt64("MAX_UPLOAD_SIZE", 26214400),
|
||||
TLSEnabled: envBool("TLS_ENABLED", true),
|
||||
Version: env("APP_VERSION", "dev"),
|
||||
Commit: env("APP_COMMIT", "none"),
|
||||
BuildDate: env("APP_BUILD_DATE", "unknown"),
|
||||
ShutdownTTL: time.Duration(envInt("SHUTDOWN_TIMEOUT_SECONDS", 20)) * time.Second,
|
||||
Maintenance: time.Duration(envInt("DB_MAINTENANCE_INTERVAL_SECONDS", 21600)) * time.Second,
|
||||
Domain: env("DOMAIN", "localhost"),
|
||||
FilesDomain: env("FILES_DOMAIN", ""),
|
||||
InstanceName: env("INSTANCE_NAME", "glchat"),
|
||||
ListenAddr: env("LISTEN_ADDR", DefaultListenAddr),
|
||||
DataDir: env("DATA_DIR", DefaultDataDir),
|
||||
WebRoot: env("WEB_ROOT", "/app/web"),
|
||||
DatabasePath: env("DATABASE_PATH", ""),
|
||||
SQLiteCache: env("SQLITE_CACHE_SIZE", "-8000"),
|
||||
SQLiteMmap: env("SQLITE_MMAP_SIZE", "134217728"),
|
||||
LogLevel: strings.ToLower(env("LOG_LEVEL", DefaultLogLevel)),
|
||||
LogFormat: strings.ToLower(env("LOG_FORMAT", "json")),
|
||||
ReadPoolSize: envInt("SQLITE_READ_POOL", 4),
|
||||
MaxUploadSize: envInt64("MAX_UPLOAD_SIZE", 26214400),
|
||||
TLSEnabled: envBool("TLS_ENABLED", true),
|
||||
SessionPepper: env("SESSION_PEPPER", ""),
|
||||
MasterKey: env("MASTER_KEY", ""),
|
||||
TOTPEncryptionKey: env("TOTP_ENCRYPTION_KEY", ""),
|
||||
SessionTTLHours: envInt("SESSION_TTL_HOURS", 720),
|
||||
Argon2MemoryKiB: envInt("ARGON2_MEMORY_KIB", 19456),
|
||||
Argon2Iterations: envInt("ARGON2_ITERATIONS", 2),
|
||||
Argon2Parallelism: envInt("ARGON2_PARALLELISM", 1),
|
||||
Version: env("APP_VERSION", "dev"),
|
||||
Commit: env("APP_COMMIT", "none"),
|
||||
BuildDate: env("APP_BUILD_DATE", "unknown"),
|
||||
ShutdownTTL: time.Duration(envInt("SHUTDOWN_TIMEOUT_SECONDS", 20)) * time.Second,
|
||||
Maintenance: time.Duration(envInt("DB_MAINTENANCE_INTERVAL_SECONDS", 21600)) * time.Second,
|
||||
}
|
||||
if cfg.FilesDomain == "" {
|
||||
cfg.FilesDomain = "files." + cfg.Domain
|
||||
|
||||
Reference in New Issue
Block a user