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 }