diff --git a/README.md b/README.md index 071e087..f0b5bc0 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,8 @@ HTTP Request - [internal/library/errors/README.md](internal/library/errors/README.md) — 錯誤碼與 HTTP 對照 - [internal/library/mongo/README.md](internal/library/mongo/README.md) — MongoDB / Redis cache 流程與用法 - [docs/model.md](docs/model.md) — `internal/model/{module}` 分層(entity / repository / usecase) -- [docs/identity-member-design.md](docs/identity-member-design.md) — **Draft** Identity / Member / Permission 模組架構(ZITADEL、LDAP、SCIM、B2B 自定義權限) +- [docs/identity-member-design.md](docs/identity-member-design.md) — Identity / Member / Permission 模組架構(ZITADEL、LDAP、SCIM、B2B 自定義權限) +- [docs/auth-unified-registration.md](docs/auth-unified-registration.md) — Gateway 統一註冊 / 登入(`/api/v1/auth/*`,含 Email、Social、JWT) ## 開發約定 @@ -191,19 +192,18 @@ HTTP Request ### 2. Logic 與 Handler ```go -// internal/logic/... — 編排與映射;有持久化時呼叫 svcCtx.{Module}UC -var errb = errs.For(code.Facade) +// internal/logic/auth — Auth scope +var errb = errs.For(code.Auth) -func (l *PingLogic) Ping() (*types.PingData, error) { - return &types.PingData{Pong: "ok"}, nil // 簡單 API 可直接回 types -} +// internal/logic/member — Member scope +var errb = errs.For(code.Member) -// internal/handler/... — 由模板生成 +// internal/handler/... — 由模板生成;parse/validate 錯誤用 Facade scope(response.RequestErrScope) data, err := l.Ping() response.Write(r.Context(), w, data, err) ``` -有 request 的 API 會自動包含 `httpx.Parse`;解析失敗會映射為 **400** `InputInvalidFormat`。 +有 request 的 API 會自動包含 `httpx.Parse`;解析失敗會映射為 **400** `InputInvalidFormat`(**Facade scope `10101000`**)。 ### 3. HTTP JSON 格式 @@ -211,21 +211,21 @@ response.Write(r.Context(), w, data, err) ```json { - "code": 0, + "code": 102000, "message": "SUCCESS", "data": { } } ``` -**失敗(HTTP 依錯誤類別,如 404)** +**失敗(HTTP 依錯誤類別,如 404;Member scope 範例)** ```json { - "code": 10301000, - "message": "user not found", + "code": 29301000, + "message": "member not found", "error": { - "biz_code": "10301000", - "scope": 10, + "biz_code": "29301000", + "scope": 29, "category": 301, "detail": 0 } @@ -242,7 +242,7 @@ response.Write(r.Context(), w, data, err) |----|------|------| | **repository** | 忠實反映基礎設施(Mongo / Redis / driver) | `*errs.Error`(DB*、ResInvalidMeasureID 等)+ `WithCause`;可預期「無資料」可回模組 `errors.go` 的 **sentinel** | | **usecase** | 業務規則(狀態、權限、組合多 repo) | `*errs.Error`(Res*、Auth*、Svc* 等);sentinel 轉成對外語意;已是正確的 `*errs.Error` 可原樣往上傳 | -| **logic** | HTTP 輸入檢查、types 映射 | 僅在進 usecase 前用 `Input*`;其餘 **原樣** `return nil, err`,不二次包裝 | +| **logic** | HTTP 輸入檢查、types 映射 | 使用**該模組 scope**(`code.Auth` / `code.Member`);cross-module 錯誤原樣 `return nil, err` | | **handler** | 序列化 | `response.Write`(內建 `errs.FromError`) | 模組頂層 sentinel 範例(`internal/model/member/errors.go`,`package member`): @@ -264,10 +264,10 @@ Repository 對照建議: | 連線 / 暫時不可用 | `errb.DBUnavailable(...).WithCause(err)` | | 其他 driver 錯 | `errb.DBError(...).WithCause(err)` | -Usecase 範例: +Usecase 範例(Member scope): ```go -var errb = errs.For(code.Facade) +var errb = errs.For(code.Member) acc, err := uc.Account.FindOne(ctx, id) if err != nil { @@ -336,10 +336,12 @@ import ( "gateway/internal/library/errors/code" ) -var errb = errs.For(code.Facade) +// logic / usecase:依模組選 scope +var authErr = errs.For(code.Auth) // 28301000 = ResNotFound +var memberErr = errs.For(code.Member) // 29301000 = ResNotFound -return nil, errb.ResNotFound("user", id) -return nil, errb.InputMissingRequired("email").WithCause(err) +return nil, memberErr.ResNotFound("member", id) +return nil, authErr.InputMissingRequired("email").WithCause(err) ``` Category 與 HTTP 對照見 [internal/library/errors/README.md](internal/library/errors/README.md)。 diff --git a/cmd/mongo-index/main.go b/cmd/mongo-index/main.go index 95a6562..518fb64 100644 --- a/cmd/mongo-index/main.go +++ b/cmd/mongo-index/main.go @@ -9,6 +9,7 @@ import ( "time" "gateway/internal/config" + authrepo "gateway/internal/model/auth/repository" memberrepo "gateway/internal/model/member/repository" notifrepo "gateway/internal/model/notification/repository" @@ -48,7 +49,10 @@ func run() error { if err := memberrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil { return fmt.Errorf("mongo-index: member: %w", err) } + if err := authrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil { + return fmt.Errorf("mongo-index: auth: %w", err) + } - fmt.Println("mongo-index: notifications + notification_dlq + member indexes OK") + fmt.Println("mongo-index: notifications + notification_dlq + member + auth indexes OK") return nil } diff --git a/docs/auth-unified-registration.md b/docs/auth-unified-registration.md new file mode 100644 index 0000000..75c0e2b --- /dev/null +++ b/docs/auth-unified-registration.md @@ -0,0 +1,405 @@ +# 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 无效 → 留在注册页 diff --git a/docs/identity-member-design.md b/docs/identity-member-design.md index 15ff7ed..2710d57 100644 --- a/docs/identity-member-design.md +++ b/docs/identity-member-design.md @@ -205,17 +205,30 @@ notification - **Management API / JWKS**GGateway zL URL sAg - **]w**G`etc/gateway.yaml` `Zitadel.Issuer` / `MgmtURL` V self-hosted I -### 3.4 U|]wMG Gateway U API^ +### 3.4 U|]wMGGateway Τ@U BFF^ -Gateway **S** `/auth/register`CUѤUC|G +> **W**G[auth-unified-registration.md](./auth-unified-registration.md)]2026-05-21 _ǡF`Kn^ -| | U| | nJƧ@ | -|---------|----------|----------------| -| **B2C** | ZITADEL Hosted Register UI]Ϋeݨ ZITADEL OIDC PKCE^ | token exchange IJo `EnsureFromOIDC` JIT | -| **B2B]LDAP^** | IT b AD / OpenLDAP رbFi Directory Sync w provision ZITADEL | LDAP IdP nJIJo `EnsureFromLDAP` JIT | -| **B2B]SCIM^** | HR / Okta / Entra SCIM Create User | SCIM endpoint g ZITADEL + Gateway] JIT^ | +Gateway **S** `/api/v1/auth/register*` @ B2C Τ@UJfFZITADEL @ identity ݡ]bKBOIDC^A**A**nDϥΪ̸ ZITADEL Hosted Register UIC -> ZITADEL email Ҥwu**inJ**veF~ȤWu**iϥΥ\**ve 5.4 ~ҡC +| | U| | | +|---------|----------|------| +| **B2C Email** | `POST /auth/register` OTP `POST /auth/register/confirm` | Logic sơGinvite consume `zitadel.CreateHumanUser` `Lifecycle.CreateUnverified` registration OTP `Activate` CloudEP JWT | +| **B2C Social]Google^** | `POST /auth/register/social/start` OAuth `GET /auth/register/social/callback` | OAuth **e**jw invite session]Redis^Fcallback invite `EnsureFromOIDC` registration metadata JWT | +| **B2B]LDAP^** | IT b AD / OpenLDAP رbFDirectory Sync w provision | nJ LDAP IdP `EnsureFromLDAP` JITF**g** register API | +| **B2B]SCIM^** | HR / Okta / Entra SCIM Create User | SCIM endpoint g ZITADEL + GatewayF**g** register API | + +**ӰȳWh]Logic hAD usecase^G** + +- Invite code ****]`Member.Registration.RequireInviteCode`Aw] `true`^ +- ڪ `accept_terms_version` +- Ue **o** CloudEP JWTFconfirm / social callback ~ `IssuePair` +- Invite ӫY ZITADEL / member **^u invite**]F auth-unified-registration 9^ +- Social nJ]DU^ **`/auth/login/social/*`**AP register session ** state e**]`login:` vs `reg:`^ + +**nJ]DU^** [auth-unified-registration.md 3.3](./auth-unified-registration.md#33-nJDU)G`/auth/login`B`/auth/token/refresh`B`/auth/token/exchange`BSocial loginC + +> ZITADEL email ҥΩ **** nJeFx͵UtH Gateway registration OTP]`OTPPurposeRegistrationEmail`^T{~ `Activate`C ### 3.5 x MFA j]wM^ @@ -979,47 +992,28 @@ C. Disable | POST | `/api/v1/members/me/totp/backup-codes` | backup codes | ? `disable_totp` | | DELETE | `/api/v1/members/me/totp` | Ѱjw | ? `disable_totp` | -### 5.9 UseCase sƥܨҡ]·Fhandler / API Ȥ@^ +### 5.9 UseCase sƥܨ -> i atomic primitives iNզX޿yC**logic h|@**F`ҩi伵w~ȡC +> i atomic primitives b **logic h** զX覡CB2C U / nJ **w@** `internal/logic/auth/`F [auth-unified-registration.md](./auth-unified-registration.md)C -#### Case AGx͵U + Email OTP ҡ]Ӹ|^ +#### Case AGx͵U + Email OTP ҡ]**w@**G`RegisterLogic` / `RegisterConfirmLogic`^ ```go -// 1) إ unverified member]HHBo token^ +// HTTP: POST /auth/register Logic sơ]Kn^ +// 1) invite consume]Y RequireInviteCode^ +// 2) zitadel.CreateHumanUser m, _ := mLifecycle.CreateUnverified(ctx, &CreatePlatformMemberRequest{ - TenantID: tenantID, Email: email, DisplayName: name, + TenantID: tenantID, Email: email, DisplayName: name, ZitadelUserID: zitadelSub, }) - -// 2) OTP]atomicBpurpose-agnostic^ -chal, _ := mOTP.Generate(ctx, &GenerateOTPRequest{ - TenantID: tenantID, - Purpose: OTPPurposeRegistrationEmail, - Identifier: m.UID, +// 3) registration metadata.Record]channel=email^ +chal, plain, _ := mOTP.Generate(ctx, &GenerateOTPRequest{ + TenantID: tenantID, UID: m.UID, Purpose: OTPPurposeRegistrationEmail, Target: email, }) - -// 3) 뻼 OTP]atomicFcaller channel / template^ -notifier.Send(ctx, &SendRequest{ - TenantID: tenantID, - UID: m.UID, - Channel: ChannelEmail, - Kind: NotifyVerifyRegistrationEmail, - Target: email, - Data: map[string]any{"code": chal.Code, "expires_in": chal.ExpiresIn}, - IdempotencyKey: chal.ChallengeID, - DoNotPersistBody: true, -}) - -// ]ϥΪ̦HBJ code ݨHUB^ - -// 4) OTP]atomic^ -_ = mOTP.Verify(ctx, &VerifyOTPRequest{ - TenantID: tenantID, ChallengeID: chal.ChallengeID, - Code: userCode, Purpose: OTPPurposeRegistrationEmail, -}) - -// 5) ҥΡ]atomic^Gunverified active +notifier.Send(ctx, &SendRequest{ Kind: NotifyVerifyRegistrationEmail, Data: map[string]any{"code": plain, ...} }) +// HTTP: POST /auth/register/confirm +_ = mOTP.Verify(ctx, &VerifyOTPRequest{ ... Purpose: OTPPurposeRegistrationEmail }) _ = mLifecycle.Activate(ctx, tenantID, m.UID) +// auth.IssuePair { access_token, refresh_token } ``` #### Case BGOIDC]Social / ZITADEL Hosted UI^nJ X OTP @@ -1510,14 +1504,24 @@ B2C ### 7.1 auth.api]} / JWT API өw^ -| Method | Path | | Ųv | -|--------|------|------|------| -| POST | `/api/v1/auth/token/exchange` | ZITADEL token CloudEP JWT | } | -| POST | `/api/v1/auth/token/refresh` | s JWT | }]a refresh^ | -| POST | `/api/v1/auth/logout` | nX]jti ¦W^ | JWT | -| POST | `/api/v1/auth/revoke-all` | MPۤvҦ session]INCR auth_gen^ | JWT + Step-up `revoke_all_sessions` | -| POST | `/api/v1/auth/step-up/start` | Ұ step-up MFAAH OTP | JWT | -| POST | `/api/v1/auth/step-up/confirm` | T{ OTP ñou `step_up_token` | JWT | +> **w@**]2026-05-21^GUuAv?`FШD/^ [auth-unified-registration.md 4](./auth-unified-registration.md#4-api-Wgenerateapiauthapi) P `generate/api/auth.api`C + +| Method | Path | | Ųv | A | +|--------|------|------|------|------| +| POST | `/api/v1/auth/register` | Email + KXU]ZITADEL + member + registration OTP^ | } | ? | +| POST | `/api/v1/auth/register/confirm` | T{ registration OTP CloudEP JWT | } | ? | +| POST | `/api/v1/auth/register/resend` | H registration OTP | } | ? | +| POST | `/api/v1/auth/register/social/start` | Social **U** start]t invite session^ | } | ? | +| GET | `/api/v1/auth/register/social/callback` | Social **U** OAuth callback JWT | } | ? | +| POST | `/api/v1/auth/login` | Email + KXnJ]ZITADEL ROPG JWT^ | } | ? | +| POST | `/api/v1/auth/login/social/start` | Social **nJ** start]L invite^ | } | ? | +| GET | `/api/v1/auth/login/social/callback` | Social **nJ** OAuth callback JWT | } | ? | +| POST | `/api/v1/auth/token/refresh` | s JWT | }]a refresh^ | ? | +| POST | `/api/v1/auth/token/exchange` | ZITADEL `id_token` CloudEP JWT]~ SSO^ | } | ? | +| POST | `/api/v1/auth/logout` | nX]jti ¦W^ | JWT | ? | +| POST | `/api/v1/auth/revoke-all` | MPۤvҦ session]INCR auth_gen^ | JWT + Step-up `revoke_all_sessions` | W | +| POST | `/api/v1/auth/step-up/start` | Ұ step-up MFAAH OTP | JWT | W | +| POST | `/api/v1/auth/step-up/confirm` | T{ OTP ñou `step_up_token` | JWT | W | ### 7.2 member.api] JWT + Casbin^ @@ -1595,6 +1599,17 @@ B2C ### 8.1 @O@ API +**ثew@]member Ҳա^G** + +``` +Request + CloudEPJWT middleware]i Bearer access JWT `J tenant_id + uid context^ + member handlerGY context L actorAfallback dev headers X-Tenant-ID + X-UID]}o^ + handler logic usecase +``` + +**ؼЧ]Casbin / permission ҲմN^G** + ``` Request go-zero JWT ñ @@ -1661,17 +1676,44 @@ Casbin ### 9.1 nJ / +#### 9.1.1 Email + KXnJ]w@^ + +``` +Client POST /api/v1/auth/login { tenant_slug, email, password } + 1. tenant.ResolveBySlug + 2. zitadel.VerifyPassword]ROPG^ + 3. ѪR id_token / userinfo zitadel sub + 4. member.GetByZitadelUserID member_status == active + 5. auth.IssuePair +Client { access_token, refresh_token, uid } +``` + +#### 9.1.2 ZITADEL id_token ]SSO / clientAw@^ + +``` +Client POST /api/v1/auth/token/exchange { tenant_slug, id_token } + 1. zitadel.VerifyIDToken]JWKS ñ + iss/aud/exp^ + 2. tenant.ResolveBySlug + 3. member.GetByZitadelUserID active + 4. auth.IssuePair +Client { access_token, refresh_token, uid } +``` + +#### 9.1.3 OIDC nJ + JIT]B2B / B2C Hosted UI |A䴩^ + ``` Client ZITADEL OIDC Login]t LDAP IdP^ Client POST /auth/token/exchange { tenant_slug, id_token } 1. zitadel.VerifyIDToken 2. tenant.ResolveBySlug org_id - 3. member.EnsureFromOIDC uid]p AMEX-10000000^ - 4. permission.SyncFromZitadelClaims user_roles - 5. auth.IssueTokenPair]role keys ַ, auth_gen^ + 3. member.EnsureFromOIDC uid]p AMEX-10000000^ // Y member sbh JIT + 4. permission.SyncFromZitadelClaims user_roles // W + 5. auth.IssueTokenPair Client { access_token, refresh_token, uid } ``` +> **`N**GB2C sU 3.4 Gateway `/auth/register*`F`/auth/token/exchange` Od **wsb member** SSO nJP~ IdPC + ### 9.2 O@ API ``` @@ -2448,7 +2490,7 @@ RateLimit: | 16 | ~ӷ UserRole | ** source j Replace**Amanual äQ~ | 6.10 | | 17 | PlainCode @ | **Casbin B~d `.plain_code` **Ah role allow G OR | 6.9 | | 18 | Permission.Name | **إ߫ᤣiW**Fo `status=close` + s | 6.4 | -| 19 | U| | **w]** ZITADEL Hosted UI]B2C^/ LDAP / SCIM]B2B^F**Od** platform-native usecase]`LifecycleUseCase.CreateUnverified` + `OTPUseCase` + `Activate`^ѥӶ}q Gateway ͵U]t email OTP ҡ^ | 3.4B5.2.1B5.9 | +| 19 | U| | **B2C**GGateway Τ@ `/auth/register*`]Email + SocialAinvite ^F**B2B**GLDAP / SCIM g register APIFplatform-native usecase wΩ Email U | 3.4B[auth-unified-registration.md](./auth-unified-registration.md) | | 20 | vs ~Ҥh | **ZITADEL ޵nJFGateway member ~ email / phone** | 1.2B5.4 | | 21 | Step-up MFA | **ҥ**FI action 5min 榸 `step_up_token` | 5.6B9.6 | | 22 | OTP 뻼qD | **۰e**]zL Notification Module ] Email / SMS Provider^ | 5.5B11B17 | @@ -2628,3 +2670,4 @@ type ServiceContext struct { | 2026-05-20 | 0.7.0 | ݨM AVL ƩOGSCIM id = Gateway UID + ZITADEL sub extension]10.3^FCasbin h pod Pub/Sub + 5min cron ©]6.11^FTenant إ saga]3.1^FPlatform Admin seed CLI]18 P0^FMember.Origin + UserRole.Source ]5.4B6.10^FSCIM token v + IP allowlist]7.5^FW audit_logs collection + TTL 90d]20.1^FnR 30 ѰΦWơ]5.7^F SoT]5.3^FDirectory Sync guardrail]10.4^FRedis sliding-window rate limit]20.2^FJWT kid h key æs]4.4^ | | 2026-05-20 | 0.8.0 | XW **Notification Module**]11^GҦ outbound qTΤ@JfBt idempotency / / DLQ / ҪO / hyBӷPe `DoNotPersistBody`FsW **~ TOTP**]5.8^䴩 Google AuthenticatorAP ZITADEL TOTP WߡFstep-up qDuǧאּ **TOTP > SMS > Email**]5.6^FؿBServiceContextBMongo collectionsBRedis keyB]wɡBIǡBMC 25V28 PBsF11V19 `s +1 | | 2026-05-20 | 0.9.0 | **UseCase ᵲ]~޿Ȥ@^**G5.2 g Atomic primitives + Composite hFsW `OTPUseCase`]purpose-agnostic atomic^B`LifecycleUseCase`]CreateUnverified / Activate / Suspend / Reactivate / SoftDelete / AbortPending^F`ProvisioningUseCase` `EnsureFromOIDC / LDAP / SCIM` TF`ProfileUseCase` [ `SetBusinessEmailVerified` / `SetBusinessPhoneVerified` atomicF[^ `unverified` A] platform-native |^Fɧ Member entity BEnum סBRequest DTOFsW 5.9 sƥܨҡ]5 case^F14 OTP Redis key purpose-basedFMC 19 ץBsW 29V32 | +| 2026-05-21 | 1.0.0 | **Gateway Τ@Uw@**G׭q 3.4]אּS `/auth/register*`^F7.1 ɻw@ auth ѡF8.1 O CloudEP JWT + dev header fallbackF9.1 login / token exchangeF5.9 Case A Ьw@FMC 19 sCԨ [auth-unified-registration.md](./auth-unified-registration.md) | diff --git a/docs/model.md b/docs/model.md index b015df1..0c216de 100644 --- a/docs/model.md +++ b/docs/model.md @@ -326,7 +326,7 @@ func MustMemberUseCase(param MemberUseCaseParam) domusecase.AccountUseCase { ## 7. 錯誤處理 -全專案對外只使用 `gateway/internal/library/errors`(`var errb = errs.For(code.Facade)`)。`domain/errors.go` **只放 sentinel**,不另建 8 碼常數表。 +全專案對外只使用 `gateway/internal/library/errors`。各模組綁定對應 scope:`code.Auth(28)`、`code.Member(29)`、`code.Notification(30)`;handler 層 parse/validate 使用 `code.Facade(10)`(`response.RequestErrScope`)。`domain/errors.go` **只放 sentinel**,不另建 8 碼常數表。 ### 7.1 模組 sentinel(`domain/errors.go`) diff --git a/etc/gateway.dev.example.yaml b/etc/gateway.dev.example.yaml index a13cecc..65d04be 100644 --- a/etc/gateway.dev.example.yaml +++ b/etc/gateway.dev.example.yaml @@ -78,3 +78,30 @@ Member: # 32-byte key encoded as hex (64 chars) or base64; leave empty to disable TOTP. # Dev-only placeholder for local totp-test; replace in production. SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" + Registration: + RequireInviteCode: true + TrustSocialEmailVerified: true + +Auth: + AccessExpire: 900 + RefreshExpire: 604800 + ActiveKID: v1 + # Dev-only placeholders; override via env JWT_ACCESS_SECRET / JWT_REFRESH_SECRET in production. + AccessSecret: "dev-access-secret-32-bytes-min!!" + RefreshSecret: "dev-refresh-secret-32-bytes-min!" + RegistrationSessionTTLSeconds: 600 + +# ZITADEL identity backend (auth register/login — PR 1+) +# ServiceUserToken: export ZITADEL_SERVICE_TOKEN=... +# OAuthClientSecret: export ZITADEL_OAUTH_CLIENT_SECRET=... +Zitadel: + Issuer: "" # e.g. https://zitadel.example.com + ServiceUserToken: "" + DefaultOrgID: "" + OAuthClientID: "" + OAuthClientSecret: "" + GoogleClientID: "" + GoogleClientSecret: "" + GoogleIdPID: "" + JWKSUrl: "" + TimeoutSeconds: 15 diff --git a/gateway.go b/gateway.go index 8dac706..2d1798c 100644 --- a/gateway.go +++ b/gateway.go @@ -13,6 +13,9 @@ import ( "gateway/internal/config" "gateway/internal/handler" + "gateway/internal/library/errors/code" + "gateway/internal/middleware" + "gateway/internal/response" "gateway/internal/svc" "github.com/zeromicro/go-zero/core/conf" @@ -24,6 +27,8 @@ var configFile = flag.String("f", "etc/gateway.yaml", "the config file") func main() { flag.Parse() + response.RequestErrScope = code.Facade + var c config.Config conf.MustLoad(*configFile, &c) @@ -31,6 +36,9 @@ func main() { defer server.Stop() sc := svc.NewServiceContext(c) + if sc.AuthToken != nil { + server.Use(middleware.CloudEPJWT(sc.AuthToken)) + } handler.RegisterHandlers(server, sc) workerCtx, stopWorkers := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) diff --git a/generate/api/README.md b/generate/api/README.md index 70730b9..779cb89 100644 --- a/generate/api/README.md +++ b/generate/api/README.md @@ -6,6 +6,8 @@ |------|------| | `gateway.api` | 入口:`info()` + `import` | | `common.api` | 共用文件型別(`APIErrorStatus`、`ErrorDetail`) | +| `auth.api` | Auth 路由(scope 28) | +| `member.api` | Member 路由(scope 29) | | `normal.api` | 路由與業務 `data` 型別 | ## 指令 @@ -28,7 +30,7 @@ make gen-doc # 生成 docs/openapi/gateway.yaml(OpenAPI 3.0) Handler 使用 `response.Write` 輸出: ```json -{ "code": 0, "message": "SUCCESS", "data": { ... } } +{ "code": 102000, "message": "SUCCESS", "data": { ... } } ``` -失敗時含 `error.biz_code` 等欄位,與 `common.api` 定義一致。 +失敗時含 `error.biz_code` / `error.scope` 等欄位。Handler parse 錯誤為 Facade scope(`10101000`);各模組 logic/usecase 使用對應 scope(Auth=28、Member=29)。 diff --git a/generate/api/auth.api b/generate/api/auth.api new file mode 100644 index 0000000..c8e7532 --- /dev/null +++ b/generate/api/auth.api @@ -0,0 +1,451 @@ +syntax = "v1" + +type ( + RegisterReq { + TenantSlug string `json:"tenant_slug" validate:"required"` + InviteCode string `json:"invite_code" validate:"required"` + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=8,max=128"` + DisplayName string `json:"display_name,optional"` + Language string `json:"language,optional"` + AcceptTermsVersion string `json:"accept_terms_version" validate:"required"` + MarketingOptIn bool `json:"marketing_opt_in,optional"` + } + + RegisterData { + ChallengeID string `json:"challenge_id"` + ExpiresIn int `json:"expires_in"` + UID string `json:"uid"` + } + + RegisterConfirmReq { + TenantSlug string `json:"tenant_slug" validate:"required"` + ChallengeID string `json:"challenge_id" validate:"required"` + Code string `json:"code" validate:"required,len=6"` + } + + RegisterResendReq { + TenantSlug string `json:"tenant_slug" validate:"required"` + ChallengeID string `json:"challenge_id" validate:"required"` + } + + AuthTokenData { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` + UID string `json:"uid"` + TokenType string `json:"token_type"` + } + + RegisterSocialStartReq { + TenantSlug string `json:"tenant_slug" validate:"required"` + InviteCode string `json:"invite_code" validate:"required"` + Provider string `json:"provider" validate:"required,oneof=google"` + AcceptTermsVersion string `json:"accept_terms_version" validate:"required"` + Language string `json:"language,optional"` + RedirectURI string `json:"redirect_uri" validate:"required,url"` + MarketingOptIn bool `json:"marketing_opt_in,optional"` + } + + RegisterSocialStartData { + OauthURL string `json:"oauth_url"` + SessionID string `json:"session_id"` + ExpiresIn int `json:"expires_in"` + } + + RegisterSocialCallbackReq { + Code string `form:"code" validate:"required"` + State string `form:"state" validate:"required"` + } + + LoginReq { + TenantSlug string `json:"tenant_slug" validate:"required"` + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=8,max=128"` + } + + TokenRefreshReq { + RefreshToken string `json:"refresh_token" validate:"required"` + } + + TokenExchangeReq { + TenantSlug string `json:"tenant_slug" validate:"required"` + IDToken string `json:"id_token" validate:"required"` + } + + LoginSocialStartReq { + TenantSlug string `json:"tenant_slug" validate:"required"` + Provider string `json:"provider" validate:"required,oneof=google"` + RedirectURI string `json:"redirect_uri" validate:"required,url"` + } + + LoginSocialStartData { + OauthURL string `json:"oauth_url"` + SessionID string `json:"session_id"` + ExpiresIn int `json:"expires_in"` + } + + LoginSocialCallbackReq { + Code string `form:"code" validate:"required"` + State string `form:"state" validate:"required"` + } + + LogoutData { + OK bool `json:"ok"` + } + + // 文件用:成功回應 envelope(HTTP 200, code=102000, message=SUCCESS) + RegisterOKStatus { + Code int64 `json:"code"` + Message string `json:"message"` + Data RegisterData `json:"data"` + } + + AuthTokenOKStatus { + Code int64 `json:"code"` + Message string `json:"message"` + Data AuthTokenData `json:"data"` + } + + RegisterSocialStartOKStatus { + Code int64 `json:"code"` + Message string `json:"message"` + Data RegisterSocialStartData `json:"data"` + } + + LoginSocialStartOKStatus { + Code int64 `json:"code"` + Message string `json:"message"` + Data LoginSocialStartData `json:"data"` + } + + LogoutOKStatus { + Code int64 `json:"code"` + Message string `json:"message"` + Data LogoutData `json:"data"` + } +) + +@server( + group: auth + prefix: /api/v1/auth +) +service gateway { + @doc "Email 註冊(建立 ZITADEL + member,寄 registration OTP)" + /* + @respdoc-200 (RegisterOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 10104000: (APIErrorStatus) 缺少必填欄位 + ) // 參數錯誤 / 驗證失敗 + @respdoc-403 ( + 28505000: (APIErrorStatus) tenant 不允許註冊 + ) // 禁止存取 + @respdoc-404 ( + 29301000: (APIErrorStatus) tenant 不存在(Member scope) + ) // 資源不存在 + @respdoc-409 ( + 28303000: (APIErrorStatus) email 已註冊(Auth scope) + ) // 資源衝突 + @respdoc-423 ( + 28313000: (APIErrorStatus) invite 消耗鎖定中 + ) // 資源鎖定 + @respdoc-429 ( + 28604000: (APIErrorStatus) OTP 重送冷卻 + 28310000: (APIErrorStatus) invite 次數用盡 + ) // 請求過於頻繁 + @respdoc-500 ( + 28201000: (APIErrorStatus) 資料庫錯誤 + 28601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 28605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + @respdoc-502 ( + 28802000: (APIErrorStatus) ZITADEL 第三方錯誤 + ) // 第三方服務錯誤 + */ + @handler register + post /register (RegisterReq) returns (RegisterData) + + @doc "確認 registration OTP 並核發 JWT" + /* + @respdoc-200 (AuthTokenOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 10104000: (APIErrorStatus) 缺少必填欄位 + ) // 參數錯誤 / 驗證失敗 + @respdoc-403 ( + 28505000: (APIErrorStatus) challenge tenant 或 purpose 不符(Auth scope) + 29505000: (APIErrorStatus) OTP 無效(Member scope) + ) // 禁止存取 + @respdoc-404 ( + 29301000: (APIErrorStatus) tenant / OTP challenge 不存在(Member scope) + ) // 資源不存在 + @respdoc-409 ( + 28309000: (APIErrorStatus) registration challenge 狀態無效(Auth scope) + 29309000: (APIErrorStatus) OTP challenge 鎖定(Member scope) + ) // 資源狀態衝突 + @respdoc-500 ( + 28201000: (APIErrorStatus) 資料庫錯誤 + 28601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 28605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + */ + @handler registerConfirm + post /register/confirm (RegisterConfirmReq) returns (AuthTokenData) + + @doc "重寄 registration OTP" + /* + @respdoc-200 (RegisterOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 10104000: (APIErrorStatus) 缺少必填欄位 + ) // 參數錯誤 + @respdoc-403 ( + 28505000: (APIErrorStatus) challenge tenant 或 purpose 不符 + ) // 禁止存取 + @respdoc-404 ( + 29301000: (APIErrorStatus) tenant / OTP challenge 不存在(Member scope) + ) // 資源不存在 + @respdoc-409 ( + 28309000: (APIErrorStatus) registration challenge 不完整(Auth scope) + ) // 資源狀態衝突 + @respdoc-429 ( + 28604000: (APIErrorStatus) OTP 重送冷卻 + ) // 請求過於頻繁 + @respdoc-500 ( + 28201000: (APIErrorStatus) 資料庫錯誤 + 28601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 28605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + */ + @handler registerResend + post /register/resend (RegisterResendReq) returns (RegisterData) + + @doc "Social 註冊:建立 session 並回傳 OAuth URL" + /* + @respdoc-200 (RegisterSocialStartOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 10104000: (APIErrorStatus) 缺少必填欄位 + 28101000: (APIErrorStatus) invite 已過期(Auth scope) + ) // 參數錯誤 + @respdoc-403 ( + 28505000: (APIErrorStatus) tenant 不允許註冊 + ) // 禁止存取 + @respdoc-404 ( + 29301000: (APIErrorStatus) tenant 不存在(Member scope) + 28301000: (APIErrorStatus) invite 不存在(Auth scope) + ) // 資源不存在 + @respdoc-429 ( + 28310000: (APIErrorStatus) invite 次數用盡 + ) // 配額不足 + @respdoc-500 ( + 28201000: (APIErrorStatus) 資料庫錯誤 + 28601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 28605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + @respdoc-502 ( + 28802000: (APIErrorStatus) ZITADEL 第三方錯誤 + ) // 第三方服務錯誤 + */ + @handler registerSocialStart + post /register/social/start (RegisterSocialStartReq) returns (RegisterSocialStartData) + + @doc "Social 註冊 OAuth callback" + /* + @respdoc-200 (AuthTokenOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 10104000: (APIErrorStatus) 缺少必填欄位 + 28101000: (APIErrorStatus) oauth state 無效(Auth scope) + ) // 參數錯誤 + @respdoc-403 ( + 28505000: (APIErrorStatus) social email 未驗證 + ) // 禁止存取 + @respdoc-404 ( + 28301000: (APIErrorStatus) registration session 不存在(Auth scope) + 29301000: (APIErrorStatus) tenant 不存在(Member scope) + ) // 資源不存在 + @respdoc-409 ( + 28303000: (APIErrorStatus) 帳號已存在(引導 login) + ) // 資源衝突 + @respdoc-423 ( + 28313000: (APIErrorStatus) invite 消耗鎖定中 + ) // 資源鎖定 + @respdoc-429 ( + 28310000: (APIErrorStatus) invite 次數用盡 + ) // 配額不足 + @respdoc-500 ( + 28201000: (APIErrorStatus) 資料庫錯誤 + 28601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 28605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + @respdoc-502 ( + 28802000: (APIErrorStatus) ZITADEL 第三方錯誤 + ) // 第三方服務錯誤 + */ + @handler registerSocialCallback + get /register/social/callback (RegisterSocialCallbackReq) returns (AuthTokenData) + + @doc "Email + 密碼登入(ZITADEL ROPG → CloudEP JWT)" + /* + @respdoc-200 (AuthTokenOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 10104000: (APIErrorStatus) 缺少必填欄位 + ) // 參數錯誤 + @respdoc-401 ( + 28501000: (APIErrorStatus) 帳密錯誤 + ) // 未授權 + @respdoc-403 ( + 28505000: (APIErrorStatus) 帳號未驗證 / 停權 / 不允許登入 + ) // 禁止存取 + @respdoc-404 ( + 29301000: (APIErrorStatus) tenant 不存在(Member scope) + ) // 資源不存在 + @respdoc-500 ( + 28201000: (APIErrorStatus) 資料庫錯誤 + 28601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 28605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + @respdoc-502 ( + 28802000: (APIErrorStatus) ZITADEL 第三方錯誤 + ) // 第三方服務錯誤 + */ + @handler login + post /login (LoginReq) returns (AuthTokenData) + + @doc "以 refresh_token 換發新的 access/refresh token" + /* + @respdoc-200 (AuthTokenOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 10104000: (APIErrorStatus) 缺少 refresh_token + ) // 參數錯誤 + @respdoc-401 ( + 28501000: (APIErrorStatus) refresh token 無效或已撤銷 + ) // 未授權 + @respdoc-500 ( + 28601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 28605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + */ + @handler tokenRefresh + post /token/refresh (TokenRefreshReq) returns (AuthTokenData) + + @doc "ZITADEL id_token 換 CloudEP JWT(企業 SSO)" + /* + @respdoc-200 (AuthTokenOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 10104000: (APIErrorStatus) 缺少必填欄位 + ) // 參數錯誤 + @respdoc-401 ( + 28501000: (APIErrorStatus) id_token 無效 + ) // 未授權 + @respdoc-403 ( + 28505000: (APIErrorStatus) 帳號未驗證 / 停權 / 不允許登入 + ) // 禁止存取 + @respdoc-404 ( + 29301000: (APIErrorStatus) tenant / member 不存在(Member scope) + ) // 資源不存在 + @respdoc-500 ( + 28201000: (APIErrorStatus) 資料庫錯誤 + 28601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 28605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + @respdoc-502 ( + 28802000: (APIErrorStatus) ZITADEL 第三方錯誤 + ) // 第三方服務錯誤 + */ + @handler tokenExchange + post /token/exchange (TokenExchangeReq) returns (AuthTokenData) + + @doc "Social 登入:建立 login session 並回傳 OAuth URL(不含 invite)" + /* + @respdoc-200 (LoginSocialStartOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 10104000: (APIErrorStatus) 缺少必填欄位 + ) // 參數錯誤 + @respdoc-403 ( + 28505000: (APIErrorStatus) tenant 不允許登入 + ) // 禁止存取 + @respdoc-404 ( + 29301000: (APIErrorStatus) tenant 不存在(Member scope) + ) // 資源不存在 + @respdoc-500 ( + 28201000: (APIErrorStatus) 資料庫錯誤 + 28601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 28605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + @respdoc-502 ( + 28802000: (APIErrorStatus) ZITADEL 第三方錯誤 + ) // 第三方服務錯誤 + */ + @handler loginSocialStart + post /login/social/start (LoginSocialStartReq) returns (LoginSocialStartData) + + @doc "Social 登入 OAuth callback" + /* + @respdoc-200 (AuthTokenOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 10104000: (APIErrorStatus) 缺少必填欄位 + 28101000: (APIErrorStatus) oauth state 無效(Auth scope) + ) // 參數錯誤 + @respdoc-403 ( + 28505000: (APIErrorStatus) social email 未驗證 / 帳號狀態不允許登入 + ) // 禁止存取 + @respdoc-404 ( + 28301000: (APIErrorStatus) login session 不存在(Auth scope) + 29301000: (APIErrorStatus) tenant / member 不存在(Member scope) + ) // 資源不存在 + @respdoc-500 ( + 28201000: (APIErrorStatus) 資料庫錯誤 + 28601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 28605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + @respdoc-502 ( + 28802000: (APIErrorStatus) ZITADEL 第三方錯誤 + ) // 第三方服務錯誤 + */ + @handler loginSocialCallback + get /login/social/callback (LoginSocialCallbackReq) returns (AuthTokenData) + + @doc "登出(撤銷 access JWT 及配對 refresh JWT)" + /* + @respdoc-200 (LogoutOKStatus) // 成功(code=102000) + @respdoc-401 ( + 28501000: (APIErrorStatus) 缺少或無效 access token + ) // 未授權 + @respdoc-500 ( + 28601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 28605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + */ + @handler logout + post /logout returns (LogoutData) +} diff --git a/generate/api/common.api b/generate/api/common.api index f4881cf..ecd15fb 100644 --- a/generate/api/common.api +++ b/generate/api/common.api @@ -1,6 +1,10 @@ syntax = "v1" // 文件與實際 HTTP 回應共用結構(handler 透過 response.Write 輸出) +// HTTP 狀態碼對照 errs.Error.HTTPStatus()(internal/library/errors/errors.go) +// 業務碼格式 SSCCCDDD(scope * 1_000_000 + category * 1_000 + detail) +// Facade scope=10(handler parse/validate):10101000 = InputInvalidFormat +// Auth scope=28、Member scope=29、Notification scope=30:各模組 logic/usecase 使用對應 scope type ( // ErrorDetail 失敗時 error 欄位 ErrorDetail { @@ -16,4 +20,10 @@ type ( Message string `json:"message"` Error ErrorDetail `json:"error"` } + + // EmptyOKStatus 成功但無 data(confirm / delete 等;code=102000) + EmptyOKStatus { + Code int64 `json:"code"` + Message string `json:"message"` + } ) diff --git a/generate/api/gateway.api b/generate/api/gateway.api index 00d7543..aae7b82 100644 --- a/generate/api/gateway.api +++ b/generate/api/gateway.api @@ -11,9 +11,11 @@ info ( consumes: "application/json" produces: "application/json" useDefinitions: true + bizCodeEnumDescription: "102000-成功
10101000-參數格式錯誤(Facade)
10104000-缺少必填欄位(Facade)
28101000-參數格式錯誤(Auth)
28104000-缺少必填欄位(Auth)
28201000-資料庫錯誤(Auth)
28301000-資源不存在(Auth)
28303000-資源已存在(Auth)
28309000-資源狀態無效(Auth)
28310000-配額不足(Auth)
28313000-資源鎖定(Auth)
28501000-未授權(Auth)
28505000-禁止存取(Auth)
28601000-系統內部錯誤(Auth)
28604000-請求過於頻繁(Auth)
28605000-功能未配置(Auth)
28802000-第三方服務錯誤(Auth)
29104000-缺少必填欄位(Member)
29201000-資料庫錯誤(Member)
29301000-資源不存在(Member)
29303000-資源已存在(Member)
29309000-資源狀態無效(Member)
29310000-配額不足(Member)
29501000-未授權(Member)
29505000-禁止存取(Member)
29601000-系統內部錯誤(Member)
29604000-請求過於頻繁(Member)
29605000-功能未配置(Member)" ) import ( + "auth.api" "common.api" "member.api" "normal.api" diff --git a/generate/api/member.api b/generate/api/member.api index 359ed42..3989e9d 100644 --- a/generate/api/member.api +++ b/generate/api/member.api @@ -75,6 +75,43 @@ type ( TOTPBackupCodesData { BackupCodes []string `json:"backup_codes"` } + + // 文件用:成功回應 envelope(HTTP 200, code=102000, message=SUCCESS) + MemberMeOKStatus { + Code int64 `json:"code"` + Message string `json:"message"` + Data MemberMeData `json:"data"` + } + + VerificationStartOKStatus { + Code int64 `json:"code"` + Message string `json:"message"` + Data VerificationStartData `json:"data"` + } + + TOTPStatusOKStatus { + Code int64 `json:"code"` + Message string `json:"message"` + Data TOTPStatusData `json:"data"` + } + + TOTPEnrollStartOKStatus { + Code int64 `json:"code"` + Message string `json:"message"` + Data TOTPEnrollStartData `json:"data"` + } + + TOTPEnrollConfirmOKStatus { + Code int64 `json:"code"` + Message string `json:"message"` + Data TOTPEnrollConfirmData `json:"data"` + } + + TOTPBackupCodesOKStatus { + Code int64 `json:"code"` + Message string `json:"message"` + Data TOTPBackupCodesData `json:"data"` + } ) @server( @@ -82,51 +119,289 @@ type ( prefix: /api/v1/members ) service gateway { - @doc "取得當前會員 profile(dev:Header X-Tenant-ID + X-UID)" + @doc "取得當前會員 profile(Bearer JWT;本機 dev 可 fallback X-Tenant-ID + X-UID)" + /* + @respdoc-200 (MemberMeOKStatus) // 成功(code=102000) + @respdoc-401 ( + 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID + ) // 未授權 + @respdoc-404 ( + 29301000: (APIErrorStatus) member 不存在 + ) // 資源不存在 + @respdoc-500 ( + 29201000: (APIErrorStatus) 資料庫錯誤 + 29601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 29605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + */ @handler getMemberMe get /me returns (MemberMeData) @doc "更新當前會員 profile" + /* + @respdoc-200 (MemberMeOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + ) // 參數錯誤 + @respdoc-401 ( + 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID + ) // 未授權 + @respdoc-404 ( + 29301000: (APIErrorStatus) member 不存在 + ) // 資源不存在 + @respdoc-500 ( + 29201000: (APIErrorStatus) 資料庫錯誤 + 29601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 29605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + */ @handler updateMemberMe patch /me (UpdateMemberMeReq) returns (MemberMeData) @doc "開始業務 email 驗證" + /* + @respdoc-200 (VerificationStartOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 29104000: (APIErrorStatus) target 必填(Member scope) + ) // 參數錯誤 + @respdoc-401 ( + 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID + ) // 未授權 + @respdoc-429 ( + 29604000: (APIErrorStatus) OTP 重送冷卻 + 29310000: (APIErrorStatus) 每日驗證上限 + ) // 請求過於頻繁 + @respdoc-500 ( + 29201000: (APIErrorStatus) 資料庫錯誤 + 29601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 29605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + */ @handler startEmailVerification post /me/verifications/email/start (VerificationStartReq) returns (VerificationStartData) @doc "確認業務 email 驗證" + /* + @respdoc-200 (EmptyOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 29104000: (APIErrorStatus) challenge_id / code 必填(Member scope) + ) // 參數錯誤 + @respdoc-401 ( + 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID + ) // 未授權 + @respdoc-403 ( + 29505000: (APIErrorStatus) OTP 無效 / challenge tenant 或 purpose 不符 + ) // 禁止存取 + @respdoc-404 ( + 29301000: (APIErrorStatus) OTP challenge / member 不存在 + ) // 資源不存在 + @respdoc-409 ( + 29309000: (APIErrorStatus) OTP challenge 鎖定 + ) // 資源狀態衝突 + @respdoc-500 ( + 29201000: (APIErrorStatus) 資料庫錯誤 + 29601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 29605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + */ @handler confirmEmailVerification post /me/verifications/email/confirm (VerificationConfirmReq) @doc "開始業務 phone 驗證" + /* + @respdoc-200 (VerificationStartOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 29104000: (APIErrorStatus) target 必填(Member scope) + ) // 參數錯誤 + @respdoc-401 ( + 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID + ) // 未授權 + @respdoc-429 ( + 29604000: (APIErrorStatus) OTP 重送冷卻 + 29310000: (APIErrorStatus) 每日驗證上限 + ) // 請求過於頻繁 + @respdoc-500 ( + 29201000: (APIErrorStatus) 資料庫錯誤 + 29601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 29605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + */ @handler startPhoneVerification post /me/verifications/phone/start (VerificationStartReq) returns (VerificationStartData) @doc "確認業務 phone 驗證" + /* + @respdoc-200 (EmptyOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 29104000: (APIErrorStatus) challenge_id / code 必填(Member scope) + ) // 參數錯誤 + @respdoc-401 ( + 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID + ) // 未授權 + @respdoc-403 ( + 29505000: (APIErrorStatus) OTP 無效 / challenge tenant 或 purpose 不符 + ) // 禁止存取 + @respdoc-404 ( + 29301000: (APIErrorStatus) OTP challenge / member 不存在 + ) // 資源不存在 + @respdoc-409 ( + 29309000: (APIErrorStatus) OTP challenge 鎖定 + ) // 資源狀態衝突 + @respdoc-500 ( + 29201000: (APIErrorStatus) 資料庫錯誤 + 29601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 29605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + */ @handler confirmPhoneVerification post /me/verifications/phone/confirm (VerificationConfirmReq) @doc "TOTP 狀態" + /* + @respdoc-200 (TOTPStatusOKStatus) // 成功(code=102000) + @respdoc-401 ( + 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID + ) // 未授權 + @respdoc-500 ( + 29201000: (APIErrorStatus) 資料庫錯誤 + 29601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 29605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + */ @handler getTOTPStatus get /me/totp returns (TOTPStatusData) @doc "開始 TOTP 綁定" + /* + @respdoc-200 (TOTPEnrollStartOKStatus) // 成功(code=102000) + @respdoc-401 ( + 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID + ) // 未授權 + @respdoc-409 ( + 29303000: (APIErrorStatus) TOTP 已綁定 + ) // 資源衝突 + @respdoc-500 ( + 29601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 29605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + */ @handler startTOTPEnroll post /me/totp/enroll-start returns (TOTPEnrollStartData) @doc "確認 TOTP 綁定" + /* + @respdoc-200 (TOTPEnrollConfirmOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 29104000: (APIErrorStatus) code 必填(Member scope) + ) // 參數錯誤 + @respdoc-401 ( + 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID + ) // 未授權 + @respdoc-403 ( + 29505000: (APIErrorStatus) TOTP 碼無效 + ) // 禁止存取 + @respdoc-404 ( + 29301000: (APIErrorStatus) enroll session / member 不存在 + ) // 資源不存在 + @respdoc-409 ( + 29303000: (APIErrorStatus) TOTP 已綁定 + ) // 資源衝突 + @respdoc-500 ( + 29601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 29605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + */ @handler confirmTOTPEnroll post /me/totp/enroll-confirm (TOTPEnrollConfirmReq) returns (TOTPEnrollConfirmData) @doc "驗證 TOTP(step-up 測試)" + /* + @respdoc-200 (EmptyOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + 29104000: (APIErrorStatus) code 必填(Member scope) + ) // 參數錯誤 + @respdoc-401 ( + 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID + ) // 未授權 + @respdoc-403 ( + 29505000: (APIErrorStatus) TOTP 碼無效或已使用 + ) // 禁止存取 + @respdoc-409 ( + 29309000: (APIErrorStatus) TOTP 未綁定 + ) // 資源狀態衝突 + @respdoc-500 ( + 29601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 29605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + */ @handler verifyTOTP post /me/totp/verify (TOTPVerifyReq) @doc "重產 TOTP 備援碼" + /* + @respdoc-200 (TOTPBackupCodesOKStatus) // 成功(code=102000) + @respdoc-401 ( + 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID + ) // 未授權 + @respdoc-404 ( + 29301000: (APIErrorStatus) member 不存在 + ) // 資源不存在 + @respdoc-409 ( + 29309000: (APIErrorStatus) TOTP 未綁定 + ) // 資源狀態衝突 + @respdoc-500 ( + 29601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 29605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + */ @handler regenerateTOTPBackupCodes post /me/totp/backup-codes returns (TOTPBackupCodesData) @doc "解除 TOTP 綁定" + /* + @respdoc-200 (EmptyOKStatus) // 成功(code=102000) + @respdoc-401 ( + 29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID + ) // 未授權 + @respdoc-404 ( + 29301000: (APIErrorStatus) member 不存在 + ) // 資源不存在 + @respdoc-500 ( + 29201000: (APIErrorStatus) 資料庫錯誤 + 29601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 + @respdoc-501 ( + 29605000: (APIErrorStatus) 功能未配置 + ) // 未實作 + */ @handler disableTOTP delete /me/totp } diff --git a/generate/api/normal.api b/generate/api/normal.api index 076ea11..1412e87 100644 --- a/generate/api/normal.api +++ b/generate/api/normal.api @@ -5,7 +5,7 @@ type PingData { Pong string `json:"pong"` } -// 文件用:成功回應 envelope(code=0, message=SUCCESS, data=PingData) +// 文件用:成功回應 envelope(HTTP 200, code=102000, message=SUCCESS) type PingOKStatus { Code int64 `json:"code"` Message string `json:"message"` @@ -24,9 +24,13 @@ service gateway { description: "確認伺服器狀態" ) /* - @respdoc-200 (PingOKStatus) // 成功 - @respdoc-400 (APIErrorStatus) // 參數錯誤(如 httpx.Parse / 驗證失敗) - @respdoc-500 (APIErrorStatus) // 系統內部錯誤 + @respdoc-200 (PingOKStatus) // 成功(code=102000) + @respdoc-400 ( + 10101000: (APIErrorStatus) 參數格式錯誤 + ) // 參數錯誤 + @respdoc-500 ( + 10601000: (APIErrorStatus) 系統內部錯誤 + ) // 內部錯誤 */ @handler ping get /health () returns (PingData) diff --git a/internal/config/config.go b/internal/config/config.go index 4c59286..67cbcf1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,8 @@ import ( "github.com/zeromicro/go-zero/rest" "gateway/internal/library/mongo" + "gateway/internal/library/zitadel" + authconfig "gateway/internal/model/auth/config" memberconfig "gateway/internal/model/member/config" notifconfig "gateway/internal/model/notification/config" ) @@ -16,6 +18,8 @@ type Config struct { rest.RestConf Mongo mongo.Conf `json:",optional"` Redis redis.RedisConf `json:",optional"` + Auth authconfig.Config `json:",optional"` + Zitadel zitadel.Conf `json:",optional"` Notification notifconfig.Config `json:",optional"` Member memberconfig.Config `json:",optional"` } diff --git a/internal/handler/auth/context.go b/internal/handler/auth/context.go new file mode 100644 index 0000000..8e7f2e9 --- /dev/null +++ b/internal/handler/auth/context.go @@ -0,0 +1,38 @@ +package auth + +import ( + "context" + "net" + "net/http" + "strings" + + logicauth "gateway/internal/logic/auth" +) + +// HandlerContext injects request audit metadata into the logic context. +// It accepts an explicit ctx (typically r.Context()) so the inheritance +// chain stays visible to static analysis (contextcheck). +func HandlerContext(ctx context.Context, r *http.Request) context.Context { + return logicauth.WithRequestMeta(ctx, logicauth.RequestMeta{ + ClientIP: clientIP(r), + UserAgent: strings.TrimSpace(r.UserAgent()), + }) +} + +func clientIP(r *http.Request) string { + if r == nil { + return "" + } + if xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xff != "" { + parts := strings.Split(xff, ",") + return strings.TrimSpace(parts[0]) + } + if xri := strings.TrimSpace(r.Header.Get("X-Real-IP")); xri != "" { + return xri + } + host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) + if err == nil { + return host + } + return strings.TrimSpace(r.RemoteAddr) +} diff --git a/internal/handler/auth/login_handler.go b/internal/handler/auth/login_handler.go new file mode 100644 index 0000000..20d4aa1 --- /dev/null +++ b/internal/handler/auth/login_handler.go @@ -0,0 +1,31 @@ +// Code scaffolded by goctl. Safe to edit. +package auth + +import ( + "net/http" + + logicauth "gateway/internal/logic/auth" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func LoginHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.LoginReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := logicauth.NewLoginLogic(HandlerContext(r.Context(), r), svcCtx) + data, err := l.Login(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/auth/login_social_callback_handler.go b/internal/handler/auth/login_social_callback_handler.go new file mode 100644 index 0000000..2807c5e --- /dev/null +++ b/internal/handler/auth/login_social_callback_handler.go @@ -0,0 +1,31 @@ +// Code scaffolded by goctl. Safe to edit. +package auth + +import ( + "net/http" + + logicauth "gateway/internal/logic/auth" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func LoginSocialCallbackHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.LoginSocialCallbackReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := logicauth.NewLoginSocialCallbackLogic(HandlerContext(r.Context(), r), svcCtx) + data, err := l.LoginSocialCallback(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/auth/login_social_start_handler.go b/internal/handler/auth/login_social_start_handler.go new file mode 100644 index 0000000..00a8b6d --- /dev/null +++ b/internal/handler/auth/login_social_start_handler.go @@ -0,0 +1,31 @@ +// Code scaffolded by goctl. Safe to edit. +package auth + +import ( + "net/http" + + logicauth "gateway/internal/logic/auth" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func LoginSocialStartHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.LoginSocialStartReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := logicauth.NewLoginSocialStartLogic(HandlerContext(r.Context(), r), svcCtx) + data, err := l.LoginSocialStart(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/auth/logout_handler.go b/internal/handler/auth/logout_handler.go new file mode 100644 index 0000000..9873694 --- /dev/null +++ b/internal/handler/auth/logout_handler.go @@ -0,0 +1,28 @@ +// Code scaffolded by goctl. Safe to edit. +package auth + +import ( + "net/http" + "strings" + + logicauth "gateway/internal/logic/auth" + "gateway/internal/response" + "gateway/internal/svc" +) + +func LogoutHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := logicauth.WithBearerAccessToken(HandlerContext(r.Context(), r), bearerFromHeader(r.Header.Get("Authorization"))) + l := logicauth.NewLogoutLogic(ctx, svcCtx) + data, err := l.Logout() + response.Write(ctx, w, data, err) + } +} + +func bearerFromHeader(header string) string { + const prefix = "Bearer " + if !strings.HasPrefix(header, prefix) { + return "" + } + return strings.TrimSpace(strings.TrimPrefix(header, prefix)) +} diff --git a/internal/handler/auth/register_confirm_handler.go b/internal/handler/auth/register_confirm_handler.go new file mode 100644 index 0000000..4dc932d --- /dev/null +++ b/internal/handler/auth/register_confirm_handler.go @@ -0,0 +1,34 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package auth + +import ( + "net/http" + + logicauth "gateway/internal/logic/auth" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +// 確認 registration OTP 並核發 JWT +func RegisterConfirmHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.RegisterConfirmReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := logicauth.NewRegisterConfirmLogic(HandlerContext(r.Context(), r), svcCtx) + data, err := l.RegisterConfirm(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/auth/register_handler.go b/internal/handler/auth/register_handler.go new file mode 100644 index 0000000..d1e56b7 --- /dev/null +++ b/internal/handler/auth/register_handler.go @@ -0,0 +1,34 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package auth + +import ( + "net/http" + + logicauth "gateway/internal/logic/auth" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +// Email 註冊(建立 ZITADEL + member,寄 registration OTP) +func RegisterHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.RegisterReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := logicauth.NewRegisterLogic(HandlerContext(r.Context(), r), svcCtx) + data, err := l.Register(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/auth/register_resend_handler.go b/internal/handler/auth/register_resend_handler.go new file mode 100644 index 0000000..01268d8 --- /dev/null +++ b/internal/handler/auth/register_resend_handler.go @@ -0,0 +1,34 @@ +// Code scaffolded by goctl. Safe to edit. +// goctl 1.10.1 + +package auth + +import ( + "net/http" + + logicauth "gateway/internal/logic/auth" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +// 重寄 registration OTP +func RegisterResendHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.RegisterResendReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := logicauth.NewRegisterResendLogic(HandlerContext(r.Context(), r), svcCtx) + data, err := l.RegisterResend(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/auth/register_social_callback_handler.go b/internal/handler/auth/register_social_callback_handler.go new file mode 100644 index 0000000..4a2650d --- /dev/null +++ b/internal/handler/auth/register_social_callback_handler.go @@ -0,0 +1,31 @@ +// Code scaffolded by goctl. Safe to edit. +package auth + +import ( + "net/http" + + logicauth "gateway/internal/logic/auth" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func RegisterSocialCallbackHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.RegisterSocialCallbackReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := logicauth.NewRegisterSocialCallbackLogic(HandlerContext(r.Context(), r), svcCtx) + data, err := l.RegisterSocialCallback(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/auth/register_social_start_handler.go b/internal/handler/auth/register_social_start_handler.go new file mode 100644 index 0000000..6b60802 --- /dev/null +++ b/internal/handler/auth/register_social_start_handler.go @@ -0,0 +1,31 @@ +// Code scaffolded by goctl. Safe to edit. +package auth + +import ( + "net/http" + + logicauth "gateway/internal/logic/auth" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func RegisterSocialStartHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.RegisterSocialStartReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := logicauth.NewRegisterSocialStartLogic(HandlerContext(r.Context(), r), svcCtx) + data, err := l.RegisterSocialStart(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/auth/token_exchange_handler.go b/internal/handler/auth/token_exchange_handler.go new file mode 100644 index 0000000..d43fa17 --- /dev/null +++ b/internal/handler/auth/token_exchange_handler.go @@ -0,0 +1,31 @@ +// Code scaffolded by goctl. Safe to edit. +package auth + +import ( + "net/http" + + logicauth "gateway/internal/logic/auth" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func TokenExchangeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.TokenExchangeReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := logicauth.NewTokenExchangeLogic(r.Context(), svcCtx) + data, err := l.TokenExchange(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/auth/token_refresh_handler.go b/internal/handler/auth/token_refresh_handler.go new file mode 100644 index 0000000..8c06dd5 --- /dev/null +++ b/internal/handler/auth/token_refresh_handler.go @@ -0,0 +1,31 @@ +// Code scaffolded by goctl. Safe to edit. +package auth + +import ( + "net/http" + + logicauth "gateway/internal/logic/auth" + "gateway/internal/response" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +func TokenRefreshHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.TokenRefreshReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + if err := svcCtx.Validator.ValidateAll(&req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := logicauth.NewTokenRefreshLogic(r.Context(), svcCtx) + data, err := l.TokenRefresh(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/internal/handler/member/context.go b/internal/handler/member/context.go index 07b11c7..61ae2c4 100644 --- a/internal/handler/member/context.go +++ b/internal/handler/member/context.go @@ -8,5 +8,8 @@ import ( ) func actorContext(ctx context.Context, r *http.Request) context.Context { + if _, err := logic.ActorFromContext(ctx); err == nil { + return ctx + } return logic.WithActor(ctx, r.Header.Get("X-Tenant-ID"), r.Header.Get("X-UID")) } diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 6f07a8f..b41c510 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -7,6 +7,7 @@ import ( "net/http" "time" + auth "gateway/internal/handler/auth" member "gateway/internal/handler/member" normal "gateway/internal/handler/normal" "gateway/internal/svc" @@ -18,7 +19,79 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { server.AddRoutes( []rest.Route{ { - // 取得當前會員 profile(dev:Header X-Tenant-ID + X-UID) + // Email + 密碼登入(ZITADEL ROPG → CloudEP JWT) + Method: http.MethodPost, + Path: "/login", + Handler: auth.LoginHandler(serverCtx), + }, + { + // Social 登入 OAuth callback + Method: http.MethodGet, + Path: "/login/social/callback", + Handler: auth.LoginSocialCallbackHandler(serverCtx), + }, + { + // Social 登入:建立 login session 並回傳 OAuth URL(不含 invite) + Method: http.MethodPost, + Path: "/login/social/start", + Handler: auth.LoginSocialStartHandler(serverCtx), + }, + { + // Email 註冊(建立 ZITADEL + member,寄 registration OTP) + Method: http.MethodPost, + Path: "/register", + Handler: auth.RegisterHandler(serverCtx), + }, + { + // 確認 registration OTP 並核發 JWT + Method: http.MethodPost, + Path: "/register/confirm", + Handler: auth.RegisterConfirmHandler(serverCtx), + }, + { + // 重寄 registration OTP + Method: http.MethodPost, + Path: "/register/resend", + Handler: auth.RegisterResendHandler(serverCtx), + }, + { + // Social 註冊 OAuth callback + Method: http.MethodGet, + Path: "/register/social/callback", + Handler: auth.RegisterSocialCallbackHandler(serverCtx), + }, + { + // Social 註冊:建立 session 並回傳 OAuth URL + Method: http.MethodPost, + Path: "/register/social/start", + Handler: auth.RegisterSocialStartHandler(serverCtx), + }, + { + // 登出(撤銷 access JWT 及配對 refresh JWT) + Method: http.MethodPost, + Path: "/logout", + Handler: auth.LogoutHandler(serverCtx), + }, + { + // ZITADEL id_token 換 CloudEP JWT(企業 SSO) + Method: http.MethodPost, + Path: "/token/exchange", + Handler: auth.TokenExchangeHandler(serverCtx), + }, + { + // 以 refresh_token 換發新的 access/refresh token + Method: http.MethodPost, + Path: "/token/refresh", + Handler: auth.TokenRefreshHandler(serverCtx), + }, + }, + rest.WithPrefix("/api/v1/auth"), + ) + + server.AddRoutes( + []rest.Route{ + { + // 取得當前會員 profile(Bearer JWT;本機 dev 可 fallback X-Tenant-ID + X-UID) Method: http.MethodGet, Path: "/me", Handler: member.GetMemberMeHandler(serverCtx), diff --git a/internal/library/errors/README.md b/internal/library/errors/README.md index d9c5154..1621d45 100644 --- a/internal/library/errors/README.md +++ b/internal/library/errors/README.md @@ -280,7 +280,7 @@ attrs := errlog.Attrs(e) ## Scope 常數 -定義於 `code/types.go`,例如:`Facade(10)`、`LocalAPI(11)`、`GearAuditLog(12)` … `GearAssetMgr(27)`。 +定義於 `code/types.go`,例如:`Facade(10)`(handler parse)、`Auth(28)`、`Member(29)`、`Notification(30)` … `GearAssetMgr(27)`。 新增服務時在該檔登記,避免號段衝突。 --- diff --git a/internal/library/errors/code/types.go b/internal/library/errors/code/types.go index 8bf1ea6..71f438c 100644 --- a/internal/library/errors/code/types.go +++ b/internal/library/errors/code/types.go @@ -135,4 +135,9 @@ const ( PluginVcenterHSM Scope = 25 PluginMGR Scope = 26 GearAssetMgr Scope = 27 + + // Gateway domain module scopes (logic + usecase). + Auth Scope = 28 + Member Scope = 29 + Notification Scope = 30 ) diff --git a/internal/library/zitadel/client.go b/internal/library/zitadel/client.go new file mode 100644 index 0000000..ecb968b --- /dev/null +++ b/internal/library/zitadel/client.go @@ -0,0 +1,265 @@ +package zitadel + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// Client calls ZITADEL Management API v2 and OAuth token endpoints. +type Client struct { + conf Conf + http *http.Client + apiBase string + issuer string + jwks *jwksCache +} + +// NewClient constructs a Client. Returns (nil, nil) when Issuer is empty. +func NewClient(conf Conf) (*Client, error) { + conf = conf.Defaults() + if conf.Issuer == "" { + return nil, nil + } + apiBase := strings.TrimRight(conf.APIBase, "/") + issuer := strings.TrimRight(conf.Issuer, "/") + if apiBase == "" { + apiBase = issuer + } + return &Client{ + conf: conf, + apiBase: apiBase, + issuer: issuer, + http: &http.Client{ + Timeout: conf.timeout(), + }, + }, nil +} + +func (c *Client) postToken(ctx context.Context, form url.Values) (*TokenResult, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.issuer+"/oauth/v2/token", strings.NewReader(form.Encode())) + if err != nil { + return nil, fmt.Errorf("zitadel: token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("zitadel: token request: %w", err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("zitadel: read token response: %w", err) + } + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest { + return nil, ErrInvalidCredentials + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("zitadel: token request: status %d: %s", resp.StatusCode, truncateBody(raw)) + } + + var tok struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` + } + if err := json.Unmarshal(raw, &tok); err != nil { + return nil, fmt.Errorf("zitadel: decode token response: %w", err) + } + if tok.AccessToken == "" { + return nil, fmt.Errorf("zitadel: empty access_token") + } + return &TokenResult{ + AccessToken: tok.AccessToken, + IDToken: tok.IDToken, + ExpiresIn: tok.ExpiresIn, + TokenType: tok.TokenType, + }, nil +} + +// CreateHumanUserRequest creates a human user with email/password profile. +type CreateHumanUserRequest struct { + OrgID string + Email string + Password string + DisplayName string + Language string + EmailVerified bool +} + +// CreateHumanUserResult is the created ZITADEL user id (sub). +type CreateHumanUserResult struct { + UserID string +} + +// CreateHumanUser registers a human user via POST /v2/users/human. +func (c *Client) CreateHumanUser(ctx context.Context, req CreateHumanUserRequest) (*CreateHumanUserResult, error) { + if c == nil { + return nil, ErrNotConfigured + } + if c.conf.ServiceUserToken == "" { + return nil, ErrNotConfigured + } + orgID := req.OrgID + if orgID == "" { + orgID = c.conf.DefaultOrgID + } + given, family := splitDisplayName(req.DisplayName, req.Email) + profile := map[string]any{ + "givenName": given, + "familyName": family, + } + if req.DisplayName != "" { + profile["displayName"] = req.DisplayName + } + if req.Language != "" { + profile["preferredLanguage"] = req.Language + } + body := map[string]any{ + "username": req.Email, + "profile": profile, + "email": map[string]any{ + "email": req.Email, + "isVerified": req.EmailVerified, + }, + "password": map[string]any{ + "password": req.Password, + "changeRequired": false, + }, + } + if orgID != "" { + body["organizationId"] = orgID + } + + var out struct { + UserID string `json:"userId"` + } + if err := c.doJSON(ctx, http.MethodPost, c.apiBase+"/v2/users/human", c.serviceAuth(), body, http.StatusOK, &out); err != nil { + return nil, err + } + if out.UserID == "" { + return nil, fmt.Errorf("zitadel: create user: empty userId in response") + } + return &CreateHumanUserResult{UserID: out.UserID}, nil +} + +// DeactivateUser disables login for the user via POST /v2/users/{id}/deactivate. +func (c *Client) DeactivateUser(ctx context.Context, userID string) error { + if c == nil { + return ErrNotConfigured + } + if userID == "" { + return fmt.Errorf("zitadel: user id is required") + } + return c.doJSON(ctx, http.MethodPost, c.apiBase+"/v2/users/"+url.PathEscape(userID)+"/deactivate", c.serviceAuth(), map[string]any{}, http.StatusOK, nil) +} + +// TokenResult holds OAuth tokens from a successful password grant. +type TokenResult struct { + AccessToken string + IDToken string + ExpiresIn int + TokenType string +} + +// VerifyPassword checks credentials using the OAuth2 resource-owner password grant. +func (c *Client) VerifyPassword(ctx context.Context, username, password string) (*TokenResult, error) { + if c == nil { + return nil, ErrNotConfigured + } + if c.conf.OAuthClientID == "" || c.conf.OAuthClientSecret == "" { + return nil, fmt.Errorf("zitadel: oauth client credentials are required for password verification") + } + form := url.Values{} + form.Set("grant_type", "password") + form.Set("client_id", c.conf.OAuthClientID) + form.Set("client_secret", c.conf.OAuthClientSecret) + form.Set("username", username) + form.Set("password", password) + form.Set("scope", "openid profile email") + + return c.postToken(ctx, form) +} + +func (c *Client) serviceAuth() string { + return "Bearer " + c.conf.ServiceUserToken +} + +func (c *Client) doJSON(ctx context.Context, method, endpoint, auth string, body any, wantStatus int, out any) error { + var r io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("zitadel: marshal request: %w", err) + } + r = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, r) + if err != nil { + return fmt.Errorf("zitadel: new request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + if auth != "" { + req.Header.Set("Authorization", auth) + } + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("zitadel: %s %s: %w", method, endpoint, err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("zitadel: read response body: %w", err) + } + if resp.StatusCode == http.StatusConflict { + return ErrUserAlreadyExists + } + if resp.StatusCode != wantStatus { + return fmt.Errorf("zitadel: %s %s: status %d: %s", method, endpoint, resp.StatusCode, truncateBody(raw)) + } + if out != nil && len(raw) > 0 { + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("zitadel: decode response: %w", err) + } + } + return nil +} + +func splitDisplayName(displayName, email string) (given, family string) { + displayName = strings.TrimSpace(displayName) + if displayName == "" { + local := email + if i := strings.Index(email, "@"); i > 0 { + local = email[:i] + } + return local, "-" + } + parts := strings.Fields(displayName) + if len(parts) == 1 { + return parts[0], "-" + } + return parts[0], strings.Join(parts[1:], " ") +} + +func truncateBody(b []byte) string { + const maxBodyLen = 512 + s := strings.TrimSpace(string(b)) + if len(s) > maxBodyLen { + return s[:maxBodyLen] + "..." + } + return s +} diff --git a/internal/library/zitadel/client_test.go b/internal/library/zitadel/client_test.go new file mode 100644 index 0000000..4b11e7f --- /dev/null +++ b/internal/library/zitadel/client_test.go @@ -0,0 +1,141 @@ +package zitadel_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "gateway/internal/library/zitadel" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateHumanUser(t *testing.T) { + t.Parallel() + var gotAuth string + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v2/users/human": + gotAuth = r.Header.Get("Authorization") + assert.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody)) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"userId":"zit-123"}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + c, err := zitadel.NewClient(zitadel.Conf{ + Issuer: srv.URL, + ServiceUserToken: "pat-test", + DefaultOrgID: "org-1", + }) + require.NoError(t, err) + + res, err := c.CreateHumanUser(context.Background(), zitadel.CreateHumanUserRequest{ + Email: "alice@example.com", + Password: "Secret123!", + DisplayName: "Alice Smith", + Language: "zh-tw", + }) + require.NoError(t, err) + require.Equal(t, "zit-123", res.UserID) + require.Equal(t, "Bearer pat-test", gotAuth) + require.Equal(t, "alice@example.com", gotBody["username"]) + require.Equal(t, "org-1", gotBody["organizationId"]) +} + +func TestCreateHumanUserConflict(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "exists", http.StatusConflict) + })) + t.Cleanup(srv.Close) + + c, err := zitadel.NewClient(zitadel.Conf{Issuer: srv.URL, ServiceUserToken: testPAT}) + require.NoError(t, err) + + _, err = c.CreateHumanUser(context.Background(), zitadel.CreateHumanUserRequest{ + Email: "dup@example.com", + Password: "x", + }) + require.ErrorIs(t, err, zitadel.ErrUserAlreadyExists) +} + +func TestDeactivateUser(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/v2/users/u-99/deactivate", r.URL.Path) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"details":{}}`)) + })) + t.Cleanup(srv.Close) + + c, err := zitadel.NewClient(zitadel.Conf{Issuer: srv.URL, ServiceUserToken: testPAT}) + require.NoError(t, err) + require.NoError(t, c.DeactivateUser(context.Background(), "u-99")) +} + +func TestVerifyPassword(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/oauth/v2/token", r.URL.Path) + body, _ := io.ReadAll(r.Body) + vals := parseForm(string(body)) + assert.Equal(t, "password", vals["grant_type"]) + assert.Equal(t, testClientID, vals["client_id"]) + assert.Equal(t, "alice@example.com", vals["username"]) + if vals["password"] != "ok" { + http.Error(w, "invalid", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"at","id_token":"id","expires_in":3600,"token_type":"Bearer"}`)) + })) + t.Cleanup(srv.Close) + + c, err := zitadel.NewClient(zitadel.Conf{ + Issuer: srv.URL, + ServiceUserToken: testPAT, + OAuthClientID: testClientID, + OAuthClientSecret: testSecret, + }) + require.NoError(t, err) + + tok, err := c.VerifyPassword(context.Background(), "alice@example.com", "ok") + require.NoError(t, err) + require.Equal(t, "at", tok.AccessToken) + require.Equal(t, "id", tok.IDToken) + + _, err = c.VerifyPassword(context.Background(), "alice@example.com", "bad") + require.ErrorIs(t, err, zitadel.ErrInvalidCredentials) +} + +func TestNewClientDisabledWhenIssuerEmpty(t *testing.T) { + t.Parallel() + c, err := zitadel.NewClient(zitadel.Conf{}) + require.NoError(t, err) + require.Nil(t, c) +} + +func parseForm(body string) map[string]string { + vals, err := url.ParseQuery(body) + if err != nil { + return map[string]string{} + } + out := make(map[string]string, len(vals)) + for k, v := range vals { + if len(v) > 0 { + out[k] = v[0] + } + } + return out +} diff --git a/internal/library/zitadel/config.go b/internal/library/zitadel/config.go new file mode 100644 index 0000000..c1f5683 --- /dev/null +++ b/internal/library/zitadel/config.go @@ -0,0 +1,47 @@ +package zitadel + +import "time" + +// Conf configures the ZITADEL HTTP client. +type Conf struct { + // Issuer is the ZITADEL instance URL (e.g. https://zitadel.example.com). + Issuer string `json:",optional"` + // APIBase overrides the base URL for Management API v2 calls; defaults to Issuer. + APIBase string `json:",optional"` + // ServiceUserToken is a PAT or service-account token for Management API (CreateUser, Deactivate). + ServiceUserToken string `json:",optional,env=ZITADEL_SERVICE_TOKEN"` + // DefaultOrgID is used when CreateHumanUserRequest.OrgID is empty. + DefaultOrgID string `json:",optional"` + // OAuthClientID and OAuthClientSecret identify the Gateway OIDC application (password grant / social). + OAuthClientID string `json:",optional"` + OAuthClientSecret string `json:",optional,env=ZITADEL_OAUTH_CLIENT_SECRET"` + // Google OAuth app credentials (register/social flow, PR 6). + GoogleClientID string `json:",optional"` + GoogleClientSecret string `json:",optional,env=ZITADEL_GOOGLE_CLIENT_SECRET"` + // GoogleIdPID is the ZITADEL external IdP id for Google (optional idp_id authorize hint). + GoogleIdPID string `json:",optional"` + // JWKSUrl overrides OIDC JWKS endpoint; defaults to {Issuer}/oauth/v2/keys. + JWKSUrl string `json:",optional"` + TimeoutSeconds int `json:",optional"` +} + +// Defaults returns zero-value-safe defaults. +func (c Conf) Defaults() Conf { + if c.APIBase == "" { + c.APIBase = c.Issuer + } + if c.TimeoutSeconds <= 0 { + c.TimeoutSeconds = 15 + } + return c +} + +func (c Conf) timeout() time.Duration { + return time.Duration(c.Defaults().TimeoutSeconds) * time.Second +} + +// Enabled reports whether ZITADEL integration is configured. +func (c Conf) Enabled() bool { + c = c.Defaults() + return c.Issuer != "" && c.ServiceUserToken != "" +} diff --git a/internal/library/zitadel/errors.go b/internal/library/zitadel/errors.go new file mode 100644 index 0000000..28a412f --- /dev/null +++ b/internal/library/zitadel/errors.go @@ -0,0 +1,10 @@ +package zitadel + +import "errors" + +var ( + ErrNotConfigured = errors.New("zitadel: not configured") + ErrUserAlreadyExists = errors.New("zitadel: user already exists") + ErrInvalidCredentials = errors.New("zitadel: invalid credentials") + ErrInvalidIDToken = errors.New("zitadel: invalid id_token") +) diff --git a/internal/library/zitadel/jwks.go b/internal/library/zitadel/jwks.go new file mode 100644 index 0000000..86b1f5f --- /dev/null +++ b/internal/library/zitadel/jwks.go @@ -0,0 +1,250 @@ +package zitadel + +import ( + "context" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "sync" + "time" + + "github.com/golang-jwt/jwt/v4" +) + +type jwksCache struct { + mu sync.RWMutex + fetchedAt time.Time + keys map[string]*rsa.PublicKey +} + +func (c *Client) jwksURL() string { + if c.conf.JWKSUrl != "" { + return c.conf.JWKSUrl + } + return c.issuer + "/oauth/v2/keys" +} + +func (c *Client) VerifyIDToken(ctx context.Context, idToken string) (*IDTokenClaims, error) { + if c == nil { + return nil, ErrNotConfigured + } + if idToken == "" { + return nil, fmt.Errorf("zitadel: id_token is required") + } + if c.conf.OAuthClientID == "" { + return nil, fmt.Errorf("zitadel: oauth client id is required for id_token verification") + } + + parser := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()})) + token, err := parser.Parse(idToken, func(t *jwt.Token) (any, error) { + kid, ok := t.Header["kid"].(string) + if !ok || kid == "" { + return nil, fmt.Errorf("zitadel: id_token missing kid") + } + return c.publicKeyForKID(ctx, kid) + }) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidIDToken, err) + } + if !token.Valid { + return nil, ErrInvalidIDToken + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, ErrInvalidIDToken + } + if err := c.validateIDTokenClaims(claims); err != nil { + return nil, err + } + + out := &IDTokenClaims{ + Sub: stringClaim(claims, "sub"), + Email: stringClaim(claims, "email"), + EmailVerified: boolClaim(claims, "email_verified"), + Name: stringClaim(claims, "name"), + Locale: stringClaim(claims, "locale"), + } + if out.Sub == "" { + return nil, ErrInvalidIDToken + } + return out, nil +} + +func (c *Client) validateIDTokenClaims(claims jwt.MapClaims) error { + iss := stringClaim(claims, "iss") + if iss != c.issuer && iss != c.issuer+"/" { + return fmt.Errorf("%w: unexpected iss", ErrInvalidIDToken) + } + if !audienceContains(claims["aud"], c.conf.OAuthClientID) { + return fmt.Errorf("%w: unexpected aud", ErrInvalidIDToken) + } + expRaw, ok := claims["exp"] + if !ok { + return fmt.Errorf("%w: missing exp", ErrInvalidIDToken) + } + var expUnix int64 + switch t := expRaw.(type) { + case float64: + expUnix = int64(t) + case json.Number: + v, err := t.Int64() + if err != nil { + return fmt.Errorf("%w: invalid exp", ErrInvalidIDToken) + } + expUnix = v + default: + return fmt.Errorf("%w: invalid exp", ErrInvalidIDToken) + } + if time.Now().UTC().Unix() >= expUnix { + return fmt.Errorf("%w: token expired", ErrInvalidIDToken) + } + return nil +} + +func (c *Client) publicKeyForKID(ctx context.Context, kid string) (*rsa.PublicKey, error) { + if c.jwks == nil { + c.jwks = &jwksCache{keys: make(map[string]*rsa.PublicKey)} + } + c.jwks.mu.RLock() + if key, ok := c.jwks.keys[kid]; ok && time.Since(c.jwks.fetchedAt) < 5*time.Minute { + c.jwks.mu.RUnlock() + return key, nil + } + c.jwks.mu.RUnlock() + + c.jwks.mu.Lock() + defer c.jwks.mu.Unlock() + if key, ok := c.jwks.keys[kid]; ok && time.Since(c.jwks.fetchedAt) < 5*time.Minute { + return key, nil + } + if err := c.refreshJWKS(ctx); err != nil { + return nil, err + } + key, ok := c.jwks.keys[kid] + if !ok { + return nil, fmt.Errorf("zitadel: jwks kid not found: %s", kid) + } + return key, nil +} + +func (c *Client) refreshJWKS(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.jwksURL(), http.NoBody) + if err != nil { + return fmt.Errorf("zitadel: jwks request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("zitadel: jwks request: %w", err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("zitadel: read jwks body: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("zitadel: jwks request: status %d: %s", resp.StatusCode, truncateBody(raw)) + } + + var payload struct { + Keys []struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` + } `json:"keys"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + return fmt.Errorf("zitadel: decode jwks: %w", err) + } + + keys := make(map[string]*rsa.PublicKey, len(payload.Keys)) + for _, k := range payload.Keys { + if k.Kty != "RSA" || k.Kid == "" || k.N == "" || k.E == "" { + continue + } + pub, err := rsaPublicKeyFromModExp(k.N, k.E) + if err != nil { + return err + } + keys[k.Kid] = pub + } + if len(keys) == 0 { + return fmt.Errorf("zitadel: jwks contains no usable rsa keys") + } + c.jwks.keys = keys + c.jwks.fetchedAt = time.Now().UTC() + return nil +} + +func rsaPublicKeyFromModExp(nB64, eB64 string) (*rsa.PublicKey, error) { + nBytes, err := base64.RawURLEncoding.DecodeString(nB64) + if err != nil { + return nil, fmt.Errorf("zitadel: decode jwks n: %w", err) + } + eBytes, err := base64.RawURLEncoding.DecodeString(eB64) + if err != nil { + return nil, fmt.Errorf("zitadel: decode jwks e: %w", err) + } + n := new(big.Int).SetBytes(nBytes) + e := new(big.Int).SetBytes(eBytes).Int64() + if e <= 0 || e > int64(^uint(0)>>1) { + return nil, fmt.Errorf("zitadel: invalid jwks exponent") + } + return &rsa.PublicKey{N: n, E: int(e)}, nil +} + +func stringClaim(claims jwt.MapClaims, key string) string { + v, ok := claims[key] + if !ok || v == nil { + return "" + } + switch t := v.(type) { + case string: + return t + default: + return fmt.Sprint(t) + } +} + +func boolClaim(claims jwt.MapClaims, key string) bool { + v, ok := claims[key] + if !ok || v == nil { + return false + } + switch t := v.(type) { + case bool: + return t + case string: + return t == "true" + default: + return false + } +} + +func audienceContains(aud any, want string) bool { + switch t := aud.(type) { + case string: + return t == want + case []any: + for _, item := range t { + if s, ok := item.(string); ok && s == want { + return true + } + } + case []string: + for _, s := range t { + if s == want { + return true + } + } + } + return false +} diff --git a/internal/library/zitadel/jwks_test.go b/internal/library/zitadel/jwks_test.go new file mode 100644 index 0000000..ad87fb9 --- /dev/null +++ b/internal/library/zitadel/jwks_test.go @@ -0,0 +1,90 @@ +package zitadel_test + +import ( + "context" + "testing" + "time" + + "gateway/internal/library/zitadel" + + "github.com/stretchr/testify/require" +) + +func TestVerifyIDToken(t *testing.T) { + t.Parallel() + + fix := newJWKSFixture(t) + now := time.Now().UTC() + raw := fix.signIDToken(t, fix.validClaims(now)) + + claims := fix.verify(t, raw) + require.Equal(t, "zitadel-sub-1", claims.Sub) + require.Equal(t, "user@example.com", claims.Email) + require.True(t, claims.EmailVerified) + + _, err := fix.Client.VerifyIDToken(context.Background(), raw[:len(raw)-2]+"xx") + require.ErrorIs(t, err, zitadel.ErrInvalidIDToken) +} + +func TestVerifyIDTokenExpired(t *testing.T) { + t.Parallel() + + fix := newJWKSFixture(t) + now := time.Now().UTC() + claims := fix.validClaims(now) + claims["exp"] = now.Add(-time.Hour).Unix() + raw := fix.signIDToken(t, claims) + + _, err := fix.Client.VerifyIDToken(context.Background(), raw) + require.ErrorIs(t, err, zitadel.ErrInvalidIDToken) + require.Contains(t, err.Error(), "expired") +} + +func TestVerifyIDTokenWrongIssuer(t *testing.T) { + t.Parallel() + + fix := newJWKSFixture(t) + now := time.Now().UTC() + claims := fix.validClaims(now) + claims["iss"] = "https://evil.example.com" + raw := fix.signIDToken(t, claims) + + _, err := fix.Client.VerifyIDToken(context.Background(), raw) + require.ErrorIs(t, err, zitadel.ErrInvalidIDToken) + require.Contains(t, err.Error(), "iss") +} + +func TestVerifyIDTokenWrongAudience(t *testing.T) { + t.Parallel() + + fix := newJWKSFixture(t) + now := time.Now().UTC() + claims := fix.validClaims(now) + claims["aud"] = "other-client" + raw := fix.signIDToken(t, claims) + + _, err := fix.Client.VerifyIDToken(context.Background(), raw) + require.ErrorIs(t, err, zitadel.ErrInvalidIDToken) + require.Contains(t, err.Error(), "aud") +} + +func TestVerifyIDTokenAcceptsIssuerWithTrailingSlash(t *testing.T) { + t.Parallel() + + fix := newJWKSFixture(t) + now := time.Now().UTC() + claims := fix.validClaims(now) + claims["iss"] = fix.Issuer + "/" + raw := fix.signIDToken(t, claims) + + claimsOut := fix.verify(t, raw) + require.Equal(t, "zitadel-sub-1", claimsOut.Sub) +} + +func TestVerifyIDTokenNotConfigured(t *testing.T) { + t.Parallel() + + var client *zitadel.Client + _, err := client.VerifyIDToken(context.Background(), "any.token.here") + require.ErrorIs(t, err, zitadel.ErrNotConfigured) +} diff --git a/internal/library/zitadel/oauth.go b/internal/library/zitadel/oauth.go new file mode 100644 index 0000000..f40039b --- /dev/null +++ b/internal/library/zitadel/oauth.go @@ -0,0 +1,152 @@ +package zitadel + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// IDTokenClaims holds selected OIDC id_token claims used by registration/login flows. +type IDTokenClaims struct { + Sub string + Email string + EmailVerified bool + Name string + Locale string +} + +// AuthorizeURL builds the ZITADEL OIDC authorization URL for social registration/login. +func (c *Client) AuthorizeURL(redirectURI, state, provider string) (string, error) { + if c == nil { + return "", ErrNotConfigured + } + if c.conf.OAuthClientID == "" { + return "", fmt.Errorf("zitadel: oauth client id is required for authorize url") + } + if redirectURI == "" || state == "" { + return "", fmt.Errorf("zitadel: redirect_uri and state are required") + } + q := url.Values{} + q.Set("client_id", c.conf.OAuthClientID) + q.Set("redirect_uri", redirectURI) + q.Set("response_type", "code") + q.Set("scope", "openid profile email") + q.Set("state", state) + if provider == "google" && c.conf.GoogleIdPID != "" { + q.Set("idp_id", c.conf.GoogleIdPID) + } + return c.issuer + "/oauth/v2/authorize?" + q.Encode(), nil +} + +// ExchangeAuthorizationCode exchanges an OAuth authorization code for tokens. +func (c *Client) ExchangeAuthorizationCode(ctx context.Context, code, redirectURI string) (*TokenResult, error) { + if c == nil { + return nil, ErrNotConfigured + } + if code == "" || redirectURI == "" { + return nil, fmt.Errorf("zitadel: code and redirect_uri are required") + } + if c.conf.OAuthClientID == "" || c.conf.OAuthClientSecret == "" { + return nil, fmt.Errorf("zitadel: oauth client credentials are required for code exchange") + } + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("client_id", c.conf.OAuthClientID) + form.Set("client_secret", c.conf.OAuthClientSecret) + form.Set("code", code) + form.Set("redirect_uri", redirectURI) + return c.postToken(ctx, form) +} + +// FetchUserInfo loads OIDC userinfo using an access token. +func (c *Client) FetchUserInfo(ctx context.Context, accessToken string) (*IDTokenClaims, error) { + if c == nil { + return nil, ErrNotConfigured + } + if accessToken == "" { + return nil, fmt.Errorf("zitadel: access token is required") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.issuer+"/oidc/v1/userinfo", http.NoBody) + if err != nil { + return nil, fmt.Errorf("zitadel: userinfo request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("zitadel: userinfo request: %w", err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("zitadel: read userinfo response: %w", err) + } + if resp.StatusCode == http.StatusUnauthorized { + return nil, ErrInvalidCredentials + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("zitadel: userinfo request: status %d: %s", resp.StatusCode, truncateBody(raw)) + } + var info struct { + Sub string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Locale string `json:"locale"` + } + if err := json.Unmarshal(raw, &info); err != nil { + return nil, fmt.Errorf("zitadel: decode userinfo response: %w", err) + } + if info.Sub == "" { + return nil, errors.New("zitadel: userinfo missing sub") + } + return &IDTokenClaims{ + Sub: info.Sub, + Email: info.Email, + EmailVerified: info.EmailVerified, + Name: info.Name, + Locale: info.Locale, + }, nil +} + +func ParseIDTokenClaims(idToken string) (*IDTokenClaims, error) { + if idToken == "" { + return nil, errors.New("zitadel: id_token is empty") + } + parts := strings.Split(idToken, ".") + if len(parts) < 2 { + return nil, errors.New("zitadel: malformed id_token") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("zitadel: decode id_token payload: %w", err) + } + var raw struct { + Sub string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + Locale string `json:"locale"` + } + if err := json.Unmarshal(payload, &raw); err != nil { + return nil, fmt.Errorf("zitadel: unmarshal id_token payload: %w", err) + } + if raw.Sub == "" { + return nil, errors.New("zitadel: id_token missing sub") + } + return &IDTokenClaims{ + Sub: raw.Sub, + Email: raw.Email, + EmailVerified: raw.EmailVerified, + Name: raw.Name, + Locale: raw.Locale, + }, nil +} diff --git a/internal/library/zitadel/oauth_test.go b/internal/library/zitadel/oauth_test.go new file mode 100644 index 0000000..3ed918d --- /dev/null +++ b/internal/library/zitadel/oauth_test.go @@ -0,0 +1,258 @@ +package zitadel_test + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "gateway/internal/library/zitadel" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuthorizeURL(t *testing.T) { + t.Parallel() + + client, err := zitadel.NewClient(zitadel.Conf{ + Issuer: testIssuerURL, + OAuthClientID: testClientID, + GoogleIdPID: "google-idp-1", + }) + require.NoError(t, err) + + raw, err := client.AuthorizeURL("https://app.example.com/callback", "state-abc", "google") + require.NoError(t, err) + + u, err := url.Parse(raw) + require.NoError(t, err) + require.Equal(t, "https", u.Scheme) + require.Equal(t, "zitadel.example.com", u.Host) + require.Equal(t, "/oauth/v2/authorize", u.Path) + + q := u.Query() + require.Equal(t, testClientID, q.Get("client_id")) + require.Equal(t, "https://app.example.com/callback", q.Get("redirect_uri")) + require.Equal(t, "code", q.Get("response_type")) + require.Equal(t, "openid profile email", q.Get("scope")) + require.Equal(t, "state-abc", q.Get("state")) + require.Equal(t, "google-idp-1", q.Get("idp_id")) +} + +func TestAuthorizeURLWithoutGoogleIdP(t *testing.T) { + t.Parallel() + + client, err := zitadel.NewClient(zitadel.Conf{ + Issuer: testIssuerURL, + OAuthClientID: testClientID, + }) + require.NoError(t, err) + + raw, err := client.AuthorizeURL("https://app.example.com/callback", "state-abc", "google") + require.NoError(t, err) + require.NotContains(t, raw, "idp_id=") +} + +func TestAuthorizeURLRequiresClientAndParams(t *testing.T) { + t.Parallel() + + client, err := zitadel.NewClient(zitadel.Conf{Issuer: testIssuerURL}) + require.NoError(t, err) + + _, err = client.AuthorizeURL("https://app/callback", "state", "google") + require.Error(t, err) + require.Contains(t, err.Error(), "oauth client id") + + _, err = client.AuthorizeURL("", "state", "google") + require.Error(t, err) + + var nilClient *zitadel.Client + _, err = nilClient.AuthorizeURL("https://app/callback", "state", "google") + require.ErrorIs(t, err, zitadel.ErrNotConfigured) +} + +func TestExchangeAuthorizationCode(t *testing.T) { + t.Parallel() + + var gotForm url.Values + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/oauth/v2/token", r.URL.Path) + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + gotForm, err = url.ParseQuery(string(body)) + assert.NoError(t, err) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"at","id_token":"id","expires_in":3600,"token_type":"Bearer"}`)) + })) + t.Cleanup(srv.Close) + + client, err := zitadel.NewClient(zitadel.Conf{ + Issuer: srv.URL, + OAuthClientID: testClientID, + OAuthClientSecret: testSecret, + }) + require.NoError(t, err) + + tok, err := client.ExchangeAuthorizationCode( + context.Background(), + "auth-code-1", + "https://app.example.com/callback", + ) + require.NoError(t, err) + require.Equal(t, "at", tok.AccessToken) + require.Equal(t, "id", tok.IDToken) + require.Equal(t, "authorization_code", gotForm.Get("grant_type")) + require.Equal(t, testClientID, gotForm.Get("client_id")) + require.Equal(t, testSecret, gotForm.Get("client_secret")) + require.Equal(t, "auth-code-1", gotForm.Get("code")) + require.Equal(t, "https://app.example.com/callback", gotForm.Get("redirect_uri")) +} + +func TestExchangeAuthorizationCodeInvalid(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "invalid", http.StatusUnauthorized) + })) + t.Cleanup(srv.Close) + + client, err := zitadel.NewClient(zitadel.Conf{ + Issuer: srv.URL, + OAuthClientID: testClientID, + OAuthClientSecret: testSecret, + }) + require.NoError(t, err) + + _, err = client.ExchangeAuthorizationCode(context.Background(), "bad", "https://app/callback") + require.ErrorIs(t, err, zitadel.ErrInvalidCredentials) +} + +func TestExchangeAuthorizationCodeRequiresParams(t *testing.T) { + t.Parallel() + + client, err := zitadel.NewClient(zitadel.Conf{ + Issuer: testIssuerURL, + OAuthClientID: testClientID, + OAuthClientSecret: testSecret, + }) + require.NoError(t, err) + + _, err = client.ExchangeAuthorizationCode(context.Background(), "", "https://app/callback") + require.Error(t, err) + + var nilClient *zitadel.Client + _, err = nilClient.ExchangeAuthorizationCode(context.Background(), "code", "https://app/callback") + require.ErrorIs(t, err, zitadel.ErrNotConfigured) +} + +func TestFetchUserInfo(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/oidc/v1/userinfo", r.URL.Path) + assert.Equal(t, "Bearer access-token", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "sub":"zitadel-sub-2", + "email":"bob@example.com", + "email_verified":true, + "name":"Bob", + "locale":"zh-tw" + }`)) + })) + t.Cleanup(srv.Close) + + client, err := zitadel.NewClient(zitadel.Conf{Issuer: srv.URL}) + require.NoError(t, err) + + claims, err := client.FetchUserInfo(context.Background(), "access-token") + require.NoError(t, err) + require.Equal(t, "zitadel-sub-2", claims.Sub) + require.Equal(t, "bob@example.com", claims.Email) + require.True(t, claims.EmailVerified) + require.Equal(t, "Bob", claims.Name) + require.Equal(t, "zh-tw", claims.Locale) +} + +func TestFetchUserInfoUnauthorized(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "invalid", http.StatusUnauthorized) + })) + t.Cleanup(srv.Close) + + client, err := zitadel.NewClient(zitadel.Conf{Issuer: srv.URL}) + require.NoError(t, err) + + _, err = client.FetchUserInfo(context.Background(), "bad-token") + require.ErrorIs(t, err, zitadel.ErrInvalidCredentials) +} + +func TestFetchUserInfoMissingSub(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"email":"nobody@example.com"}`)) + })) + t.Cleanup(srv.Close) + + client, err := zitadel.NewClient(zitadel.Conf{Issuer: srv.URL}) + require.NoError(t, err) + + _, err = client.FetchUserInfo(context.Background(), "access-token") + require.Error(t, err) + require.Contains(t, err.Error(), "missing sub") +} + +func TestParseIDTokenClaims(t *testing.T) { + t.Parallel() + + payload, err := json.Marshal(map[string]any{ + "sub": "sub-1", + "email": "alice@example.com", + "email_verified": true, + "name": "Alice", + "locale": "en-us", + }) + require.NoError(t, err) + + raw := strings.Join([]string{ + base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256"}`)), + base64.RawURLEncoding.EncodeToString(payload), + "signature", + }, ".") + + claims, err := zitadel.ParseIDTokenClaims(raw) + require.NoError(t, err) + require.Equal(t, "sub-1", claims.Sub) + require.Equal(t, "alice@example.com", claims.Email) + require.True(t, claims.EmailVerified) + require.Equal(t, "Alice", claims.Name) + require.Equal(t, "en-us", claims.Locale) +} + +func TestParseIDTokenClaimsErrors(t *testing.T) { + t.Parallel() + + _, err := zitadel.ParseIDTokenClaims("") + require.Error(t, err) + + _, err = zitadel.ParseIDTokenClaims("not-a-jwt") + require.Error(t, err) + require.Contains(t, err.Error(), "malformed") + + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"email":"x@example.com"}`)) + raw := "header." + payload + ".sig" + _, err = zitadel.ParseIDTokenClaims(raw) + require.Error(t, err) + require.Contains(t, err.Error(), "missing sub") +} diff --git a/internal/library/zitadel/test_helpers_test.go b/internal/library/zitadel/test_helpers_test.go new file mode 100644 index 0000000..bc09e10 --- /dev/null +++ b/internal/library/zitadel/test_helpers_test.go @@ -0,0 +1,105 @@ +package zitadel_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "gateway/internal/library/zitadel" + + "github.com/golang-jwt/jwt/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testPAT = "pat" + testClientID = "gw-client" + testSecret = "gw-secret" + testIssuerURL = "https://zitadel.example.com" +) + +type jwksFixture struct { + Server *httptest.Server + Client *zitadel.Client + Key *rsa.PrivateKey + KID string + Issuer string +} + +func newJWKSFixture(t *testing.T) *jwksFixture { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + kid := "test-kid" + jwks := map[string]any{ + "keys": []map[string]any{{ + "kty": "RSA", + "kid": kid, + "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.PublicKey.E)).Bytes()), + }}, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/oauth/v2/keys", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + assert.NoError(t, json.NewEncoder(w).Encode(jwks)) + })) + + client, err := zitadel.NewClient(zitadel.Conf{ + Issuer: srv.URL, + ServiceUserToken: testPAT, + OAuthClientID: testClientID, + OAuthClientSecret: "secret", + }) + require.NoError(t, err) + + t.Cleanup(srv.Close) + + return &jwksFixture{ + Server: srv, + Client: client, + Key: key, + KID: kid, + Issuer: srv.URL, + } +} + +func (f *jwksFixture) signIDToken(t *testing.T, claims jwt.MapClaims) string { + t.Helper() + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = f.KID + raw, err := token.SignedString(f.Key) + require.NoError(t, err) + return raw +} + +func (f *jwksFixture) validClaims(now time.Time) jwt.MapClaims { + return jwt.MapClaims{ + "iss": f.Issuer, + "sub": "zitadel-sub-1", + "aud": testClientID, + "exp": now.Add(time.Hour).Unix(), + "email": "user@example.com", + "email_verified": true, + } +} + +func (f *jwksFixture) verify(t *testing.T, raw string) *zitadel.IDTokenClaims { + t.Helper() + + claims, err := f.Client.VerifyIDToken(context.Background(), raw) + require.NoError(t, err) + return claims +} diff --git a/internal/logic/auth/errors.go b/internal/logic/auth/errors.go new file mode 100644 index 0000000..5089e47 --- /dev/null +++ b/internal/logic/auth/errors.go @@ -0,0 +1,11 @@ +package auth + +import ( + errs "gateway/internal/library/errors" + "gateway/internal/library/errors/code" +) + +// errb is the Auth module error builder (scope 28). +// Use only for auth orchestration: ZITADEL mapping, login policy, oauth state, missing auth deps. +// Member / notification usecase errors must be returned unchanged (return nil, err). +var errb = errs.For(code.Auth) diff --git a/internal/logic/auth/login_helper.go b/internal/logic/auth/login_helper.go new file mode 100644 index 0000000..218511f --- /dev/null +++ b/internal/logic/auth/login_helper.go @@ -0,0 +1,126 @@ +package auth + +import ( + "context" + "strings" + + errs "gateway/internal/library/errors" + "gateway/internal/library/errors/code" + "gateway/internal/library/zitadel" + domauth "gateway/internal/model/auth/domain/usecase" + memberdom "gateway/internal/model/member/domain" + memberenum "gateway/internal/model/member/domain/enum" + dommember "gateway/internal/model/member/domain/usecase" + "gateway/internal/svc" + "gateway/internal/types" +) + +func issueAuthToken(ctx context.Context, sc *svc.ServiceContext, tenantID, uid string) (*types.AuthTokenData, error) { + if sc.AuthToken == nil { + return nil, errb.SysNotImplemented("auth token not configured") + } + pair, err := sc.AuthToken.IssuePair(ctx, &domauth.IssuePairRequest{ + TenantID: tenantID, + UID: uid, + }) + if err != nil { + return nil, err + } + return &types.AuthTokenData{ + AccessToken: pair.AccessToken, + RefreshToken: pair.RefreshToken, + ExpiresIn: pair.ExpiresIn, + UID: uid, + TokenType: pair.TokenType, + }, nil +} + +func tokenDataFromRefresh(ctx context.Context, sc *svc.ServiceContext, refreshToken string) (*types.AuthTokenData, error) { + if sc.AuthToken == nil { + return nil, errb.SysNotImplemented("auth token not configured") + } + pair, err := sc.AuthToken.Refresh(ctx, refreshToken) + if err != nil { + return nil, err + } + claims, err := sc.AuthToken.ParseAccessToken(ctx, pair.AccessToken) + if err != nil { + return nil, err + } + return &types.AuthTokenData{ + AccessToken: pair.AccessToken, + RefreshToken: pair.RefreshToken, + ExpiresIn: pair.ExpiresIn, + UID: claims.UID, + TokenType: pair.TokenType, + }, nil +} + +func zitadelIdentityFromToken(ctx context.Context, client *zitadel.Client, tok *zitadel.TokenResult) (*zitadel.IDTokenClaims, error) { + if tok == nil { + return nil, errb.SvcThirdParty("empty token result") + } + if tok.IDToken != "" { + claims, err := zitadel.ParseIDTokenClaims(tok.IDToken) + if err != nil { + return nil, errb.SvcThirdParty("parse id_token failed").WithCause(err) + } + return claims, nil + } + claims, err := client.FetchUserInfo(ctx, tok.AccessToken) + if err != nil { + return nil, wrapZitadelErr(err) + } + return claims, nil +} + +func memberForLogin(ctx context.Context, sc *svc.ServiceContext, tenantID, zitadelSub string) (*dommember.MemberDTO, error) { + if sc.MemberProfile == nil { + return nil, errb.SysNotImplemented("member profile not configured") + } + dto, err := sc.MemberProfile.GetByZitadelUserID(ctx, tenantID, zitadelSub) + if err != nil { + if e := errs.FromError(err); e != nil && e.Category() == code.ResNotFound { + return nil, errb.AuthUnauthorized("invalid credentials").WithCause(memberdom.ErrNotFound) + } + return nil, err + } + if err := ensureLoginEligible(dto.Status); err != nil { + return nil, err + } + return dto, nil +} + +func ensureLoginEligible(status memberenum.MemberStatus) error { + switch status { + case memberenum.MemberStatusActive: + return nil + case memberenum.MemberStatusUnverified: + return errb.AuthForbidden("account is not verified") + case memberenum.MemberStatusSuspended: + return errb.AuthForbidden("account is suspended") + case memberenum.MemberStatusDeleted: + return errb.AuthUnauthorized("invalid credentials") + default: + return errb.AuthForbidden("account is not allowed to login") + } +} + +func normalizeLoginEmail(email string) string { + return strings.TrimSpace(strings.ToLower(email)) +} + +func requireLoginDeps(sc *svc.ServiceContext) error { + if sc.Zitadel == nil { + return errb.SysNotImplemented("zitadel not configured") + } + if sc.MemberProfile == nil { + return errb.SysNotImplemented("member profile not configured") + } + return nil +} + +func isMemberNotFound(err error) bool { + e := errs.FromError(err) + return e != nil && e.Category() == code.ResNotFound +} diff --git a/internal/logic/auth/login_logic.go b/internal/logic/auth/login_logic.go new file mode 100644 index 0000000..6899cee --- /dev/null +++ b/internal/logic/auth/login_logic.go @@ -0,0 +1,58 @@ +package auth + +import ( + "context" + "strings" + + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type LoginLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic { + return &LoginLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *LoginLogic) Login(req *types.LoginReq) (*types.AuthTokenData, error) { + if err := requireLoginDeps(l.svcCtx); err != nil { + return nil, err + } + + tenant, err := resolveTenant(l.ctx, l.svcCtx, req.TenantSlug) + if err != nil { + return nil, err + } + + email := normalizeLoginEmail(req.Email) + tok, err := l.svcCtx.Zitadel.VerifyPassword(l.ctx, email, req.Password) + if err != nil { + return nil, wrapZitadelErr(err) + } + + identity, err := zitadelIdentityFromToken(l.ctx, l.svcCtx.Zitadel, tok) + if err != nil { + return nil, err + } + + member, err := memberForLogin(l.ctx, l.svcCtx, tenant.TenantID, identity.Sub) + if err != nil { + return nil, err + } + if identity.Email != "" && !strings.EqualFold(strings.TrimSpace(member.ZitadelEmail), identity.Email) { + // Prefer ZITADEL subject match; email mismatch is logged but does not block login. + logx.WithContext(l.ctx).Infof("login: zitadel email mismatch for uid=%s", member.UID) + } + + return issueAuthToken(l.ctx, l.svcCtx, tenant.TenantID, member.UID) +} diff --git a/internal/logic/auth/login_social_callback_logic.go b/internal/logic/auth/login_social_callback_logic.go new file mode 100644 index 0000000..6209b59 --- /dev/null +++ b/internal/logic/auth/login_social_callback_logic.go @@ -0,0 +1,74 @@ +package auth + +import ( + "context" + + "gateway/internal/library/zitadel" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type LoginSocialCallbackLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewLoginSocialCallbackLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginSocialCallbackLogic { + return &LoginSocialCallbackLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *LoginSocialCallbackLogic) LoginSocialCallback(req *types.LoginSocialCallbackReq) (*types.AuthTokenData, error) { + if err := requireLoginDeps(l.svcCtx); err != nil { + return nil, err + } + if l.svcCtx.AuthLoginSession == nil { + return nil, errb.SysNotImplemented("login session not configured") + } + + sessionID, err := parseLoginOAuthState(req.State) + if err != nil { + return nil, err + } + + session, err := l.svcCtx.AuthLoginSession.Get(l.ctx, sessionID) + if err != nil { + return nil, err + } + defer func() { + if delErr := l.svcCtx.AuthLoginSession.Delete(l.ctx, sessionID); delErr != nil { + logx.WithContext(l.ctx).Errorf("login social callback: delete session: %v", delErr) + } + }() + + tok, err := l.svcCtx.Zitadel.ExchangeAuthorizationCode(l.ctx, req.Code, session.RedirectURI) + if err != nil { + return nil, wrapZitadelErr(err) + } + + var claims *zitadel.IDTokenClaims + if tok.IDToken != "" { + claims, err = l.svcCtx.Zitadel.VerifyIDToken(l.ctx, tok.IDToken) + } else { + claims, err = zitadelIdentityFromToken(l.ctx, l.svcCtx.Zitadel, tok) + } + if err != nil { + return nil, wrapZitadelErr(err) + } + if !claims.EmailVerified { + return nil, errb.AuthForbidden("social email is not verified") + } + + member, err := memberForLogin(l.ctx, l.svcCtx, session.TenantID, claims.Sub) + if err != nil { + return nil, err + } + + return issueAuthToken(l.ctx, l.svcCtx, session.TenantID, member.UID) +} diff --git a/internal/logic/auth/login_social_start_logic.go b/internal/logic/auth/login_social_start_logic.go new file mode 100644 index 0000000..bf73c96 --- /dev/null +++ b/internal/logic/auth/login_social_start_logic.go @@ -0,0 +1,65 @@ +package auth + +import ( + "context" + "strings" + "time" + + domauth "gateway/internal/model/auth/domain/usecase" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type LoginSocialStartLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewLoginSocialStartLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginSocialStartLogic { + return &LoginSocialStartLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *LoginSocialStartLogic) LoginSocialStart(req *types.LoginSocialStartReq) (*types.LoginSocialStartData, error) { + if l.svcCtx.Zitadel == nil { + return nil, errb.SysNotImplemented("zitadel not configured") + } + if l.svcCtx.AuthLoginSession == nil { + return nil, errb.SysNotImplemented("login session not configured") + } + + tenant, err := resolveTenant(l.ctx, l.svcCtx, req.TenantSlug) + if err != nil { + return nil, err + } + + provider := strings.ToLower(strings.TrimSpace(req.Provider)) + ttl := time.Duration(l.svcCtx.Config.Auth.Defaults().RegistrationSessionTTLSeconds) * time.Second + session, err := l.svcCtx.AuthLoginSession.Create(l.ctx, &domauth.CreateLoginSessionRequest{ + TenantID: tenant.TenantID, + TenantSlug: tenant.Slug, + Provider: provider, + RedirectURI: strings.TrimSpace(req.RedirectURI), + TTL: ttl, + }) + if err != nil { + return nil, err + } + + oauthURL, err := l.svcCtx.Zitadel.AuthorizeURL(req.RedirectURI, loginOAuthState(session.SessionID), provider) + if err != nil { + return nil, wrapZitadelErr(err) + } + + return &types.LoginSocialStartData{ + OauthURL: oauthURL, + SessionID: session.SessionID, + ExpiresIn: session.ExpiresIn, + }, nil +} diff --git a/internal/logic/auth/logout_logic.go b/internal/logic/auth/logout_logic.go new file mode 100644 index 0000000..a3fcb61 --- /dev/null +++ b/internal/logic/auth/logout_logic.go @@ -0,0 +1,54 @@ +package auth + +import ( + "context" + "strings" + + domauth "gateway/internal/model/auth/domain/usecase" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type LogoutLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewLogoutLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LogoutLogic { + return &LogoutLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *LogoutLogic) Logout() (*types.LogoutData, error) { + if l.svcCtx.AuthToken == nil { + return nil, errb.SysNotImplemented("auth token not configured") + } + raw := bearerAccessToken(l.ctx) + if raw == "" { + return nil, errb.AuthUnauthorized("missing access token") + } + if err := l.svcCtx.AuthToken.Logout(l.ctx, &domauth.LogoutRequest{AccessToken: raw}); err != nil { + return nil, err + } + return &types.LogoutData{OK: true}, nil +} + +type bearerTokenContextKey struct{} + +// WithBearerAccessToken stores the raw Bearer access token for auth logic (e.g. logout). +func WithBearerAccessToken(ctx context.Context, token string) context.Context { + return context.WithValue(ctx, bearerTokenContextKey{}, strings.TrimSpace(token)) +} + +func bearerAccessToken(ctx context.Context) string { + if v, ok := ctx.Value(bearerTokenContextKey{}).(string); ok { + return strings.TrimSpace(v) + } + return "" +} diff --git a/internal/logic/auth/oauth_state.go b/internal/logic/auth/oauth_state.go new file mode 100644 index 0000000..9228d49 --- /dev/null +++ b/internal/logic/auth/oauth_state.go @@ -0,0 +1,39 @@ +package auth + +import ( + "strings" + + authdomain "gateway/internal/model/auth/domain" +) + +func oauthState(prefix, sessionID string) string { + return prefix + sessionID +} + +func parseOAuthState(state, prefix string) (sessionID string, err error) { + state = strings.TrimSpace(state) + if !strings.HasPrefix(state, prefix) { + return "", errb.InputInvalidFormat("invalid oauth state") + } + sessionID = strings.TrimPrefix(state, prefix) + if sessionID == "" { + return "", errb.InputInvalidFormat("invalid oauth state") + } + return sessionID, nil +} + +func registerOAuthState(sessionID string) string { + return oauthState(authdomain.OAuthStatePrefixRegister, sessionID) +} + +func loginOAuthState(sessionID string) string { + return oauthState(authdomain.OAuthStatePrefixLogin, sessionID) +} + +func parseRegisterOAuthState(state string) (string, error) { + return parseOAuthState(state, authdomain.OAuthStatePrefixRegister) +} + +func parseLoginOAuthState(state string) (string, error) { + return parseOAuthState(state, authdomain.OAuthStatePrefixLogin) +} diff --git a/internal/logic/auth/oauth_state_test.go b/internal/logic/auth/oauth_state_test.go new file mode 100644 index 0000000..02ab0ad --- /dev/null +++ b/internal/logic/auth/oauth_state_test.go @@ -0,0 +1,24 @@ +package auth + +import ( + "testing" + + authdomain "gateway/internal/model/auth/domain" + + "github.com/stretchr/testify/require" +) + +func TestOAuthStateParsing(t *testing.T) { + t.Parallel() + + regID, err := parseRegisterOAuthState(authdomain.OAuthStatePrefixRegister + "abc") + require.NoError(t, err) + require.Equal(t, "abc", regID) + + loginID, err := parseLoginOAuthState(authdomain.OAuthStatePrefixLogin + "xyz") + require.NoError(t, err) + require.Equal(t, "xyz", loginID) + + _, err = parseLoginOAuthState(authdomain.OAuthStatePrefixRegister + "abc") + require.Error(t, err) +} diff --git a/internal/logic/auth/register_confirm_logic.go b/internal/logic/auth/register_confirm_logic.go new file mode 100644 index 0000000..38a7b2a --- /dev/null +++ b/internal/logic/auth/register_confirm_logic.go @@ -0,0 +1,66 @@ +package auth + +import ( + "context" + + dommember "gateway/internal/model/member/domain/usecase" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RegisterConfirmLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRegisterConfirmLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterConfirmLogic { + return &RegisterConfirmLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *RegisterConfirmLogic) RegisterConfirm(req *types.RegisterConfirmReq) (*types.AuthTokenData, error) { + if l.svcCtx.MemberOTP == nil || l.svcCtx.MemberLifecycle == nil { + return nil, errb.SysNotImplemented("member module not configured") + } + if l.svcCtx.AuthToken == nil { + return nil, errb.SysNotImplemented("auth token not configured") + } + + tenant, err := resolveTenant(l.ctx, l.svcCtx, req.TenantSlug) + if err != nil { + return nil, err + } + + ch, err := l.svcCtx.MemberOTP.MatchChallenge(l.ctx, &dommember.MatchChallengeRequest{ + ChallengeID: req.ChallengeID, + TenantID: tenant.TenantID, + Purpose: registrationPurpose(), + RequireUID: true, + RequireTarget: false, + }) + if err != nil { + return nil, err + } + + if _, err := l.svcCtx.MemberOTP.Verify(l.ctx, &dommember.VerifyOTPRequest{ + TenantID: tenant.TenantID, + UID: ch.UID, + ChallengeID: req.ChallengeID, + Code: req.Code, + Purpose: registrationPurpose(), + }); err != nil { + return nil, err + } + + if err := l.svcCtx.MemberLifecycle.Activate(l.ctx, tenant.TenantID, ch.UID); err != nil { + return nil, err + } + + return issueAuthToken(l.ctx, l.svcCtx, tenant.TenantID, ch.UID) +} diff --git a/internal/logic/auth/register_helper.go b/internal/logic/auth/register_helper.go new file mode 100644 index 0000000..fa114f8 --- /dev/null +++ b/internal/logic/auth/register_helper.go @@ -0,0 +1,98 @@ +package auth + +import ( + "context" + "errors" + "strings" + + errs "gateway/internal/library/errors" + "gateway/internal/library/zitadel" + authmetaenum "gateway/internal/model/auth/domain/enum" + domauth "gateway/internal/model/auth/domain/usecase" + memberenum "gateway/internal/model/member/domain/enum" + dommember "gateway/internal/model/member/domain/usecase" + "gateway/internal/svc" +) + +func resolveTenant(ctx context.Context, sc *svc.ServiceContext, slug string) (*dommember.TenantDTO, error) { + if sc.MemberTenant == nil { + return nil, errb.SysNotImplemented("member tenant not configured") + } + slug = strings.TrimSpace(slug) + tenant, err := sc.MemberTenant.ResolveBySlug(ctx, slug) + if err != nil { + return nil, err + } + if tenant.Status != memberenum.TenantStatusActive.String() { + return nil, errb.AuthForbidden("tenant registration is not allowed") + } + return tenant, nil +} + +func wrapZitadelErr(err error) error { + if err == nil { + return nil + } + if errors.Is(err, zitadel.ErrNotConfigured) { + return errb.SysNotImplemented("zitadel not configured").WithCause(err) + } + if errors.Is(err, zitadel.ErrUserAlreadyExists) { + return errb.ResAlreadyExist("email already registered").WithCause(err) + } + if errors.Is(err, zitadel.ErrInvalidCredentials) { + return errb.AuthUnauthorized("invalid credentials").WithCause(err) + } + if errors.Is(err, zitadel.ErrInvalidIDToken) { + return errb.AuthUnauthorized("invalid id_token").WithCause(err) + } + if e := errs.FromError(err); e != nil { + return err + } + return errb.SvcThirdParty("zitadel request failed").WithCause(err) +} + +func registrationPurpose() memberenum.OTPPurpose { + return memberenum.OTPPurposeRegistrationEmail +} + +func recordRegistrationMeta( + ctx context.Context, + sc *svc.ServiceContext, + tenantID, uid, inviteCodeID, acceptTermsVersion string, + marketingOptIn bool, + channel authmetaenum.RegistrationChannel, +) error { + if sc.AuthRegistrationMeta == nil { + return errb.SysNotImplemented("registration metadata not configured") + } + meta := RequestMetaFromContext(ctx) + return sc.AuthRegistrationMeta.Record(ctx, &domauth.RecordRegistrationRequest{ + TenantID: tenantID, + UID: uid, + InviteCodeID: inviteCodeID, + AcceptTermsVersion: acceptTermsVersion, + MarketingOptIn: marketingOptIn, + Channel: channel, + ClientIP: strings.TrimSpace(meta.ClientIP), + UserAgent: strings.TrimSpace(meta.UserAgent), + }) +} + +func requireRegistrationDeps(sc *svc.ServiceContext) error { + if sc.Zitadel == nil { + return errb.SysNotImplemented("zitadel not configured") + } + if sc.MemberLifecycle == nil { + return errb.SysNotImplemented("member lifecycle not configured") + } + if sc.MemberOTP == nil { + return errb.SysNotImplemented("member OTP not configured") + } + if sc.MemberVerifyRate == nil { + return errb.SysNotImplemented("member verify rate not configured") + } + if sc.Notifier == nil { + return errb.SysNotImplemented("notifier not configured") + } + return nil +} diff --git a/internal/logic/auth/register_logic.go b/internal/logic/auth/register_logic.go new file mode 100644 index 0000000..4f8bbc5 --- /dev/null +++ b/internal/logic/auth/register_logic.go @@ -0,0 +1,156 @@ +package auth + +import ( + "context" + "strings" + "time" + + "gateway/internal/library/zitadel" + authmetaenum "gateway/internal/model/auth/domain/enum" + domauth "gateway/internal/model/auth/domain/usecase" + memberdom "gateway/internal/model/member/domain" + dommember "gateway/internal/model/member/domain/usecase" + notifenum "gateway/internal/model/notification/domain/enum" + notifuc "gateway/internal/model/notification/domain/usecase" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RegisterLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterLogic { + return &RegisterLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *RegisterLogic) Register(req *types.RegisterReq) (*types.RegisterData, error) { + if err := requireRegistrationDeps(l.svcCtx); err != nil { + return nil, err + } + + tenant, err := resolveTenant(l.ctx, l.svcCtx, req.TenantSlug) + if err != nil { + return nil, err + } + + regCfg := l.svcCtx.Config.Member.Defaults().Registration + var inviteCodeID string + if regCfg.RequireInviteCode { + if l.svcCtx.AuthInvite == nil { + return nil, errb.SysNotImplemented("invite validation not configured") + } + consumed, err := l.svcCtx.AuthInvite.Consume(l.ctx, &domauth.ConsumeInviteRequest{ + TenantID: tenant.TenantID, + Code: req.InviteCode, + }) + if err != nil { + return nil, err + } + inviteCodeID = consumed.ID + } + + email := strings.TrimSpace(strings.ToLower(req.Email)) + zResult, err := l.svcCtx.Zitadel.CreateHumanUser(l.ctx, zitadel.CreateHumanUserRequest{ + OrgID: tenant.OrgID, + Email: email, + Password: req.Password, + DisplayName: strings.TrimSpace(req.DisplayName), + Language: strings.TrimSpace(req.Language), + }) + if err != nil { + return nil, wrapZitadelErr(err) + } + + memberDTO, err := l.svcCtx.MemberLifecycle.CreateUnverified(l.ctx, &dommember.CreatePlatformMemberRequest{ + TenantID: tenant.TenantID, + Email: email, + DisplayName: strings.TrimSpace(req.DisplayName), + Language: strings.TrimSpace(req.Language), + ZitadelUserID: zResult.UserID, + }) + if err != nil { + if deactErr := l.svcCtx.Zitadel.DeactivateUser(l.ctx, zResult.UserID); deactErr != nil { + logx.WithContext(l.ctx).Errorf("register: deactivate zitadel user after member failure: %v", deactErr) + } + return nil, err + } + + if err := recordRegistrationMeta(l.ctx, l.svcCtx, tenant.TenantID, memberDTO.UID, inviteCodeID, req.AcceptTermsVersion, req.MarketingOptIn, authmetaenum.RegistrationChannelEmail); err != nil { + if abortErr := l.svcCtx.MemberLifecycle.AbortPending(l.ctx, tenant.TenantID, memberDTO.UID); abortErr != nil { + logx.WithContext(l.ctx).Errorf("register: abort pending member after metadata failure: %v", abortErr) + } + if deactErr := l.svcCtx.Zitadel.DeactivateUser(l.ctx, zResult.UserID); deactErr != nil { + logx.WithContext(l.ctx).Errorf("register: deactivate zitadel user after metadata failure: %v", deactErr) + } + return nil, err + } + + data, err := sendRegistrationOTP(l.ctx, l.svcCtx, tenant.TenantID, memberDTO.UID, email) + if err != nil { + if abortErr := l.svcCtx.MemberLifecycle.AbortPending(l.ctx, tenant.TenantID, memberDTO.UID); abortErr != nil { + logx.WithContext(l.ctx).Errorf("register: abort pending member: %v", abortErr) + } + if deactErr := l.svcCtx.Zitadel.DeactivateUser(l.ctx, zResult.UserID); deactErr != nil { + logx.WithContext(l.ctx).Errorf("register: deactivate zitadel user after otp failure: %v", deactErr) + } + return nil, err + } + data.UID = memberDTO.UID + return data, nil +} + +func sendRegistrationOTP( + ctx context.Context, + sc *svc.ServiceContext, + tenantID, uid, email string, +) (*types.RegisterData, error) { + cfg := sc.Config.Member.Defaults() + rateKey := memberdom.GetVerifyRateRedisKey(tenantID, uid, string(registrationPurpose())) + if err := sc.MemberVerifyRate.AssertResendAllowed(ctx, rateKey, time.Duration(cfg.OTP.ResendCooldownSeconds)*time.Second); err != nil { + return nil, err + } + + dto, plainCode, err := sc.MemberOTP.Generate(ctx, &dommember.GenerateOTPRequest{ + TenantID: tenantID, + UID: uid, + Purpose: registrationPurpose(), + Target: email, + }) + if err != nil { + return nil, err + } + locale := sc.Config.Notification.DefaultLocale + if strings.TrimSpace(locale) == "" { + locale = "en-us" + } + if _, sendErr := sc.Notifier.Send(ctx, ¬ifuc.SendRequest{ + TenantID: tenantID, + UID: uid, + Channel: notifenum.ChannelEmail, + Kind: notifenum.NotifyVerifyRegistrationEmail, + Target: email, + Locale: locale, + Data: map[string]any{"code": plainCode, "expires_in": dto.ExpiresIn}, + IdempotencyKey: dto.ChallengeID, + DoNotPersistBody: true, + Severity: notifenum.SeverityInfo, + }); sendErr != nil { + if invErr := sc.MemberOTP.Invalidate(ctx, dto.ChallengeID); invErr != nil { + return nil, invErr + } + return nil, sendErr + } + return &types.RegisterData{ + ChallengeID: dto.ChallengeID, + ExpiresIn: dto.ExpiresIn, + }, nil +} diff --git a/internal/logic/auth/register_resend_logic.go b/internal/logic/auth/register_resend_logic.go new file mode 100644 index 0000000..132729c --- /dev/null +++ b/internal/logic/auth/register_resend_logic.go @@ -0,0 +1,58 @@ +package auth + +import ( + "context" + + dommember "gateway/internal/model/member/domain/usecase" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RegisterResendLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRegisterResendLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterResendLogic { + return &RegisterResendLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *RegisterResendLogic) RegisterResend(req *types.RegisterResendReq) (*types.RegisterData, error) { + if err := requireRegistrationDeps(l.svcCtx); err != nil { + return nil, err + } + + tenant, err := resolveTenant(l.ctx, l.svcCtx, req.TenantSlug) + if err != nil { + return nil, err + } + + ch, err := l.svcCtx.MemberOTP.MatchChallenge(l.ctx, &dommember.MatchChallengeRequest{ + ChallengeID: req.ChallengeID, + TenantID: tenant.TenantID, + Purpose: registrationPurpose(), + RequireUID: true, + RequireTarget: true, + }) + if err != nil { + return nil, err + } + + if err := l.svcCtx.MemberOTP.Invalidate(l.ctx, req.ChallengeID); err != nil { + return nil, err + } + + data, err := sendRegistrationOTP(l.ctx, l.svcCtx, tenant.TenantID, ch.UID, ch.Target) + if err != nil { + return nil, err + } + data.UID = ch.UID + return data, nil +} diff --git a/internal/logic/auth/register_social_callback_logic.go b/internal/logic/auth/register_social_callback_logic.go new file mode 100644 index 0000000..ac2889c --- /dev/null +++ b/internal/logic/auth/register_social_callback_logic.go @@ -0,0 +1,128 @@ +package auth + +import ( + "context" + + "gateway/internal/library/zitadel" + authmetaenum "gateway/internal/model/auth/domain/enum" + domauth "gateway/internal/model/auth/domain/usecase" + memberdom "gateway/internal/model/member/domain" + dommember "gateway/internal/model/member/domain/usecase" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RegisterSocialCallbackLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRegisterSocialCallbackLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterSocialCallbackLogic { + return &RegisterSocialCallbackLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *RegisterSocialCallbackLogic) RegisterSocialCallback(req *types.RegisterSocialCallbackReq) (*types.AuthTokenData, error) { + if l.svcCtx.Zitadel == nil || l.svcCtx.AuthRegistrationSession == nil { + return nil, errb.SysNotImplemented("social registration not configured") + } + if l.svcCtx.MemberProvisioning == nil || l.svcCtx.MemberProfile == nil { + return nil, errb.SysNotImplemented("member provisioning not configured") + } + if l.svcCtx.AuthToken == nil { + return nil, errb.SysNotImplemented("auth token not configured") + } + + sessionID, err := parseRegisterOAuthState(req.State) + if err != nil { + return nil, err + } + + session, err := l.svcCtx.AuthRegistrationSession.Get(l.ctx, sessionID) + if err != nil { + return nil, err + } + defer func() { + if delErr := l.svcCtx.AuthRegistrationSession.Delete(l.ctx, sessionID); delErr != nil { + logx.WithContext(l.ctx).Errorf("register social callback: delete session: %v", delErr) + } + }() + + tok, err := l.svcCtx.Zitadel.ExchangeAuthorizationCode(l.ctx, req.Code, session.RedirectURI) + if err != nil { + return nil, wrapZitadelErr(err) + } + var claims *zitadel.IDTokenClaims + if tok.IDToken != "" { + claims, err = l.svcCtx.Zitadel.VerifyIDToken(l.ctx, tok.IDToken) + } else { + claims, err = zitadelIdentityFromToken(l.ctx, l.svcCtx.Zitadel, tok) + } + if err != nil { + return nil, wrapZitadelErr(err) + } + + if !claims.EmailVerified { + return nil, errb.AuthForbidden("social email is not verified") + } + + isExisting := false + if _, err := l.svcCtx.MemberProfile.GetByZitadelUserID(l.ctx, session.TenantID, claims.Sub); err == nil { + isExisting = true + } else if !isMemberNotFound(err) { + return nil, err + } + if isExisting && session.InviteNewUsersOnly { + return nil, errb.ResAlreadyExist("account already exists, please login").WithCause(memberdom.ErrDuplicateMember) + } + + var inviteCodeID string + if l.svcCtx.Config.Member.Defaults().Registration.RequireInviteCode { + if l.svcCtx.AuthInvite == nil { + return nil, errb.SysNotImplemented("invite validation not configured") + } + consumed, err := l.svcCtx.AuthInvite.Consume(l.ctx, &domauth.ConsumeInviteRequest{ + TenantID: session.TenantID, + Code: session.InviteCode, + }) + if err != nil { + return nil, err + } + inviteCodeID = consumed.ID + } + + memberDTO, err := l.svcCtx.MemberProvisioning.EnsureFromOIDC(l.ctx, &dommember.EnsureFromOIDCRequest{ + TenantID: session.TenantID, + ZitadelSub: claims.Sub, + Email: claims.Email, + EmailVerified: claims.EmailVerified, + DisplayName: claims.Name, + Locale: firstNonEmpty(session.Language, claims.Locale), + }) + if err != nil { + return nil, err + } + + if !isExisting { + if err := recordRegistrationMeta(l.ctx, l.svcCtx, session.TenantID, memberDTO.UID, inviteCodeID, session.AcceptTermsVersion, session.MarketingOptIn, authmetaenum.RegistrationChannelGoogle); err != nil { + return nil, err + } + } + + return issueAuthToken(l.ctx, l.svcCtx, session.TenantID, memberDTO.UID) +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} diff --git a/internal/logic/auth/register_social_start_logic.go b/internal/logic/auth/register_social_start_logic.go new file mode 100644 index 0000000..391c257 --- /dev/null +++ b/internal/logic/auth/register_social_start_logic.go @@ -0,0 +1,88 @@ +package auth + +import ( + "context" + "strings" + "time" + + domauth "gateway/internal/model/auth/domain/usecase" + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RegisterSocialStartLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRegisterSocialStartLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterSocialStartLogic { + return &RegisterSocialStartLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *RegisterSocialStartLogic) RegisterSocialStart(req *types.RegisterSocialStartReq) (*types.RegisterSocialStartData, error) { + if l.svcCtx.Zitadel == nil { + return nil, errb.SysNotImplemented("zitadel not configured") + } + if l.svcCtx.AuthRegistrationSession == nil { + return nil, errb.SysNotImplemented("registration session not configured") + } + + tenant, err := resolveTenant(l.ctx, l.svcCtx, req.TenantSlug) + if err != nil { + return nil, err + } + + regCfg := l.svcCtx.Config.Member.Defaults().Registration + inviteNewUsersOnly := true + if regCfg.RequireInviteCode { + if l.svcCtx.AuthInvite == nil { + return nil, errb.SysNotImplemented("invite validation not configured") + } + view, err := l.svcCtx.AuthInvite.Validate(l.ctx, &domauth.ValidateInviteRequest{ + TenantID: tenant.TenantID, + Code: req.InviteCode, + }) + if err != nil { + return nil, err + } + inviteNewUsersOnly = view.NewUsersOnly + } + + meta := RequestMetaFromContext(l.ctx) + ttl := time.Duration(l.svcCtx.Config.Auth.Defaults().RegistrationSessionTTLSeconds) * time.Second + session, err := l.svcCtx.AuthRegistrationSession.Create(l.ctx, &domauth.CreateRegistrationSessionRequest{ + TenantID: tenant.TenantID, + TenantSlug: tenant.Slug, + InviteCode: req.InviteCode, + InviteNewUsersOnly: inviteNewUsersOnly, + AcceptTermsVersion: req.AcceptTermsVersion, + MarketingOptIn: req.MarketingOptIn, + Language: strings.TrimSpace(req.Language), + Provider: strings.ToLower(strings.TrimSpace(req.Provider)), + RedirectURI: strings.TrimSpace(req.RedirectURI), + ClientIP: meta.ClientIP, + UserAgent: meta.UserAgent, + TTL: ttl, + }) + if err != nil { + return nil, err + } + + oauthURL, err := l.svcCtx.Zitadel.AuthorizeURL(req.RedirectURI, registerOAuthState(session.SessionID), req.Provider) + if err != nil { + return nil, wrapZitadelErr(err) + } + + return &types.RegisterSocialStartData{ + OauthURL: oauthURL, + SessionID: session.SessionID, + ExpiresIn: session.ExpiresIn, + }, nil +} diff --git a/internal/logic/auth/request_meta.go b/internal/logic/auth/request_meta.go new file mode 100644 index 0000000..39816f4 --- /dev/null +++ b/internal/logic/auth/request_meta.go @@ -0,0 +1,27 @@ +package auth + +import "context" + +type requestMetaKey struct{} + +// RequestMeta carries client audit fields injected by handlers. +type RequestMeta struct { + ClientIP string + UserAgent string +} + +// WithRequestMeta attaches client metadata to context. +func WithRequestMeta(ctx context.Context, meta RequestMeta) context.Context { + return context.WithValue(ctx, requestMetaKey{}, meta) +} + +// RequestMetaFromContext reads client metadata from context. +func RequestMetaFromContext(ctx context.Context) RequestMeta { + if ctx == nil { + return RequestMeta{} + } + if meta, ok := ctx.Value(requestMetaKey{}).(RequestMeta); ok { + return meta + } + return RequestMeta{} +} diff --git a/internal/logic/auth/token_exchange_logic.go b/internal/logic/auth/token_exchange_logic.go new file mode 100644 index 0000000..01217b6 --- /dev/null +++ b/internal/logic/auth/token_exchange_logic.go @@ -0,0 +1,51 @@ +package auth + +import ( + "context" + "strings" + + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type TokenExchangeLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewTokenExchangeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *TokenExchangeLogic { + return &TokenExchangeLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *TokenExchangeLogic) TokenExchange(req *types.TokenExchangeReq) (*types.AuthTokenData, error) { + if err := requireLoginDeps(l.svcCtx); err != nil { + return nil, err + } + + tenant, err := resolveTenant(l.ctx, l.svcCtx, req.TenantSlug) + if err != nil { + return nil, err + } + + claims, err := l.svcCtx.Zitadel.VerifyIDToken(l.ctx, strings.TrimSpace(req.IDToken)) + if err != nil { + return nil, wrapZitadelErr(err) + } + + member, err := memberForLogin(l.ctx, l.svcCtx, tenant.TenantID, claims.Sub) + if err != nil { + return nil, err + } + if claims.Email != "" && !strings.EqualFold(strings.TrimSpace(member.ZitadelEmail), claims.Email) { + logx.WithContext(l.ctx).Infof("token exchange: zitadel email mismatch for uid=%s", member.UID) + } + + return issueAuthToken(l.ctx, l.svcCtx, tenant.TenantID, member.UID) +} diff --git a/internal/logic/auth/token_refresh_logic.go b/internal/logic/auth/token_refresh_logic.go new file mode 100644 index 0000000..c06546d --- /dev/null +++ b/internal/logic/auth/token_refresh_logic.go @@ -0,0 +1,28 @@ +package auth + +import ( + "context" + + "gateway/internal/svc" + "gateway/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type TokenRefreshLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewTokenRefreshLogic(ctx context.Context, svcCtx *svc.ServiceContext) *TokenRefreshLogic { + return &TokenRefreshLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, + } +} + +func (l *TokenRefreshLogic) TokenRefresh(req *types.TokenRefreshReq) (*types.AuthTokenData, error) { + return tokenDataFromRefresh(l.ctx, l.svcCtx, req.RefreshToken) +} diff --git a/internal/logic/member/actor.go b/internal/logic/member/actor.go index 0fba39b..47e9018 100644 --- a/internal/logic/member/actor.go +++ b/internal/logic/member/actor.go @@ -7,7 +7,7 @@ import ( type actorKey struct{} -// Actor identifies the calling member in dev mode (JWT middleware not wired yet). +// Actor identifies the calling member (JWT middleware or dev headers). type Actor struct { TenantID string UID string @@ -18,11 +18,11 @@ func WithActor(ctx context.Context, tenantID, uid string) context.Context { return context.WithValue(ctx, actorKey{}, Actor{TenantID: tenantID, UID: uid}) } -// ActorFromContext reads the dev actor injected by handlers. +// ActorFromContext reads the member actor injected by JWT middleware or dev headers. func ActorFromContext(ctx context.Context) (Actor, error) { v, ok := ctx.Value(actorKey{}).(Actor) if !ok || v.TenantID == "" || v.UID == "" { - return Actor{}, fmt.Errorf("missing X-Tenant-ID or X-UID header") + return Actor{}, fmt.Errorf("missing bearer token or X-Tenant-ID/X-UID headers") } return v, nil } diff --git a/internal/logic/member/errors.go b/internal/logic/member/errors.go new file mode 100644 index 0000000..15b5351 --- /dev/null +++ b/internal/logic/member/errors.go @@ -0,0 +1,11 @@ +package member + +import ( + errs "gateway/internal/library/errors" + "gateway/internal/library/errors/code" +) + +// errb is the Member module error builder (scope 29). +// Use for member-route orchestration (actor, optional field checks without validate tags). +// Usecase errors must be returned unchanged (return nil, err). +var errb = errs.For(code.Member) diff --git a/internal/logic/member/get_member_me_logic.go b/internal/logic/member/get_member_me_logic.go index e20f9ba..95ef649 100644 --- a/internal/logic/member/get_member_me_logic.go +++ b/internal/logic/member/get_member_me_logic.go @@ -27,7 +27,7 @@ func (l *GetMemberMeLogic) GetMemberMe() (*types.MemberMeData, error) { return nil, err } if l.svcCtx.MemberProfile == nil { - return nil, errb.SysInternal("member profile not configured") + return nil, errb.SysNotImplemented("member profile not configured") } dto, err := l.svcCtx.MemberProfile.GetByUID(l.ctx, &domusecase.GetMemberRequest{ TenantID: actor.TenantID, diff --git a/internal/logic/member/update_member_me_logic.go b/internal/logic/member/update_member_me_logic.go index 36b18a4..7ffb2b1 100644 --- a/internal/logic/member/update_member_me_logic.go +++ b/internal/logic/member/update_member_me_logic.go @@ -27,7 +27,7 @@ func (l *UpdateMemberMeLogic) UpdateMemberMe(req *types.UpdateMemberMeReq) (*typ return nil, err } if l.svcCtx.MemberProfile == nil { - return nil, errb.SysInternal("member profile not configured") + return nil, errb.SysNotImplemented("member profile not configured") } update := &domusecase.UpdateMemberRequest{TenantID: actor.TenantID, UID: actor.UID} if req != nil { diff --git a/internal/logic/member/verify_helper.go b/internal/logic/member/verify_helper.go index bd9ef7a..f462993 100644 --- a/internal/logic/member/verify_helper.go +++ b/internal/logic/member/verify_helper.go @@ -4,8 +4,6 @@ import ( "context" "time" - errs "gateway/internal/library/errors" - "gateway/internal/library/errors/code" memberdom "gateway/internal/model/member/domain" "gateway/internal/model/member/domain/enum" domusecase "gateway/internal/model/member/domain/usecase" @@ -15,8 +13,6 @@ import ( "gateway/internal/types" ) -var errb = errs.For(code.Facade) - func startVerification( ctx context.Context, sc *svc.ServiceContext, @@ -27,10 +23,13 @@ func startVerification( target string, ) (*types.VerificationStartData, error) { if sc.MemberOTP == nil { - return nil, errb.SysInternal("member OTP not configured") + return nil, errb.SysNotImplemented("member OTP not configured") } if sc.Notifier == nil { - return nil, errb.SysInternal("notifier not configured") + return nil, errb.SysNotImplemented("notifier not configured") + } + if sc.MemberVerifyRate == nil { + return nil, errb.SysNotImplemented("member verify rate not configured") } if target == "" { return nil, errb.InputMissingRequired("target is required") @@ -38,20 +37,12 @@ func startVerification( cfg := sc.Config.Member.Defaults() rateKey := memberdom.GetVerifyRateRedisKey(actor.TenantID, actor.UID, string(purpose)) - ok, err := sc.MemberVerifyRate.TryResendLock(ctx, rateKey, time.Duration(cfg.OTP.ResendCooldownSeconds)*time.Second) - if err != nil { - return nil, errb.SysInternal("rate limit check failed").WithCause(err) - } - if !ok { - return nil, errb.AuthForbidden("resend cooldown active").WithCause(memberdom.ErrResendCooldown) + if err := sc.MemberVerifyRate.AssertResendAllowed(ctx, rateKey, time.Duration(cfg.OTP.ResendCooldownSeconds)*time.Second); err != nil { + return nil, err } dailyKey := memberdom.GetVerifyDailyRedisKey(actor.TenantID, actor.UID, string(purpose)) - count, err := sc.MemberVerifyRate.IncrDaily(ctx, dailyKey, 24*time.Hour) - if err != nil { - return nil, errb.SysInternal("daily limit check failed").WithCause(err) - } - if count > int64(cfg.OTP.DailyVerifyLimit) { - return nil, errb.AuthForbidden("daily verification limit exceeded").WithCause(memberdom.ErrDailyLimit) + if err := sc.MemberVerifyRate.AssertDailyAllowed(ctx, dailyKey, 24*time.Hour, cfg.OTP.DailyVerifyLimit); err != nil { + return nil, err } dto, plainCode, err := sc.MemberOTP.Generate(ctx, &domusecase.GenerateOTPRequest{ @@ -77,7 +68,7 @@ func startVerification( Severity: notifenum.SeverityInfo, }); sendErr != nil { if invErr := sc.MemberOTP.Invalidate(ctx, dto.ChallengeID); invErr != nil { - return nil, errb.SysInternal("invalidate otp after send failure").WithCause(invErr) + return nil, invErr } return nil, sendErr } @@ -96,7 +87,7 @@ func confirmVerification( setVerified func(context.Context, string, string, string) error, ) error { if sc.MemberOTP == nil || sc.MemberProfile == nil { - return errb.SysInternal("member module not configured") + return errb.SysNotImplemented("member module not configured") } if req == nil || req.ChallengeID == "" || req.Code == "" { return errb.InputMissingRequired("challenge_id and code are required") @@ -116,7 +107,7 @@ func confirmVerification( func requireTOTP(sc *svc.ServiceContext) error { if sc.MemberTOTP == nil { - return errb.SysInternal("member TOTP not configured") + return errb.SysNotImplemented("member TOTP not configured") } return nil } @@ -124,7 +115,7 @@ func requireTOTP(sc *svc.ServiceContext) error { func actorOrErr(ctx context.Context) (Actor, error) { actor, err := ActorFromContext(ctx) if err != nil { - return Actor{}, errb.AuthForbidden(err.Error()) + return Actor{}, errb.AuthUnauthorized("missing bearer token or X-Tenant-ID/X-UID headers") } return actor, nil } diff --git a/internal/middleware/auth_jwt.go b/internal/middleware/auth_jwt.go new file mode 100644 index 0000000..192179b --- /dev/null +++ b/internal/middleware/auth_jwt.go @@ -0,0 +1,47 @@ +package middleware + +import ( + "net/http" + "strings" + + errs "gateway/internal/library/errors" + "gateway/internal/library/errors/code" + logicmember "gateway/internal/logic/member" + domauth "gateway/internal/model/auth/domain/usecase" + "gateway/internal/response" + + "github.com/zeromicro/go-zero/rest" +) + +// CloudEPJWT parses Bearer access tokens and injects member actor into request context. +// When token is absent or invalid, the request proceeds unchanged (dev headers may still apply on member routes). +func CloudEPJWT(tokens domauth.TokenUseCase) rest.Middleware { + return func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + raw := bearerToken(r.Header.Get("Authorization")) + if raw != "" && tokens != nil { + claims, err := tokens.ParseAccessToken(ctx, raw) + if err == nil { + ctx = logicmember.WithActor(ctx, claims.TenantID, claims.UID) + r = r.WithContext(ctx) + next(w, r) + return + } + if e := errs.FromError(err); e != nil && e.Category() == code.AuthUnauthorized { + response.Write(r.Context(), w, nil, err) + return + } + } + next(w, r) + } + } +} + +func bearerToken(header string) string { + const prefix = "Bearer " + if !strings.HasPrefix(header, prefix) { + return "" + } + return strings.TrimSpace(strings.TrimPrefix(header, prefix)) +} diff --git a/internal/model/auth/config/config.go b/internal/model/auth/config/config.go new file mode 100644 index 0000000..f93c0db --- /dev/null +++ b/internal/model/auth/config/config.go @@ -0,0 +1,35 @@ +package config + +// Config is auth module settings (embedded in gateway root config). +type Config struct { + AccessExpire int64 `json:",optional"` + RefreshExpire int64 `json:",optional"` + ActiveKID string `json:",optional"` + AccessSecret string `json:",optional,env=JWT_ACCESS_SECRET"` + RefreshSecret string `json:",optional,env=JWT_REFRESH_SECRET"` + // RegistrationSessionTTLSeconds is used by register/social flow (PR 6). + RegistrationSessionTTLSeconds int `json:",optional"` +} + +// Defaults returns zero-value-safe defaults. +func (c Config) Defaults() Config { + if c.AccessExpire <= 0 { + c.AccessExpire = 900 + } + if c.RefreshExpire <= 0 { + c.RefreshExpire = 604800 + } + if c.ActiveKID == "" { + c.ActiveKID = "v1" + } + if c.RegistrationSessionTTLSeconds <= 0 { + c.RegistrationSessionTTLSeconds = 600 + } + return c +} + +// Enabled reports whether JWT signing is configured. +func (c Config) Enabled() bool { + c = c.Defaults() + return c.AccessSecret != "" && c.RefreshSecret != "" +} diff --git a/internal/model/auth/domain/const.go b/internal/model/auth/domain/const.go new file mode 100644 index 0000000..6c161fd --- /dev/null +++ b/internal/model/auth/domain/const.go @@ -0,0 +1,79 @@ +package domain + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" +) + +// MongoDB BSON field names for auth module collections. +const ( + BSONFieldID = "_id" + BSONFieldTenantID = "tenant_id" + BSONFieldCodeHash = "code_hash" + BSONFieldMaxUses = "max_uses" + BSONFieldUsedCount = "used_count" + BSONFieldExpiresAt = "expires_at" + BSONFieldNewUsersOnly = "new_users_only" + BSONFieldCreateAt = "create_at" + BSONFieldUpdateAt = "update_at" + + BSONFieldUID = "uid" + BSONFieldInviteCodeID = "invite_code_id" + BSONFieldAcceptTermsVersion = "accept_terms_version" + BSONFieldMarketingOptIn = "marketing_opt_in" + BSONFieldRegistrationChannel = "registration_channel" + BSONFieldClientIP = "client_ip" + BSONFieldUserAgent = "user_agent" + BSONFieldOccurredAt = "occurred_at" +) + +const inviteConsumeLockTTLSeconds = 30 + +const ( + OAuthStatePrefixRegister = "reg:" + OAuthStatePrefixLogin = "login:" +) + +// RegistrationSessionRedisKey returns the Redis key for a social registration session. +func RegistrationSessionRedisKey(sessionID string) string { + return fmt.Sprintf("auth:register:session:%s", sessionID) +} + +// LoginSessionRedisKey returns the Redis key for a social login session. +func LoginSessionRedisKey(sessionID string) string { + return fmt.Sprintf("auth:login:session:%s", sessionID) +} + +// NormalizeInviteCode trims and uppercases user input before hashing. +func NormalizeInviteCode(code string) string { + return strings.ToUpper(strings.TrimSpace(code)) +} + +// HashInviteCode returns a stable SHA-256 hex digest for storage and lookup. +func HashInviteCode(code string) string { + normalized := NormalizeInviteCode(code) + sum := sha256.Sum256([]byte(normalized)) + return hex.EncodeToString(sum[:]) +} + +// InviteConsumeLockRedisKey returns the Redis key for serializing invite consumption. +func InviteConsumeLockRedisKey(tenantID, codeHash string) string { + return fmt.Sprintf("auth:invite:consume:%s:%s", tenantID, codeHash) +} + +// InviteConsumeLockTTLSeconds is the Redis lock TTL for Consume. +func InviteConsumeLockTTLSeconds() int { + return inviteConsumeLockTTLSeconds +} + +// JWTPairRedisKey maps an access or refresh jti to its paired jti. +func JWTPairRedisKey(jti string) string { + return fmt.Sprintf("auth:jwt:pair:%s", jti) +} + +// JWTBlacklistRedisKey marks a revoked jti until natural expiry. +func JWTBlacklistRedisKey(jti string) string { + return fmt.Sprintf("auth:jwt:bl:%s", jti) +} diff --git a/internal/model/auth/domain/entity/invite.go b/internal/model/auth/domain/entity/invite.go new file mode 100644 index 0000000..60d9709 --- /dev/null +++ b/internal/model/auth/domain/entity/invite.go @@ -0,0 +1,23 @@ +package entity + +import ( + "go.mongodb.org/mongo-driver/v2/bson" +) + +// InviteCode stores tenant-scoped registration invite metadata. +type InviteCode struct { + ID bson.ObjectID `bson:"_id,omitempty"` + TenantID string `bson:"tenant_id"` + CodeHash string `bson:"code_hash"` + MaxUses int64 `bson:"max_uses"` + UsedCount int64 `bson:"used_count"` + ExpiresAt int64 `bson:"expires_at,omitempty"` + NewUsersOnly bool `bson:"new_users_only"` + CreateAt int64 `bson:"create_at"` + UpdateAt int64 `bson:"update_at"` +} + +// CollectionName returns the MongoDB collection for invite codes. +func (InviteCode) CollectionName() string { + return "invite_codes" +} diff --git a/internal/model/auth/domain/entity/registration_meta.go b/internal/model/auth/domain/entity/registration_meta.go new file mode 100644 index 0000000..87dbc8d --- /dev/null +++ b/internal/model/auth/domain/entity/registration_meta.go @@ -0,0 +1,27 @@ +package entity + +import ( + "gateway/internal/model/auth/domain/enum" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// RegistrationMetadata captures audit fields for a member registration event. +type RegistrationMetadata struct { + ID bson.ObjectID `bson:"_id,omitempty"` + TenantID string `bson:"tenant_id"` + UID string `bson:"uid"` + InviteCodeID string `bson:"invite_code_id,omitempty"` + AcceptTermsVersion string `bson:"accept_terms_version"` + MarketingOptIn bool `bson:"marketing_opt_in"` + RegistrationChannel enum.RegistrationChannel `bson:"registration_channel"` + ClientIP string `bson:"client_ip,omitempty"` + UserAgent string `bson:"user_agent,omitempty"` + OccurredAt int64 `bson:"occurred_at"` + CreateAt int64 `bson:"create_at"` +} + +// CollectionName returns the MongoDB collection for registration metadata. +func (RegistrationMetadata) CollectionName() string { + return "registration_metadata" +} diff --git a/internal/model/auth/domain/enum/registration_channel.go b/internal/model/auth/domain/enum/registration_channel.go new file mode 100644 index 0000000..054b6f6 --- /dev/null +++ b/internal/model/auth/domain/enum/registration_channel.go @@ -0,0 +1,22 @@ +package enum + +// RegistrationChannel identifies how a member registered. +type RegistrationChannel string + +const ( + RegistrationChannelEmail RegistrationChannel = "email" + RegistrationChannelGoogle RegistrationChannel = "google" +) + +func (c RegistrationChannel) String() string { + return string(c) +} + +func (c RegistrationChannel) Valid() bool { + switch c { + case RegistrationChannelEmail, RegistrationChannelGoogle: + return true + default: + return false + } +} diff --git a/internal/model/auth/domain/errors.go b/internal/model/auth/domain/errors.go new file mode 100644 index 0000000..0e68eb5 --- /dev/null +++ b/internal/model/auth/domain/errors.go @@ -0,0 +1,17 @@ +// Package domain holds auth module domain definitions. +package domain + +import "fmt" + +// Module-wide sentinel errors for invite flows. +var ( + ErrInviteNotFound = fmt.Errorf("auth: invite not found") + ErrInviteExpired = fmt.Errorf("auth: invite expired") + ErrInviteExhausted = fmt.Errorf("auth: invite exhausted") + ErrInviteLocked = fmt.Errorf("auth: invite consume locked") + ErrInviteCodeEmpty = fmt.Errorf("auth: invite code is empty") + ErrInviteTenantEmpty = fmt.Errorf("auth: tenant_id is empty") + ErrDuplicateRegistrationMeta = fmt.Errorf("auth: duplicate registration metadata") + ErrRegistrationSessionNotFound = fmt.Errorf("auth: registration session not found") + ErrLoginSessionNotFound = fmt.Errorf("auth: login session not found") +) diff --git a/internal/model/auth/domain/repository/invite.go b/internal/model/auth/domain/repository/invite.go new file mode 100644 index 0000000..ba3141b --- /dev/null +++ b/internal/model/auth/domain/repository/invite.go @@ -0,0 +1,21 @@ +package repository + +import ( + "context" + + "gateway/internal/model/auth/domain/entity" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// InviteRepository persists invite codes. +type InviteRepository interface { + GetByTenantAndCodeHash(ctx context.Context, tenantID, codeHash string) (*entity.InviteCode, error) + ConsumeOne(ctx context.Context, id bson.ObjectID) (*entity.InviteCode, error) +} + +// InviteConsumeLock serializes concurrent consumption for the same invite code. +type InviteConsumeLock interface { + TryLock(ctx context.Context, tenantID, codeHash string) (bool, error) + Unlock(ctx context.Context, tenantID, codeHash string) error +} diff --git a/internal/model/auth/domain/repository/login_session.go b/internal/model/auth/domain/repository/login_session.go new file mode 100644 index 0000000..e1c8374 --- /dev/null +++ b/internal/model/auth/domain/repository/login_session.go @@ -0,0 +1,29 @@ +package repository + +import ( + "context" + "time" + + authdomain "gateway/internal/model/auth/domain" +) + +// LoginSession holds OAuth login-only state in Redis. +type LoginSession struct { + SessionID string + TenantID string + TenantSlug string + Provider string + RedirectURI string +} + +// LoginSessionStore persists short-lived login sessions. +type LoginSessionStore interface { + Save(ctx context.Context, session *LoginSession, ttl time.Duration) error + Get(ctx context.Context, sessionID string) (*LoginSession, error) + Delete(ctx context.Context, sessionID string) error +} + +// LoginSessionRedisKey re-exports the Redis key helper for tests. +func LoginSessionRedisKey(sessionID string) string { + return authdomain.LoginSessionRedisKey(sessionID) +} diff --git a/internal/model/auth/domain/repository/registration_meta.go b/internal/model/auth/domain/repository/registration_meta.go new file mode 100644 index 0000000..768e256 --- /dev/null +++ b/internal/model/auth/domain/repository/registration_meta.go @@ -0,0 +1,12 @@ +package repository + +import ( + "context" + + "gateway/internal/model/auth/domain/entity" +) + +// RegistrationMetaRepository persists registration audit records. +type RegistrationMetaRepository interface { + Insert(ctx context.Context, rec *entity.RegistrationMetadata) error +} diff --git a/internal/model/auth/domain/repository/registration_session.go b/internal/model/auth/domain/repository/registration_session.go new file mode 100644 index 0000000..0bfbbd0 --- /dev/null +++ b/internal/model/auth/domain/repository/registration_session.go @@ -0,0 +1,36 @@ +package repository + +import ( + "context" + "time" + + authdomain "gateway/internal/model/auth/domain" +) + +// RegistrationSession holds OAuth pre-registration state in Redis. +type RegistrationSession struct { + SessionID string + TenantID string + TenantSlug string + InviteCode string + InviteNewUsersOnly bool + AcceptTermsVersion string + MarketingOptIn bool + Language string + Provider string + RedirectURI string + ClientIP string + UserAgent string +} + +// RegistrationSessionStore persists short-lived registration sessions. +type RegistrationSessionStore interface { + Save(ctx context.Context, session *RegistrationSession, ttl time.Duration) error + Get(ctx context.Context, sessionID string) (*RegistrationSession, error) + Delete(ctx context.Context, sessionID string) error +} + +// RegistrationSessionRedisKey re-exports the Redis key helper for tests. +func RegistrationSessionRedisKey(sessionID string) string { + return authdomain.RegistrationSessionRedisKey(sessionID) +} diff --git a/internal/model/auth/domain/repository/token_revoke.go b/internal/model/auth/domain/repository/token_revoke.go new file mode 100644 index 0000000..4a8c1b2 --- /dev/null +++ b/internal/model/auth/domain/repository/token_revoke.go @@ -0,0 +1,15 @@ +package repository + +import ( + "context" + "time" +) + +// TokenRevokeStore tracks access/refresh jti pairs and JWT revocation blacklist. +type TokenRevokeStore interface { + SavePair(ctx context.Context, accessJTI, refreshJTI string, accessTTL, refreshTTL time.Duration) error + GetPairedJTI(ctx context.Context, jti string) (string, error) + DeletePair(ctx context.Context, accessJTI, refreshJTI string) error + Blacklist(ctx context.Context, jti string, ttl time.Duration) error + IsBlacklisted(ctx context.Context, jti string) (bool, error) +} diff --git a/internal/model/auth/domain/usecase/invite.go b/internal/model/auth/domain/usecase/invite.go new file mode 100644 index 0000000..9bdc0f7 --- /dev/null +++ b/internal/model/auth/domain/usecase/invite.go @@ -0,0 +1,37 @@ +package usecase + +import "context" + +// ValidateInviteRequest checks an invite code without consuming it. +type ValidateInviteRequest struct { + TenantID string + Code string +} + +// InviteView is a read-only invite snapshot. +type InviteView struct { + ID string + TenantID string + NewUsersOnly bool + RemainingUses int64 +} + +// ConsumeInviteRequest consumes one use of an invite code. +type ConsumeInviteRequest struct { + TenantID string + Code string +} + +// ConsumedInvite is returned after a successful consume. +type ConsumedInvite struct { + ID string + TenantID string + NewUsersOnly bool + UsedCount int64 +} + +// InviteUseCase validates and consumes registration invite codes. +type InviteUseCase interface { + Validate(ctx context.Context, req *ValidateInviteRequest) (*InviteView, error) + Consume(ctx context.Context, req *ConsumeInviteRequest) (*ConsumedInvite, error) +} diff --git a/internal/model/auth/domain/usecase/login_session.go b/internal/model/auth/domain/usecase/login_session.go new file mode 100644 index 0000000..a482809 --- /dev/null +++ b/internal/model/auth/domain/usecase/login_session.go @@ -0,0 +1,28 @@ +package usecase + +import ( + "context" + "time" +) + +// CreateLoginSessionRequest binds tenant/provider before OAuth login redirect. +type CreateLoginSessionRequest struct { + TenantID string + TenantSlug string + Provider string + RedirectURI string + TTL time.Duration +} + +// LoginSessionView is returned to clients before OAuth login redirect. +type LoginSessionView struct { + SessionID string + ExpiresIn int +} + +// LoginSessionUseCase manages social login sessions (no invite / registration metadata). +type LoginSessionUseCase interface { + Create(ctx context.Context, req *CreateLoginSessionRequest) (*LoginSessionView, error) + Get(ctx context.Context, sessionID string) (*CreateLoginSessionRequest, error) + Delete(ctx context.Context, sessionID string) error +} diff --git a/internal/model/auth/domain/usecase/registration_meta.go b/internal/model/auth/domain/usecase/registration_meta.go new file mode 100644 index 0000000..85d4317 --- /dev/null +++ b/internal/model/auth/domain/usecase/registration_meta.go @@ -0,0 +1,24 @@ +package usecase + +import ( + "context" + + "gateway/internal/model/auth/domain/enum" +) + +// RecordRegistrationRequest stores registration audit metadata. +type RecordRegistrationRequest struct { + TenantID string + UID string + InviteCodeID string + AcceptTermsVersion string + MarketingOptIn bool + Channel enum.RegistrationChannel + ClientIP string + UserAgent string +} + +// RegistrationMetaUseCase records registration audit metadata. +type RegistrationMetaUseCase interface { + Record(ctx context.Context, req *RecordRegistrationRequest) error +} diff --git a/internal/model/auth/domain/usecase/registration_session.go b/internal/model/auth/domain/usecase/registration_session.go new file mode 100644 index 0000000..3909c04 --- /dev/null +++ b/internal/model/auth/domain/usecase/registration_session.go @@ -0,0 +1,35 @@ +package usecase + +import ( + "context" + "time" +) + +// CreateRegistrationSessionRequest binds invite + terms before OAuth redirect. +type CreateRegistrationSessionRequest struct { + TenantID string + TenantSlug string + InviteCode string + InviteNewUsersOnly bool + AcceptTermsVersion string + MarketingOptIn bool + Language string + Provider string + RedirectURI string + ClientIP string + UserAgent string + TTL time.Duration +} + +// RegistrationSessionView is returned to clients before OAuth redirect. +type RegistrationSessionView struct { + SessionID string + ExpiresIn int +} + +// RegistrationSessionUseCase manages social registration sessions. +type RegistrationSessionUseCase interface { + Create(ctx context.Context, req *CreateRegistrationSessionRequest) (*RegistrationSessionView, error) + Get(ctx context.Context, sessionID string) (*CreateRegistrationSessionRequest, error) + Delete(ctx context.Context, sessionID string) error +} diff --git a/internal/model/auth/domain/usecase/token.go b/internal/model/auth/domain/usecase/token.go new file mode 100644 index 0000000..7528d82 --- /dev/null +++ b/internal/model/auth/domain/usecase/token.go @@ -0,0 +1,47 @@ +package usecase + +import "context" + +// TokenType distinguishes CloudEP JWT kinds. +type TokenType string + +const ( + TokenTypeAccess TokenType = "access" + TokenTypeRefresh TokenType = "refresh" +) + +// TokenPair is issued to clients after login or register confirm. +type TokenPair struct { + AccessToken string + RefreshToken string + ExpiresIn int64 + TokenType string +} + +// IssuePairRequest identifies the member receiving tokens. +type IssuePairRequest struct { + TenantID string + UID string + AuthGen int64 +} + +// AccessClaims are parsed from a valid access JWT. +type AccessClaims struct { + TenantID string + UID string + AuthGen int64 + JTI string +} + +// LogoutRequest revokes the current access token and its paired refresh token. +type LogoutRequest struct { + AccessToken string +} + +// TokenUseCase signs and validates CloudEP JWTs. +type TokenUseCase interface { + IssuePair(ctx context.Context, req *IssuePairRequest) (*TokenPair, error) + Refresh(ctx context.Context, refreshToken string) (*TokenPair, error) + Logout(ctx context.Context, req *LogoutRequest) error + ParseAccessToken(ctx context.Context, accessToken string) (*AccessClaims, error) +} diff --git a/internal/model/auth/repository/index.go b/internal/model/auth/repository/index.go new file mode 100644 index 0000000..1476ed2 --- /dev/null +++ b/internal/model/auth/repository/index.go @@ -0,0 +1,37 @@ +package repository + +import ( + "context" + "fmt" + + libmongo "gateway/internal/library/mongo" +) + +// EnsureMongoIndexes creates indexes for auth module collections. +func EnsureMongoIndexes(ctx context.Context, conf *libmongo.Conf) error { + if conf == nil || conf.Host == "" { + return nil + } + if err := ensureInviteIndexes(ctx, conf); err != nil { + return err + } + return ensureRegistrationMetaIndexes(ctx, conf) +} + +func ensureInviteIndexes(ctx context.Context, conf *libmongo.Conf) error { + //nolint:contextcheck // repository ctor pings Mongo at startup without caller ctx + repo, ok := NewInviteRepository(InviteRepositoryParam{Conf: conf}).(*inviteRepository) + if !ok { + return fmt.Errorf("auth: unexpected invite repository type") + } + return repo.Index20260521001UP(ctx) +} + +func ensureRegistrationMetaIndexes(ctx context.Context, conf *libmongo.Conf) error { + //nolint:contextcheck // repository ctor pings Mongo at startup without caller ctx + repo, ok := NewRegistrationMetaRepository(RegistrationMetaRepositoryParam{Conf: conf}).(*registrationMetaRepository) + if !ok { + return fmt.Errorf("auth: unexpected registration metadata repository type") + } + return repo.Index20260521002UP(ctx) +} diff --git a/internal/model/auth/repository/invite_lock_redis.go b/internal/model/auth/repository/invite_lock_redis.go new file mode 100644 index 0000000..08bdc5d --- /dev/null +++ b/internal/model/auth/repository/invite_lock_redis.go @@ -0,0 +1,38 @@ +package repository + +import ( + "context" + + redislib "gateway/internal/library/redis" + authdomain "gateway/internal/model/auth/domain" + domrepo "gateway/internal/model/auth/domain/repository" +) + +type redisInviteConsumeLock struct { + client *redislib.Client +} + +// NewRedisInviteConsumeLock creates a Redis-backed invite consume lock. +func NewRedisInviteConsumeLock(client *redislib.Client) domrepo.InviteConsumeLock { + if client == nil || client.Zero() == nil { + panic("auth: redis client is required for invite consume lock") + } + return &redisInviteConsumeLock{client: client} +} + +func (s *redisInviteConsumeLock) TryLock(ctx context.Context, tenantID, codeHash string) (bool, error) { + key := authdomain.InviteConsumeLockRedisKey(tenantID, codeHash) + ok, err := s.client.Zero().SetnxExCtx(ctx, key, "1", authdomain.InviteConsumeLockTTLSeconds()) + if err != nil { + return false, err + } + return ok, nil +} + +func (s *redisInviteConsumeLock) Unlock(ctx context.Context, tenantID, codeHash string) error { + key := authdomain.InviteConsumeLockRedisKey(tenantID, codeHash) + _, err := s.client.Zero().DelCtx(ctx, key) + return err +} + +var _ domrepo.InviteConsumeLock = (*redisInviteConsumeLock)(nil) diff --git a/internal/model/auth/repository/invite_mongo.go b/internal/model/auth/repository/invite_mongo.go new file mode 100644 index 0000000..6dccbf3 --- /dev/null +++ b/internal/model/auth/repository/invite_mongo.go @@ -0,0 +1,87 @@ +package repository + +import ( + "context" + "errors" + "time" + + libmongo "gateway/internal/library/mongo" + authdomain "gateway/internal/model/auth/domain" + "gateway/internal/model/auth/domain/entity" + domrepo "gateway/internal/model/auth/domain/repository" + + "go.mongodb.org/mongo-driver/v2/bson" + mongodriver "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// InviteRepositoryParam configures the Mongo invite repository. +type InviteRepositoryParam struct { + Conf *libmongo.Conf +} + +type inviteRepository struct { + db libmongo.DocumentDBUseCase +} + +// NewInviteRepository creates a Mongo-backed InviteRepository. +func NewInviteRepository(param InviteRepositoryParam) domrepo.InviteRepository { + documentDB, err := libmongo.NewDocumentDB(param.Conf, entity.InviteCode{}.CollectionName()) + if err != nil { + panic(err) + } + return &inviteRepository{db: documentDB} +} + +func (r *inviteRepository) GetByTenantAndCodeHash(ctx context.Context, tenantID, codeHash string) (*entity.InviteCode, error) { + var doc entity.InviteCode + filter := bson.M{ + authdomain.BSONFieldTenantID: tenantID, + authdomain.BSONFieldCodeHash: codeHash, + } + if err := r.db.GetClient().FindOne(ctx, &doc, filter); err != nil { + if errors.Is(err, mongodriver.ErrNoDocuments) { + return nil, authdomain.ErrInviteNotFound + } + return nil, err + } + return &doc, nil +} + +func (r *inviteRepository) ConsumeOne(ctx context.Context, id bson.ObjectID) (*entity.InviteCode, error) { + now := time.Now().UTC().UnixMilli() + filter := bson.M{ + authdomain.BSONFieldID: id, + "$expr": bson.M{ + "$lt": bson.A{"$" + authdomain.BSONFieldUsedCount, "$" + authdomain.BSONFieldMaxUses}, + }, + "$or": bson.A{ + bson.M{authdomain.BSONFieldExpiresAt: bson.M{"$lte": 0}}, + bson.M{authdomain.BSONFieldExpiresAt: bson.M{"$gt": now}}, + }, + } + update := bson.M{ + "$inc": bson.M{authdomain.BSONFieldUsedCount: 1}, + "$set": bson.M{authdomain.BSONFieldUpdateAt: now}, + } + opts := options.FindOneAndUpdate().SetReturnDocument(options.After) + var doc entity.InviteCode + if err := r.db.GetClient().FindOneAndUpdate(ctx, &doc, filter, update, opts); err != nil { + if errors.Is(err, mongodriver.ErrNoDocuments) { + return nil, authdomain.ErrInviteExhausted + } + return nil, err + } + return &doc, nil +} + +// Index20260521001UP ensures invite_codes collection indexes exist. +func (r *inviteRepository) Index20260521001UP(ctx context.Context) error { + return r.db.PopulateMultiIndex(ctx, + []string{authdomain.BSONFieldTenantID, authdomain.BSONFieldCodeHash}, + []int32{1, 1}, + true, + ) +} + +var _ domrepo.InviteRepository = (*inviteRepository)(nil) diff --git a/internal/model/auth/repository/login_session_redis.go b/internal/model/auth/repository/login_session_redis.go new file mode 100644 index 0000000..3a11230 --- /dev/null +++ b/internal/model/auth/repository/login_session_redis.go @@ -0,0 +1,64 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + redislib "gateway/internal/library/redis" + authdomain "gateway/internal/model/auth/domain" + domrepo "gateway/internal/model/auth/domain/repository" + + "github.com/zeromicro/go-zero/core/stores/redis" +) + +type redisLoginSessionStore struct { + client *redis.Redis +} + +// NewRedisLoginSessionStore creates a Redis-backed login session store. +func NewRedisLoginSessionStore(client *redislib.Client) domrepo.LoginSessionStore { + if client == nil || client.Zero() == nil { + panic("auth: redis client is required for login session store") + } + return &redisLoginSessionStore{client: client.Zero()} +} + +func (s *redisLoginSessionStore) Save(ctx context.Context, session *domrepo.LoginSession, ttl time.Duration) error { + if session == nil || session.SessionID == "" { + return fmt.Errorf("auth: login session id is required") + } + raw, err := json.Marshal(session) + if err != nil { + return fmt.Errorf("auth: marshal login session: %w", err) + } + seconds := int(ttl.Seconds()) + if seconds < 1 { + seconds = 1 + } + return s.client.SetexCtx(ctx, authdomain.LoginSessionRedisKey(session.SessionID), string(raw), seconds) +} + +func (s *redisLoginSessionStore) Get(ctx context.Context, sessionID string) (*domrepo.LoginSession, error) { + val, err := s.client.GetCtx(ctx, authdomain.LoginSessionRedisKey(sessionID)) + if errors.Is(err, redis.Nil) { + return nil, authdomain.ErrLoginSessionNotFound + } + if err != nil { + return nil, err + } + var session domrepo.LoginSession + if err := json.Unmarshal([]byte(val), &session); err != nil { + return nil, fmt.Errorf("auth: unmarshal login session: %w", err) + } + return &session, nil +} + +func (s *redisLoginSessionStore) Delete(ctx context.Context, sessionID string) error { + _, err := s.client.DelCtx(ctx, authdomain.LoginSessionRedisKey(sessionID)) + return err +} + +var _ domrepo.LoginSessionStore = (*redisLoginSessionStore)(nil) diff --git a/internal/model/auth/repository/registration_meta_mongo.go b/internal/model/auth/repository/registration_meta_mongo.go new file mode 100644 index 0000000..21248d4 --- /dev/null +++ b/internal/model/auth/repository/registration_meta_mongo.go @@ -0,0 +1,64 @@ +package repository + +import ( + "context" + "time" + + libmongo "gateway/internal/library/mongo" + authdomain "gateway/internal/model/auth/domain" + "gateway/internal/model/auth/domain/entity" + domrepo "gateway/internal/model/auth/domain/repository" + + "go.mongodb.org/mongo-driver/v2/bson" + mongodriver "go.mongodb.org/mongo-driver/v2/mongo" +) + +// RegistrationMetaRepositoryParam configures the Mongo registration metadata repository. +type RegistrationMetaRepositoryParam struct { + Conf *libmongo.Conf +} + +type registrationMetaRepository struct { + db libmongo.DocumentDBUseCase +} + +// NewRegistrationMetaRepository creates a Mongo-backed RegistrationMetaRepository. +func NewRegistrationMetaRepository(param RegistrationMetaRepositoryParam) domrepo.RegistrationMetaRepository { + documentDB, err := libmongo.NewDocumentDB(param.Conf, entity.RegistrationMetadata{}.CollectionName()) + if err != nil { + panic(err) + } + return ®istrationMetaRepository{db: documentDB} +} + +func (r *registrationMetaRepository) Insert(ctx context.Context, rec *entity.RegistrationMetadata) error { + now := time.Now().UTC().UnixMilli() + if rec.ID.IsZero() { + rec.ID = bson.NewObjectID() + } + if rec.CreateAt == 0 { + rec.CreateAt = now + } + if rec.OccurredAt == 0 { + rec.OccurredAt = now + } + _, err := r.db.GetClient().InsertOne(ctx, rec) + if err != nil { + if mongodriver.IsDuplicateKeyError(err) { + return authdomain.ErrDuplicateRegistrationMeta + } + return err + } + return nil +} + +// Index20260521002UP ensures registration_metadata collection indexes exist. +func (r *registrationMetaRepository) Index20260521002UP(ctx context.Context) error { + return r.db.PopulateMultiIndex(ctx, + []string{authdomain.BSONFieldTenantID, authdomain.BSONFieldUID}, + []int32{1, 1}, + true, + ) +} + +var _ domrepo.RegistrationMetaRepository = (*registrationMetaRepository)(nil) diff --git a/internal/model/auth/repository/registration_session_redis.go b/internal/model/auth/repository/registration_session_redis.go new file mode 100644 index 0000000..06aca77 --- /dev/null +++ b/internal/model/auth/repository/registration_session_redis.go @@ -0,0 +1,64 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + redislib "gateway/internal/library/redis" + authdomain "gateway/internal/model/auth/domain" + domrepo "gateway/internal/model/auth/domain/repository" + + "github.com/zeromicro/go-zero/core/stores/redis" +) + +type redisRegistrationSessionStore struct { + client *redis.Redis +} + +// NewRedisRegistrationSessionStore creates a Redis-backed registration session store. +func NewRedisRegistrationSessionStore(client *redislib.Client) domrepo.RegistrationSessionStore { + if client == nil || client.Zero() == nil { + panic("auth: redis client is required for registration session store") + } + return &redisRegistrationSessionStore{client: client.Zero()} +} + +func (s *redisRegistrationSessionStore) Save(ctx context.Context, session *domrepo.RegistrationSession, ttl time.Duration) error { + if session == nil || session.SessionID == "" { + return fmt.Errorf("auth: registration session id is required") + } + raw, err := json.Marshal(session) + if err != nil { + return fmt.Errorf("auth: marshal registration session: %w", err) + } + seconds := int(ttl.Seconds()) + if seconds < 1 { + seconds = 1 + } + return s.client.SetexCtx(ctx, authdomain.RegistrationSessionRedisKey(session.SessionID), string(raw), seconds) +} + +func (s *redisRegistrationSessionStore) Get(ctx context.Context, sessionID string) (*domrepo.RegistrationSession, error) { + val, err := s.client.GetCtx(ctx, authdomain.RegistrationSessionRedisKey(sessionID)) + if errors.Is(err, redis.Nil) { + return nil, authdomain.ErrRegistrationSessionNotFound + } + if err != nil { + return nil, err + } + var session domrepo.RegistrationSession + if err := json.Unmarshal([]byte(val), &session); err != nil { + return nil, fmt.Errorf("auth: unmarshal registration session: %w", err) + } + return &session, nil +} + +func (s *redisRegistrationSessionStore) Delete(ctx context.Context, sessionID string) error { + _, err := s.client.DelCtx(ctx, authdomain.RegistrationSessionRedisKey(sessionID)) + return err +} + +var _ domrepo.RegistrationSessionStore = (*redisRegistrationSessionStore)(nil) diff --git a/internal/model/auth/repository/token_revoke_redis.go b/internal/model/auth/repository/token_revoke_redis.go new file mode 100644 index 0000000..ecc3506 --- /dev/null +++ b/internal/model/auth/repository/token_revoke_redis.go @@ -0,0 +1,95 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "time" + + redislib "gateway/internal/library/redis" + authdomain "gateway/internal/model/auth/domain" + domrepo "gateway/internal/model/auth/domain/repository" + + "github.com/zeromicro/go-zero/core/stores/redis" +) + +type redisTokenRevokeStore struct { + client *redis.Redis +} + +// NewRedisTokenRevokeStore creates a Redis-backed JWT revoke store. +func NewRedisTokenRevokeStore(client *redislib.Client) domrepo.TokenRevokeStore { + if client == nil || client.Zero() == nil { + panic("auth: redis client is required for token revoke store") + } + return &redisTokenRevokeStore{client: client.Zero()} +} + +func (s *redisTokenRevokeStore) SavePair(ctx context.Context, accessJTI, refreshJTI string, accessTTL, refreshTTL time.Duration) error { + if accessJTI == "" || refreshJTI == "" { + return fmt.Errorf("auth: jwt pair jti is required") + } + accessSec := ttlSeconds(accessTTL) + refreshSec := ttlSeconds(refreshTTL) + if err := s.client.SetexCtx(ctx, authdomain.JWTPairRedisKey(accessJTI), refreshJTI, accessSec); err != nil { + return err + } + return s.client.SetexCtx(ctx, authdomain.JWTPairRedisKey(refreshJTI), accessJTI, refreshSec) +} + +func (s *redisTokenRevokeStore) GetPairedJTI(ctx context.Context, jti string) (string, error) { + if jti == "" { + return "", fmt.Errorf("auth: jti is required") + } + val, err := s.client.GetCtx(ctx, authdomain.JWTPairRedisKey(jti)) + if errors.Is(err, redis.Nil) || val == "" { + return "", nil + } + if err != nil { + return "", err + } + return val, nil +} + +func (s *redisTokenRevokeStore) DeletePair(ctx context.Context, accessJTI, refreshJTI string) error { + keys := make([]string, 0, 2) + if accessJTI != "" { + keys = append(keys, authdomain.JWTPairRedisKey(accessJTI)) + } + if refreshJTI != "" { + keys = append(keys, authdomain.JWTPairRedisKey(refreshJTI)) + } + if len(keys) == 0 { + return nil + } + _, err := s.client.DelCtx(ctx, keys...) + return err +} + +func (s *redisTokenRevokeStore) Blacklist(ctx context.Context, jti string, ttl time.Duration) error { + if jti == "" { + return fmt.Errorf("auth: jti is required") + } + return s.client.SetexCtx(ctx, authdomain.JWTBlacklistRedisKey(jti), "1", ttlSeconds(ttl)) +} + +func (s *redisTokenRevokeStore) IsBlacklisted(ctx context.Context, jti string) (bool, error) { + if jti == "" { + return false, fmt.Errorf("auth: jti is required") + } + exists, err := s.client.ExistsCtx(ctx, authdomain.JWTBlacklistRedisKey(jti)) + if err != nil { + return false, err + } + return exists, nil +} + +func ttlSeconds(d time.Duration) int { + sec := int(d.Round(time.Second).Seconds()) + if sec < 1 { + return 1 + } + return sec +} + +var _ domrepo.TokenRevokeStore = (*redisTokenRevokeStore)(nil) diff --git a/internal/model/auth/usecase/errors.go b/internal/model/auth/usecase/errors.go new file mode 100644 index 0000000..87747b8 --- /dev/null +++ b/internal/model/auth/usecase/errors.go @@ -0,0 +1,47 @@ +package usecase + +import ( + "errors" + "strings" + + errs "gateway/internal/library/errors" + "gateway/internal/library/errors/code" + authdomain "gateway/internal/model/auth/domain" +) + +var errb = errs.For(code.Auth) + +func wrapRepoErr(err error, msg ...string) error { + if err == nil { + return nil + } + if errors.Is(err, authdomain.ErrInviteNotFound) { + return errb.ResNotFound("invite", "").WithCause(err) + } + if errors.Is(err, authdomain.ErrInviteExpired) { + return errb.InputInvalidFormat("invite code expired").WithCause(err) + } + if errors.Is(err, authdomain.ErrInviteExhausted) { + return errb.ResInsufficientQuota("invite code exhausted").WithCause(err) + } + if errors.Is(err, authdomain.ErrInviteLocked) { + return errb.ResLocked("invite consume in progress").WithCause(err) + } + if errors.Is(err, authdomain.ErrDuplicateRegistrationMeta) { + return errb.ResAlreadyExist("registration metadata already exists").WithCause(err) + } + if errors.Is(err, authdomain.ErrRegistrationSessionNotFound) { + return errb.ResNotFound("registration session", "").WithCause(err) + } + if errors.Is(err, authdomain.ErrLoginSessionNotFound) { + return errb.ResNotFound("login session", "").WithCause(err) + } + if e := errs.FromError(err); e != nil { + return err + } + m := strings.TrimSpace(strings.Join(msg, " ")) + if m == "" { + m = "auth repository error" + } + return errb.DBError(m).WithCause(err) +} diff --git a/internal/model/auth/usecase/invite_usecase.go b/internal/model/auth/usecase/invite_usecase.go new file mode 100644 index 0000000..c6932f5 --- /dev/null +++ b/internal/model/auth/usecase/invite_usecase.go @@ -0,0 +1,148 @@ +package usecase + +import ( + "context" + "strings" + "time" + + authdomain "gateway/internal/model/auth/domain" + "gateway/internal/model/auth/domain/entity" + domrepo "gateway/internal/model/auth/domain/repository" + domusecase "gateway/internal/model/auth/domain/usecase" + + "github.com/zeromicro/go-zero/core/logx" +) + +type inviteUseCase struct { + repo domrepo.InviteRepository + lock domrepo.InviteConsumeLock +} + +// InviteUseCaseParam wires InviteUseCase. +type InviteUseCaseParam struct { + Repo domrepo.InviteRepository + Lock domrepo.InviteConsumeLock +} + +// MustInviteUseCase constructs InviteUseCase. +func MustInviteUseCase(param InviteUseCaseParam) domusecase.InviteUseCase { + if param.Repo == nil { + panic("auth: invite repository is required") + } + if param.Lock == nil { + panic("auth: invite consume lock is required") + } + return &inviteUseCase{repo: param.Repo, lock: param.Lock} +} + +func (uc *inviteUseCase) Validate(ctx context.Context, req *domusecase.ValidateInviteRequest) (*domusecase.InviteView, error) { + if req == nil { + return nil, errb.InputMissingRequired("invite request is required") + } + invite, err := uc.lookup(ctx, req) + if err != nil { + return nil, err + } + return toInviteView(invite), nil +} + +func (uc *inviteUseCase) Consume(ctx context.Context, req *domusecase.ConsumeInviteRequest) (*domusecase.ConsumedInvite, error) { + tenantID, code, err := normalizeInviteInput(req) + if err != nil { + return nil, err + } + codeHash := authdomain.HashInviteCode(code) + + ok, err := uc.lock.TryLock(ctx, tenantID, codeHash) + if err != nil { + return nil, wrapRepoErr(err, "invite consume lock failed") + } + if !ok { + return nil, wrapRepoErr(authdomain.ErrInviteLocked) + } + defer func() { + if err := uc.lock.Unlock(ctx, tenantID, codeHash); err != nil { + logx.WithContext(ctx).Errorf("auth: invite unlock failed tenant=%s codeHash=%s: %v", tenantID, codeHash, err) + } + }() + + invite, err := uc.repo.GetByTenantAndCodeHash(ctx, tenantID, codeHash) + if err != nil { + return nil, wrapRepoErr(err) + } + if err := checkInviteActive(invite); err != nil { + return nil, wrapRepoErr(err) + } + + consumed, err := uc.repo.ConsumeOne(ctx, invite.ID) + if err != nil { + return nil, wrapRepoErr(err) + } + return &domusecase.ConsumedInvite{ + ID: consumed.ID.Hex(), + TenantID: consumed.TenantID, + NewUsersOnly: consumed.NewUsersOnly, + UsedCount: consumed.UsedCount, + }, nil +} + +func (uc *inviteUseCase) lookup(ctx context.Context, req *domusecase.ValidateInviteRequest) (*entity.InviteCode, error) { + tenantID, code, err := normalizeInviteFields(req.TenantID, req.Code) + if err != nil { + return nil, err + } + invite, err := uc.repo.GetByTenantAndCodeHash(ctx, tenantID, authdomain.HashInviteCode(code)) + if err != nil { + return nil, wrapRepoErr(err) + } + if err := checkInviteActive(invite); err != nil { + return nil, wrapRepoErr(err) + } + return invite, nil +} + +func normalizeInviteInput(req *domusecase.ConsumeInviteRequest) (tenantID, code string, err error) { + if req == nil { + return "", "", errb.InputMissingRequired("invite request is required") + } + return normalizeInviteFields(req.TenantID, req.Code) +} + +func normalizeInviteFields(tenantIDRaw, codeRaw string) (tenantID, code string, err error) { + tenantID = strings.TrimSpace(tenantIDRaw) + code = authdomain.NormalizeInviteCode(codeRaw) + if tenantID == "" { + return "", "", errb.InputMissingRequired("tenant_id is required") + } + if code == "" { + return "", "", errb.InputMissingRequired("invite_code is required") + } + return tenantID, code, nil +} + +func checkInviteActive(invite *entity.InviteCode) error { + if invite == nil { + return authdomain.ErrInviteNotFound + } + now := time.Now().UTC().UnixMilli() + if invite.ExpiresAt > 0 && invite.ExpiresAt <= now { + return authdomain.ErrInviteExpired + } + if invite.UsedCount >= invite.MaxUses { + return authdomain.ErrInviteExhausted + } + return nil +} + +func toInviteView(invite *entity.InviteCode) *domusecase.InviteView { + remaining := invite.MaxUses - invite.UsedCount + if remaining < 0 { + remaining = 0 + } + return &domusecase.InviteView{ + ID: invite.ID.Hex(), + TenantID: invite.TenantID, + NewUsersOnly: invite.NewUsersOnly, + RemainingUses: remaining, + } +} diff --git a/internal/model/auth/usecase/invite_usecase_test.go b/internal/model/auth/usecase/invite_usecase_test.go new file mode 100644 index 0000000..bb7ee15 --- /dev/null +++ b/internal/model/auth/usecase/invite_usecase_test.go @@ -0,0 +1,199 @@ +package usecase_test + +import ( + "context" + "sync" + "testing" + "time" + + authdomain "gateway/internal/model/auth/domain" + "gateway/internal/model/auth/domain/entity" + domrepo "gateway/internal/model/auth/domain/repository" + domusecase "gateway/internal/model/auth/domain/usecase" + authusecase "gateway/internal/model/auth/usecase" + + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" +) + +const testTenantAcme = "acme" + +func TestInviteUseCaseValidateAndConsume(t *testing.T) { + t.Parallel() + repo := newMemoryInviteRepo() + lock := newMemoryInviteLock() + uc := authusecase.MustInviteUseCase(authusecase.InviteUseCaseParam{ + Repo: repo, + Lock: lock, + }) + ctx := context.Background() + + repo.seed(&entity.InviteCode{ + ID: bson.NewObjectID(), + TenantID: testTenantAcme, + CodeHash: authdomain.HashInviteCode("BETA-2026-TEST"), + MaxUses: 2, + NewUsersOnly: true, + }) + + view, err := uc.Validate(ctx, &domusecase.ValidateInviteRequest{ + TenantID: testTenantAcme, + Code: "beta-2026-test", + }) + require.NoError(t, err) + require.Equal(t, testTenantAcme, view.TenantID) + require.Equal(t, int64(2), view.RemainingUses) + require.True(t, view.NewUsersOnly) + + consumed, err := uc.Consume(ctx, &domusecase.ConsumeInviteRequest{ + TenantID: testTenantAcme, + Code: "BETA-2026-TEST", + }) + require.NoError(t, err) + require.Equal(t, int64(1), consumed.UsedCount) + + view, err = uc.Validate(ctx, &domusecase.ValidateInviteRequest{ + TenantID: testTenantAcme, + Code: "BETA-2026-TEST", + }) + require.NoError(t, err) + require.Equal(t, int64(1), view.RemainingUses) +} + +func TestInviteUseCaseExpired(t *testing.T) { + t.Parallel() + repo := newMemoryInviteRepo() + uc := authusecase.MustInviteUseCase(authusecase.InviteUseCaseParam{ + Repo: repo, + Lock: newMemoryInviteLock(), + }) + + repo.seed(&entity.InviteCode{ + ID: bson.NewObjectID(), + TenantID: testTenantAcme, + CodeHash: authdomain.HashInviteCode("EXPIRED"), + MaxUses: 1, + ExpiresAt: time.Now().UTC().Add(-time.Hour).UnixMilli(), + }) + + _, err := uc.Validate(context.Background(), &domusecase.ValidateInviteRequest{ + TenantID: testTenantAcme, + Code: "EXPIRED", + }) + require.Error(t, err) +} + +func TestInviteUseCaseConcurrentConsume(t *testing.T) { + t.Parallel() + repo := newMemoryInviteRepo() + lock := newMemoryInviteLock() + uc := authusecase.MustInviteUseCase(authusecase.InviteUseCaseParam{ + Repo: repo, + Lock: lock, + }) + + repo.seed(&entity.InviteCode{ + ID: bson.NewObjectID(), + TenantID: testTenantAcme, + CodeHash: authdomain.HashInviteCode("ONCE"), + MaxUses: 1, + }) + + var wg sync.WaitGroup + successes := make(chan struct{}, 2) + failures := make(chan struct{}, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, err := uc.Consume(context.Background(), &domusecase.ConsumeInviteRequest{ + TenantID: testTenantAcme, + Code: "ONCE", + }) + if err == nil { + successes <- struct{}{} + return + } + failures <- struct{}{} + }() + } + wg.Wait() + close(successes) + close(failures) + require.Len(t, successes, 1) + require.Len(t, failures, 1) +} + +type memoryInviteRepo struct { + mu sync.Mutex + items map[string]*entity.InviteCode +} + +func newMemoryInviteRepo() *memoryInviteRepo { + return &memoryInviteRepo{items: make(map[string]*entity.InviteCode)} +} + +func (r *memoryInviteRepo) key(tenantID, codeHash string) string { + return tenantID + ":" + codeHash +} + +func (r *memoryInviteRepo) seed(invite *entity.InviteCode) { + r.mu.Lock() + defer r.mu.Unlock() + cp := *invite + r.items[r.key(invite.TenantID, invite.CodeHash)] = &cp +} + +func (r *memoryInviteRepo) GetByTenantAndCodeHash(_ context.Context, tenantID, codeHash string) (*entity.InviteCode, error) { + r.mu.Lock() + defer r.mu.Unlock() + invite, ok := r.items[r.key(tenantID, codeHash)] + if !ok { + return nil, authdomain.ErrInviteNotFound + } + cp := *invite + return &cp, nil +} + +func (r *memoryInviteRepo) ConsumeOne(_ context.Context, id bson.ObjectID) (*entity.InviteCode, error) { + r.mu.Lock() + defer r.mu.Unlock() + for _, invite := range r.items { + if invite.ID != id { + continue + } + now := time.Now().UTC().UnixMilli() + if invite.ExpiresAt > 0 && invite.ExpiresAt <= now { + return nil, authdomain.ErrInviteExpired + } + if invite.UsedCount >= invite.MaxUses { + return nil, authdomain.ErrInviteExhausted + } + invite.UsedCount++ + cp := *invite + return &cp, nil + } + return nil, authdomain.ErrInviteNotFound +} + +var _ domrepo.InviteRepository = (*memoryInviteRepo)(nil) + +type memoryInviteLock struct { + mu sync.Mutex +} + +func newMemoryInviteLock() *memoryInviteLock { + return &memoryInviteLock{} +} + +func (l *memoryInviteLock) TryLock(_ context.Context, _, _ string) (bool, error) { + l.mu.Lock() + return true, nil +} + +func (l *memoryInviteLock) Unlock(_ context.Context, _, _ string) error { + l.mu.Unlock() + return nil +} + +var _ domrepo.InviteConsumeLock = (*memoryInviteLock)(nil) diff --git a/internal/model/auth/usecase/login_session_usecase.go b/internal/model/auth/usecase/login_session_usecase.go new file mode 100644 index 0000000..71b7b64 --- /dev/null +++ b/internal/model/auth/usecase/login_session_usecase.go @@ -0,0 +1,92 @@ +package usecase + +import ( + "context" + "errors" + "time" + + authdomain "gateway/internal/model/auth/domain" + domrepo "gateway/internal/model/auth/domain/repository" + domusecase "gateway/internal/model/auth/domain/usecase" + + "github.com/google/uuid" +) + +type loginSessionUseCase struct { + store domrepo.LoginSessionStore +} + +// LoginSessionUseCaseParam wires LoginSessionUseCase. +type LoginSessionUseCaseParam struct { + Store domrepo.LoginSessionStore +} + +// MustLoginSessionUseCase constructs LoginSessionUseCase. +func MustLoginSessionUseCase(param LoginSessionUseCaseParam) domusecase.LoginSessionUseCase { + if param.Store == nil { + panic("auth: login session store is required") + } + return &loginSessionUseCase{store: param.Store} +} + +func (uc *loginSessionUseCase) Create(ctx context.Context, req *domusecase.CreateLoginSessionRequest) (*domusecase.LoginSessionView, error) { + if req == nil || req.TenantID == "" || req.TenantSlug == "" { + return nil, errb.InputMissingRequired("tenant_id and tenant_slug are required") + } + if req.Provider == "" { + return nil, errb.InputMissingRequired("provider is required") + } + if req.RedirectURI == "" { + return nil, errb.InputMissingRequired("redirect_uri is required") + } + ttl := req.TTL + if ttl <= 0 { + ttl = 10 * time.Minute + } + sessionID := uuid.NewString() + session := &domrepo.LoginSession{ + SessionID: sessionID, + TenantID: req.TenantID, + TenantSlug: req.TenantSlug, + Provider: req.Provider, + RedirectURI: req.RedirectURI, + } + if err := uc.store.Save(ctx, session, ttl); err != nil { + return nil, wrapRepoErr(err, "save login session failed") + } + return &domusecase.LoginSessionView{ + SessionID: sessionID, + ExpiresIn: int(ttl.Seconds()), + }, nil +} + +func (uc *loginSessionUseCase) Get(ctx context.Context, sessionID string) (*domusecase.CreateLoginSessionRequest, error) { + if sessionID == "" { + return nil, errb.InputMissingRequired("session_id is required") + } + session, err := uc.store.Get(ctx, sessionID) + if err != nil { + if errors.Is(err, authdomain.ErrLoginSessionNotFound) { + return nil, errb.ResNotFound("login session", sessionID).WithCause(err) + } + return nil, wrapRepoErr(err, "read login session failed") + } + return &domusecase.CreateLoginSessionRequest{ + TenantID: session.TenantID, + TenantSlug: session.TenantSlug, + Provider: session.Provider, + RedirectURI: session.RedirectURI, + }, nil +} + +func (uc *loginSessionUseCase) Delete(ctx context.Context, sessionID string) error { + if sessionID == "" { + return errb.InputMissingRequired("session_id is required") + } + if err := uc.store.Delete(ctx, sessionID); err != nil { + return wrapRepoErr(err, "delete login session failed") + } + return nil +} + +var _ domusecase.LoginSessionUseCase = (*loginSessionUseCase)(nil) diff --git a/internal/model/auth/usecase/module.go b/internal/model/auth/usecase/module.go new file mode 100644 index 0000000..af12c5f --- /dev/null +++ b/internal/model/auth/usecase/module.go @@ -0,0 +1,73 @@ +package usecase + +import ( + "fmt" + + libmongo "gateway/internal/library/mongo" + redislib "gateway/internal/library/redis" + domrepo "gateway/internal/model/auth/domain/repository" + domusecase "gateway/internal/model/auth/domain/usecase" + "gateway/internal/model/auth/repository" +) + +// Module bundles auth atomic primitives. +type Module struct { + Invite domusecase.InviteUseCase + RegistrationMeta domusecase.RegistrationMetaUseCase + RegistrationSession domusecase.RegistrationSessionUseCase + LoginSession domusecase.LoginSessionUseCase + + Invites domrepo.InviteRepository + RegistrationMetaRepo domrepo.RegistrationMetaRepository +} + +// ModuleParam wires auth module dependencies. +type ModuleParam struct { + Redis *redislib.Client + MongoConf *libmongo.Conf + + // Optional overrides for tests. + Invites domrepo.InviteRepository + Lock domrepo.InviteConsumeLock +} + +// NewModuleFromParam builds auth atomic usecases. +func NewModuleFromParam(param ModuleParam) (*Module, error) { + if param.Redis == nil || param.Redis.Zero() == nil { + return nil, fmt.Errorf("auth: redis is required") + } + if param.MongoConf == nil || param.MongoConf.Host == "" { + return nil, fmt.Errorf("auth: mongo is required for invite usecase") + } + + invites := param.Invites + if invites == nil { + invites = repository.NewInviteRepository(repository.InviteRepositoryParam{Conf: param.MongoConf}) + } + regMetaRepo := repository.NewRegistrationMetaRepository(repository.RegistrationMetaRepositoryParam{Conf: param.MongoConf}) + sessionStore := repository.NewRedisRegistrationSessionStore(param.Redis) + loginStore := repository.NewRedisLoginSessionStore(param.Redis) + lock := param.Lock + if lock == nil { + lock = repository.NewRedisInviteConsumeLock(param.Redis) + } + + mod := &Module{ + Invites: invites, + RegistrationMetaRepo: regMetaRepo, + Invite: MustInviteUseCase(InviteUseCaseParam{ + Repo: invites, + Lock: lock, + }), + RegistrationMeta: MustRegistrationMetaUseCase(RegistrationMetaUseCaseParam{ + Repo: regMetaRepo, + }), + RegistrationSession: MustRegistrationSessionUseCase(RegistrationSessionUseCaseParam{ + Store: sessionStore, + }), + LoginSession: MustLoginSessionUseCase(LoginSessionUseCaseParam{ + Store: loginStore, + }), + } + return mod, nil +} diff --git a/internal/model/auth/usecase/registration_meta_usecase.go b/internal/model/auth/usecase/registration_meta_usecase.go new file mode 100644 index 0000000..a946303 --- /dev/null +++ b/internal/model/auth/usecase/registration_meta_usecase.go @@ -0,0 +1,63 @@ +package usecase + +import ( + "context" + "errors" + "time" + + authdomain "gateway/internal/model/auth/domain" + "gateway/internal/model/auth/domain/entity" + domrepo "gateway/internal/model/auth/domain/repository" + domusecase "gateway/internal/model/auth/domain/usecase" +) + +type registrationMetaUseCase struct { + repo domrepo.RegistrationMetaRepository +} + +// RegistrationMetaUseCaseParam wires RegistrationMetaUseCase. +type RegistrationMetaUseCaseParam struct { + Repo domrepo.RegistrationMetaRepository +} + +// MustRegistrationMetaUseCase constructs RegistrationMetaUseCase. +func MustRegistrationMetaUseCase(param RegistrationMetaUseCaseParam) domusecase.RegistrationMetaUseCase { + if param.Repo == nil { + panic("auth: registration metadata repository is required") + } + return ®istrationMetaUseCase{repo: param.Repo} +} + +func (uc *registrationMetaUseCase) Record(ctx context.Context, req *domusecase.RecordRegistrationRequest) error { + if req == nil || req.TenantID == "" || req.UID == "" { + return errb.InputMissingRequired("tenant_id and uid are required") + } + if req.AcceptTermsVersion == "" { + return errb.InputMissingRequired("accept_terms_version is required") + } + if !req.Channel.Valid() { + return errb.InputInvalidFormat("invalid registration channel") + } + now := time.Now().UTC().UnixMilli() + rec := &entity.RegistrationMetadata{ + TenantID: req.TenantID, + UID: req.UID, + InviteCodeID: req.InviteCodeID, + AcceptTermsVersion: req.AcceptTermsVersion, + MarketingOptIn: req.MarketingOptIn, + RegistrationChannel: req.Channel, + ClientIP: req.ClientIP, + UserAgent: req.UserAgent, + OccurredAt: now, + CreateAt: now, + } + if err := uc.repo.Insert(ctx, rec); err != nil { + if errors.Is(err, authdomain.ErrDuplicateRegistrationMeta) { + return errb.ResAlreadyExist("registration metadata already exists").WithCause(err) + } + return wrapRepoErr(err, "insert registration metadata failed") + } + return nil +} + +var _ domusecase.RegistrationMetaUseCase = (*registrationMetaUseCase)(nil) diff --git a/internal/model/auth/usecase/registration_session_usecase.go b/internal/model/auth/usecase/registration_session_usecase.go new file mode 100644 index 0000000..3abb780 --- /dev/null +++ b/internal/model/auth/usecase/registration_session_usecase.go @@ -0,0 +1,112 @@ +package usecase + +import ( + "context" + "errors" + "time" + + authdomain "gateway/internal/model/auth/domain" + domrepo "gateway/internal/model/auth/domain/repository" + domusecase "gateway/internal/model/auth/domain/usecase" + + "github.com/google/uuid" +) + +type registrationSessionUseCase struct { + store domrepo.RegistrationSessionStore +} + +// RegistrationSessionUseCaseParam wires RegistrationSessionUseCase. +type RegistrationSessionUseCaseParam struct { + Store domrepo.RegistrationSessionStore +} + +// MustRegistrationSessionUseCase constructs RegistrationSessionUseCase. +func MustRegistrationSessionUseCase(param RegistrationSessionUseCaseParam) domusecase.RegistrationSessionUseCase { + if param.Store == nil { + panic("auth: registration session store is required") + } + return ®istrationSessionUseCase{store: param.Store} +} + +func (uc *registrationSessionUseCase) Create(ctx context.Context, req *domusecase.CreateRegistrationSessionRequest) (*domusecase.RegistrationSessionView, error) { + if req == nil || req.TenantID == "" || req.TenantSlug == "" { + return nil, errb.InputMissingRequired("tenant_id and tenant_slug are required") + } + if req.InviteCode == "" { + return nil, errb.InputMissingRequired("invite_code is required") + } + if req.AcceptTermsVersion == "" { + return nil, errb.InputMissingRequired("accept_terms_version is required") + } + if req.Provider == "" { + return nil, errb.InputMissingRequired("provider is required") + } + if req.RedirectURI == "" { + return nil, errb.InputMissingRequired("redirect_uri is required") + } + ttl := req.TTL + if ttl <= 0 { + ttl = 10 * time.Minute + } + sessionID := uuid.NewString() + session := &domrepo.RegistrationSession{ + SessionID: sessionID, + TenantID: req.TenantID, + TenantSlug: req.TenantSlug, + InviteCode: req.InviteCode, + InviteNewUsersOnly: req.InviteNewUsersOnly, + AcceptTermsVersion: req.AcceptTermsVersion, + MarketingOptIn: req.MarketingOptIn, + Language: req.Language, + Provider: req.Provider, + RedirectURI: req.RedirectURI, + ClientIP: req.ClientIP, + UserAgent: req.UserAgent, + } + if err := uc.store.Save(ctx, session, ttl); err != nil { + return nil, wrapRepoErr(err, "save registration session failed") + } + return &domusecase.RegistrationSessionView{ + SessionID: sessionID, + ExpiresIn: int(ttl.Seconds()), + }, nil +} + +func (uc *registrationSessionUseCase) Get(ctx context.Context, sessionID string) (*domusecase.CreateRegistrationSessionRequest, error) { + if sessionID == "" { + return nil, errb.InputMissingRequired("session_id is required") + } + session, err := uc.store.Get(ctx, sessionID) + if err != nil { + if errors.Is(err, authdomain.ErrRegistrationSessionNotFound) { + return nil, errb.ResNotFound("registration session", sessionID).WithCause(err) + } + return nil, wrapRepoErr(err, "read registration session failed") + } + return &domusecase.CreateRegistrationSessionRequest{ + TenantID: session.TenantID, + TenantSlug: session.TenantSlug, + InviteCode: session.InviteCode, + InviteNewUsersOnly: session.InviteNewUsersOnly, + AcceptTermsVersion: session.AcceptTermsVersion, + MarketingOptIn: session.MarketingOptIn, + Language: session.Language, + Provider: session.Provider, + RedirectURI: session.RedirectURI, + ClientIP: session.ClientIP, + UserAgent: session.UserAgent, + }, nil +} + +func (uc *registrationSessionUseCase) Delete(ctx context.Context, sessionID string) error { + if sessionID == "" { + return errb.InputMissingRequired("session_id is required") + } + if err := uc.store.Delete(ctx, sessionID); err != nil { + return wrapRepoErr(err, "delete registration session failed") + } + return nil +} + +var _ domusecase.RegistrationSessionUseCase = (*registrationSessionUseCase)(nil) diff --git a/internal/model/auth/usecase/token_usecase.go b/internal/model/auth/usecase/token_usecase.go new file mode 100644 index 0000000..c34b558 --- /dev/null +++ b/internal/model/auth/usecase/token_usecase.go @@ -0,0 +1,263 @@ +package usecase + +import ( + "context" + "errors" + "fmt" + "time" + + authconfig "gateway/internal/model/auth/config" + domrepo "gateway/internal/model/auth/domain/repository" + domusecase "gateway/internal/model/auth/domain/usecase" + + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" +) + +type tokenUseCase struct { + cfg authconfig.Config + revoke domrepo.TokenRevokeStore +} + +// TokenUseCaseParam wires TokenUseCase. +type TokenUseCaseParam struct { + Config authconfig.Config + Revoke domrepo.TokenRevokeStore +} + +// MustTokenUseCase constructs TokenUseCase. +func MustTokenUseCase(param TokenUseCaseParam) domusecase.TokenUseCase { + cfg := param.Config.Defaults() + if !cfg.Enabled() { + panic("auth: JWT secrets are required") + } + return &tokenUseCase{cfg: cfg, revoke: param.Revoke} +} + +func (uc *tokenUseCase) IssuePair(ctx context.Context, req *domusecase.IssuePairRequest) (*domusecase.TokenPair, error) { + if req == nil || req.TenantID == "" || req.UID == "" { + return nil, errb.InputMissingRequired("tenant_id and uid are required") + } + access, err := uc.sign(req, domusecase.TokenTypeAccess, uc.cfg.AccessExpire, uc.cfg.AccessSecret) + if err != nil { + return nil, errb.SysInternal("sign access token failed").WithCause(err) + } + refresh, err := uc.sign(req, domusecase.TokenTypeRefresh, uc.cfg.RefreshExpire, uc.cfg.RefreshSecret) + if err != nil { + return nil, errb.SysInternal("sign refresh token failed").WithCause(err) + } + if uc.revoke != nil { + accessTTL := time.Until(access.expiresAt) + refreshTTL := time.Until(refresh.expiresAt) + if err := uc.revoke.SavePair(ctx, access.jti, refresh.jti, accessTTL, refreshTTL); err != nil { + return nil, errb.DBError("save jwt pair failed").WithCause(err) + } + } + return &domusecase.TokenPair{ + AccessToken: access.raw, + RefreshToken: refresh.raw, + ExpiresIn: uc.cfg.AccessExpire, + TokenType: "Bearer", + }, nil +} + +func (uc *tokenUseCase) Refresh(ctx context.Context, refreshToken string) (*domusecase.TokenPair, error) { + if refreshToken == "" { + return nil, errb.InputMissingRequired("refresh_token is required") + } + claims, err := uc.parse(refreshToken, domusecase.TokenTypeRefresh, uc.cfg.RefreshSecret) + if err != nil { + if errors.Is(err, errInvalidToken) { + return nil, errb.AuthUnauthorized("invalid refresh token").WithCause(err) + } + return nil, errb.SysInternal("parse refresh token failed").WithCause(err) + } + if err := uc.ensureNotBlacklisted(ctx, claims.ID); err != nil { + return nil, err + } + + pair, err := uc.IssuePair(ctx, &domusecase.IssuePairRequest{ + TenantID: claims.TenantID, + UID: claims.UID, + AuthGen: claims.AuthGen, + }) + if err != nil { + return nil, err + } + if uc.revoke != nil { + if err := uc.revoke.Blacklist(ctx, claims.ID, remainingTTL(claims.expiresAt)); err != nil { + return nil, errb.DBError("blacklist refresh token failed").WithCause(err) + } + if accessJTI, err := uc.revoke.GetPairedJTI(ctx, claims.ID); err != nil { + return nil, errb.DBError("read jwt pair failed").WithCause(err) + } else if accessJTI != "" { + if err := uc.revoke.Blacklist(ctx, accessJTI, time.Duration(uc.cfg.AccessExpire)*time.Second); err != nil { + return nil, errb.DBError("blacklist access token failed").WithCause(err) + } + if err := uc.revoke.DeletePair(ctx, accessJTI, claims.ID); err != nil { + return nil, errb.DBError("delete jwt pair failed").WithCause(err) + } + } + } + return pair, nil +} + +func (uc *tokenUseCase) Logout(ctx context.Context, req *domusecase.LogoutRequest) error { + if req == nil || req.AccessToken == "" { + return errb.InputMissingRequired("access token is required") + } + if uc.revoke == nil { + return errb.SysNotImplemented("token revoke store not configured") + } + claims, err := uc.parse(req.AccessToken, domusecase.TokenTypeAccess, uc.cfg.AccessSecret) + if err != nil { + if errors.Is(err, errInvalidToken) { + return errb.AuthUnauthorized("invalid access token").WithCause(err) + } + return errb.SysInternal("parse access token failed").WithCause(err) + } + if err := uc.revoke.Blacklist(ctx, claims.ID, remainingTTL(claims.expiresAt)); err != nil { + return errb.DBError("blacklist access token failed").WithCause(err) + } + refreshJTI, err := uc.revoke.GetPairedJTI(ctx, claims.ID) + if err != nil { + return errb.DBError("read jwt pair failed").WithCause(err) + } + if refreshJTI != "" { + if err := uc.revoke.Blacklist(ctx, refreshJTI, time.Duration(uc.cfg.RefreshExpire)*time.Second); err != nil { + return errb.DBError("blacklist refresh token failed").WithCause(err) + } + } + if err := uc.revoke.DeletePair(ctx, claims.ID, refreshJTI); err != nil { + return errb.DBError("delete jwt pair failed").WithCause(err) + } + return nil +} + +func (uc *tokenUseCase) ParseAccessToken(ctx context.Context, accessToken string) (*domusecase.AccessClaims, error) { + if accessToken == "" { + return nil, errb.AuthUnauthorized("missing access token") + } + claims, err := uc.parse(accessToken, domusecase.TokenTypeAccess, uc.cfg.AccessSecret) + if err != nil { + if errors.Is(err, errInvalidToken) { + return nil, errb.AuthUnauthorized("invalid access token").WithCause(err) + } + return nil, errb.SysInternal("parse access token failed").WithCause(err) + } + if err := uc.ensureNotBlacklisted(ctx, claims.ID); err != nil { + return nil, err + } + return &domusecase.AccessClaims{ + TenantID: claims.TenantID, + UID: claims.UID, + AuthGen: claims.AuthGen, + JTI: claims.ID, + }, nil +} + +func (uc *tokenUseCase) ensureNotBlacklisted(ctx context.Context, jti string) error { + if uc.revoke == nil || jti == "" { + return nil + } + blacklisted, err := uc.revoke.IsBlacklisted(ctx, jti) + if err != nil { + return errb.DBError("check jwt blacklist failed").WithCause(err) + } + if blacklisted { + return errb.AuthUnauthorized("token revoked") + } + return nil +} + +var errInvalidToken = errors.New("auth: invalid token") + +type jwtClaims struct { + TenantID string `json:"tenant_id"` + UID string `json:"uid"` + Typ string `json:"typ"` + AuthGen int64 `json:"auth_gen"` + jwt.RegisteredClaims +} + +type parsedClaims struct { + TenantID string + UID string + AuthGen int64 + ID string + expiresAt time.Time +} + +type signedToken struct { + raw string + jti string + expiresAt time.Time +} + +func (uc *tokenUseCase) sign(req *domusecase.IssuePairRequest, typ domusecase.TokenType, expireSec int64, secret string) (*signedToken, error) { + now := time.Now().UTC() + expiresAt := now.Add(time.Duration(expireSec) * time.Second) + jti := uuid.NewString() + claims := jwtClaims{ + TenantID: req.TenantID, + UID: req.UID, + Typ: string(typ), + AuthGen: req.AuthGen, + RegisteredClaims: jwt.RegisteredClaims{ + ID: jti, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(expiresAt), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + token.Header["kid"] = uc.cfg.ActiveKID + raw, err := token.SignedString([]byte(secret)) + if err != nil { + return nil, err + } + return &signedToken{raw: raw, jti: jti, expiresAt: expiresAt}, nil +} + +func (uc *tokenUseCase) parse(raw string, want domusecase.TokenType, secret string) (*parsedClaims, error) { + parsed, err := jwt.ParseWithClaims(raw, &jwtClaims{}, func(t *jwt.Token) (any, error) { + if t.Method != jwt.SigningMethodHS256 { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return []byte(secret), nil + }) + if err != nil { + return nil, fmt.Errorf("%w: %w", errInvalidToken, err) + } + claims, ok := parsed.Claims.(*jwtClaims) + if !ok || !parsed.Valid { + return nil, errInvalidToken + } + if claims.Typ != string(want) { + return nil, errInvalidToken + } + if claims.TenantID == "" || claims.UID == "" { + return nil, errInvalidToken + } + expiresAt := time.Time{} + if claims.ExpiresAt != nil { + expiresAt = claims.ExpiresAt.Time + } + return &parsedClaims{ + TenantID: claims.TenantID, + UID: claims.UID, + AuthGen: claims.AuthGen, + ID: claims.ID, + expiresAt: expiresAt, + }, nil +} + +func remainingTTL(expiresAt time.Time) time.Duration { + if expiresAt.IsZero() { + return time.Second + } + ttl := time.Until(expiresAt) + if ttl < time.Second { + return time.Second + } + return ttl +} diff --git a/internal/model/auth/usecase/token_usecase_test.go b/internal/model/auth/usecase/token_usecase_test.go new file mode 100644 index 0000000..792ed29 --- /dev/null +++ b/internal/model/auth/usecase/token_usecase_test.go @@ -0,0 +1,165 @@ +package usecase_test + +import ( + "context" + "sync" + "testing" + "time" + + authconfig "gateway/internal/model/auth/config" + domrepo "gateway/internal/model/auth/domain/repository" + domusecase "gateway/internal/model/auth/domain/usecase" + authusecase "gateway/internal/model/auth/usecase" + + "github.com/stretchr/testify/require" +) + +const ( + testTenantDev = "dev-tenant" + testUIDDev = "DEV-10000001" +) + +func TestTokenUseCaseIssueAndRefresh(t *testing.T) { + t.Parallel() + uc := newTokenUC(t, nil) + + pair, err := uc.IssuePair(context.Background(), &domusecase.IssuePairRequest{ + TenantID: testTenantDev, + UID: testUIDDev, + }) + require.NoError(t, err) + require.NotEmpty(t, pair.AccessToken) + require.NotEmpty(t, pair.RefreshToken) + require.Equal(t, int64(900), pair.ExpiresIn) + + claims, err := uc.ParseAccessToken(context.Background(), pair.AccessToken) + require.NoError(t, err) + require.Equal(t, testTenantDev, claims.TenantID) + require.Equal(t, testUIDDev, claims.UID) + + refreshed, err := uc.Refresh(context.Background(), pair.RefreshToken) + require.NoError(t, err) + require.NotEmpty(t, refreshed.AccessToken) + require.NotEqual(t, pair.AccessToken, refreshed.AccessToken) +} + +func TestTokenUseCaseInvalidRefresh(t *testing.T) { + t.Parallel() + uc := newTokenUC(t, nil) + _, err := uc.Refresh(context.Background(), "not-a-jwt") + require.Error(t, err) +} + +func TestTokenUseCaseLogoutRevokesPair(t *testing.T) { + t.Parallel() + store := newMemRevokeStore() + uc := newTokenUC(t, store) + + pair, err := uc.IssuePair(context.Background(), &domusecase.IssuePairRequest{ + TenantID: testTenantDev, + UID: testUIDDev, + }) + require.NoError(t, err) + + err = uc.Logout(context.Background(), &domusecase.LogoutRequest{AccessToken: pair.AccessToken}) + require.NoError(t, err) + + _, err = uc.ParseAccessToken(context.Background(), pair.AccessToken) + require.Error(t, err) + + _, err = uc.Refresh(context.Background(), pair.RefreshToken) + require.Error(t, err) +} + +func TestTokenUseCaseRefreshRotatesAndRevokesOldRefresh(t *testing.T) { + t.Parallel() + store := newMemRevokeStore() + uc := newTokenUC(t, store) + + pair, err := uc.IssuePair(context.Background(), &domusecase.IssuePairRequest{ + TenantID: testTenantDev, + UID: testUIDDev, + }) + require.NoError(t, err) + + refreshed, err := uc.Refresh(context.Background(), pair.RefreshToken) + require.NoError(t, err) + require.NotEqual(t, pair.RefreshToken, refreshed.RefreshToken) + + _, err = uc.Refresh(context.Background(), pair.RefreshToken) + require.Error(t, err) + + claims, err := uc.ParseAccessToken(context.Background(), refreshed.AccessToken) + require.NoError(t, err) + require.Equal(t, testUIDDev, claims.UID) +} + +func newTokenUC(t *testing.T, revoke domrepo.TokenRevokeStore) domusecase.TokenUseCase { + t.Helper() + return authusecase.MustTokenUseCase(authusecase.TokenUseCaseParam{ + Config: authconfig.Config{ + AccessSecret: "access-secret-32-bytes-minimum!!", + RefreshSecret: "refresh-secret-32-bytes-minimum!", + AccessExpire: 900, + RefreshExpire: 604800, + ActiveKID: "v1", + }, + Revoke: revoke, + }) +} + +type memRevokeStore struct { + mu sync.Mutex + pairs map[string]string + bl map[string]time.Time +} + +func newMemRevokeStore() *memRevokeStore { + return &memRevokeStore{ + pairs: make(map[string]string), + bl: make(map[string]time.Time), + } +} + +func (s *memRevokeStore) SavePair(_ context.Context, accessJTI, refreshJTI string, _, _ time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + s.pairs[accessJTI] = refreshJTI + s.pairs[refreshJTI] = accessJTI + return nil +} + +func (s *memRevokeStore) GetPairedJTI(_ context.Context, jti string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.pairs[jti], nil +} + +func (s *memRevokeStore) DeletePair(_ context.Context, accessJTI, refreshJTI string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.pairs, accessJTI) + delete(s.pairs, refreshJTI) + return nil +} + +func (s *memRevokeStore) Blacklist(_ context.Context, jti string, ttl time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + s.bl[jti] = time.Now().Add(ttl) + return nil +} + +func (s *memRevokeStore) IsBlacklisted(_ context.Context, jti string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + exp, ok := s.bl[jti] + if !ok { + return false, nil + } + if time.Now().After(exp) { + delete(s.bl, jti) + return false, nil + } + return true, nil +} diff --git a/internal/model/member/config/config.go b/internal/model/member/config/config.go index ca6b394..0142dd4 100644 --- a/internal/model/member/config/config.go +++ b/internal/model/member/config/config.go @@ -2,8 +2,15 @@ package config // Config is member module settings (embedded in gateway root config). type Config struct { - OTP OTPConfig `json:",optional"` - TOTP TOTPConfig `json:",optional"` + OTP OTPConfig `json:",optional"` + TOTP TOTPConfig `json:",optional"` + Registration RegistrationConfig `json:",optional"` +} + +// RegistrationConfig governs platform registration flows. +type RegistrationConfig struct { + RequireInviteCode bool `json:",optional"` + TrustSocialEmailVerified bool `json:",optional"` } // OTPConfig governs the business OTP primitive (email/phone verification). @@ -80,5 +87,7 @@ func (c Config) Defaults() Config { if c.TOTP.ReplayTTLSeconds <= 0 { c.TOTP.ReplayTTLSeconds = 90 } + // RequireInviteCode defaults true when unset (zero value). + // TrustSocialEmailVerified defaults true when unset (zero value). return c } diff --git a/internal/model/member/domain/usecase/lifecycle.go b/internal/model/member/domain/usecase/lifecycle.go index 67640b4..28da043 100644 --- a/internal/model/member/domain/usecase/lifecycle.go +++ b/internal/model/member/domain/usecase/lifecycle.go @@ -14,9 +14,10 @@ type LifecycleUseCase interface { // CreatePlatformMemberRequest creates an unverified platform-native member. type CreatePlatformMemberRequest struct { - TenantID string - Email string - PasswordHash string - DisplayName string - Language string + TenantID string + Email string + PasswordHash string + DisplayName string + Language string + ZitadelUserID string } diff --git a/internal/model/member/domain/usecase/otp.go b/internal/model/member/domain/usecase/otp.go index bebf96c..3b42fbb 100644 --- a/internal/model/member/domain/usecase/otp.go +++ b/internal/model/member/domain/usecase/otp.go @@ -24,10 +24,31 @@ type VerifyOTPRequest struct { Purpose enum.OTPPurpose } +// MatchChallengeRequest validates persisted challenge metadata before orchestration. +type MatchChallengeRequest struct { + ChallengeID string + TenantID string + Purpose enum.OTPPurpose + RequireUID bool + RequireTarget bool +} + // OTPUseCase is the purpose-agnostic OTP primitive. type OTPUseCase interface { Generate(ctx context.Context, req *GenerateOTPRequest) (*OTPChallengeDTO, string, error) // Verify returns the challenge target (e.g. email/phone) after successful validation. Verify(ctx context.Context, req *VerifyOTPRequest) (target string, err error) Invalidate(ctx context.Context, challengeID string) error + // GetChallenge returns persisted challenge metadata for orchestration (e.g. register resend). + GetChallenge(ctx context.Context, challengeID string) (*OTPChallengeInfo, error) + // MatchChallenge loads a challenge and validates tenant / purpose / required fields. + MatchChallenge(ctx context.Context, req *MatchChallengeRequest) (*OTPChallengeInfo, error) +} + +// OTPChallengeInfo is read-only challenge metadata. +type OTPChallengeInfo struct { + TenantID string + UID string + Purpose enum.OTPPurpose + Target string } diff --git a/internal/model/member/domain/usecase/profile.go b/internal/model/member/domain/usecase/profile.go index fa6703d..8568d5f 100644 --- a/internal/model/member/domain/usecase/profile.go +++ b/internal/model/member/domain/usecase/profile.go @@ -9,6 +9,7 @@ import ( // ProfileUseCase reads and updates member profile fields (not lifecycle transitions). type ProfileUseCase interface { GetByUID(ctx context.Context, req *GetMemberRequest) (*MemberDTO, error) + GetByZitadelUserID(ctx context.Context, tenantID, zitadelUserID string) (*MemberDTO, error) Update(ctx context.Context, req *UpdateMemberRequest) (*MemberDTO, error) List(ctx context.Context, req *ListMembersRequest) (*ListMembersResponse, error) SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error diff --git a/internal/model/member/domain/usecase/verify_rate.go b/internal/model/member/domain/usecase/verify_rate.go new file mode 100644 index 0000000..c13caf4 --- /dev/null +++ b/internal/model/member/domain/usecase/verify_rate.go @@ -0,0 +1,12 @@ +package usecase + +import ( + "context" + "time" +) + +// VerifyRateUseCase guards OTP resend cooldown and daily verification quotas. +type VerifyRateUseCase interface { + AssertResendAllowed(ctx context.Context, key string, cooldown time.Duration) error + AssertDailyAllowed(ctx context.Context, key string, window time.Duration, limit int) error +} diff --git a/internal/model/member/repository/index.go b/internal/model/member/repository/index.go index e0deaa3..aa9f7f4 100644 --- a/internal/model/member/repository/index.go +++ b/internal/model/member/repository/index.go @@ -7,6 +7,8 @@ import ( libmongo "gateway/internal/library/mongo" ) +const bsonOpSet = "$set" + // EnsureMongoIndexes creates indexes for member module collections. func EnsureMongoIndexes(ctx context.Context, conf *libmongo.Conf) error { if conf == nil || conf.Host == "" { diff --git a/internal/model/member/repository/member_mongo.go b/internal/model/member/repository/member_mongo.go index 4efa0df..788373e 100644 --- a/internal/model/member/repository/member_mongo.go +++ b/internal/model/member/repository/member_mongo.go @@ -105,7 +105,7 @@ func (r *memberRepository) UpdateProfile(ctx context.Context, tenantID, uid stri filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid} opts := options.FindOneAndUpdate().SetReturnDocument(options.After) var doc entity.Member - if err := r.db.GetClient().FindOneAndUpdate(ctx, &doc, filter, bson.M{"$set": set}, opts); err != nil { + if err := r.db.GetClient().FindOneAndUpdate(ctx, &doc, filter, bson.M{bsonOpSet: set}, opts); err != nil { if errors.Is(err, mongodriver.ErrNoDocuments) { return nil, member.ErrNotFound } @@ -126,7 +126,7 @@ func (r *memberRepository) UpdateStatus(ctx context.Context, tenantID, uid strin set[member.BSONFieldDeletedAt] = time.Now().UTC().UnixMilli() } filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid} - res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set}) + res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{bsonOpSet: set}) if err != nil { return err } @@ -172,7 +172,7 @@ func (r *memberRepository) SetBusinessEmailVerified(ctx context.Context, tenantI member.BSONFieldUpdateAt: now, } filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid} - res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set}) + res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{bsonOpSet: set}) if err != nil { return err } @@ -191,7 +191,7 @@ func (r *memberRepository) SetBusinessPhoneVerified(ctx context.Context, tenantI member.BSONFieldUpdateAt: now, } filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid} - res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set}) + res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{bsonOpSet: set}) if err != nil { return err } diff --git a/internal/model/member/repository/totp_profile_mongo.go b/internal/model/member/repository/totp_profile_mongo.go index f924eaa..f7b8ff6 100644 --- a/internal/model/member/repository/totp_profile_mongo.go +++ b/internal/model/member/repository/totp_profile_mongo.go @@ -49,7 +49,7 @@ func (r *MongoTOTPProfileRepository) Save(ctx context.Context, tenantID, uid str member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli(), } filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid} - res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set}) + res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{bsonOpSet: set}) if err != nil { return err } @@ -68,7 +68,7 @@ func (r *MongoTOTPProfileRepository) Clear(ctx context.Context, tenantID, uid st member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli(), } filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid} - res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set}) + res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{bsonOpSet: set}) if err != nil { return err } @@ -85,8 +85,8 @@ func (r *MongoTOTPProfileRepository) ConsumeBackupCode(ctx context.Context, tena member.BSONFieldTOTPBackupCodesHash: hash, } update := bson.M{ - "$pull": bson.M{member.BSONFieldTOTPBackupCodesHash: hash}, - "$set": bson.M{member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli()}, + "$pull": bson.M{member.BSONFieldTOTPBackupCodesHash: hash}, + bsonOpSet: bson.M{member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli()}, } res, err := r.db.GetClient().UpdateOne(ctx, filter, update) if err != nil { @@ -101,7 +101,7 @@ func (r *MongoTOTPProfileRepository) ReplaceBackupCodes(ctx context.Context, ten member.BSONFieldUpdateAt: time.Now().UTC().UnixMilli(), } filter := bson.M{member.BSONFieldTenantID: tenantID, member.BSONFieldUID: uid} - res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{"$set": set}) + res, err := r.db.GetClient().UpdateOne(ctx, filter, bson.M{bsonOpSet: set}) if err != nil { return err } diff --git a/internal/model/member/usecase/errors.go b/internal/model/member/usecase/errors.go new file mode 100644 index 0000000..0645e27 --- /dev/null +++ b/internal/model/member/usecase/errors.go @@ -0,0 +1,44 @@ +package usecase + +import ( + "errors" + "strings" + + errs "gateway/internal/library/errors" + "gateway/internal/library/errors/code" + member "gateway/internal/model/member/domain" +) + +var errb = errs.For(code.Member) + +func wrapRepoErr(err error, msg ...string) error { + if err == nil { + return nil + } + if errors.Is(err, member.ErrNotFound) { + return errb.ResNotFound("member", "").WithCause(err) + } + if errors.Is(err, member.ErrTenantNotFound) { + return errb.ResNotFound("tenant", "").WithCause(err) + } + if errors.Is(err, member.ErrChallengeNotFound) { + return errb.ResNotFound("otp challenge", "").WithCause(err) + } + if errors.Is(err, member.ErrTOTPEnrollMissing) { + return errb.ResNotFound("totp enroll", "").WithCause(err) + } + if errors.Is(err, member.ErrDuplicateMember) { + return errb.ResAlreadyExist("member already exists").WithCause(err) + } + if errors.Is(err, member.ErrDuplicateTenant) { + return errb.ResAlreadyExist("tenant already exists").WithCause(err) + } + if e := errs.FromError(err); e != nil { + return err + } + m := strings.TrimSpace(strings.Join(msg, " ")) + if m == "" { + m = "member repository error" + } + return errb.DBError(m).WithCause(err) +} diff --git a/internal/model/member/usecase/lifecycle_usecase.go b/internal/model/member/usecase/lifecycle_usecase.go index 76318f0..62d06e8 100644 --- a/internal/model/member/usecase/lifecycle_usecase.go +++ b/internal/model/member/usecase/lifecycle_usecase.go @@ -46,30 +46,31 @@ func (uc *lifecycleUseCase) CreateUnverified(ctx context.Context, req *domusecas if errors.Is(err, member.ErrTenantNotFound) { return nil, errb.ResNotFound("tenant", req.TenantID).WithCause(err) } - return nil, errb.SysInternal("read tenant failed").WithCause(err) + return nil, wrapRepoErr(err, "read tenant failed") } uid, err := uc.uidGen.Next(ctx, req.TenantID, tenant.UIDPrefix) if err != nil { - return nil, errb.SysInternal("allocate uid failed").WithCause(err) + return nil, wrapRepoErr(err, "allocate uid failed") } now := time.Now().UTC().UnixMilli() rec := &entity.Member{ - TenantID: req.TenantID, - UID: uid, - ZitadelEmail: req.Email, - DisplayName: req.DisplayName, - Language: defaultLanguage(req.Language), - Status: enum.MemberStatusUnverified, - Origin: enum.MemberOriginPlatformNative, - PasswordHash: req.PasswordHash, - CreateAt: now, - UpdateAt: now, + TenantID: req.TenantID, + UID: uid, + ZitadelUserID: req.ZitadelUserID, + ZitadelEmail: req.Email, + DisplayName: req.DisplayName, + Language: defaultLanguage(req.Language), + Status: enum.MemberStatusUnverified, + Origin: enum.MemberOriginPlatformNative, + PasswordHash: req.PasswordHash, + CreateAt: now, + UpdateAt: now, } if err := uc.members.Insert(ctx, rec); err != nil { if errors.Is(err, member.ErrDuplicateMember) { return nil, errb.ResAlreadyExist("member already exists").WithCause(err) } - return nil, errb.SysInternal("create member failed").WithCause(err) + return nil, wrapRepoErr(err, "create member failed") } return memberToDTO(rec), nil } @@ -92,13 +93,13 @@ func (uc *lifecycleUseCase) SoftDelete(ctx context.Context, tenantID, uid string if errors.Is(err, member.ErrNotFound) { return errb.ResNotFound("member", uid).WithCause(err) } - return errb.SysInternal("read member failed").WithCause(err) + return wrapRepoErr(err, "read member failed") } if rec.Status == enum.MemberStatusDeleted { return nil } if err := uc.members.UpdateStatus(ctx, tenantID, uid, enum.MemberStatusDeleted, ""); err != nil { - return errb.SysInternal("soft delete member failed").WithCause(err) + return wrapRepoErr(err, "soft delete member failed") } return nil } @@ -116,13 +117,13 @@ func (uc *lifecycleUseCase) transition(ctx context.Context, tenantID, uid string if errors.Is(err, member.ErrNotFound) { return errb.ResNotFound("member", uid).WithCause(err) } - return errb.SysInternal("read member failed").WithCause(err) + return wrapRepoErr(err, "read member failed") } if rec.Status != from { return errb.ResInvalidState("invalid member status transition").WithCause(member.ErrInvalidStatus) } if err := uc.members.UpdateStatus(ctx, tenantID, uid, to, reason); err != nil { - return errb.SysInternal("update member status failed").WithCause(err) + return wrapRepoErr(err, "update member status failed") } return nil } diff --git a/internal/model/member/usecase/module.go b/internal/model/member/usecase/module.go index bbbfef4..e50d49a 100644 --- a/internal/model/member/usecase/module.go +++ b/internal/model/member/usecase/module.go @@ -23,7 +23,7 @@ type Module struct { Lifecycle domusecase.LifecycleUseCase Provisioning domusecase.ProvisioningUseCase Tenant domusecase.TenantUseCase - VerifyRate domrepo.VerifyRateStore + VerifyRate domusecase.VerifyRateUseCase Members domrepo.MemberRepository Tenants domrepo.TenantRepository @@ -82,7 +82,7 @@ func NewModuleFromParam(param ModuleParam) (*Module, error) { mod := &Module{ OTP: MustOTPUseCase(OTPUseCaseParam{Store: otpStore, Config: cfg}), - VerifyRate: rateStore, + VerifyRate: MustVerifyRateUseCase(VerifyRateUseCaseParam{Store: rateStore}), Members: members, Tenants: tenants, Identities: identities, diff --git a/internal/model/member/usecase/otp_usecase.go b/internal/model/member/usecase/otp_usecase.go index fa5ed5d..609bb5e 100644 --- a/internal/model/member/usecase/otp_usecase.go +++ b/internal/model/member/usecase/otp_usecase.go @@ -10,16 +10,12 @@ import ( "github.com/google/uuid" "golang.org/x/crypto/bcrypt" - errs "gateway/internal/library/errors" - "gateway/internal/library/errors/code" memberconfig "gateway/internal/model/member/config" member "gateway/internal/model/member/domain" domrepo "gateway/internal/model/member/domain/repository" domusecase "gateway/internal/model/member/domain/usecase" ) -var errb = errs.For(code.Facade) - type otpUseCase struct { store domrepo.OTPChallengeStore config memberconfig.Config @@ -62,7 +58,7 @@ func (uc *otpUseCase) Generate(ctx context.Context, req *domusecase.GenerateOTPR } ttl := time.Duration(uc.config.OTP.TTLSeconds) * time.Second if err := uc.store.Save(ctx, challengeID, ch, ttl); err != nil { - return nil, "", errb.SysInternal("otp persist failed").WithCause(err) + return nil, "", wrapRepoErr(err, "otp persist failed") } return &domusecase.OTPChallengeDTO{ ChallengeID: challengeID, @@ -79,7 +75,7 @@ func (uc *otpUseCase) Verify(ctx context.Context, req *domusecase.VerifyOTPReque if errors.Is(err, member.ErrChallengeNotFound) { return "", errb.ResNotFound("otp challenge", req.ChallengeID).WithCause(err) } - return "", errb.SysInternal("otp read failed").WithCause(err) + return "", wrapRepoErr(err, "otp read failed") } if ch.TenantID != req.TenantID { return "", errb.AuthForbidden("otp challenge tenant mismatch") @@ -104,7 +100,7 @@ func (uc *otpUseCase) Verify(ctx context.Context, req *domusecase.VerifyOTPReque if errors.Is(incErr, member.ErrChallengeNotFound) { return "", errb.ResNotFound("otp challenge", req.ChallengeID).WithCause(incErr) } - return "", errb.SysInternal("otp persist failed").WithCause(incErr) + return "", wrapRepoErr(incErr, "otp persist failed") } if attempts >= uc.config.OTP.MaxAttempts { return "", errb.ResInvalidState("otp challenge locked").WithCause(member.ErrChallengeLocked) @@ -113,7 +109,7 @@ func (uc *otpUseCase) Verify(ctx context.Context, req *domusecase.VerifyOTPReque } target := ch.Target if delErr := uc.store.Delete(ctx, req.ChallengeID); delErr != nil { - return "", errb.SysInternal("otp delete failed").WithCause(delErr) + return "", wrapRepoErr(delErr, "otp delete failed") } return target, nil } @@ -122,7 +118,49 @@ func (uc *otpUseCase) Invalidate(ctx context.Context, challengeID string) error if challengeID == "" { return errb.InputMissingRequired("challenge_id is required") } - return uc.store.Delete(ctx, challengeID) + return wrapRepoErr(uc.store.Delete(ctx, challengeID), "otp delete failed") +} + +func (uc *otpUseCase) GetChallenge(ctx context.Context, challengeID string) (*domusecase.OTPChallengeInfo, error) { + if challengeID == "" { + return nil, errb.InputMissingRequired("challenge_id is required") + } + ch, err := uc.store.Get(ctx, challengeID) + if err != nil { + if errors.Is(err, member.ErrChallengeNotFound) { + return nil, errb.ResNotFound("otp challenge", challengeID).WithCause(err) + } + return nil, wrapRepoErr(err, "otp read failed") + } + return &domusecase.OTPChallengeInfo{ + TenantID: ch.TenantID, + UID: ch.UID, + Purpose: ch.Purpose, + Target: ch.Target, + }, nil +} + +func (uc *otpUseCase) MatchChallenge(ctx context.Context, req *domusecase.MatchChallengeRequest) (*domusecase.OTPChallengeInfo, error) { + if req == nil || req.ChallengeID == "" || req.TenantID == "" || req.Purpose == "" { + return nil, errb.InputMissingRequired("challenge_id, tenant_id and purpose are required") + } + ch, err := uc.GetChallenge(ctx, req.ChallengeID) + if err != nil { + return nil, err + } + if ch.TenantID != req.TenantID { + return nil, errb.AuthForbidden("otp challenge tenant mismatch") + } + if ch.Purpose != req.Purpose { + return nil, errb.AuthForbidden("otp challenge purpose mismatch") + } + if req.RequireUID && ch.UID == "" { + return nil, errb.ResInvalidState("otp challenge missing uid") + } + if req.RequireTarget && ch.Target == "" { + return nil, errb.ResInvalidState("otp challenge missing target") + } + return ch, nil } func generateNumericOTP(length int) (string, error) { diff --git a/internal/model/member/usecase/profile_usecase.go b/internal/model/member/usecase/profile_usecase.go index bbb4654..9ed8583 100644 --- a/internal/model/member/usecase/profile_usecase.go +++ b/internal/model/member/usecase/profile_usecase.go @@ -35,7 +35,21 @@ func (uc *profileUseCase) GetByUID(ctx context.Context, req *domusecase.GetMembe if errors.Is(err, member.ErrNotFound) { return nil, errb.ResNotFound("member", req.UID).WithCause(err) } - return nil, errb.SysInternal("read member failed").WithCause(err) + return nil, wrapRepoErr(err, "read member failed") + } + return memberToDTO(rec), nil +} + +func (uc *profileUseCase) GetByZitadelUserID(ctx context.Context, tenantID, zitadelUserID string) (*domusecase.MemberDTO, error) { + if tenantID == "" || zitadelUserID == "" { + return nil, errb.InputMissingRequired("tenant_id and zitadel_user_id are required") + } + rec, err := uc.members.GetByZitadelUserID(ctx, tenantID, zitadelUserID) + if err != nil { + if errors.Is(err, member.ErrNotFound) { + return nil, errb.ResNotFound("member", zitadelUserID).WithCause(err) + } + return nil, wrapRepoErr(err, "read member failed") } return memberToDTO(rec), nil } @@ -55,7 +69,7 @@ func (uc *profileUseCase) Update(ctx context.Context, req *domusecase.UpdateMemb if errors.Is(err, member.ErrNotFound) { return nil, errb.ResNotFound("member", req.UID).WithCause(err) } - return nil, errb.SysInternal("update member failed").WithCause(err) + return nil, wrapRepoErr(err, "update member failed") } return memberToDTO(rec), nil } @@ -71,7 +85,7 @@ func (uc *profileUseCase) List(ctx context.Context, req *domusecase.ListMembersR Limit: req.Limit, }) if err != nil { - return nil, errb.SysInternal("list members failed").WithCause(err) + return nil, wrapRepoErr(err, "list members failed") } out := make([]*domusecase.MemberDTO, 0, len(items)) for _, item := range items { @@ -93,7 +107,7 @@ func (uc *profileUseCase) SetBusinessEmailVerified(ctx context.Context, tenantID if errors.Is(err, member.ErrNotFound) { return errb.ResNotFound("member", uid).WithCause(err) } - return errb.SysInternal("set business email verified failed").WithCause(err) + return wrapRepoErr(err, "set business email verified failed") } return nil } @@ -106,7 +120,7 @@ func (uc *profileUseCase) SetBusinessPhoneVerified(ctx context.Context, tenantID if errors.Is(err, member.ErrNotFound) { return errb.ResNotFound("member", uid).WithCause(err) } - return errb.SysInternal("set business phone verified failed").WithCause(err) + return wrapRepoErr(err, "set business phone verified failed") } return nil } diff --git a/internal/model/member/usecase/provisioning_usecase.go b/internal/model/member/usecase/provisioning_usecase.go index 4f7220a..c67e4a3 100644 --- a/internal/model/member/usecase/provisioning_usecase.go +++ b/internal/model/member/usecase/provisioning_usecase.go @@ -47,7 +47,7 @@ func (uc *provisioningUseCase) EnsureFromOIDC(ctx context.Context, req *domuseca if existing, err := uc.members.GetByZitadelUserID(ctx, req.TenantID, req.ZitadelSub); err == nil { return memberToDTO(existing), nil } else if !errors.Is(err, member.ErrNotFound) { - return nil, errb.SysInternal("read member failed").WithCause(err) + return nil, wrapRepoErr(err, "read member failed") } return uc.createProvisioned(ctx, req.TenantID, req.ZitadelSub, "", req.Email, req.DisplayName, req.Locale, enum.MemberOriginOIDC) } @@ -64,7 +64,7 @@ func (uc *provisioningUseCase) EnsureFromLDAP(ctx context.Context, req *domuseca if existing, err := uc.members.GetByZitadelUserID(ctx, req.TenantID, sub); err == nil { return memberToDTO(existing), nil } else if !errors.Is(err, member.ErrNotFound) { - return nil, errb.SysInternal("read member failed").WithCause(err) + return nil, wrapRepoErr(err, "read member failed") } } if req.ExternalID != "" { @@ -104,11 +104,11 @@ func (uc *provisioningUseCase) createProvisioned( if errors.Is(err, member.ErrTenantNotFound) { return nil, errb.ResNotFound("tenant", tenantID).WithCause(err) } - return nil, errb.SysInternal("read tenant failed").WithCause(err) + return nil, wrapRepoErr(err, "read tenant failed") } uid, err := uc.uidGen.Next(ctx, tenantID, tenant.UIDPrefix) if err != nil { - return nil, errb.SysInternal("allocate uid failed").WithCause(err) + return nil, wrapRepoErr(err, "allocate uid failed") } now := time.Now().UTC().UnixMilli() rec := &entity.Member{ @@ -132,7 +132,7 @@ func (uc *provisioningUseCase) createProvisioned( } return nil, errb.ResAlreadyExist("member already exists").WithCause(err) } - return nil, errb.SysInternal("create member failed").WithCause(err) + return nil, wrapRepoErr(err, "create member failed") } idn := &entity.Identity{ TenantID: tenantID, @@ -143,7 +143,7 @@ func (uc *provisioningUseCase) createProvisioned( UpdateAt: now, } if err := uc.identities.Insert(ctx, idn); err != nil && !errors.Is(err, member.ErrDuplicateMember) { - return nil, errb.SysInternal("create identity failed").WithCause(err) + return nil, wrapRepoErr(err, "create identity failed") } return memberToDTO(rec), nil } diff --git a/internal/model/member/usecase/tenant_usecase.go b/internal/model/member/usecase/tenant_usecase.go index a9740c5..5d62aa2 100644 --- a/internal/model/member/usecase/tenant_usecase.go +++ b/internal/model/member/usecase/tenant_usecase.go @@ -44,7 +44,7 @@ func (uc *tenantUseCase) Create(ctx context.Context, req *domusecase.CreateTenan if _, err := uc.tenants.GetByUIDPrefix(ctx, prefix); err == nil { return nil, errb.ResAlreadyExist("uid_prefix already exists") } else if !errors.Is(err, member.ErrTenantNotFound) { - return nil, errb.SysInternal("read tenant failed").WithCause(err) + return nil, wrapRepoErr(err, "read tenant failed") } now := time.Now().UTC().UnixMilli() rec := &entity.Tenant{ @@ -60,7 +60,7 @@ func (uc *tenantUseCase) Create(ctx context.Context, req *domusecase.CreateTenan if errors.Is(err, member.ErrDuplicateTenant) { return nil, errb.ResAlreadyExist("tenant already exists").WithCause(err) } - return nil, errb.SysInternal("create tenant failed").WithCause(err) + return nil, wrapRepoErr(err, "create tenant failed") } return tenantToDTO(rec), nil } @@ -74,7 +74,7 @@ func (uc *tenantUseCase) ResolveBySlug(ctx context.Context, slug string) (*domus if errors.Is(err, member.ErrTenantNotFound) { return nil, errb.ResNotFound("tenant", slug).WithCause(err) } - return nil, errb.SysInternal("read tenant failed").WithCause(err) + return nil, wrapRepoErr(err, "read tenant failed") } return tenantToDTO(rec), nil } diff --git a/internal/model/member/usecase/totp_usecase.go b/internal/model/member/usecase/totp_usecase.go index ecb6dff..a380dde 100644 --- a/internal/model/member/usecase/totp_usecase.go +++ b/internal/model/member/usecase/totp_usecase.go @@ -71,7 +71,7 @@ func (uc *totpUseCase) StartEnroll(ctx context.Context, tenantID, uid, account s } rec, err := uc.profile.Get(ctx, tenantID, uid) if err != nil { - return nil, errb.SysInternal("read totp profile failed").WithCause(err) + return nil, wrapRepoErr(err, "read totp profile failed") } if rec != nil && rec.Enrolled { return nil, errb.ResAlreadyExist("totp already enrolled").WithCause(member.ErrTOTPAlreadyEnroll) @@ -87,7 +87,7 @@ func (uc *totpUseCase) StartEnroll(ctx context.Context, tenantID, uid, account s } ttl := time.Duration(uc.config.TOTP.EnrollTTLSeconds) * time.Second if err := uc.enroll.Save(ctx, tenantID, uid, cipherBlob, ttl); err != nil { - return nil, errb.SysInternal("totp enroll persist failed").WithCause(err) + return nil, wrapRepoErr(err, "totp enroll persist failed") } accountLabel := account @@ -122,7 +122,7 @@ func (uc *totpUseCase) ConfirmEnroll(ctx context.Context, tenantID, uid, code st } rec, err := uc.profile.Get(ctx, tenantID, uid) if err != nil { - return nil, errb.SysInternal("read totp profile failed").WithCause(err) + return nil, wrapRepoErr(err, "read totp profile failed") } if rec != nil && rec.Enrolled { return nil, errb.ResAlreadyExist("totp already enrolled").WithCause(member.ErrTOTPAlreadyEnroll) @@ -133,7 +133,7 @@ func (uc *totpUseCase) ConfirmEnroll(ctx context.Context, tenantID, uid, code st if errors.Is(err, member.ErrTOTPEnrollMissing) { return nil, errb.ResNotFound("totp enroll", uid).WithCause(err) } - return nil, errb.SysInternal("read totp enroll failed").WithCause(err) + return nil, wrapRepoErr(err, "read totp enroll failed") } secret, err := uc.cipher.Decrypt(cipherBlob) if err != nil { @@ -155,10 +155,13 @@ func (uc *totpUseCase) ConfirmEnroll(ctx context.Context, tenantID, uid, code st BackupCodesHash: hashes, EnrolledAt: uc.now().UnixMilli(), }); err != nil { - return nil, errb.SysInternal("persist totp profile failed").WithCause(err) + if errors.Is(err, member.ErrNotFound) { + return nil, errb.ResNotFound("member", uid).WithCause(err) + } + return nil, wrapRepoErr(err, "persist totp profile failed") } if delErr := uc.enroll.Delete(ctx, tenantID, uid); delErr != nil { - return nil, errb.SysInternal("clear totp enroll failed").WithCause(delErr) + return nil, wrapRepoErr(delErr, "clear totp enroll failed") } return plainCodes, nil } @@ -169,7 +172,7 @@ func (uc *totpUseCase) VerifyCode(ctx context.Context, tenantID, uid, code strin } rec, err := uc.profile.Get(ctx, tenantID, uid) if err != nil { - return errb.SysInternal("read totp profile failed").WithCause(err) + return wrapRepoErr(err, "read totp profile failed") } if rec == nil || !rec.Enrolled { return errb.ResInvalidState("totp not enrolled").WithCause(member.ErrTOTPNotEnrolled) @@ -187,7 +190,7 @@ func (uc *totpUseCase) VerifyCode(ctx context.Context, tenantID, uid, code strin ttl := time.Duration(uc.config.TOTP.ReplayTTLSeconds) * time.Second fresh, markErr := uc.replay.MarkUsed(ctx, tenantID, uid, step, ttl) if markErr != nil { - return errb.SysInternal("totp replay mark failed").WithCause(markErr) + return wrapRepoErr(markErr, "totp replay mark failed") } if !fresh { return errb.AuthForbidden("totp code already used").WithCause(member.ErrTOTPCodeReplay) @@ -219,10 +222,13 @@ func (uc *totpUseCase) Disable(ctx context.Context, tenantID, uid string) error return errb.InputMissingRequired("tenant_id and uid are required") } if err := uc.profile.Clear(ctx, tenantID, uid); err != nil { - return errb.SysInternal("clear totp profile failed").WithCause(err) + if errors.Is(err, member.ErrNotFound) { + return errb.ResNotFound("member", uid).WithCause(err) + } + return wrapRepoErr(err, "clear totp profile failed") } if err := uc.enroll.Delete(ctx, tenantID, uid); err != nil { - return errb.SysInternal("clear totp enroll failed").WithCause(err) + return wrapRepoErr(err, "clear totp enroll failed") } return nil } @@ -233,7 +239,7 @@ func (uc *totpUseCase) RegenerateBackupCodes(ctx context.Context, tenantID, uid } rec, err := uc.profile.Get(ctx, tenantID, uid) if err != nil { - return nil, errb.SysInternal("read totp profile failed").WithCause(err) + return nil, wrapRepoErr(err, "read totp profile failed") } if rec == nil || !rec.Enrolled { return nil, errb.ResInvalidState("totp not enrolled").WithCause(member.ErrTOTPNotEnrolled) @@ -243,7 +249,10 @@ func (uc *totpUseCase) RegenerateBackupCodes(ctx context.Context, tenantID, uid return nil, err } if err := uc.profile.ReplaceBackupCodes(ctx, tenantID, uid, hashes); err != nil { - return nil, errb.SysInternal("replace backup codes failed").WithCause(err) + if errors.Is(err, member.ErrNotFound) { + return nil, errb.ResNotFound("member", uid).WithCause(err) + } + return nil, wrapRepoErr(err, "replace backup codes failed") } return plain, nil } @@ -254,7 +263,7 @@ func (uc *totpUseCase) Status(ctx context.Context, tenantID, uid string) (*domus } rec, err := uc.profile.Get(ctx, tenantID, uid) if err != nil { - return nil, errb.SysInternal("read totp profile failed").WithCause(err) + return nil, wrapRepoErr(err, "read totp profile failed") } dto := &domusecase.TOTPStatusDTO{} if rec == nil { diff --git a/internal/model/member/usecase/verify_rate_usecase.go b/internal/model/member/usecase/verify_rate_usecase.go new file mode 100644 index 0000000..c746a8f --- /dev/null +++ b/internal/model/member/usecase/verify_rate_usecase.go @@ -0,0 +1,55 @@ +package usecase + +import ( + "context" + "time" + + member "gateway/internal/model/member/domain" + domrepo "gateway/internal/model/member/domain/repository" + domusecase "gateway/internal/model/member/domain/usecase" +) + +type verifyRateUseCase struct { + store domrepo.VerifyRateStore +} + +// VerifyRateUseCaseParam wires VerifyRateUseCase. +type VerifyRateUseCaseParam struct { + Store domrepo.VerifyRateStore +} + +// MustVerifyRateUseCase constructs VerifyRateUseCase. +func MustVerifyRateUseCase(param VerifyRateUseCaseParam) domusecase.VerifyRateUseCase { + if param.Store == nil { + panic("member: verify rate store is required") + } + return &verifyRateUseCase{store: param.Store} +} + +func (uc *verifyRateUseCase) AssertResendAllowed(ctx context.Context, key string, cooldown time.Duration) error { + if key == "" { + return errb.InputMissingRequired("rate limit key is required") + } + ok, err := uc.store.TryResendLock(ctx, key, cooldown) + if err != nil { + return errb.DBError("rate limit check failed").WithCause(err) + } + if !ok { + return errb.SysTooManyRequest("resend cooldown active").WithCause(member.ErrResendCooldown) + } + return nil +} + +func (uc *verifyRateUseCase) AssertDailyAllowed(ctx context.Context, key string, window time.Duration, limit int) error { + if key == "" { + return errb.InputMissingRequired("daily limit key is required") + } + count, err := uc.store.IncrDaily(ctx, key, window) + if err != nil { + return errb.DBError("daily limit check failed").WithCause(err) + } + if count > int64(limit) { + return errb.ResInsufficientQuota("daily verification limit exceeded").WithCause(member.ErrDailyLimit) + } + return nil +} diff --git a/internal/model/notification/usecase/admin_usecase.go b/internal/model/notification/usecase/admin_usecase.go index 0c0f127..5a58b62 100644 --- a/internal/model/notification/usecase/admin_usecase.go +++ b/internal/model/notification/usecase/admin_usecase.go @@ -54,7 +54,7 @@ func (uc *adminNotifierUseCase) RetryDLQ(ctx context.Context, tenantID, dlqID, t return nil, errb.InputMissingRequired("target is required to retry dlq delivery") } if uc.queue == nil { - return nil, errb.SysInternal("retry queue is not configured") + return nil, errb.SysNotImplemented("retry queue is not configured") } row, err := uc.dlq.FindByID(ctx, tenantID, dlqID) @@ -90,7 +90,7 @@ func (uc *adminNotifierUseCase) RetryDLQ(ctx context.Context, tenantID, dlqID, t DoNotPersistBody: row.Payload.DoNotPersistBody, } if err := ScheduleImmediate(ctx, uc.queue, job); err != nil { - return nil, errb.SysInternal("schedule dlq retry failed").WithCause(err) + return nil, wrapStoreErr(err, "schedule dlq retry failed") } doc.Status = enum.NotifyStatusPending diff --git a/internal/model/notification/usecase/delivery.go b/internal/model/notification/usecase/delivery.go index 00ea857..a63b284 100644 --- a/internal/model/notification/usecase/delivery.go +++ b/internal/model/notification/usecase/delivery.go @@ -23,7 +23,7 @@ func (d deliveryDeps) deliver(ctx context.Context, req *domusecase.SendRequest, switch req.Channel { case enum.ChannelEmail: if d.Email == nil { - return "", "", fmt.Errorf("email provider chain is not configured") + return "", "", errb.SysNotImplemented("email provider chain is not configured") } return d.Email.Send(ctx, &email.Message{ From: d.Config.Email.From, @@ -33,7 +33,7 @@ func (d deliveryDeps) deliver(ctx context.Context, req *domusecase.SendRequest, }) case enum.ChannelSMS: if d.SMS == nil { - return "", "", fmt.Errorf("sms provider chain is not configured") + return "", "", errb.SysNotImplemented("sms provider chain is not configured") } name := req.UID if name == "" { @@ -45,7 +45,7 @@ func (d deliveryDeps) deliver(ctx context.Context, req *domusecase.SendRequest, Body: rendered.SMSText, }) default: - return "", "", fmt.Errorf("channel %q delivery not implemented", req.Channel) + return "", "", errb.SysNotImplemented(fmt.Sprintf("channel %q delivery not implemented", req.Channel)) } } diff --git a/internal/model/notification/usecase/errors.go b/internal/model/notification/usecase/errors.go new file mode 100644 index 0000000..f71ca5c --- /dev/null +++ b/internal/model/notification/usecase/errors.go @@ -0,0 +1,55 @@ +package usecase + +import ( + "errors" + "strings" + + errs "gateway/internal/library/errors" + "gateway/internal/library/errors/code" + "gateway/internal/model/notification" +) + +var errb = errs.For(code.Notification) + +func wrapRepoErr(err error, msg ...string) error { + if err == nil { + return nil + } + if errors.Is(err, notification.ErrNotFound) { + return errb.ResNotFound("notification", "").WithCause(err) + } + if errors.Is(err, notification.ErrInvalidObjectID) { + return errb.ResInvalidMeasureID("notification id").WithCause(err) + } + if errors.Is(err, notification.ErrDuplicateIdempotency) { + return errb.ResAlreadyExist("notification idempotency key").WithCause(err) + } + if errors.Is(err, notification.ErrInvalidChannel) { + return errb.InputInvalidFormat("invalid notification channel").WithCause(err) + } + if errors.Is(err, notification.ErrQuotaExceeded) { + return errb.ResInsufficientQuota("notification quota exceeded").WithCause(err) + } + if e := errs.FromError(err); e != nil { + return err + } + m := strings.TrimSpace(strings.Join(msg, " ")) + if m == "" { + m = "notification repository error" + } + return errb.DBError(m).WithCause(err) +} + +func wrapStoreErr(err error, msg ...string) error { + if err == nil { + return nil + } + if e := errs.FromError(err); e != nil { + return err + } + m := strings.TrimSpace(strings.Join(msg, " ")) + if m == "" { + m = "notification store error" + } + return errb.DBError(m).WithCause(err) +} diff --git a/internal/model/notification/usecase/notifier_usecase.go b/internal/model/notification/usecase/notifier_usecase.go index 1ba8ab3..4a15f5e 100644 --- a/internal/model/notification/usecase/notifier_usecase.go +++ b/internal/model/notification/usecase/notifier_usecase.go @@ -7,8 +7,6 @@ import ( "fmt" "time" - errs "gateway/internal/library/errors" - "gateway/internal/library/errors/code" "gateway/internal/model/notification" notifconfig "gateway/internal/model/notification/config" domentity "gateway/internal/model/notification/domain/entity" @@ -26,8 +24,6 @@ const ( quotaKeyTTL = 25 * time.Hour ) -var errb = errs.For(code.Facade) - // NotifierUseCaseParam wires dependencies for NotifierUseCase. type NotifierUseCaseParam struct { Repo domrepo.NotificationRepository @@ -170,7 +166,7 @@ func (uc *notifierUseCase) Enqueue(ctx context.Context, req *domusecase.SendRequ } if uc.RetryQueue == nil { - return nil, errb.SysInternal("async notification requires redis retry queue") + return nil, errb.SysNotImplemented("async notification requires redis retry queue") } job := &domusecase.RetryJob{ NotificationID: doc.ID.Hex(), @@ -184,7 +180,7 @@ func (uc *notifierUseCase) Enqueue(ctx context.Context, req *domusecase.SendRequ DoNotPersistBody: req.DoNotPersistBody, } if err := ScheduleImmediate(ctx, uc.RetryQueue, job); err != nil { - return nil, errb.SysInternal("failed to schedule notification").WithCause(err) + return nil, wrapStoreErr(err, "failed to schedule notification") } dto := entityToDTO(doc) @@ -244,12 +240,12 @@ func (uc *notifierUseCase) lookupIdempotent(ctx context.Context, req *domusecase key := notification.GetIdempotencyRedisKey(req.TenantID, string(req.Kind), req.IdempotencyKey) raw, err := uc.Idempotency.Get(ctx, key) if err != nil { - return nil, false, errb.SysInternal("idempotency cache read failed").WithCause(err) + return nil, false, wrapStoreErr(err, "idempotency cache read failed") } if len(raw) > 0 { var dto domusecase.NotificationDTO if err := json.Unmarshal(raw, &dto); err != nil { - return nil, false, errb.SysInternal("idempotency cache decode failed").WithCause(err) + return nil, false, errb.DBDataConvert("idempotency cache decode failed").WithCause(err) } return &dto, true, nil } @@ -293,7 +289,7 @@ func (uc *notifierUseCase) checkQuota(ctx context.Context, req *domusecase.SendR key := notification.GetQuotaRedisKey(req.TenantID, string(req.Channel), day) count, err := uc.Quota.Incr(ctx, key, quotaKeyTTL) if err != nil { - return errb.SysInternal("quota check failed").WithCause(err) + return wrapStoreErr(err, "quota check failed") } if count > int64(limit) { return errb.ResInsufficientQuota("notification daily quota exceeded") @@ -320,7 +316,7 @@ func (uc *notifierUseCase) markFailed(ctx context.Context, doc *domentity.Notifi }); updateErr != nil { cause = fmt.Errorf("%w; persist failed status: %w", cause, updateErr) } - return errb.SysInternal("notification template render failed").WithCause(cause) + return errb.SvcInternal("notification template render failed").WithCause(cause) } func validateSendRequest(req *domusecase.SendRequest) error { diff --git a/internal/model/notification/usecase/repo_errors.go b/internal/model/notification/usecase/repo_errors.go deleted file mode 100644 index ce2d6d0..0000000 --- a/internal/model/notification/usecase/repo_errors.go +++ /dev/null @@ -1,20 +0,0 @@ -package usecase - -import ( - "errors" - - "gateway/internal/model/notification" -) - -func wrapRepoErr(err error) error { - if errors.Is(err, notification.ErrNotFound) { - return errb.ResNotFound("notification", "").WithCause(err) - } - if errors.Is(err, notification.ErrInvalidObjectID) { - return errb.ResInvalidMeasureID("notification id").WithCause(err) - } - if errors.Is(err, notification.ErrDuplicateIdempotency) { - return errb.ResAlreadyExist("notification idempotency key").WithCause(err) - } - return errb.DBError("notification repository error").WithCause(err) -} diff --git a/internal/model/notification/usecase/retry_worker.go b/internal/model/notification/usecase/retry_worker.go index dddc609..d996f8d 100644 --- a/internal/model/notification/usecase/retry_worker.go +++ b/internal/model/notification/usecase/retry_worker.go @@ -124,7 +124,7 @@ func (w *RetryWorker) ProcessJob(ctx context.Context, job *domusecase.RetryJob) if errors.Is(err, notification.ErrNotFound) { return nil } - return err + return wrapRepoErr(err, "read notification failed") } if doc.Status == enum.NotifyStatusSent || doc.Status == enum.NotifyStatusDropped { return nil @@ -159,7 +159,7 @@ func (w *RetryWorker) ProcessJob(ctx context.Context, job *domusecase.RetryJob) Body: body, DeliveredAt: &now, }); err != nil { - return err + return wrapRepoErr(err, "update notification delivery failed") } return nil } @@ -175,7 +175,7 @@ func (w *RetryWorker) handleFailure(ctx context.Context, doc *domentity.Notifica LastError: lastErr, Attempts: attempts, }); err != nil { - return err + return wrapRepoErr(err, "update notification delivery failed") } return w.insertDLQ(ctx, doc, job, lastErr, attempts) } @@ -185,11 +185,14 @@ func (w *RetryWorker) handleFailure(ctx context.Context, doc *domentity.Notifica LastError: lastErr, Attempts: attempts, }); err != nil { - return err + return wrapRepoErr(err, "update notification delivery failed") } runAt := time.Now().UTC().Add(retryDelay(w.Config.Async, attempts)).UnixMilli() - return w.Queue.Schedule(ctx, runAt, job) + if err := w.Queue.Schedule(ctx, runAt, job); err != nil { + return wrapStoreErr(err, "schedule notification retry failed") + } + return nil } func (w *RetryWorker) insertDLQ( @@ -212,7 +215,7 @@ func (w *RetryWorker) insertDLQ( DoNotPersistBody: job.DoNotPersistBody, } } - return w.DLQ.Insert(ctx, &domentity.NotificationDLQ{ + return wrapRepoErr(w.DLQ.Insert(ctx, &domentity.NotificationDLQ{ NotificationID: doc.ID.Hex(), TenantID: doc.TenantID, UID: doc.UID, @@ -224,13 +227,13 @@ func (w *RetryWorker) insertDLQ( Payload: payload, OccurredAt: doc.OccurredAt, CreateAt: &now, - }) + }), "insert notification dlq failed") } // ScheduleImmediate enqueues a job to run now (used by Enqueue). func ScheduleImmediate(ctx context.Context, queue domrepo.RetryQueue, job *domusecase.RetryJob) error { if queue == nil { - return fmt.Errorf("notification: retry queue is not configured") + return errb.SysNotImplemented("notification: retry queue is not configured") } return queue.Schedule(ctx, time.Now().UTC().UnixMilli(), job) } diff --git a/internal/response/README.md b/internal/response/README.md index 4d24f70..ae88c2b 100644 --- a/internal/response/README.md +++ b/internal/response/README.md @@ -42,14 +42,17 @@ response.RequestErrScope = code.Facade ## Logic 範例 +各模組 logic / usecase 綁定對應 scope(**不要**一律用 `code.Facade`): + ```go -var errb = errs.For(code.Facade) +// internal/logic/member/errors.go +var errb = errs.For(code.Member) func (l *XLogic) GetUser(req *types.GetUserReq) (*types.UserVO, error) { - if req.Id == "" { - return nil, errb.InputMissingRequired("id") + out, err := l.svcCtx.MemberProfile.Get(...) + if err != nil { + return nil, err // Member scope 錯誤原樣傳遞 } - // ... return vo, nil } ``` @@ -59,16 +62,16 @@ func (l *XLogic) GetUser(req *types.GetUserReq) (*types.UserVO, error) { 成功(HTTP 200): ```json -{ "code": 0, "message": "SUCCESS", "data": { ... } } +{ "code": 102000, "message": "SUCCESS", "data": { ... } } ``` -失敗(HTTP 依 category,如 404): +失敗(HTTP 依 category,如 404;Member scope 範例): ```json { - "code": 10301000, - "message": "user not found", - "error": { "biz_code": "10301000", "scope": 10, "category": 301, "detail": 0 } + "code": 29301000, + "message": "member not found", + "error": { "biz_code": "29301000", "scope": 29, "category": 301, "detail": 0 } } ``` diff --git a/internal/response/response.go b/internal/response/response.go index 049c8e5..22cdf8b 100644 --- a/internal/response/response.go +++ b/internal/response/response.go @@ -14,7 +14,7 @@ import ( ) // Write serializes data or err into types.Status and writes the HTTP response. -// - Success: HTTP 200, code=0, message=SUCCESS, data= +// - Success: HTTP 200, code=102000, message=SUCCESS, data= // - Failure: HTTP from errs.Error.HTTPStatus(), code/message/error from business error func Write(ctx context.Context, w http.ResponseWriter, data any, err error) { if err != nil { diff --git a/internal/svc/service_context.go b/internal/svc/service_context.go index 689a1a0..940dcea 100644 --- a/internal/svc/service_context.go +++ b/internal/svc/service_context.go @@ -10,6 +10,11 @@ import ( libmongo "gateway/internal/library/mongo" redislib "gateway/internal/library/redis" "gateway/internal/library/validate" + "gateway/internal/library/zitadel" + authdomrepo "gateway/internal/model/auth/domain/repository" + domauth "gateway/internal/model/auth/domain/usecase" + authrepo "gateway/internal/model/auth/repository" + authusecase "gateway/internal/model/auth/usecase" domrepo "gateway/internal/model/member/domain/repository" dommember "gateway/internal/model/member/domain/usecase" memberusecase "gateway/internal/model/member/usecase" @@ -19,12 +24,18 @@ import ( ) type ServiceContext struct { - Config config.Config - Validator validate.Validate - Redis *redislib.Client - Notifier domnotif.NotifierUseCase - NotificationAdmin domnotif.AdminNotifierUseCase - NotificationRetry *notification_retry.Runner + Config config.Config + Validator validate.Validate + Redis *redislib.Client + AuthToken domauth.TokenUseCase + AuthInvite domauth.InviteUseCase + AuthRegistrationMeta domauth.RegistrationMetaUseCase + AuthRegistrationSession domauth.RegistrationSessionUseCase + AuthLoginSession domauth.LoginSessionUseCase + Zitadel *zitadel.Client + Notifier domnotif.NotifierUseCase + NotificationAdmin domnotif.AdminNotifierUseCase + NotificationRetry *notification_retry.Runner MemberOTP dommember.OTPUseCase MemberTOTP dommember.TOTPUseCase @@ -32,7 +43,7 @@ type ServiceContext struct { MemberLifecycle dommember.LifecycleUseCase MemberProvisioning dommember.ProvisioningUseCase MemberTenant dommember.TenantUseCase - MemberVerifyRate domrepo.VerifyRateStore + MemberVerifyRate dommember.VerifyRateUseCase MemberRepo domrepo.MemberRepository } @@ -52,6 +63,22 @@ func NewServiceContext(c config.Config) *ServiceContext { Validator: v, Redis: rds, } + authCfg := c.Auth.Defaults() + if authCfg.Enabled() { + var revoke authdomrepo.TokenRevokeStore + if rds != nil && rds.Zero() != nil { + revoke = authrepo.NewRedisTokenRevokeStore(rds) + } + sc.AuthToken = authusecase.MustTokenUseCase(authusecase.TokenUseCaseParam{ + Config: authCfg, + Revoke: revoke, + }) + } + zClient, err := zitadel.NewClient(c.Zitadel) + if err != nil { + panic(err) + } + sc.Zitadel = zClient if c.Mongo.Host != "" { mod, err := notifusecase.NewModuleFromParam(notifusecase.FactoryParam{ MongoConf: &c.Mongo, @@ -64,6 +91,18 @@ func NewServiceContext(c config.Config) *ServiceContext { sc.Notifier = mod.Notifier sc.NotificationAdmin = mod.Admin sc.NotificationRetry = notification_retry.NewRunner(mod.RetryWorker) + + authMod, err := authusecase.NewModuleFromParam(authusecase.ModuleParam{ + MongoConf: &c.Mongo, + Redis: rds, + }) + if err != nil { + panic(err) + } + sc.AuthInvite = authMod.Invite + sc.AuthRegistrationMeta = authMod.RegistrationMeta + sc.AuthRegistrationSession = authMod.RegistrationSession + sc.AuthLoginSession = authMod.LoginSession } if rds != nil && rds.Zero() != nil { var mongoConf *libmongo.Conf diff --git a/internal/types/types.go b/internal/types/types.go index 9e3ea25..ef13212 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -9,6 +9,14 @@ type APIErrorStatus struct { Error ErrorDetail `json:"error"` } +type AuthTokenData struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int64 `json:"expires_in"` + UID string `json:"uid"` + TokenType string `json:"token_type"` +} + type ErrorDetail struct { BizCode string `json:"biz_code"` Scope uint32 `json:"scope,omitempty"` @@ -16,6 +24,33 @@ type ErrorDetail struct { Detail uint32 `json:"detail,omitempty"` } +type LoginReq struct { + TenantSlug string `json:"tenant_slug" validate:"required"` + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=8,max=128"` +} + +type LoginSocialCallbackReq struct { + Code string `form:"code" validate:"required"` + State string `form:"state" validate:"required"` +} + +type LoginSocialStartData struct { + OauthURL string `json:"oauth_url"` + SessionID string `json:"session_id"` + ExpiresIn int `json:"expires_in"` +} + +type LoginSocialStartReq struct { + TenantSlug string `json:"tenant_slug" validate:"required"` + Provider string `json:"provider" validate:"required,oneof=google"` + RedirectURI string `json:"redirect_uri" validate:"required,url"` +} + +type LogoutData struct { + OK bool `json:"ok"` +} + type MemberMeData struct { TenantID string `json:"tenant_id"` UID string `json:"uid"` @@ -46,6 +81,55 @@ type PingOKStatus struct { Data PingData `json:"data"` } +type RegisterConfirmReq struct { + TenantSlug string `json:"tenant_slug" validate:"required"` + ChallengeID string `json:"challenge_id" validate:"required"` + Code string `json:"code" validate:"required,len=6"` +} + +type RegisterData struct { + ChallengeID string `json:"challenge_id"` + ExpiresIn int `json:"expires_in"` + UID string `json:"uid"` +} + +type RegisterReq struct { + TenantSlug string `json:"tenant_slug" validate:"required"` + InviteCode string `json:"invite_code" validate:"required"` + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=8,max=128"` + DisplayName string `json:"display_name,optional"` + Language string `json:"language,optional"` + AcceptTermsVersion string `json:"accept_terms_version" validate:"required"` + MarketingOptIn bool `json:"marketing_opt_in,optional"` +} + +type RegisterResendReq struct { + TenantSlug string `json:"tenant_slug" validate:"required"` + ChallengeID string `json:"challenge_id" validate:"required"` +} + +type RegisterSocialCallbackReq struct { + Code string `form:"code" validate:"required"` + State string `form:"state" validate:"required"` +} + +type RegisterSocialStartData struct { + OauthURL string `json:"oauth_url"` + SessionID string `json:"session_id"` + ExpiresIn int `json:"expires_in"` +} + +type RegisterSocialStartReq struct { + TenantSlug string `json:"tenant_slug" validate:"required"` + InviteCode string `json:"invite_code" validate:"required"` + Provider string `json:"provider" validate:"required,oneof=google"` + AcceptTermsVersion string `json:"accept_terms_version" validate:"required"` + Language string `json:"language,optional"` + RedirectURI string `json:"redirect_uri" validate:"required,url"` + MarketingOptIn bool `json:"marketing_opt_in,optional"` +} + type TOTPBackupCodesData struct { BackupCodes []string `json:"backup_codes"` } @@ -79,6 +163,15 @@ type TOTPVerifyReq struct { Code string `json:"code"` } +type TokenExchangeReq struct { + TenantSlug string `json:"tenant_slug" validate:"required"` + IDToken string `json:"id_token" validate:"required"` +} + +type TokenRefreshReq struct { + RefreshToken string `json:"refresh_token" validate:"required"` +} + type UpdateMemberMeReq struct { DisplayName string `json:"display_name,optional"` Avatar string `json:"avatar,optional"`