# Member 模組 — OTP / TOTP Member 模組目前提供兩組 **atomic usecase**(單一職責、互不呼叫): | UseCase | 用途 | 典型場景 | |---------|------|----------| | **OTP** | 伺服器產生的一次性數字碼 | 業務 email / 手機驗證、step-up 簡訊 | | **TOTP** | RFC 6238 時間型驗證碼 | Google Authenticator 等 App 的 step-up MFA | > **架構原則**:usecase **不可**呼叫其他 usecase。 > 「產碼 → 寄信/簡訊 → 驗碼 → 更新 profile」這類多步驟流程,由 **logic 層**(或 CLI driver)編排。 > 詳見 [`docs/model.md`](../../../docs/model.md) §6.1。 --- ## 目錄結構 ``` internal/model/member/ ├── config/ # OTP / TOTP 設定 ├── domain/ # 介面、enum、errors、redis key │ ├── enum/ │ ├── repository/ │ └── usecase/ ├── repository/ # Redis / memory 實作 ├── totp/ # RFC 6238 純函式(模組專屬,非 internal/library) ├── usecase/ # OTPUseCase、TOTPUseCase 實作 └── README.md ``` --- ## OTP(One-Time Password) ### 原理 1. **Generate**:伺服器用 `crypto/rand` 產生 N 位數字碼(預設 6 位),以 **bcrypt** 雜湊後存入 Redis,TTL 預設 300 秒。 2. **寄送**:明文驗證碼只在 `Generate` 回傳值中出現一次;logic 層負責呼叫 `notification.Notifier.Send` 投遞。 3. **Verify**:使用者提交 `challenge_id + code`,伺服器比對 bcrypt;成功後 **刪除 challenge**(一次性)。 4. **防暴力**:錯誤次數達 `MaxAttempts`(預設 5)即鎖定該 challenge。 ```mermaid sequenceDiagram participant Logic participant OTP as OTPUseCase participant Redis participant Notifier Logic->>OTP: Generate(tenant, uid, purpose, target) OTP->>Redis: Save challenge (bcrypt hash) OTP-->>Logic: challenge_id, plainCode Logic->>Notifier: Send(code, expires_in) Note over Logic: 使用者收到信/簡訊 Logic->>OTP: Verify(challenge_id, code, uid, purpose) OTP->>Redis: Get + bcrypt compare OTP->>Redis: Delete challenge OTP-->>Logic: target (email/phone) Logic->>Logic: Profile.SetBusinessEmailVerified(...) ``` ### Purpose(用途標籤) ```go enum.OTPPurposeBusinessEmail // 業務 email 驗證 enum.OTPPurposeBusinessPhone // 業務 phone 驗證 enum.OTPPurposeStepUp // step-up(未來 logic 層使用) ``` Verify 時 `Purpose` 必須與 Generate 一致,否則拒絕。 ### API ```go // 產碼 dto, plainCode, err := otpUC.Generate(ctx, &domusecase.GenerateOTPRequest{ TenantID: "t1", UID: "u1", Purpose: enum.OTPPurposeBusinessEmail, Target: "user@example.com", }) // dto.ChallengeID → 給前端帶回 confirm API // plainCode → 只在此刻存在,交給 Notifier 寄出 // 驗碼 target, err := otpUC.Verify(ctx, &domusecase.VerifyOTPRequest{ TenantID: "t1", UID: "u1", ChallengeID: dto.ChallengeID, Code: "482913", Purpose: enum.OTPPurposeBusinessEmail, }) // 成功 → target == "user@example.com",challenge 已刪除 // 寄送失敗時回滾 _ = otpUC.Invalidate(ctx, dto.ChallengeID) ``` ### Rate limit(logic 層使用) `VerifyRateStore` 提供 resend cooldown 與每日上限,**不在 OTPUseCase 內建**: ```go // 冷卻(60 秒內不可重發) ok, _ := verifyRate.TryResendLock(ctx, member.GetVerifyRateRedisKey(tenant, uid, "email"), 60*time.Second) // 每日上限(預設 10 次) count, _ := verifyRate.IncrDaily(ctx, member.GetVerifyDailyRedisKey(tenant, uid, "email"), 24*time.Hour) ``` --- ## TOTP(Time-based OTP) ### 原理 遵循 **RFC 6238**,與 Google Authenticator / Authy 相容: | 參數 | 預設值 | |------|--------| | 演算法 | HMAC-SHA1 | | 週期 | 30 秒 | | 位數 | 6 | | 時間窗口 | ±1 step(容忍時鐘偏差) | **儲存安全**: - Secret 以 **AES-256-GCM** 加密(KEK = `Member.TOTP.SecretKEK`)後寫入 profile。 - 備援碼以 **bcrypt** 雜湊儲存,明文只在 `ConfirmEnroll` / `RegenerateBackupCodes` 回傳一次。 - 綁定前的 staged secret 暫存 Redis(`EnrollTTLSeconds`,預設 600 秒)。 - 驗碼成功後以 Redis 記錄 time step,**同一時間窗口內不可重放**。 ```mermaid sequenceDiagram participant User participant Logic participant TOTP as TOTPUseCase participant Redis participant Profile Note over Logic,Profile: 綁定階段 Logic->>TOTP: StartEnroll(tenant, uid, account) TOTP->>Redis: Save encrypted staged secret TOTP-->>Logic: otpauth_url (QR code) User->>User: 掃碼加入 Authenticator Logic->>TOTP: ConfirmEnroll(tenant, uid, code) TOTP->>Profile: Save encrypted secret + backup hashes TOTP->>Redis: Delete staged secret TOTP-->>Logic: backup_codes[] (只顯示一次) Note over Logic,Profile: 日常使用(step-up) Logic->>TOTP: VerifyCode(tenant, uid, code) TOTP->>Profile: Decrypt secret TOTP->>Redis: MarkUsed(timestep) — 防重放 TOTP-->>Logic: ok / err ``` ### API ```go // 1. 開始綁定 — 回傳 otpauth URL 供前端渲染 QR code start, err := totpUC.StartEnroll(ctx, "t1", "u1", "user@example.com") // start.OtpauthURL, start.Digits, start.PeriodSec, start.ExpiresIn // 2. 確認綁定 — 使用者輸入 Authenticator 上的 6 碼 backupCodes, err := totpUC.ConfirmEnroll(ctx, "t1", "u1", "482913") // backupCodes 只回傳這一次,請引導使用者妥善保存 // 3. step-up 驗碼 — TOTP 或備援碼皆可 err = totpUC.VerifyCode(ctx, "t1", "u1", "482913") // 6 碼 TOTP err = totpUC.VerifyCode(ctx, "t1", "u1", "ABCD-EFGH") // 備援碼(用過即刪) // 4. 查狀態 status, err := totpUC.Status(ctx, "t1", "u1") // status.Enrolled, status.BackupCodesRemaining // 5. 停用 / 重產備援碼(logic 層應先要求 step-up) _ = totpUC.Disable(ctx, "t1", "u1") newCodes, err := totpUC.RegenerateBackupCodes(ctx, "t1", "u1") ``` ### VerifyCode 判定順序 1. 長度 = 6 → 當 TOTP 驗(含 ±window) 2. 通過 → Redis 記錄 time step;已用過則回 `ErrTOTPCodeReplay` 3. TOTP 失敗 → 逐一 bcrypt 比對備援碼;命中則消耗一組 4. 皆失敗 → `ErrTOTPInvalidCode` --- ## 設定 `etc/gateway.dev.yaml` → `Member` 區塊: ```yaml Member: OTP: Length: 6 # 驗證碼位數 TTLSeconds: 300 # challenge 存活時間 MaxAttempts: 5 # 單 challenge 最大錯誤次數 ResendCooldownSeconds: 60 # 重發冷卻(logic 層用 VerifyRateStore) DailyVerifyLimit: 10 # 每日上限(logic 層用 VerifyRateStore) TOTP: Issuer: CloudEP Algorithm: SHA1 Digits: 6 PeriodSeconds: 30 Window: 1 # ±1 time step BackupCodeCount: 10 BackupCodeLength: 12 EnrollTTLSeconds: 600 # 綁定 staged secret TTL ReplayTTLSeconds: 90 # 重放保護 TTL SecretKEK: "" # 32-byte AES key(hex 64 字元或 base64);留空則不啟用 TOTP ``` `SecretKEK` 可透過環境變數 `TOTP_SECRET_KEK` 注入(production 建議走 KMS / secret manager)。 --- ## 裝配與注入 ### Module factory ```go mod, err := memberusecase.NewModuleFromParam(memberusecase.ModuleParam{ Redis: rds, Config: c.Member, }) // mod.OTP — 永遠有值(需 Redis) // mod.TOTP — SecretKEK 有設定時才有值,否則 nil // mod.VerifyRate — resend / daily cap // mod.Profile — 預設 memory,P4 換 Mongo ``` ### ServiceContext Gateway 啟動時(Redis 就緒)自動注入: ```go svc.MemberOTP // domusecase.OTPUseCase svc.MemberTOTP // domusecase.TOTPUseCase(可能 nil) svc.MemberVerifyRate // VerifyRateStore svc.MemberProfile // ProfileRepository ``` --- ## Logic 層編排範例 以下示範 **verify business email** 完整流程(logic 層職責,尚未有 HTTP handler): ```go // ── 發起驗證 ── dto, code, err := svc.MemberOTP.Generate(ctx, &domusecase.GenerateOTPRequest{ TenantID: tenant, UID: uid, Purpose: enum.OTPPurposeBusinessEmail, Target: email, }) if err != nil { return err } _, err = svc.Notifier.Send(ctx, ¬if.SendRequest{ TenantID: tenant, UID: uid, Channel: enum.ChannelEmail, Kind: enum.NotifyVerifyEmail, Target: email, Locale: locale, Data: map[string]any{"code": code, "expires_in": dto.ExpiresIn}, IdempotencyKey: dto.ChallengeID, }) if err != nil { _ = svc.MemberOTP.Invalidate(ctx, dto.ChallengeID) // 寄送失敗回滾 return err } return dto // 回傳 challenge_id 給前端 // ── 確認驗證 ── target, err := svc.MemberOTP.Verify(ctx, &domusecase.VerifyOTPRequest{ TenantID: tenant, UID: uid, ChallengeID: req.ChallengeID, Code: req.Code, Purpose: enum.OTPPurposeBusinessEmail, }) if err != nil { return err } return svc.MemberProfile.SetBusinessEmailVerified(ctx, tenant, uid, target) ``` `cmd/notify-test` 的 `startMemberVerify` 實作了發起驗證的前半段(Generate + Send),可作為 driver 參考: ```bash make deps-up make notify-test METHOD=member-email TO=you@example.com make notify-test METHOD=member-phone PHONE=0912345678 ``` --- ## Redis Key 命名 | Key 前綴 | 用途 | |----------|------| | `member:otp:challenge:{id}` | OTP challenge 狀態 | | `member:otp:challenge:{id}:attempts` | 錯誤次數計數 | | `member:verify:rate:{tenant}:{uid}:{kind}` | 重發冷卻 | | `member:verify:daily:{tenant}:{uid}:{kind}` | 每日上限 | | `member:totp:enroll:{tenant}:{uid}` | 綁定 staged secret | | `member:totp:used:{tenant}:{uid}:{timestep}` | TOTP 重放保護 | Helper 函式見 `domain/redis.go`(`GetOTPChallengeRedisKey` 等)。 --- ## 測試 ### 單元測試 ```bash go test ./internal/model/member/... -v make check ``` ### 互動式 TOTP(Google Authenticator) 本機需 Redis,並在 `etc/gateway.dev.yaml` 設定 `Member.TOTP.SecretKEK`(example 已附 dev-only 占位 key)。 ```bash make deps-up make totp-test ``` 流程(單一 process,預設 `-step flow`): 1. 終端機印出 **QR code** 與 **Secret key** 2. 手機 Google Authenticator → 掃描 QR(或手動輸入 Secret) 3. 輸入 Authenticator 上的 6 碼 → **ConfirmEnroll**(綁定完成,顯示備援碼) 4. 等 code 刷新後再輸入新 6 碼 → **VerifyCode**(step-up 驗證) 5. 自動測試重放保護(同一碼再驗應失敗) 進階: ```bash make totp-test STEP=status make totp-test STEP=disable make totp-test STEP=verify CODE=482913 ``` | 檔案 | 覆蓋 | |------|------| | `usecase/otp_usecase_test.go` | Generate/Verify、UID mismatch、max attempts lock | | `usecase/totp_usecase_test.go` | 綁定、VerifyCode、備援碼、重放、Disable、Regenerate | | `totp/totp_test.go` | RFC 6238 測試向量、window、otpauth URL | | `library/crypto/aesgcm_test.go` | TOTP secret 加解密 | --- ## 尚未實作 - HTTP API / goctl handler(verify-email、verify-phone、totp enroll 等) - Logic 層 confirm 流程(Verify + Profile flip + rate limit) - `ProfileRepository` / `TOTPProfileRepository` 的 MongoDB 實作(目前 memory) - Step-up token 簽發(auth 模組) 設計細節見 [`docs/identity-member-design.md`](../../../docs/identity-member-design.md) §5.2、§5.8。