406 lines
13 KiB
Markdown
406 lines
13 KiB
Markdown
|
|
# Gateway 統一註冊 — 設計規格
|
|||
|
|
|
|||
|
|
> **狀態**:已實作(PR 1–8 ✅;PR 9 文件修訂 ✅)
|
|||
|
|
> **修訂**:取代 [identity-member-design.md §3.4](./identity-member-design.md)「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](./model.md))。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 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 註冊
|
|||
|
|
|
|||
|
|
1. Logic 驗證:`tenant_slug`、`invite_code`、條款、密碼強度、email 格式
|
|||
|
|
2. Logic 驗證並 **消耗** invite(見 §6)
|
|||
|
|
3. `zitadel.CreateHumanUser`(email + password,org = tenant 對應 ZITADEL org)
|
|||
|
|
4. `member.Lifecycle.CreateUnverified`(`origin=platform_native`,存 `zitadel_sub` 若 API 回傳)
|
|||
|
|
5. Logic 寫入 **registration metadata**(§7)
|
|||
|
|
6. `member.OTP.Generate`(`purpose=registration_email`)+ `notifier.Send`(`verify_registration_email`)
|
|||
|
|
7. 回 `{ challenge_id, expires_in }`,**不發 JWT**
|
|||
|
|
8. `POST /auth/register/confirm`:OTP verify → `Activate` → `IssueTokenPair`
|
|||
|
|
|
|||
|
|
### 3.2 路徑 C:Social 註冊(Google)
|
|||
|
|
|
|||
|
|
Invite **必填**;在 OAuth redirect **之前** 綁定 session,避免 callback 時無 invite。
|
|||
|
|
|
|||
|
|
1. `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 }`
|
|||
|
|
2. 使用者完成 Google OAuth → callback `GET /auth/register/social/callback?code=...&state=...`
|
|||
|
|
3. Logic:換 token、驗 state、讀 session 取 invite → **消耗 invite**
|
|||
|
|
4. `zitadel` 查/建 user(或 link)→ `member.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 session(P1,與 register 分 state) |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 4. API 規格(`generate/api/auth.api`)
|
|||
|
|
|
|||
|
|
### 4.1 公開 — 註冊
|
|||
|
|
|
|||
|
|
**POST `/api/v1/auth/register`**
|
|||
|
|
|
|||
|
|
Request:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"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:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"code": 0,
|
|||
|
|
"data": {
|
|||
|
|
"challenge_id": "uuid",
|
|||
|
|
"expires_in": 300,
|
|||
|
|
"uid": "ACME-10000042"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**POST `/api/v1/auth/register/confirm`**
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"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`**
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"tenant_slug": "acme",
|
|||
|
|
"challenge_id": "uuid"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**POST `/api/v1/auth/register/social/start`**
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"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`, `password` min length, `invite_code` required
|
|||
|
|
- 公開路由 **不走** 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 / Logout`
|
|||
|
|
- `InviteUseCase.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 流程
|
|||
|
|
|
|||
|
|
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 + 人工处理阈值
|
|||
|
|
|
|||
|
|
可选 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`)
|
|||
|
|
|
|||
|
|
```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](./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. 前端契约(简要)
|
|||
|
|
|
|||
|
|
1. 注册页收集:`tenant_slug`, `invite_code`, 条款, 语言
|
|||
|
|
2. Tab:Email | Google(同 invite + 条款)
|
|||
|
|
3. Email:register → 输入 OTP → confirm → 存 token
|
|||
|
|
4. Google:social/start → redirect → callback 页取 token
|
|||
|
|
5. 错误:`409` 已存在 → 引导 login;invite 无效 → 留在注册页
|