13 KiB
Gateway 統一註冊 — 設計規格
狀態:已實作(PR 1–8 ✅;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 code(Logic 驗證) |
| C | Social(Google 為 P0,可擴 Apple)— 同一註冊 UX,OAuth 前綁定 invite |
Logic 層負責商務驗證與跨 atomic usecase 編排;usecase 維持 atomic(見 model.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+B:Email 註冊
- Logic 驗證:
tenant_slug、invite_code、條款、密碼強度、email 格式 - Logic 驗證並 消耗 invite(見 §6)
zitadel.CreateHumanUser(email + password,org = tenant 對應 ZITADEL org)member.Lifecycle.CreateUnverified(origin=platform_native,存zitadel_sub若 API 回傳)- Logic 寫入 registration metadata(§7)
member.OTP.Generate(purpose=registration_email)+notifier.Send(verify_registration_email)- 回
{ challenge_id, expires_in },不發 JWT POST /auth/register/confirm:OTP verify →Activate→IssueTokenPair
3.2 路徑 C:Social 註冊(Google)
Invite 必填;在 OAuth redirect 之前 綁定 session,避免 callback 時無 invite。
POST /auth/register/social/start
Body:tenant_slug,invite_code,provider=google,accept_terms_version, 可選language
Logic:驗 tenant + invite + 條款 → 建立 registration session(Redis)→ 回{ oauth_url, session_id }- 使用者完成 Google OAuth → callback
GET /auth/register/social/callback?code=...&state=... - Logic:換 token、驗 state、讀 session 取 invite → 消耗 invite
zitadel查/建 user(或 link)→member.Provisioning.EnsureFromOIDC- 若 email 已在 ZITADEL verified → 直接
Activate;否則可走 OTP 或信任 IdPemail_verified(已決策:信任 Google email_verified=true 則 skip OTP) - 寫 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 session(P1,與 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,或 JSON(依 Accept / 設定)
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_slug(SSO 備用) |
| POST | /api/v1/auth/logout |
JWT,jti 黑名單 |
4.3 驗證 tag(.api)
register:required,email,passwordmin length,invite_coderequired- 公開路由 不走 JWT middleware;走 rate limit(§12)
5. 分層與新增模組
internal/
library/zitadel/ # HTTP client:CreateUser, VerifyPassword, OIDC token
model/auth/
config/
domain/
entity/registration_meta.go
repository/invite.go, registration_session.go
usecase/token.go, invite.go
usecase/ # atomic:IssueTokenPair, 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 組合
- Social:OAuth state 與 registration session 綁定
auth usecase(atomic)
TokenUseCase.IssuePair / Refresh / LogoutInviteUseCase.Validate / Consume(或 tenant 模組,見 §6)
禁止:auth usecase 呼叫 member usecase;一律在 logic 編排。
6. Invite Code(B 必填)
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 流程
ValidateInvite(tenant_id, plain_code)→ ok /ErrInviteInvalid|Expired|Exhausted- Consume 在 ZITADEL/member 建立 之前 用 Redis lock + Mongo
$inc used_count(防并发超卖) - 若後續 ZITADEL 或 member 失敗 → 不回滚 invite(已決策:防刷;可 admin 补发)。Logic 记录 audit + 人工处理阈值
可选 P1:ReleaseInvite 仅当失败发生在 Validate 后 30s 内且 ZITADEL 未创建。
7. Registration Metadata(Logic 要留的資料)
Collection:registration_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 路径在 CreateUnverified 后;social 在 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 无效 | InputInvalidFormat 或 ResNotFound("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 Limit(Redis)
| 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 TokenUseCase(Issue/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 CLI:
cmd/auth-register-test模拟 register → confirm → login - Social:mock 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.md(v1.0.0,2026-05-21):
- §3.4:Gateway 統一註冊 BFF(Email / Social / invite 必填)
- §7.1:auth.api 路由表(含實作狀態)
- §8.1:CloudEP JWT middleware + dev header fallback
- §9.1:login / token exchange 流程
- §5.9 Case A:標記為已實作
- 決策列 19:註冊路徑決策更新
17. 前端契约(简要)
- 注册页收集:
tenant_slug,invite_code, 条款, 语言 - Tab:Email | Google(同 invite + 条款)
- Email:register → 输入 OTP → confirm → 存 token
- Google:social/start → redirect → callback 页取 token
- 错误:
409已存在 → 引导 login;invite 无效 → 留在注册页