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:
2026-09-19 21:29:07 +03:00
parent a5205cc054
commit 16218ee045
10 changed files with 11294 additions and 24 deletions
+5
View File
@@ -107,6 +107,11 @@ linters:
- gosec - gosec
- errcheck - errcheck
- forbidigo - forbidigo
# В тестах пропуски результатов и вспомогательные
# переменные мешают читаемости сильнее, чем помогают.
- dogsled
- prealloc
- unparam
# store: идентификаторы Snowflake заведомо < MaxInt64 (преобразования # store: идентификаторы Snowflake заведомо < MaxInt64 (преобразования
# документированы в idToInt/intToID), SQL собирается из константных # документированы в idToInt/intToID), SQL собирается из константных
# фрагментов шаблона, а значения всегда передаются параметрами. # фрагментов шаблона, а значения всегда передаются параметрами.
+6
View File
@@ -41,6 +41,12 @@ Copyright (C) 2026 glchat contributors.
`web/package.json`/`web/package-lock.json`. Совместимость лицензий с AGPL-3.0 `web/package.json`/`web/package-lock.json`. Совместимость лицензий с AGPL-3.0
проверяется при добавлении каждой зависимости (AGENT.md §15). проверяется при добавлении каждой зависимости (AGENT.md §15).
## Данные
| Файл | Источник | Лицензия |
|---|---|---|
| `internal/auth/data/leaked-passwords.txt` | [SecLists](https://github.com/danielmiessler/SecLists) `Passwords/Common-Credentials/10k-most-common.txt` | MIT |
## Ассеты ## Ассеты
Встроенные звуки, иконки и изображения — только CC0, собственные или Встроенные звуки, иконки и изображения — только CC0, собственные или
+2
View File
@@ -4,11 +4,13 @@ go 1.26.0
require ( require (
github.com/mattn/go-sqlite3 v1.14.52 github.com/mattn/go-sqlite3 v1.14.52
github.com/pquerna/otp v1.5.0
github.com/pressly/goose/v3 v3.28.0 github.com/pressly/goose/v3 v3.28.0
golang.org/x/crypto v0.55.0 golang.org/x/crypto v0.55.0
) )
require ( require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/mfridman/interpolate v0.0.2 // indirect github.com/mfridman/interpolate v0.0.2 // indirect
github.com/sethvargo/go-retry v0.4.0 // indirect github.com/sethvargo/go-retry v0.4.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
+8
View File
@@ -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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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= 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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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 h1:D2M+iL31GmpZxSHOhX8mqyqAT3CXnokUmm0eKoSP+Vc=
github.com/pressly/goose/v3 v3.28.0/go.mod h1:v26MOuB8bL3kzzrt3Vqhb3R0PRVsl8hFQKdrht/L6Rk= 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw=
github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= 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 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
File diff suppressed because it is too large Load Diff
+55
View File
@@ -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) }
+485
View File
@@ -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)
}
+469
View File
@@ -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
}
+224
View File
@@ -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
View File
@@ -29,34 +29,49 @@ type Config struct {
ReadPoolSize int ReadPoolSize int
MaxUploadSize int64 MaxUploadSize int64
TLSEnabled bool TLSEnabled bool
Version string // Секреты инстанса (генерируются установщиком, AGENT.md §10.3).
Commit string SessionPepper string
BuildDate string MasterKey string
ShutdownTTL time.Duration TOTPEncryptionKey string
Maintenance time.Duration SessionTTLHours int
Argon2MemoryKiB int
Argon2Iterations int
Argon2Parallelism int
Version string
Commit string
BuildDate string
ShutdownTTL time.Duration
Maintenance time.Duration
} }
func Load() (Config, error) { func Load() (Config, error) {
cfg := Config{ cfg := Config{
Domain: env("DOMAIN", "localhost"), Domain: env("DOMAIN", "localhost"),
FilesDomain: env("FILES_DOMAIN", ""), FilesDomain: env("FILES_DOMAIN", ""),
InstanceName: env("INSTANCE_NAME", "glchat"), InstanceName: env("INSTANCE_NAME", "glchat"),
ListenAddr: env("LISTEN_ADDR", DefaultListenAddr), ListenAddr: env("LISTEN_ADDR", DefaultListenAddr),
DataDir: env("DATA_DIR", DefaultDataDir), DataDir: env("DATA_DIR", DefaultDataDir),
WebRoot: env("WEB_ROOT", "/app/web"), WebRoot: env("WEB_ROOT", "/app/web"),
DatabasePath: env("DATABASE_PATH", ""), DatabasePath: env("DATABASE_PATH", ""),
SQLiteCache: env("SQLITE_CACHE_SIZE", "-8000"), SQLiteCache: env("SQLITE_CACHE_SIZE", "-8000"),
SQLiteMmap: env("SQLITE_MMAP_SIZE", "134217728"), SQLiteMmap: env("SQLITE_MMAP_SIZE", "134217728"),
LogLevel: strings.ToLower(env("LOG_LEVEL", DefaultLogLevel)), LogLevel: strings.ToLower(env("LOG_LEVEL", DefaultLogLevel)),
LogFormat: strings.ToLower(env("LOG_FORMAT", "json")), LogFormat: strings.ToLower(env("LOG_FORMAT", "json")),
ReadPoolSize: envInt("SQLITE_READ_POOL", 4), ReadPoolSize: envInt("SQLITE_READ_POOL", 4),
MaxUploadSize: envInt64("MAX_UPLOAD_SIZE", 26214400), MaxUploadSize: envInt64("MAX_UPLOAD_SIZE", 26214400),
TLSEnabled: envBool("TLS_ENABLED", true), TLSEnabled: envBool("TLS_ENABLED", true),
Version: env("APP_VERSION", "dev"), SessionPepper: env("SESSION_PEPPER", ""),
Commit: env("APP_COMMIT", "none"), MasterKey: env("MASTER_KEY", ""),
BuildDate: env("APP_BUILD_DATE", "unknown"), TOTPEncryptionKey: env("TOTP_ENCRYPTION_KEY", ""),
ShutdownTTL: time.Duration(envInt("SHUTDOWN_TIMEOUT_SECONDS", 20)) * time.Second, SessionTTLHours: envInt("SESSION_TTL_HOURS", 720),
Maintenance: time.Duration(envInt("DB_MAINTENANCE_INTERVAL_SECONDS", 21600)) * time.Second, 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 == "" { if cfg.FilesDomain == "" {
cfg.FilesDomain = "files." + cfg.Domain cfg.FilesDomain = "files." + cfg.Domain