293 lines
10 KiB
Markdown
293 lines
10 KiB
Markdown
# Member 模組
|
||
|
||
Gateway 的會員核心:**Tenant / Member / Identity** 三大實體,加上可讀 UID、業務 email/phone OTP、TOTP step-up MFA、重發 / 每日配額。
|
||
|
||
> **架構原則**([`docs/model.md`](../../../docs/model.md) §6.1):usecase **不可** 呼叫其他 usecase;多步流程(OTP → 寄信 → 驗碼 → flip flag)一律在 `internal/logic/member/` 編排。
|
||
>
|
||
> **規格 vs 速查**:完整 Mongo collection 欄位、Redis key TTL、API endpoint list → [`SDD.md`](./SDD.md)。本 README 只保留 sequence diagram、curl、ServiceContext wiring 等日常開發要看的東西。
|
||
|
||
---
|
||
|
||
## 核心實體
|
||
|
||
| 實體 | 用途 | 主要欄位 | 儲存 |
|
||
|------|------|---------|------|
|
||
| **Tenant** | 租戶元資料 | `tenant_id`、`slug`、`uid_prefix`、`status`、`org_id` | Mongo `tenants` |
|
||
| **Member** | 會員 profile(租戶範圍) | `(tenant_id, uid)`、`zitadel_user_id`、`status`、`origin`、business email/phone、totp cipher | Mongo `members` |
|
||
| **Identity** | 外部 ID → UID 對映 | `zitadel_user_id`、`external_id`、`uid` | Mongo `identities` |
|
||
|
||
對外可讀主鍵:`(tenant_id, uid)`;UID 格式 `{UIDPrefix}-{Sequence}`(例:`ACME-10000003`)。
|
||
|
||
**Origin:** `platform_native`(前台註冊)/ `oidc`(ZITADEL/Social)/ `ldap` / `scim`
|
||
|
||
**狀態機:**
|
||
|
||
```mermaid
|
||
stateDiagram-v2
|
||
[*] --> unverified: Lifecycle.CreateUnverified
|
||
[*] --> active: Provisioning.Ensure*
|
||
unverified --> active: Activate (OTP 通過)
|
||
unverified --> deleted: AbortPending (註冊逾時)
|
||
active --> suspended: Suspend
|
||
suspended --> active: Reactivate
|
||
active --> deleted: SoftDelete
|
||
suspended --> deleted: SoftDelete
|
||
```
|
||
|
||
---
|
||
|
||
## 目錄結構
|
||
|
||
```
|
||
internal/model/member/
|
||
├── README.md
|
||
├── config/ # OTP / TOTP / Registration 設定
|
||
├── domain/ # 介面、enum、entity、errors、redis key
|
||
│ ├── entity/ # Member / Tenant / Identity
|
||
│ ├── enum/ # MemberStatus / Origin / OTPPurpose / TenantStatus / VerifyKind
|
||
│ ├── repository/ # 7 個 repository 介面
|
||
│ ├── usecase/ # 7 個 usecase 介面 + DTO
|
||
│ ├── const.go # BSON 欄位、UID 常數
|
||
│ ├── errors.go # ErrNotFound / ErrDuplicateMember ...
|
||
│ └── redis.go # GetOTPChallengeRedisKey ...
|
||
├── repository/ # Mongo + Redis 實作
|
||
├── totp/ # RFC 6238 純函式
|
||
└── usecase/ # 實作 + module factory
|
||
```
|
||
|
||
---
|
||
|
||
## Atomic UseCase 一覽
|
||
|
||
| UseCase | 介面方法 | 職責 |
|
||
|---------|---------|------|
|
||
| **TenantUseCase** | `Create` / `ResolveBySlug` | 建租戶、依 slug 反查 |
|
||
| **LifecycleUseCase** | `CreateUnverified` / `Activate` / `Suspend` / `Reactivate` / `SoftDelete` / `AbortPending` | platform 會員建立 + 狀態轉換 |
|
||
| **ProfileUseCase** | `GetByUID` / `GetByZitadelUserID` / `Update` / `List` / `SetBusinessEmailVerified` / `SetBusinessPhoneVerified` | profile 讀寫、業務 contact 驗證標記 |
|
||
| **ProvisioningUseCase** | `EnsureFromOIDC` / `EnsureFromLDAP` / `EnsureFromSCIM` | 外部身份首登 upsert(冪等) |
|
||
| **OTPUseCase** | `Generate` / `Verify` / `Invalidate` / `GetChallenge` / `MatchChallenge` | 一次性數字碼(bcrypt + Redis) |
|
||
| **TOTPUseCase** | `StartEnroll` / `ConfirmEnroll` / `VerifyCode` / `Disable` / `RegenerateBackupCodes` / `Status` | RFC 6238 step-up MFA(AES-GCM 保護 secret) |
|
||
| **VerifyRateUseCase** | `AssertResendAllowed` / `AssertDailyAllowed` | resend 冷卻 + 每日上限 |
|
||
|
||
**Module factory 條件啟用:**
|
||
- Redis 必填 → `OTP` / `VerifyRate` 永遠存在
|
||
- Mongo 啟用 → `Profile` / `Lifecycle` / `Tenant` / `Provisioning`
|
||
- `Member.TOTP.SecretKEK` 啟用 → `TOTP`(否則 `mod.TOTP == nil`)
|
||
|
||
---
|
||
|
||
## 資料儲存
|
||
|
||
### MongoDB Collections
|
||
|
||
| Collection | 主要索引 |
|
||
|------------|---------|
|
||
| `members` | unique `(tenant_id, uid)`、unique `(tenant_id, zitadel_user_id)` sparse |
|
||
| `tenants` | unique `slug`、unique `uid_prefix` |
|
||
| `identities` | unique `(tenant_id, external_id)`、unique `(tenant_id, zitadel_user_id)` |
|
||
|
||
索引建立由 `repository.EnsureMongoIndexes`(`cmd/mongo-index` 會跑)。
|
||
|
||
### Redis Keys
|
||
|
||
| Key | 用途 | TTL |
|
||
|------|------|-----|
|
||
| `member:otp:challenge:{id}` | OTP challenge(bcrypt hash) | `OTP.TTLSeconds`(預設 300) |
|
||
| `member:otp:challenge:{id}:attempts` | OTP 錯誤次數 | 同 challenge |
|
||
| `member:verify:rate:{tenant}:{uid}:{kind}` | resend 冷卻 | `OTP.ResendCooldownSeconds`(預設 60) |
|
||
| `member:verify:daily:{tenant}:{uid}:{kind}` | 每日上限 | 24h |
|
||
| `member:totp:enroll:{tenant}:{uid}` | 綁定中 staged secret cipher | `TOTP.EnrollTTLSeconds`(預設 600) |
|
||
| `member:totp:used:{tenant}:{uid}:{timestep}` | TOTP replay 保護 | `TOTP.ReplayTTLSeconds`(預設 90) |
|
||
| `member:seq:{tenant}` | UID 序號 | 永久 |
|
||
|
||
Key helper 在 `domain/redis.go`,**禁止** 在他處字串拼接。
|
||
|
||
---
|
||
|
||
## 重要流程
|
||
|
||
### 1. 業務 Email / Phone OTP 驗證(logic 編排示範)
|
||
|
||
由 `internal/logic/member/verify_helper.go` 串多個 atomic:
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
autonumber
|
||
participant Client
|
||
participant Logic as logic/member.startVerification
|
||
participant Rate as VerifyRate
|
||
participant OTP
|
||
participant Notif as Notifier
|
||
participant Profile
|
||
|
||
Client->>Logic: POST /me/verifications/email/start {target}
|
||
Logic->>Rate: AssertResendAllowed(cooldown)
|
||
Logic->>Rate: AssertDailyAllowed(每日上限)
|
||
Logic->>OTP: Generate(purpose=BusinessEmail, target=email)
|
||
OTP-->>Logic: challenge_id, plainCode
|
||
Logic->>Notif: Send(VerifyEmail, code)
|
||
alt Notifier 失敗
|
||
Logic->>OTP: Invalidate(challenge_id)
|
||
end
|
||
Logic-->>Client: {challenge_id, expires_in}
|
||
|
||
Note over Client,Profile: 使用者收到信
|
||
Client->>Logic: POST /me/verifications/email/confirm {challenge_id, code}
|
||
Logic->>OTP: Verify (bcrypt compare、attempts ↑↑↑)
|
||
OTP-->>Logic: target(email)
|
||
Logic->>Profile: SetBusinessEmailVerified(tenant, uid, target)
|
||
Logic-->>Client: 204
|
||
```
|
||
|
||
Key:`Verify` 成功後 challenge 立刻刪除(一次性);`Generate` 必先過 `VerifyRate` 兩道閘。
|
||
|
||
### 2. TOTP(綁定 + step-up + 重放保護)
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
autonumber
|
||
participant Client
|
||
participant TOTP as TOTPUseCase
|
||
participant Profile as TOTPProfileRepository
|
||
participant Enroll as TOTPEnrollStore
|
||
participant Replay as TOTPReplayStore
|
||
participant Cipher as crypto.Cipher
|
||
|
||
Note over Client,Cipher: 綁定階段
|
||
Client->>TOTP: StartEnroll(tenant, uid, account)
|
||
TOTP->>Profile: 必須未 enrolled
|
||
TOTP->>Cipher: Encrypt(secret)
|
||
TOTP->>Enroll: Save(cipherBlob, TTL=600s)
|
||
TOTP-->>Client: {otpauth_url, digits, period}
|
||
|
||
Client->>TOTP: ConfirmEnroll(code)
|
||
TOTP->>Enroll: Get cipherBlob
|
||
TOTP->>Cipher: Decrypt → secret
|
||
TOTP->>TOTP: totp.Verify(±window)
|
||
TOTP->>Profile: Save (Enrolled, SecretCipher, BackupCodesHash)
|
||
TOTP-->>Client: plainBackupCodes(僅此一次回傳)
|
||
|
||
Note over Client,Replay: 日常 step-up
|
||
Client->>TOTP: VerifyCode(code)
|
||
TOTP->>Replay: MarkUsed(timestep) → fresh?
|
||
alt 已用過
|
||
TOTP-->>Client: ErrTOTPCodeReplay
|
||
end
|
||
```
|
||
|
||
### 3. UID 生成
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
Caller->>Gen: Next(tenant, uidPrefix)
|
||
Gen->>Redis: INCR member:seq:{tenant}
|
||
alt seq == 1(首次)
|
||
Gen->>Redis: INCRBY 9_999_999 → 10_000_000
|
||
end
|
||
Gen-->>Caller: "ACME-10000003"
|
||
```
|
||
|
||
`UIDSequenceStart = 10_000_000`,prefix 限 2~4 個大寫字母。
|
||
|
||
> 平台註冊 + Provisioning OIDC/LDAP/SCIM 詳細時序,見 [`docs/identity-member-design.md`](../../../docs/identity-member-design.md)。
|
||
|
||
---
|
||
|
||
## 設定(`etc/gateway.dev.yaml`)
|
||
|
||
```yaml
|
||
Member:
|
||
Registration:
|
||
RequireInviteCode: true
|
||
TrustSocialEmailVerified: true # OIDC email_verified=true 直接 active
|
||
OTP:
|
||
Length: 6
|
||
TTLSeconds: 300
|
||
MaxAttempts: 5
|
||
ResendCooldownSeconds: 60
|
||
DailyVerifyLimit: 10
|
||
TOTP:
|
||
Issuer: CloudEP
|
||
Algorithm: SHA1
|
||
Digits: 6
|
||
PeriodSeconds: 30
|
||
Window: 1
|
||
BackupCodeCount: 10
|
||
BackupCodeLength: 12
|
||
EnrollTTLSeconds: 600
|
||
ReplayTTLSeconds: 90
|
||
SecretKEK: "" # 32-byte hex (64) 或 base64;留空關閉 TOTP
|
||
```
|
||
|
||
**`SecretKEK`** prod 走 env 或 KMS(`TOTP_SECRET_KEK`)。
|
||
|
||
---
|
||
|
||
## ServiceContext 注入
|
||
|
||
```go
|
||
sc.MemberOTP // 一定有(Redis 必填)
|
||
sc.MemberVerifyRate // 一定有
|
||
sc.MemberProfile // Mongo 啟用後
|
||
sc.MemberLifecycle // Mongo 啟用後
|
||
sc.MemberTenant // Mongo 啟用後
|
||
sc.MemberProvisioning // Mongo 啟用後
|
||
sc.MemberTOTP // TOTP.SecretKEK 設定後,否則 nil
|
||
```
|
||
|
||
```go
|
||
if sc.MemberTOTP == nil {
|
||
return errb.SysNotImplemented("member TOTP not configured")
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 測試
|
||
|
||
### 單元
|
||
|
||
```bash
|
||
go test ./internal/model/member/... -v
|
||
make check
|
||
```
|
||
|
||
| 檔案 | 覆蓋 |
|
||
|------|------|
|
||
| `usecase/otp_usecase_test.go` | Generate / Verify、purpose mismatch、attempts lock |
|
||
| `usecase/totp_usecase_test.go` | 綁定、VerifyCode、備援碼、重放、Disable、Regenerate |
|
||
| `totp/totp_test.go` | RFC 6238 測試向量、window、otpauth URL |
|
||
|
||
### 本機 API
|
||
|
||
```bash
|
||
make deps-up && make mongo-index
|
||
make member-seed # 建 dev tenant + member
|
||
make run-dev
|
||
|
||
curl -s -H "Authorization: Bearer $TOKEN" \
|
||
http://127.0.0.1:8888/api/v1/members/me | jq
|
||
```
|
||
|
||
### 互動式 TOTP
|
||
|
||
```bash
|
||
make totp-test # STEP=flow:整套綁定 + 驗碼 + 重放
|
||
make totp-test STEP=status
|
||
```
|
||
|
||
需 `Member.TOTP.SecretKEK` 已設定。
|
||
|
||
### E2E
|
||
|
||
見 [`docs/e2e-testing.md`](../../../docs/e2e-testing.md)(`TestMember_*`)。
|
||
|
||
---
|
||
|
||
## 相關文件
|
||
|
||
- [`SDD.md`](./SDD.md) — Member 模組規格書(Data Dictionary、完整 API 端點)
|
||
- [`docs/model.md`](../../../docs/model.md) — Clean Architecture 分層
|
||
- [`docs/identity-member-design.md`](../../../docs/identity-member-design.md) — 跨模組設計
|
||
- [`internal/library/errors/README.md`](../../library/errors/README.md) — 錯誤碼
|