|
|
||
|---|---|---|
| .. | ||
| config | ||
| domain | ||
| repository | ||
| totp | ||
| usecase | ||
| README.md | ||
README.md
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§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)
原理
- Generate:伺服器用
crypto/rand產生 N 位數字碼(預設 6 位),以 bcrypt 雜湊後存入 Redis,TTL 預設 300 秒。 - 寄送:明文驗證碼只在
Generate回傳值中出現一次;logic 層負責呼叫notification.Notifier.Send投遞。 - Verify:使用者提交
challenge_id + code,伺服器比對 bcrypt;成功後 刪除 challenge(一次性)。 - 防暴力:錯誤次數達
MaxAttempts(預設 5)即鎖定該 challenge。
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(用途標籤)
enum.OTPPurposeBusinessEmail // 業務 email 驗證
enum.OTPPurposeBusinessPhone // 業務 phone 驗證
enum.OTPPurposeStepUp // step-up(未來 logic 層使用)
Verify 時 Purpose 必須與 Generate 一致,否則拒絕。
API
// 產碼
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 內建:
// 冷卻(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,同一時間窗口內不可重放。
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
// 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 判定順序
- 長度 = 6 → 當 TOTP 驗(含 ±window)
- 通過 → Redis 記錄 time step;已用過則回
ErrTOTPCodeReplay - TOTP 失敗 → 逐一 bcrypt 比對備援碼;命中則消耗一組
- 皆失敗 →
ErrTOTPInvalidCode
設定
etc/gateway.dev.yaml → Member 區塊:
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
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 就緒)自動注入:
svc.MemberOTP // domusecase.OTPUseCase
svc.MemberTOTP // domusecase.TOTPUseCase(可能 nil)
svc.MemberVerifyRate // VerifyRateStore
svc.MemberProfile // ProfileRepository
Logic 層編排範例
以下示範 verify business email 完整流程(logic 層職責,尚未有 HTTP handler):
// ── 發起驗證 ──
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 參考:
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 等)。
測試
單元測試
go test ./internal/model/member/... -v
make check
互動式 TOTP(Google Authenticator)
本機需 Redis,並在 etc/gateway.dev.yaml 設定 Member.TOTP.SecretKEK(example 已附 dev-only 占位 key)。
make deps-up
make totp-test
流程(單一 process,預設 -step flow):
- 終端機印出 QR code 與 Secret key
- 手機 Google Authenticator → 掃描 QR(或手動輸入 Secret)
- 輸入 Authenticator 上的 6 碼 → ConfirmEnroll(綁定完成,顯示備援碼)
- 等 code 刷新後再輸入新 6 碼 → VerifyCode(step-up 驗證)
- 自動測試重放保護(同一碼再驗應失敗)
進階:
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 §5.2、§5.8。