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:
parent
2ae86e9002
commit
713a81f70b
42
README.md
42
README.md
|
|
@ -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 scope(response.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 依錯誤類別,如 404;Member 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)。
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,405 @@
|
||||||
|
# Gateway 統一註冊 — 設計規格
|
||||||
|
|
||||||
|
> **狀態**:已實作(PR 1–8 ✅;PR 9 文件修訂 ✅)
|
||||||
|
> **修訂**:取代 [identity-member-design.md §3.4](./identity-member-design.md)「Gateway 不暴露 `/auth/register`」
|
||||||
|
> **最後更新**:2026-05-21
|
||||||
|
|
||||||
|
## 1. 目標
|
||||||
|
|
||||||
|
使用者 **只與 Portal Gateway 互動** 完成註冊/首次登入;ZITADEL 作為 identity 後端(帳密、OIDC、鎖定),不作第二個註冊入口。
|
||||||
|
|
||||||
|
| 路徑 | 說明 |
|
||||||
|
|------|------|
|
||||||
|
| **A** | Email + Password + Email OTP 確認 |
|
||||||
|
| **B** | 上述流程 **必填 invite code**(Logic 驗證) |
|
||||||
|
| **C** | Social(Google 為 P0,可擴 Apple)— 同一註冊 UX,OAuth 前綁定 invite |
|
||||||
|
|
||||||
|
Logic 層負責商務驗證與跨 atomic usecase 編排;usecase 維持 atomic(見 [model.md §6.1](./model.md))。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 為何比 ZITADEL Hosted 註冊好
|
||||||
|
|
||||||
|
- 單一 App / API,無「跳去 IdP 再回來」的割裂感
|
||||||
|
- 註冊表單可帶 **商務欄位**(條款版本、語系、marketing、invite、referral)
|
||||||
|
- 失敗補償與 audit 在 Gateway 可控
|
||||||
|
- 與現有 member OTP、notification 模板、`CreateUnverified` / `Activate` 直接對接
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 三條註冊路徑(統一入口 UX)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ 前端:同一「註冊」頁 / Wizard │
|
||||||
|
│ tenant + invite_code + 條款 (+ 語系) │
|
||||||
|
└──────────────┬──────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────────────┼────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
Email + Password Social (Google) (未來 Apple)
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
POST /auth/register POST /auth/register/social/start
|
||||||
|
│ │ → oauth_url
|
||||||
|
▼ ▼
|
||||||
|
POST /auth/register/confirm GET /auth/register/social/callback
|
||||||
|
│ │
|
||||||
|
└────────┬───────────┘
|
||||||
|
▼
|
||||||
|
IssueTokenPair (CloudEP JWT)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1 路徑 A+B:Email 註冊
|
||||||
|
|
||||||
|
1. Logic 驗證:`tenant_slug`、`invite_code`、條款、密碼強度、email 格式
|
||||||
|
2. Logic 驗證並 **消耗** invite(見 §6)
|
||||||
|
3. `zitadel.CreateHumanUser`(email + password,org = tenant 對應 ZITADEL org)
|
||||||
|
4. `member.Lifecycle.CreateUnverified`(`origin=platform_native`,存 `zitadel_sub` 若 API 回傳)
|
||||||
|
5. Logic 寫入 **registration metadata**(§7)
|
||||||
|
6. `member.OTP.Generate`(`purpose=registration_email`)+ `notifier.Send`(`verify_registration_email`)
|
||||||
|
7. 回 `{ challenge_id, expires_in }`,**不發 JWT**
|
||||||
|
8. `POST /auth/register/confirm`:OTP verify → `Activate` → `IssueTokenPair`
|
||||||
|
|
||||||
|
### 3.2 路徑 C:Social 註冊(Google)
|
||||||
|
|
||||||
|
Invite **必填**;在 OAuth redirect **之前** 綁定 session,避免 callback 時無 invite。
|
||||||
|
|
||||||
|
1. `POST /auth/register/social/start`
|
||||||
|
Body:`tenant_slug`, `invite_code`, `provider=google`, `accept_terms_version`, 可選 `language`
|
||||||
|
Logic:驗 tenant + invite + 條款 → 建立 **registration session**(Redis)→ 回 `{ oauth_url, session_id }`
|
||||||
|
2. 使用者完成 Google OAuth → callback `GET /auth/register/social/callback?code=...&state=...`
|
||||||
|
3. Logic:換 token、驗 state、讀 session 取 invite → **消耗 invite**
|
||||||
|
4. `zitadel` 查/建 user(或 link)→ `member.Provisioning.EnsureFromOIDC`
|
||||||
|
5. 若 email 已在 ZITADEL verified → 直接 `Activate`;否則可走 OTP 或信任 IdP `email_verified`(**已決策:信任 Google email_verified=true 則 skip OTP**)
|
||||||
|
6. 寫 registration metadata → `IssueTokenPair`
|
||||||
|
|
||||||
|
> 已存在 member(同 tenant + zitadel_sub)→ 視為 **登入**,仍驗 invite 是否允許「新註冊 only」;若 invite 僅限新 user 且已存在 → `409` + 引導 login。
|
||||||
|
|
||||||
|
### 3.3 登入(非註冊)
|
||||||
|
|
||||||
|
| API | 說明 |
|
||||||
|
|-----|------|
|
||||||
|
| `POST /auth/login` | email + password → ZITADEL ROPG / token → member lookup → JWT |
|
||||||
|
| `POST /auth/token/exchange` | 保留:已登入 ZITADEL 的 id_token 換 JWT(企業 SSO、舊 client) |
|
||||||
|
| `POST /auth/login/social` | 可選:OAuth 僅登入、不經 register session(P1,與 register 分 state) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 規格(`generate/api/auth.api`)
|
||||||
|
|
||||||
|
### 4.1 公開 — 註冊
|
||||||
|
|
||||||
|
**POST `/api/v1/auth/register`**
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tenant_slug": "acme",
|
||||||
|
"invite_code": "BETA-2026-XXXX",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "string",
|
||||||
|
"display_name": "Daniel",
|
||||||
|
"language": "zh-tw",
|
||||||
|
"accept_terms_version": "2026-05-01",
|
||||||
|
"marketing_opt_in": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"challenge_id": "uuid",
|
||||||
|
"expires_in": 300,
|
||||||
|
"uid": "ACME-10000042"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**POST `/api/v1/auth/register/confirm`**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tenant_slug": "acme",
|
||||||
|
"challenge_id": "uuid",
|
||||||
|
"code": "482913"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response 200: `{ access_token, refresh_token, expires_in, uid, token_type: "Bearer" }`
|
||||||
|
|
||||||
|
**POST `/api/v1/auth/register/resend`**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tenant_slug": "acme",
|
||||||
|
"challenge_id": "uuid"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**POST `/api/v1/auth/register/social/start`**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tenant_slug": "acme",
|
||||||
|
"invite_code": "BETA-2026-XXXX",
|
||||||
|
"provider": "google",
|
||||||
|
"accept_terms_version": "2026-05-01",
|
||||||
|
"language": "zh-tw",
|
||||||
|
"redirect_uri": "https://app.example.com/auth/callback"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response: `{ oauth_url, session_id, expires_in }`
|
||||||
|
|
||||||
|
**GET `/api/v1/auth/register/social/callback`**
|
||||||
|
|
||||||
|
Query: `code`, `state`(含 session_id)
|
||||||
|
Response: 302 到前端帶 token,或 JSON(依 `Accept` / 設定)
|
||||||
|
|
||||||
|
### 4.2 公開 — 登入 / Token
|
||||||
|
|
||||||
|
| Method | Path | 說明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| POST | `/api/v1/auth/login` | email + password + tenant_slug |
|
||||||
|
| POST | `/api/v1/auth/token/refresh` | refresh_token |
|
||||||
|
| POST | `/api/v1/auth/token/exchange` | id_token + tenant_slug(SSO 備用) |
|
||||||
|
| POST | `/api/v1/auth/logout` | JWT,jti 黑名單 |
|
||||||
|
|
||||||
|
### 4.3 驗證 tag(`.api`)
|
||||||
|
|
||||||
|
- `register`: `required,email`, `password` min length, `invite_code` required
|
||||||
|
- 公開路由 **不走** JWT middleware;**走** rate limit(§12)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 分層與新增模組
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/
|
||||||
|
library/zitadel/ # HTTP client:CreateUser, VerifyPassword, OIDC token
|
||||||
|
model/auth/
|
||||||
|
config/
|
||||||
|
domain/
|
||||||
|
entity/registration_meta.go
|
||||||
|
repository/invite.go, registration_session.go
|
||||||
|
usecase/token.go, invite.go
|
||||||
|
usecase/ # atomic:IssueTokenPair, ValidateInvite, ConsumeInvite
|
||||||
|
logic/auth/
|
||||||
|
register_logic.go
|
||||||
|
register_confirm_logic.go
|
||||||
|
register_resend_logic.go
|
||||||
|
register_social_start_logic.go
|
||||||
|
register_social_callback_logic.go
|
||||||
|
login_logic.go
|
||||||
|
middleware/
|
||||||
|
jwt_cloud_ep.go # P1:取代 dev X-Tenant-ID header
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logic 專屬商務(不下沉 usecase)**
|
||||||
|
|
||||||
|
- invite 是否必填、是否過期、是否限新 user
|
||||||
|
- 條款版本是否接受
|
||||||
|
- tenant 是否允許 B2C 註冊
|
||||||
|
- 密碼政策(client + server 雙重)
|
||||||
|
- 註冊 rate limit key 組合
|
||||||
|
- Social:OAuth state 與 registration session 綁定
|
||||||
|
|
||||||
|
**auth usecase(atomic)**
|
||||||
|
|
||||||
|
- `TokenUseCase.IssuePair / Refresh / Logout`
|
||||||
|
- `InviteUseCase.Validate / Consume`(或 tenant 模組,見 §6)
|
||||||
|
|
||||||
|
**禁止**:auth usecase 呼叫 member usecase;一律在 logic 編排。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Invite Code(B 必填)
|
||||||
|
|
||||||
|
### 6.1 資料模型(Mongo `invite_codes`)
|
||||||
|
|
||||||
|
| 欄位 | 說明 |
|
||||||
|
|------|------|
|
||||||
|
| tenant_id | 租戶 |
|
||||||
|
| code_hash | bcrypt/sha256,不明文存 |
|
||||||
|
| max_uses | 总次數 |
|
||||||
|
| used_count | 已用 |
|
||||||
|
| expires_at | 可選 |
|
||||||
|
| new_users_only | default true |
|
||||||
|
| created_at | |
|
||||||
|
|
||||||
|
索引:`(tenant_id, code_hash)` unique
|
||||||
|
|
||||||
|
### 6.2 流程
|
||||||
|
|
||||||
|
1. `ValidateInvite(tenant_id, plain_code)` → ok / `ErrInviteInvalid|Expired|Exhausted`
|
||||||
|
2. **Consume** 在 ZITADEL/member 建立 **之前** 用 Redis lock + Mongo `$inc used_count`(防并发超卖)
|
||||||
|
3. 若後續 ZITADEL 或 member 失敗 → **不回滚 invite**(已決策:防刷;可 admin 补发)。Logic 记录 audit + 人工处理阈值
|
||||||
|
|
||||||
|
可选 P1:`ReleaseInvite` 仅当失败发生在 Validate 后 30s 内且 ZITADEL 未创建。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Registration Metadata(Logic 要留的資料)
|
||||||
|
|
||||||
|
Collection:`registration_metadata` 或 member 子文档 `registration`:
|
||||||
|
|
||||||
|
| 欄位 | 來源 |
|
||||||
|
|------|------|
|
||||||
|
| tenant_id, uid | member |
|
||||||
|
| invite_code_id | 消耗后的 invite 记录 id |
|
||||||
|
| accept_terms_version | request |
|
||||||
|
| marketing_opt_in | request |
|
||||||
|
| registration_channel | `email` \| `google` |
|
||||||
|
| client_ip / user_agent | handler 注入 context |
|
||||||
|
| occurred_at | server |
|
||||||
|
|
||||||
|
写入时机:email 路径在 `CreateUnverified` 后;social 在 `EnsureFromOIDC` 后。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 狀態機
|
||||||
|
|
||||||
|
### Email 註冊
|
||||||
|
|
||||||
|
```
|
||||||
|
register → unverified (member) + zitadel user active
|
||||||
|
confirm OK → active + JWT
|
||||||
|
OTP max attempts → challenge locked
|
||||||
|
abort: cron 清理 7 天未 confirm 的 unverified + zitadel deactivate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Social 註冊
|
||||||
|
|
||||||
|
```
|
||||||
|
social/start → registration_session (Redis TTL 10min, 含 invite_id)
|
||||||
|
callback OK → active + JWT
|
||||||
|
session 过期 → 重新 start
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 失敗補償
|
||||||
|
|
||||||
|
| 步驟失敗 | 補償 |
|
||||||
|
|----------|------|
|
||||||
|
| invite 无效 | 直接 4xx,无 side effect |
|
||||||
|
| ZITADEL CreateUser 成功,member 失败 | Logic 调用 `zitadel.DeactivateUser(sub)` |
|
||||||
|
| member 成功,Send OTP 失败 | `OTP.Invalidate(challenge_id)` |
|
||||||
|
| confirm 时 Activate 失败 | 5xx,保留 challenge 可重试 confirm |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 錯誤碼(Logic / Usecase)
|
||||||
|
|
||||||
|
| 情境 | errb |
|
||||||
|
|------|------|
|
||||||
|
| invite 无效 | `InputInvalidFormat` 或 `ResNotFound("invite")` |
|
||||||
|
| invite 用尽 | `ResInsufficientQuota` |
|
||||||
|
| email 已注册 | `ResAlreadyExist` |
|
||||||
|
| OTP 错误 | `AuthForbidden` + cause |
|
||||||
|
| tenant 不允许注册 | `AuthForbidden` |
|
||||||
|
| 未 accept 条款 | `InputMissingRequired` |
|
||||||
|
| ZITADEL 下游失败 | `SvcThirdParty` |
|
||||||
|
| DB 失败 | `DBError` via wrapRepoErr |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 設定(`etc/gateway.yaml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
Auth:
|
||||||
|
AccessExpire: 900
|
||||||
|
RefreshExpire: 604800
|
||||||
|
RegistrationSessionTTLSeconds: 600
|
||||||
|
|
||||||
|
Zitadel:
|
||||||
|
Issuer: https://zitadel.internal
|
||||||
|
MgmtURL: https://zitadel.internal/management/v1
|
||||||
|
ServiceUserToken: ${ZITADEL_SERVICE_TOKEN}
|
||||||
|
GoogleClientID: ...
|
||||||
|
GoogleClientSecret: ...
|
||||||
|
DefaultOrgID: ... # 或 per-tenant 映射表
|
||||||
|
|
||||||
|
Member:
|
||||||
|
Registration:
|
||||||
|
RequireInviteCode: true
|
||||||
|
UnverifiedRetentionDays: 7
|
||||||
|
TrustSocialEmailVerified: true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Rate Limit(Redis)
|
||||||
|
|
||||||
|
| Key | 限制 |
|
||||||
|
|-----|------|
|
||||||
|
| `auth:register:ip:{ip}` | 10 / hour |
|
||||||
|
| `auth:register:email:{tenant}:{email}` | 3 / hour |
|
||||||
|
| `auth:register:invite_fail:{ip}` | 20 / hour |
|
||||||
|
| OTP resend | 沿用 member `VerifyRateStore` 或独立 cooldown |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 實施順序(PR 切分)
|
||||||
|
|
||||||
|
| PR | 内容 | 验收 |
|
||||||
|
|----|------|------|
|
||||||
|
| **1** | `library/zitadel` + config + 单元测试(mock server) | CreateUser / VerifyPassword |
|
||||||
|
| **2** | `model/auth` TokenUseCase(Issue/Refresh)+ JWT middleware 骨架 | 签/验 JWT |
|
||||||
|
| **3** | invite repository + InviteUseCase + mongo index | validate/consume |
|
||||||
|
| **4** | `auth.api` + Logic register + confirm + resend | curl 完整 email 注册 |
|
||||||
|
| **5** | registration_metadata + audit 字段 | Mongo 可查来源 |
|
||||||
|
| **6** | Social start/callback + registration session | Google 注册拿 JWT |
|
||||||
|
| **7** | `POST /auth/login` + 取代 dev header | member API Bearer 访问 |
|
||||||
|
| **8** | `/auth/token/exchange` + login/social 分离 | 企业 SSO 不冲突 |
|
||||||
|
| **9** | 文档修订 identity-member-design §3.4、§7.1 | 文档一致 ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 測試策略
|
||||||
|
|
||||||
|
- **单元**:invite consume 并发、OTP purpose、errb 映射
|
||||||
|
- **集成**:testcontainers Mongo/Redis + zitadel mock
|
||||||
|
- **E2E CLI**:`cmd/auth-register-test` 模拟 register → confirm → login
|
||||||
|
- **Social**:mock OAuth callback with fixed state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 與現有代码复用
|
||||||
|
|
||||||
|
| 已有 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `Lifecycle.CreateUnverified` / `Activate` | email 注册 |
|
||||||
|
| `OTP.*` + `OTPPurposeRegistrationEmail` | 确认 |
|
||||||
|
| `Notifier` + `NotifyVerifyRegistrationEmail` | 寄信 |
|
||||||
|
| `Provisioning.EnsureFromOIDC` | social JIT |
|
||||||
|
| `Tenant.ResolveBySlug` | tenant_slug → tenant_id |
|
||||||
|
| `response.Write` + `errb` | HTTP 输出 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 修訂 identity-member-design.md(已完成)
|
||||||
|
|
||||||
|
以下修訂已寫入 [identity-member-design.md](./identity-member-design.md)(v1.0.0,2026-05-21):
|
||||||
|
|
||||||
|
- **§3.4**:Gateway 統一註冊 BFF(Email / Social / invite 必填)
|
||||||
|
- **§7.1**:auth.api 路由表(含實作狀態)
|
||||||
|
- **§8.1**:CloudEP JWT middleware + dev header fallback
|
||||||
|
- **§9.1**:login / token exchange 流程
|
||||||
|
- **§5.9 Case A**:標記為已實作
|
||||||
|
- **決策列 19**:註冊路徑決策更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 前端契约(简要)
|
||||||
|
|
||||||
|
1. 注册页收集:`tenant_slug`, `invite_code`, 条款, 语言
|
||||||
|
2. Tab:Email | Google(同 invite + 条款)
|
||||||
|
3. Email:register → 输入 OTP → confirm → 存 token
|
||||||
|
4. Google:social/start → redirect → callback 页取 token
|
||||||
|
5. 错误:`409` 已存在 → 引导 login;invite 无效 → 留在注册页
|
||||||
|
|
@ -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 |
|
|
||||||
| **B2B(LDAP)** | 由 IT 在 AD / OpenLDAP 建帳;可選 Directory Sync 預 provision 到 ZITADEL | LDAP IdP 登入觸發 `EnsureFromLDAP` JIT |
|
|
||||||
| **B2B(SCIM)** | 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 Social(Google)** | `POST /auth/register/social/start` → OAuth → `GET /auth/register/social/callback` | OAuth **前**綁定 invite session(Redis);callback 消耗 invite → `EnsureFromOIDC` → registration metadata → JWT |
|
||||||
|
| **B2B(LDAP)** | 由 IT 在 AD / OpenLDAP 建帳;Directory Sync 預 provision | 登入走 LDAP IdP → `EnsureFromLDAP` JIT;**不經** register API |
|
||||||
|
| **B2B(SCIM)** | HR / Okta / Entra 推 SCIM Create User | SCIM endpoint 寫 ZITADEL + Gateway;**不經** register API |
|
||||||
|
|
||||||
|
**商務規則(Logic 層,非 usecase):**
|
||||||
|
|
||||||
|
- Invite code **必填**(`Member.Registration.RequireInviteCode`,預設 `true`)
|
||||||
|
- 條款版本 `accept_terms_version` 必填
|
||||||
|
- 註冊完成前 **不發** CloudEP JWT;confirm / 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.Record(channel=email)
|
||||||
// 2) 產生 OTP(atomic、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) 投遞 OTP(atomic;caller 控制 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) 驗證 OTP(atomic)
|
|
||||||
_ = mOTP.Verify(ctx, &VerifyOTPRequest{
|
|
||||||
TenantID: tenantID, ChallengeID: chal.ChallengeID,
|
|
||||||
Code: userCode, Purpose: OTPPurposeRegistrationEmail,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 5) 啟用(atomic):unverified → active
|
|
||||||
_ = mLifecycle.Activate(ctx, tenantID, m.UID)
|
_ = mLifecycle.Activate(ctx, tenantID, m.UID)
|
||||||
|
// auth.IssuePair → { access_token, refresh_token }
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Case B:OIDC(Social / ZITADEL Hosted UI)登入 — 不需 OTP
|
#### Case B:OIDC(Social / 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` | 撤銷自己所有 session(INCR 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` | 撤銷自己所有 session(INCR 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 無 actor,fallback 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.VerifyPassword(ROPG)
|
||||||
|
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.VerifyIDToken(JWKS 驗簽 + iss/aud/exp)
|
||||||
|
2. tenant.ResolveBySlug
|
||||||
|
3. member.GetByZitadelUserID → 校驗 active
|
||||||
|
4. auth.IssuePair
|
||||||
|
Client ← { access_token, refresh_token, uid }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 9.1.3 OIDC 登入 + JIT(B2B / 舊 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.IssueTokenPair(role 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 UI(B2C)/ LDAP / SCIM(B2B);**保留** platform-native usecase(`LifecycleUseCase.CreateUnverified` + `OTPUseCase` + `Activate`)供未來開通 Gateway 原生註冊(含 email OTP 驗證) | §3.4、§5.2.1、§5.9 |
|
| 19 | 註冊路徑 | **B2C**:Gateway 統一 `/auth/register*`(Email + Social,invite 必填);**B2B**:LDAP / SCIM 不經 register API;platform-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 | 待決策 A–L 全數拍板:SCIM id = Gateway UID + ZITADEL sub extension(§10.3);Casbin 多 pod Pub/Sub + 5min cron 兜底(§6.11);Tenant 建立 saga(§3.1);Platform Admin seed CLI(§18 P0);Member.Origin + UserRole.Source 雙欄(§5.4、§6.10);SCIM token 全權 + IP allowlist(§7.5);獨立 audit_logs collection + TTL 90d(§20.1);軟刪 30 天匿名化(§5.7);分欄位 SoT(§5.3);Directory Sync guardrail(§10.4);Redis sliding-window rate limit(§20.2);JWT kid 多 key 並存(§4.4) |
|
| 2026-05-20 | 0.7.0 | 待決策 A–L 全數拍板:SCIM id = Gateway UID + ZITADEL sub extension(§10.3);Casbin 多 pod Pub/Sub + 5min cron 兜底(§6.11);Tenant 建立 saga(§3.1);Platform Admin seed CLI(§18 P0);Member.Origin + UserRole.Source 雙欄(§5.4、§6.10);SCIM token 全權 + IP allowlist(§7.5);獨立 audit_logs collection + TTL 90d(§20.1);軟刪 30 天匿名化(§5.7);分欄位 SoT(§5.3);Directory Sync guardrail(§10.4);Redis sliding-window rate limit(§20.2);JWT 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、設定檔、實施順序、決策列 25–28 同步更新;§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、設定檔、實施順序、決策列 25–28 同步更新;§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 修正、新增 29–32 |
|
| 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 修正、新增 29–32 |
|
||||||
|
| 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) |
|
||||||
|
|
|
||||||
|
|
@ -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`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.yaml(OpenAPI 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 使用對應 scope(Auth=28、Member=29)。
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,451 @@
|
||||||
|
syntax = "v1"
|
||||||
|
|
||||||
|
type (
|
||||||
|
RegisterReq {
|
||||||
|
TenantSlug string `json:"tenant_slug" validate:"required"`
|
||||||
|
InviteCode string `json:"invite_code" validate:"required"`
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
Password string `json:"password" validate:"required,min=8,max=128"`
|
||||||
|
DisplayName string `json:"display_name,optional"`
|
||||||
|
Language string `json:"language,optional"`
|
||||||
|
AcceptTermsVersion string `json:"accept_terms_version" validate:"required"`
|
||||||
|
MarketingOptIn bool `json:"marketing_opt_in,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterData {
|
||||||
|
ChallengeID string `json:"challenge_id"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
UID string `json:"uid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterConfirmReq {
|
||||||
|
TenantSlug string `json:"tenant_slug" validate:"required"`
|
||||||
|
ChallengeID string `json:"challenge_id" validate:"required"`
|
||||||
|
Code string `json:"code" validate:"required,len=6"`
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterResendReq {
|
||||||
|
TenantSlug string `json:"tenant_slug" validate:"required"`
|
||||||
|
ChallengeID string `json:"challenge_id" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthTokenData {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
|
UID string `json:"uid"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterSocialStartReq {
|
||||||
|
TenantSlug string `json:"tenant_slug" validate:"required"`
|
||||||
|
InviteCode string `json:"invite_code" validate:"required"`
|
||||||
|
Provider string `json:"provider" validate:"required,oneof=google"`
|
||||||
|
AcceptTermsVersion string `json:"accept_terms_version" validate:"required"`
|
||||||
|
Language string `json:"language,optional"`
|
||||||
|
RedirectURI string `json:"redirect_uri" validate:"required,url"`
|
||||||
|
MarketingOptIn bool `json:"marketing_opt_in,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterSocialStartData {
|
||||||
|
OauthURL string `json:"oauth_url"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterSocialCallbackReq {
|
||||||
|
Code string `form:"code" validate:"required"`
|
||||||
|
State string `form:"state" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginReq {
|
||||||
|
TenantSlug string `json:"tenant_slug" validate:"required"`
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
Password string `json:"password" validate:"required,min=8,max=128"`
|
||||||
|
}
|
||||||
|
|
||||||
|
TokenRefreshReq {
|
||||||
|
RefreshToken string `json:"refresh_token" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
TokenExchangeReq {
|
||||||
|
TenantSlug string `json:"tenant_slug" validate:"required"`
|
||||||
|
IDToken string `json:"id_token" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginSocialStartReq {
|
||||||
|
TenantSlug string `json:"tenant_slug" validate:"required"`
|
||||||
|
Provider string `json:"provider" validate:"required,oneof=google"`
|
||||||
|
RedirectURI string `json:"redirect_uri" validate:"required,url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginSocialStartData {
|
||||||
|
OauthURL string `json:"oauth_url"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginSocialCallbackReq {
|
||||||
|
Code string `form:"code" validate:"required"`
|
||||||
|
State string `form:"state" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
LogoutData {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件用:成功回應 envelope(HTTP 200, code=102000, message=SUCCESS)
|
||||||
|
RegisterOKStatus {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data RegisterData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
AuthTokenOKStatus {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data AuthTokenData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisterSocialStartOKStatus {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data RegisterSocialStartData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginSocialStartOKStatus {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data LoginSocialStartData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
LogoutOKStatus {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data LogoutData `json:"data"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@server(
|
||||||
|
group: auth
|
||||||
|
prefix: /api/v1/auth
|
||||||
|
)
|
||||||
|
service gateway {
|
||||||
|
@doc "Email 註冊(建立 ZITADEL + member,寄 registration OTP)"
|
||||||
|
/*
|
||||||
|
@respdoc-200 (RegisterOKStatus) // 成功(code=102000)
|
||||||
|
@respdoc-400 (
|
||||||
|
10101000: (APIErrorStatus) 參數格式錯誤
|
||||||
|
10104000: (APIErrorStatus) 缺少必填欄位
|
||||||
|
) // 參數錯誤 / 驗證失敗
|
||||||
|
@respdoc-403 (
|
||||||
|
28505000: (APIErrorStatus) tenant 不允許註冊
|
||||||
|
) // 禁止存取
|
||||||
|
@respdoc-404 (
|
||||||
|
29301000: (APIErrorStatus) tenant 不存在(Member scope)
|
||||||
|
) // 資源不存在
|
||||||
|
@respdoc-409 (
|
||||||
|
28303000: (APIErrorStatus) email 已註冊(Auth scope)
|
||||||
|
) // 資源衝突
|
||||||
|
@respdoc-423 (
|
||||||
|
28313000: (APIErrorStatus) invite 消耗鎖定中
|
||||||
|
) // 資源鎖定
|
||||||
|
@respdoc-429 (
|
||||||
|
28604000: (APIErrorStatus) OTP 重送冷卻
|
||||||
|
28310000: (APIErrorStatus) invite 次數用盡
|
||||||
|
) // 請求過於頻繁
|
||||||
|
@respdoc-500 (
|
||||||
|
28201000: (APIErrorStatus) 資料庫錯誤
|
||||||
|
28601000: (APIErrorStatus) 系統內部錯誤
|
||||||
|
) // 內部錯誤
|
||||||
|
@respdoc-501 (
|
||||||
|
28605000: (APIErrorStatus) 功能未配置
|
||||||
|
) // 未實作
|
||||||
|
@respdoc-502 (
|
||||||
|
28802000: (APIErrorStatus) ZITADEL 第三方錯誤
|
||||||
|
) // 第三方服務錯誤
|
||||||
|
*/
|
||||||
|
@handler register
|
||||||
|
post /register (RegisterReq) returns (RegisterData)
|
||||||
|
|
||||||
|
@doc "確認 registration OTP 並核發 JWT"
|
||||||
|
/*
|
||||||
|
@respdoc-200 (AuthTokenOKStatus) // 成功(code=102000)
|
||||||
|
@respdoc-400 (
|
||||||
|
10101000: (APIErrorStatus) 參數格式錯誤
|
||||||
|
10104000: (APIErrorStatus) 缺少必填欄位
|
||||||
|
) // 參數錯誤 / 驗證失敗
|
||||||
|
@respdoc-403 (
|
||||||
|
28505000: (APIErrorStatus) challenge tenant 或 purpose 不符(Auth scope)
|
||||||
|
29505000: (APIErrorStatus) OTP 無效(Member scope)
|
||||||
|
) // 禁止存取
|
||||||
|
@respdoc-404 (
|
||||||
|
29301000: (APIErrorStatus) tenant / OTP challenge 不存在(Member scope)
|
||||||
|
) // 資源不存在
|
||||||
|
@respdoc-409 (
|
||||||
|
28309000: (APIErrorStatus) registration challenge 狀態無效(Auth scope)
|
||||||
|
29309000: (APIErrorStatus) OTP challenge 鎖定(Member scope)
|
||||||
|
) // 資源狀態衝突
|
||||||
|
@respdoc-500 (
|
||||||
|
28201000: (APIErrorStatus) 資料庫錯誤
|
||||||
|
28601000: (APIErrorStatus) 系統內部錯誤
|
||||||
|
) // 內部錯誤
|
||||||
|
@respdoc-501 (
|
||||||
|
28605000: (APIErrorStatus) 功能未配置
|
||||||
|
) // 未實作
|
||||||
|
*/
|
||||||
|
@handler registerConfirm
|
||||||
|
post /register/confirm (RegisterConfirmReq) returns (AuthTokenData)
|
||||||
|
|
||||||
|
@doc "重寄 registration OTP"
|
||||||
|
/*
|
||||||
|
@respdoc-200 (RegisterOKStatus) // 成功(code=102000)
|
||||||
|
@respdoc-400 (
|
||||||
|
10101000: (APIErrorStatus) 參數格式錯誤
|
||||||
|
10104000: (APIErrorStatus) 缺少必填欄位
|
||||||
|
) // 參數錯誤
|
||||||
|
@respdoc-403 (
|
||||||
|
28505000: (APIErrorStatus) challenge tenant 或 purpose 不符
|
||||||
|
) // 禁止存取
|
||||||
|
@respdoc-404 (
|
||||||
|
29301000: (APIErrorStatus) tenant / OTP challenge 不存在(Member scope)
|
||||||
|
) // 資源不存在
|
||||||
|
@respdoc-409 (
|
||||||
|
28309000: (APIErrorStatus) registration challenge 不完整(Auth scope)
|
||||||
|
) // 資源狀態衝突
|
||||||
|
@respdoc-429 (
|
||||||
|
28604000: (APIErrorStatus) OTP 重送冷卻
|
||||||
|
) // 請求過於頻繁
|
||||||
|
@respdoc-500 (
|
||||||
|
28201000: (APIErrorStatus) 資料庫錯誤
|
||||||
|
28601000: (APIErrorStatus) 系統內部錯誤
|
||||||
|
) // 內部錯誤
|
||||||
|
@respdoc-501 (
|
||||||
|
28605000: (APIErrorStatus) 功能未配置
|
||||||
|
) // 未實作
|
||||||
|
*/
|
||||||
|
@handler registerResend
|
||||||
|
post /register/resend (RegisterResendReq) returns (RegisterData)
|
||||||
|
|
||||||
|
@doc "Social 註冊:建立 session 並回傳 OAuth URL"
|
||||||
|
/*
|
||||||
|
@respdoc-200 (RegisterSocialStartOKStatus) // 成功(code=102000)
|
||||||
|
@respdoc-400 (
|
||||||
|
10101000: (APIErrorStatus) 參數格式錯誤
|
||||||
|
10104000: (APIErrorStatus) 缺少必填欄位
|
||||||
|
28101000: (APIErrorStatus) invite 已過期(Auth scope)
|
||||||
|
) // 參數錯誤
|
||||||
|
@respdoc-403 (
|
||||||
|
28505000: (APIErrorStatus) tenant 不允許註冊
|
||||||
|
) // 禁止存取
|
||||||
|
@respdoc-404 (
|
||||||
|
29301000: (APIErrorStatus) tenant 不存在(Member scope)
|
||||||
|
28301000: (APIErrorStatus) invite 不存在(Auth scope)
|
||||||
|
) // 資源不存在
|
||||||
|
@respdoc-429 (
|
||||||
|
28310000: (APIErrorStatus) invite 次數用盡
|
||||||
|
) // 配額不足
|
||||||
|
@respdoc-500 (
|
||||||
|
28201000: (APIErrorStatus) 資料庫錯誤
|
||||||
|
28601000: (APIErrorStatus) 系統內部錯誤
|
||||||
|
) // 內部錯誤
|
||||||
|
@respdoc-501 (
|
||||||
|
28605000: (APIErrorStatus) 功能未配置
|
||||||
|
) // 未實作
|
||||||
|
@respdoc-502 (
|
||||||
|
28802000: (APIErrorStatus) ZITADEL 第三方錯誤
|
||||||
|
) // 第三方服務錯誤
|
||||||
|
*/
|
||||||
|
@handler registerSocialStart
|
||||||
|
post /register/social/start (RegisterSocialStartReq) returns (RegisterSocialStartData)
|
||||||
|
|
||||||
|
@doc "Social 註冊 OAuth callback"
|
||||||
|
/*
|
||||||
|
@respdoc-200 (AuthTokenOKStatus) // 成功(code=102000)
|
||||||
|
@respdoc-400 (
|
||||||
|
10101000: (APIErrorStatus) 參數格式錯誤
|
||||||
|
10104000: (APIErrorStatus) 缺少必填欄位
|
||||||
|
28101000: (APIErrorStatus) oauth state 無效(Auth scope)
|
||||||
|
) // 參數錯誤
|
||||||
|
@respdoc-403 (
|
||||||
|
28505000: (APIErrorStatus) social email 未驗證
|
||||||
|
) // 禁止存取
|
||||||
|
@respdoc-404 (
|
||||||
|
28301000: (APIErrorStatus) registration session 不存在(Auth scope)
|
||||||
|
29301000: (APIErrorStatus) tenant 不存在(Member scope)
|
||||||
|
) // 資源不存在
|
||||||
|
@respdoc-409 (
|
||||||
|
28303000: (APIErrorStatus) 帳號已存在(引導 login)
|
||||||
|
) // 資源衝突
|
||||||
|
@respdoc-423 (
|
||||||
|
28313000: (APIErrorStatus) invite 消耗鎖定中
|
||||||
|
) // 資源鎖定
|
||||||
|
@respdoc-429 (
|
||||||
|
28310000: (APIErrorStatus) invite 次數用盡
|
||||||
|
) // 配額不足
|
||||||
|
@respdoc-500 (
|
||||||
|
28201000: (APIErrorStatus) 資料庫錯誤
|
||||||
|
28601000: (APIErrorStatus) 系統內部錯誤
|
||||||
|
) // 內部錯誤
|
||||||
|
@respdoc-501 (
|
||||||
|
28605000: (APIErrorStatus) 功能未配置
|
||||||
|
) // 未實作
|
||||||
|
@respdoc-502 (
|
||||||
|
28802000: (APIErrorStatus) ZITADEL 第三方錯誤
|
||||||
|
) // 第三方服務錯誤
|
||||||
|
*/
|
||||||
|
@handler registerSocialCallback
|
||||||
|
get /register/social/callback (RegisterSocialCallbackReq) returns (AuthTokenData)
|
||||||
|
|
||||||
|
@doc "Email + 密碼登入(ZITADEL ROPG → CloudEP JWT)"
|
||||||
|
/*
|
||||||
|
@respdoc-200 (AuthTokenOKStatus) // 成功(code=102000)
|
||||||
|
@respdoc-400 (
|
||||||
|
10101000: (APIErrorStatus) 參數格式錯誤
|
||||||
|
10104000: (APIErrorStatus) 缺少必填欄位
|
||||||
|
) // 參數錯誤
|
||||||
|
@respdoc-401 (
|
||||||
|
28501000: (APIErrorStatus) 帳密錯誤
|
||||||
|
) // 未授權
|
||||||
|
@respdoc-403 (
|
||||||
|
28505000: (APIErrorStatus) 帳號未驗證 / 停權 / 不允許登入
|
||||||
|
) // 禁止存取
|
||||||
|
@respdoc-404 (
|
||||||
|
29301000: (APIErrorStatus) tenant 不存在(Member scope)
|
||||||
|
) // 資源不存在
|
||||||
|
@respdoc-500 (
|
||||||
|
28201000: (APIErrorStatus) 資料庫錯誤
|
||||||
|
28601000: (APIErrorStatus) 系統內部錯誤
|
||||||
|
) // 內部錯誤
|
||||||
|
@respdoc-501 (
|
||||||
|
28605000: (APIErrorStatus) 功能未配置
|
||||||
|
) // 未實作
|
||||||
|
@respdoc-502 (
|
||||||
|
28802000: (APIErrorStatus) ZITADEL 第三方錯誤
|
||||||
|
) // 第三方服務錯誤
|
||||||
|
*/
|
||||||
|
@handler login
|
||||||
|
post /login (LoginReq) returns (AuthTokenData)
|
||||||
|
|
||||||
|
@doc "以 refresh_token 換發新的 access/refresh token"
|
||||||
|
/*
|
||||||
|
@respdoc-200 (AuthTokenOKStatus) // 成功(code=102000)
|
||||||
|
@respdoc-400 (
|
||||||
|
10101000: (APIErrorStatus) 參數格式錯誤
|
||||||
|
10104000: (APIErrorStatus) 缺少 refresh_token
|
||||||
|
) // 參數錯誤
|
||||||
|
@respdoc-401 (
|
||||||
|
28501000: (APIErrorStatus) refresh token 無效或已撤銷
|
||||||
|
) // 未授權
|
||||||
|
@respdoc-500 (
|
||||||
|
28601000: (APIErrorStatus) 系統內部錯誤
|
||||||
|
) // 內部錯誤
|
||||||
|
@respdoc-501 (
|
||||||
|
28605000: (APIErrorStatus) 功能未配置
|
||||||
|
) // 未實作
|
||||||
|
*/
|
||||||
|
@handler tokenRefresh
|
||||||
|
post /token/refresh (TokenRefreshReq) returns (AuthTokenData)
|
||||||
|
|
||||||
|
@doc "ZITADEL id_token 換 CloudEP JWT(企業 SSO)"
|
||||||
|
/*
|
||||||
|
@respdoc-200 (AuthTokenOKStatus) // 成功(code=102000)
|
||||||
|
@respdoc-400 (
|
||||||
|
10101000: (APIErrorStatus) 參數格式錯誤
|
||||||
|
10104000: (APIErrorStatus) 缺少必填欄位
|
||||||
|
) // 參數錯誤
|
||||||
|
@respdoc-401 (
|
||||||
|
28501000: (APIErrorStatus) id_token 無效
|
||||||
|
) // 未授權
|
||||||
|
@respdoc-403 (
|
||||||
|
28505000: (APIErrorStatus) 帳號未驗證 / 停權 / 不允許登入
|
||||||
|
) // 禁止存取
|
||||||
|
@respdoc-404 (
|
||||||
|
29301000: (APIErrorStatus) tenant / member 不存在(Member scope)
|
||||||
|
) // 資源不存在
|
||||||
|
@respdoc-500 (
|
||||||
|
28201000: (APIErrorStatus) 資料庫錯誤
|
||||||
|
28601000: (APIErrorStatus) 系統內部錯誤
|
||||||
|
) // 內部錯誤
|
||||||
|
@respdoc-501 (
|
||||||
|
28605000: (APIErrorStatus) 功能未配置
|
||||||
|
) // 未實作
|
||||||
|
@respdoc-502 (
|
||||||
|
28802000: (APIErrorStatus) ZITADEL 第三方錯誤
|
||||||
|
) // 第三方服務錯誤
|
||||||
|
*/
|
||||||
|
@handler tokenExchange
|
||||||
|
post /token/exchange (TokenExchangeReq) returns (AuthTokenData)
|
||||||
|
|
||||||
|
@doc "Social 登入:建立 login session 並回傳 OAuth URL(不含 invite)"
|
||||||
|
/*
|
||||||
|
@respdoc-200 (LoginSocialStartOKStatus) // 成功(code=102000)
|
||||||
|
@respdoc-400 (
|
||||||
|
10101000: (APIErrorStatus) 參數格式錯誤
|
||||||
|
10104000: (APIErrorStatus) 缺少必填欄位
|
||||||
|
) // 參數錯誤
|
||||||
|
@respdoc-403 (
|
||||||
|
28505000: (APIErrorStatus) tenant 不允許登入
|
||||||
|
) // 禁止存取
|
||||||
|
@respdoc-404 (
|
||||||
|
29301000: (APIErrorStatus) tenant 不存在(Member scope)
|
||||||
|
) // 資源不存在
|
||||||
|
@respdoc-500 (
|
||||||
|
28201000: (APIErrorStatus) 資料庫錯誤
|
||||||
|
28601000: (APIErrorStatus) 系統內部錯誤
|
||||||
|
) // 內部錯誤
|
||||||
|
@respdoc-501 (
|
||||||
|
28605000: (APIErrorStatus) 功能未配置
|
||||||
|
) // 未實作
|
||||||
|
@respdoc-502 (
|
||||||
|
28802000: (APIErrorStatus) ZITADEL 第三方錯誤
|
||||||
|
) // 第三方服務錯誤
|
||||||
|
*/
|
||||||
|
@handler loginSocialStart
|
||||||
|
post /login/social/start (LoginSocialStartReq) returns (LoginSocialStartData)
|
||||||
|
|
||||||
|
@doc "Social 登入 OAuth callback"
|
||||||
|
/*
|
||||||
|
@respdoc-200 (AuthTokenOKStatus) // 成功(code=102000)
|
||||||
|
@respdoc-400 (
|
||||||
|
10101000: (APIErrorStatus) 參數格式錯誤
|
||||||
|
10104000: (APIErrorStatus) 缺少必填欄位
|
||||||
|
28101000: (APIErrorStatus) oauth state 無效(Auth scope)
|
||||||
|
) // 參數錯誤
|
||||||
|
@respdoc-403 (
|
||||||
|
28505000: (APIErrorStatus) social email 未驗證 / 帳號狀態不允許登入
|
||||||
|
) // 禁止存取
|
||||||
|
@respdoc-404 (
|
||||||
|
28301000: (APIErrorStatus) login session 不存在(Auth scope)
|
||||||
|
29301000: (APIErrorStatus) tenant / member 不存在(Member scope)
|
||||||
|
) // 資源不存在
|
||||||
|
@respdoc-500 (
|
||||||
|
28201000: (APIErrorStatus) 資料庫錯誤
|
||||||
|
28601000: (APIErrorStatus) 系統內部錯誤
|
||||||
|
) // 內部錯誤
|
||||||
|
@respdoc-501 (
|
||||||
|
28605000: (APIErrorStatus) 功能未配置
|
||||||
|
) // 未實作
|
||||||
|
@respdoc-502 (
|
||||||
|
28802000: (APIErrorStatus) ZITADEL 第三方錯誤
|
||||||
|
) // 第三方服務錯誤
|
||||||
|
*/
|
||||||
|
@handler loginSocialCallback
|
||||||
|
get /login/social/callback (LoginSocialCallbackReq) returns (AuthTokenData)
|
||||||
|
|
||||||
|
@doc "登出(撤銷 access JWT 及配對 refresh JWT)"
|
||||||
|
/*
|
||||||
|
@respdoc-200 (LogoutOKStatus) // 成功(code=102000)
|
||||||
|
@respdoc-401 (
|
||||||
|
28501000: (APIErrorStatus) 缺少或無效 access token
|
||||||
|
) // 未授權
|
||||||
|
@respdoc-500 (
|
||||||
|
28601000: (APIErrorStatus) 系統內部錯誤
|
||||||
|
) // 內部錯誤
|
||||||
|
@respdoc-501 (
|
||||||
|
28605000: (APIErrorStatus) 功能未配置
|
||||||
|
) // 未實作
|
||||||
|
*/
|
||||||
|
@handler logout
|
||||||
|
post /logout returns (LogoutData)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
// 業務碼格式 SSCCCDDD(scope * 1_000_000 + category * 1_000 + detail)
|
||||||
|
// Facade scope=10(handler parse/validate):10101000 = InputInvalidFormat
|
||||||
|
// Auth scope=28、Member scope=29、Notification scope=30:各模組 logic/usecase 使用對應 scope
|
||||||
type (
|
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 成功但無 data(confirm / delete 等;code=102000)
|
||||||
|
EmptyOKStatus {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,43 @@ type (
|
||||||
TOTPBackupCodesData {
|
TOTPBackupCodesData {
|
||||||
BackupCodes []string `json:"backup_codes"`
|
BackupCodes []string `json:"backup_codes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 文件用:成功回應 envelope(HTTP 200, code=102000, message=SUCCESS)
|
||||||
|
MemberMeOKStatus {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data MemberMeData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
VerificationStartOKStatus {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data VerificationStartData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
TOTPStatusOKStatus {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data TOTPStatusData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
TOTPEnrollStartOKStatus {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data TOTPEnrollStartData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
TOTPEnrollConfirmOKStatus {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data TOTPEnrollConfirmData `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
TOTPBackupCodesOKStatus {
|
||||||
|
Code int64 `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data TOTPBackupCodesData `json:"data"`
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@server(
|
@server(
|
||||||
|
|
@ -82,51 +119,289 @@ type (
|
||||||
prefix: /api/v1/members
|
prefix: /api/v1/members
|
||||||
)
|
)
|
||||||
service gateway {
|
service gateway {
|
||||||
@doc "取得當前會員 profile(dev:Header X-Tenant-ID + X-UID)"
|
@doc "取得當前會員 profile(Bearer JWT;本機 dev 可 fallback X-Tenant-ID + X-UID)"
|
||||||
|
/*
|
||||||
|
@respdoc-200 (MemberMeOKStatus) // 成功(code=102000)
|
||||||
|
@respdoc-401 (
|
||||||
|
29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID
|
||||||
|
) // 未授權
|
||||||
|
@respdoc-404 (
|
||||||
|
29301000: (APIErrorStatus) member 不存在
|
||||||
|
) // 資源不存在
|
||||||
|
@respdoc-500 (
|
||||||
|
29201000: (APIErrorStatus) 資料庫錯誤
|
||||||
|
29601000: (APIErrorStatus) 系統內部錯誤
|
||||||
|
) // 內部錯誤
|
||||||
|
@respdoc-501 (
|
||||||
|
29605000: (APIErrorStatus) 功能未配置
|
||||||
|
) // 未實作
|
||||||
|
*/
|
||||||
@handler getMemberMe
|
@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 "驗證 TOTP(step-up 測試)"
|
@doc "驗證 TOTP(step-up 測試)"
|
||||||
|
/*
|
||||||
|
@respdoc-200 (EmptyOKStatus) // 成功(code=102000)
|
||||||
|
@respdoc-400 (
|
||||||
|
10101000: (APIErrorStatus) 參數格式錯誤
|
||||||
|
29104000: (APIErrorStatus) code 必填(Member scope)
|
||||||
|
) // 參數錯誤
|
||||||
|
@respdoc-401 (
|
||||||
|
29501000: (APIErrorStatus) 缺少 Bearer 或 X-Tenant-ID/X-UID
|
||||||
|
) // 未授權
|
||||||
|
@respdoc-403 (
|
||||||
|
29505000: (APIErrorStatus) TOTP 碼無效或已使用
|
||||||
|
) // 禁止存取
|
||||||
|
@respdoc-409 (
|
||||||
|
29309000: (APIErrorStatus) TOTP 未綁定
|
||||||
|
) // 資源狀態衝突
|
||||||
|
@respdoc-500 (
|
||||||
|
29601000: (APIErrorStatus) 系統內部錯誤
|
||||||
|
) // 內部錯誤
|
||||||
|
@respdoc-501 (
|
||||||
|
29605000: (APIErrorStatus) 功能未配置
|
||||||
|
) // 未實作
|
||||||
|
*/
|
||||||
@handler verifyTOTP
|
@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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ type PingData {
|
||||||
Pong string `json:"pong"`
|
Pong string `json:"pong"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文件用:成功回應 envelope(code=0, message=SUCCESS, data=PingData)
|
// 文件用:成功回應 envelope(HTTP 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)
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
{
|
{
|
||||||
// 取得當前會員 profile(dev:Header X-Tenant-ID + X-UID)
|
// Email + 密碼登入(ZITADEL ROPG → CloudEP JWT)
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/login",
|
||||||
|
Handler: auth.LoginHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Social 登入 OAuth callback
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/login/social/callback",
|
||||||
|
Handler: auth.LoginSocialCallbackHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Social 登入:建立 login session 並回傳 OAuth URL(不含 invite)
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/login/social/start",
|
||||||
|
Handler: auth.LoginSocialStartHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Email 註冊(建立 ZITADEL + member,寄 registration OTP)
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/register",
|
||||||
|
Handler: auth.RegisterHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 確認 registration OTP 並核發 JWT
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/register/confirm",
|
||||||
|
Handler: auth.RegisterConfirmHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 重寄 registration OTP
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/register/resend",
|
||||||
|
Handler: auth.RegisterResendHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Social 註冊 OAuth callback
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/register/social/callback",
|
||||||
|
Handler: auth.RegisterSocialCallbackHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Social 註冊:建立 session 並回傳 OAuth URL
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/register/social/start",
|
||||||
|
Handler: auth.RegisterSocialStartHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 登出(撤銷 access JWT 及配對 refresh JWT)
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/logout",
|
||||||
|
Handler: auth.LogoutHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// ZITADEL id_token 換 CloudEP JWT(企業 SSO)
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/token/exchange",
|
||||||
|
Handler: auth.TokenExchangeHandler(serverCtx),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 以 refresh_token 換發新的 access/refresh token
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Path: "/token/refresh",
|
||||||
|
Handler: auth.TokenRefreshHandler(serverCtx),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rest.WithPrefix("/api/v1/auth"),
|
||||||
|
)
|
||||||
|
|
||||||
|
server.AddRoutes(
|
||||||
|
[]rest.Route{
|
||||||
|
{
|
||||||
|
// 取得當前會員 profile(Bearer JWT;本機 dev 可 fallback X-Tenant-ID + X-UID)
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
Path: "/me",
|
Path: "/me",
|
||||||
Handler: member.GetMemberMeHandler(serverCtx),
|
Handler: member.GetMemberMeHandler(serverCtx),
|
||||||
|
|
|
||||||
|
|
@ -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)`。
|
||||||
新增服務時在該檔登記,避免號段衝突。
|
新增服務時在該檔登記,避免號段衝突。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 != ""
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 ""
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gateway/internal/library/zitadel"
|
||||||
|
authmetaenum "gateway/internal/model/auth/domain/enum"
|
||||||
|
domauth "gateway/internal/model/auth/domain/usecase"
|
||||||
|
memberdom "gateway/internal/model/member/domain"
|
||||||
|
dommember "gateway/internal/model/member/domain/usecase"
|
||||||
|
notifenum "gateway/internal/model/notification/domain/enum"
|
||||||
|
notifuc "gateway/internal/model/notification/domain/usecase"
|
||||||
|
"gateway/internal/svc"
|
||||||
|
"gateway/internal/types"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RegisterLogic struct {
|
||||||
|
logx.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterLogic {
|
||||||
|
return &RegisterLogic{
|
||||||
|
Logger: logx.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *RegisterLogic) Register(req *types.RegisterReq) (*types.RegisterData, error) {
|
||||||
|
if err := requireRegistrationDeps(l.svcCtx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant, err := resolveTenant(l.ctx, l.svcCtx, req.TenantSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
regCfg := l.svcCtx.Config.Member.Defaults().Registration
|
||||||
|
var inviteCodeID string
|
||||||
|
if regCfg.RequireInviteCode {
|
||||||
|
if l.svcCtx.AuthInvite == nil {
|
||||||
|
return nil, errb.SysNotImplemented("invite validation not configured")
|
||||||
|
}
|
||||||
|
consumed, err := l.svcCtx.AuthInvite.Consume(l.ctx, &domauth.ConsumeInviteRequest{
|
||||||
|
TenantID: tenant.TenantID,
|
||||||
|
Code: req.InviteCode,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
inviteCodeID = consumed.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
email := strings.TrimSpace(strings.ToLower(req.Email))
|
||||||
|
zResult, err := l.svcCtx.Zitadel.CreateHumanUser(l.ctx, zitadel.CreateHumanUserRequest{
|
||||||
|
OrgID: tenant.OrgID,
|
||||||
|
Email: email,
|
||||||
|
Password: req.Password,
|
||||||
|
DisplayName: strings.TrimSpace(req.DisplayName),
|
||||||
|
Language: strings.TrimSpace(req.Language),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, wrapZitadelErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
memberDTO, err := l.svcCtx.MemberLifecycle.CreateUnverified(l.ctx, &dommember.CreatePlatformMemberRequest{
|
||||||
|
TenantID: tenant.TenantID,
|
||||||
|
Email: email,
|
||||||
|
DisplayName: strings.TrimSpace(req.DisplayName),
|
||||||
|
Language: strings.TrimSpace(req.Language),
|
||||||
|
ZitadelUserID: zResult.UserID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if deactErr := l.svcCtx.Zitadel.DeactivateUser(l.ctx, zResult.UserID); deactErr != nil {
|
||||||
|
logx.WithContext(l.ctx).Errorf("register: deactivate zitadel user after member failure: %v", deactErr)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := recordRegistrationMeta(l.ctx, l.svcCtx, tenant.TenantID, memberDTO.UID, inviteCodeID, req.AcceptTermsVersion, req.MarketingOptIn, authmetaenum.RegistrationChannelEmail); err != nil {
|
||||||
|
if abortErr := l.svcCtx.MemberLifecycle.AbortPending(l.ctx, tenant.TenantID, memberDTO.UID); abortErr != nil {
|
||||||
|
logx.WithContext(l.ctx).Errorf("register: abort pending member after metadata failure: %v", abortErr)
|
||||||
|
}
|
||||||
|
if deactErr := l.svcCtx.Zitadel.DeactivateUser(l.ctx, zResult.UserID); deactErr != nil {
|
||||||
|
logx.WithContext(l.ctx).Errorf("register: deactivate zitadel user after metadata failure: %v", deactErr)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := sendRegistrationOTP(l.ctx, l.svcCtx, tenant.TenantID, memberDTO.UID, email)
|
||||||
|
if err != nil {
|
||||||
|
if abortErr := l.svcCtx.MemberLifecycle.AbortPending(l.ctx, tenant.TenantID, memberDTO.UID); abortErr != nil {
|
||||||
|
logx.WithContext(l.ctx).Errorf("register: abort pending member: %v", abortErr)
|
||||||
|
}
|
||||||
|
if deactErr := l.svcCtx.Zitadel.DeactivateUser(l.ctx, zResult.UserID); deactErr != nil {
|
||||||
|
logx.WithContext(l.ctx).Errorf("register: deactivate zitadel user after otp failure: %v", deactErr)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data.UID = memberDTO.UID
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendRegistrationOTP(
|
||||||
|
ctx context.Context,
|
||||||
|
sc *svc.ServiceContext,
|
||||||
|
tenantID, uid, email string,
|
||||||
|
) (*types.RegisterData, error) {
|
||||||
|
cfg := sc.Config.Member.Defaults()
|
||||||
|
rateKey := memberdom.GetVerifyRateRedisKey(tenantID, uid, string(registrationPurpose()))
|
||||||
|
if err := sc.MemberVerifyRate.AssertResendAllowed(ctx, rateKey, time.Duration(cfg.OTP.ResendCooldownSeconds)*time.Second); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dto, plainCode, err := sc.MemberOTP.Generate(ctx, &dommember.GenerateOTPRequest{
|
||||||
|
TenantID: tenantID,
|
||||||
|
UID: uid,
|
||||||
|
Purpose: registrationPurpose(),
|
||||||
|
Target: email,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
locale := sc.Config.Notification.DefaultLocale
|
||||||
|
if strings.TrimSpace(locale) == "" {
|
||||||
|
locale = "en-us"
|
||||||
|
}
|
||||||
|
if _, sendErr := sc.Notifier.Send(ctx, ¬ifuc.SendRequest{
|
||||||
|
TenantID: tenantID,
|
||||||
|
UID: uid,
|
||||||
|
Channel: notifenum.ChannelEmail,
|
||||||
|
Kind: notifenum.NotifyVerifyRegistrationEmail,
|
||||||
|
Target: email,
|
||||||
|
Locale: locale,
|
||||||
|
Data: map[string]any{"code": plainCode, "expires_in": dto.ExpiresIn},
|
||||||
|
IdempotencyKey: dto.ChallengeID,
|
||||||
|
DoNotPersistBody: true,
|
||||||
|
Severity: notifenum.SeverityInfo,
|
||||||
|
}); sendErr != nil {
|
||||||
|
if invErr := sc.MemberOTP.Invalidate(ctx, dto.ChallengeID); invErr != nil {
|
||||||
|
return nil, invErr
|
||||||
|
}
|
||||||
|
return nil, sendErr
|
||||||
|
}
|
||||||
|
return &types.RegisterData{
|
||||||
|
ChallengeID: dto.ChallengeID,
|
||||||
|
ExpiresIn: dto.ExpiresIn,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 ""
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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{}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -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 != ""
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
libmongo "gateway/internal/library/mongo"
|
||||||
|
authdomain "gateway/internal/model/auth/domain"
|
||||||
|
"gateway/internal/model/auth/domain/entity"
|
||||||
|
domrepo "gateway/internal/model/auth/domain/repository"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
mongodriver "go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegistrationMetaRepositoryParam configures the Mongo registration metadata repository.
|
||||||
|
type RegistrationMetaRepositoryParam struct {
|
||||||
|
Conf *libmongo.Conf
|
||||||
|
}
|
||||||
|
|
||||||
|
type registrationMetaRepository struct {
|
||||||
|
db libmongo.DocumentDBUseCase
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistrationMetaRepository creates a Mongo-backed RegistrationMetaRepository.
|
||||||
|
func NewRegistrationMetaRepository(param RegistrationMetaRepositoryParam) domrepo.RegistrationMetaRepository {
|
||||||
|
documentDB, err := libmongo.NewDocumentDB(param.Conf, entity.RegistrationMetadata{}.CollectionName())
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return ®istrationMetaRepository{db: documentDB}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *registrationMetaRepository) Insert(ctx context.Context, rec *entity.RegistrationMetadata) error {
|
||||||
|
now := time.Now().UTC().UnixMilli()
|
||||||
|
if rec.ID.IsZero() {
|
||||||
|
rec.ID = bson.NewObjectID()
|
||||||
|
}
|
||||||
|
if rec.CreateAt == 0 {
|
||||||
|
rec.CreateAt = now
|
||||||
|
}
|
||||||
|
if rec.OccurredAt == 0 {
|
||||||
|
rec.OccurredAt = now
|
||||||
|
}
|
||||||
|
_, err := r.db.GetClient().InsertOne(ctx, rec)
|
||||||
|
if err != nil {
|
||||||
|
if mongodriver.IsDuplicateKeyError(err) {
|
||||||
|
return authdomain.ErrDuplicateRegistrationMeta
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index20260521002UP ensures registration_metadata collection indexes exist.
|
||||||
|
func (r *registrationMetaRepository) Index20260521002UP(ctx context.Context) error {
|
||||||
|
return r.db.PopulateMultiIndex(ctx,
|
||||||
|
[]string{authdomain.BSONFieldTenantID, authdomain.BSONFieldUID},
|
||||||
|
[]int32{1, 1},
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ domrepo.RegistrationMetaRepository = (*registrationMetaRepository)(nil)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
authdomain "gateway/internal/model/auth/domain"
|
||||||
|
"gateway/internal/model/auth/domain/entity"
|
||||||
|
domrepo "gateway/internal/model/auth/domain/repository"
|
||||||
|
domusecase "gateway/internal/model/auth/domain/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
type registrationMetaUseCase struct {
|
||||||
|
repo domrepo.RegistrationMetaRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistrationMetaUseCaseParam wires RegistrationMetaUseCase.
|
||||||
|
type RegistrationMetaUseCaseParam struct {
|
||||||
|
Repo domrepo.RegistrationMetaRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustRegistrationMetaUseCase constructs RegistrationMetaUseCase.
|
||||||
|
func MustRegistrationMetaUseCase(param RegistrationMetaUseCaseParam) domusecase.RegistrationMetaUseCase {
|
||||||
|
if param.Repo == nil {
|
||||||
|
panic("auth: registration metadata repository is required")
|
||||||
|
}
|
||||||
|
return ®istrationMetaUseCase{repo: param.Repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *registrationMetaUseCase) Record(ctx context.Context, req *domusecase.RecordRegistrationRequest) error {
|
||||||
|
if req == nil || req.TenantID == "" || req.UID == "" {
|
||||||
|
return errb.InputMissingRequired("tenant_id and uid are required")
|
||||||
|
}
|
||||||
|
if req.AcceptTermsVersion == "" {
|
||||||
|
return errb.InputMissingRequired("accept_terms_version is required")
|
||||||
|
}
|
||||||
|
if !req.Channel.Valid() {
|
||||||
|
return errb.InputInvalidFormat("invalid registration channel")
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().UnixMilli()
|
||||||
|
rec := &entity.RegistrationMetadata{
|
||||||
|
TenantID: req.TenantID,
|
||||||
|
UID: req.UID,
|
||||||
|
InviteCodeID: req.InviteCodeID,
|
||||||
|
AcceptTermsVersion: req.AcceptTermsVersion,
|
||||||
|
MarketingOptIn: req.MarketingOptIn,
|
||||||
|
RegistrationChannel: req.Channel,
|
||||||
|
ClientIP: req.ClientIP,
|
||||||
|
UserAgent: req.UserAgent,
|
||||||
|
OccurredAt: now,
|
||||||
|
CreateAt: now,
|
||||||
|
}
|
||||||
|
if err := uc.repo.Insert(ctx, rec); err != nil {
|
||||||
|
if errors.Is(err, authdomain.ErrDuplicateRegistrationMeta) {
|
||||||
|
return errb.ResAlreadyExist("registration metadata already exists").WithCause(err)
|
||||||
|
}
|
||||||
|
return wrapRepoErr(err, "insert registration metadata failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ domusecase.RegistrationMetaUseCase = (*registrationMetaUseCase)(nil)
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
authdomain "gateway/internal/model/auth/domain"
|
||||||
|
domrepo "gateway/internal/model/auth/domain/repository"
|
||||||
|
domusecase "gateway/internal/model/auth/domain/usecase"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type registrationSessionUseCase struct {
|
||||||
|
store domrepo.RegistrationSessionStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistrationSessionUseCaseParam wires RegistrationSessionUseCase.
|
||||||
|
type RegistrationSessionUseCaseParam struct {
|
||||||
|
Store domrepo.RegistrationSessionStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustRegistrationSessionUseCase constructs RegistrationSessionUseCase.
|
||||||
|
func MustRegistrationSessionUseCase(param RegistrationSessionUseCaseParam) domusecase.RegistrationSessionUseCase {
|
||||||
|
if param.Store == nil {
|
||||||
|
panic("auth: registration session store is required")
|
||||||
|
}
|
||||||
|
return ®istrationSessionUseCase{store: param.Store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *registrationSessionUseCase) Create(ctx context.Context, req *domusecase.CreateRegistrationSessionRequest) (*domusecase.RegistrationSessionView, error) {
|
||||||
|
if req == nil || req.TenantID == "" || req.TenantSlug == "" {
|
||||||
|
return nil, errb.InputMissingRequired("tenant_id and tenant_slug are required")
|
||||||
|
}
|
||||||
|
if req.InviteCode == "" {
|
||||||
|
return nil, errb.InputMissingRequired("invite_code is required")
|
||||||
|
}
|
||||||
|
if req.AcceptTermsVersion == "" {
|
||||||
|
return nil, errb.InputMissingRequired("accept_terms_version is required")
|
||||||
|
}
|
||||||
|
if req.Provider == "" {
|
||||||
|
return nil, errb.InputMissingRequired("provider is required")
|
||||||
|
}
|
||||||
|
if req.RedirectURI == "" {
|
||||||
|
return nil, errb.InputMissingRequired("redirect_uri is required")
|
||||||
|
}
|
||||||
|
ttl := req.TTL
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = 10 * time.Minute
|
||||||
|
}
|
||||||
|
sessionID := uuid.NewString()
|
||||||
|
session := &domrepo.RegistrationSession{
|
||||||
|
SessionID: sessionID,
|
||||||
|
TenantID: req.TenantID,
|
||||||
|
TenantSlug: req.TenantSlug,
|
||||||
|
InviteCode: req.InviteCode,
|
||||||
|
InviteNewUsersOnly: req.InviteNewUsersOnly,
|
||||||
|
AcceptTermsVersion: req.AcceptTermsVersion,
|
||||||
|
MarketingOptIn: req.MarketingOptIn,
|
||||||
|
Language: req.Language,
|
||||||
|
Provider: req.Provider,
|
||||||
|
RedirectURI: req.RedirectURI,
|
||||||
|
ClientIP: req.ClientIP,
|
||||||
|
UserAgent: req.UserAgent,
|
||||||
|
}
|
||||||
|
if err := uc.store.Save(ctx, session, ttl); err != nil {
|
||||||
|
return nil, wrapRepoErr(err, "save registration session failed")
|
||||||
|
}
|
||||||
|
return &domusecase.RegistrationSessionView{
|
||||||
|
SessionID: sessionID,
|
||||||
|
ExpiresIn: int(ttl.Seconds()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *registrationSessionUseCase) Get(ctx context.Context, sessionID string) (*domusecase.CreateRegistrationSessionRequest, error) {
|
||||||
|
if sessionID == "" {
|
||||||
|
return nil, errb.InputMissingRequired("session_id is required")
|
||||||
|
}
|
||||||
|
session, err := uc.store.Get(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, authdomain.ErrRegistrationSessionNotFound) {
|
||||||
|
return nil, errb.ResNotFound("registration session", sessionID).WithCause(err)
|
||||||
|
}
|
||||||
|
return nil, wrapRepoErr(err, "read registration session failed")
|
||||||
|
}
|
||||||
|
return &domusecase.CreateRegistrationSessionRequest{
|
||||||
|
TenantID: session.TenantID,
|
||||||
|
TenantSlug: session.TenantSlug,
|
||||||
|
InviteCode: session.InviteCode,
|
||||||
|
InviteNewUsersOnly: session.InviteNewUsersOnly,
|
||||||
|
AcceptTermsVersion: session.AcceptTermsVersion,
|
||||||
|
MarketingOptIn: session.MarketingOptIn,
|
||||||
|
Language: session.Language,
|
||||||
|
Provider: session.Provider,
|
||||||
|
RedirectURI: session.RedirectURI,
|
||||||
|
ClientIP: session.ClientIP,
|
||||||
|
UserAgent: session.UserAgent,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *registrationSessionUseCase) Delete(ctx context.Context, sessionID string) error {
|
||||||
|
if sessionID == "" {
|
||||||
|
return errb.InputMissingRequired("session_id is required")
|
||||||
|
}
|
||||||
|
if err := uc.store.Delete(ctx, sessionID); err != nil {
|
||||||
|
return wrapRepoErr(err, "delete registration session failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ domusecase.RegistrationSessionUseCase = (*registrationSessionUseCase)(nil)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
Loading…
Reference in New Issue