Files
glchat/internal/auth/totp.go
T
grendervill 16218ee045 feat(auth): сервис аккаунтов и аутентификации Фазы 1
- регистрация: политика пароля (≥10 + локальный словарь 10k утечек, SecLists MIT),
  username и email без учёта регистра, шифрование email с привязкой к аккаунту,
  blind index для поиска, автовступление в главный сервер с ролью @user
- вход: Argon2id+pepper, TOTP при включённом 2FA, блокировка после серии неудач,
  события безопасности; единый ответ на неверный пароль и неизвестный аккаунт
- сессии: opaque-токены (в БД только SHA-256), ротация токена, logout/logout-all,
  отзыв остальных сессий при смене пароля, step-up с окном 10 минут
- 2FA: настройка секрета с QR-URL, подтверждение кодом, 8 резервных кодов
  (хранятся хэшами), обязательность для инстанс-админов, запрет отключения
- тесты на реальной SQLite: регистрация/вход/дубликаты, слабые и утёкшие пароли,
  ротация и logout-all, TOTP и резервные коды, step-up, смена пароля,
  выключенная регистрация, отсутствие секретов в логах
2026-09-19 21:29:07 +03:00

225 lines
6.3 KiB
Go

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
}