|
|
||
|---|---|---|
| .. | ||
| config | ||
| domain | ||
| repository | ||
| totp | ||
| usecase | ||
| README.md | ||
| SDD.md | ||
README.md
Member 模組
Gateway 的會員核心:涵蓋 Tenant(租戶)、Member(會員 profile)、Identity(外部身份對映) 三大實體,以及租戶內 readable UID、業務 email/phone OTP 驗證、TOTP step-up MFA、resend / daily 配額等業務功能。
架構原則(
docs/model.md§6.1): usecase 不可 呼叫其他 usecase。多步流程(例如「發起 OTP → 寄信 → 驗碼 → flip business_email_verified」)由 logic 層 編排。 本 module 所有 usecase 都是 atomic primitives。
目錄
核心概念
| 實體 | 用途 | 主要欄位 | 儲存 |
|---|---|---|---|
| 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 |
Member 雙鍵:(tenant_id, uid) 為對外的可讀主鍵;zitadel_user_id 是 OIDC 來源的對映鍵。
多租戶等級:每個 Member 必屬於一個 Tenant,UID 用 {TenantUIDPrefix}-{Sequence} 格式(例:ACME-10000003)。
來源(Origin)
platform_native // 前台註冊(auth.RegisterLogic + Lifecycle.CreateUnverified)
oidc // ZITADEL 社群登入 / SSO(Provisioning.EnsureFromOIDC)
ldap // Directory Sync(Provisioning.EnsureFromLDAP)
scim // SCIM 2.0(Provisioning.EnsureFromSCIM)
目錄結構
internal/model/member/
├── config/ # OTP / TOTP / Registration 設定
├── domain/ # 介面、enum、entity、errors、redis key helper
│ ├── const.go # BSON 欄位、UID 常數
│ ├── entity/ # Member、Tenant、Identity Mongo doc
│ ├── enum/ # MemberStatus、MemberOrigin、OTPPurpose、TenantStatus、VerifyKind
│ ├── errors.go # ErrNotFound、ErrDuplicateMember 等
│ ├── redis.go # GetOTPChallengeRedisKey 等 helper
│ ├── repository/ # 7 個 repository 介面
│ └── usecase/ # 7 個 usecase 介面 + DTO
├── repository/ # Mongo / Redis 實作
├── totp/ # RFC 6238 純函式(secret、verify、otpauth URL)
├── usecase/ # 7 個 usecase 實作 + module factory + mapper
└── README.md # 本檔
domain/ 純介面 + 常數,不依賴外部 lib(除 bson.ObjectID)。
usecase/ 只依賴 domain/。
repository/ 依賴 library/mongo、library/redis。
Module 結構與依賴
flowchart TB
Logic["logic 層<br/>(handler 編排)"]
subgraph M["member.Module (atomic usecases)"]
direction LR
OTP["OTP"]
TOTP["TOTP"]
Profile["Profile"]
Lifecycle["Lifecycle"]
Provisioning["Provisioning"]
Tenant["Tenant"]
VerifyRate["VerifyRate"]
end
subgraph R["domain.Repository (介面)"]
MemberRepo["MemberRepository"]
TenantRepo["TenantRepository"]
IdentityRepo["IdentityRepository"]
OTPStore["OTPChallengeStore"]
RateStore["VerifyRateStore"]
TOTPProf["TOTPProfileRepository"]
TOTPEnroll["TOTPEnrollStore"]
TOTPReplay["TOTPReplayStore"]
UIDGen["UIDGenerator"]
end
subgraph I["repository/ 實作"]
Mongo[(MongoDB)]
Redis[(Redis)]
end
Logic -->|單呼叫| M
OTP --> OTPStore
TOTP --> TOTPProf
TOTP --> TOTPEnroll
TOTP --> TOTPReplay
Profile --> MemberRepo
Lifecycle --> MemberRepo
Lifecycle --> TenantRepo
Lifecycle --> UIDGen
Provisioning --> MemberRepo
Provisioning --> IdentityRepo
Provisioning --> TenantRepo
Provisioning --> UIDGen
Tenant --> TenantRepo
VerifyRate --> RateStore
MemberRepo --- Mongo
TenantRepo --- Mongo
IdentityRepo --- Mongo
TOTPProf --- Mongo
OTPStore --- Redis
RateStore --- Redis
TOTPEnroll --- Redis
TOTPReplay --- Redis
UIDGen --- Redis
注入規則:Module factory 依條件啟用 usecase:
Redis必填 →OTP、VerifyRate永遠存在。MongoConf設定 → 啟用Profile、Lifecycle、Tenant、Provisioning。TOTP.SecretKEK設定 → 啟用TOTP(否則mod.TOTP == nil)。
Atomic UseCase 一覽
| UseCase | 介面方法 | 職責 |
|---|---|---|
| TenantUseCase | Create / ResolveBySlug |
建立租戶、依 slug 反查 |
| LifecycleUseCase | CreateUnverified / Activate / Suspend / Reactivate / SoftDelete / AbortPending |
platform 會員建立 + 狀態轉換(嚴格的 from→to 檢查) |
| ProfileUseCase | GetByUID / GetByZitadelUserID / Update / List / SetBusinessEmailVerified / SetBusinessPhoneVerified |
讀取 / patch 可變欄位、業務 contact 標記已驗證 |
| ProvisioningUseCase | EnsureFromOIDC / EnsureFromLDAP / EnsureFromSCIM |
外部身份首登/同步 upsert(Member + Identity) |
| 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 |
OTP 重發冷卻 + 每日上限 |
資料儲存
MongoDB Collections
| Collection | Entity | 主要索引 |
|---|---|---|
members |
Member |
unique (tenant_id, uid)、unique (tenant_id, zitadel_user_id)(sparse) |
tenants |
Tenant |
unique slug、unique uid_prefix |
identities |
Identity |
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 冷卻 lock | OTP.ResendCooldownSeconds(預設 60) |
member:verify:daily:{tenant}:{uid}:{kind} |
每日上限計數 | 24h |
member:totp:enroll:{tenant}:{uid} |
綁定中的 staged secret(AES-GCM cipher) | TOTP.EnrollTTLSeconds(預設 600) |
member:totp:used:{tenant}:{uid}:{timestep} |
TOTP 重放保護 | TOTP.ReplayTTLSeconds(預設 90) |
member:seq:{tenant} |
UID 序號(INCR) |
永久 |
Helper 函式見 domain/redis.go,禁止 在他處字串拼接 key。
生命週期與狀態機
stateDiagram-v2
[*] --> unverified: Lifecycle.CreateUnverified<br/>(platform 註冊)
[*] --> active: Provisioning.Ensure*<br/>(OIDC/LDAP/SCIM 首登)
unverified --> active: Activate<br/>(OTP 驗證通過)
unverified --> deleted: AbortPending<br/>(註冊逾時)
active --> suspended: Suspend(reason)
suspended --> active: Reactivate
active --> deleted: SoftDelete
suspended --> deleted: SoftDelete
deleted --> [*]
transition() 強制 from → to,不符回 ErrInvalidStatus。
核心流程時序圖
1. 模組裝配 (NewModuleFromParam)
sequenceDiagram
autonumber
participant SVC as svc.NewServiceContext
participant Mod as member.NewModuleFromParam
participant Repo as repository
participant Redis
participant Mongo
SVC->>Mod: ModuleParam{Redis, MongoConf, Config}
Mod->>Repo: NewRedisOTPChallengeStore(redis)
Mod->>Repo: NewRedisVerifyRateStore(redis)
alt MongoConf.Host != ""
Mod->>Repo: NewMemberRepository / NewTenantRepository / NewIdentityRepository
Mod->>Repo: NewMongoTOTPProfileRepository
Repo->>Mongo: ping (lazy)
end
Mod->>Repo: NewRedisUIDGenerator(redis)
Mod->>Mod: MustOTPUseCase / MustVerifyRateUseCase
alt Mongo 就緒
Mod->>Mod: MustProfileUseCase / MustLifecycleUseCase / MustTenantUseCase / MustProvisioningUseCase
end
alt TOTP.SecretKEK != ""
Mod->>Mod: NewAESGCMFromString(KEK)
Mod->>Repo: NewRedisTOTPEnrollStore / NewRedisTOTPReplayStore
Mod->>Mod: MustTOTPUseCase
end
Mod-->>SVC: *Module(7 usecase + 3 repo)
SVC->>SVC: sc.MemberOTP / sc.MemberLifecycle / ...
2. Tenant 建立
sequenceDiagram
autonumber
participant CLI as cmd/member-seed
participant TenantUC as TenantUseCase
participant Repo as TenantRepository
participant Mongo
CLI->>TenantUC: Create(req{TenantID, Slug, Name, UIDPrefix})
TenantUC->>TenantUC: normalizeUIDPrefix + 長度檢查 (2-4)
TenantUC->>Repo: GetByUIDPrefix(prefix)
Repo->>Mongo: findOne
alt prefix 已存在
TenantUC-->>CLI: ErrAlreadyExist("uid_prefix already exists")
else 不存在
TenantUC->>Repo: Insert(Tenant{Status: active})
Repo->>Mongo: insertOne
TenantUC-->>CLI: TenantDTO
end
3. Platform 註冊 (auth + member.Lifecycle)
屬於
internal/logic/auth/register_logic.go的編排;Member module 只負責 atomic 動作。
sequenceDiagram
autonumber
participant Client
participant RegLogic as logic/auth.RegisterLogic
participant TenantUC as TenantUseCase
participant Zitadel as library/zitadel
participant Lifecycle as LifecycleUseCase
participant OTP as OTPUseCase
participant Notifier
participant Confirm as logic/auth.RegisterConfirmLogic
Client->>RegLogic: POST /auth/register {tenant_slug, email, password}
RegLogic->>TenantUC: ResolveBySlug(slug)
TenantUC-->>RegLogic: TenantDTO
RegLogic->>Zitadel: CreateHumanUser(...)
Zitadel-->>RegLogic: zitadel_user_id
RegLogic->>Lifecycle: CreateUnverified(req{tenant, email, hash, zitadel_user_id})
Lifecycle->>Lifecycle: 取 tenant.UIDPrefix → UIDGenerator.Next
Lifecycle->>Lifecycle: members.Insert(status=unverified)
Lifecycle-->>RegLogic: MemberDTO(uid)
RegLogic->>OTP: Generate(purpose=Register, uid, target=email)
OTP-->>RegLogic: challenge_id, plainCode
RegLogic->>Notifier: Send(VerifyEmail, code)
alt Notifier 失敗
RegLogic->>Lifecycle: AbortPending(uid)
RegLogic-->>Client: 5xx
else 成功
RegLogic-->>Client: {challenge_id, expires_in}
end
Note over Client,Confirm: 使用者收到信
Client->>Confirm: POST /auth/register/confirm {challenge_id, code}
Confirm->>OTP: MatchChallenge(challenge_id, tenant, purpose=Register, RequireUID)
OTP-->>Confirm: OTPChallengeInfo{uid}
Confirm->>OTP: Verify(challenge_id, code, uid, purpose)
OTP-->>Confirm: target(email)
Confirm->>Lifecycle: Activate(tenant, uid) // unverified → active
Confirm-->>Client: JWT (auth 簽發)
4. Provisioning — OIDC / LDAP / SCIM
外部身份首次登入時透過 EnsureFromOIDC upsert,冪等(既存即回傳)。
sequenceDiagram
autonumber
participant Logic as logic/auth.LoginSocialCallback
participant Prov as ProvisioningUseCase
participant MR as MemberRepository
participant IR as IdentityRepository
participant TR as TenantRepository
participant UID as UIDGenerator
participant Redis
participant Mongo
Logic->>Prov: EnsureFromOIDC(tenant, zitadel_sub, email, ...)
Prov->>MR: GetByZitadelUserID(tenant, sub)
MR->>Mongo: find
alt 已存在
MR-->>Prov: Member
Prov-->>Logic: MemberDTO (origin=oidc, status=active)
else ErrNotFound
Prov->>TR: GetByTenantID(tenant)
TR-->>Prov: Tenant{UIDPrefix}
Prov->>UID: Next(tenant, prefix)
UID->>Redis: INCR member:seq:{tenant}
UID-->>Prov: "ACME-10000003"
Prov->>MR: Insert(Member{status=active, origin=oidc, zitadel_user_id})
MR->>Mongo: insertOne
alt duplicate(競態)
MR-->>Prov: ErrDuplicateMember
Prov->>MR: GetByZitadelUserID // 再讀一次回傳
end
Prov->>IR: Insert(Identity{zitadel_user_id, uid})
IR->>Mongo: insertOne(忽略 dup)
Prov-->>Logic: MemberDTO
end
LDAP / SCIM 同樣模式,額外查 IdentityRepository.GetByExternalID 處理沒有 zitadel_sub 的情境。
5. 業務 Email / Phone OTP 驗證
由 internal/logic/member/verify_helper.go 編排(startVerification + confirmVerification),展示 logic 層如何把多個 atomic usecase 串起來。
sequenceDiagram
autonumber
participant Client
participant Logic as logic/member.startVerification
participant Rate as VerifyRateUseCase
participant OTP as OTPUseCase
participant Notif as Notifier
participant Profile as ProfileUseCase
participant Redis
Client->>Logic: POST /me/verifications/email/start {target}
Logic->>Rate: AssertResendAllowed(rateKey, cooldown=60s)
Rate->>Redis: SETNX member:verify:rate:{t}:{u}:business_email
alt cooldown 中
Rate-->>Logic: ErrTooManyRequest
Logic-->>Client: 429
end
Logic->>Rate: AssertDailyAllowed(dailyKey, 24h, limit=10)
Rate->>Redis: INCR member:verify:daily:{t}:{u}:business_email
Logic->>OTP: Generate(uid, purpose=BusinessEmail, target=email)
OTP->>Redis: SET member:otp:challenge:{id} (bcrypt hash, TTL=300s)
OTP-->>Logic: challenge_id, plainCode
Logic->>Notif: Send(channel=email, kind=VerifyEmail, data={code, expires_in})
alt Notifier 失敗
Logic->>OTP: Invalidate(challenge_id)
Logic-->>Client: 5xx
else 成功
Logic-->>Client: {challenge_id, expires_in}
end
Note over Client,Profile: 使用者收到信
Client->>Logic: POST /me/verifications/email/confirm {challenge_id, code}
Logic->>OTP: Verify(challenge_id, code, uid, purpose=BusinessEmail)
OTP->>Redis: GET + bcrypt compare
alt 失敗
OTP->>Redis: INCR attempts
alt attempts >= 5
OTP-->>Logic: ErrChallengeLocked
else
OTP-->>Logic: ErrInvalidOTP
end
else 成功
OTP->>Redis: DEL challenge
OTP-->>Logic: target(email)
Logic->>Profile: SetBusinessEmailVerified(tenant, uid, target)
Profile-->>Logic: nil
Logic-->>Client: 204
end
關鍵設計:Verify 成功後 challenge 立刻刪除(一次性);Generate 一定要先過 VerifyRate 兩道閘門。
6. TOTP 綁定 / Step-up
sequenceDiagram
autonumber
participant Client
participant Logic
participant TOTP as TOTPUseCase
participant Profile as TOTPProfileRepository
participant Enroll as TOTPEnrollStore
participant Replay as TOTPReplayStore
participant Cipher as crypto.Cipher (AES-GCM)
Note over Client,Cipher: A. 綁定階段
Client->>Logic: POST /me/totp/enroll
Logic->>TOTP: StartEnroll(tenant, uid, account)
TOTP->>Profile: Get → 必須未 enrolled
TOTP->>TOTP: totp.GenerateSecret() (隨機 20 byte)
TOTP->>Cipher: Encrypt(secret) → cipherBlob
TOTP->>Enroll: Save(cipherBlob, TTL=600s)
TOTP-->>Logic: {otpauth_url, digits=6, period=30}
Logic-->>Client: QR code 資料
Client->>Client: 掃 QR 加入 Authenticator
Client->>Logic: POST /me/totp/enroll/confirm {code}
Logic->>TOTP: ConfirmEnroll(tenant, uid, code)
TOTP->>Enroll: Get → cipherBlob
TOTP->>Cipher: Decrypt → secret
TOTP->>TOTP: totp.Verify(secret, code, ±window)
alt 驗碼失敗
TOTP-->>Logic: ErrTOTPInvalidCode
else 成功
TOTP->>TOTP: 產生 N 個 backup codes + bcrypt hashes
TOTP->>Profile: Save({Enrolled, SecretCipher, BackupCodesHash})
TOTP->>Enroll: Delete (清掉 staged)
TOTP-->>Logic: plainCodes[](僅此一次回傳)
end
Note over Client,Replay: B. 日常 step-up
Client->>Logic: 任意敏感操作攜 6 碼
Logic->>TOTP: VerifyCode(tenant, uid, code)
TOTP->>Profile: Get → 必須 enrolled
TOTP->>Cipher: Decrypt(SecretCipher)
alt code 長度 = 6
TOTP->>TOTP: totp.Verify(±window) → step
alt OK
TOTP->>Replay: MarkUsed(timestep, TTL=90s) → fresh?
alt 已用過
TOTP-->>Logic: ErrTOTPCodeReplay
else 未用過
TOTP-->>Logic: nil
end
else 失敗
TOTP->>TOTP: fall through to backup code
end
end
alt 嘗試備援碼
loop 每組 hash
TOTP->>TOTP: bcrypt.CompareHashAndPassword
end
alt 命中
TOTP->>Profile: ConsumeBackupCode(hash) (atomic)
TOTP-->>Logic: nil
else 全失敗
TOTP-->>Logic: ErrTOTPInvalidCode
end
end
7. UID 生成
sequenceDiagram
autonumber
participant Caller as Lifecycle / Provisioning
participant Gen as UIDGenerator
participant Redis
Caller->>Gen: Next(tenant, uidPrefix)
Gen->>Redis: INCR member:seq:{tenant}
Redis-->>Gen: seq
alt seq == 1 (首次)
Note right of Gen: 一次補上起始值<br/>(避開像 ACME-1 這種短 UID)
Gen->>Redis: INCRBY (UIDSequenceStart - 1) = 9_999_999
Redis-->>Gen: 10_000_000
end
Gen-->>Caller: "{PREFIX}-{seq}" 例:ACME-10000003
UIDSequenceStart = 10_000_000(7 位起跳),UIDPrefix 限制 2~4 個大寫字母。
Redis Key 命名
| Helper | 對應 key | 使用者 |
|---|---|---|
GetOTPChallengeRedisKey(id) |
member:otp:challenge:{id} |
OTPChallengeStore |
GetOTPAttemptsRedisKey(id) |
member:otp:challenge:{id}:attempts |
OTPChallengeStore |
GetVerifyRateRedisKey(tenant, uid, kind) |
member:verify:rate:... |
VerifyRate (logic 層) |
GetVerifyDailyRedisKey(tenant, uid, kind) |
member:verify:daily:... |
同上 |
GetTOTPEnrollRedisKey(tenant, uid) |
member:totp:enroll:... |
TOTPEnrollStore |
GetTOTPUsedRedisKey(tenant, uid, step) |
member:totp:used:... |
TOTPReplayStore |
GetMemberSeqRedisKey(tenant) |
member:seq:{tenant} |
UIDGenerator |
kind 通常是 enum.OTPPurpose 字串(business_email、business_phone、step_up 等)。
設定
etc/gateway.dev.yaml → Member 區塊:
Member:
Registration:
RequireInviteCode: true # 平台註冊是否強制邀請碼
TrustSocialEmailVerified: true # OIDC email_verified=true 時直接 active
OTP:
Length: 6 # 驗證碼位數
TTLSeconds: 300 # challenge 存活時間
MaxAttempts: 5 # 單 challenge 最大錯誤次數
ResendCooldownSeconds: 60 # 重發冷卻
DailyVerifyLimit: 10 # 每日上限
TOTP:
Issuer: CloudEP
Algorithm: SHA1
Digits: 6
PeriodSeconds: 30
Window: 1 # ±1 time step 容忍
BackupCodeCount: 10
BackupCodeLength: 12
EnrollTTLSeconds: 600
ReplayTTLSeconds: 90
SecretKEK: "" # 32-byte AES key(hex 64 字元或 base64);留空關閉 TOTP
SecretKEK 可改用環境變數 TOTP_SECRET_KEK(prod 建議走 KMS / secret manager)。
ServiceContext 注入
// internal/svc/service_context.go
sc.MemberOTP // domusecase.OTPUseCase (一定有)
sc.MemberVerifyRate // domusecase.VerifyRateUseCase (一定有)
sc.MemberProfile // domusecase.ProfileUseCase (Mongo 設定後)
sc.MemberLifecycle // domusecase.LifecycleUseCase (Mongo 設定後)
sc.MemberTenant // domusecase.TenantUseCase (Mongo 設定後)
sc.MemberProvisioning // domusecase.ProvisioningUseCase(Mongo 設定後)
sc.MemberTOTP // domusecase.TOTPUseCase (TOTP.SecretKEK 設定後;否則 nil)
Logic 層使用前務必檢查可能 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、UID/purpose mismatch、attempts lock |
usecase/totp_usecase_test.go |
綁定、VerifyCode、備援碼、重放、Disable、Regenerate |
totp/totp_test.go |
RFC 6238 測試向量、window、otpauth URL |
本機 API(P4)
make deps-up # docker compose: mongo + redis
make mongo-index # 建索引
make member-seed # 建 dev tenant + 一筆 member,輸出 X-Tenant-ID/X-UID headers
make run-local # 啟動 gateway
# Profile
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 驗證(start → confirm)
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。
互動式 TOTP(Google Authenticator)
make deps-up
make totp-test # 預設 STEP=flow:整套綁定 + 驗碼 + 重放
make totp-test STEP=status
make totp-test STEP=disable
需在 etc/gateway.dev.yaml 設定 Member.TOTP.SecretKEK(example 已附 dev-only 占位 key)。
設計參考
- 詳細領域模型 / 多租戶設計 / B2B Permission 對接:
docs/identity-member-design.md - 模組分層公約(usecase 不可呼叫 usecase):
docs/model.md§6.1 - 統一錯誤格式(
errb.*):internal/library/errors/README.md