feat(auth): сервис аккаунтов и аутентификации Фазы 1
- регистрация: политика пароля (≥10 + локальный словарь 10k утечек, SecLists MIT), username и email без учёта регистра, шифрование email с привязкой к аккаунту, blind index для поиска, автовступление в главный сервер с ролью @user - вход: Argon2id+pepper, TOTP при включённом 2FA, блокировка после серии неудач, события безопасности; единый ответ на неверный пароль и неизвестный аккаунт - сессии: opaque-токены (в БД только SHA-256), ротация токена, logout/logout-all, отзыв остальных сессий при смене пароля, step-up с окном 10 минут - 2FA: настройка секрета с QR-URL, подтверждение кодом, 8 резервных кодов (хранятся хэшами), обязательность для инстанс-админов, запрет отключения - тесты на реальной SQLite: регистрация/вход/дубликаты, слабые и утёкшие пароли, ротация и logout-all, TOTP и резервные коды, step-up, смена пароля, выключенная регистрация, отсутствие секретов в логах
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user