totp_service.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  1. package service
  2. import (
  3. "context"
  4. "crypto/rand"
  5. "crypto/subtle"
  6. "encoding/hex"
  7. "fmt"
  8. "log/slog"
  9. "time"
  10. "github.com/pquerna/otp/totp"
  11. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  12. )
  13. var (
  14. ErrTotpNotEnabled = infraerrors.BadRequest("TOTP_NOT_ENABLED", "totp feature is not enabled")
  15. ErrTotpAlreadyEnabled = infraerrors.BadRequest("TOTP_ALREADY_ENABLED", "totp is already enabled for this account")
  16. ErrTotpNotSetup = infraerrors.BadRequest("TOTP_NOT_SETUP", "totp is not set up for this account")
  17. ErrTotpInvalidCode = infraerrors.BadRequest("TOTP_INVALID_CODE", "invalid totp code")
  18. ErrTotpSetupExpired = infraerrors.BadRequest("TOTP_SETUP_EXPIRED", "totp setup session expired")
  19. ErrTotpTooManyAttempts = infraerrors.TooManyRequests("TOTP_TOO_MANY_ATTEMPTS", "too many verification attempts, please try again later")
  20. ErrVerifyCodeRequired = infraerrors.BadRequest("VERIFY_CODE_REQUIRED", "email verification code is required")
  21. ErrPasswordRequired = infraerrors.BadRequest("PASSWORD_REQUIRED", "password is required")
  22. )
  23. // TotpCache defines cache operations for TOTP service
  24. type TotpCache interface {
  25. // Setup session methods
  26. GetSetupSession(ctx context.Context, userID int64) (*TotpSetupSession, error)
  27. SetSetupSession(ctx context.Context, userID int64, session *TotpSetupSession, ttl time.Duration) error
  28. DeleteSetupSession(ctx context.Context, userID int64) error
  29. // Login session methods (for 2FA login flow)
  30. GetLoginSession(ctx context.Context, tempToken string) (*TotpLoginSession, error)
  31. SetLoginSession(ctx context.Context, tempToken string, session *TotpLoginSession, ttl time.Duration) error
  32. DeleteLoginSession(ctx context.Context, tempToken string) error
  33. // Rate limiting
  34. IncrementVerifyAttempts(ctx context.Context, userID int64) (int, error)
  35. GetVerifyAttempts(ctx context.Context, userID int64) (int, error)
  36. ClearVerifyAttempts(ctx context.Context, userID int64) error
  37. // Step-up grant methods (敏感操作 sudo 窗口)
  38. SetStepUpGrant(ctx context.Context, userID int64, sessionKey string, ttl time.Duration) error
  39. HasStepUpGrant(ctx context.Context, userID int64, sessionKey string) (bool, error)
  40. }
  41. // SecretEncryptor defines encryption operations for TOTP secrets
  42. type SecretEncryptor interface {
  43. Encrypt(plaintext string) (string, error)
  44. Decrypt(ciphertext string) (string, error)
  45. }
  46. // TotpSetupSession represents a TOTP setup session
  47. type TotpSetupSession struct {
  48. Secret string // Plain text TOTP secret (not encrypted yet)
  49. SetupToken string // Random token to verify setup request
  50. CreatedAt time.Time
  51. }
  52. // TotpLoginSession represents a pending 2FA login session
  53. type TotpLoginSession struct {
  54. UserID int64
  55. Email string
  56. TokenExpiry time.Time
  57. PendingOAuthBind *PendingOAuthBindLoginSession `json:"pending_oauth_bind,omitempty"`
  58. }
  59. type PendingOAuthBindLoginSession struct {
  60. PendingSessionToken string `json:"pending_session_token,omitempty"`
  61. BrowserSessionKey string `json:"browser_session_key,omitempty"`
  62. }
  63. // TotpStatus represents the TOTP status for a user
  64. type TotpStatus struct {
  65. Enabled bool `json:"enabled"`
  66. EnabledAt *time.Time `json:"enabled_at,omitempty"`
  67. FeatureEnabled bool `json:"feature_enabled"`
  68. }
  69. // TotpSetupResponse represents the response for initiating TOTP setup
  70. type TotpSetupResponse struct {
  71. Secret string `json:"secret"`
  72. QRCodeURL string `json:"qr_code_url"`
  73. SetupToken string `json:"setup_token"`
  74. Countdown int `json:"countdown"` // seconds until setup expires
  75. }
  76. const (
  77. totpSetupTTL = 5 * time.Minute
  78. totpLoginTTL = 5 * time.Minute
  79. totpAttemptsTTL = 15 * time.Minute
  80. maxTotpAttempts = 5
  81. totpIssuer = "Sub2API"
  82. )
  83. // TotpService handles TOTP operations
  84. type TotpService struct {
  85. userRepo UserRepository
  86. encryptor SecretEncryptor
  87. cache TotpCache
  88. settingService *SettingService
  89. emailService *EmailService
  90. emailQueueService *EmailQueueService
  91. }
  92. // NewTotpService creates a new TOTP service
  93. func NewTotpService(
  94. userRepo UserRepository,
  95. encryptor SecretEncryptor,
  96. cache TotpCache,
  97. settingService *SettingService,
  98. emailService *EmailService,
  99. emailQueueService *EmailQueueService,
  100. ) *TotpService {
  101. return &TotpService{
  102. userRepo: userRepo,
  103. encryptor: encryptor,
  104. cache: cache,
  105. settingService: settingService,
  106. emailService: emailService,
  107. emailQueueService: emailQueueService,
  108. }
  109. }
  110. // GetStatus returns the TOTP status for a user
  111. func (s *TotpService) GetStatus(ctx context.Context, userID int64) (*TotpStatus, error) {
  112. featureEnabled := s.settingService.IsTotpEnabled(ctx)
  113. user, err := s.userRepo.GetByID(ctx, userID)
  114. if err != nil {
  115. return nil, fmt.Errorf("get user: %w", err)
  116. }
  117. return &TotpStatus{
  118. Enabled: user.TotpEnabled,
  119. EnabledAt: user.TotpEnabledAt,
  120. FeatureEnabled: featureEnabled,
  121. }, nil
  122. }
  123. // usesEmailVerification 判断 TOTP 启用/停用时的身份校验方式。
  124. // 管理员一律使用密码校验:管理员账号的邮箱常为占位地址收不到验证码,
  125. // 且管理员凭证失守时攻击者往往同时控制通知邮箱,邮箱验证码不构成有效防线。
  126. // 普通用户维持原有行为:开启邮箱验证时用邮箱验证码,否则用密码。
  127. func (s *TotpService) usesEmailVerification(ctx context.Context, user *User) bool {
  128. return !user.IsAdmin() && s.settingService.IsEmailVerifyEnabled(ctx)
  129. }
  130. // verifyIdentity 按 usesEmailVerification 的结果校验邮箱验证码或密码。
  131. func (s *TotpService) verifyIdentity(ctx context.Context, user *User, emailCode, password string) error {
  132. if s.usesEmailVerification(ctx, user) {
  133. if emailCode == "" {
  134. return ErrVerifyCodeRequired
  135. }
  136. return s.emailService.VerifyCode(ctx, user.Email, emailCode)
  137. }
  138. if password == "" {
  139. return ErrPasswordRequired
  140. }
  141. if !user.CheckPassword(password) {
  142. return ErrPasswordIncorrect
  143. }
  144. return nil
  145. }
  146. // InitiateSetup starts the TOTP setup process
  147. // If email verification is enabled, emailCode is required; otherwise password is required
  148. func (s *TotpService) InitiateSetup(ctx context.Context, userID int64, emailCode, password string) (*TotpSetupResponse, error) {
  149. // Check if TOTP feature is enabled globally
  150. if !s.settingService.IsTotpEnabled(ctx) {
  151. return nil, ErrTotpNotEnabled
  152. }
  153. // Get user and check if TOTP is already enabled
  154. user, err := s.userRepo.GetByID(ctx, userID)
  155. if err != nil {
  156. return nil, fmt.Errorf("get user: %w", err)
  157. }
  158. if user.TotpEnabled {
  159. return nil, ErrTotpAlreadyEnabled
  160. }
  161. if err := s.verifyIdentity(ctx, user, emailCode, password); err != nil {
  162. return nil, err
  163. }
  164. // Generate a new TOTP key
  165. key, err := totp.Generate(totp.GenerateOpts{
  166. Issuer: totpIssuer,
  167. AccountName: user.Email,
  168. })
  169. if err != nil {
  170. return nil, fmt.Errorf("generate totp key: %w", err)
  171. }
  172. // Generate a random setup token
  173. setupToken, err := generateRandomToken(32)
  174. if err != nil {
  175. return nil, fmt.Errorf("generate setup token: %w", err)
  176. }
  177. // Store the setup session in cache
  178. session := &TotpSetupSession{
  179. Secret: key.Secret(),
  180. SetupToken: setupToken,
  181. CreatedAt: time.Now(),
  182. }
  183. if err := s.cache.SetSetupSession(ctx, userID, session, totpSetupTTL); err != nil {
  184. return nil, fmt.Errorf("store setup session: %w", err)
  185. }
  186. return &TotpSetupResponse{
  187. Secret: key.Secret(),
  188. QRCodeURL: key.URL(),
  189. SetupToken: setupToken,
  190. Countdown: int(totpSetupTTL.Seconds()),
  191. }, nil
  192. }
  193. // CompleteSetup completes the TOTP setup by verifying the code
  194. func (s *TotpService) CompleteSetup(ctx context.Context, userID int64, totpCode, setupToken string) error {
  195. // Check if TOTP feature is enabled globally
  196. if !s.settingService.IsTotpEnabled(ctx) {
  197. return ErrTotpNotEnabled
  198. }
  199. // Get the setup session
  200. session, err := s.cache.GetSetupSession(ctx, userID)
  201. if err != nil {
  202. return ErrTotpSetupExpired
  203. }
  204. if session == nil {
  205. return ErrTotpSetupExpired
  206. }
  207. // Verify the setup token (constant-time comparison)
  208. if subtle.ConstantTimeCompare([]byte(session.SetupToken), []byte(setupToken)) != 1 {
  209. return ErrTotpSetupExpired
  210. }
  211. // Verify the TOTP code
  212. if !totp.Validate(totpCode, session.Secret) {
  213. return ErrTotpInvalidCode
  214. }
  215. setupSecretPrefix := "N/A"
  216. if len(session.Secret) >= 4 {
  217. setupSecretPrefix = session.Secret[:4]
  218. }
  219. slog.Debug("totp_complete_setup_before_encrypt",
  220. "user_id", userID,
  221. "secret_len", len(session.Secret),
  222. "secret_prefix", setupSecretPrefix)
  223. // Encrypt the secret
  224. encryptedSecret, err := s.encryptor.Encrypt(session.Secret)
  225. if err != nil {
  226. return fmt.Errorf("encrypt totp secret: %w", err)
  227. }
  228. slog.Debug("totp_complete_setup_encrypted",
  229. "user_id", userID,
  230. "encrypted_len", len(encryptedSecret))
  231. // Verify encryption by decrypting
  232. decrypted, decErr := s.encryptor.Decrypt(encryptedSecret)
  233. if decErr != nil {
  234. slog.Debug("totp_complete_setup_verify_failed",
  235. "user_id", userID,
  236. "error", decErr)
  237. } else {
  238. decryptedPrefix := "N/A"
  239. if len(decrypted) >= 4 {
  240. decryptedPrefix = decrypted[:4]
  241. }
  242. slog.Debug("totp_complete_setup_verified",
  243. "user_id", userID,
  244. "original_len", len(session.Secret),
  245. "decrypted_len", len(decrypted),
  246. "match", session.Secret == decrypted,
  247. "decrypted_prefix", decryptedPrefix)
  248. }
  249. // Update user with encrypted TOTP secret
  250. if err := s.userRepo.UpdateTotpSecret(ctx, userID, &encryptedSecret); err != nil {
  251. return fmt.Errorf("update totp secret: %w", err)
  252. }
  253. // Enable TOTP for the user
  254. if err := s.userRepo.EnableTotp(ctx, userID); err != nil {
  255. return fmt.Errorf("enable totp: %w", err)
  256. }
  257. // Clean up the setup session
  258. _ = s.cache.DeleteSetupSession(ctx, userID)
  259. return nil
  260. }
  261. // Disable disables TOTP for a user
  262. // If email verification is enabled, emailCode is required; otherwise password is required
  263. func (s *TotpService) Disable(ctx context.Context, userID int64, emailCode, password string) error {
  264. // Get user
  265. user, err := s.userRepo.GetByID(ctx, userID)
  266. if err != nil {
  267. return fmt.Errorf("get user: %w", err)
  268. }
  269. if !user.TotpEnabled {
  270. return ErrTotpNotSetup
  271. }
  272. if err := s.verifyIdentity(ctx, user, emailCode, password); err != nil {
  273. return err
  274. }
  275. // Disable TOTP
  276. if err := s.userRepo.DisableTotp(ctx, userID); err != nil {
  277. return fmt.Errorf("disable totp: %w", err)
  278. }
  279. return nil
  280. }
  281. // VerifyCode verifies a TOTP code for a user
  282. func (s *TotpService) VerifyCode(ctx context.Context, userID int64, code string) error {
  283. slog.Debug("totp_verify_code_called",
  284. "user_id", userID,
  285. "code_len", len(code))
  286. // Check rate limiting
  287. attempts, err := s.cache.GetVerifyAttempts(ctx, userID)
  288. if err == nil && attempts >= maxTotpAttempts {
  289. return ErrTotpTooManyAttempts
  290. }
  291. // Get user
  292. user, err := s.userRepo.GetByID(ctx, userID)
  293. if err != nil {
  294. slog.Debug("totp_verify_get_user_failed",
  295. "user_id", userID,
  296. "error", err)
  297. return infraerrors.InternalServer("TOTP_VERIFY_ERROR", "failed to verify totp code")
  298. }
  299. if !user.TotpEnabled || user.TotpSecretEncrypted == nil {
  300. slog.Debug("totp_verify_not_setup",
  301. "user_id", userID,
  302. "enabled", user.TotpEnabled,
  303. "has_secret", user.TotpSecretEncrypted != nil)
  304. return ErrTotpNotSetup
  305. }
  306. slog.Debug("totp_verify_encrypted_secret",
  307. "user_id", userID,
  308. "encrypted_len", len(*user.TotpSecretEncrypted))
  309. // Decrypt the secret
  310. secret, err := s.encryptor.Decrypt(*user.TotpSecretEncrypted)
  311. if err != nil {
  312. slog.Debug("totp_verify_decrypt_failed",
  313. "user_id", userID,
  314. "error", err)
  315. return infraerrors.InternalServer("TOTP_VERIFY_ERROR", "failed to verify totp code")
  316. }
  317. secretPrefix := "N/A"
  318. if len(secret) >= 4 {
  319. secretPrefix = secret[:4]
  320. }
  321. slog.Debug("totp_verify_decrypted",
  322. "user_id", userID,
  323. "secret_len", len(secret),
  324. "secret_prefix", secretPrefix)
  325. // Verify the code
  326. valid := totp.Validate(code, secret)
  327. slog.Debug("totp_verify_result",
  328. "user_id", userID,
  329. "valid", valid,
  330. "secret_len", len(secret),
  331. "secret_prefix", secretPrefix,
  332. "server_time", time.Now().UTC().Format(time.RFC3339))
  333. if !valid {
  334. // Increment failed attempts
  335. _, _ = s.cache.IncrementVerifyAttempts(ctx, userID)
  336. return ErrTotpInvalidCode
  337. }
  338. // Clear attempt counter on success
  339. _ = s.cache.ClearVerifyAttempts(ctx, userID)
  340. return nil
  341. }
  342. // StepUpGrantTTL 敏感操作 step-up 验证的有效窗口(sudo 模式)。
  343. const StepUpGrantTTL = 15 * time.Minute
  344. // VerifyStepUp 校验 TOTP 码并授予当前会话一段时间的 step-up 权限。
  345. // 返回授权有效期,供前端展示/设置提醒。
  346. func (s *TotpService) VerifyStepUp(ctx context.Context, userID int64, sessionKey, code string) (time.Duration, error) {
  347. if err := s.VerifyCode(ctx, userID, code); err != nil {
  348. return 0, err
  349. }
  350. if err := s.cache.SetStepUpGrant(ctx, userID, sessionKey, StepUpGrantTTL); err != nil {
  351. return 0, fmt.Errorf("store step-up grant: %w", err)
  352. }
  353. return StepUpGrantTTL, nil
  354. }
  355. // HasStepUpGrant 检查当前会话是否在 step-up 有效期内。
  356. func (s *TotpService) HasStepUpGrant(ctx context.Context, userID int64, sessionKey string) (bool, error) {
  357. return s.cache.HasStepUpGrant(ctx, userID, sessionKey)
  358. }
  359. // CreateLoginSession creates a temporary login session for 2FA
  360. func (s *TotpService) CreateLoginSession(ctx context.Context, userID int64, email string) (string, error) {
  361. return s.createLoginSession(ctx, userID, email, nil)
  362. }
  363. // CreatePendingOAuthBindLoginSession creates a temporary 2FA session that will
  364. // finalize a pending OAuth bind after the TOTP code is verified.
  365. func (s *TotpService) CreatePendingOAuthBindLoginSession(
  366. ctx context.Context,
  367. userID int64,
  368. email string,
  369. pendingSessionToken string,
  370. browserSessionKey string,
  371. ) (string, error) {
  372. return s.createLoginSession(ctx, userID, email, &PendingOAuthBindLoginSession{
  373. PendingSessionToken: pendingSessionToken,
  374. BrowserSessionKey: browserSessionKey,
  375. })
  376. }
  377. func (s *TotpService) createLoginSession(
  378. ctx context.Context,
  379. userID int64,
  380. email string,
  381. pendingOAuthBind *PendingOAuthBindLoginSession,
  382. ) (string, error) {
  383. // Generate a random temp token
  384. tempToken, err := generateRandomToken(32)
  385. if err != nil {
  386. return "", fmt.Errorf("generate temp token: %w", err)
  387. }
  388. session := &TotpLoginSession{
  389. UserID: userID,
  390. Email: email,
  391. TokenExpiry: time.Now().Add(totpLoginTTL),
  392. PendingOAuthBind: pendingOAuthBind,
  393. }
  394. if err := s.cache.SetLoginSession(ctx, tempToken, session, totpLoginTTL); err != nil {
  395. return "", fmt.Errorf("store login session: %w", err)
  396. }
  397. return tempToken, nil
  398. }
  399. // GetLoginSession retrieves a login session
  400. func (s *TotpService) GetLoginSession(ctx context.Context, tempToken string) (*TotpLoginSession, error) {
  401. return s.cache.GetLoginSession(ctx, tempToken)
  402. }
  403. // DeleteLoginSession deletes a login session
  404. func (s *TotpService) DeleteLoginSession(ctx context.Context, tempToken string) error {
  405. return s.cache.DeleteLoginSession(ctx, tempToken)
  406. }
  407. // IsTotpEnabledForUser checks if TOTP is enabled for a specific user
  408. func (s *TotpService) IsTotpEnabledForUser(ctx context.Context, userID int64) (bool, error) {
  409. user, err := s.userRepo.GetByID(ctx, userID)
  410. if err != nil {
  411. return false, fmt.Errorf("get user: %w", err)
  412. }
  413. return user.TotpEnabled, nil
  414. }
  415. // MaskEmail masks an email address for display
  416. func MaskEmail(email string) string {
  417. if len(email) < 3 {
  418. return "***"
  419. }
  420. atIdx := -1
  421. for i, c := range email {
  422. if c == '@' {
  423. atIdx = i
  424. break
  425. }
  426. }
  427. if atIdx == -1 || atIdx < 1 {
  428. return email[:1] + "***"
  429. }
  430. localPart := email[:atIdx]
  431. domain := email[atIdx:]
  432. if len(localPart) <= 2 {
  433. return localPart[:1] + "***" + domain
  434. }
  435. return localPart[:1] + "***" + localPart[len(localPart)-1:] + domain
  436. }
  437. // generateRandomToken generates a random hex-encoded token
  438. func generateRandomToken(byteLength int) (string, error) {
  439. b := make([]byte, byteLength)
  440. if _, err := rand.Read(b); err != nil {
  441. return "", err
  442. }
  443. return hex.EncodeToString(b), nil
  444. }
  445. // VerificationMethod represents the method required for TOTP operations
  446. type VerificationMethod struct {
  447. Method string `json:"method"` // "email" or "password"
  448. }
  449. // GetVerificationMethod returns the verification method for TOTP operations.
  450. // 与 verifyIdentity 保持同一判定:管理员一律返回 password。
  451. func (s *TotpService) GetVerificationMethod(ctx context.Context, userID int64) (*VerificationMethod, error) {
  452. user, err := s.userRepo.GetByID(ctx, userID)
  453. if err != nil {
  454. return nil, fmt.Errorf("get user: %w", err)
  455. }
  456. if s.usesEmailVerification(ctx, user) {
  457. return &VerificationMethod{Method: "email"}, nil
  458. }
  459. return &VerificationMethod{Method: "password"}, nil
  460. }
  461. // SendVerifyCode sends an email verification code for TOTP operations
  462. func (s *TotpService) SendVerifyCode(ctx context.Context, userID int64, locale ...string) error {
  463. // Check if email verification is enabled
  464. if !s.settingService.IsEmailVerifyEnabled(ctx) {
  465. return infraerrors.BadRequest("EMAIL_VERIFY_NOT_ENABLED", "email verification is not enabled")
  466. }
  467. // Get user email
  468. user, err := s.userRepo.GetByID(ctx, userID)
  469. if err != nil {
  470. return fmt.Errorf("get user: %w", err)
  471. }
  472. // Get site name for email
  473. siteName := s.settingService.GetSiteName(ctx)
  474. // Send verification code via queue
  475. return s.emailQueueService.EnqueueVerifyCode(user.Email, siteName, firstEmailLocale(locale))
  476. }