From 5d7b2e79ea5f23dfa7968c32118558072d16c52c Mon Sep 17 00:00:00 2001 From: warrenchen Date: Fri, 30 Jan 2026 16:57:29 +0900 Subject: [PATCH] Initial project --- .gitignore | 73 ++++++ README.md | 51 ++++ docs/DESIGN.md | 124 ++++++++++ docs/FLOWS.md | 47 ++++ docs/INSTALL.md | 16 ++ docs/OPENAPI.md | 18 ++ docs/SCHEMA.sql | 144 +++++++++++ docs/SEED.sql | 28 +++ docs/TECH_STACK.md | 15 ++ docs/UI.md | 35 +++ docs/USE_CASES.md | 30 +++ docs/openapi.yaml | 603 +++++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 1184 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/DESIGN.md create mode 100644 docs/FLOWS.md create mode 100644 docs/INSTALL.md create mode 100644 docs/OPENAPI.md create mode 100644 docs/SCHEMA.sql create mode 100644 docs/SEED.sql create mode 100644 docs/TECH_STACK.md create mode 100644 docs/UI.md create mode 100644 docs/USE_CASES.md create mode 100644 docs/openapi.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca73059 --- /dev/null +++ b/.gitignore @@ -0,0 +1,73 @@ +# Build results +bin/ +obj/ +[Bb]uild/ +[Bb]uilds/ + +# Visual Studio +.vs/ +*.suo +*.user +*.userosscache +*.sln.docstates +*.vcxproj.user +*.vcxproj.filters +*.vcxproj.user + +# Rider / JetBrains +.idea/ +*.sln.iml + +# VS Code +.vscode/ + +# Visual Studio Code local history +.history/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# NuGet +*.nupkg +*.snupkg +# The packages folder can be ignored because of PackageReference +# Uncomment if using packages.config +#packages/ + +# Logs +*.log + +# User-specific files +*.userprefs + +# Resharper / DotCover +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# Test results +TestResults/ +*.trx + +# Coverage +*.coverage +*.coveragexml +coverage/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# Secrets +secrets.json +appsettings.*.json +!appsettings.json + +# OS +.DS_Store +Thumbs.db + +# Others +*.swp +*.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ad64b0 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# 會員平台(Member Center) + +此專案為多網站共用的會員登入中心與電子報訂閱管理平台。文件統一放在 `docs/`。 + +## 目標(MVP) +- 多個網站共用的會員登入中心(SSO) +- OAuth2 + OIDC 登入(API 與 Redirect 兩種方式) +- 電子報訂閱管理(含未註冊訂閱) +- 未註冊訂閱者可自行取消訂閱 +- 未註冊訂閱者未來註冊可沿用訂閱資料 + +## 既定條件 +- OAuth2 + OIDC +- 會員中心只負責 Email/訂閱管理,發送系統另建 +- 訂閱採 double opt-in +- 各站各自設計 UI,主要走 API;少數狀況使用 redirect +- 會員資料跨站共享(邏輯隔離) +- 訂閱事件同步採 event/queue +- DB:PostgreSQL +- 開發:C# .NET Core + MVC + OpenIddict + +## 範圍(第一階段) +- 多租戶(站點)概念與基本設定 +- 會員與訂閱者 Email 身分識別 +- OAuth2/OIDC 授權(Authorization Code + PKCE) +- 訂閱/取消訂閱流程(含未註冊) +- 基本管理後台(核心 CRUD) + +## 非目標(暫不處理) +- 第三方社群登入 +- MFA +- 行銷自動化或行為追蹤 +- 金流/付費會員 + +## 文件 +- `docs/DESIGN.md` +- `docs/UI.md` +- `docs/USE_CASES.md` +- `docs/FLOWS.md` +- `docs/OPENAPI.md` +- `docs/openapi.yaml` +- `docs/SCHEMA.sql` +- `docs/TECH_STACK.md` +- `docs/SEED.sql` +- `docs/INSTALL.md` + +## 待確認事項 +- OAuth2 scopes 與最小 claims(email/profile) +- 事件系統選擇(Kafka/RabbitMQ/SNS+SQS) +- 取消訂閱的 UI 形式(純一鍵或提供偏好) +- GDPR/CCPA 資料匯出與刪除是否納入第一期 diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..ce8a87a --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,124 @@ +# 系統設計草案(Member Center) + +日期:2026-01-30 + +## 1. 需求摘要(已確認) +1) 多個網站共用的會員登入中心(SSO) +2) 電子報訂閱管理(發送另建) +3) 未註冊會員可訂閱;註冊後沿用訂閱資料 +4) 未註冊會員可取消訂閱(單一清單退訂) +5) 登入需支援 API 與 Redirect 兩種方式(OAuth2 + OIDC) + +## 2. 架構原則 +- OAuth2 + OIDC(Authorization Code + PKCE) +- 會員中心只管理 Email 與訂閱狀態 +- Double Opt-in +- 各站自行設計 UI,主要走 API;少數狀況使用 redirect +- 多租戶為邏輯隔離,但會員資料跨站共享 +- 訂閱狀態同步採 event/queue +- PostgreSQL +- 實作:C# .NET Core + MVC + OpenIddict + +## 3. 使用者故事(精簡) +- 訪客:在任一站點未登入狀態下輸入 Email 訂閱電子報 +- 訪客:在信件中點擊連結完成 double opt-in +- 訪客:點擊取消訂閱連結即可退訂(單一清單) +- 會員:在任一站點登入後,其他站點可無痛登入(SSO) +- 站點後台:可管理站點資訊、訂閱清單、會員基本資料 + +## 4. 核心模組 +- Identity Service:註冊、登入、密碼重設、Email 驗證 +- OAuth2/OIDC Service:授權流程、token 發放、ID Token +- Subscription Service:訂閱/退訂/偏好管理 +- Admin Console:租戶與清單管理 +- Mailer Integration:驗證信/退訂信/確認信的發送介面(外部系統) +- Event Publisher:訂閱事件發佈(供發信系統或數據系統消費) + +## 5. 資料模型(概念) +- tenants + - id, name, domains, status, created_at +- users + - id, email, password_hash, email_verified_at, status, created_at +- oauth_clients + - id, tenant_id, name, redirect_uris, client_type, created_at +- oauth_codes + - id, client_id, user_id, code_hash, code_challenge, expires_at, consumed_at +- oauth_tokens + - id, user_id, client_id, access_token_hash, refresh_token_hash, expires_at, revoked_at +- oidc_id_tokens + - id, user_id, client_id, id_token_hash, expires_at +- newsletter_lists + - id, tenant_id, name, status, created_at +- newsletter_subscriptions + - id, list_id, tenant_id, email, user_id (nullable), status, preferences, created_at +- email_verifications + - id, email, tenant_id, token_hash, purpose, expires_at, consumed_at +- unsubscribe_tokens + - id, subscription_id, token_hash, expires_at, consumed_at +- audit_logs + - id, actor_type, actor_id, action, payload, created_at + +關聯說明: +- newsletter_subscriptions.email 與 users.email 維持唯一性關聯 +- 使用者註冊時,如 email 存在訂閱紀錄,補上 user_id +- 單一清單退訂:unsubscribe token 綁定 subscription_id + +## 6. 核心流程 + +### 6.1 OAuth2/OIDC Redirect 登入(Authorization Code + PKCE) +1) 站點導向 `/oauth/authorize`,帶 `client_id`, `redirect_uri`, `code_challenge`, `scope=openid email` +2) 使用者於會員中心登入 +3) 成功後導回 `redirect_uri` 並附 `code` +4) 站點以 `code` + `code_verifier` 向 `/oauth/token` 換取 token + `id_token` + +### 6.2 OAuth2 API 使用(站點自行 UI) +1) 站點以 API 驗證使用者登入(會員中心提供 login API) +2) 成功後取得 token(含 ID Token 可選) +3) 站點以 access_token 呼叫其他會員中心 API + +### 6.3 未登入狀態的訂閱流程(在獨立平台) +1) 使用者在各站點輸入 Email 並選擇訂閱清單 +2) 站點呼叫 `POST /newsletter/subscribe` +3) 會員中心建立 `pending` 訂閱並發送驗證信(透過外部發信系統) +4) 使用者點擊信件連結 `/newsletter/confirm?token=...` +5) 訂閱狀態改為 `active` +6) 會員中心發出事件 `subscription.activated` 到 event/queue + +### 6.4 未登入退訂(單一清單) +1) 信件提供「一鍵退訂」連結 `/newsletter/unsubscribe?token=...` +2) 驗證 token 後將該訂閱標記為 `unsubscribed` +3) 會員中心發出事件 `subscription.unsubscribed` 到 event/queue + +### 6.5 註冊後銜接 +1) 使用者完成註冊 +2) 系統搜尋 `newsletter_subscriptions.email` +3) 將 `user_id` 補上並保留偏好 +4) 可選:發出事件 `subscription.linked_to_user` + +## 7. API 介面(草案) +- GET `/oauth/authorize` +- POST `/oauth/token` +- GET `/.well-known/openid-configuration` +- POST `/auth/login` (API-only login) +- POST `/auth/refresh` +- POST `/newsletter/subscribe` +- GET `/newsletter/confirm` +- POST `/newsletter/unsubscribe` +- GET `/newsletter/preferences` +- POST `/newsletter/preferences` + +## 8. 安全與合規 +- 密碼強度與防暴力破解(rate limit + lockout) +- Token rotation + refresh token revoke +- Redirect URI 白名單 + PKCE +- Double opt-in(可配置) +- Audit log +- GDPR/CCPA:資料匯出與刪除(規劃中) + +## 9. 其他文件 +- `docs/UI.md` +- `docs/USE_CASES.md` +- `docs/FLOWS.md` +- `docs/OPENAPI.md` +- `docs/SCHEMA.sql` +- `docs/TECH_STACK.md` diff --git a/docs/FLOWS.md b/docs/FLOWS.md new file mode 100644 index 0000000..5ab5d32 --- /dev/null +++ b/docs/FLOWS.md @@ -0,0 +1,47 @@ +# Flows + +流程以「API 自建 UI」與「會員中心統一 UI」兩種模式描述。 + +## F-01 註冊會員 +- [API] 站點送出 `POST /auth/register`(待補 API) +- [API] 會員中心建立 user、寄送驗證信 +- [UI] 導向會員中心註冊頁完成註冊 +- [UI] 會員中心寄送驗證信 + +## F-02 登入(OAuth2 + OIDC) +- [API] 站點送出 `POST /auth/login` 取得 access_token + id_token +- [API] 站點建立自身 session +- [UI] 導向 `/oauth/authorize` 完成授權碼流程 +- [UI] 站點用 code 換 token + id_token + +## F-03 忘記密碼 / 重設密碼 +- [API] 站點送出 `POST /auth/password/forgot` +- [UI] 會員中心頁提交 email 並發送重設信 +- [API/UI] 使用 token 進入重設密碼頁 + +## F-04 訂閱電子報(未登入) +- [API] 站點送出 `POST /newsletter/subscribe` +- [API] 會員中心建立 pending 訂閱並發送驗證信 +- [UI] 使用者點擊驗證信連結 `/newsletter/confirm?token=...` +- [UI] 訂閱改為 active,發出 event `subscription.activated` + +## F-05 取消訂閱(單一清單) +- [UI] 使用者點擊退訂連結 `/newsletter/unsubscribe?token=...` +- [UI] 訂閱狀態改為 unsubscribed +- [API] 發出 event `subscription.unsubscribed` + +## F-06 訂閱偏好管理(登入後) +- [API] 站點讀取 `/newsletter/preferences` +- [API] 站點更新 `/newsletter/preferences` +- [UI] 會員中心提供偏好頁(可選) + +## F-07 會員資料查看 +- [API] 站點讀取 `/user/profile` +- [UI] 會員中心提供個人資料頁 + +## F-08 管理者管理租戶/清單/Client +- [UI] 會員中心管理後台進行 CRUD + +## F-09 訂閱與會員綁定 +- [API] 使用者完成註冊後,會員中心將訂閱資料與 user_id 綁定 +- [API] 發送事件 `subscription.linked_to_user` diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..0be72ff --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,16 @@ +# 安裝與初始化(Schema + Seed) + +提供一個簡單安裝流程,將 DB schema 與種子資料一起初始化。 + +## 內容 +- Schema:`docs/SCHEMA.sql` +- Seed:`docs/SEED.sql` + +## 建議流程(範例) +1) 建立資料庫 +2) 執行 `SCHEMA.sql` +3) 執行 `SEED.sql`(包含預設 admin 角色與初始帳號) + +## 注意事項 +- `docs/SEED.sql` 內 `admin@example.com` 與 `` 為預設值,請部署前替換 +- 若使用 ASP.NET Core Identity,請使用對應的密碼雜湊格式 diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md new file mode 100644 index 0000000..87b0da1 --- /dev/null +++ b/docs/OPENAPI.md @@ -0,0 +1,18 @@ +# OpenAPI 草案(完整) + +已補上完整端點與資料結構,並提供 `docs/openapi.yaml` 作為可直接擴充的版本。 + +## 版本 +- OpenAPI: 3.1.0 +- 檔案:`docs/openapi.yaml` + +## 核心資源 +- OAuth2/OIDC:授權、token、discovery、JWKS +- Auth:註冊、登入、刷新、登出、忘記/重設密碼、Email 驗證 +- User:個人資料 +- Newsletter:訂閱/確認/退訂/偏好 +- Admin:Tenants/Lists/OAuth Clients(MVP CRUD) + +## Security Schemes +- OAuth2 (Authorization Code + PKCE) +- Bearer JWT(API 使用) diff --git a/docs/SCHEMA.sql b/docs/SCHEMA.sql new file mode 100644 index 0000000..2ce5b7d --- /dev/null +++ b/docs/SCHEMA.sql @@ -0,0 +1,144 @@ +-- PostgreSQL schema draft (MVP) + +CREATE TABLE tenants ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + domains TEXT[] NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE users ( + id UUID PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + email_verified_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE oauth_clients ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL REFERENCES tenants(id), + name TEXT NOT NULL, + redirect_uris TEXT[] NOT NULL DEFAULT '{}', + client_type TEXT NOT NULL DEFAULT 'confidential', + client_secret_hash TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE oauth_clients + ADD CONSTRAINT chk_oauth_clients_secret + CHECK ( + (client_type = 'confidential' AND client_secret_hash IS NOT NULL) + OR (client_type <> 'confidential') + ); + +CREATE TABLE oauth_codes ( + id UUID PRIMARY KEY, + client_id UUID NOT NULL REFERENCES oauth_clients(id), + user_id UUID NOT NULL REFERENCES users(id), + code_hash TEXT NOT NULL, + code_challenge TEXT NOT NULL, + code_challenge_method TEXT NOT NULL DEFAULT 'S256', + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ +); + +CREATE TABLE oauth_tokens ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + client_id UUID NOT NULL REFERENCES oauth_clients(id), + access_token_hash TEXT NOT NULL, + refresh_token_hash TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ +); + +CREATE TABLE oidc_id_tokens ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id), + client_id UUID NOT NULL REFERENCES oauth_clients(id), + id_token_hash TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE newsletter_lists ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL REFERENCES tenants(id), + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE newsletter_subscriptions ( + id UUID PRIMARY KEY, + list_id UUID NOT NULL REFERENCES newsletter_lists(id), + email TEXT NOT NULL, + user_id UUID REFERENCES users(id), + status TEXT NOT NULL DEFAULT 'pending', + preferences JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (list_id, email) +); + +CREATE TABLE email_verifications ( + id UUID PRIMARY KEY, + email TEXT NOT NULL, + tenant_id UUID NOT NULL REFERENCES tenants(id), + token_hash TEXT NOT NULL, + purpose TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ +); + +CREATE TABLE unsubscribe_tokens ( + id UUID PRIMARY KEY, + subscription_id UUID NOT NULL REFERENCES newsletter_subscriptions(id), + token_hash TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ +); + +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY, + actor_type TEXT NOT NULL, + actor_id UUID, + action TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE roles ( + id UUID PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE user_roles ( + user_id UUID NOT NULL REFERENCES users(id), + role_id UUID NOT NULL REFERENCES roles(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, role_id) +); + +CREATE INDEX idx_newsletter_subscriptions_email + ON newsletter_subscriptions(email); + +CREATE INDEX idx_newsletter_subscriptions_list_id + ON newsletter_subscriptions(list_id); + +CREATE INDEX idx_oauth_tokens_user_id + ON oauth_tokens(user_id); + +CREATE INDEX idx_oauth_tokens_client_id + ON oauth_tokens(client_id); + +CREATE INDEX idx_oauth_codes_client_id + ON oauth_codes(client_id); + +CREATE INDEX idx_email_verifications_email + ON email_verifications(email); + +CREATE INDEX idx_user_roles_role_id + ON user_roles(role_id); diff --git a/docs/SEED.sql b/docs/SEED.sql new file mode 100644 index 0000000..914d02a --- /dev/null +++ b/docs/SEED.sql @@ -0,0 +1,28 @@ +-- Seed data for roles and initial admin + +-- Roles +INSERT INTO roles (id, name) +VALUES + (gen_random_uuid(), 'admin'), + (gen_random_uuid(), 'support') +ON CONFLICT (name) DO NOTHING; + +-- Initial admin user (placeholder password hash) +-- Replace password_hash with your identity provider hash. +INSERT INTO users (id, email, password_hash, email_verified_at, status) +VALUES ( + gen_random_uuid(), + 'admin@example.com', + '', + now(), + 'active' +) +ON CONFLICT (email) DO NOTHING; + +-- Assign admin role to the admin user +INSERT INTO user_roles (user_id, role_id) +SELECT u.id, r.id +FROM users u +JOIN roles r ON r.name = 'admin' +WHERE u.email = 'admin@example.com' +ON CONFLICT DO NOTHING; diff --git a/docs/TECH_STACK.md b/docs/TECH_STACK.md new file mode 100644 index 0000000..4892d96 --- /dev/null +++ b/docs/TECH_STACK.md @@ -0,0 +1,15 @@ +# 技術堆疊(已選定) + +## 核心 +- 語言/框架:C# .NET Core + MVC +- OAuth2/OIDC:OpenIddict +- DB:PostgreSQL +- 事件同步:Event/Queue + +## 建議元件 +- ORM:EF Core + Npgsql +- Auth:ASP.NET Core Identity +- 背景任務:Hangfire 或 Quartz.NET +- 事件匯流:MassTransit + RabbitMQ/Kafka(依部署環境擇一) +- 測試:xUnit + Testcontainers +- 可觀測性:OpenTelemetry + Serilog diff --git a/docs/UI.md b/docs/UI.md new file mode 100644 index 0000000..c68f305 --- /dev/null +++ b/docs/UI.md @@ -0,0 +1,35 @@ +# UI 規劃(管理者 / 會員) + +## 會員中心(統一 UI) +### 會員端 +- 註冊 / 登入 / 忘記密碼 +- Email 驗證 +- 個人資料(Email 為主) +- 訂閱管理(清單與偏好) +- 退訂(單一清單) +- 連結外站(可選:回到來源站點) + +### 管理者端 +- 租戶管理(Tenant CRUD) +- OAuth Client 管理(redirect_uris / scopes) +- 電子報清單管理(Lists CRUD) +- 訂閱查詢 / 匯出 +- 審計紀錄查詢 +- 系統設定(安全策略、token 時效) + +## 各站自建 UI(API) +### 會員端 +- 登入 / 註冊 / 忘記密碼 +- Email 驗證頁(可自建或導回會員中心) +- 訂閱表單(未登入) +- 訂閱偏好管理(登入後) +- 退訂頁(從 email token 進來) + +### 管理者端 +- 站內顯示會員資料(只讀) +- 站內訂閱狀態顯示 + +## UI 核心原則 +- 會員中心 UI 為統一入口(少數情境) +- 其餘皆走 API 與各站自建 UI +- 會員中心 UI 不承擔行銷內容或寄送 diff --git a/docs/USE_CASES.md b/docs/USE_CASES.md new file mode 100644 index 0000000..02650ae --- /dev/null +++ b/docs/USE_CASES.md @@ -0,0 +1,30 @@ +# Use Cases + +標記: +- [API] 代表各站自建 UI,透過 API 完成 +- [UI] 代表使用會員中心統一 UI + +## 會員端 +- UC-01 註冊會員 [API/UI] +- UC-02 登入(取得 token) [API/UI] +- UC-03 登出 [API/UI] +- UC-04 忘記密碼 / 重設密碼 [API/UI] +- UC-05 Email 驗證 [API/UI] +- UC-06 訂閱電子報(未登入) [API] +- UC-07 訂閱確認(double opt-in) [UI] +- UC-08 取消訂閱(單一清單) [UI] +- UC-09 訂閱偏好管理(登入後) [API/UI] +- UC-10 會員資料查看(Email 為主) [API/UI] + +## 管理者端 +- UC-11 租戶管理 [UI] +- UC-12 OAuth Client 管理 [UI] +- UC-13 電子報清單管理 [UI] +- UC-14 訂閱查詢 / 匯出 [UI] +- UC-15 審計紀錄查詢 [UI] +- UC-16 安全策略設定(token 時效等) [UI] + +## 系統 / 整合 +- UC-17 發送訂閱事件(event/queue) [API] +- UC-18 發送退訂事件(event/queue) [API] +- UC-19 訂閱與會員綁定 [API] diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..64b51a1 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,603 @@ +openapi: 3.1.0 +info: + title: Member Center API + version: 0.1.0 + description: OAuth2/OIDC + Newsletter Subscription API +servers: + - url: /api +security: + - BearerAuth: [] + +paths: + /oauth/authorize: + get: + summary: OAuth2 Authorization Endpoint + description: Authorization Code + PKCE flow + parameters: + - in: query + name: client_id + required: true + schema: { type: string } + - in: query + name: redirect_uri + required: true + schema: { type: string } + - in: query + name: response_type + required: true + schema: { type: string, enum: [code] } + - in: query + name: scope + required: true + schema: { type: string } + - in: query + name: code_challenge + required: true + schema: { type: string } + - in: query + name: code_challenge_method + required: true + schema: { type: string, enum: [S256] } + - in: query + name: state + required: false + schema: { type: string } + responses: + '302': + description: Redirect to client with code + + /oauth/token: + post: + summary: OAuth2 Token Endpoint + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: [grant_type, code, redirect_uri, client_id, code_verifier] + properties: + grant_type: { type: string, enum: [authorization_code, refresh_token] } + code: { type: string } + redirect_uri: { type: string } + client_id: { type: string } + code_verifier: { type: string } + refresh_token: { type: string } + responses: + '200': + description: Token response + content: + application/json: + schema: + $ref: '#/components/schemas/TokenResponse' + + /.well-known/openid-configuration: + get: + summary: OIDC Discovery + responses: + '200': + description: OIDC discovery document + + /.well-known/jwks.json: + get: + summary: JWKS + responses: + '200': + description: JSON Web Key Set + + /auth/register: + post: + summary: Register user + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterRequest' + responses: + '200': + description: Registered + content: + application/json: + schema: + $ref: '#/components/schemas/UserProfile' + + /auth/login: + post: + summary: API login + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: Token response + content: + application/json: + schema: + $ref: '#/components/schemas/TokenResponse' + + /auth/refresh: + post: + summary: Refresh token + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RefreshRequest' + responses: + '200': + description: Token response + content: + application/json: + schema: + $ref: '#/components/schemas/TokenResponse' + + /auth/logout: + post: + summary: Logout (revoke refresh token) + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + refresh_token: + type: string + responses: + '204': + description: Logged out + + /auth/password/forgot: + post: + summary: Request password reset + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ForgotPasswordRequest' + responses: + '204': + description: Email sent + + /auth/password/reset: + post: + summary: Reset password + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ResetPasswordRequest' + responses: + '204': + description: Password reset + + /auth/email/verify: + get: + summary: Verify email + parameters: + - in: query + name: token + required: true + schema: { type: string } + responses: + '200': + description: Email verified + + /user/profile: + get: + summary: Get current user profile + security: + - BearerAuth: [] + responses: + '200': + description: Profile + content: + application/json: + schema: + $ref: '#/components/schemas/UserProfile' + + /newsletter/subscribe: + post: + summary: Subscribe (unauthenticated allowed) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SubscribeRequest' + responses: + '200': + description: Pending subscription + content: + application/json: + schema: + $ref: '#/components/schemas/Subscription' + + /newsletter/confirm: + get: + summary: Confirm subscription (double opt-in) + parameters: + - in: query + name: token + required: true + schema: { type: string } + responses: + '200': + description: Active subscription + content: + application/json: + schema: + $ref: '#/components/schemas/Subscription' + + /newsletter/unsubscribe: + post: + summary: Unsubscribe single list + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token] + properties: + token: { type: string } + responses: + '200': + description: Unsubscribed + content: + application/json: + schema: + $ref: '#/components/schemas/Subscription' + + /newsletter/preferences: + get: + summary: Get preferences + parameters: + - in: query + name: subscription_id + required: false + schema: { type: string } + - in: query + name: tenant_id + required: false + schema: { type: string } + - in: query + name: email + required: false + schema: { type: string, format: email } + responses: + '200': + description: Preferences + content: + application/json: + schema: + $ref: '#/components/schemas/Subscription' + + post: + summary: Update preferences + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [subscription_id, preferences] + properties: + subscription_id: { type: string } + preferences: { type: object } + responses: + '200': + description: Updated + content: + application/json: + schema: + $ref: '#/components/schemas/Subscription' + + /admin/tenants: + get: + summary: List tenants + security: [{ BearerAuth: [] }] + responses: + '200': + description: List + post: + summary: Create tenant + security: [{ BearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Tenant' + responses: + '201': + description: Created + + /admin/tenants/{id}: + get: + summary: Get tenant + security: [{ BearerAuth: [] }] + parameters: + - in: path + name: id + required: true + schema: { type: string } + responses: + '200': + description: Tenant + put: + summary: Update tenant + security: [{ BearerAuth: [] }] + parameters: + - in: path + name: id + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Tenant' + responses: + '200': + description: Updated + delete: + summary: Delete tenant + security: [{ BearerAuth: [] }] + parameters: + - in: path + name: id + required: true + schema: { type: string } + responses: + '204': + description: Deleted + + /admin/newsletter-lists: + get: + summary: List newsletter lists + security: [{ BearerAuth: [] }] + responses: + '200': + description: List + post: + summary: Create list + security: [{ BearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewsletterList' + responses: + '201': + description: Created + + /admin/newsletter-lists/{id}: + get: + summary: Get list + security: [{ BearerAuth: [] }] + parameters: + - in: path + name: id + required: true + schema: { type: string } + responses: + '200': + description: List + put: + summary: Update list + security: [{ BearerAuth: [] }] + parameters: + - in: path + name: id + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewsletterList' + responses: + '200': + description: Updated + delete: + summary: Delete list + security: [{ BearerAuth: [] }] + parameters: + - in: path + name: id + required: true + schema: { type: string } + responses: + '204': + description: Deleted + + /admin/oauth-clients: + get: + summary: List OAuth clients + security: [{ BearerAuth: [] }] + responses: + '200': + description: List + post: + summary: Create OAuth client + security: [{ BearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthClient' + responses: + '201': + description: Created + + /admin/oauth-clients/{id}: + get: + summary: Get OAuth client + security: [{ BearerAuth: [] }] + parameters: + - in: path + name: id + required: true + schema: { type: string } + responses: + '200': + description: OAuth client + put: + summary: Update OAuth client + security: [{ BearerAuth: [] }] + parameters: + - in: path + name: id + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OAuthClient' + responses: + '200': + description: Updated + delete: + summary: Delete OAuth client + security: [{ BearerAuth: [] }] + parameters: + - in: path + name: id + required: true + schema: { type: string } + responses: + '204': + description: Deleted + +components: + securitySchemes: + OAuth2: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: /api/oauth/authorize + tokenUrl: /api/oauth/token + scopes: + openid: OpenID Connect + email: Email + profile: Basic profile + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + schemas: + TokenResponse: + type: object + properties: + access_token: { type: string } + refresh_token: { type: string } + id_token: { type: string } + token_type: { type: string, example: Bearer } + expires_in: { type: integer } + + RegisterRequest: + type: object + required: [email, password] + properties: + email: { type: string, format: email } + password: { type: string } + + LoginRequest: + type: object + required: [email, password, client_id] + properties: + email: { type: string, format: email } + password: { type: string } + client_id: { type: string } + scope: { type: string } + + RefreshRequest: + type: object + required: [refresh_token, client_id] + properties: + refresh_token: { type: string } + client_id: { type: string } + + ForgotPasswordRequest: + type: object + required: [email] + properties: + email: { type: string, format: email } + + ResetPasswordRequest: + type: object + required: [token, new_password] + properties: + token: { type: string } + new_password: { type: string } + + UserProfile: + type: object + properties: + id: { type: string } + email: { type: string, format: email } + email_verified: { type: boolean } + created_at: { type: string, format: date-time } + + SubscribeRequest: + type: object + required: [tenant_id, list_id, email] + properties: + tenant_id: { type: string } + list_id: { type: string } + email: { type: string, format: email } + preferences: { type: object } + source: { type: string } + + Subscription: + type: object + properties: + id: { type: string } + tenant_id: { type: string } + list_id: { type: string } + email: { type: string, format: email } + status: { type: string, enum: [pending, active, unsubscribed] } + preferences: { type: object } + created_at: { type: string, format: date-time } + + Tenant: + type: object + properties: + id: { type: string } + name: { type: string } + domains: { type: array, items: { type: string } } + status: { type: string } + + NewsletterList: + type: object + properties: + id: { type: string } + tenant_id: { type: string } + name: { type: string } + status: { type: string } + + OAuthClient: + type: object + properties: + id: { type: string } + tenant_id: { type: string } + name: { type: string } + redirect_uris: { type: array, items: { type: string } } + client_type: { type: string, enum: [public, confidential] }