# 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) — 錯誤碼