Compare commits

...

2 Commits

Author SHA1 Message Date
grendervill f61751ed26 feat(api): REST на chi + huma с auth-ручками и OpenAPI 3.1
- переход на chi + huma (решение D-006): huma отдаёт типизированные ручки и
  генерирует документ, auth-ручки живут на chi (cookie и заголовки напрямую)
- единый формат ошибок: код (auth.invalid_credentials, auth.2fa_required,
  perm.denied и т.д.) + человекочитаемое сообщение (AGENT.md 8.5)
- cookie сессии __Host-session: HttpOnly, SameSite=Lax, Secure при TLS;
  альтернатива — Bearer-токен для desktop/CLI (AGENT.md 8.1)
- ручки: register, login, logout, logout-all, sessions, step-up, 2fa/setup,
  2fa/enable, users/@me; IP и User-Agent прокидываются из запроса в контекст
- /api/v1/openapi.json: объединённый документ (схемы huma + контракт auth)
- тесты: регистрация через API с cookie, ошибки входа, обязательная сессия,
  валидация, наличие всех путей в OpenAPI
2026-09-19 21:36:00 +03:00
grendervill 16218ee045 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, смена пароля,
  выключенная регистрация, отсутствие секретов в логах
2026-09-19 21:29:07 +03:00
20 changed files with 12278 additions and 172 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, собственные или
+9 -1
View File
@@ -13,9 +13,11 @@ import (
"syscall" "syscall"
"time" "time"
"glchat/internal/auth"
"glchat/internal/config" "glchat/internal/config"
"glchat/internal/database" "glchat/internal/database"
"glchat/internal/server" "glchat/internal/server"
"glchat/internal/store"
) )
// Build metadata is injected with -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=...". // Build metadata is injected with -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=...".
@@ -129,7 +131,13 @@ func run() error {
stopMaintenance := db.RunMaintenance(ctx, logger, cfg.Maintenance) stopMaintenance := db.RunMaintenance(ctx, logger, cfg.Maintenance)
defer stopMaintenance() defer stopMaintenance()
srv := server.New(cfg, db, logger) st := store.New(db)
authService, err := auth.New(ctx, cfg, st, logger)
if err != nil {
return fmt.Errorf("initialize authentication: %w", err)
}
srv := server.New(cfg, db, logger, server.Deps{Store: st, Auth: authService})
errCh := make(chan error, 1) errCh := make(chan error, 1)
go func() { go func() {
logger.Info("http server listening", logger.Info("http server listening",
+4
View File
@@ -3,12 +3,16 @@ module glchat
go 1.26.0 go 1.26.0
require ( require (
github.com/danielgtaylor/huma/v2 v2.39.1
github.com/go-chi/chi/v5 v5.3.2
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
+12
View File
@@ -1,5 +1,12 @@
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/danielgtaylor/huma/v2 v2.39.1 h1:0kwF4ltQoYZ+IU55VPy+BcGekzgF44R64daTGde1H+g=
github.com/danielgtaylor/huma/v2 v2.39.1/go.mod h1:zcnQ38duIJ3VUHwFaBoZ6x8T+KN/mr33oyqxcj0HTug=
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/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
@@ -10,12 +17,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
}
+15
View File
@@ -29,6 +29,14 @@ type Config struct {
ReadPoolSize int ReadPoolSize int
MaxUploadSize int64 MaxUploadSize int64
TLSEnabled bool TLSEnabled bool
// Секреты инстанса (генерируются установщиком, AGENT.md §10.3).
SessionPepper string
MasterKey string
TOTPEncryptionKey string
SessionTTLHours int
Argon2MemoryKiB int
Argon2Iterations int
Argon2Parallelism int
Version string Version string
Commit string Commit string
BuildDate string BuildDate string
@@ -52,6 +60,13 @@ func Load() (Config, error) {
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),
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"), Version: env("APP_VERSION", "dev"),
Commit: env("APP_COMMIT", "none"), Commit: env("APP_COMMIT", "none"),
BuildDate: env("APP_BUILD_DATE", "unknown"), BuildDate: env("APP_BUILD_DATE", "unknown"),
+31
View File
@@ -1,8 +1,10 @@
package httpx package httpx
import ( import (
"context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"net/http"
) )
func newRequestID() string { func newRequestID() string {
@@ -12,3 +14,32 @@ func newRequestID() string {
} }
return hex.EncodeToString(buf[:]) return hex.EncodeToString(buf[:])
} }
type requestInfoKey struct{}
// RequestInfo — данные исходного запроса, нужные сервисам (IP, User-Agent).
type RequestInfo struct {
IP string
UserAgent string
}
// WithRequestInfo кладёт данные запроса в контекст (используется HTTP-слоем).
func WithRequestInfo(ctx context.Context, r *http.Request) context.Context {
return context.WithValue(ctx, requestInfoKey{}, RequestInfo{
IP: ClientIP(r, nil),
UserAgent: r.Header.Get("User-Agent"),
})
}
func requestInfo(ctx context.Context) RequestInfo {
if info, ok := ctx.Value(requestInfoKey{}).(RequestInfo); ok {
return info
}
return RequestInfo{}
}
// ClientIPFromContext возвращает IP клиента для контекста huma.
func ClientIPFromContext(ctx context.Context) string { return requestInfo(ctx).IP }
// UserAgentFromContext возвращает User-Agent для контекста huma.
func UserAgentFromContext(ctx context.Context) string { return requestInfo(ctx).UserAgent }
+8
View File
@@ -44,6 +44,14 @@ func (r *statusRecorder) Flush() {
} }
} }
// RequestInfoMiddleware кладёт IP и User-Agent запроса в контекст: huma-хендлеры
// не получают *http.Request, а сервисам эти данные нужны (аудит, безопасность).
func RequestInfoMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r.WithContext(WithRequestInfo(r.Context(), r)))
})
}
func RequestID(next http.Handler) http.Handler { func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-Id") id := r.Header.Get("X-Request-Id")
+335
View File
@@ -0,0 +1,335 @@
package server
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"glchat/internal/auth"
"glchat/internal/httpx"
"glchat/internal/store"
)
// sessionCookieName — имя cookie сессии (AGENT.md 8.1: префикс __Host-).
const sessionCookieName = "__Host-session"
const sessionCookiePath = "/"
// sessionCookie собирает cookie сессии: HttpOnly, SameSite=Lax и Secure при TLS.
// Secure выключается только для установок без TLS (--skip-tls, стенд за туннелем):
// в этом режиме браузер не принимает Secure-cookie по http.
func (s *Server) sessionCookie(token string, expires time.Time) *http.Cookie {
return &http.Cookie{ //nolint:gosec // Secure зависит от TLS_ENABLED, HttpOnly и SameSite заданы
Name: sessionCookieName,
Value: token,
Path: sessionCookiePath,
HttpOnly: true,
Secure: s.cfg.TLSEnabled,
SameSite: http.SameSiteLaxMode,
Expires: expires.UTC(),
}
}
func (s *Server) clearSessionCookie() *http.Cookie {
return &http.Cookie{ //nolint:gosec // Secure зависит от TLS_ENABLED, HttpOnly и SameSite заданы
Name: sessionCookieName,
Value: "",
Path: sessionCookiePath,
HttpOnly: true,
Secure: s.cfg.TLSEnabled,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
}
}
// registerAuthRoutes вешает ручки аутентификации на chi: cookie и заголовки
// выставляются напрямую, а контракт описан в OpenAPI (docs.go).
func (s *Server) registerAuthRoutes(router chi.Router) {
router.Post("/auth/register", s.handleRegister)
router.Post("/auth/login", s.handleLogin)
router.Post("/auth/logout", s.handleLogout)
router.Post("/auth/logout-all", s.handleLogoutAll)
router.Get("/auth/sessions", s.handleListSessions)
router.Post("/auth/step-up", s.handleStepUp)
router.Post("/auth/2fa/setup", s.handleSetupTOTP)
router.Post("/auth/2fa/enable", s.handleEnableTOTP)
router.Get("/users/@me", s.handleGetMe)
}
type registerRequest struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Password string `json:"password"`
Locale string `json:"locale"`
}
type currentUserPayload struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Bio string `json:"bio"`
Status string `json:"status"`
CustomStatus string `json:"custom_status"`
AvatarFileID string `json:"avatar_file_id,omitempty"`
BannerFileID string `json:"banner_file_id,omitempty"`
IsInstanceAdmin bool `json:"is_instance_admin"`
Badges []string `json:"badges"`
Locale string `json:"locale"`
}
func userPayload(user *store.User) currentUserPayload {
payload := currentUserPayload{
ID: formatSnowflake(user.ID),
Username: user.Username,
DisplayName: user.DisplayName,
Bio: user.Bio,
Status: user.Status,
CustomStatus: user.CustomStatus,
IsInstanceAdmin: user.IsInstanceAdmin,
Badges: user.Badges,
Locale: user.Locale,
}
if payload.Badges == nil {
payload.Badges = []string{}
}
if user.AvatarFileID != nil {
payload.AvatarFileID = formatSnowflake(*user.AvatarFileID)
}
if user.BannerFileID != nil {
payload.BannerFileID = formatSnowflake(*user.BannerFileID)
}
return payload
}
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
writeAPIError(w, auth.ErrSessionExpired)
return
}
var request registerRequest
if !decodeBody(w, r, &request) {
return
}
user, token, session, err := s.auth.Register(r.Context(), auth.RegisterInput{
Username: request.Username,
DisplayName: request.DisplayName,
Email: request.Email,
Password: request.Password,
Locale: request.Locale,
IP: httpx.ClientIPFromContext(r.Context()),
UserAgent: httpx.UserAgentFromContext(r.Context()),
})
if err != nil {
writeAPIError(w, err)
return
}
http.SetCookie(w, s.sessionCookie(token, session.ExpiresAt))
writeJSON(w, map[string]any{"user": userPayload(user)})
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
TOTPCode string `json:"totp_code"`
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if s.auth == nil {
writeAPIError(w, auth.ErrSessionExpired)
return
}
var request loginRequest
if !decodeBody(w, r, &request) {
return
}
user, token, session, err := s.auth.Login(r.Context(), auth.LoginInput{
Email: request.Email,
Password: request.Password,
TOTPCode: request.TOTPCode,
IP: httpx.ClientIPFromContext(r.Context()),
UserAgent: httpx.UserAgentFromContext(r.Context()),
})
if err != nil {
writeAPIError(w, err)
return
}
http.SetCookie(w, s.sessionCookie(token, session.ExpiresAt))
writeJSON(w, map[string]any{"user": userPayload(user)})
}
// authenticate читает сессию из cookie или Bearer-токена (desktop, AGENT.md 8.1).
func (s *Server) authenticate(w http.ResponseWriter, r *http.Request) (*store.User, *store.Session, bool) {
if s.auth == nil {
writeAPIError(w, auth.ErrSessionExpired)
return nil, nil, false
}
token := ""
if cookie, err := r.Cookie(sessionCookieName); err == nil {
token = cookie.Value
}
if token == "" {
token = normalizeBearer(r.Header.Get("Authorization"))
}
if token == "" {
writeAPIError(w, auth.ErrSessionExpired)
return nil, nil, false
}
user, session, err := s.auth.ResolveSession(r.Context(), token)
if err != nil {
writeAPIError(w, err)
return nil, nil, false
}
return user, session, true
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
_, session, ok := s.authenticate(w, r)
if !ok {
// Выход без валидной сессии не ошибка: cookie всё равно очищаем.
http.SetCookie(w, s.clearSessionCookie())
writeJSON(w, map[string]any{"ok": true})
return
}
if err := s.auth.Logout(r.Context(), session.ID); err != nil {
writeAPIError(w, err)
return
}
http.SetCookie(w, s.clearSessionCookie())
writeJSON(w, map[string]any{"ok": true})
}
func (s *Server) handleLogoutAll(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
if err := s.auth.LogoutAll(r.Context(), user.ID); err != nil {
writeAPIError(w, err)
return
}
http.SetCookie(w, s.clearSessionCookie())
writeJSON(w, map[string]any{"ok": true})
}
type sessionPayload struct {
ID string `json:"id"`
UserAgent string `json:"user_agent"`
IP string `json:"ip"`
CreatedAt string `json:"created_at"`
LastSeen string `json:"last_seen"`
ExpiresAt string `json:"expires_at"`
Current bool `json:"current"`
SteppedUp bool `json:"stepped_up"`
}
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
user, current, ok := s.authenticate(w, r)
if !ok {
return
}
sessions, err := s.store.ListSessions(r.Context(), user.ID)
if err != nil {
writeAPIError(w, err)
return
}
now := time.Now().UTC()
payload := make([]sessionPayload, 0, len(sessions))
for _, session := range sessions {
payload = append(payload, sessionPayload{
ID: formatSnowflake(session.ID),
UserAgent: session.UserAgent,
IP: session.IP,
CreatedAt: session.CreatedAt.Format(time.RFC3339),
LastSeen: session.LastSeen.Format(time.RFC3339),
ExpiresAt: session.ExpiresAt.Format(time.RFC3339),
Current: session.ID == current.ID,
SteppedUp: session.SteppedUp(now),
})
}
writeJSON(w, map[string]any{"sessions": payload})
}
func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
writeJSON(w, map[string]any{"user": userPayload(user)})
}
type totpEnableRequest struct {
Code string `json:"code"`
}
func (s *Server) handleSetupTOTP(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
setup, err := s.auth.SetupTOTP(r.Context(), user.ID)
if err != nil {
writeAPIError(w, err)
return
}
writeJSON(w, map[string]any{
"secret": setup.Secret,
"url": setup.URL,
"issuer": setup.IssuerName,
})
}
func (s *Server) handleEnableTOTP(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
var request totpEnableRequest
if !decodeBody(w, r, &request) {
return
}
codes, err := s.auth.EnableTOTP(r.Context(), user.ID, request.Code)
if err != nil {
writeAPIError(w, err)
return
}
writeJSON(w, map[string]any{"recovery_codes": codes})
}
type stepUpRequest struct {
Password string `json:"password"`
TOTPCode string `json:"totp_code"`
}
func (s *Server) handleStepUp(w http.ResponseWriter, r *http.Request) {
user, session, ok := s.authenticate(w, r)
if !ok {
return
}
var request stepUpRequest
if !decodeBody(w, r, &request) {
return
}
if err := s.auth.RequireStepUp(r.Context(), user, session, request.Password, request.TOTPCode); err != nil {
writeAPIError(w, err)
return
}
writeJSON(w, map[string]any{"ok": true})
}
// formatSnowflake сериализует идентификатор строкой: JS не хранит uint64
// без потери точности (AGENT.md 8.1).
func formatSnowflake(id uint64) string {
if id == 0 {
return ""
}
var buf [20]byte
pos := len(buf)
for id > 0 {
pos--
buf[pos] = byte('0' + id%10)
id /= 10
}
return string(buf[pos:])
}
+118
View File
@@ -0,0 +1,118 @@
package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func doJSON(t *testing.T, srv *Server, method, path, body string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequestWithContext(context.Background(), method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
for _, cookie := range cookies {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
return rec
}
func TestRegisterEndpointCreatesSession(t *testing.T) {
srv, _ := newTestServer(t)
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register",
`{"username":"api_user","email":"api@example.com","password":"correct-horse-battery"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var payload struct {
User struct {
ID string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
} `json:"user"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode body: %v", err)
}
if payload.User.Username != "api_user" || payload.User.ID == "" {
t.Fatalf("unexpected user payload: %s", rec.Body.String())
}
cookies := rec.Result().Cookies()
if len(cookies) == 0 || cookies[0].Name != sessionCookieName {
t.Fatalf("session cookie is missing: %v", cookies)
}
if !cookies[0].HttpOnly {
t.Fatal("session cookie must be HttpOnly")
}
// С полученной cookie доступен профиль.
me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookies[0])
if me.Code != http.StatusOK {
t.Fatalf("GET /users/@me = %d, body = %s", me.Code, me.Body.String())
}
}
func TestLoginEndpointErrors(t *testing.T) {
srv, _ := newTestServer(t)
doJSON(t, srv, http.MethodPost, "/api/v1/auth/register",
`{"username":"login_user","email":"login@example.com","password":"correct-horse-battery"}`)
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
`{"email":"login@example.com","password":"wrong-password"}`)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("wrong password status = %d, want 401", rec.Code)
}
if !strings.Contains(rec.Body.String(), "invalid credentials") {
t.Fatalf("unexpected error body: %s", rec.Body.String())
}
rec = doJSON(t, srv, http.MethodPost, "/api/v1/auth/login",
`{"email":"login@example.com","password":"correct-horse-battery"}`)
if rec.Code != http.StatusOK {
t.Fatalf("valid login status = %d, body = %s", rec.Code, rec.Body.String())
}
}
func TestAuthenticatedEndpointsRequireSession(t *testing.T) {
srv, _ := newTestServer(t)
for _, path := range []string{"/api/v1/users/@me", "/api/v1/auth/sessions"} {
rec := doJSON(t, srv, http.MethodGet, path, "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("GET %s without session = %d, want 401", path, rec.Code)
}
}
}
func TestValidationRejectsShortPassword(t *testing.T) {
srv, _ := newTestServer(t)
rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register",
`{"username":"short_user","email":"short@example.com","password":"short"}`)
if rec.Code == http.StatusOK {
t.Fatalf("short password accepted: %s", rec.Body.String())
}
}
func TestOpenAPIDocumentsAuthEndpoints(t *testing.T) {
srv, _ := newTestServer(t)
rec := doJSON(t, srv, http.MethodGet, "/api/v1/openapi.json", "")
if rec.Code != http.StatusOK {
t.Fatalf("openapi status = %d", rec.Code)
}
var doc struct {
Paths map[string]any `json:"paths"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
t.Fatalf("decode openapi: %v", err)
}
for _, path := range []string{"/auth/register", "/auth/login", "/auth/logout", "/auth/sessions", "/users/@me", "/auth/2fa/setup", "/meta"} {
if _, ok := doc.Paths[path]; !ok {
t.Errorf("openapi is missing %s", path)
}
}
}
+104
View File
@@ -0,0 +1,104 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"glchat/internal/auth"
"glchat/internal/httpx"
"glchat/internal/permissions"
"glchat/internal/store"
)
// apiError — доменная ошибка с кодом для клиента (AGENT.md 8.5).
type apiError struct {
Status int `json:"-"`
Code string `json:"code"`
Message string `json:"message"`
cause error
}
func (e apiError) Error() string { return e.Code + ": " + e.Message }
func (e apiError) Unwrap() error { return e.cause }
// newAPIError подбирает код и статус по доменной ошибке.
func newAPIError(err error) apiError {
candidate := apiError{Status: http.StatusInternalServerError, Code: "internal.error", Message: "internal error", cause: err}
switch {
case errors.Is(err, auth.ErrInvalidCredentials):
candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.invalid_credentials", "invalid credentials"
case errors.Is(err, auth.ErrTOTPRequired):
candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.2fa_required", "two-factor code required"
case errors.Is(err, auth.ErrTOTPInvalid):
candidate.Status, candidate.Code, candidate.Message = http.StatusBadRequest, "auth.totp_invalid", "invalid two-factor code"
case errors.Is(err, auth.ErrInstanceAdminTOTP):
candidate.Status, candidate.Code = http.StatusForbidden, "auth.2fa_required"
candidate.Message = "instance administrators must enable two-factor authentication"
case errors.Is(err, auth.ErrSessionExpired):
candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.session_expired", "session expired"
case errors.Is(err, auth.ErrStepUpRequired):
candidate.Status, candidate.Code = http.StatusForbidden, "auth.step_up_required"
candidate.Message = "step-up authentication required"
case errors.Is(err, auth.ErrUsernameTaken):
candidate.Status, candidate.Code, candidate.Message = http.StatusConflict, "auth.username_taken", "username is already taken"
case errors.Is(err, auth.ErrEmailTaken):
candidate.Status, candidate.Code, candidate.Message = http.StatusConflict, "auth.email_taken", "email is already registered"
case errors.Is(err, auth.ErrRegistrationOff):
candidate.Status, candidate.Code = http.StatusForbidden, "auth.registration_disabled"
candidate.Message = "registration is disabled"
case errors.Is(err, auth.ErrWeakPassword):
candidate.Status, candidate.Code, candidate.Message = http.StatusUnprocessableEntity, "auth.weak_password", err.Error()
case errors.Is(err, auth.ErrInvalidUsername):
candidate.Status, candidate.Code, candidate.Message = http.StatusUnprocessableEntity, "auth.invalid_username", err.Error()
case errors.Is(err, auth.ErrTOTPAlreadyEnabled):
candidate.Status, candidate.Code = http.StatusConflict, "auth.2fa_already_enabled"
candidate.Message = "two-factor authentication is already enabled"
case errors.Is(err, auth.ErrNoTOTPSecret):
candidate.Status, candidate.Code = http.StatusBadRequest, "auth.2fa_not_configured"
candidate.Message = "two-factor authentication is not configured"
case errors.Is(err, store.ErrNotFound):
candidate.Status, candidate.Code, candidate.Message = http.StatusNotFound, "not_found", "resource not found"
case errors.Is(err, store.ErrConflict):
candidate.Status, candidate.Code, candidate.Message = http.StatusConflict, "conflict", "resource already exists"
case errors.Is(err, permissions.ErrDenied):
candidate.Status, candidate.Code, candidate.Message = http.StatusForbidden, "perm.denied", "permission denied"
}
return candidate
}
// writeAPIError отдаёт ошибку в едином формате с машиночитаемым кодом.
func writeAPIError(w http.ResponseWriter, err error) {
apiErr := newAPIError(err)
if apiErr.Status >= http.StatusInternalServerError {
apiErr.Message = "internal error"
}
httpx.WriteJSON(w, apiErr.Status, map[string]any{
"error": map[string]any{
"code": apiErr.Code,
"message": apiErr.Message,
},
})
}
// writeJSON пишет успешный ответ (все текущие ручки возвращают 200).
func writeJSON(w http.ResponseWriter, body any) {
httpx.WriteJSON(w, http.StatusOK, body)
}
// decodeBody читает JSON-тело с ограничением размера.
func decodeBody(w http.ResponseWriter, r *http.Request, dst any) bool {
if r.Body == nil {
writeAPIError(w, errors.New("empty request body"))
return false
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(dst); err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]any{
"error": map[string]any{"code": "request.bad", "message": "malformed json body"},
})
return false
}
return true
}
-29
View File
@@ -7,31 +7,6 @@ import (
"glchat/internal/httpx" "glchat/internal/httpx"
) )
// methodOfPattern splits a Go 1.22 routing pattern such as "GET /api/v1/meta"
// into its method and path parts. Patterns without a method apply to all.
func methodOfPattern(pattern string) (method, path string) {
if i := strings.IndexByte(pattern, ' '); i > 0 {
return pattern[:i], pattern[i+1:]
}
return "", pattern
}
func (s *Server) routeExists(method, path string) bool {
for _, pattern := range s.patterns {
patternMethod, patternPath := methodOfPattern(pattern)
if patternMethod == "" || patternMethod == method {
continue
}
if patternPath == path {
return true
}
if strings.HasSuffix(patternPath, "/") && strings.HasPrefix(path, patternPath) {
return true
}
}
return false
}
// reservedPrefixes are handled by the API, the gateway or the file CDN; an // reservedPrefixes are handled by the API, the gateway or the file CDN; an
// unknown path under them is a real 404 and must not receive the SPA shell. // unknown path under them is a real 404 and must not receive the SPA shell.
var reservedPrefixes = []string{"/api/", "/gateway", "/files/", "/rtc"} var reservedPrefixes = []string{"/api/", "/gateway", "/files/", "/rtc"}
@@ -46,10 +21,6 @@ func isReservedPath(path string) bool {
} }
func (s *Server) handleFallback(w http.ResponseWriter, r *http.Request) { func (s *Server) handleFallback(w http.ResponseWriter, r *http.Request) {
if s.routeExists(r.Method, r.URL.Path) {
httpx.WriteErrorStatus(w, http.StatusMethodNotAllowed, httpx.CodeBadRequest, "method not allowed")
return
}
if isReservedPath(r.URL.Path) { if isReservedPath(r.URL.Path) {
httpx.WriteError(w, httpx.NewError(httpx.CodeNotFound, "resource not found")) httpx.WriteError(w, httpx.NewError(httpx.CodeNotFound, "resource not found"))
return return
+247 -51
View File
@@ -1,65 +1,264 @@
package server package server
// openAPIDocument is the hand-maintained OpenAPI 3.1 contract for the endpoints import (
// implemented so far. Phase 1 replaces it with a schema generated from typed "encoding/json"
// handler definitions (AGENT.md 8.1). "log/slog"
var openAPIDocument = []byte(`{ "net/http"
"openapi": "3.1.0", )
"info": {
"title": "glchat API", // handleOpenAPI отдаёт объединённый документ OpenAPI 3.1: пути, описанные
"version": "0.1.0", // huma (meta и служебные ручки), плюс контракт auth-ручек, которые живут
"description": "Self-hosted communication platform. Phase 0 exposes health and metadata endpoints only.", // на chi и описаны в authPathsJSON (AGENT.md 8.1: единый источник типов).
"license": { "name": "AGPL-3.0-or-later", "identifier": "AGPL-3.0-or-later" } func (s *Server) handleOpenAPI(w http.ResponseWriter, r *http.Request) {
document := map[string]any{}
if body, err := json.Marshal(s.api.OpenAPI()); err == nil {
if err := json.Unmarshal(body, &document); err != nil {
s.logger.ErrorContext(r.Context(), "decode generated openapi", slog.Any("error", err))
}
}
if document == nil {
document = map[string]any{}
}
paths, _ := document["paths"].(map[string]any)
if paths == nil {
paths = map[string]any{}
}
var extra map[string]any
if err := json.Unmarshal([]byte(authPathsJSON), &extra); err != nil {
s.logger.ErrorContext(r.Context(), "decode auth openapi paths", slog.Any("error", err))
}
for path, item := range extra {
paths[path] = item
}
document["paths"] = paths
if components, ok := document["components"].(map[string]any); ok {
if schemas, ok := components["schemas"].(map[string]any); ok {
var extraSchemas map[string]any
if err := json.Unmarshal([]byte(authSchemasJSON), &extraSchemas); err == nil {
for name, schema := range extraSchemas {
schemas[name] = schema
}
}
}
}
body, err := json.Marshal(document)
if err != nil {
httpxWriteInternalError(w)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
if _, err := w.Write(body); err != nil {
s.logger.ErrorContext(r.Context(), "write openapi document", slog.Any("error", err))
}
}
func httpxWriteInternalError(w http.ResponseWriter) {
http.Error(w, "internal error", http.StatusInternalServerError)
}
// authPathsJSON — контракт ручек аутентификации (chi-обработчики).
const authPathsJSON = `{
"/auth/register": {
"post": {
"operationId": "register",
"summary": "Регистрация по email и паролю",
"tags": ["Auth"],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/RegisterRequest" } } }
}, },
"servers": [{ "url": "/api/v1" }],
"paths": {
"/meta": {
"get": {
"operationId": "getMeta",
"summary": "Instance metadata, API version and feature flags",
"tags": ["Meta"],
"responses": { "responses": {
"200": { "200": { "description": "Аккаунт создан, сессия выдана", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AuthResponse" } } } },
"description": "Instance metadata", "403": { "description": "Регистрация выключена", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"content": { "409": { "description": "Email или username заняты", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"application/json": { "422": { "description": "Пароль или username не проходят политику", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
"schema": { "$ref": "#/components/schemas/Meta" }
}
}
}
}
} }
} }
}, },
"components": { "/auth/login": {
"schemas": { "post": {
"Meta": { "operationId": "login",
"summary": "Вход по email и паролю (с TOTP при включённой 2FA)",
"tags": ["Auth"],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/LoginRequest" } } }
},
"responses": {
"200": { "description": "Вход выполнен, сессия выдана", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AuthResponse" } } } },
"401": { "description": "Неверные данные или требуется код 2FA (auth.2fa_required)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/auth/logout": {
"post": {
"operationId": "logout",
"summary": "Выход: отзывает текущую сессию",
"tags": ["Auth"],
"responses": { "200": { "description": "Сессия отозвана", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } } }
}
},
"/auth/logout-all": {
"post": {
"operationId": "logoutAll",
"summary": "Выйти везде: отзывает все сессии пользователя",
"tags": ["Auth"],
"responses": { "200": { "description": "Все сессии отозваны", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } } }
}
},
"/auth/sessions": {
"get": {
"operationId": "listSessions",
"summary": "Активные сессии пользователя",
"tags": ["Auth"],
"responses": { "200": { "description": "Список сессий", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionsResponse" } } } } }
}
},
"/auth/2fa/setup": {
"post": {
"operationId": "setupTOTP",
"summary": "Создать секрет TOTP (до подтверждения кодом)",
"tags": ["Auth"],
"responses": { "200": { "description": "Секрет и otpauth-URL", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TOTPSetup" } } } } }
}
},
"/auth/2fa/enable": {
"post": {
"operationId": "enableTOTP",
"summary": "Включить 2FA, подтвердив код; возвращает резервные коды",
"tags": ["Auth"],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/TOTPEnableRequest" } } }
},
"responses": { "200": { "description": "2FA включена", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecoveryCodes" } } } } }
}
},
"/auth/step-up": {
"post": {
"operationId": "stepUp",
"summary": "Подтвердить пароль (и 2FA) для чувствительных действий",
"tags": ["Auth"],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/StepUpRequest" } } }
},
"responses": { "200": { "description": "Аутентификация подтверждена", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } } }
}
},
"/users/@me": {
"get": {
"operationId": "getMe",
"summary": "Текущий пользователь",
"tags": ["Users"],
"responses": { "200": { "description": "Профиль пользователя", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserResponse" } } } } }
}
}
}`
// authSchemasJSON — схемы запросов и ответов auth-ручек.
const authSchemasJSON = `{
"RegisterRequest": {
"type": "object", "type": "object",
"required": ["name", "version", "api_version", "base_url", "features"], "required": ["username", "email", "password"],
"properties": { "properties": {
"name": { "type": "string" }, "username": { "type": "string", "minLength": 2, "maxLength": 32 },
"version": { "type": "string" }, "display_name": { "type": "string", "maxLength": 64 },
"commit": { "type": "string" }, "email": { "type": "string", "format": "email" },
"build_date": { "type": "string" }, "password": { "type": "string", "minLength": 10 },
"api_version": { "type": "string", "enum": ["v1"] }, "locale": { "type": "string", "enum": ["ru", "en"] }
"base_url": { "type": "string" }, }
"files_url": { "type": "string" }, },
"gateway_url": { "type": "string" }, "LoginRequest": {
"rtc_path": { "type": "string" },
"max_upload_size": { "type": "integer", "format": "int64" },
"features": {
"type": "object", "type": "object",
"required": ["email", "password"],
"properties": { "properties": {
"registration_enabled": { "type": "boolean" }, "email": { "type": "string", "format": "email" },
"anti_bot_enabled": { "type": "boolean" }, "password": { "type": "string" },
"voice_enabled": { "type": "boolean" }, "totp_code": { "type": "string", "description": "Код TOTP или резервный код" }
"web_push_enabled": { "type": "boolean" },
"oauth_enabled": { "type": "boolean" },
"passkeys_enabled": { "type": "boolean" }
} }
},
"TOTPEnableRequest": {
"type": "object",
"required": ["code"],
"properties": { "code": { "type": "string", "minLength": 6 } }
},
"StepUpRequest": {
"type": "object",
"required": ["password"],
"properties": {
"password": { "type": "string" },
"totp_code": { "type": "string" }
} }
},
"User": {
"type": "object",
"required": ["id", "username", "display_name", "status", "is_instance_admin", "badges", "locale"],
"properties": {
"id": { "type": "string", "description": "Snowflake строкой" },
"username": { "type": "string" },
"display_name": { "type": "string" },
"bio": { "type": "string" },
"status": { "type": "string", "enum": ["online", "idle", "dnd", "invisible"] },
"custom_status": { "type": "string" },
"avatar_file_id": { "type": "string" },
"banner_file_id": { "type": "string" },
"is_instance_admin": { "type": "boolean" },
"badges": { "type": "array", "items": { "type": "string" } },
"locale": { "type": "string", "enum": ["ru", "en"] }
} }
}, },
"AuthResponse": {
"type": "object",
"required": ["user"],
"properties": { "user": { "$ref": "#/components/schemas/User" } }
},
"UserResponse": {
"type": "object",
"required": ["user"],
"properties": { "user": { "$ref": "#/components/schemas/User" } }
},
"Session": {
"type": "object",
"required": ["id", "created_at", "last_seen", "expires_at", "current", "stepped_up"],
"properties": {
"id": { "type": "string" },
"user_agent": { "type": "string" },
"ip": { "type": "string" },
"created_at": { "type": "string", "format": "date-time" },
"last_seen": { "type": "string", "format": "date-time" },
"expires_at": { "type": "string", "format": "date-time" },
"current": { "type": "boolean" },
"stepped_up": { "type": "boolean" }
}
},
"SessionsResponse": {
"type": "object",
"required": ["sessions"],
"properties": { "sessions": { "type": "array", "items": { "$ref": "#/components/schemas/Session" } } }
},
"TOTPSetup": {
"type": "object",
"required": ["secret", "url"],
"properties": {
"secret": { "type": "string" },
"url": { "type": "string" },
"issuer": { "type": "string" }
}
},
"RecoveryCodes": {
"type": "object",
"required": ["recovery_codes"],
"properties": { "recovery_codes": { "type": "array", "items": { "type": "string" } } }
},
"OkResponse": {
"type": "object",
"required": ["ok"],
"properties": { "ok": { "type": "boolean" } }
},
"Error": { "Error": {
"type": "object", "type": "object",
"required": ["error"], "required": ["error"],
@@ -75,7 +274,4 @@ var openAPIDocument = []byte(`{
} }
} }
} }
} }`
}
}
`)
+72 -25
View File
@@ -4,36 +4,62 @@ import (
"context" "context"
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humachi"
"github.com/go-chi/chi/v5"
"glchat/internal/auth"
"glchat/internal/config" "glchat/internal/config"
"glchat/internal/database" "glchat/internal/database"
"glchat/internal/httpx" "glchat/internal/httpx"
"glchat/internal/meta" "glchat/internal/meta"
"glchat/internal/store"
) )
// Deps — зависимости HTTP-слоя: хранилище и сервис аутентификации.
// В Фазе 0 они могут отсутствовать (инстанс без секретов ещё поднимается).
type Deps struct {
Store *store.Store
Auth *auth.Service
}
type Server struct { type Server struct {
cfg config.Config cfg config.Config
db *database.DB db *database.DB
store *store.Store
auth *auth.Service
logger *slog.Logger logger *slog.Logger
http *http.Server http *http.Server
static *staticHandler static *staticHandler
patterns []string api huma.API
} }
func New(cfg config.Config, db *database.DB, logger *slog.Logger) *Server { func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Server {
s := &Server{ s := &Server{
cfg: cfg, cfg: cfg,
db: db, db: db,
store: deps.Store,
auth: deps.Auth,
logger: logger, logger: logger,
patterns: append([]string(nil), routePatterns...),
static: newStaticHandler(cfg.WebRoot), static: newStaticHandler(cfg.WebRoot),
} }
mux := http.NewServeMux()
s.routes(mux) router := chi.NewRouter()
handler := httpx.Chain(mux, router.Route("/api/v1", func(apiRouter chi.Router) {
s.api = s.registerAPI(apiRouter)
s.registerMetaRoutes(s.api)
s.registerAuthRoutes(apiRouter)
apiRouter.Get("/openapi.json", s.handleOpenAPI)
})
s.registerRoutes(router)
handler := httpx.Chain(router,
httpx.SecurityHeaders(cfg.FilesDomain), httpx.SecurityHeaders(cfg.FilesDomain),
httpx.RequestID, httpx.RequestID,
httpx.RequestInfoMiddleware,
httpx.Logger(logger), httpx.Logger(logger),
httpx.Recoverer(logger), httpx.Recoverer(logger),
httpx.JSONBodyLimit(1<<20), httpx.JSONBodyLimit(1<<20),
@@ -50,20 +76,30 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger) *Server {
return s return s
} }
var routePatterns = []string{ // registerAPI создаёт huma-API: OpenAPI 3.1 и TS-типы выводятся из Go-типов
"GET /healthz", // (AGENT.md 8.1, решение D-006).
"GET /readyz", func (s *Server) registerAPI(router chi.Router) huma.API {
"GET /api/v1/meta", cfg := huma.DefaultConfig("glchat API", s.cfg.Version)
"GET /api/v1/openapi.json", cfg.Info.Description = "Self-hosted платформа общения: REST /api/v1 и WebSocket Gateway."
"/", cfg.Info.License = &huma.License{Name: "AGPL-3.0-or-later", Identifier: "AGPL-3.0-or-later"}
cfg.Servers = []*huma.Server{{URL: "/api/v1"}}
cfg.OpenAPIPath = "/openapi"
cfg.DocsPath = "/docs"
cfg.Components.SecuritySchemes = map[string]*huma.SecurityScheme{
"sessionCookie": {Type: "apiKey", In: "cookie", Name: sessionCookieName},
"bearerAuth": {Type: "http", Scheme: "bearer"},
}
return humachi.New(router, cfg)
} }
func (s *Server) routes(mux *http.ServeMux) { func (s *Server) registerRoutes(router chi.Router) {
mux.HandleFunc("GET /healthz", s.handleHealthz) router.Get("/healthz", s.handleHealthz)
mux.HandleFunc("GET /readyz", s.handleReadyz) router.Get("/readyz", s.handleReadyz)
mux.HandleFunc("GET /api/v1/meta", s.handleMeta)
mux.HandleFunc("GET /api/v1/openapi.json", s.handleOpenAPI) router.NotFound(s.handleFallback)
mux.HandleFunc("/", s.handleFallback) router.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) {
httpx.WriteErrorStatus(w, http.StatusMethodNotAllowed, httpx.CodeBadRequest, "method not allowed")
})
} }
func (s *Server) Handler() http.Handler { return s.http.Handler } func (s *Server) Handler() http.Handler { return s.http.Handler }
@@ -104,16 +140,27 @@ func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
}) })
} }
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) { type metaOutput struct {
httpx.WriteJSON(w, http.StatusOK, meta.New(s.cfg)) Body meta.Response
} }
func (s *Server) handleOpenAPI(w http.ResponseWriter, r *http.Request) { func (s *Server) registerMetaRoutes(api huma.API) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") huma.Register(api, huma.Operation{
w.WriteHeader(http.StatusOK) OperationID: "getMeta",
if _, err := w.Write(openAPIDocument); err != nil { Method: http.MethodGet,
s.logger.ErrorContext(r.Context(), "write openapi document", slog.Any("error", err)) Path: "/meta",
Summary: "Метаданные инстанса, версия API и флаги функций",
Tags: []string{"Meta"},
}, func(_ context.Context, _ *struct{}) (*metaOutput, error) {
return &metaOutput{Body: meta.New(s.cfg)}, nil
})
}
func normalizeBearer(value string) string {
if strings.HasPrefix(strings.ToLower(value), "bearer ") {
return strings.TrimSpace(value[7:])
} }
return strings.TrimSpace(value)
} }
var startedAt = time.Now() var startedAt = time.Now()
+14 -2
View File
@@ -10,8 +10,10 @@ import (
"strings" "strings"
"testing" "testing"
"glchat/internal/auth"
"glchat/internal/config" "glchat/internal/config"
"glchat/internal/database" "glchat/internal/database"
"glchat/internal/store"
) )
func newTestServer(t *testing.T) (*Server, *database.DB) { func newTestServer(t *testing.T) (*Server, *database.DB) {
@@ -42,11 +44,21 @@ func newTestServer(t *testing.T) (*Server, *database.DB) {
BuildDate: "2026-09-19T00:00:00Z", BuildDate: "2026-09-19T00:00:00Z",
MaxUploadSize: 26214400, MaxUploadSize: 26214400,
TLSEnabled: true, TLSEnabled: true,
SessionPepper: "test-pepper",
MasterKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
Argon2MemoryKiB: 1024,
Argon2Iterations: 1,
Argon2Parallelism: 1,
LogLevel: "error", LogLevel: "error",
LogFormat: "json", LogFormat: "json",
} }
logger := slog.New(slog.DiscardHandler) logger := slog.New(slog.DiscardHandler)
return New(cfg, db, logger), db st := store.New(db)
authService, err := auth.New(context.Background(), cfg, st, logger)
if err != nil {
t.Fatalf("initialize authentication: %v", err)
}
return New(cfg, db, logger, Deps{Store: st, Auth: authService}), db
} }
func TestHealthz(t *testing.T) { func TestHealthz(t *testing.T) {
@@ -221,7 +233,7 @@ func TestServeWebClientReportsMissingBundle(t *testing.T) {
cfg := config.Config{Domain: "localhost", WebRoot: t.TempDir(), LogFormat: "json", LogLevel: "error"} cfg := config.Config{Domain: "localhost", WebRoot: t.TempDir(), LogFormat: "json", LogLevel: "error"}
logger := slog.New(slog.DiscardHandler) logger := slog.New(slog.DiscardHandler)
srv := New(cfg, db, logger) srv := New(cfg, db, logger, Deps{})
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)) srv.Handler().ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil))