template-monorepo/internal/model/member/README.md

12 KiB
Raw Blame History

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

OTPOne-Time Password

原理

  1. Generate:伺服器用 crypto/rand 產生 N 位數字碼(預設 6 位),以 bcrypt 雜湊後存入 RedisTTL 預設 300 秒。
  2. 寄送:明文驗證碼只在 Generate 回傳值中出現一次logic 層負責呼叫 notification.Notifier.Send 投遞。
  3. Verify:使用者提交 challenge_id + code,伺服器比對 bcrypt成功後 刪除 challenge(一次性)。
  4. 防暴力:錯誤次數達 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 limitlogic 層使用)

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)

TOTPTime-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 暫存 RedisEnrollTTLSeconds,預設 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 判定順序

  1. 長度 = 6 → 當 TOTP 驗(含 ±window
  2. 通過 → Redis 記錄 time step已用過則回 ErrTOTPCodeReplay
  3. TOTP 失敗 → 逐一 bcrypt 比對備援碼;命中則消耗一組
  4. 皆失敗 → ErrTOTPInvalidCode

設定

etc/gateway.dev.yamlMember 區塊:

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 keyhex 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    — 預設 memoryP4 換 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, &notif.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-teststartMemberVerify 實作了發起驗證的前半段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.goGetOTPChallengeRedisKey 等)。


測試

本機 APIP4

JWT / Casbin 尚未接入dev 模式用 Header 帶身份: X-Tenant-IDX-UID

make deps-up
make mongo-index
make member-seed          # 建立 dev tenant + member輸出 headers
make run-local            # 或 make run

# 範例
curl -s -H "X-Tenant-ID:dev-tenant" -H "X-UID:DEV-10000000" \
  http://127.0.0.1:8888/api/v1/members/me | jq

# 業務 email 驗證logic 層OTP.Generate → Notifier.Send
curl -s -X POST -H "Content-Type: application/json" \
  -H "X-Tenant-ID:dev-tenant" -H "X-UID:DEV-10000000" \
  -d '{"target":"you@example.com"}' \
  http://127.0.0.1:8888/api/v1/members/me/verifications/email/start | jq

完整 API 見 generate/api/member.api§7.2 對照表)。

單元測試

go test ./internal/model/member/... -v
make check

互動式 TOTPGoogle Authenticator

本機需 Redis並在 etc/gateway.dev.yaml 設定 Member.TOTP.SecretKEKexample 已附 dev-only 占位 key

make deps-up
make totp-test

流程(單一 process預設 -step flow

  1. 終端機印出 QR codeSecret key
  2. 手機 Google Authenticator → 掃描 QR或手動輸入 Secret
  3. 輸入 Authenticator 上的 6 碼 → ConfirmEnroll(綁定完成,顯示備援碼)
  4. 等 code 刷新後再輸入新 6 碼 → VerifyCodestep-up 驗證)
  5. 自動測試重放保護(同一碼再驗應失敗)

進階:

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 handlerverify-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。