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

406 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Gateway 統一註冊 — 設計規格
> **狀態**已實作PR 18 ✅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** | SocialGoogle 為 P0可擴 Apple— 同一註冊 UXOAuth 前綁定 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+BEmail 註冊
1. Logic 驗證:`tenant_slug`、`invite_code`、條款、密碼強度、email 格式
2. Logic 驗證並 **消耗** invite見 §6
3. `zitadel.CreateHumanUser`email + passwordorg = 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 路徑 CSocial 註冊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 sessionP1與 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_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 + 人工处理阈值
可选 P1`ReleaseInvite` 仅当失败发生在 Validate 后 30s 内且 ZITADEL 未创建。
---
## 7. Registration MetadataLogic 要留的資料)
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 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 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.02026-05-21
- **§3.4**Gateway 統一註冊 BFFEmail / 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. TabEmail | Google同 invite + 条款)
3. Emailregister → 输入 OTP → confirm → 存 token
4. Googlesocial/start → redirect → callback 页取 token
5. 错误:`409` 已存在 → 引导 logininvite 无效 → 留在注册页