2026-09-19 21:29:07 +03:00
|
|
|
// 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"
|
2026-09-26 15:12:43 +03:00
|
|
|
|
|
|
|
|
"github.com/go-webauthn/webauthn/webauthn"
|
2026-09-19 21:29:07 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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")
|
2026-09-22 18:59:53 +03:00
|
|
|
// ErrUserBanned — глобальный бан инстанса (AGENT.md 7.18): вход запрещён,
|
|
|
|
|
// действующие сессии недействительны.
|
|
|
|
|
ErrUserBanned = errors.New("user.banned")
|
2026-09-19 21:29:07 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-09-26 15:12:43 +03:00
|
|
|
// webauthn — RP-конфигурация passkeys (nil, если домен не годится для RP ID).
|
|
|
|
|
webauthn *webauthn.WebAuthn
|
|
|
|
|
// ceremonies — начатые церемонии WebAuthn (в памяти, одноразовые).
|
|
|
|
|
ceremonies *ceremonyStore
|
2026-09-26 15:13:24 +03:00
|
|
|
// внешний адрес инстанса для redirect_uri, stateKey — ключ подписи state.
|
2026-09-27 18:27:33 +03:00
|
|
|
stateKey string
|
2026-09-19 21:29:07 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-27 18:27:33 +03:00
|
|
|
// RP-конфигурация passkeys: включается конфигом, при
|
2026-09-26 15:13:24 +03:00
|
|
|
// отсутствии настроек соответствующие ручки отвечают понятной ошибкой.
|
|
|
|
|
|
2026-09-26 15:12:43 +03:00
|
|
|
service := &Service{
|
2026-09-19 21:29:07 +03:00
|
|
|
store: st,
|
|
|
|
|
hasher: hasher,
|
|
|
|
|
masterKey: masterKey,
|
|
|
|
|
totpHasher: totpHasher,
|
|
|
|
|
settings: st,
|
|
|
|
|
policy: policy,
|
|
|
|
|
cfg: Config{SessionTTL: sessionTTL, MaxLoginFails: 5, LockoutWindow: 15 * time.Minute},
|
|
|
|
|
logger: logger,
|
2026-09-27 18:27:33 +03:00
|
|
|
stateKey: cfg.SessionPepper,
|
|
|
|
|
ceremonies: newCeremonyStore(),
|
2026-09-26 15:12:43 +03:00
|
|
|
}
|
|
|
|
|
// Passkeys требуют корректного RP ID: для IP-адреса (стенд без домена)
|
|
|
|
|
// браузеры WebAuthn не работают, поэтому такой инстанс просто живёт без них.
|
|
|
|
|
service.webauthn = newWebAuthn(cfg, logger)
|
|
|
|
|
return service, nil
|
2026-09-19 21:29:07 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-20 01:11:04 +03:00
|
|
|
// Новый пользователь попадает в пустой список серверов: вход только по
|
|
|
|
|
// приглашению или созданием своего сервера (решение пользователя, 2026-09-20).
|
2026-09-19 21:29:07 +03:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 20:00:33 +03:00
|
|
|
// CreateUserByOperator создаёт аккаунт по команде обслуживания (CLI
|
|
|
|
|
// create-user): регистрация на инстансе может быть выключена, а сессия
|
|
|
|
|
// создателю не нужна — он действует от имени администратора. Используется в
|
|
|
|
|
// том числе для подготовки нагрузки (AGENT.md 11.4).
|
|
|
|
|
func (s *Service) CreateUserByOperator(ctx context.Context, in RegisterInput) (*store.User, error) {
|
|
|
|
|
username := strings.TrimSpace(in.Username)
|
|
|
|
|
if err := crypto.ValidateUsername(username); err != nil {
|
|
|
|
|
return nil, ErrInvalidUsername
|
|
|
|
|
}
|
|
|
|
|
if err := s.policy.validate(in.Password); err != nil {
|
|
|
|
|
return nil, ErrWeakPassword
|
|
|
|
|
}
|
|
|
|
|
email := crypto.NormalizeEmail(in.Email)
|
|
|
|
|
if err := validateEmail(email); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
passwordHash, err := s.hasher.Hash(in.Password)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("hash password: %w", err)
|
|
|
|
|
}
|
|
|
|
|
userID := s.store.NextID()
|
|
|
|
|
emailEncrypted, err := s.encryptEmail(userID, email)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
user, err := s.store.CreateUser(ctx, store.CreateUserParams{
|
|
|
|
|
ID: userID,
|
|
|
|
|
Username: username,
|
|
|
|
|
DisplayName: defaultDisplayName(in.DisplayName, username),
|
|
|
|
|
EmailEnc: emailEncrypted,
|
|
|
|
|
EmailIndex: s.masterKey.BlindIndex(email),
|
|
|
|
|
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, ErrUsernameTaken
|
|
|
|
|
}
|
|
|
|
|
return nil, ErrEmailTaken
|
|
|
|
|
}
|
|
|
|
|
return nil, fmt.Errorf("create user: %w", err)
|
|
|
|
|
}
|
|
|
|
|
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "created_by_operator", "", "", "")
|
|
|
|
|
return user, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// IssueSessionForOperator выдаёт сессию по команде обслуживания: нужна для
|
|
|
|
|
// нагрузочного теста (AGENT.md 11.4), где сотни клиентов не могут пройти вход
|
|
|
|
|
// через /auth/login — лимит попыток по IP рассчитан на защиту от перебора.
|
|
|
|
|
func (s *Service) IssueSessionForOperator(ctx context.Context, userID uint64, userAgent string) (string, error) {
|
|
|
|
|
token, _, err := s.createSession(ctx, userID, userAgent, "")
|
|
|
|
|
return token, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 21:37:13 +03:00
|
|
|
// UserByEmail ищет аккаунт по email через blind index (без расшифровки БД).
|
|
|
|
|
func (s *Service) UserByEmail(ctx context.Context, email string) (*store.User, error) {
|
|
|
|
|
return s.userByEmail(ctx, email)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SetPassword меняет пароль без проверки текущего: используется установщиком
|
|
|
|
|
// и CLI-командой reset-password, где пароль задаёт администратор инстанса.
|
|
|
|
|
func (s *Service) SetPassword(ctx context.Context, userID uint64, password string) error {
|
|
|
|
|
if err := s.policy.validate(password); err != nil {
|
|
|
|
|
return ErrWeakPassword
|
|
|
|
|
}
|
|
|
|
|
hash, err := s.hasher.Hash(password)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("hash password: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if err := s.store.UpdateUserPassword(ctx, userID, hash); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
// Смена пароля администратором отзывает все сессии пользователя.
|
|
|
|
|
if err := s.store.DeleteSessionsForUser(ctx, userID); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return s.store.RecordSecurityEvent(ctx, &userID, "password_change", "", "", `{"by":"admin"}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RegisterAdmin создаёт аккаунт администратора инстанса в обход флага
|
|
|
|
|
// регистрации: bootstrap выполняется установщиком (AGENT.md 10.3).
|
|
|
|
|
func (s *Service) RegisterAdmin(ctx context.Context, in RegisterInput) (*store.User, string, *store.Session, error) {
|
|
|
|
|
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()
|
|
|
|
|
encrypted, err := s.encryptEmail(userID, email)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, "", nil, err
|
|
|
|
|
}
|
|
|
|
|
user, err := s.store.CreateUser(ctx, store.CreateUserParams{
|
|
|
|
|
ID: userID,
|
|
|
|
|
Username: username,
|
|
|
|
|
DisplayName: defaultDisplayName(in.DisplayName, username),
|
|
|
|
|
EmailEnc: encrypted,
|
|
|
|
|
EmailIndex: s.masterKey.BlindIndex(email),
|
|
|
|
|
PasswordHash: passwordHash,
|
|
|
|
|
Locale: in.Locale,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, "", nil, err
|
|
|
|
|
}
|
|
|
|
|
if err := s.store.SetInstanceAdmin(ctx, user.ID, true); err != nil {
|
|
|
|
|
return nil, "", nil, err
|
|
|
|
|
}
|
|
|
|
|
if err := s.store.SetUserBadges(ctx, user.ID, []string{"instance_admin"}); err != nil {
|
|
|
|
|
s.logger.WarnContext(ctx, "failed to set admin badge", 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, "bootstrap_admin", in.IP, in.UserAgent, "")
|
|
|
|
|
user, err = s.store.GetUser(ctx, user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, "", nil, err
|
|
|
|
|
}
|
|
|
|
|
return user, token, session, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 21:29:07 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 18:59:53 +03:00
|
|
|
// Глобальный бан инстанса проверяется до пароля: причина отказа важнее
|
|
|
|
|
// (AGENT.md 7.18). Бан инстанс-админа невозможен, поэтому порядок безопасен.
|
|
|
|
|
if banned, err := s.store.IsInstanceBanned(ctx, user.ID); err != nil {
|
|
|
|
|
return nil, "", nil, err
|
|
|
|
|
} else if banned {
|
|
|
|
|
_ = s.store.RecordSecurityEvent(ctx, &user.ID, "login_banned", in.IP, in.UserAgent, "")
|
|
|
|
|
return nil, "", nil, ErrUserBanned
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 21:29:07 +03:00
|
|
|
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
|
|
|
|
|
}
|
2026-09-22 18:59:53 +03:00
|
|
|
// Бан инстанса обрывает уже выданные сессии: сессию удаляем, чтобы клиент
|
|
|
|
|
// не возвращался к ней повторно (AGENT.md 7.18).
|
|
|
|
|
if user.BannedAt != nil {
|
|
|
|
|
_ = s.store.DeleteSession(ctx, session.ID)
|
|
|
|
|
return nil, nil, ErrUserBanned
|
|
|
|
|
}
|
2026-09-19 21:29:07 +03:00
|
|
|
_ = 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|