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

292 lines
9.8 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.

# Auth 模組
Gateway 認證領域層邀請碼、註冊稽核、OAuth Session、CloudEP JWT 簽發/刷新/登出。完整 HTTP 流程由 `internal/logic/auth/` 編排,並與 ZITADEL身份、Member會員、Notification通知協作。
> **架構原則**[`docs/model.md`](../../../docs/model.md) §6.1usecase 為 atomic primitive**不可** 呼叫其他 usecase跨模組編排放 `internal/logic/auth/`。
>
> **規格 vs 速查**
> - 規格書Data Dictionary `invite_codes` / `registration_metadata`、Redis key、API endpoint list→ [`SDD.md`](./SDD.md)
> - 統一註冊Email/OTP/Social完整時序 + ZITADEL 互動細節 → [`docs/auth-unified-registration.md`](../../../docs/auth-unified-registration.md)
> - 本 README = 範圍 + ServiceContext wiring + 設定 + curl + 快速跟其他模組對齊用
## 範圍
| 範圍內 | 範圍外(委派) |
|--------|-----------------|
| 邀請碼 Validate / Consume | 使用者身份建立 → ZITADEL |
| 註冊稽核 metadata | 會員 profile / OTP → Member |
| OAuth 註冊 / 登入暫存 SessionRedis | 郵件 / 簡訊 → Notification |
| CloudEP JWT access / refresh 生命週期 | RBAC → Permission |
| JWT 黑名單 + jti pair | |
---
## 目錄結構
```
internal/model/auth/
├── README.md # 本檔
├── config/
├── domain/
│ ├── entity/ # InviteCode、RegistrationMetadata
│ ├── enum/ # RegistrationChannelemail / google
│ ├── repository/ # 介面 + Session struct
│ ├── usecase/ # 介面 + DTO
│ ├── const.go # BSON 欄位、Redis key、code hash helper
│ └── errors.go # 領域 sentinel
├── repository/ # Mongo + Redis 實作 + EnsureMongoIndexes
└── usecase/ # 實作 + NewModuleFromParam
```
## Module 與依賴
```mermaid
flowchart TB
Logic["logic/auth\n(orchestration)"]
subgraph M["auth.Module"]
Token[TokenUseCase]
Invite[InviteUseCase]
RegMeta[RegistrationMetaUseCase]
RegSess[RegistrationSessionUseCase]
LoginSess[LoginSessionUseCase]
end
Logic --> Token
Logic --> Invite
Logic --> RegMeta
Logic --> RegSess
Logic --> LoginSess
Logic --> Member[(member.Module)]
Logic --> Zitadel[(library/zitadel)]
Logic --> Notif[(notification)]
```
**ServiceContext 注入:**
| 欄位 | UseCase | 啟用條件 |
|------|---------|----------|
| `AuthToken` | TokenUseCase | JWT secret + Redis |
| `AuthInvite` | InviteUseCase | Mongo + Redis |
| `AuthRegistrationMeta` | RegistrationMetaUseCase | Mongo |
| `AuthRegistrationSession` | RegistrationSessionUseCase | Redis |
| `AuthLoginSession` | LoginSessionUseCase | Redis |
---
## API`/api/v1/auth`
### 公開(無 Bearer
| Method | Path | 說明 |
|--------|------|------|
| POST | `/register` | Email 註冊 → `{challenge_id, expires_in, uid}` |
| POST | `/register/confirm` | OTP 確認 → 核發 JWT |
| POST | `/register/resend` | 重發註冊 OTP |
| POST | `/register/social/start` | 社交註冊起始 → `{oauth_url, session_id}` |
| GET | `/register/social/callback` | OAuth callback → JWT |
| POST | `/login` | 密碼登入ZITADEL ROPG |
| POST | `/login/social/start` | 社交登入起始 |
| GET | `/login/social/callback` | 社交登入 callback |
| POST | `/token/refresh` | 刷新 token pair |
| POST | `/token/exchange` | id_token → CloudEP JWT |
### 需 Bearer
| Method | Path | 說明 |
|--------|------|------|
| POST | `/logout` | 黑名單 access + paired refresh jti |
完整 schema`generate/api/auth.api`;成功 envelope `code=102000`
---
## TokenCloudEP JWT
HS256access / refresh 使用不同 secret。
| Claim | 說明 |
|-------|------|
| `tenant_id` | 租戶 ID |
| `uid` | 會員 UID |
| `typ` | `access``refresh` |
| `auth_gen` | 簽發世代(強制登出時 +1 |
| `jti` | Token 唯一 ID |
| `iat` / `exp` | 簽發 / 過期 |
| Token | 預設 TTL |
|-------|---------|
| Access | 900s15 分鐘) |
| Refresh | 604800s7 天) |
| Registration Session | 600s10 分鐘) |
### Redis Key
| Key | 用途 | TTL |
|-----|------|-----|
| `auth:jwt:pair:{jti}` | access ↔ refresh jti 映射 | token TTL |
| `auth:jwt:bl:{jti}` | 黑名單 jti | 至自然過期 |
| `auth:register:session:{id}` | 社交註冊 OAuth session | 600s |
| `auth:login:session:{id}` | 社交登入 OAuth session | 600s |
| `auth:invite:consume:{tenant}:{hash}` | 邀請碼消費鎖 | 30s |
### Refresh 流程
```mermaid
sequenceDiagram
participant C as Client
participant L as TokenRefreshLogic
participant T as TokenUseCase
participant R as Redis
C->>L: POST /auth/token/refresh {refresh_token}
L->>T: Refresh(refreshToken)
T->>T: Parse + verify typ=refresh
T->>R: Blacklist 舊 refresh jti + paired access jti
T->>T: IssuePairnew access + refresh
T->>R: SavePair(new jti mapping)
T-->>L: TokenPair
L-->>C: AuthTokenData
```
---
## 統一註冊Email + Social
> 取代原 ZITADEL Hosted Page使用者只與 Gateway 互動。Invite 為 B2B 必填、B2C 可選(`Member.Registration.RequireInviteCode`)。
### 三條路徑
```
┌─────────────────────────────────────────────┐
│ 前端「註冊」頁(共用 invite + 條款) │
└────────────┬────────────────┬───────────────┘
▼ ▼
Email + Password Social (Google)
│ │
POST /register POST /register/social/start
│ │ → oauth_url
POST /register/confirm GET /register/social/callback
│ │
└───────┬────────┘
IssueTokenPairCloudEP JWT
```
### Email 路徑
1. Logic 驗 tenant_slug + invite + 條款 + 密碼強度 + email 格式
2. Logic 消耗 inviteRedis SETNX lock + Mongo `$inc used_count`
3. ZITADEL `CreateHumanUser`
4. `member.Lifecycle.CreateUnverified``origin=platform_native`
5. 寫 registration metadata
6. `member.OTP.Generate(purpose=registration_email)` + `notifier.Send(verify_registration_email)`
7.`{challenge_id, expires_in}`**不發 JWT**
8. `POST /register/confirm`OTP verify → `Activate``IssueTokenPair`
### Social 路徑Google
- Invite 必填;在 OAuth redirect **前** 綁定 registration sessionRedis TTL 600s
- callback 才 **消耗** invite避免 IdP 中途取消)
- `member.Provisioning.EnsureFromOIDC`(同 sub 已存在則視為登入)
- `Member.Registration.TrustSocialEmailVerified=true`IdP `email_verified=true` 直接 `Activate`,否則走 OTP
### 失敗補償
| 步驟 | 補償 |
|------|------|
| Invite 無效 | 直接 4xx無 side effect |
| ZITADEL 建 user OK / member 失敗 | Logic 呼叫 `zitadel.DeactivateUser(sub)` |
| Member OK / Send OTP 失敗 | `OTP.Invalidate(challenge_id)` |
| confirm 時 Activate 失敗 | 5xx保留 challenge 可重試 |
> Invite 在 ZITADEL/member 失敗時 **不自動回滾**(防刷;可 admin 補發)。
### Logic 商務驗證一覽(不下沉 usecase
- invite 必填 / 過期 / 限新 user
- 條款版本接受
- tenant 是否允許 B2C 註冊
- 密碼政策
- 註冊 rate limit key 組合
- SocialOAuth state 與 registration session 綁定
---
## Invite Code
| 欄位 | 說明 |
|------|------|
| `tenant_id` | 租戶 |
| `code_hash` | SHA-256(normalized code)**永不明文** |
| `max_uses` / `used_count` | 總次數 / 已用 |
| `expires_at` | 0 = 永不過期 |
| `new_users_only` | 限新用戶(社交註冊用) |
- 索引:`(tenant_id, code_hash)` unique
- Validate / Consume 介面:`InviteUseCase`
- Email 註冊在 `/register` 起始即 ConsumeSocial 在 Callback 才 Consume
---
## Rate Limit建議由 middleware 落地)
| Key | 限制 |
|-----|------|
| `auth:register:ip:{ip}` | 10 / hour |
| `auth:register:email:{tenant}:{email}` | 3 / hour |
| `auth:register:invite_fail:{ip}` | 20 / hour |
OTP resend 沿用 `member.VerifyRate`
---
## 錯誤碼Auth scope 28
| 情境 | errb |
|------|------|
| invite 無效 | `InputInvalidFormat` / `ResNotFound("invite")` |
| invite 用盡 | `ResInsufficientQuota` |
| email 已註冊 | `ResAlreadyExist` |
| OTP 錯誤 | `AuthForbidden` + cause |
| tenant 不允許註冊 | `AuthForbidden` |
| 未接受條款 | `InputMissingRequired` |
| ZITADEL 下游失敗 | `SvcThirdParty` |
| DB 失敗 | `DBError` via wrapRepoErr |
Scope / category 完整對照:[`internal/library/errors/README.md`](../../library/errors/README.md)。
---
## 設定(`etc/gateway.dev.yaml`
```yaml
Auth:
AccessExpire: 900
RefreshExpire: 604800
ActiveKID: v1
AccessSecret: ... # 32+ bytesprod 走 env / secret manager
RefreshSecret: ...
RegistrationSessionTTLSeconds: 600
Zitadel:
Issuer: https://zitadel.internal
ServiceUserToken: ${ZITADEL_SERVICE_TOKEN}
GoogleClientID: ...
GoogleClientSecret: ...
DefaultOrgID: ...
```
---
## 測試
- **單元**`go test ./internal/model/auth/...`invite consume 並發、JWT parse/refresh、errb 映射)
- **E2E**:見 [`docs/e2e-testing.md`](../../../docs/e2e-testing.md)`TestAuth_*` / `TestZZZ_AuthTokenRefreshAndLogout`ZITADEL 整合路徑A-01~A-09需 staging 環境
---
## 相關文件
- [`docs/model.md`](../../../docs/model.md) — Clean Architecture 分層
- [`generate/api/README.md`](../../../generate/api/README.md) — `.api` + middleware 約定
- [`internal/library/errors/README.md`](../../library/errors/README.md) — 8 碼錯誤碼
- [`internal/model/member/README.md`](../member/README.md) — Member 模組