10 KiB
Member 模組
Gateway 的會員核心:Tenant / Member / Identity 三大實體,加上可讀 UID、業務 email/phone OTP、TOTP step-up MFA、重發 / 每日配額。
架構原則(
docs/model.md§6.1):usecase 不可 呼叫其他 usecase;多步流程(OTP → 寄信 → 驗碼 → flip flag)一律在internal/logic/member/編排。規格 vs 速查:完整 Mongo collection 欄位、Redis key TTL、API endpoint list →
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
狀態機:
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:
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 + 重放保護)
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 生成
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。
設定(etc/gateway.dev.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 注入
sc.MemberOTP // 一定有(Redis 必填)
sc.MemberVerifyRate // 一定有
sc.MemberProfile // Mongo 啟用後
sc.MemberLifecycle // Mongo 啟用後
sc.MemberTenant // Mongo 啟用後
sc.MemberProvisioning // Mongo 啟用後
sc.MemberTOTP // TOTP.SecretKEK 設定後,否則 nil
if sc.MemberTOTP == nil {
return errb.SysNotImplemented("member TOTP not configured")
}
測試
單元
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
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
make totp-test # STEP=flow:整套綁定 + 驗碼 + 重放
make totp-test STEP=status
需 Member.TOTP.SecretKEK 已設定。
E2E
見 docs/e2e-testing.md(TestMember_*)。
相關文件
SDD.md— Member 模組規格書(Data Dictionary、完整 API 端點)docs/model.md— Clean Architecture 分層docs/identity-member-design.md— 跨模組設計internal/library/errors/README.md— 錯誤碼