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

384 lines
12 KiB
Markdown
Raw Normal View History

2026-05-20 13:03:59 +00:00
# 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
```
---
## 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。
```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 limitlogic 層使用)
`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)
```
---
## 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 暫存 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 keyhex 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 — 預設 memoryP4 換 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, &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-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` 等)。
---
## 測試
2026-05-20 23:51:22 +00:00
### 本機 APIP4
> JWT / Casbin 尚未接入dev 模式用 Header 帶身份:
> `X-Tenant-ID`、`X-UID`
```bash
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 對照表)。
2026-05-20 13:03:59 +00:00
### 單元測試
```bash
go test ./internal/model/member/... -v
make check
```
### 互動式 TOTPGoogle 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 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`](../../../docs/identity-member-design.md) §5.2、§5.8。