# Auth 模組 Gateway 認證領域層:邀請碼、註冊稽核、OAuth Session、CloudEP JWT 簽發/刷新/登出。完整 HTTP 流程由 `internal/logic/auth/` 編排,並與 ZITADEL(身份)、Member(會員)、Notification(通知)協作。 > **架構原則**([`docs/model.md`](../../../docs/model.md) §6.1):usecase 為 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 註冊 / 登入暫存 Session(Redis) | 郵件 / 簡訊 → Notification | | CloudEP JWT access / refresh 生命週期 | RBAC → Permission | | JWT 黑名單 + jti pair | | --- ## 目錄結構 ``` internal/model/auth/ ├── README.md # 本檔 ├── config/ ├── domain/ │ ├── entity/ # InviteCode、RegistrationMetadata │ ├── enum/ # RegistrationChannel(email / 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`。 --- ## Token(CloudEP JWT) HS256;access / refresh 使用不同 secret。 | Claim | 說明 | |-------|------| | `tenant_id` | 租戶 ID | | `uid` | 會員 UID | | `typ` | `access` 或 `refresh` | | `auth_gen` | 簽發世代(強制登出時 +1) | | `jti` | Token 唯一 ID | | `iat` / `exp` | 簽發 / 過期 | | Token | 預設 TTL | |-------|---------| | Access | 900s(15 分鐘) | | Refresh | 604800s(7 天) | | Registration Session | 600s(10 分鐘) | ### 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: IssuePair(new 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 │ │ └───────┬────────┘ ▼ IssueTokenPair(CloudEP JWT) ``` ### Email 路徑 1. Logic 驗 tenant_slug + invite + 條款 + 密碼強度 + email 格式 2. Logic 消耗 invite(Redis 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 session(Redis 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 組合 - Social:OAuth 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` 起始即 Consume;Social 在 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+ bytes,prod 走 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 模組