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

292 lines
9.8 KiB
Markdown
Raw Normal View History

# 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 模組