template-monorepo/docs/auth-unified-registration.md

13 KiB
Raw Blame History

Gateway 統一註冊 — 設計規格

狀態已實作PR 18 PR 9 文件修訂
修訂:取代 identity-member-design.md §3.4「Gateway 不暴露 /auth/register
最後更新2026-05-21

1. 目標

使用者 只與 Portal Gateway 互動 完成註冊首次登入ZITADEL 作為 identity 後端帳密、OIDC、鎖定不作第二個註冊入口。

路徑 說明
A Email + Password + Email OTP 確認
B 上述流程 必填 invite codeLogic 驗證)
C SocialGoogle 為 P0可擴 Apple— 同一註冊 UXOAuth 前綁定 invite

Logic 層負責商務驗證與跨 atomic usecase 編排usecase 維持 atomicmodel.md §6.1)。


2. 為何比 ZITADEL Hosted 註冊好

  • 單一 App / API無「跳去 IdP 再回來」的割裂感
  • 註冊表單可帶 商務欄位條款版本、語系、marketing、invite、referral
  • 失敗補償與 audit 在 Gateway 可控
  • 與現有 member OTP、notification 模板、CreateUnverified / Activate 直接對接

3. 三條註冊路徑(統一入口 UX

                    ┌─────────────────────────────────────┐
                    │     前端:同一「註冊」頁 / Wizard      │
                    │  tenant + invite_code + 條款 (+ 語系)  │
                    └──────────────┬──────────────────────┘
                                   │
              ┌────────────────────┼────────────────────┐
              ▼                    ▼                    ▼
     Email + Password      Social (Google)        (未來 Apple
              │                    │
              ▼                    ▼
   POST /auth/register    POST /auth/register/social/start
              │                    │ → oauth_url
              ▼                    ▼
   POST /auth/register/confirm   GET /auth/register/social/callback
              │                    │
              └────────┬───────────┘
                       ▼
              IssueTokenPair (CloudEP JWT)

3.1 路徑 A+BEmail 註冊

  1. Logic 驗證:tenant_sluginvite_code、條款、密碼強度、email 格式
  2. Logic 驗證並 消耗 invite見 §6
  3. zitadel.CreateHumanUseremail + passwordorg = tenant 對應 ZITADEL org
  4. member.Lifecycle.CreateUnverifiedorigin=platform_native,存 zitadel_sub 若 API 回傳)
  5. Logic 寫入 registration metadata§7
  6. member.OTP.Generatepurpose=registration_email+ notifier.Sendverify_registration_email
  7. { challenge_id, expires_in }不發 JWT
  8. POST /auth/register/confirmOTP verify → ActivateIssueTokenPair

3.2 路徑 CSocial 註冊Google

Invite 必填;在 OAuth redirect 之前 綁定 session避免 callback 時無 invite。

  1. POST /auth/register/social/start
    Bodytenant_slug, invite_code, provider=google, accept_terms_version, 可選 language
    Logic驗 tenant + invite + 條款 → 建立 registration sessionRedis→ 回 { oauth_url, session_id }
  2. 使用者完成 Google OAuth → callback GET /auth/register/social/callback?code=...&state=...
  3. Logic換 token、驗 state、讀 session 取 invite → 消耗 invite
  4. zitadel 查/建 user或 linkmember.Provisioning.EnsureFromOIDC
  5. 若 email 已在 ZITADEL verified → 直接 Activate;否則可走 OTP 或信任 IdP email_verified已決策:信任 Google email_verified=true 則 skip OTP
  6. 寫 registration metadata → IssueTokenPair

已存在 member同 tenant + zitadel_sub→ 視為 登入,仍驗 invite 是否允許「新註冊 only」若 invite 僅限新 user 且已存在 → 409 + 引導 login。

3.3 登入(非註冊)

API 說明
POST /auth/login email + password → ZITADEL ROPG / token → member lookup → JWT
POST /auth/token/exchange 保留:已登入 ZITADEL 的 id_token 換 JWT企業 SSO、舊 client
POST /auth/login/social 可選OAuth 僅登入、不經 register sessionP1與 register 分 state

4. API 規格(generate/api/auth.api

4.1 公開 — 註冊

POST /api/v1/auth/register

Request:

{
  "tenant_slug": "acme",
  "invite_code": "BETA-2026-XXXX",
  "email": "user@example.com",
  "password": "string",
  "display_name": "Daniel",
  "language": "zh-tw",
  "accept_terms_version": "2026-05-01",
  "marketing_opt_in": false
}

Response 200:

{
  "code": 0,
  "data": {
    "challenge_id": "uuid",
    "expires_in": 300,
    "uid": "ACME-10000042"
  }
}

POST /api/v1/auth/register/confirm

{
  "tenant_slug": "acme",
  "challenge_id": "uuid",
  "code": "482913"
}

Response 200: { access_token, refresh_token, expires_in, uid, token_type: "Bearer" }

POST /api/v1/auth/register/resend

{
  "tenant_slug": "acme",
  "challenge_id": "uuid"
}

POST /api/v1/auth/register/social/start

{
  "tenant_slug": "acme",
  "invite_code": "BETA-2026-XXXX",
  "provider": "google",
  "accept_terms_version": "2026-05-01",
  "language": "zh-tw",
  "redirect_uri": "https://app.example.com/auth/callback"
}

Response: { oauth_url, session_id, expires_in }

GET /api/v1/auth/register/social/callback

Query: code, state(含 session_id
Response: 302 到前端帶 token或 JSONAccept / 設定)

4.2 公開 — 登入 / Token

Method Path 說明
POST /api/v1/auth/login email + password + tenant_slug
POST /api/v1/auth/token/refresh refresh_token
POST /api/v1/auth/token/exchange id_token + tenant_slugSSO 備用)
POST /api/v1/auth/logout JWTjti 黑名單

4.3 驗證 tag.api

  • register: required,email, password min length, invite_code required
  • 公開路由 不走 JWT middleware rate limit§12

5. 分層與新增模組

internal/
  library/zitadel/           # HTTP clientCreateUser, VerifyPassword, OIDC token
  model/auth/
    config/
    domain/
      entity/registration_meta.go
      repository/invite.go, registration_session.go
      usecase/token.go, invite.go
    usecase/                 # atomicIssueTokenPair, ValidateInvite, ConsumeInvite
  logic/auth/
    register_logic.go
    register_confirm_logic.go
    register_resend_logic.go
    register_social_start_logic.go
    register_social_callback_logic.go
    login_logic.go
  middleware/
    jwt_cloud_ep.go          # P1取代 dev X-Tenant-ID header

Logic 專屬商務(不下沉 usecase

  • invite 是否必填、是否過期、是否限新 user
  • 條款版本是否接受
  • tenant 是否允許 B2C 註冊
  • 密碼政策client + server 雙重)
  • 註冊 rate limit key 組合
  • SocialOAuth state 與 registration session 綁定

auth usecaseatomic

  • TokenUseCase.IssuePair / Refresh / Logout
  • InviteUseCase.Validate / Consume(或 tenant 模組,見 §6

禁止auth usecase 呼叫 member usecase一律在 logic 編排。


6. Invite CodeB 必填)

6.1 資料模型Mongo invite_codes

欄位 說明
tenant_id 租戶
code_hash bcrypt/sha256不明文存
max_uses 总次數
used_count 已用
expires_at 可選
new_users_only default true
created_at

索引:(tenant_id, code_hash) unique

6.2 流程

  1. ValidateInvite(tenant_id, plain_code) → ok / ErrInviteInvalid|Expired|Exhausted
  2. Consume 在 ZITADEL/member 建立 之前 用 Redis lock + Mongo $inc used_count(防并发超卖)
  3. 若後續 ZITADEL 或 member 失敗 → 不回滚 invite(已決策:防刷;可 admin 补发。Logic 记录 audit + 人工处理阈值

可选 P1ReleaseInvite 仅当失败发生在 Validate 后 30s 内且 ZITADEL 未创建。


7. Registration MetadataLogic 要留的資料)

Collectionregistration_metadata 或 member 子文档 registration

欄位 來源
tenant_id, uid member
invite_code_id 消耗后的 invite 记录 id
accept_terms_version request
marketing_opt_in request
registration_channel email | google
client_ip / user_agent handler 注入 context
occurred_at server

写入时机email 路径在 CreateUnverifiedsocial 在 EnsureFromOIDC 后。


8. 狀態機

Email 註冊

register → unverified (member) + zitadel user active
confirm OK → active + JWT
OTP max attempts → challenge locked
abort: cron 清理 7 天未 confirm 的 unverified + zitadel deactivate

Social 註冊

social/start → registration_session (Redis TTL 10min, 含 invite_id)
callback OK → active + JWT
session 过期 → 重新 start

9. 失敗補償

步驟失敗 補償
invite 无效 直接 4xx无 side effect
ZITADEL CreateUser 成功member 失败 Logic 调用 zitadel.DeactivateUser(sub)
member 成功Send OTP 失败 OTP.Invalidate(challenge_id)
confirm 时 Activate 失败 5xx保留 challenge 可重试 confirm

10. 錯誤碼Logic / Usecase

情境 errb
invite 无效 InputInvalidFormatResNotFound("invite")
invite 用尽 ResInsufficientQuota
email 已注册 ResAlreadyExist
OTP 错误 AuthForbidden + cause
tenant 不允许注册 AuthForbidden
未 accept 条款 InputMissingRequired
ZITADEL 下游失败 SvcThirdParty
DB 失败 DBError via wrapRepoErr

11. 設定(etc/gateway.yaml

Auth:
  AccessExpire: 900
  RefreshExpire: 604800
  RegistrationSessionTTLSeconds: 600

Zitadel:
  Issuer: https://zitadel.internal
  MgmtURL: https://zitadel.internal/management/v1
  ServiceUserToken: ${ZITADEL_SERVICE_TOKEN}
  GoogleClientID: ...
  GoogleClientSecret: ...
  DefaultOrgID: ...   # 或 per-tenant 映射表

Member:
  Registration:
    RequireInviteCode: true
    UnverifiedRetentionDays: 7
    TrustSocialEmailVerified: true

12. Rate LimitRedis

Key 限制
auth:register:ip:{ip} 10 / hour
auth:register:email:{tenant}:{email} 3 / hour
auth:register:invite_fail:{ip} 20 / hour
OTP resend 沿用 member VerifyRateStore 或独立 cooldown

13. 實施順序PR 切分)

PR 内容 验收
1 library/zitadel + config + 单元测试mock server CreateUser / VerifyPassword
2 model/auth TokenUseCaseIssue/Refresh+ JWT middleware 骨架 签/验 JWT
3 invite repository + InviteUseCase + mongo index validate/consume
4 auth.api + Logic register + confirm + resend curl 完整 email 注册
5 registration_metadata + audit 字段 Mongo 可查来源
6 Social start/callback + registration session Google 注册拿 JWT
7 POST /auth/login + 取代 dev header member API Bearer 访问
8 /auth/token/exchange + login/social 分离 企业 SSO 不冲突
9 文档修订 identity-member-design §3.4、§7.1 文档一致

14. 測試策略

  • 单元invite consume 并发、OTP purpose、errb 映射
  • 集成testcontainers Mongo/Redis + zitadel mock
  • E2E CLIcmd/auth-register-test 模拟 register → confirm → login
  • Socialmock OAuth callback with fixed state

15. 與現有代码复用

已有 用途
Lifecycle.CreateUnverified / Activate email 注册
OTP.* + OTPPurposeRegistrationEmail 确认
Notifier + NotifyVerifyRegistrationEmail 寄信
Provisioning.EnsureFromOIDC social JIT
Tenant.ResolveBySlug tenant_slug → tenant_id
response.Write + errb HTTP 输出

16. 修訂 identity-member-design.md已完成

以下修訂已寫入 identity-member-design.mdv1.0.02026-05-21

  • §3.4Gateway 統一註冊 BFFEmail / Social / invite 必填)
  • §7.1auth.api 路由表(含實作狀態)
  • §8.1CloudEP JWT middleware + dev header fallback
  • §9.1login / token exchange 流程
  • §5.9 Case A:標記為已實作
  • 決策列 19:註冊路徑決策更新

17. 前端契约(简要)

  1. 注册页收集:tenant_slug, invite_code, 条款, 语言
  2. TabEmail | Google同 invite + 条款)
  3. Emailregister → 输入 OTP → confirm → 存 token
  4. Googlesocial/start → redirect → callback 页取 token
  5. 错误:409 已存在 → 引导 logininvite 无效 → 留在注册页