feat(auth): add unified registration/login module with Zitadel + lint cleanup

- Introduce auth module: handlers, logic, domain/repository/usecase, JWT
  middleware, and Zitadel OIDC client (password + authorization code +
  userinfo + JWKS verification)
- Wire member rate-limit, structured errors, and refactored member/
  notification usecases (introduce shared errors, drop repo_errors.go)
- Bring the codebase to zero golangci-lint issues:
  * goimports formatting
  * errcheck on io.ReadAll/Unlock cleanup paths
  * contextcheck: HandlerContext now takes (ctx, *http.Request)
  * gocritic: rename shadowed `max`, use http.NoBody
  * goconst: extract test fixtures and bsonOpSet
  * testifylint: switch to assert inside httptest handlers

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王性驊 2026-05-21 14:45:35 +08:00
parent 2ae86e9002
commit 713a81f70b
121 changed files with 6937 additions and 242 deletions

View File

@ -176,7 +176,8 @@ HTTP Request
- [internal/library/errors/README.md](internal/library/errors/README.md) — 錯誤碼與 HTTP 對照 - [internal/library/errors/README.md](internal/library/errors/README.md) — 錯誤碼與 HTTP 對照
- [internal/library/mongo/README.md](internal/library/mongo/README.md) — MongoDB / Redis cache 流程與用法 - [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/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 ### 2. Logic 與 Handler
```go ```go
// internal/logic/... — 編排與映射;有持久化時呼叫 svcCtx.{Module}UC // internal/logic/auth — Auth scope
var errb = errs.For(code.Facade) var errb = errs.For(code.Auth)
func (l *PingLogic) Ping() (*types.PingData, error) { // internal/logic/member — Member scope
return &types.PingData{Pong: "ok"}, nil // 簡單 API 可直接回 types var errb = errs.For(code.Member)
}
// internal/handler/... — 由模板生成 // internal/handler/... — 由模板生成parse/validate 錯誤用 Facade scoperesponse.RequestErrScope
data, err := l.Ping() data, err := l.Ping()
response.Write(r.Context(), w, data, err) 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 格式 ### 3. HTTP JSON 格式
@ -211,21 +211,21 @@ response.Write(r.Context(), w, data, err)
```json ```json
{ {
"code": 0, "code": 102000,
"message": "SUCCESS", "message": "SUCCESS",
"data": { } "data": { }
} }
``` ```
**失敗HTTP 依錯誤類別,如 404** **失敗HTTP 依錯誤類別,如 404Member scope 範例**
```json ```json
{ {
"code": 10301000, "code": 29301000,
"message": "user not found", "message": "member not found",
"error": { "error": {
"biz_code": "10301000", "biz_code": "29301000",
"scope": 10, "scope": 29,
"category": 301, "category": 301,
"detail": 0 "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** | | **repository** | 忠實反映基礎設施Mongo / Redis / driver | `*errs.Error`DB*、ResInvalidMeasureID 等)+ `WithCause`;可預期「無資料」可回模組 `errors.go`**sentinel** |
| **usecase** | 業務規則(狀態、權限、組合多 repo | `*errs.Error`Res*、Auth*、Svc* 等sentinel 轉成對外語意;已是正確的 `*errs.Error` 可原樣往上傳 | | **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` | | **handler** | 序列化 | `response.Write`(內建 `errs.FromError` |
模組頂層 sentinel 範例(`internal/model/member/errors.go``package member` 模組頂層 sentinel 範例(`internal/model/member/errors.go``package member`
@ -264,10 +264,10 @@ Repository 對照建議:
| 連線 / 暫時不可用 | `errb.DBUnavailable(...).WithCause(err)` | | 連線 / 暫時不可用 | `errb.DBUnavailable(...).WithCause(err)` |
| 其他 driver 錯 | `errb.DBError(...).WithCause(err)` | | 其他 driver 錯 | `errb.DBError(...).WithCause(err)` |
Usecase 範例: Usecase 範例Member scope
```go ```go
var errb = errs.For(code.Facade) var errb = errs.For(code.Member)
acc, err := uc.Account.FindOne(ctx, id) acc, err := uc.Account.FindOne(ctx, id)
if err != nil { if err != nil {
@ -336,10 +336,12 @@ import (
"gateway/internal/library/errors/code" "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, memberErr.ResNotFound("member", id)
return nil, errb.InputMissingRequired("email").WithCause(err) return nil, authErr.InputMissingRequired("email").WithCause(err)
``` ```
Category 與 HTTP 對照見 [internal/library/errors/README.md](internal/library/errors/README.md)。 Category 與 HTTP 對照見 [internal/library/errors/README.md](internal/library/errors/README.md)。

View File

@ -9,6 +9,7 @@ import (
"time" "time"
"gateway/internal/config" "gateway/internal/config"
authrepo "gateway/internal/model/auth/repository"
memberrepo "gateway/internal/model/member/repository" memberrepo "gateway/internal/model/member/repository"
notifrepo "gateway/internal/model/notification/repository" notifrepo "gateway/internal/model/notification/repository"
@ -48,7 +49,10 @@ func run() error {
if err := memberrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil { if err := memberrepo.EnsureMongoIndexes(ctx, &c.Mongo); err != nil {
return fmt.Errorf("mongo-index: member: %w", err) 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 return nil
} }

View File

@ -0,0 +1,405 @@
# Gateway 統一註冊 — 設計規格
> **狀態**已實作PR 18 ✅PR 9 文件修訂 ✅)
> **修訂**:取代 [identity-member-design.md §3.4](./identity-member-design.md)「Gateway 不暴露 `/auth/register`
> **最後更新**2026-05-21
## 1. 目標
使用者 **只與 Portal Gateway 互動** 完成註冊首次登入ZITADEL 作為 identity 後端帳密、OIDC、鎖定不作第二個註冊入口。
| 路徑 | 說明 |
|------|------|
| **A** | Email + Password + Email OTP 確認 |
| **B** | 上述流程 **必填 invite code**Logic 驗證) |
| **C** | SocialGoogle 為 P0可擴 Apple— 同一註冊 UXOAuth 前綁定 invite |
Logic 層負責商務驗證與跨 atomic usecase 編排usecase 維持 atomic見 [model.md §6.1](./model.md))。
---
## 2. 為何比 ZITADEL Hosted 註冊好
- 單一 App / API無「跳去 IdP 再回來」的割裂感
- 註冊表單可帶 **商務欄位**條款版本、語系、marketing、invite、referral
- 失敗補償與 audit 在 Gateway 可控
- 與現有 member OTP、notification 模板、`CreateUnverified` / `Activate` 直接對接
---
## 3. 三條註冊路徑(統一入口 UX
```
┌─────────────────────────────────────┐
│ 前端:同一「註冊」頁 / Wizard │
│ tenant + invite_code + 條款 (+ 語系) │
└──────────────┬──────────────────────┘
┌────────────────────┼────────────────────┐
▼ ▼ ▼
Email + Password Social (Google) (未來 Apple
│ │
▼ ▼
POST /auth/register POST /auth/register/social/start
│ │ → oauth_url
▼ ▼
POST /auth/register/confirm GET /auth/register/social/callback
│ │
└────────┬───────────┘
IssueTokenPair (CloudEP JWT)
```
### 3.1 路徑 A+BEmail 註冊
1. Logic 驗證:`tenant_slug`、`invite_code`、條款、密碼強度、email 格式
2. Logic 驗證並 **消耗** invite見 §6
3. `zitadel.CreateHumanUser`email + passwordorg = tenant 對應 ZITADEL org
4. `member.Lifecycle.CreateUnverified``origin=platform_native`,存 `zitadel_sub` 若 API 回傳)
5. Logic 寫入 **registration metadata**§7
6. `member.OTP.Generate``purpose=registration_email`+ `notifier.Send``verify_registration_email`
7. 回 `{ challenge_id, expires_in }`**不發 JWT**
8. `POST /auth/register/confirm`OTP verify → `Activate``IssueTokenPair`
### 3.2 路徑 CSocial 註冊Google
Invite **必填**;在 OAuth redirect **之前** 綁定 session避免 callback 時無 invite。
1. `POST /auth/register/social/start`
Body`tenant_slug`, `invite_code`, `provider=google`, `accept_terms_version`, 可選 `language`
Logic驗 tenant + invite + 條款 → 建立 **registration session**Redis→ 回 `{ oauth_url, session_id }`
2. 使用者完成 Google OAuth → callback `GET /auth/register/social/callback?code=...&state=...`
3. Logic換 token、驗 state、讀 session 取 invite → **消耗 invite**
4. `zitadel` 查/建 user或 link`member.Provisioning.EnsureFromOIDC`
5. 若 email 已在 ZITADEL verified → 直接 `Activate`;否則可走 OTP 或信任 IdP `email_verified`**已決策:信任 Google email_verified=true 則 skip OTP**
6. 寫 registration metadata → `IssueTokenPair`
> 已存在 member同 tenant + zitadel_sub→ 視為 **登入**,仍驗 invite 是否允許「新註冊 only」若 invite 僅限新 user 且已存在 → `409` + 引導 login。
### 3.3 登入(非註冊)
| API | 說明 |
|-----|------|
| `POST /auth/login` | email + password → ZITADEL ROPG / token → member lookup → JWT |
| `POST /auth/token/exchange` | 保留:已登入 ZITADEL 的 id_token 換 JWT企業 SSO、舊 client |
| `POST /auth/login/social` | 可選OAuth 僅登入、不經 register sessionP1與 register 分 state |
---
## 4. API 規格(`generate/api/auth.api`
### 4.1 公開 — 註冊
**POST `/api/v1/auth/register`**
Request:
```json
{
"tenant_slug": "acme",
"invite_code": "BETA-2026-XXXX",
"email": "user@example.com",
"password": "string",
"display_name": "Daniel",
"language": "zh-tw",
"accept_terms_version": "2026-05-01",
"marketing_opt_in": false
}
```
Response 200:
```json
{
"code": 0,
"data": {
"challenge_id": "uuid",
"expires_in": 300,
"uid": "ACME-10000042"
}
}
```
**POST `/api/v1/auth/register/confirm`**
```json
{
"tenant_slug": "acme",
"challenge_id": "uuid",
"code": "482913"
}
```
Response 200: `{ access_token, refresh_token, expires_in, uid, token_type: "Bearer" }`
**POST `/api/v1/auth/register/resend`**
```json
{
"tenant_slug": "acme",
"challenge_id": "uuid"
}
```
**POST `/api/v1/auth/register/social/start`**
```json
{
"tenant_slug": "acme",
"invite_code": "BETA-2026-XXXX",
"provider": "google",
"accept_terms_version": "2026-05-01",
"language": "zh-tw",
"redirect_uri": "https://app.example.com/auth/callback"
}
```
Response: `{ oauth_url, session_id, expires_in }`
**GET `/api/v1/auth/register/social/callback`**
Query: `code`, `state`(含 session_id
Response: 302 到前端帶 token或 JSON`Accept` / 設定)
### 4.2 公開 — 登入 / Token
| Method | Path | 說明 |
|--------|------|------|
| POST | `/api/v1/auth/login` | email + password + tenant_slug |
| POST | `/api/v1/auth/token/refresh` | refresh_token |
| POST | `/api/v1/auth/token/exchange` | id_token + tenant_slugSSO 備用) |
| POST | `/api/v1/auth/logout` | JWTjti 黑名單 |
### 4.3 驗證 tag`.api`
- `register`: `required,email`, `password` min length, `invite_code` required
- 公開路由 **不走** JWT middleware**走** rate limit§12
---
## 5. 分層與新增模組
```
internal/
library/zitadel/ # HTTP clientCreateUser, VerifyPassword, OIDC token
model/auth/
config/
domain/
entity/registration_meta.go
repository/invite.go, registration_session.go
usecase/token.go, invite.go
usecase/ # atomicIssueTokenPair, ValidateInvite, ConsumeInvite
logic/auth/
register_logic.go
register_confirm_logic.go
register_resend_logic.go
register_social_start_logic.go
register_social_callback_logic.go
login_logic.go
middleware/
jwt_cloud_ep.go # P1取代 dev X-Tenant-ID header
```
**Logic 專屬商務(不下沉 usecase**
- invite 是否必填、是否過期、是否限新 user
- 條款版本是否接受
- tenant 是否允許 B2C 註冊
- 密碼政策client + server 雙重)
- 註冊 rate limit key 組合
- SocialOAuth state 與 registration session 綁定
**auth usecaseatomic**
- `TokenUseCase.IssuePair / Refresh / Logout`
- `InviteUseCase.Validate / Consume`(或 tenant 模組,見 §6
**禁止**auth usecase 呼叫 member usecase一律在 logic 編排。
---
## 6. Invite CodeB 必填)
### 6.1 資料模型Mongo `invite_codes`
| 欄位 | 說明 |
|------|------|
| tenant_id | 租戶 |
| code_hash | bcrypt/sha256不明文存 |
| max_uses | 总次數 |
| used_count | 已用 |
| expires_at | 可選 |
| new_users_only | default true |
| created_at | |
索引:`(tenant_id, code_hash)` unique
### 6.2 流程
1. `ValidateInvite(tenant_id, plain_code)` → ok / `ErrInviteInvalid|Expired|Exhausted`
2. **Consume** 在 ZITADEL/member 建立 **之前** 用 Redis lock + Mongo `$inc used_count`(防并发超卖)
3. 若後續 ZITADEL 或 member 失敗 → **不回滚 invite**(已決策:防刷;可 admin 补发。Logic 记录 audit + 人工处理阈值
可选 P1`ReleaseInvite` 仅当失败发生在 Validate 后 30s 内且 ZITADEL 未创建。
---
## 7. Registration MetadataLogic 要留的資料)
Collection`registration_metadata` 或 member 子文档 `registration`
| 欄位 | 來源 |
|------|------|
| tenant_id, uid | member |
| invite_code_id | 消耗后的 invite 记录 id |
| accept_terms_version | request |
| marketing_opt_in | request |
| registration_channel | `email` \| `google` |
| client_ip / user_agent | handler 注入 context |
| occurred_at | server |
写入时机email 路径在 `CreateUnverified`social 在 `EnsureFromOIDC` 后。
---
## 8. 狀態機
### Email 註冊
```
register → unverified (member) + zitadel user active
confirm OK → active + JWT
OTP max attempts → challenge locked
abort: cron 清理 7 天未 confirm 的 unverified + zitadel deactivate
```
### Social 註冊
```
social/start → registration_session (Redis TTL 10min, 含 invite_id)
callback OK → active + JWT
session 过期 → 重新 start
```
---
## 9. 失敗補償
| 步驟失敗 | 補償 |
|----------|------|
| invite 无效 | 直接 4xx无 side effect |
| ZITADEL CreateUser 成功member 失败 | Logic 调用 `zitadel.DeactivateUser(sub)` |
| member 成功Send OTP 失败 | `OTP.Invalidate(challenge_id)` |
| confirm 时 Activate 失败 | 5xx保留 challenge 可重试 confirm |
---
## 10. 錯誤碼Logic / Usecase
| 情境 | errb |
|------|------|
| invite 无效 | `InputInvalidFormat``ResNotFound("invite")` |
| invite 用尽 | `ResInsufficientQuota` |
| email 已注册 | `ResAlreadyExist` |
| OTP 错误 | `AuthForbidden` + cause |
| tenant 不允许注册 | `AuthForbidden` |
| 未 accept 条款 | `InputMissingRequired` |
| ZITADEL 下游失败 | `SvcThirdParty` |
| DB 失败 | `DBError` via wrapRepoErr |
---
## 11. 設定(`etc/gateway.yaml`
```yaml
Auth:
AccessExpire: 900
RefreshExpire: 604800
RegistrationSessionTTLSeconds: 600
Zitadel:
Issuer: https://zitadel.internal
MgmtURL: https://zitadel.internal/management/v1
ServiceUserToken: ${ZITADEL_SERVICE_TOKEN}
GoogleClientID: ...
GoogleClientSecret: ...
DefaultOrgID: ... # 或 per-tenant 映射表
Member:
Registration:
RequireInviteCode: true
UnverifiedRetentionDays: 7
TrustSocialEmailVerified: true
```
---
## 12. Rate LimitRedis
| Key | 限制 |
|-----|------|
| `auth:register:ip:{ip}` | 10 / hour |
| `auth:register:email:{tenant}:{email}` | 3 / hour |
| `auth:register:invite_fail:{ip}` | 20 / hour |
| OTP resend | 沿用 member `VerifyRateStore` 或独立 cooldown |
---
## 13. 實施順序PR 切分)
| PR | 内容 | 验收 |
|----|------|------|
| **1** | `library/zitadel` + config + 单元测试mock server | CreateUser / VerifyPassword |
| **2** | `model/auth` TokenUseCaseIssue/Refresh+ JWT middleware 骨架 | 签/验 JWT |
| **3** | invite repository + InviteUseCase + mongo index | validate/consume |
| **4** | `auth.api` + Logic register + confirm + resend | curl 完整 email 注册 |
| **5** | registration_metadata + audit 字段 | Mongo 可查来源 |
| **6** | Social start/callback + registration session | Google 注册拿 JWT |
| **7** | `POST /auth/login` + 取代 dev header | member API Bearer 访问 |
| **8** | `/auth/token/exchange` + login/social 分离 | 企业 SSO 不冲突 |
| **9** | 文档修订 identity-member-design §3.4、§7.1 | 文档一致 ✅ |
---
## 14. 測試策略
- **单元**invite consume 并发、OTP purpose、errb 映射
- **集成**testcontainers Mongo/Redis + zitadel mock
- **E2E CLI**`cmd/auth-register-test` 模拟 register → confirm → login
- **Social**mock OAuth callback with fixed state
---
## 15. 與現有代码复用
| 已有 | 用途 |
|------|------|
| `Lifecycle.CreateUnverified` / `Activate` | email 注册 |
| `OTP.*` + `OTPPurposeRegistrationEmail` | 确认 |
| `Notifier` + `NotifyVerifyRegistrationEmail` | 寄信 |
| `Provisioning.EnsureFromOIDC` | social JIT |
| `Tenant.ResolveBySlug` | tenant_slug → tenant_id |
| `response.Write` + `errb` | HTTP 输出 |
---
## 16. 修訂 identity-member-design.md已完成
以下修訂已寫入 [identity-member-design.md](./identity-member-design.md)v1.0.02026-05-21
- **§3.4**Gateway 統一註冊 BFFEmail / Social / invite 必填)
- **§7.1**auth.api 路由表(含實作狀態)
- **§8.1**CloudEP JWT middleware + dev header fallback
- **§9.1**login / token exchange 流程
- **§5.9 Case A**:標記為已實作
- **決策列 19**:註冊路徑決策更新
---
## 17. 前端契约(简要)
1. 注册页收集:`tenant_slug`, `invite_code`, 条款, 语言
2. TabEmail | Google同 invite + 条款)
3. Emailregister → 输入 OTP → confirm → 存 token
4. Googlesocial/start → redirect → callback 页取 token
5. 错误:`409` 已存在 → 引导 logininvite 无效 → 留在注册页

View File

@ -205,17 +205,30 @@ notification
- **Management API / JWKS**Gateway 透過內網 URL 存取,不經公網 - **Management API / JWKS**Gateway 透過內網 URL 存取,不經公網
- **設定**`etc/gateway.yaml` 的 `Zitadel.Issuer` / `MgmtURL` 指向 self-hosted 端點 - **設定**`etc/gateway.yaml` 的 `Zitadel.Issuer` / `MgmtURL` 指向 self-hosted 端點
### 3.4 註冊路徑(已決策:不提供 Gateway 註冊 API ### 3.4 註冊路徑(已決策:Gateway 統一註冊 BFF
Gateway **不暴露** `/auth/register`。註冊由下列路徑完成: > **完整規格**[auth-unified-registration.md](./auth-unified-registration.md)2026-05-21 起為準;本節為摘要)
| 租戶類型 | 註冊路徑 | 首次登入副作用 | Gateway **暴露** `/api/v1/auth/register*` 作為 B2C 統一註冊入口ZITADEL 作為 identity 後端帳密、OIDC**不再**要求使用者跳轉 ZITADEL Hosted Register UI。
|---------|----------|----------------|
| **B2C** | ZITADEL Hosted Register UI或前端走 ZITADEL OIDC PKCE | token exchange 觸發 `EnsureFromOIDC` JIT |
| **B2BLDAP** | 由 IT 在 AD / OpenLDAP 建帳;可選 Directory Sync 預 provision 到 ZITADEL | LDAP IdP 登入觸發 `EnsureFromLDAP` JIT |
| **B2BSCIM** | HR / Okta / Entra 推 SCIM Create User | SCIM endpoint 寫 ZITADEL + Gateway不需 JIT |
> ZITADEL 內建 email 驗證已完成「**可登入**」門檻;業務上「**可使用功能**」門檻見 §5.4 業務驗證。 | 租戶類型 | 註冊路徑 | 說明 |
|---------|----------|------|
| **B2C Email** | `POST /auth/register` → OTP → `POST /auth/register/confirm` | Logic 編排invite consume → `zitadel.CreateHumanUser``Lifecycle.CreateUnverified` → registration OTP → `Activate` → CloudEP JWT |
| **B2C SocialGoogle** | `POST /auth/register/social/start` → OAuth → `GET /auth/register/social/callback` | OAuth **前**綁定 invite sessionRediscallback 消耗 invite → `EnsureFromOIDC` → registration metadata → JWT |
| **B2BLDAP** | 由 IT 在 AD / OpenLDAP 建帳Directory Sync 預 provision | 登入走 LDAP IdP → `EnsureFromLDAP` JIT**不經** register API |
| **B2BSCIM** | HR / Okta / Entra 推 SCIM Create User | SCIM endpoint 寫 ZITADEL + Gateway**不經** register API |
**商務規則Logic 層,非 usecase**
- Invite code **必填**`Member.Registration.RequireInviteCode`,預設 `true`
- 條款版本 `accept_terms_version` 必填
- 註冊完成前 **不發** CloudEP JWTconfirm / social callback 後才 `IssuePair`
- Invite 消耗後若 ZITADEL / member 失敗 → **不回滾 invite**(防刷;見 auth-unified-registration §9
- Social 登入(非註冊)走 **`/auth/login/social/*`**,與 register session **分 state 前綴**`login:` vs `reg:`
**登入(非註冊)** 見 [auth-unified-registration.md §3.3](./auth-unified-registration.md#33-登入非註冊)`/auth/login`、`/auth/token/refresh`、`/auth/token/exchange`、Social login。
> ZITADEL 內建 email 驗證用於 **身份** 登入門檻;平台原生註冊另以 Gateway registration OTP`OTPPurposeRegistrationEmail`)確認後才 `Activate`
### 3.5 平台 MFA 強制(已決策) ### 3.5 平台 MFA 強制(已決策)
@ -979,47 +992,28 @@ C. Disable
| POST | `/api/v1/members/me/totp/backup-codes` | 重產 backup codes | ? `disable_totp` | | POST | `/api/v1/members/me/totp/backup-codes` | 重產 backup codes | ? `disable_totp` |
| DELETE | `/api/v1/members/me/totp` | 解除綁定 | ? `disable_totp` | | DELETE | `/api/v1/members/me/totp` | 解除綁定 | ? `disable_totp` |
### 5.9 UseCase 編排示例純概念handler / API 暫不實作) ### 5.9 UseCase 編排示例
> 展示 atomic primitives 可任意組合的邏輯流。**logic 層尚未實作**;本節僅證明介面契約可支撐預期業務 > 展示 atomic primitives **logic 層** 的組合方式。B2C 註冊 / 登入 **已實作**`internal/logic/auth/`;見 [auth-unified-registration.md](./auth-unified-registration.md)
#### Case A平台原生註冊 + Email OTP 驗證(未來路徑 #### Case A平台原生註冊 + Email OTP 驗證(**已實作**`RegisterLogic` / `RegisterConfirmLogic`
```go ```go
// 1) 建立 unverified member不寄信、不發 token // HTTP: POST /auth/register → Logic 編排(摘要)
// 1) invite consume若 RequireInviteCode
// 2) zitadel.CreateHumanUser
m, _ := mLifecycle.CreateUnverified(ctx, &CreatePlatformMemberRequest{ m, _ := mLifecycle.CreateUnverified(ctx, &CreatePlatformMemberRequest{
TenantID: tenantID, Email: email, DisplayName: name, TenantID: tenantID, Email: email, DisplayName: name, ZitadelUserID: zitadelSub,
}) })
// 3) registration metadata.Recordchannel=email
// 2) 產生 OTPatomic、purpose-agnostic chal, plain, _ := mOTP.Generate(ctx, &GenerateOTPRequest{
chal, _ := mOTP.Generate(ctx, &GenerateOTPRequest{ TenantID: tenantID, UID: m.UID, Purpose: OTPPurposeRegistrationEmail, Target: email,
TenantID: tenantID,
Purpose: OTPPurposeRegistrationEmail,
Identifier: m.UID,
}) })
notifier.Send(ctx, &SendRequest{ Kind: NotifyVerifyRegistrationEmail, Data: map[string]any{"code": plain, ...} })
// 3) 投遞 OTPatomiccaller 控制 channel / template // HTTP: POST /auth/register/confirm
notifier.Send(ctx, &SendRequest{ _ = mOTP.Verify(ctx, &VerifyOTPRequest{ ... Purpose: OTPPurposeRegistrationEmail })
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,
})
// (使用者收信、輸入 code → 後端走以下兩步)
// 4) 驗證 OTPatomic
_ = mOTP.Verify(ctx, &VerifyOTPRequest{
TenantID: tenantID, ChallengeID: chal.ChallengeID,
Code: userCode, Purpose: OTPPurposeRegistrationEmail,
})
// 5) 啟用atomicunverified → active
_ = mLifecycle.Activate(ctx, tenantID, m.UID) _ = mLifecycle.Activate(ctx, tenantID, m.UID)
// auth.IssuePair → { access_token, refresh_token }
``` ```
#### Case BOIDCSocial / ZITADEL Hosted UI登入 — 不需 OTP #### Case BOIDCSocial / ZITADEL Hosted UI登入 — 不需 OTP
@ -1510,14 +1504,24 @@ B2C
### 7.1 auth.api公開 / 需 JWT 視 API 而定) ### 7.1 auth.api公開 / 需 JWT 視 API 而定)
| Method | Path | 說明 | 鑑權 | > **已實作**2026-05-21下表「狀態」欄?注;完整請求/回應見 [auth-unified-registration.md §4](./auth-unified-registration.md#4-api-規格generateapiauthapi) 與 `generate/api/auth.api`
|--------|------|------|------|
| POST | `/api/v1/auth/token/exchange` | ZITADEL token → CloudEP JWT | 公開 | | Method | Path | 說明 | 鑑權 | 狀態 |
| POST | `/api/v1/auth/token/refresh` | 刷新 JWT | 公開(帶 refresh | |--------|------|------|------|------|
| POST | `/api/v1/auth/logout` | 登出jti 黑名單) | JWT | | POST | `/api/v1/auth/register` | Email + 密碼註冊ZITADEL + member + registration OTP | 公開 | ? |
| POST | `/api/v1/auth/revoke-all` | 撤銷自己所有 sessionINCR auth_gen | JWT + Step-up `revoke_all_sessions` | | POST | `/api/v1/auth/register/confirm` | 確認 registration OTP → CloudEP JWT | 公開 | ? |
| POST | `/api/v1/auth/step-up/start` | 啟動 step-up MFA寄 OTP | JWT | | POST | `/api/v1/auth/register/resend` | 重寄 registration OTP | 公開 | ? |
| POST | `/api/v1/auth/step-up/confirm` | 確認 OTP → 簽發短壽 `step_up_token` | JWT | | POST | `/api/v1/auth/register/social/start` | Social **註冊** start含 invite session | 公開 | ? |
| GET | `/api/v1/auth/register/social/callback` | Social **註冊** OAuth callback → JWT | 公開 | ? |
| POST | `/api/v1/auth/login` | Email + 密碼登入ZITADEL ROPG → JWT | 公開 | ? |
| POST | `/api/v1/auth/login/social/start` | Social **登入** start無 invite | 公開 | ? |
| GET | `/api/v1/auth/login/social/callback` | Social **登入** OAuth callback → JWT | 公開 | ? |
| POST | `/api/v1/auth/token/refresh` | 刷新 JWT | 公開(帶 refresh | ? |
| POST | `/api/v1/auth/token/exchange` | ZITADEL `id_token` → CloudEP JWT企業 SSO | 公開 | ? |
| POST | `/api/v1/auth/logout` | 登出jti 黑名單) | JWT | ? |
| POST | `/api/v1/auth/revoke-all` | 撤銷自己所有 sessionINCR auth_gen | JWT + Step-up `revoke_all_sessions` | 規劃中 |
| POST | `/api/v1/auth/step-up/start` | 啟動 step-up MFA寄 OTP | JWT | 規劃中 |
| POST | `/api/v1/auth/step-up/confirm` | 確認 OTP → 簽發短壽 `step_up_token` | JWT | 規劃中 |
### 7.2 member.api需 JWT + Casbin ### 7.2 member.api需 JWT + Casbin
@ -1595,6 +1599,17 @@ B2C
### 8.1 一般受保護 API ### 8.1 一般受保護 API
**目前已實作member 模組):**
```
Request
→ CloudEPJWT middleware可選 Bearer access JWT → 注入 tenant_id + uid 至 context
→ member handler若 context 無 actorfallback dev headers X-Tenant-ID + X-UID本機開發
→ handler → logic → usecase
```
**目標完整鏈Casbin / permission 模組就緒後):**
``` ```
Request Request
→ go-zero JWT 驗簽 → go-zero JWT 驗簽
@ -1661,17 +1676,44 @@ Casbin
### 9.1 登入 / 換票 ### 9.1 登入 / 換票
#### 9.1.1 Email + 密碼登入(已實作)
```
Client → POST /api/v1/auth/login { tenant_slug, email, password }
1. tenant.ResolveBySlug
2. zitadel.VerifyPasswordROPG
3. 解析 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 / 舊 client已實作
```
Client → POST /api/v1/auth/token/exchange { tenant_slug, id_token }
1. zitadel.VerifyIDTokenJWKS 驗簽 + iss/aud/exp
2. tenant.ResolveBySlug
3. member.GetByZitadelUserID → 校驗 active
4. auth.IssuePair
Client ← { access_token, refresh_token, uid }
```
#### 9.1.3 OIDC 登入 + JITB2B / 舊 B2C Hosted UI 路徑,仍支援)
``` ```
Client → ZITADEL OIDC Login含 LDAP IdP Client → ZITADEL OIDC Login含 LDAP IdP
Client → POST /auth/token/exchange { tenant_slug, id_token } Client → POST /auth/token/exchange { tenant_slug, id_token }
1. zitadel.VerifyIDToken 1. zitadel.VerifyIDToken
2. tenant.ResolveBySlug → 校驗 org_id 2. tenant.ResolveBySlug → 校驗 org_id
3. member.EnsureFromOIDC → uid如 AMEX-10000000 3. member.EnsureFromOIDC → uid如 AMEX-10000000 // 若 member 不存在則 JIT
4. permission.SyncFromZitadelClaims → user_roles 4. permission.SyncFromZitadelClaims → user_roles // 規劃中
5. auth.IssueTokenPairrole keys 快照, auth_gen 5. auth.IssueTokenPair
Client ← { access_token, refresh_token, uid } Client ← { access_token, refresh_token, uid }
``` ```
> **注意**B2C 新註冊應走 §3.4 Gateway `/auth/register*``/auth/token/exchange` 保留給 **已存在 member** 的 SSO 登入與企業 IdP。
### 9.2 受保護 API ### 9.2 受保護 API
``` ```
@ -2448,7 +2490,7 @@ RateLimit:
| 16 | 外部來源 UserRole | **按 source 隔離 Replace**manual 永不被洗 | §6.10 | | 16 | 外部來源 UserRole | **按 source 隔離 Replace**manual 永不被洗 | §6.10 |
| 17 | PlainCode 實作 | **Casbin 額外查 `.plain_code` 變體**,多 role allow 結果取 OR | §6.9 | | 17 | PlainCode 實作 | **Casbin 額外查 `.plain_code` 變體**,多 role allow 結果取 OR | §6.9 |
| 18 | Permission.Name | **建立後不可改名**;廢棄走 `status=close` + 新建 | §6.4 | | 18 | Permission.Name | **建立後不可改名**;廢棄走 `status=close` + 新建 | §6.4 |
| 19 | 註冊路徑 | **預設**走 ZITADEL Hosted UIB2C/ LDAP / SCIMB2B**保留** platform-native usecase`LifecycleUseCase.CreateUnverified` + `OTPUseCase` + `Activate`)供未來開通 Gateway 原生註冊(含 email OTP 驗證) | §3.4、§5.2.1、§5.9 | | 19 | 註冊路徑 | **B2C**Gateway 統一 `/auth/register*`Email + Socialinvite 必填);**B2B**LDAP / SCIM 不經 register APIplatform-native usecase 已用於 Email 註冊 | §3.4、[auth-unified-registration.md](./auth-unified-registration.md) |
| 20 | 身份 vs 業務驗證分層 | **ZITADEL 管登入身份Gateway member 自驗業務 email / phone** | §1.2、§5.4 | | 20 | 身份 vs 業務驗證分層 | **ZITADEL 管登入身份Gateway member 自驗業務 email / phone** | §1.2、§5.4 |
| 21 | Step-up MFA | **啟用**;高風險 action 需 5min 單次性 `step_up_token` | §5.6、§9.6 | | 21 | Step-up MFA | **啟用**;高風險 action 需 5min 單次性 `step_up_token` | §5.6、§9.6 |
| 22 | OTP 投遞通道 | **自送**(透過 Notification Module 包 Email / SMS Provider | §5.5、§11、§17 | | 22 | OTP 投遞通道 | **自送**(透過 Notification Module 包 Email / SMS Provider | §5.5、§11、§17 |
@ -2628,3 +2670,4 @@ type ServiceContext struct {
| 2026-05-20 | 0.7.0 | 待決策 AL 全數拍板SCIM id = Gateway UID + ZITADEL sub extension§10.3Casbin 多 pod Pub/Sub + 5min cron 兜底§6.11Tenant 建立 saga§3.1Platform Admin seed CLI§18 P0Member.Origin + UserRole.Source 雙欄§5.4、§6.10SCIM token 全權 + IP allowlist§7.5);獨立 audit_logs collection + TTL 90d§20.1);軟刪 30 天匿名化§5.7);分欄位 SoT§5.3Directory Sync guardrail§10.4Redis sliding-window rate limit§20.2JWT kid 多 key 並存§4.4 | | 2026-05-20 | 0.7.0 | 待決策 AL 全數拍板SCIM id = Gateway UID + ZITADEL sub extension§10.3Casbin 多 pod Pub/Sub + 5min cron 兜底§6.11Tenant 建立 saga§3.1Platform Admin seed CLI§18 P0Member.Origin + UserRole.Source 雙欄§5.4、§6.10SCIM token 全權 + IP allowlist§7.5);獨立 audit_logs collection + TTL 90d§20.1);軟刪 30 天匿名化§5.7);分欄位 SoT§5.3Directory Sync guardrail§10.4Redis sliding-window rate limit§20.2JWT kid 多 key 並存§4.4 |
| 2026-05-20 | 0.8.0 | 抽出獨立 **Notification Module**§11所有 outbound 通訊統一入口、含 idempotency / 重試 / DLQ / 模板 / 多語、敏感內容 `DoNotPersistBody`;新增 **業務 TOTP**§5.8)支援 Google Authenticator與 ZITADEL 身份 TOTP 獨立step-up 通道優先序改為 **TOTP > SMS > Email**§5.6目錄、ServiceContext、Mongo collections、Redis key、設定檔、實施順序、決策列 2528 同步更新§11§19 章節編號全部 +1 | | 2026-05-20 | 0.8.0 | 抽出獨立 **Notification Module**§11所有 outbound 通訊統一入口、含 idempotency / 重試 / DLQ / 模板 / 多語、敏感內容 `DoNotPersistBody`;新增 **業務 TOTP**§5.8)支援 Google Authenticator與 ZITADEL 身份 TOTP 獨立step-up 通道優先序改為 **TOTP > SMS > Email**§5.6目錄、ServiceContext、Mongo collections、Redis key、設定檔、實施順序、決策列 2528 同步更新§11§19 章節編號全部 +1 |
| 2026-05-20 | 0.9.0 | **UseCase 介面契約凍結(業務邏輯暫不實作)**§5.2 重寫為 Atomic primitives + Composite 兩層;新增 `OTPUseCase`purpose-agnostic atomic、`LifecycleUseCase`CreateUnverified / Activate / Suspend / Reactivate / SoftDelete / AbortPending`ProvisioningUseCase` 拆 `EnsureFromOIDC / LDAP / SCIM` 三變體;`ProfileUseCase` 加 `SetBusinessEmailVerified` / `SetBusinessPhoneVerified` atomic加回 `unverified` 狀態(僅 platform-native 路徑);補完 Member entity 欄位、Enum 草案、Request DTO新增 §5.9 編排示例5 case§14 OTP Redis key 改 purpose-based決策列 19 修正、新增 2932 | | 2026-05-20 | 0.9.0 | **UseCase 介面契約凍結(業務邏輯暫不實作)**§5.2 重寫為 Atomic primitives + Composite 兩層;新增 `OTPUseCase`purpose-agnostic atomic、`LifecycleUseCase`CreateUnverified / Activate / Suspend / Reactivate / SoftDelete / AbortPending`ProvisioningUseCase` 拆 `EnsureFromOIDC / LDAP / SCIM` 三變體;`ProfileUseCase` 加 `SetBusinessEmailVerified` / `SetBusinessPhoneVerified` atomic加回 `unverified` 狀態(僅 platform-native 路徑);補完 Member entity 欄位、Enum 草案、Request DTO新增 §5.9 編排示例5 case§14 OTP Redis key 改 purpose-based決策列 19 修正、新增 2932 |
| 2026-05-21 | 1.0.0 | **Gateway 統一註冊已實作**:修訂 §3.4(改為暴露 `/auth/register*`§7.1 補齊已實作 auth 路由§8.1 記載 CloudEP JWT + dev header fallback§9.1 拆分 login / token exchange§5.9 Case A 標為已實作;決策列 19 更新。詳見 [auth-unified-registration.md](./auth-unified-registration.md) |

View File

@ -326,7 +326,7 @@ func MustMemberUseCase(param MemberUseCaseParam) domusecase.AccountUseCase {
## 7. 錯誤處理 ## 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` ### 7.1 模組 sentinel`domain/errors.go`

View File

@ -78,3 +78,30 @@ Member:
# 32-byte key encoded as hex (64 chars) or base64; leave empty to disable TOTP. # 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. # Dev-only placeholder for local totp-test; replace in production.
SecretKEK: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" 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

View File

@ -13,6 +13,9 @@ import (
"gateway/internal/config" "gateway/internal/config"
"gateway/internal/handler" "gateway/internal/handler"
"gateway/internal/library/errors/code"
"gateway/internal/middleware"
"gateway/internal/response"
"gateway/internal/svc" "gateway/internal/svc"
"github.com/zeromicro/go-zero/core/conf" "github.com/zeromicro/go-zero/core/conf"
@ -24,6 +27,8 @@ var configFile = flag.String("f", "etc/gateway.yaml", "the config file")
func main() { func main() {
flag.Parse() flag.Parse()
response.RequestErrScope = code.Facade
var c config.Config var c config.Config
conf.MustLoad(*configFile, &c) conf.MustLoad(*configFile, &c)
@ -31,6 +36,9 @@ func main() {
defer server.Stop() defer server.Stop()
sc := svc.NewServiceContext(c) sc := svc.NewServiceContext(c)
if sc.AuthToken != nil {
server.Use(middleware.CloudEPJWT(sc.AuthToken))
}
handler.RegisterHandlers(server, sc) handler.RegisterHandlers(server, sc)
workerCtx, stopWorkers := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) workerCtx, stopWorkers := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)

View File

@ -6,6 +6,8 @@
|------|------| |------|------|
| `gateway.api` | 入口:`info()` + `import` | | `gateway.api` | 入口:`info()` + `import` |
| `common.api` | 共用文件型別(`APIErrorStatus`、`ErrorDetail` | | `common.api` | 共用文件型別(`APIErrorStatus`、`ErrorDetail` |
| `auth.api` | Auth 路由scope 28 |
| `member.api` | Member 路由scope 29 |
| `normal.api` | 路由與業務 `data` 型別 | | `normal.api` | 路由與業務 `data` 型別 |
## 指令 ## 指令
@ -28,7 +30,7 @@ make gen-doc # 生成 docs/openapi/gateway.yamlOpenAPI 3.0
Handler 使用 `response.Write` 輸出: Handler 使用 `response.Write` 輸出:
```json ```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 使用對應 scopeAuth=28、Member=29

451
generate/api/auth.api Normal file
View File

@ -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"`
}
// 文件用:成功回應 envelopeHTTP 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)
}

View File

@ -1,6 +1,10 @@
syntax = "v1" syntax = "v1"
// 文件與實際 HTTP 回應共用結構handler 透過 response.Write 輸出) // 文件與實際 HTTP 回應共用結構handler 透過 response.Write 輸出)
// HTTP 狀態碼對照 errs.Error.HTTPStatus()internal/library/errors/errors.go
// 業務碼格式 SSCCCDDDscope * 1_000_000 + category * 1_000 + detail
// Facade scope=10handler parse/validate10101000 = InputInvalidFormat
// Auth scope=28、Member scope=29、Notification scope=30各模組 logic/usecase 使用對應 scope
type ( type (
// ErrorDetail 失敗時 error 欄位 // ErrorDetail 失敗時 error 欄位
ErrorDetail { ErrorDetail {
@ -16,4 +20,10 @@ type (
Message string `json:"message"` Message string `json:"message"`
Error ErrorDetail `json:"error"` Error ErrorDetail `json:"error"`
} }
// EmptyOKStatus 成功但無 dataconfirm / delete 等code=102000
EmptyOKStatus {
Code int64 `json:"code"`
Message string `json:"message"`
}
) )

View File

@ -11,9 +11,11 @@ info (
consumes: "application/json" consumes: "application/json"
produces: "application/json" produces: "application/json"
useDefinitions: true useDefinitions: true
bizCodeEnumDescription: "102000-成功<br>10101000-參數格式錯誤(Facade)<br>10104000-缺少必填欄位(Facade)<br>28101000-參數格式錯誤(Auth)<br>28104000-缺少必填欄位(Auth)<br>28201000-資料庫錯誤(Auth)<br>28301000-資源不存在(Auth)<br>28303000-資源已存在(Auth)<br>28309000-資源狀態無效(Auth)<br>28310000-配額不足(Auth)<br>28313000-資源鎖定(Auth)<br>28501000-未授權(Auth)<br>28505000-禁止存取(Auth)<br>28601000-系統內部錯誤(Auth)<br>28604000-請求過於頻繁(Auth)<br>28605000-功能未配置(Auth)<br>28802000-第三方服務錯誤(Auth)<br>29104000-缺少必填欄位(Member)<br>29201000-資料庫錯誤(Member)<br>29301000-資源不存在(Member)<br>29303000-資源已存在(Member)<br>29309000-資源狀態無效(Member)<br>29310000-配額不足(Member)<br>29501000-未授權(Member)<br>29505000-禁止存取(Member)<br>29601000-系統內部錯誤(Member)<br>29604000-請求過於頻繁(Member)<br>29605000-功能未配置(Member)"
) )
import ( import (
"auth.api"
"common.api" "common.api"
"member.api" "member.api"
"normal.api" "normal.api"

View File

@ -75,6 +75,43 @@ type (
TOTPBackupCodesData { TOTPBackupCodesData {
BackupCodes []string `json:"backup_codes"` BackupCodes []string `json:"backup_codes"`
} }
// 文件用:成功回應 envelopeHTTP 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( @server(
@ -82,51 +119,289 @@ type (
prefix: /api/v1/members prefix: /api/v1/members
) )
service gateway { service gateway {
@doc "取得當前會員 profiledevHeader X-Tenant-ID + X-UID" @doc "取得當前會員 profileBearer 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 @handler getMemberMe
get /me returns (MemberMeData) get /me returns (MemberMeData)
@doc "更新當前會員 profile" @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 @handler updateMemberMe
patch /me (UpdateMemberMeReq) returns (MemberMeData) patch /me (UpdateMemberMeReq) returns (MemberMeData)
@doc "開始業務 email 驗證" @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 @handler startEmailVerification
post /me/verifications/email/start (VerificationStartReq) returns (VerificationStartData) post /me/verifications/email/start (VerificationStartReq) returns (VerificationStartData)
@doc "確認業務 email 驗證" @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 @handler confirmEmailVerification
post /me/verifications/email/confirm (VerificationConfirmReq) post /me/verifications/email/confirm (VerificationConfirmReq)
@doc "開始業務 phone 驗證" @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 @handler startPhoneVerification
post /me/verifications/phone/start (VerificationStartReq) returns (VerificationStartData) post /me/verifications/phone/start (VerificationStartReq) returns (VerificationStartData)
@doc "確認業務 phone 驗證" @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 @handler confirmPhoneVerification
post /me/verifications/phone/confirm (VerificationConfirmReq) post /me/verifications/phone/confirm (VerificationConfirmReq)
@doc "TOTP 狀態" @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 @handler getTOTPStatus
get /me/totp returns (TOTPStatusData) get /me/totp returns (TOTPStatusData)
@doc "開始 TOTP 綁定" @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 @handler startTOTPEnroll
post /me/totp/enroll-start returns (TOTPEnrollStartData) post /me/totp/enroll-start returns (TOTPEnrollStartData)
@doc "確認 TOTP 綁定" @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 @handler confirmTOTPEnroll
post /me/totp/enroll-confirm (TOTPEnrollConfirmReq) returns (TOTPEnrollConfirmData) post /me/totp/enroll-confirm (TOTPEnrollConfirmReq) returns (TOTPEnrollConfirmData)
@doc "驗證 TOTPstep-up 測試)" @doc "驗證 TOTPstep-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 @handler verifyTOTP
post /me/totp/verify (TOTPVerifyReq) post /me/totp/verify (TOTPVerifyReq)
@doc "重產 TOTP 備援碼" @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 @handler regenerateTOTPBackupCodes
post /me/totp/backup-codes returns (TOTPBackupCodesData) post /me/totp/backup-codes returns (TOTPBackupCodesData)
@doc "解除 TOTP 綁定" @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 @handler disableTOTP
delete /me/totp delete /me/totp
} }

View File

@ -5,7 +5,7 @@ type PingData {
Pong string `json:"pong"` Pong string `json:"pong"`
} }
// 文件用:成功回應 envelopecode=0, message=SUCCESS, data=PingData // 文件用:成功回應 envelopeHTTP 200, code=102000, message=SUCCESS
type PingOKStatus { type PingOKStatus {
Code int64 `json:"code"` Code int64 `json:"code"`
Message string `json:"message"` Message string `json:"message"`
@ -24,9 +24,13 @@ service gateway {
description: "確認伺服器狀態" description: "確認伺服器狀態"
) )
/* /*
@respdoc-200 (PingOKStatus) // 成功 @respdoc-200 (PingOKStatus) // 成功code=102000
@respdoc-400 (APIErrorStatus) // 參數錯誤(如 httpx.Parse / 驗證失敗) @respdoc-400 (
@respdoc-500 (APIErrorStatus) // 系統內部錯誤 10101000: (APIErrorStatus) 參數格式錯誤
) // 參數錯誤
@respdoc-500 (
10601000: (APIErrorStatus) 系統內部錯誤
) // 內部錯誤
*/ */
@handler ping @handler ping
get /health () returns (PingData) get /health () returns (PingData)

View File

@ -8,6 +8,8 @@ import (
"github.com/zeromicro/go-zero/rest" "github.com/zeromicro/go-zero/rest"
"gateway/internal/library/mongo" "gateway/internal/library/mongo"
"gateway/internal/library/zitadel"
authconfig "gateway/internal/model/auth/config"
memberconfig "gateway/internal/model/member/config" memberconfig "gateway/internal/model/member/config"
notifconfig "gateway/internal/model/notification/config" notifconfig "gateway/internal/model/notification/config"
) )
@ -16,6 +18,8 @@ type Config struct {
rest.RestConf rest.RestConf
Mongo mongo.Conf `json:",optional"` Mongo mongo.Conf `json:",optional"`
Redis redis.RedisConf `json:",optional"` Redis redis.RedisConf `json:",optional"`
Auth authconfig.Config `json:",optional"`
Zitadel zitadel.Conf `json:",optional"`
Notification notifconfig.Config `json:",optional"` Notification notifconfig.Config `json:",optional"`
Member memberconfig.Config `json:",optional"` Member memberconfig.Config `json:",optional"`
} }

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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))
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -8,5 +8,8 @@ import (
) )
func actorContext(ctx context.Context, r *http.Request) context.Context { 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")) return logic.WithActor(ctx, r.Header.Get("X-Tenant-ID"), r.Header.Get("X-UID"))
} }

View File

@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"time" "time"
auth "gateway/internal/handler/auth"
member "gateway/internal/handler/member" member "gateway/internal/handler/member"
normal "gateway/internal/handler/normal" normal "gateway/internal/handler/normal"
"gateway/internal/svc" "gateway/internal/svc"
@ -18,7 +19,79 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
server.AddRoutes( server.AddRoutes(
[]rest.Route{ []rest.Route{
{ {
// 取得當前會員 profiledevHeader 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{
{
// 取得當前會員 profileBearer JWT本機 dev 可 fallback X-Tenant-ID + X-UID
Method: http.MethodGet, Method: http.MethodGet,
Path: "/me", Path: "/me",
Handler: member.GetMemberMeHandler(serverCtx), Handler: member.GetMemberMeHandler(serverCtx),

View File

@ -280,7 +280,7 @@ attrs := errlog.Attrs(e)
## Scope 常數 ## 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)`
新增服務時在該檔登記,避免號段衝突。 新增服務時在該檔登記,避免號段衝突。
--- ---

View File

@ -135,4 +135,9 @@ const (
PluginVcenterHSM Scope = 25 PluginVcenterHSM Scope = 25
PluginMGR Scope = 26 PluginMGR Scope = 26
GearAssetMgr Scope = 27 GearAssetMgr Scope = 27
// Gateway domain module scopes (logic + usecase).
Auth Scope = 28
Member Scope = 29
Notification Scope = 30
) )

View File

@ -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
}

View File

@ -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
}

View File

@ -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 != ""
}

View File

@ -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")
)

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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")
}

View File

@ -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
}

View File

@ -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)

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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 ""
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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, &notifuc.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
}

View File

@ -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
}

View File

@ -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 ""
}

View File

@ -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
}

View File

@ -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{}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -7,7 +7,7 @@ import (
type actorKey struct{} 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 { type Actor struct {
TenantID string TenantID string
UID 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}) 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) { func ActorFromContext(ctx context.Context) (Actor, error) {
v, ok := ctx.Value(actorKey{}).(Actor) v, ok := ctx.Value(actorKey{}).(Actor)
if !ok || v.TenantID == "" || v.UID == "" { 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 return v, nil
} }

View File

@ -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)

View File

@ -27,7 +27,7 @@ func (l *GetMemberMeLogic) GetMemberMe() (*types.MemberMeData, error) {
return nil, err return nil, err
} }
if l.svcCtx.MemberProfile == nil { 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{ dto, err := l.svcCtx.MemberProfile.GetByUID(l.ctx, &domusecase.GetMemberRequest{
TenantID: actor.TenantID, TenantID: actor.TenantID,

View File

@ -27,7 +27,7 @@ func (l *UpdateMemberMeLogic) UpdateMemberMe(req *types.UpdateMemberMeReq) (*typ
return nil, err return nil, err
} }
if l.svcCtx.MemberProfile == nil { 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} update := &domusecase.UpdateMemberRequest{TenantID: actor.TenantID, UID: actor.UID}
if req != nil { if req != nil {

View File

@ -4,8 +4,6 @@ import (
"context" "context"
"time" "time"
errs "gateway/internal/library/errors"
"gateway/internal/library/errors/code"
memberdom "gateway/internal/model/member/domain" memberdom "gateway/internal/model/member/domain"
"gateway/internal/model/member/domain/enum" "gateway/internal/model/member/domain/enum"
domusecase "gateway/internal/model/member/domain/usecase" domusecase "gateway/internal/model/member/domain/usecase"
@ -15,8 +13,6 @@ import (
"gateway/internal/types" "gateway/internal/types"
) )
var errb = errs.For(code.Facade)
func startVerification( func startVerification(
ctx context.Context, ctx context.Context,
sc *svc.ServiceContext, sc *svc.ServiceContext,
@ -27,10 +23,13 @@ func startVerification(
target string, target string,
) (*types.VerificationStartData, error) { ) (*types.VerificationStartData, error) {
if sc.MemberOTP == nil { if sc.MemberOTP == nil {
return nil, errb.SysInternal("member OTP not configured") return nil, errb.SysNotImplemented("member OTP not configured")
} }
if sc.Notifier == nil { 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 == "" { if target == "" {
return nil, errb.InputMissingRequired("target is required") return nil, errb.InputMissingRequired("target is required")
@ -38,20 +37,12 @@ func startVerification(
cfg := sc.Config.Member.Defaults() cfg := sc.Config.Member.Defaults()
rateKey := memberdom.GetVerifyRateRedisKey(actor.TenantID, actor.UID, string(purpose)) 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 := sc.MemberVerifyRate.AssertResendAllowed(ctx, rateKey, time.Duration(cfg.OTP.ResendCooldownSeconds)*time.Second); err != nil {
if err != nil { return nil, err
return nil, errb.SysInternal("rate limit check failed").WithCause(err)
}
if !ok {
return nil, errb.AuthForbidden("resend cooldown active").WithCause(memberdom.ErrResendCooldown)
} }
dailyKey := memberdom.GetVerifyDailyRedisKey(actor.TenantID, actor.UID, string(purpose)) dailyKey := memberdom.GetVerifyDailyRedisKey(actor.TenantID, actor.UID, string(purpose))
count, err := sc.MemberVerifyRate.IncrDaily(ctx, dailyKey, 24*time.Hour) if err := sc.MemberVerifyRate.AssertDailyAllowed(ctx, dailyKey, 24*time.Hour, cfg.OTP.DailyVerifyLimit); err != nil {
if err != nil { return nil, err
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)
} }
dto, plainCode, err := sc.MemberOTP.Generate(ctx, &domusecase.GenerateOTPRequest{ dto, plainCode, err := sc.MemberOTP.Generate(ctx, &domusecase.GenerateOTPRequest{
@ -77,7 +68,7 @@ func startVerification(
Severity: notifenum.SeverityInfo, Severity: notifenum.SeverityInfo,
}); sendErr != nil { }); sendErr != nil {
if invErr := sc.MemberOTP.Invalidate(ctx, dto.ChallengeID); invErr != 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 return nil, sendErr
} }
@ -96,7 +87,7 @@ func confirmVerification(
setVerified func(context.Context, string, string, string) error, setVerified func(context.Context, string, string, string) error,
) error { ) error {
if sc.MemberOTP == nil || sc.MemberProfile == nil { 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 == "" { if req == nil || req.ChallengeID == "" || req.Code == "" {
return errb.InputMissingRequired("challenge_id and code are required") return errb.InputMissingRequired("challenge_id and code are required")
@ -116,7 +107,7 @@ func confirmVerification(
func requireTOTP(sc *svc.ServiceContext) error { func requireTOTP(sc *svc.ServiceContext) error {
if sc.MemberTOTP == nil { if sc.MemberTOTP == nil {
return errb.SysInternal("member TOTP not configured") return errb.SysNotImplemented("member TOTP not configured")
} }
return nil return nil
} }
@ -124,7 +115,7 @@ func requireTOTP(sc *svc.ServiceContext) error {
func actorOrErr(ctx context.Context) (Actor, error) { func actorOrErr(ctx context.Context) (Actor, error) {
actor, err := ActorFromContext(ctx) actor, err := ActorFromContext(ctx)
if err != nil { 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 return actor, nil
} }

View File

@ -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))
}

View File

@ -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 != ""
}

View File

@ -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)
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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
}
}

View File

@ -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")
)

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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 &registrationMetaRepository{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)

View File

@ -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)

View File

@ -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)

View File

@ -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)
}

View File

@ -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,
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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
}

View File

@ -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 &registrationMetaUseCase{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)

View File

@ -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 &registrationSessionUseCase{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)

View File

@ -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
}

View File

@ -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
}

View File

@ -2,8 +2,15 @@ package config
// Config is member module settings (embedded in gateway root config). // Config is member module settings (embedded in gateway root config).
type Config struct { type Config struct {
OTP OTPConfig `json:",optional"` OTP OTPConfig `json:",optional"`
TOTP TOTPConfig `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). // OTPConfig governs the business OTP primitive (email/phone verification).
@ -80,5 +87,7 @@ func (c Config) Defaults() Config {
if c.TOTP.ReplayTTLSeconds <= 0 { if c.TOTP.ReplayTTLSeconds <= 0 {
c.TOTP.ReplayTTLSeconds = 90 c.TOTP.ReplayTTLSeconds = 90
} }
// RequireInviteCode defaults true when unset (zero value).
// TrustSocialEmailVerified defaults true when unset (zero value).
return c return c
} }

View File

@ -14,9 +14,10 @@ type LifecycleUseCase interface {
// CreatePlatformMemberRequest creates an unverified platform-native member. // CreatePlatformMemberRequest creates an unverified platform-native member.
type CreatePlatformMemberRequest struct { type CreatePlatformMemberRequest struct {
TenantID string TenantID string
Email string Email string
PasswordHash string PasswordHash string
DisplayName string DisplayName string
Language string Language string
ZitadelUserID string
} }

View File

@ -24,10 +24,31 @@ type VerifyOTPRequest struct {
Purpose enum.OTPPurpose 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. // OTPUseCase is the purpose-agnostic OTP primitive.
type OTPUseCase interface { type OTPUseCase interface {
Generate(ctx context.Context, req *GenerateOTPRequest) (*OTPChallengeDTO, string, error) Generate(ctx context.Context, req *GenerateOTPRequest) (*OTPChallengeDTO, string, error)
// Verify returns the challenge target (e.g. email/phone) after successful validation. // Verify returns the challenge target (e.g. email/phone) after successful validation.
Verify(ctx context.Context, req *VerifyOTPRequest) (target string, err error) Verify(ctx context.Context, req *VerifyOTPRequest) (target string, err error)
Invalidate(ctx context.Context, challengeID string) 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
} }

View File

@ -9,6 +9,7 @@ import (
// ProfileUseCase reads and updates member profile fields (not lifecycle transitions). // ProfileUseCase reads and updates member profile fields (not lifecycle transitions).
type ProfileUseCase interface { type ProfileUseCase interface {
GetByUID(ctx context.Context, req *GetMemberRequest) (*MemberDTO, error) 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) Update(ctx context.Context, req *UpdateMemberRequest) (*MemberDTO, error)
List(ctx context.Context, req *ListMembersRequest) (*ListMembersResponse, error) List(ctx context.Context, req *ListMembersRequest) (*ListMembersResponse, error)
SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error SetBusinessEmailVerified(ctx context.Context, tenantID, uid, email string) error

View File

@ -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
}

View File

@ -7,6 +7,8 @@ import (
libmongo "gateway/internal/library/mongo" libmongo "gateway/internal/library/mongo"
) )
const bsonOpSet = "$set"
// EnsureMongoIndexes creates indexes for member module collections. // EnsureMongoIndexes creates indexes for member module collections.
func EnsureMongoIndexes(ctx context.Context, conf *libmongo.Conf) error { func EnsureMongoIndexes(ctx context.Context, conf *libmongo.Conf) error {
if conf == nil || conf.Host == "" { if conf == nil || conf.Host == "" {

Some files were not shown because too many files have changed in this diff Show More