360 lines
11 KiB
Markdown
360 lines
11 KiB
Markdown
|
|
# 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。
|