Compare commits
2 Commits
a5205cc054
...
f61751ed26
| Author | SHA1 | Date | |
|---|---|---|---|
| f61751ed26 | |||
| 16218ee045 |
@@ -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 собирается из константных
|
||||||
# фрагментов шаблона, а значения всегда передаются параметрами.
|
# фрагментов шаблона, а значения всегда передаются параметрами.
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,55 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"glchat/internal/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed data/leaked-passwords.txt
|
||||||
|
var leakedPasswordsFS embed.FS
|
||||||
|
|
||||||
|
// passwordPolicy проверяет пароль по локальному словарю утечек: внешние API
|
||||||
|
// запрещены (AGENT.md 7.1).
|
||||||
|
type passwordPolicy struct {
|
||||||
|
leaked map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPasswordPolicy() (*passwordPolicy, error) {
|
||||||
|
file, err := leakedPasswordsFS.Open("data/leaked-passwords.txt")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open leaked password list: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
leaked := make(map[string]struct{}, 10000)
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
word := strings.TrimSpace(scanner.Text())
|
||||||
|
if word == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
leaked[strings.ToLower(word)] = struct{}{}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("read leaked password list: %w", err)
|
||||||
|
}
|
||||||
|
return &passwordPolicy{leaked: leaked}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate проверяет длину и отсутствие пароля в словаре утечек.
|
||||||
|
func (p *passwordPolicy) validate(password string) error {
|
||||||
|
if err := crypto.ValidatePassword(password); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, found := p.leaked[strings.ToLower(password)]; found {
|
||||||
|
return fmt.Errorf("password occurs in the leaked password list")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeakedCount возвращает размер словаря (для диагностики и метрик).
|
||||||
|
func (p *passwordPolicy) LeakedCount() int { return len(p.leaked) }
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
// Package auth реализует аккаунты, аутентификацию и сессии (AGENT.md 7.1):
|
||||||
|
// пароли Argon2id с pepper, шифрование PII, blind index по email, ротация
|
||||||
|
// сессий, TOTP и step-up.
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"glchat/internal/config"
|
||||||
|
"glchat/internal/crypto"
|
||||||
|
"glchat/internal/permissions"
|
||||||
|
"glchat/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidCredentials = errors.New("auth.invalid_credentials")
|
||||||
|
ErrEmailTaken = errors.New("auth.email_taken")
|
||||||
|
ErrUsernameTaken = errors.New("auth.username_taken")
|
||||||
|
ErrRegistrationOff = errors.New("auth.registration_disabled")
|
||||||
|
ErrTOTPRequired = errors.New("auth.2fa_required")
|
||||||
|
ErrTOTPInvalid = errors.New("auth.totp_invalid")
|
||||||
|
ErrSessionExpired = errors.New("auth.session_expired")
|
||||||
|
ErrStepUpRequired = errors.New("auth.step_up_required")
|
||||||
|
ErrWeakPassword = errors.New("auth.weak_password")
|
||||||
|
ErrInvalidUsername = errors.New("auth.invalid_username")
|
||||||
|
ErrInstanceAdminTOTP = errors.New("auth.instance_admin_requires_2fa")
|
||||||
|
ErrNoTOTPSecret = errors.New("auth.totp_not_configured")
|
||||||
|
ErrTOTPAlreadyEnabled = errors.New("auth.totp_already_enabled")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config — параметры сервиса аутентификации.
|
||||||
|
type Config struct {
|
||||||
|
SessionTTL time.Duration
|
||||||
|
MaxLoginFails int
|
||||||
|
LockoutWindow time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service — операции с аккаунтами и сессиями.
|
||||||
|
type Service struct {
|
||||||
|
store *store.Store
|
||||||
|
hasher *crypto.PasswordHasher
|
||||||
|
masterKey *crypto.MasterKey
|
||||||
|
totpHasher *crypto.PasswordHasher
|
||||||
|
settings instanceSettings
|
||||||
|
policy *passwordPolicy
|
||||||
|
cfg Config
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
type instanceSettings interface {
|
||||||
|
InstanceSettings(ctx context.Context) (*store.InstanceSettings, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New собирает сервис из конфигурации: pepper и master key обязательны
|
||||||
|
// (AGENT.md 9.2 — без них инстанс не должен подниматься).
|
||||||
|
func New(ctx context.Context, cfg config.Config, st *store.Store, logger *slog.Logger) (*Service, error) {
|
||||||
|
if cfg.SessionPepper == "" {
|
||||||
|
return nil, fmt.Errorf("SESSION_PEPPER is required")
|
||||||
|
}
|
||||||
|
if cfg.MasterKey == "" {
|
||||||
|
return nil, fmt.Errorf("MASTER_KEY is required")
|
||||||
|
}
|
||||||
|
masterKey, err := crypto.ParseMasterKey(cfg.MasterKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse MASTER_KEY: %w", err)
|
||||||
|
}
|
||||||
|
argonParams, err := argon2ParamsFromConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hasher, err := crypto.NewPasswordHasher([]byte(cfg.SessionPepper), argonParams)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create password hasher: %w", err)
|
||||||
|
}
|
||||||
|
totpKey := cfg.TOTPEncryptionKey
|
||||||
|
if totpKey == "" {
|
||||||
|
totpKey = cfg.SessionPepper
|
||||||
|
}
|
||||||
|
totpHasher, err := crypto.NewPasswordHasher([]byte(totpKey), crypto.Argon2Params{
|
||||||
|
Memory: 8192, Iterations: 1, Parallelism: 1, SaltLength: 16, KeyLength: 32,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create totp hasher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
policy, err := newPasswordPolicy()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionTTL := time.Duration(cfg.SessionTTLHours) * time.Hour
|
||||||
|
if sessionTTL <= 0 {
|
||||||
|
sessionTTL = store.SessionTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Service{
|
||||||
|
store: st,
|
||||||
|
hasher: hasher,
|
||||||
|
masterKey: masterKey,
|
||||||
|
totpHasher: totpHasher,
|
||||||
|
settings: st,
|
||||||
|
policy: policy,
|
||||||
|
cfg: Config{SessionTTL: sessionTTL, MaxLoginFails: 5, LockoutWindow: 15 * time.Minute},
|
||||||
|
logger: logger,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// argon2ParamsFromConfig проверяет границы параметров Argon2id из env.
|
||||||
|
func argon2ParamsFromConfig(cfg config.Config) (crypto.Argon2Params, error) {
|
||||||
|
params := crypto.Argon2Params{SaltLength: 16, KeyLength: 32}
|
||||||
|
switch {
|
||||||
|
case cfg.Argon2MemoryKiB < 1024 || cfg.Argon2MemoryKiB > 4194304:
|
||||||
|
return params, fmt.Errorf("ARGON2_MEMORY_KIB must be between 1024 and 4194304")
|
||||||
|
case cfg.Argon2Iterations < 1 || cfg.Argon2Iterations > 10:
|
||||||
|
return params, fmt.Errorf("ARGON2_ITERATIONS must be between 1 and 10")
|
||||||
|
case cfg.Argon2Parallelism < 1 || cfg.Argon2Parallelism > 255:
|
||||||
|
return params, fmt.Errorf("ARGON2_PARALLELISM must be between 1 and 255")
|
||||||
|
}
|
||||||
|
params.Memory = uint32(cfg.Argon2MemoryKiB)
|
||||||
|
params.Iterations = uint32(cfg.Argon2Iterations)
|
||||||
|
params.Parallelism = uint8(cfg.Argon2Parallelism)
|
||||||
|
return params, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionTTL возвращает срок жизни сессии.
|
||||||
|
func (s *Service) SessionTTL() time.Duration { return s.cfg.SessionTTL }
|
||||||
|
|
||||||
|
// HashToken возвращает хэш токена для поиска сессии.
|
||||||
|
func (s *Service) HashToken(token string) string { return crypto.HashToken(token) }
|
||||||
|
|
||||||
|
type RegisterInput struct {
|
||||||
|
Username string
|
||||||
|
DisplayName string
|
||||||
|
Email string
|
||||||
|
Password string
|
||||||
|
Locale string
|
||||||
|
IP string
|
||||||
|
UserAgent string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register создаёт аккаунт, шифрует email, проверяет пароль и сразу выдаёт
|
||||||
|
// сессию (AGENT.md 7.1: регистрация без подтверждения письма).
|
||||||
|
func (s *Service) Register(ctx context.Context, in RegisterInput) (*store.User, string, *store.Session, error) {
|
||||||
|
settings, err := s.settings.InstanceSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", nil, fmt.Errorf("load instance settings: %w", err)
|
||||||
|
}
|
||||||
|
if !settings.RegistrationEnabled {
|
||||||
|
return nil, "", nil, ErrRegistrationOff
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(in.Username)
|
||||||
|
if err := crypto.ValidateUsername(username); err != nil {
|
||||||
|
return nil, "", nil, ErrInvalidUsername
|
||||||
|
}
|
||||||
|
if err := s.policy.validate(in.Password); err != nil {
|
||||||
|
return nil, "", nil, ErrWeakPassword
|
||||||
|
}
|
||||||
|
email := crypto.NormalizeEmail(in.Email)
|
||||||
|
if err := validateEmail(email); err != nil {
|
||||||
|
return nil, "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := s.hasher.Hash(in.Password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", nil, fmt.Errorf("hash password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := s.store.NextID()
|
||||||
|
emailEncrypted, err := s.encryptEmail(userID, email)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", nil, err
|
||||||
|
}
|
||||||
|
emailIndex := s.masterKey.BlindIndex(email)
|
||||||
|
|
||||||
|
user, err := s.store.CreateUser(ctx, store.CreateUserParams{
|
||||||
|
ID: userID,
|
||||||
|
Username: username,
|
||||||
|
DisplayName: defaultDisplayName(in.DisplayName, username),
|
||||||
|
EmailEnc: emailEncrypted,
|
||||||
|
EmailIndex: emailIndex,
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
Locale: in.Locale,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrConflict) {
|
||||||
|
// Различаем причины конфликта, не раскрывая лишнего наружу.
|
||||||
|
if existing, lookupErr := s.store.GetUserByUsername(ctx, username); lookupErr == nil && existing != nil {
|
||||||
|
return nil, "", nil, ErrUsernameTaken
|
||||||
|
}
|
||||||
|
return nil, "", nil, ErrEmailTaken
|
||||||
|
}
|
||||||
|
return nil, "", nil, fmt.Errorf("create user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.joinMainGuild(ctx, user.ID); err != nil {
|
||||||
|
s.logger.WarnContext(ctx, "failed to join main guild", slog.Any("error", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
token, session, err := s.createSession(ctx, user.ID, in.UserAgent, in.IP)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", nil, err
|
||||||
|
}
|
||||||
|
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "register", in.IP, in.UserAgent, "")
|
||||||
|
return user, token, session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginInput struct {
|
||||||
|
Email string
|
||||||
|
Password string
|
||||||
|
TOTPCode string
|
||||||
|
IP string
|
||||||
|
UserAgent string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login проверяет пароль, при включённом TOTP требует код, ротирует токен
|
||||||
|
// сессии (защита от session fixation) и пишет событие безопасности.
|
||||||
|
func (s *Service) Login(ctx context.Context, in LoginInput) (*store.User, string, *store.Session, error) {
|
||||||
|
user, err := s.userByEmail(ctx, in.Email)
|
||||||
|
if err != nil {
|
||||||
|
// Одинаковый ответ для «нет пользователя» и «неверный пароль».
|
||||||
|
return nil, "", nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.checkLockout(ctx, user.ID); err != nil {
|
||||||
|
return nil, "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.hasher.Verify(in.Password, user.PasswordHash); err != nil {
|
||||||
|
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "login_failed", in.IP, in.UserAgent, "")
|
||||||
|
return nil, "", nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
totp, err := s.store.GetTOTPSecret(ctx, user.ID)
|
||||||
|
switch {
|
||||||
|
case err == nil && totp.Enabled:
|
||||||
|
if in.TOTPCode == "" {
|
||||||
|
return nil, "", nil, ErrTOTPRequired
|
||||||
|
}
|
||||||
|
if err := s.verifyTOTP(ctx, totp, in.TOTPCode); err != nil {
|
||||||
|
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "2fa_failed", in.IP, in.UserAgent, "")
|
||||||
|
return nil, "", nil, err
|
||||||
|
}
|
||||||
|
case errors.Is(err, store.ErrNotFound) && user.IsInstanceAdmin:
|
||||||
|
// Инстанс-админ обязан иметь 2FA (AGENT.md 7.19).
|
||||||
|
return nil, "", nil, ErrInstanceAdminTOTP
|
||||||
|
}
|
||||||
|
|
||||||
|
token, session, err := s.createSession(ctx, user.ID, in.UserAgent, in.IP)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", nil, err
|
||||||
|
}
|
||||||
|
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "login", in.IP, in.UserAgent, "")
|
||||||
|
return user, token, session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveSession проверяет токен: возвращает пользователя и сессию.
|
||||||
|
func (s *Service) ResolveSession(ctx context.Context, token string) (*store.User, *store.Session, error) {
|
||||||
|
if token == "" {
|
||||||
|
return nil, nil, ErrSessionExpired
|
||||||
|
}
|
||||||
|
session, err := s.store.GetSessionByTokenHash(ctx, crypto.HashToken(token))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, ErrSessionExpired
|
||||||
|
}
|
||||||
|
if !session.ExpiresAt.After(time.Now().UTC()) {
|
||||||
|
_ = s.store.DeleteSession(ctx, session.ID)
|
||||||
|
return nil, nil, ErrSessionExpired
|
||||||
|
}
|
||||||
|
user, err := s.store.GetUser(ctx, session.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, ErrSessionExpired
|
||||||
|
}
|
||||||
|
_ = s.store.TouchSession(ctx, session.ID)
|
||||||
|
return user, session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotateSession выдаёт новый токен для существующей сессии (AGENT.md 7.1).
|
||||||
|
func (s *Service) RotateSession(ctx context.Context, sessionID uint64) (string, error) {
|
||||||
|
token, hash, err := crypto.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := s.store.RotateSession(ctx, sessionID, hash); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Logout(ctx context.Context, sessionID uint64) error {
|
||||||
|
return s.store.DeleteSession(ctx, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogoutAll отзывает все сессии пользователя («выйти везде»).
|
||||||
|
func (s *Service) LogoutAll(ctx context.Context, userID uint64) error {
|
||||||
|
return s.store.DeleteSessionsForUser(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireStepUp проверяет свежесть аутентификации для чувствительных действий.
|
||||||
|
func (s *Service) RequireStepUp(ctx context.Context, user *store.User, session *store.Session, password, totpCode string) error {
|
||||||
|
if session.SteppedUp(time.Now().UTC()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.hasher.Verify(password, user.PasswordHash); err != nil {
|
||||||
|
return ErrStepUpRequired
|
||||||
|
}
|
||||||
|
totp, err := s.store.GetTOTPSecret(ctx, user.ID)
|
||||||
|
if err == nil && totp.Enabled {
|
||||||
|
if totpCode == "" {
|
||||||
|
return ErrTOTPRequired
|
||||||
|
}
|
||||||
|
if err := s.verifyTOTP(ctx, totp, totpCode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.store.MarkSteppedUp(ctx, session.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Обновляем копию сессии в памяти: повторная проверка в том же обработчике
|
||||||
|
// не должна снова требовать step-up.
|
||||||
|
if refreshed, err := s.store.GetSessionByTokenHash(ctx, session.TokenHash); err == nil {
|
||||||
|
*session = *refreshed
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword меняет пароль и отзывает все остальные сессии.
|
||||||
|
func (s *Service) ChangePassword(ctx context.Context, userID uint64, currentSessionID uint64, currentPassword, newPassword string) error {
|
||||||
|
user, err := s.store.GetUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.hasher.Verify(currentPassword, user.PasswordHash); err != nil {
|
||||||
|
return ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
if err := s.policy.validate(newPassword); err != nil {
|
||||||
|
return ErrWeakPassword
|
||||||
|
}
|
||||||
|
hash, err := s.hasher.Hash(newPassword)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("hash password: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.store.UpdateUserPassword(ctx, userID, hash); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Смена пароля отзывает все сессии, кроме текущей (AGENT.md 7.1).
|
||||||
|
if err := s.store.DeleteOtherSessions(ctx, userID, currentSessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = s.store.RecordSecurityEvent(ctx, &userID, "password_change", "", "", "")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email возвращает расшифрованный email пользователя.
|
||||||
|
func (s *Service) Email(ctx context.Context, userID uint64) (string, error) {
|
||||||
|
encrypted, err := s.store.EncryptedEmail(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
payload, err := s.masterKey.Decrypt(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("decrypt email: %w", err)
|
||||||
|
}
|
||||||
|
prefix := "u" + strconv.FormatUint(userID, 10) + ":"
|
||||||
|
if !strings.HasPrefix(payload, prefix) {
|
||||||
|
return "", fmt.Errorf("email payload is bound to another account")
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(payload, prefix), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) userByEmail(ctx context.Context, email string) (*store.User, error) {
|
||||||
|
index := s.masterKey.BlindIndex(crypto.NormalizeEmail(email))
|
||||||
|
return s.store.GetUserByEmailIndex(ctx, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) createSession(ctx context.Context, userID uint64, userAgent, ip string) (string, *store.Session, error) {
|
||||||
|
token, hash, err := crypto.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
session, err := s.store.CreateSession(ctx, userID, store.CreateSessionParams{
|
||||||
|
TokenHash: hash,
|
||||||
|
UserAgent: truncate(userAgent, 256),
|
||||||
|
IP: ip,
|
||||||
|
TTL: s.cfg.SessionTTL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("create session: %w", err)
|
||||||
|
}
|
||||||
|
return token, session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinMainGuild добавляет нового пользователя на главный сервер с ролью
|
||||||
|
// «Пользователь» (AGENT.md 7.3).
|
||||||
|
func (s *Service) joinMainGuild(ctx context.Context, userID uint64) error {
|
||||||
|
settings, err := s.settings.InstanceSettings(ctx)
|
||||||
|
if err != nil || settings.MainGuildID == 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := s.store.AddGuildMember(ctx, settings.MainGuildID, userID, ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defaultRole, err := s.store.DefaultRole(ctx, settings.MainGuildID)
|
||||||
|
if err != nil {
|
||||||
|
// Роль по умолчанию может отсутствовать (сервер без ролей) — это не мешает
|
||||||
|
// автовступлению, поэтому ошибку не возвращаем, но фиксируем в логе.
|
||||||
|
s.logger.WarnContext(ctx, "main guild has no default role",
|
||||||
|
slog.Uint64("guild_id", settings.MainGuildID), slog.Any("error", err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.store.AssignRole(ctx, settings.MainGuildID, userID, defaultRole.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkLockout защищает от перебора: серия неудач блокирует попытки на окно.
|
||||||
|
func (s *Service) checkLockout(ctx context.Context, userID uint64) error {
|
||||||
|
events, err := s.store.ListSecurityEvents(ctx, userID, 20)
|
||||||
|
if err != nil {
|
||||||
|
// Без истории событий блокировку посчитать нельзя: безопаснее пропустить
|
||||||
|
// проверку, чем запереть пользователя из-за сбоя чтения.
|
||||||
|
s.logger.WarnContext(ctx, "cannot read security events for lockout check",
|
||||||
|
slog.Uint64("user_id", userID), slog.Any("error", err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
failures := 0
|
||||||
|
cutoff := time.Now().UTC().Add(-s.cfg.LockoutWindow)
|
||||||
|
for _, event := range events {
|
||||||
|
if event.Type != "login_failed" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if event.CreatedAt.Before(cutoff) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
failures++
|
||||||
|
}
|
||||||
|
if failures >= s.cfg.MaxLoginFails {
|
||||||
|
return ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PermissionsGuild вычисляет права пользователя на сервере (обёртка для API).
|
||||||
|
func (s *Service) PermissionsGuild(resolved permissions.Resolved, permission permissions.Permission) error {
|
||||||
|
if resolved.Has(permission) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return permissions.ErrDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultDisplayName(displayName, username string) string {
|
||||||
|
if strings.TrimSpace(displayName) == "" {
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
return truncate(displayName, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(value string, limit int) string {
|
||||||
|
if len(value) <= limit {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return value[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateEmail(email string) error {
|
||||||
|
at := strings.LastIndex(email, "@")
|
||||||
|
if at <= 0 || at == len(email)-1 || !strings.Contains(email[at+1:], ".") {
|
||||||
|
return fmt.Errorf("auth.invalid_email")
|
||||||
|
}
|
||||||
|
if len(email) > 254 {
|
||||||
|
return fmt.Errorf("auth.invalid_email")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptEmail привязывает шифротекст к идентификатору пользователя:
|
||||||
|
// скопированное значение не расшифруется под другим аккаунтом.
|
||||||
|
func (s *Service) encryptEmail(userID uint64, email string) (string, error) {
|
||||||
|
payload := "u" + strconv.FormatUint(userID, 10) + ":" + email
|
||||||
|
return s.masterKey.Encrypt(payload)
|
||||||
|
}
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
|
|
||||||
|
"glchat/internal/auth"
|
||||||
|
"glchat/internal/config"
|
||||||
|
"glchat/internal/database"
|
||||||
|
"glchat/internal/permissions"
|
||||||
|
"glchat/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testPepper = "unit-test-session-pepper"
|
||||||
|
testMasterKey = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testConfig() config.Config {
|
||||||
|
return config.Config{
|
||||||
|
SessionPepper: testPepper,
|
||||||
|
MasterKey: testMasterKey,
|
||||||
|
TOTPEncryptionKey: "unit-test-totp-key",
|
||||||
|
SessionTTLHours: 24,
|
||||||
|
// Низкие параметры Argon2id: тесты не должны работать секундами.
|
||||||
|
Argon2MemoryKiB: 1024,
|
||||||
|
Argon2Iterations: 1,
|
||||||
|
Argon2Parallelism: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newService(t *testing.T) (*auth.Service, *store.Store) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
db, err := database.Open(ctx, database.Options{
|
||||||
|
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
||||||
|
ReadPool: 2,
|
||||||
|
Migrate: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open database: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
st := store.New(db)
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
service, err := auth.New(ctx, testConfig(), st, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("auth.New: %v", err)
|
||||||
|
}
|
||||||
|
return service, st
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootstrapMainGuild создаёт главный сервер с ролями по умолчанию, как это
|
||||||
|
// делает установщик (AGENT.md 7.3).
|
||||||
|
func bootstrapMainGuild(t *testing.T, st *store.Store, ownerID uint64) *store.Guild {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
guild, err := st.CreateGuild(ctx, store.CreateGuildParams{Name: "Главный сервер", OwnerID: ownerID, IsMain: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create main guild: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.CreateRole(ctx, store.CreateRoleParams{
|
||||||
|
GuildID: guild.ID, Name: "Администратор", Permissions: uint64(permissions.AllPermissions), Position: 100,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create admin role: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := st.CreateRole(ctx, store.CreateRoleParams{
|
||||||
|
GuildID: guild.ID, Name: "Пользователь", Permissions: uint64(permissions.DefaultUserPermissions),
|
||||||
|
Position: 0, IsDefault: true, Mentionable: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create default role: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.SetInstanceSetting(ctx, "main_guild_id", itoa(guild.ID)); err != nil {
|
||||||
|
t.Fatalf("set main guild: %v", err)
|
||||||
|
}
|
||||||
|
return guild
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerErr выполняет регистрацию и возвращает только ошибку: в тестах,
|
||||||
|
// где важен лишь результат проверки, это читается лучше вызова с пропусками.
|
||||||
|
func registerErr(service *auth.Service, in auth.RegisterInput) error {
|
||||||
|
_, _, _, err := service.Register(context.Background(), in)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(value uint64) string {
|
||||||
|
if value == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
digits := []byte{}
|
||||||
|
for value > 0 {
|
||||||
|
digits = append([]byte{byte('0' + value%10)}, digits...)
|
||||||
|
value /= 10
|
||||||
|
}
|
||||||
|
return string(digits)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterAndLogin(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
service, st := newService(t)
|
||||||
|
|
||||||
|
user, token, session, err := service.Register(ctx, auth.RegisterInput{
|
||||||
|
Username: "alex", DisplayName: "Александр", Email: "Alex@Example.COM",
|
||||||
|
Password: "correct-horse-battery", IP: "203.0.113.5", UserAgent: "test-agent",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Register: %v", err)
|
||||||
|
}
|
||||||
|
// Главный сервер создаётся установщиком (владелец — уже существующий админ).
|
||||||
|
bootstrapMainGuild(t, st, user.ID)
|
||||||
|
// Новый пользователь регистрируется после появления главного сервера,
|
||||||
|
// чтобы проверить автовступление.
|
||||||
|
second, _, _, err := service.Register(ctx, auth.RegisterInput{
|
||||||
|
Username: "newcomer", Email: "newcomer@example.com", Password: "correct-horse-battery",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Register newcomer: %v", err)
|
||||||
|
}
|
||||||
|
newcomerGuilds, err := st.ListGuildsForUser(ctx, second.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListGuildsForUser(newcomer): %v", err)
|
||||||
|
}
|
||||||
|
if len(newcomerGuilds) != 1 || !newcomerGuilds[0].IsMain {
|
||||||
|
t.Fatalf("newcomer must join the main guild, got %+v", newcomerGuilds)
|
||||||
|
}
|
||||||
|
newcomerRoles, err := st.MemberRoles(ctx, newcomerGuilds[0].ID, second.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MemberRoles(newcomer): %v", err)
|
||||||
|
}
|
||||||
|
if len(newcomerRoles) != 1 || !newcomerRoles[0].IsDefault {
|
||||||
|
t.Fatalf("newcomer must receive the default role, got %+v", newcomerRoles)
|
||||||
|
}
|
||||||
|
if user.ID == 0 || token == "" || session.ID == 0 {
|
||||||
|
t.Fatal("registration must return user, token and session")
|
||||||
|
}
|
||||||
|
if user.IsInstanceAdmin {
|
||||||
|
t.Fatal("regular user must not be instance admin")
|
||||||
|
}
|
||||||
|
if strings.Contains(user.PasswordHash, "correct-horse-battery") {
|
||||||
|
t.Fatal("password must not be stored in clear text")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email зашифрован в БД и расшифровывается только сервисом.
|
||||||
|
encrypted, err := st.EncryptedEmail(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncryptedEmail: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(encrypted), "alex@example.com") {
|
||||||
|
t.Fatal("email must be stored encrypted")
|
||||||
|
}
|
||||||
|
decrypted, err := service.Email(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Email: %v", err)
|
||||||
|
}
|
||||||
|
if decrypted != "alex@example.com" {
|
||||||
|
t.Fatalf("Email = %q, want normalized address", decrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вход по email в любом регистре.
|
||||||
|
logged, newToken, _, err := service.Login(ctx, auth.LoginInput{
|
||||||
|
Email: "ALEX@example.com", Password: "correct-horse-battery", IP: "203.0.113.5",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Login: %v", err)
|
||||||
|
}
|
||||||
|
if logged.ID != user.ID || newToken == token {
|
||||||
|
t.Fatal("login must return the same user with a fresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||||
|
Email: "alex@example.com", Password: "wrong-password", IP: "203.0.113.5",
|
||||||
|
}); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("Login with wrong password = %v, want ErrInvalidCredentials", err)
|
||||||
|
}
|
||||||
|
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||||
|
Email: "nobody@example.com", Password: "correct-horse-battery",
|
||||||
|
}); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("Login with unknown email = %v, want ErrInvalidCredentials", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterRejectsWeakAndLeakedPasswords(t *testing.T) {
|
||||||
|
service, _ := newService(t)
|
||||||
|
|
||||||
|
for name, password := range map[string]string{
|
||||||
|
"short": "short",
|
||||||
|
"leaked": "password",
|
||||||
|
} {
|
||||||
|
err := registerErr(service, auth.RegisterInput{
|
||||||
|
Username: "user_" + name, Email: name + "@example.com", Password: password,
|
||||||
|
})
|
||||||
|
if !errors.Is(err, auth.ErrWeakPassword) {
|
||||||
|
t.Fatalf("Register with %s password = %v, want ErrWeakPassword", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, username := range map[string]string{"short": "a", "bad": "with space", "cyrillic": "имя"} {
|
||||||
|
err := registerErr(service, auth.RegisterInput{
|
||||||
|
Username: username, Email: "valid_" + name + "@example.com", Password: "correct-horse-battery",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, auth.ErrInvalidUsername) {
|
||||||
|
t.Fatalf("Register with %s username = %v, want ErrInvalidUsername", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegisterRejectsDuplicates(t *testing.T) {
|
||||||
|
service, _ := newService(t)
|
||||||
|
first := auth.RegisterInput{Username: "duplicate", Email: "first@example.com", Password: "correct-horse-battery"}
|
||||||
|
if err := registerErr(service, first); err != nil {
|
||||||
|
t.Fatalf("first Register: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := registerErr(service, auth.RegisterInput{
|
||||||
|
Username: "duplicate", Email: "second@example.com", Password: "correct-horse-battery",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, auth.ErrUsernameTaken) {
|
||||||
|
t.Fatalf("duplicate username = %v, want ErrUsernameTaken", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = registerErr(service, auth.RegisterInput{
|
||||||
|
Username: "another", Email: "FIRST@example.com", Password: "correct-horse-battery",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, auth.ErrEmailTaken) {
|
||||||
|
t.Fatalf("duplicate email (case-insensitive) = %v, want ErrEmailTaken", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionRotationAndLogoutAll(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
service, _ := newService(t)
|
||||||
|
user, token, session, err := service.Register(ctx, auth.RegisterInput{
|
||||||
|
Username: "session_user", Email: "session@example.com", Password: "correct-horse-battery",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Register: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rotated, err := service.RotateSession(ctx, session.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RotateSession: %v", err)
|
||||||
|
}
|
||||||
|
if rotated == token {
|
||||||
|
t.Fatal("rotation must produce a new token")
|
||||||
|
}
|
||||||
|
if _, _, err := service.ResolveSession(ctx, token); !errors.Is(err, auth.ErrSessionExpired) {
|
||||||
|
t.Fatalf("old token must stop working, got %v", err)
|
||||||
|
}
|
||||||
|
if _, resolved, err := service.ResolveSession(ctx, rotated); err != nil || resolved.UserID != user.ID {
|
||||||
|
t.Fatalf("rotated token must resolve: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.LogoutAll(ctx, user.ID); err != nil {
|
||||||
|
t.Fatalf("LogoutAll: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := service.ResolveSession(ctx, rotated); !errors.Is(err, auth.ErrSessionExpired) {
|
||||||
|
t.Fatal("logout-all must revoke every session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTOTPFlowAndStepUp(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
service, _ := newService(t)
|
||||||
|
user, _, session, err := service.Register(ctx, auth.RegisterInput{
|
||||||
|
Username: "totp_user", Email: "totp@example.com", Password: "correct-horse-battery",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Register: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
setup, err := service.SetupTOTP(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetupTOTP: %v", err)
|
||||||
|
}
|
||||||
|
if setup.Secret == "" || !strings.HasPrefix(setup.URL, "otpauth://totp/") {
|
||||||
|
t.Fatalf("unexpected setup payload: %+v", setup)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := service.EnableTOTP(ctx, user.ID, "000000"); !errors.Is(err, auth.ErrTOTPInvalid) {
|
||||||
|
t.Fatalf("EnableTOTP with bad code = %v, want ErrTOTPInvalid", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code := totpCode(t, setup.Secret)
|
||||||
|
recovery, err := service.EnableTOTP(ctx, user.ID, code)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnableTOTP: %v", err)
|
||||||
|
}
|
||||||
|
if len(recovery) != 8 {
|
||||||
|
t.Fatalf("recovery codes = %d, want 8", len(recovery))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вход без кода требует 2FA, с кодом — проходит.
|
||||||
|
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||||
|
Email: "totp@example.com", Password: "correct-horse-battery",
|
||||||
|
}); !errors.Is(err, auth.ErrTOTPRequired) {
|
||||||
|
t.Fatalf("Login without TOTP = %v, want ErrTOTPRequired", err)
|
||||||
|
}
|
||||||
|
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||||
|
Email: "totp@example.com", Password: "correct-horse-battery", TOTPCode: totpCode(t, setup.Secret),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Login with TOTP: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Резервный код работает один раз.
|
||||||
|
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||||
|
Email: "totp@example.com", Password: "correct-horse-battery", TOTPCode: recovery[0],
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Login with recovery code: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||||
|
Email: "totp@example.com", Password: "correct-horse-battery", TOTPCode: recovery[0],
|
||||||
|
}); !errors.Is(err, auth.ErrTOTPInvalid) {
|
||||||
|
t.Fatalf("reused recovery code = %v, want ErrTOTPInvalid", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step-up: свежая сессия проходит, затем окно можно проверить повторно.
|
||||||
|
if err := service.RequireStepUp(ctx, user, session, "correct-horse-battery", totpCode(t, setup.Secret)); err != nil {
|
||||||
|
t.Fatalf("RequireStepUp: %v", err)
|
||||||
|
}
|
||||||
|
if err := service.RequireStepUp(ctx, user, session, "correct-horse-battery", ""); err != nil {
|
||||||
|
t.Fatalf("RequireStepUp inside the window: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstanceAdminRequiresTOTP(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
service, st := newService(t)
|
||||||
|
user, _, _, err := service.Register(ctx, auth.RegisterInput{
|
||||||
|
Username: "admin", Email: "admin@example.com", Password: "correct-horse-battery",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Register: %v", err)
|
||||||
|
}
|
||||||
|
if err := st.SetInstanceAdmin(ctx, user.ID, true); err != nil {
|
||||||
|
t.Fatalf("SetInstanceAdmin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Без настроенного TOTP инстанс-админ не может войти (AGENT.md 7.19).
|
||||||
|
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||||
|
Email: "admin@example.com", Password: "correct-horse-battery",
|
||||||
|
}); !errors.Is(err, auth.ErrInstanceAdminTOTP) {
|
||||||
|
t.Fatalf("Login as admin without TOTP = %v, want ErrInstanceAdminTOTP", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
setup, err := service.SetupTOTP(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetupTOTP: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := service.EnableTOTP(ctx, user.ID, totpCode(t, setup.Secret)); err != nil {
|
||||||
|
t.Fatalf("EnableTOTP: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||||
|
Email: "admin@example.com", Password: "correct-horse-battery", TOTPCode: totpCode(t, setup.Secret),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Login as admin with TOTP: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отключить 2FA инстанс-админу нельзя.
|
||||||
|
if err := service.DisableTOTP(ctx, user.ID); !errors.Is(err, auth.ErrInstanceAdminTOTP) {
|
||||||
|
t.Fatalf("DisableTOTP for admin = %v, want ErrInstanceAdminTOTP", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangePasswordRevokesOtherSessions(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
service, _ := newService(t)
|
||||||
|
user, _, current, err := service.Register(ctx, auth.RegisterInput{
|
||||||
|
Username: "password_user", Email: "password@example.com", Password: "correct-horse-battery",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Register: %v", err)
|
||||||
|
}
|
||||||
|
_, otherToken, _, err := service.Login(ctx, auth.LoginInput{
|
||||||
|
Email: "password@example.com", Password: "correct-horse-battery",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second Login: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.ChangePassword(ctx, user.ID, current.ID, "correct-horse-battery", "brand-new-password-1"); err != nil {
|
||||||
|
t.Fatalf("ChangePassword: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := service.ResolveSession(ctx, otherToken); !errors.Is(err, auth.ErrSessionExpired) {
|
||||||
|
t.Fatal("other sessions must be revoked after a password change")
|
||||||
|
}
|
||||||
|
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||||
|
Email: "password@example.com", Password: "correct-horse-battery",
|
||||||
|
}); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("old password must stop working, got %v", err)
|
||||||
|
}
|
||||||
|
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||||
|
Email: "password@example.com", Password: "brand-new-password-1",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Login with new password: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistrationCanBeDisabled(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
service, st := newService(t)
|
||||||
|
if err := st.SetInstanceSetting(ctx, "registration_enabled", "false"); err != nil {
|
||||||
|
t.Fatalf("SetInstanceSetting: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, _, err := service.Register(ctx, auth.RegisterInput{
|
||||||
|
Username: "blocked", Email: "blocked@example.com", Password: "correct-horse-battery",
|
||||||
|
}); !errors.Is(err, auth.ErrRegistrationOff) {
|
||||||
|
t.Fatalf("Register with disabled registration = %v, want ErrRegistrationOff", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordsAndSecretsAreNotLogged(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
var buf strings.Builder
|
||||||
|
db, err := database.Open(ctx, database.Options{
|
||||||
|
Path: filepath.Join(t.TempDir(), "glchat.db"), ReadPool: 2, Migrate: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open database: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
st := store.New(db)
|
||||||
|
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
service, err := auth.New(ctx, testConfig(), st, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("auth.New: %v", err)
|
||||||
|
}
|
||||||
|
user, token, _, err := service.Register(ctx, auth.RegisterInput{
|
||||||
|
Username: "secret_user", Email: "secret@example.com", Password: "correct-horse-battery",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Register: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, _, err := service.Login(ctx, auth.LoginInput{
|
||||||
|
Email: "secret@example.com", Password: "correct-horse-battery",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Login: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logs := buf.String()
|
||||||
|
for _, secret := range []string{"correct-horse-battery", token, testPepper, testMasterKey} {
|
||||||
|
if secret != "" && strings.Contains(logs, secret) {
|
||||||
|
t.Fatalf("logs contain a secret value: %s", secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Хэш пароля тоже не должен попадать в лог.
|
||||||
|
hash := user.PasswordHash
|
||||||
|
if hash != "" && strings.Contains(logs, hash) {
|
||||||
|
t.Fatal("logs contain the password hash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// totpCode считает текущий код для секрета.
|
||||||
|
func totpCode(t *testing.T, secret string) string {
|
||||||
|
t.Helper()
|
||||||
|
code, err := totp.GenerateCode(secret, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateCode: %v", err)
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pquerna/otp"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
|
|
||||||
|
"glchat/internal/crypto"
|
||||||
|
"glchat/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TOTPSetup — данные для подключения второго фактора: секрет показывается
|
||||||
|
// один раз, в БД хранится зашифрованным (AGENT.md 9.2).
|
||||||
|
type TOTPSetup struct {
|
||||||
|
Secret string
|
||||||
|
URL string
|
||||||
|
Recovery []string
|
||||||
|
IssuerName string
|
||||||
|
Account string
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
recoveryCodeCount = 8
|
||||||
|
totpIssuer = "glchat"
|
||||||
|
totpPeriod = 30
|
||||||
|
totpSkew = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetupTOTP создаёт новый секрет (не включая 2FA до подтверждения кодом).
|
||||||
|
func (s *Service) SetupTOTP(ctx context.Context, userID uint64) (*TOTPSetup, error) {
|
||||||
|
user, err := s.store.GetUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
existing, err := s.store.GetTOTPSecret(ctx, userID)
|
||||||
|
if err == nil && existing.Enabled {
|
||||||
|
return nil, ErrTOTPAlreadyEnabled
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := totp.Generate(totp.GenerateOpts{
|
||||||
|
Issuer: totpIssuer,
|
||||||
|
AccountName: user.Username,
|
||||||
|
Period: totpPeriod,
|
||||||
|
SecretSize: 20,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate totp secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted, err := s.masterKey.Encrypt(key.Secret())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encrypt totp secret: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.store.UpsertTOTPSecret(ctx, userID, encrypted); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &TOTPSetup{
|
||||||
|
Secret: key.Secret(),
|
||||||
|
URL: key.URL(),
|
||||||
|
IssuerName: totpIssuer,
|
||||||
|
Account: user.Username,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableTOTP подтверждает секрет кодом и выдаёт резервные коды (в БД — хэши).
|
||||||
|
func (s *Service) EnableTOTP(ctx context.Context, userID uint64, code string) ([]string, error) {
|
||||||
|
secret, err := s.store.GetTOTPSecret(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrNoTOTPSecret
|
||||||
|
}
|
||||||
|
if secret.Enabled {
|
||||||
|
return nil, ErrTOTPAlreadyEnabled
|
||||||
|
}
|
||||||
|
if err := s.verifyTOTPSecret(secret.SecretEncrypted, code); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
codes := make([]string, 0, recoveryCodeCount)
|
||||||
|
hashes := make([]string, 0, recoveryCodeCount)
|
||||||
|
for i := 0; i < recoveryCodeCount; i++ {
|
||||||
|
raw, err := crypto.NewRecoveryCode()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hash, err := s.totpHasher.Hash(strings.ToUpper(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
codes = append(codes, raw)
|
||||||
|
hashes = append(hashes, hash)
|
||||||
|
}
|
||||||
|
if err := s.store.EnableTOTP(ctx, userID, hashes); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = s.store.RecordSecurityEvent(ctx, &userID, "2fa_change", "", "", `{"enabled":true}`)
|
||||||
|
return codes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableTOTP выключает второй фактор (после проверки пароля вызывающим кодом)
|
||||||
|
// и запрещает это инстанс-администратору (AGENT.md 7.19).
|
||||||
|
func (s *Service) DisableTOTP(ctx context.Context, userID uint64) error {
|
||||||
|
user, err := s.store.GetUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if user.IsInstanceAdmin {
|
||||||
|
return ErrInstanceAdminTOTP
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteTOTPSecret(ctx, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = s.store.RecordSecurityEvent(ctx, &userID, "2fa_change", "", "", `{"enabled":false}`)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegenerateRecoveryCodes заменяет резервные коды (пароль проверяет API).
|
||||||
|
func (s *Service) RegenerateRecoveryCodes(ctx context.Context, userID uint64, code string) ([]string, error) {
|
||||||
|
secret, err := s.store.GetTOTPSecret(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrNoTOTPSecret
|
||||||
|
}
|
||||||
|
if !secret.Enabled {
|
||||||
|
return nil, ErrNoTOTPSecret
|
||||||
|
}
|
||||||
|
if err := s.verifyTOTP(ctx, secret, code); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
codes := make([]string, 0, recoveryCodeCount)
|
||||||
|
hashes := make([]string, 0, recoveryCodeCount)
|
||||||
|
for i := 0; i < recoveryCodeCount; i++ {
|
||||||
|
raw, err := crypto.NewRecoveryCode()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hash, err := s.totpHasher.Hash(strings.ToUpper(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
codes = append(codes, raw)
|
||||||
|
hashes = append(hashes, hash)
|
||||||
|
}
|
||||||
|
if err := s.store.EnableTOTP(ctx, userID, hashes); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return codes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOTPEnabled сообщает состояние второго фактора.
|
||||||
|
func (s *Service) TOTPEnabled(ctx context.Context, userID uint64) (bool, error) {
|
||||||
|
secret, err := s.store.GetTOTPSecret(ctx, userID)
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return secret.Enabled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyTOTP проверяет код TOTP или резервный код.
|
||||||
|
func (s *Service) verifyTOTP(ctx context.Context, secret *store.TOTPSecret, code string) error {
|
||||||
|
normalized := strings.TrimSpace(code)
|
||||||
|
if normalized == "" {
|
||||||
|
return ErrTOTPInvalid
|
||||||
|
}
|
||||||
|
// Резервный код: одноразовый, после использования удаляется.
|
||||||
|
if len(normalized) == 14 && strings.Count(normalized, "-") == 2 {
|
||||||
|
remaining, ok := s.consumeRecoveryCode(secret, normalized)
|
||||||
|
if !ok {
|
||||||
|
return ErrTOTPInvalid
|
||||||
|
}
|
||||||
|
if err := s.store.ConsumeRecoveryCode(ctx, secret.UserID, remaining); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.verifyTOTPSecret(secret.SecretEncrypted, normalized); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) consumeRecoveryCode(secret *store.TOTPSecret, code string) ([]string, bool) {
|
||||||
|
upper := strings.ToUpper(strings.TrimSpace(code))
|
||||||
|
remaining := make([]string, 0, len(secret.RecoveryCodeHashs))
|
||||||
|
matched := false
|
||||||
|
for _, hash := range secret.RecoveryCodeHashs {
|
||||||
|
if !matched && s.totpHasher.Verify(upper, hash) == nil {
|
||||||
|
matched = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remaining = append(remaining, hash)
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
return secret.RecoveryCodeHashs, false
|
||||||
|
}
|
||||||
|
return remaining, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) verifyTOTPSecret(encrypted, code string) error {
|
||||||
|
plain, err := s.masterKey.Decrypt(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decrypt totp secret: %w", err)
|
||||||
|
}
|
||||||
|
valid, err := totp.ValidateCustom(strings.TrimSpace(code), plain, time.Now().UTC(), totp.ValidateOpts{
|
||||||
|
Period: totpPeriod,
|
||||||
|
Skew: totpSkew,
|
||||||
|
Digits: otp.DigitsSix,
|
||||||
|
Algorithm: otp.AlgorithmSHA1,
|
||||||
|
})
|
||||||
|
if err != nil || !valid {
|
||||||
|
return ErrTOTPInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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:])
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user