template-monorepo/internal/model/member/README.md

22 KiB

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_idsluguid_prefixstatusorg_id Mongo tenants
Member 會員 profile(租戶範圍) tenant_id+uidzitadel_user_idstatusorigin、business email/phone、TOTP cipher Mongo members
Identity 外部 ID → UID 對映表 zitadel_user_idexternal_iduid 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/mongolibrary/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 必填 → OTPVerifyRate 永遠存在。
  • MongoConf 設定 → 啟用 ProfileLifecycleTenantProvisioning
  • 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_emailbusiness_phonestep_up 等)。


設定

etc/gateway.dev.yamlMember 區塊:

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