feat: Enhance OAuth client management and add one-click unsubscribe functionality
- Updated OpenAPI documentation to include new OAuth2 usage types: `send_api`. - Added endpoints for issuing one-click unsubscribe tokens in both single and batch requests. - Modified OAuth client creation and management to enforce new usage types and redirect URI requirements. - Implemented logic in the Newsletter service to handle one-click unsubscribe token issuance. - Updated UI to reflect changes in OAuth client usage options and redirect URI handling. - Enhanced token generation logic to support new scopes and audience settings for Send Engine.
This commit is contained in:
parent
5c7d4cdf5a
commit
4fbf2e5497
@ -1,5 +1,8 @@
|
|||||||
ASPNETCORE_ENVIRONMENT=Development
|
ASPNETCORE_ENVIRONMENT=Development
|
||||||
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
|
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
|
||||||
|
Auth__Issuer=http://localhost:7850/
|
||||||
|
Auth__MemberCenterAudience=member_center_api
|
||||||
|
Auth__SendEngineAudience=send_engine_api
|
||||||
SendEngine__BaseUrl=http://localhost:6060
|
SendEngine__BaseUrl=http://localhost:6060
|
||||||
SendEngine__WebhookSecret=change-me
|
SendEngine__WebhookSecret=change-me
|
||||||
Testing__DisableSubscriptionDryRunNoDb=false
|
Testing__DisableSubscriptionDryRunNoDb=false
|
||||||
|
|||||||
@ -41,7 +41,6 @@
|
|||||||
- `docs/openapi.yaml`:OpenAPI 3.1 正式規格檔
|
- `docs/openapi.yaml`:OpenAPI 3.1 正式規格檔
|
||||||
- `docs/SCHEMA.sql`:資料庫 schema(PostgreSQL)
|
- `docs/SCHEMA.sql`:資料庫 schema(PostgreSQL)
|
||||||
- `docs/SEED.sql`:初始化/測試資料
|
- `docs/SEED.sql`:初始化/測試資料
|
||||||
- `docs/SEND_ENGINE.md`:自建發送引擎摘要(規劃)
|
|
||||||
- `docs/MESSMAIL.md`:租戶站台介接指引(訂閱/退訂/發信)
|
- `docs/MESSMAIL.md`:租戶站台介接指引(訂閱/退訂/發信)
|
||||||
- `docs/TECH_STACK.md`:技術棧與選型
|
- `docs/TECH_STACK.md`:技術棧與選型
|
||||||
- `docs/INSTALL.md`:安裝、初始化與維運指令
|
- `docs/INSTALL.md`:安裝、初始化與維運指令
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
- 各站自行設計 UI,主要走 API;少數狀況使用 redirect
|
- 各站自行設計 UI,主要走 API;少數狀況使用 redirect
|
||||||
- 多租戶為邏輯隔離,但會員資料跨站共享
|
- 多租戶為邏輯隔離,但會員資料跨站共享
|
||||||
- 公開訂閱端點必須使用 `list_id + email` 做資料邊界,禁止僅以 `email` 查詢或操作
|
- 公開訂閱端點必須使用 `list_id + email` 做資料邊界,禁止僅以 `email` 查詢或操作
|
||||||
- 訂閱狀態同步採 event/queue
|
- 訂閱狀態同步目前採 webhook(event payload);queue 為後續可選擴充
|
||||||
- PostgreSQL
|
- PostgreSQL
|
||||||
- 實作:C# .NET Core + MVC + OpenIddict
|
- 實作:C# .NET Core + MVC + OpenIddict
|
||||||
|
|
||||||
@ -43,7 +43,7 @@
|
|||||||
- roles / user_roles (Identity)
|
- roles / user_roles (Identity)
|
||||||
- id, name, created_at
|
- id, name, created_at
|
||||||
- OpenIddictApplications
|
- OpenIddictApplications
|
||||||
- id, client_id, client_secret, display_name, permissions, redirect_uris, properties(含 `tenant_id`, `usage=tenant_api|webhook_outbound|platform_service`)
|
- id, client_id, client_secret, display_name, permissions, redirect_uris, properties(含 `tenant_id`, `usage=tenant_api|send_api|webhook_outbound|platform_service`)
|
||||||
- OpenIddictAuthorizations
|
- OpenIddictAuthorizations
|
||||||
- id, application_id, status, subject, type, scopes
|
- id, application_id, status, subject, type, scopes
|
||||||
- OpenIddictTokens
|
- OpenIddictTokens
|
||||||
@ -97,6 +97,12 @@
|
|||||||
2) 驗證 token 後將該訂閱標記為 `unsubscribed`
|
2) 驗證 token 後將該訂閱標記為 `unsubscribed`
|
||||||
3) 會員中心發出事件 `subscription.unsubscribed` 到 event/queue
|
3) 會員中心發出事件 `subscription.unsubscribed` 到 event/queue
|
||||||
|
|
||||||
|
### 6.4b Send Engine 發信前申請 One-Click Token
|
||||||
|
1) Send Engine 依收件者呼叫 `POST /newsletter/one-click-unsubscribe-token`,或批次呼叫 `POST /newsletter/one-click-unsubscribe-tokens`
|
||||||
|
2) body 帶 `tenant_id + list_id + subscriber_id`(批次版為 `subscriber_ids[]`)
|
||||||
|
3) 會員中心簽發 token(與手動退訂 token 分離 purpose)
|
||||||
|
4) Send Engine 將 token 寫入 `List-Unsubscribe` 連結
|
||||||
|
|
||||||
### 6.6 Send Engine 事件同步(Member Center → Send Engine)
|
### 6.6 Send Engine 事件同步(Member Center → Send Engine)
|
||||||
1) Member Center 發出事件(`subscription.activated` / `subscription.unsubscribed` / `preferences.updated`)
|
1) Member Center 發出事件(`subscription.activated` / `subscription.unsubscribed` / `preferences.updated`)
|
||||||
2) 以 webhook 推送至 Send Engine(簽章與重放防護)
|
2) 以 webhook 推送至 Send Engine(簽章與重放防護)
|
||||||
@ -130,6 +136,8 @@
|
|||||||
- GET `/newsletter/confirm`
|
- GET `/newsletter/confirm`
|
||||||
- POST `/newsletter/unsubscribe`
|
- POST `/newsletter/unsubscribe`
|
||||||
- POST `/newsletter/unsubscribe-token`
|
- POST `/newsletter/unsubscribe-token`
|
||||||
|
- POST `/newsletter/one-click-unsubscribe-token`
|
||||||
|
- POST `/newsletter/one-click-unsubscribe-tokens`
|
||||||
- GET `/newsletter/preferences`
|
- GET `/newsletter/preferences`
|
||||||
- POST `/newsletter/preferences`
|
- POST `/newsletter/preferences`
|
||||||
- POST `/webhooks/subscriptions`(Send Engine 端點,Member Center 呼叫)
|
- POST `/webhooks/subscriptions`(Send Engine 端點,Member Center 呼叫)
|
||||||
@ -137,24 +145,26 @@
|
|||||||
- POST `/subscriptions/disable`(Member Center 端點,Send Engine 呼叫)
|
- POST `/subscriptions/disable`(Member Center 端點,Send Engine 呼叫)
|
||||||
- POST `/integrations/send-engine/webhook-clients/upsert`(Member Center 端點,Send Engine 呼叫)
|
- POST `/integrations/send-engine/webhook-clients/upsert`(Member Center 端點,Send Engine 呼叫)
|
||||||
|
|
||||||
## 7.1 待新增 API / Auth(規劃中)
|
## 7.1 進度狀態(2026-02)
|
||||||
### API
|
### API
|
||||||
- `GET /newsletter/subscriptions?list_id=...`:供發送引擎同步訂閱清單
|
- `GET /newsletter/subscriptions?list_id=...`:已實作
|
||||||
- `POST /webhooks/subscriptions`:Member Center → Send Engine 事件推送
|
- `POST /webhooks/subscriptions`:已實作(Member Center 發送)
|
||||||
- `POST /webhooks/lists/full-sync`:Member Center → Send Engine 全量同步
|
- `POST /subscriptions/disable`:已實作
|
||||||
- `POST /subscriptions/disable`:Send Engine → Member Center 黑名單回寫
|
- `POST /integrations/send-engine/webhook-clients/upsert`:已實作
|
||||||
|
- `POST /webhooks/lists/full-sync`:尚未實作(保留規格)
|
||||||
|
|
||||||
### Auth / Scope
|
### Auth / Scope
|
||||||
- `tenant_api` / `webhook_outbound` OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界
|
- `tenant_api` / `send_api` / `webhook_outbound` OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界
|
||||||
- OAuth Client 需區分用途:`tenant_api` / `webhook_outbound` / `platform_service`(禁止混用)
|
- OAuth Client 需區分用途:`tenant_api` / `send_api` / `webhook_outbound` / `platform_service`(禁止混用)
|
||||||
- 新增 scope:`newsletter:list.read`、`newsletter:events.read`
|
- 新增 scope:`newsletter:list.read`、`newsletter:send.write`、`newsletter:send.read`、`newsletter:events.read`
|
||||||
- 新增 scope:`newsletter:events.write`
|
- 新增 scope:`newsletter:events.write`
|
||||||
- 新增 scope:`newsletter:events.write.global`
|
- 新增 scope:`newsletter:events.write.global`
|
||||||
|
- JWT Access Token 已改為 JWS(`DisableAccessTokenEncryption`),供 Send Engine 以 JWKS 驗簽
|
||||||
|
|
||||||
### 租戶端取 Token(Client Credentials)
|
### 租戶端取 Token(Client Credentials)
|
||||||
- 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token
|
- 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token
|
||||||
- token 內含 `tenant_id` 與 scope
|
- token 內含 `tenant_id` 與 scope
|
||||||
- Send Engine 收到租戶請求後以 JWKS 驗簽(建議)或向 Member Center 進行 introspection
|
- Send Engine 收到租戶請求後以 JWKS 驗簽 JWT(JWS)
|
||||||
- 驗簽通過後將 `tenant_id` 固定在 request context,不接受 body 覆寫
|
- 驗簽通過後將 `tenant_id` 固定在 request context,不接受 body 覆寫
|
||||||
|
|
||||||
## 8. 安全與合規
|
## 8. 安全與合規
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
流程以「API 自建 UI」與「會員中心統一 UI」兩種模式描述。
|
流程以「API 自建 UI」與「會員中心統一 UI」兩種模式描述。
|
||||||
|
|
||||||
## F-01 註冊會員
|
## F-01 註冊會員
|
||||||
- [API] 站點送出 `POST /auth/register`(待補 API)
|
- [API] 站點送出 `POST /auth/register`
|
||||||
- [API] 會員中心建立 user、寄送驗證信
|
- [API] 會員中心建立 user、寄送驗證信
|
||||||
- [UI] 導向會員中心註冊頁完成註冊
|
- [UI] 導向會員中心註冊頁完成註冊
|
||||||
- [UI] 會員中心寄送驗證信
|
- [UI] 會員中心寄送驗證信
|
||||||
@ -14,6 +14,12 @@
|
|||||||
- [UI] 導向 `/oauth/authorize` 完成授權碼流程
|
- [UI] 導向 `/oauth/authorize` 完成授權碼流程
|
||||||
- [UI] 站點用 code 換 token + id_token
|
- [UI] 站點用 code 換 token + id_token
|
||||||
|
|
||||||
|
## F-02b 內容站台呼叫 Send Engine(Client Credentials + JWT 驗簽)
|
||||||
|
- [API] 內容站台以 `client_credentials` 呼叫 `POST /oauth/token` 取得 access_token(`usage=send_api`)
|
||||||
|
- [API] 內容站台帶 Bearer token 呼叫 Send Engine 建立發送任務
|
||||||
|
- [API] Send Engine 以 Member Center JWKS 驗簽 token
|
||||||
|
- [API] 驗證 `scope/tenant_id/exp` 通過後才受理任務
|
||||||
|
|
||||||
## F-03 忘記密碼 / 重設密碼
|
## F-03 忘記密碼 / 重設密碼
|
||||||
- [API] 站點送出 `POST /auth/password/forgot`
|
- [API] 站點送出 `POST /auth/password/forgot`
|
||||||
- [UI] 會員中心頁提交 email 並發送重設信
|
- [UI] 會員中心頁提交 email 並發送重設信
|
||||||
@ -31,6 +37,12 @@
|
|||||||
- [UI] 訂閱狀態改為 unsubscribed
|
- [UI] 訂閱狀態改為 unsubscribed
|
||||||
- [API] 發出 event `subscription.unsubscribed`
|
- [API] 發出 event `subscription.unsubscribed`
|
||||||
|
|
||||||
|
## F-05b One-Click 退訂 Token(Send Engine 發信前)
|
||||||
|
- [API] Send Engine 以 `tenant_id + list_id + subscriber_id` 呼叫 `POST /newsletter/one-click-unsubscribe-token`
|
||||||
|
- [API] 或以 `tenant_id + list_id + subscriber_ids[]` 呼叫 `POST /newsletter/one-click-unsubscribe-tokens` 批次取得
|
||||||
|
- [API] Member Center 回傳 one-click `unsubscribe_token`
|
||||||
|
- [API] Send Engine 將 token 置入信件 `List-Unsubscribe` URL
|
||||||
|
|
||||||
## F-06 訂閱偏好管理(登入後)
|
## F-06 訂閱偏好管理(登入後)
|
||||||
- [API] 站點以 `list_id + email` 讀取 `/newsletter/preferences`
|
- [API] 站點以 `list_id + email` 讀取 `/newsletter/preferences`
|
||||||
- [API] 站點以 `list_id + email` 更新 `/newsletter/preferences`
|
- [API] 站點以 `list_id + email` 更新 `/newsletter/preferences`
|
||||||
|
|||||||
@ -40,6 +40,9 @@
|
|||||||
```
|
```
|
||||||
ASPNETCORE_ENVIRONMENT=Development
|
ASPNETCORE_ENVIRONMENT=Development
|
||||||
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
|
ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgres;Password=postgres
|
||||||
|
Auth__Issuer=http://localhost:7850/
|
||||||
|
Auth__MemberCenterAudience=member_center_api
|
||||||
|
Auth__SendEngineAudience=send_engine_api
|
||||||
SendEngine__BaseUrl=http://localhost:6060
|
SendEngine__BaseUrl=http://localhost:6060
|
||||||
SendEngine__WebhookSecret=change-me
|
SendEngine__WebhookSecret=change-me
|
||||||
```
|
```
|
||||||
|
|||||||
@ -53,7 +53,7 @@ End User
|
|||||||
租戶站台沒有真人帳號,使用 **Client Credentials** 取得 access token。
|
租戶站台沒有真人帳號,使用 **Client Credentials** 取得 access token。
|
||||||
|
|
||||||
- 向 Member Center 申請 OAuth Client
|
- 向 Member Center 申請 OAuth Client
|
||||||
- OAuth Client 必須使用 `usage=tenant_api`(不可使用 `webhook_outbound`)
|
- OAuth Client 建議使用 `usage=send_api`(專供發送);`tenant_api` 留給其他租戶 API
|
||||||
- scope 依用途最小化
|
- scope 依用途最小化
|
||||||
|
|
||||||
建議 scopes:
|
建議 scopes:
|
||||||
@ -77,6 +77,12 @@ curl -s -X POST https://{send-engine}/api/send-jobs \
|
|||||||
-d '{ \"tenant_id\": \"<TENANT_ID>\", \"list_id\": \"<LIST_ID>\", \"subject\": \"Weekly\", \"body_text\": \"Hello\" }'
|
-d '{ \"tenant_id\": \"<TENANT_ID>\", \"list_id\": \"<LIST_ID>\", \"subject\": \"Weekly\", \"body_text\": \"Hello\" }'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Send Engine 驗證 Token(JWT / JWKS)
|
||||||
|
- Send Engine 以 Member Center 的 JWKS 驗簽 access token(JWS)。
|
||||||
|
- 驗證重點:`iss`、`aud`、`scope`、`tenant_id`、`exp`。
|
||||||
|
- `iss`:由 Member Center `Auth__Issuer` 設定(例:`http://localhost:7850/`)
|
||||||
|
- `aud`:Send Engine 流程預設 `send_engine_api`(可用 `Auth__SendEngineAudience` 覆寫)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. API 表格(租戶站台 → Member Center)
|
## 3. API 表格(租戶站台 → Member Center)
|
||||||
@ -90,6 +96,8 @@ curl -s -X POST https://{send-engine}/api/send-jobs \
|
|||||||
| 讀取偏好 | GET | `/newsletter/preferences` | `list_id`, `email` | 需 list_id + email |
|
| 讀取偏好 | GET | `/newsletter/preferences` | `list_id`, `email` | 需 list_id + email |
|
||||||
| 更新偏好 | POST | `/newsletter/preferences` | `list_id`, `email`, `preferences` | 需 list_id + email |
|
| 更新偏好 | POST | `/newsletter/preferences` | `list_id`, `email`, `preferences` | 需 list_id + email |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 訂閱(範例)
|
### 訂閱(範例)
|
||||||
```bash
|
```bash
|
||||||
curl -s -X POST https://{member-center}/newsletter/subscribe \
|
curl -s -X POST https://{member-center}/newsletter/subscribe \
|
||||||
|
|||||||
@ -14,14 +14,17 @@
|
|||||||
- Admin:Tenants/Lists/OAuth Clients(MVP CRUD)
|
- Admin:Tenants/Lists/OAuth Clients(MVP CRUD)
|
||||||
|
|
||||||
## Security Schemes
|
## Security Schemes
|
||||||
- OAuth2 (Authorization Code + PKCE)
|
- OAuth2 (Authorization Code + PKCE、Client Credentials)
|
||||||
- Bearer JWT(API 使用)
|
- Bearer JWT(API 使用)
|
||||||
|
|
||||||
## 補充說明
|
## 補充說明
|
||||||
- `/oauth/token`、`/auth/login`、`/auth/refresh` 使用 `application/x-www-form-urlencoded`
|
- `/oauth/token`、`/auth/login`、`/auth/refresh` 使用 `application/x-www-form-urlencoded`
|
||||||
|
- Access token 以 JWT(JWS)簽發,建議驗證 `iss` 與 `aud`
|
||||||
- `/auth/email/verify` 需要 `token` + `email`
|
- `/auth/email/verify` 需要 `token` + `email`
|
||||||
- `/newsletter/subscribe` 會回傳 `confirm_token`
|
- `/newsletter/subscribe` 會回傳 `confirm_token`
|
||||||
- `/newsletter/unsubscribe-token` 需要 `list_id + email` 才能申請 `unsubscribe_token`
|
- `/newsletter/unsubscribe-token` 需要 `list_id + email` 才能申請 `unsubscribe_token`
|
||||||
|
- `/newsletter/one-click-unsubscribe-token` 提供 Send Engine 發信前取得 one-click 退訂 token(`tenant_id + list_id + subscriber_id`)
|
||||||
|
- `/newsletter/one-click-unsubscribe-tokens` 提供 Send Engine 批次取得 one-click 退訂 token(`tenant_id + list_id + subscriber_ids[]`)
|
||||||
- `/newsletter/preferences`(GET/POST)需要 `list_id + email`,避免跨租戶資料讀取/更新
|
- `/newsletter/preferences`(GET/POST)需要 `list_id + email`,避免跨租戶資料讀取/更新
|
||||||
|
|
||||||
## 通用欄位
|
## 通用欄位
|
||||||
@ -65,14 +68,19 @@
|
|||||||
- `usage=tenant_api`:
|
- `usage=tenant_api`:
|
||||||
- 供租戶站台拿 token 呼叫 Member Center / Send Engine API
|
- 供租戶站台拿 token 呼叫 Member Center / Send Engine API
|
||||||
- scope 僅給業務所需(如 `newsletter:events.write`)
|
- scope 僅給業務所需(如 `newsletter:events.write`)
|
||||||
|
- `usage=send_api`:
|
||||||
|
- 供租戶站台呼叫 Send Engine 發信流程
|
||||||
|
- 內建 scope:`newsletter:send.write`、`newsletter:send.read`
|
||||||
- `usage=webhook_outbound`:
|
- `usage=webhook_outbound`:
|
||||||
- 供 Member Center 內部標記「對外 webhook 用」的租戶憑證用途
|
- 供 Member Center 內部標記「對外 webhook 用」的租戶憑證用途
|
||||||
- 不可用於租戶 API 呼叫
|
- 不可用於租戶 API 呼叫
|
||||||
- `X-Client-Id` 仍以 Send Engine `auth_clients.id` 為準
|
- `X-Client-Id` 仍以 Send Engine `auth_clients.id` 為準
|
||||||
|
- 需設定 `redirect_uris`(Authorization Code 流程)
|
||||||
- `usage=platform_service`:
|
- `usage=platform_service`:
|
||||||
- 供平台級 S2S(例如 SES 聚合事件回寫)
|
- 供平台級 S2S(例如 SES 聚合事件回寫)
|
||||||
- 可不綁定 `tenant_id`,scope 使用 `newsletter:events.write.global`
|
- 可不綁定 `tenant_id`,scope 使用 `newsletter:events.write.global`
|
||||||
- `tenant_api` / `platform_service` 建議(且實作要求)`client_type=confidential`
|
- `tenant_api` / `send_api` / `platform_service` 建議(且實作要求)`client_type=confidential`
|
||||||
|
- `redirect_uris` 僅 `webhook_outbound` 需要;其他 usage 可為空
|
||||||
- 管理規則:
|
- 管理規則:
|
||||||
- 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`)
|
- 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`)
|
||||||
- 平台級流程另建 `platform_service` 憑證
|
- 平台級流程另建 `platform_service` 憑證
|
||||||
@ -85,12 +93,16 @@
|
|||||||
- `POST /webhooks/lists/full-sync`:規格已定義(Member Center 發送;Send Engine 接收)
|
- `POST /webhooks/lists/full-sync`:規格已定義(Member Center 發送;Send Engine 接收)
|
||||||
- `POST /subscriptions/disable`:已實作(Send Engine 回寫黑名單)
|
- `POST /subscriptions/disable`:已實作(Send Engine 回寫黑名單)
|
||||||
- `POST /integrations/send-engine/webhook-clients/upsert`:已實作(Send Engine 回填 tenant webhook client id)
|
- `POST /integrations/send-engine/webhook-clients/upsert`:已實作(Send Engine 回填 tenant webhook client id)
|
||||||
|
- `POST /newsletter/one-click-unsubscribe-token`:已實作(Send Engine 發信前申請 one-click token)
|
||||||
|
- `POST /newsletter/one-click-unsubscribe-tokens`:已實作(Send Engine 批次申請 one-click token)
|
||||||
|
|
||||||
### Auth / Scope
|
### Auth / Scope
|
||||||
- `tenant_api` / `webhook_outbound` 類型需綁定 `tenant_id`
|
- `tenant_api` / `send_api` / `webhook_outbound` 類型需綁定 `tenant_id`
|
||||||
- `platform_service` 可不綁定 `tenant_id`
|
- `platform_service` 可不綁定 `tenant_id`
|
||||||
- 新增 scope:
|
- 新增 scope:
|
||||||
- `newsletter:list.read`
|
- `newsletter:list.read`
|
||||||
|
- `newsletter:send.write`
|
||||||
|
- `newsletter:send.read`
|
||||||
- `newsletter:events.read`
|
- `newsletter:events.read`
|
||||||
- `newsletter:events.write`
|
- `newsletter:events.write`
|
||||||
- `newsletter:events.write.global`
|
- `newsletter:events.write.global`
|
||||||
@ -99,6 +111,11 @@
|
|||||||
- `newsletter:events.write`(tenant-scoped)
|
- `newsletter:events.write`(tenant-scoped)
|
||||||
- `newsletter:events.write.global`(platform-scoped,SES 回寫用)
|
- `newsletter:events.write.global`(platform-scoped,SES 回寫用)
|
||||||
- 建議 Send Engine 使用 client credentials 取 token,不建議使用長效固定 token
|
- 建議 Send Engine 使用 client credentials 取 token,不建議使用長效固定 token
|
||||||
|
- Send Engine 建議以 JWKS 驗簽 JWT(JWS),並驗證 `scope/tenant_id/exp`
|
||||||
|
- `iss` 由 `Auth:Issuer` 設定(例:`http://localhost:7850/`)
|
||||||
|
- `aud` 預設:
|
||||||
|
- Send Engine 流程:`send_engine_api`(可用 `Auth:SendEngineAudience` 覆寫)
|
||||||
|
- Member Center API 流程:`member_center_api`(可用 `Auth:MemberCenterAudience` 覆寫)
|
||||||
|
|
||||||
### 回寫原因碼(Send Engine -> Member Center)
|
### 回寫原因碼(Send Engine -> Member Center)
|
||||||
- `hard_bounce`
|
- `hard_bounce`
|
||||||
@ -115,3 +132,46 @@
|
|||||||
- `occurred_at`(RFC3339)
|
- `occurred_at`(RFC3339)
|
||||||
|
|
||||||
Member Center 會用 `subscriber_id + list_id` 查詢訂閱,再驗證 `tenant_id` 邊界;驗證通過後才寫入全域 email 黑名單。
|
Member Center 會用 `subscriber_id + list_id` 查詢訂閱,再驗證 `tenant_id` 邊界;驗證通過後才寫入全域 email 黑名單。
|
||||||
|
|
||||||
|
### One-Click 退訂 Token 介接方式(Send Engine)
|
||||||
|
用途:Send Engine 在寄信前批次向 Member Center 取得每位收件者的 one-click 退訂 token。
|
||||||
|
|
||||||
|
步驟:
|
||||||
|
1. 以 client credentials 取得 access token(scope:`newsletter:events.write` 或 `newsletter:events.write.global`)
|
||||||
|
2. 呼叫 `POST /newsletter/one-click-unsubscribe-tokens`
|
||||||
|
3. 將回傳 `status=issued` 的 `unsubscribe_token` 寫入每封信的 `List-Unsubscribe` URL
|
||||||
|
|
||||||
|
Request(批次):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tenant_id": "c9034414-43d6-404e-8d41-e80922420bf1",
|
||||||
|
"list_id": "a92fdeda-29bb-42ca-9c05-e7df3983288a",
|
||||||
|
"subscriber_ids": [
|
||||||
|
"33333333-3333-3333-3333-333333333333",
|
||||||
|
"44444444-4444-4444-4444-444444444444"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response(批次):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"subscriber_id": "33333333-3333-3333-3333-333333333333",
|
||||||
|
"unsubscribe_token": "token-xxx",
|
||||||
|
"status": "issued"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"subscriber_id": "44444444-4444-4444-4444-444444444444",
|
||||||
|
"unsubscribe_token": null,
|
||||||
|
"status": "blacklisted"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
狀態說明:
|
||||||
|
- `issued`:可直接用於 one-click 退訂連結
|
||||||
|
- `not_found`:找不到對應訂閱者(tenant/list/subscriber 邊界不匹配)
|
||||||
|
- `blacklisted`:已在黑名單,不提供 token
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
### 管理者端
|
### 管理者端
|
||||||
- 租戶管理(Tenant CRUD)
|
- 租戶管理(Tenant CRUD)
|
||||||
- OAuth Client 管理(usage / redirect_uris / scopes / client_id / client_secret)
|
- OAuth Client 管理(usage / redirect_uris / client_id / client_secret;scope 由 usage 自動配置)
|
||||||
- 電子報清單管理(Lists CRUD)
|
- 電子報清單管理(Lists CRUD)
|
||||||
- 訂閱查詢 / 匯出
|
- 訂閱查詢 / 匯出
|
||||||
- 審計紀錄查詢
|
- 審計紀錄查詢
|
||||||
@ -49,7 +49,9 @@
|
|||||||
### 管理者端(統一 UI)
|
### 管理者端(統一 UI)
|
||||||
- UC-11 租戶管理: `/admin/tenants`
|
- UC-11 租戶管理: `/admin/tenants`
|
||||||
- UC-11.1 Tenant 可設定 `Send Engine Webhook Client Id`(UUID)
|
- UC-11.1 Tenant 可設定 `Send Engine Webhook Client Id`(UUID)
|
||||||
- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret,可旋轉;可選 `usage=tenant_api` / `webhook_outbound` / `platform_service`;`platform_service` 可不指定 tenant)
|
- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret,可旋轉;可選 `usage=tenant_api` / `send_api` / `webhook_outbound` / `platform_service`;`platform_service` 可不指定 tenant)
|
||||||
|
- `redirect_uris` 僅 `webhook_outbound` 需要;其餘 usage 不需要
|
||||||
|
- `tenant_api` / `send_api` / `platform_service` 強制 `client_type=confidential`
|
||||||
- UC-13 電子報清單管理: `/admin/newsletter-lists`
|
- UC-13 電子報清單管理: `/admin/newsletter-lists`
|
||||||
- UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export`
|
- UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export`
|
||||||
- UC-15 審計紀錄查詢: `/admin/audit-logs`
|
- UC-15 審計紀錄查詢: `/admin/audit-logs`
|
||||||
|
|||||||
@ -4,7 +4,7 @@ info:
|
|||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
description: OAuth2/OIDC + Newsletter Subscription API
|
description: OAuth2/OIDC + Newsletter Subscription API
|
||||||
servers:
|
servers:
|
||||||
- url: /api
|
- url: /
|
||||||
security:
|
security:
|
||||||
- BearerAuth: []
|
- BearerAuth: []
|
||||||
|
|
||||||
@ -59,6 +59,7 @@ paths:
|
|||||||
oneOf:
|
oneOf:
|
||||||
- $ref: '#/components/schemas/AuthorizationCodeTokenRequest'
|
- $ref: '#/components/schemas/AuthorizationCodeTokenRequest'
|
||||||
- $ref: '#/components/schemas/RefreshTokenRequest'
|
- $ref: '#/components/schemas/RefreshTokenRequest'
|
||||||
|
- $ref: '#/components/schemas/ClientCredentialsTokenRequest'
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Token response
|
description: Token response
|
||||||
@ -74,7 +75,7 @@ paths:
|
|||||||
'200':
|
'200':
|
||||||
description: OIDC discovery document
|
description: OIDC discovery document
|
||||||
|
|
||||||
/.well-known/jwks.json:
|
/.well-known/jwks:
|
||||||
get:
|
get:
|
||||||
summary: JWKS
|
summary: JWKS
|
||||||
responses:
|
responses:
|
||||||
@ -339,6 +340,77 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/newsletter/one-click-unsubscribe-token:
|
||||||
|
post:
|
||||||
|
summary: Issue one-click unsubscribe token (Send Engine pre-send)
|
||||||
|
security: [{ BearerAuth: [] }]
|
||||||
|
description: Requires scope `newsletter:events.write` or `newsletter:events.write.global`.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/IssueOneClickUnsubscribeTokenRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Unsubscribe token issued
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
unsubscribe_token: { type: string }
|
||||||
|
'400':
|
||||||
|
description: Bad request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
'403':
|
||||||
|
description: Forbidden
|
||||||
|
'404':
|
||||||
|
description: Not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/newsletter/one-click-unsubscribe-tokens:
|
||||||
|
post:
|
||||||
|
summary: Issue one-click unsubscribe tokens in batch (Send Engine pre-send)
|
||||||
|
security: [{ BearerAuth: [] }]
|
||||||
|
description: Requires scope `newsletter:events.write` or `newsletter:events.write.global`.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/IssueOneClickUnsubscribeTokensRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Unsubscribe tokens issued
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/OneClickUnsubscribeTokenItem'
|
||||||
|
'400':
|
||||||
|
description: Bad request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'401':
|
||||||
|
description: Unauthorized
|
||||||
|
'403':
|
||||||
|
description: Forbidden
|
||||||
|
|
||||||
/newsletter/preferences:
|
/newsletter/preferences:
|
||||||
get:
|
get:
|
||||||
summary: Get preferences
|
summary: Get preferences
|
||||||
@ -739,13 +811,24 @@ components:
|
|||||||
type: oauth2
|
type: oauth2
|
||||||
flows:
|
flows:
|
||||||
authorizationCode:
|
authorizationCode:
|
||||||
authorizationUrl: /api/oauth/authorize
|
authorizationUrl: /oauth/authorize
|
||||||
tokenUrl: /api/oauth/token
|
tokenUrl: /oauth/token
|
||||||
scopes:
|
scopes:
|
||||||
openid: OpenID Connect
|
openid: OpenID Connect
|
||||||
email: Email
|
email: Email
|
||||||
profile: Basic profile
|
profile: Basic profile
|
||||||
newsletter:list.read: Read newsletter subscriptions by list
|
newsletter:list.read: Read newsletter subscriptions by list
|
||||||
|
newsletter:send.write: Create/send newsletter jobs
|
||||||
|
newsletter:send.read: Read newsletter send status
|
||||||
|
newsletter:events.read: Read newsletter events
|
||||||
|
newsletter:events.write: Write newsletter events (tenant scoped)
|
||||||
|
newsletter:events.write.global: Write newsletter events (platform scoped)
|
||||||
|
clientCredentials:
|
||||||
|
tokenUrl: /oauth/token
|
||||||
|
scopes:
|
||||||
|
newsletter:list.read: Read newsletter subscriptions by list
|
||||||
|
newsletter:send.write: Create/send newsletter jobs
|
||||||
|
newsletter:send.read: Read newsletter send status
|
||||||
newsletter:events.read: Read newsletter events
|
newsletter:events.read: Read newsletter events
|
||||||
newsletter:events.write: Write newsletter events (tenant scoped)
|
newsletter:events.write: Write newsletter events (tenant scoped)
|
||||||
newsletter:events.write.global: Write newsletter events (platform scoped)
|
newsletter:events.write.global: Write newsletter events (platform scoped)
|
||||||
@ -806,6 +889,15 @@ components:
|
|||||||
refresh_token: { type: string }
|
refresh_token: { type: string }
|
||||||
client_id: { type: string }
|
client_id: { type: string }
|
||||||
|
|
||||||
|
ClientCredentialsTokenRequest:
|
||||||
|
type: object
|
||||||
|
required: [grant_type, client_id, client_secret]
|
||||||
|
properties:
|
||||||
|
grant_type: { type: string, enum: [client_credentials] }
|
||||||
|
client_id: { type: string }
|
||||||
|
client_secret: { type: string }
|
||||||
|
scope: { type: string }
|
||||||
|
|
||||||
ForgotPasswordRequest:
|
ForgotPasswordRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [email]
|
required: [email]
|
||||||
@ -867,6 +959,31 @@ components:
|
|||||||
disabled_by: { type: string }
|
disabled_by: { type: string }
|
||||||
occurred_at: { type: string, format: date-time }
|
occurred_at: { type: string, format: date-time }
|
||||||
|
|
||||||
|
IssueOneClickUnsubscribeTokenRequest:
|
||||||
|
type: object
|
||||||
|
required: [tenant_id, list_id, subscriber_id]
|
||||||
|
properties:
|
||||||
|
tenant_id: { type: string, format: uuid }
|
||||||
|
list_id: { type: string, format: uuid }
|
||||||
|
subscriber_id: { type: string, format: uuid }
|
||||||
|
|
||||||
|
IssueOneClickUnsubscribeTokensRequest:
|
||||||
|
type: object
|
||||||
|
required: [tenant_id, list_id, subscriber_ids]
|
||||||
|
properties:
|
||||||
|
tenant_id: { type: string, format: uuid }
|
||||||
|
list_id: { type: string, format: uuid }
|
||||||
|
subscriber_ids:
|
||||||
|
type: array
|
||||||
|
items: { type: string, format: uuid }
|
||||||
|
|
||||||
|
OneClickUnsubscribeTokenItem:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
subscriber_id: { type: string, format: uuid }
|
||||||
|
unsubscribe_token: { type: string, nullable: true }
|
||||||
|
status: { type: string, enum: [issued, not_found, blacklisted] }
|
||||||
|
|
||||||
ErrorResponse:
|
ErrorResponse:
|
||||||
type: object
|
type: object
|
||||||
required: [error, message, request_id]
|
required: [error, message, request_id]
|
||||||
@ -949,6 +1066,6 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
name: { type: string }
|
name: { type: string }
|
||||||
usage: { type: string, enum: [tenant_api, webhook_outbound, platform_service] }
|
usage: { type: string, enum: [tenant_api, send_api, webhook_outbound, platform_service] }
|
||||||
redirect_uris: { type: array, items: { type: string } }
|
redirect_uris: { type: array, items: { type: string } }
|
||||||
client_type: { type: string, enum: [public, confidential] }
|
client_type: { type: string, enum: [public, confidential] }
|
||||||
|
|||||||
@ -4,4 +4,4 @@ public sealed record TenantRequest(string Name, List<string> Domains, string Sta
|
|||||||
|
|
||||||
public sealed record NewsletterListRequest(Guid TenantId, string Name, string Status);
|
public sealed record NewsletterListRequest(Guid TenantId, string Name, string Status);
|
||||||
|
|
||||||
public sealed record OAuthClientRequest(Guid? TenantId, string Name, List<string> RedirectUris, string ClientType, string Usage = "tenant_api");
|
public sealed record OAuthClientRequest(Guid? TenantId, string Name, List<string>? RedirectUris, string ClientType, string Usage = "tenant_api");
|
||||||
|
|||||||
@ -15,6 +15,16 @@ public sealed record IssueUnsubscribeTokenRequest(
|
|||||||
[property: JsonPropertyName("list_id")] Guid ListId,
|
[property: JsonPropertyName("list_id")] Guid ListId,
|
||||||
[property: JsonPropertyName("email")] string Email);
|
[property: JsonPropertyName("email")] string Email);
|
||||||
|
|
||||||
|
public sealed record IssueOneClickUnsubscribeTokenRequest(
|
||||||
|
[property: JsonPropertyName("tenant_id")] Guid TenantId,
|
||||||
|
[property: JsonPropertyName("list_id")] Guid ListId,
|
||||||
|
[property: JsonPropertyName("subscriber_id")] Guid SubscriberId);
|
||||||
|
|
||||||
|
public sealed record IssueOneClickUnsubscribeTokensRequest(
|
||||||
|
[property: JsonPropertyName("tenant_id")] Guid TenantId,
|
||||||
|
[property: JsonPropertyName("list_id")] Guid ListId,
|
||||||
|
[property: JsonPropertyName("subscriber_ids")] List<Guid> SubscriberIds);
|
||||||
|
|
||||||
public sealed record UpdatePreferencesRequest(
|
public sealed record UpdatePreferencesRequest(
|
||||||
[property: JsonPropertyName("list_id")] Guid ListId,
|
[property: JsonPropertyName("list_id")] Guid ListId,
|
||||||
[property: JsonPropertyName("email")] string Email,
|
[property: JsonPropertyName("email")] string Email,
|
||||||
|
|||||||
@ -43,7 +43,7 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
{
|
{
|
||||||
if (!IsValidUsage(request.Usage))
|
if (!IsValidUsage(request.Usage))
|
||||||
{
|
{
|
||||||
return BadRequest("usage must be tenant_api, webhook_outbound, or platform_service.");
|
return BadRequest("usage must be tenant_api, send_api, webhook_outbound, or platform_service.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
|
if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
|
||||||
@ -57,6 +57,16 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
return BadRequest("client_type must be confidential for this usage.");
|
return BadRequest("client_type must be confidential for this usage.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (redirectUris, redirectUriError) = NormalizeRedirectUris(request.RedirectUris);
|
||||||
|
if (!string.IsNullOrWhiteSpace(redirectUriError))
|
||||||
|
{
|
||||||
|
return BadRequest(redirectUriError);
|
||||||
|
}
|
||||||
|
if (UsesAuthorizationCodeFlow(request.Usage) && redirectUris.Count == 0)
|
||||||
|
{
|
||||||
|
return BadRequest("redirect_uris is required for webhook_outbound usage.");
|
||||||
|
}
|
||||||
|
|
||||||
var descriptor = new OpenIddictApplicationDescriptor
|
var descriptor = new OpenIddictApplicationDescriptor
|
||||||
{
|
{
|
||||||
ClientId = Guid.NewGuid().ToString("N"),
|
ClientId = Guid.NewGuid().ToString("N"),
|
||||||
@ -65,7 +75,7 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
};
|
};
|
||||||
ApplyPermissions(descriptor, request.Usage);
|
ApplyPermissions(descriptor, request.Usage);
|
||||||
|
|
||||||
foreach (var uri in request.RedirectUris)
|
foreach (var uri in redirectUris)
|
||||||
{
|
{
|
||||||
descriptor.RedirectUris.Add(new Uri(uri));
|
descriptor.RedirectUris.Add(new Uri(uri));
|
||||||
}
|
}
|
||||||
@ -112,7 +122,7 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
{
|
{
|
||||||
if (!IsValidUsage(request.Usage))
|
if (!IsValidUsage(request.Usage))
|
||||||
{
|
{
|
||||||
return BadRequest("usage must be tenant_api, webhook_outbound, or platform_service.");
|
return BadRequest("usage must be tenant_api, send_api, webhook_outbound, or platform_service.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
|
if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty))
|
||||||
@ -126,6 +136,16 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
return BadRequest("client_type must be confidential for this usage.");
|
return BadRequest("client_type must be confidential for this usage.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (redirectUris, redirectUriError) = NormalizeRedirectUris(request.RedirectUris);
|
||||||
|
if (!string.IsNullOrWhiteSpace(redirectUriError))
|
||||||
|
{
|
||||||
|
return BadRequest(redirectUriError);
|
||||||
|
}
|
||||||
|
if (UsesAuthorizationCodeFlow(request.Usage) && redirectUris.Count == 0)
|
||||||
|
{
|
||||||
|
return BadRequest("redirect_uris is required for webhook_outbound usage.");
|
||||||
|
}
|
||||||
|
|
||||||
var app = await _applicationManager.FindByIdAsync(id);
|
var app = await _applicationManager.FindByIdAsync(id);
|
||||||
if (app is null)
|
if (app is null)
|
||||||
{
|
{
|
||||||
@ -139,7 +159,7 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
descriptor.ClientType = request.ClientType;
|
descriptor.ClientType = request.ClientType;
|
||||||
ApplyPermissions(descriptor, request.Usage);
|
ApplyPermissions(descriptor, request.Usage);
|
||||||
descriptor.RedirectUris.Clear();
|
descriptor.RedirectUris.Clear();
|
||||||
foreach (var uri in request.RedirectUris)
|
foreach (var uri in redirectUris)
|
||||||
{
|
{
|
||||||
descriptor.RedirectUris.Add(new Uri(uri));
|
descriptor.RedirectUris.Add(new Uri(uri));
|
||||||
}
|
}
|
||||||
@ -180,6 +200,7 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
private static bool IsValidUsage(string usage)
|
private static bool IsValidUsage(string usage)
|
||||||
{
|
{
|
||||||
return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|
return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase)
|
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
|
|| string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
@ -192,7 +213,37 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
private static bool RequiresClientCredentials(string usage)
|
private static bool RequiresClientCredentials(string usage)
|
||||||
{
|
{
|
||||||
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase);
|
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool UsesAuthorizationCodeFlow(string usage)
|
||||||
|
{
|
||||||
|
return string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (List<string> Uris, string? Error) NormalizeRedirectUris(List<string>? redirectUris)
|
||||||
|
{
|
||||||
|
if (redirectUris is null || redirectUris.Count == 0)
|
||||||
|
{
|
||||||
|
return ([], null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var values = redirectUris
|
||||||
|
.Where(uri => !string.IsNullOrWhiteSpace(uri))
|
||||||
|
.Select(uri => uri.Trim())
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var uri in values)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(uri, UriKind.Absolute, out _))
|
||||||
|
{
|
||||||
|
return ([], "redirect_uris must contain absolute URIs only.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (values, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ApplyPermissions(OpenIddictApplicationDescriptor descriptor, string usage)
|
private static void ApplyPermissions(OpenIddictApplicationDescriptor descriptor, string usage)
|
||||||
@ -201,13 +252,19 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
|
||||||
|
|
||||||
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase))
|
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
|
||||||
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
descriptor.Permissions.Add("scp:newsletter:events.write.global");
|
descriptor.Permissions.Add("scp:newsletter:events.write.global");
|
||||||
}
|
}
|
||||||
|
else if (string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
descriptor.Permissions.Add("scp:newsletter:send.write");
|
||||||
|
descriptor.Permissions.Add("scp:newsletter:send.read");
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
descriptor.Permissions.Add("scp:newsletter:events.write");
|
descriptor.Permissions.Add("scp:newsletter:events.write");
|
||||||
|
|||||||
@ -95,6 +95,77 @@ public class NewsletterController : ControllerBase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpPost("one-click-unsubscribe-token")]
|
||||||
|
public async Task<IActionResult> IssueOneClickUnsubscribeToken([FromBody] IssueOneClickUnsubscribeTokenRequest request)
|
||||||
|
{
|
||||||
|
var hasTenantScope = HasScope(User, "newsletter:events.write");
|
||||||
|
var hasGlobalScope = HasScope(User, "newsletter:events.write.global");
|
||||||
|
if (!hasTenantScope && !hasGlobalScope)
|
||||||
|
{
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.TenantId == Guid.Empty || request.ListId == Guid.Empty || request.SubscriberId == Guid.Empty)
|
||||||
|
{
|
||||||
|
return BadRequest("tenant_id, list_id, subscriber_id are required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasGlobalScope && TryGetTenantId(User, out var tokenTenantId) && tokenTenantId != request.TenantId)
|
||||||
|
{
|
||||||
|
return BadRequest("tenant_id does not match token tenant scope.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = await _newsletterService.IssueOneClickUnsubscribeTokenAsync(request.TenantId, request.ListId, request.SubscriberId);
|
||||||
|
if (token is null)
|
||||||
|
{
|
||||||
|
return NotFound("Subscription not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
unsubscribe_token = token
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpPost("one-click-unsubscribe-tokens")]
|
||||||
|
public async Task<IActionResult> IssueOneClickUnsubscribeTokens([FromBody] IssueOneClickUnsubscribeTokensRequest request)
|
||||||
|
{
|
||||||
|
var hasTenantScope = HasScope(User, "newsletter:events.write");
|
||||||
|
var hasGlobalScope = HasScope(User, "newsletter:events.write.global");
|
||||||
|
if (!hasTenantScope && !hasGlobalScope)
|
||||||
|
{
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.TenantId == Guid.Empty || request.ListId == Guid.Empty || request.SubscriberIds is null || request.SubscriberIds.Count == 0)
|
||||||
|
{
|
||||||
|
return BadRequest("tenant_id, list_id, subscriber_ids are required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.SubscriberIds.Count > 1000)
|
||||||
|
{
|
||||||
|
return BadRequest("subscriber_ids exceeds maximum batch size (1000).");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasGlobalScope && TryGetTenantId(User, out var tokenTenantId) && tokenTenantId != request.TenantId)
|
||||||
|
{
|
||||||
|
return BadRequest("tenant_id does not match token tenant scope.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = await _newsletterService.IssueOneClickUnsubscribeTokensAsync(request.TenantId, request.ListId, request.SubscriberIds);
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
items = items.Select(x => new
|
||||||
|
{
|
||||||
|
subscriber_id = x.SubscriberId,
|
||||||
|
unsubscribe_token = x.UnsubscribeToken,
|
||||||
|
status = x.Status
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("preferences")]
|
[HttpGet("preferences")]
|
||||||
public async Task<IActionResult> Preferences([FromQuery(Name = "list_id")] Guid? listId, [FromQuery] string? email)
|
public async Task<IActionResult> Preferences([FromQuery(Name = "list_id")] Guid? listId, [FromQuery] string? email)
|
||||||
{
|
{
|
||||||
@ -175,4 +246,11 @@ public class NewsletterController : ControllerBase
|
|||||||
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
.SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||||
return values.Contains(scope, StringComparer.Ordinal);
|
return values.Contains(scope, StringComparer.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool TryGetTenantId(System.Security.Claims.ClaimsPrincipal user, out Guid tenantId)
|
||||||
|
{
|
||||||
|
tenantId = Guid.Empty;
|
||||||
|
var value = user.FindFirst("tenant_id")?.Value;
|
||||||
|
return !string.IsNullOrWhiteSpace(value) && Guid.TryParse(value, out tenantId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,15 +13,20 @@ namespace MemberCenter.Api.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
public class TokenController : ControllerBase
|
public class TokenController : ControllerBase
|
||||||
{
|
{
|
||||||
|
private readonly string _memberCenterAudience;
|
||||||
|
private readonly string _sendEngineAudience;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||||
private readonly IOpenIddictApplicationManager _applicationManager;
|
private readonly IOpenIddictApplicationManager _applicationManager;
|
||||||
|
|
||||||
public TokenController(
|
public TokenController(
|
||||||
|
IConfiguration configuration,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
SignInManager<ApplicationUser> signInManager,
|
SignInManager<ApplicationUser> signInManager,
|
||||||
IOpenIddictApplicationManager applicationManager)
|
IOpenIddictApplicationManager applicationManager)
|
||||||
{
|
{
|
||||||
|
_memberCenterAudience = configuration["Auth:MemberCenterAudience"] ?? "member_center_api";
|
||||||
|
_sendEngineAudience = configuration["Auth:SendEngineAudience"] ?? "send_engine_api";
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_signInManager = signInManager;
|
_signInManager = signInManager;
|
||||||
_applicationManager = applicationManager;
|
_applicationManager = applicationManager;
|
||||||
@ -55,6 +60,7 @@ public class TokenController : ControllerBase
|
|||||||
var principal = await _signInManager.CreateUserPrincipalAsync(user);
|
var principal = await _signInManager.CreateUserPrincipalAsync(user);
|
||||||
var scopes = request.Scope.GetScopesOrDefault();
|
var scopes = request.Scope.GetScopesOrDefault();
|
||||||
principal.SetScopes(scopes);
|
principal.SetScopes(scopes);
|
||||||
|
principal.SetResources(ResolveResources(scopes));
|
||||||
|
|
||||||
foreach (var claim in principal.Claims)
|
foreach (var claim in principal.Claims)
|
||||||
{
|
{
|
||||||
@ -121,7 +127,9 @@ public class TokenController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
var principal = new ClaimsPrincipal(identity);
|
var principal = new ClaimsPrincipal(identity);
|
||||||
principal.SetScopes(request.Scope.GetScopesOrDefault());
|
var scopes = request.Scope.GetScopesOrDefault();
|
||||||
|
principal.SetScopes(scopes);
|
||||||
|
principal.SetResources(ResolveResources(scopes));
|
||||||
|
|
||||||
foreach (var claim in principal.Claims)
|
foreach (var claim in principal.Claims)
|
||||||
{
|
{
|
||||||
@ -133,4 +141,35 @@ public class TokenController : ControllerBase
|
|||||||
|
|
||||||
return BadRequest("Unsupported grant type.");
|
return BadRequest("Unsupported grant type.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private IEnumerable<string> ResolveResources(IEnumerable<string> scopes)
|
||||||
|
{
|
||||||
|
var scopeSet = scopes as ISet<string> ?? new HashSet<string>(scopes, StringComparer.Ordinal);
|
||||||
|
if (scopeSet.Count == 0)
|
||||||
|
{
|
||||||
|
return [_memberCenterAudience];
|
||||||
|
}
|
||||||
|
|
||||||
|
var resources = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
if (scopeSet.Contains("newsletter:send.write") || scopeSet.Contains("newsletter:send.read"))
|
||||||
|
{
|
||||||
|
resources.Add(_sendEngineAudience);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopeSet.Any(scope => scope.StartsWith("newsletter:", StringComparison.Ordinal) && scope is not "newsletter:send.write" && scope is not "newsletter:send.read")
|
||||||
|
|| scopeSet.Contains(OpenIddictConstants.Scopes.OpenId)
|
||||||
|
|| scopeSet.Contains(OpenIddictConstants.Scopes.Email)
|
||||||
|
|| scopeSet.Contains(OpenIddictConstants.Scopes.Profile))
|
||||||
|
{
|
||||||
|
resources.Add(_memberCenterAudience);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resources.Count == 0)
|
||||||
|
{
|
||||||
|
resources.Add(_memberCenterAudience);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,6 +53,16 @@ builder.Services.AddOpenIddict()
|
|||||||
options.SetAuthorizationEndpointUris("/oauth/authorize");
|
options.SetAuthorizationEndpointUris("/oauth/authorize");
|
||||||
options.SetTokenEndpointUris("/oauth/token", "/auth/login", "/auth/refresh");
|
options.SetTokenEndpointUris("/oauth/token", "/auth/login", "/auth/refresh");
|
||||||
options.SetLogoutEndpointUris("/auth/logout");
|
options.SetLogoutEndpointUris("/auth/logout");
|
||||||
|
var issuer = builder.Configuration["Auth:Issuer"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(issuer))
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(issuer, UriKind.Absolute, out var issuerUri))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Auth:Issuer must be an absolute URI.");
|
||||||
|
}
|
||||||
|
|
||||||
|
options.SetIssuer(issuerUri);
|
||||||
|
}
|
||||||
|
|
||||||
options.AllowAuthorizationCodeFlow()
|
options.AllowAuthorizationCodeFlow()
|
||||||
.RequireProofKeyForCodeExchange();
|
.RequireProofKeyForCodeExchange();
|
||||||
@ -67,12 +77,15 @@ builder.Services.AddOpenIddict()
|
|||||||
OpenIddictConstants.Scopes.Email,
|
OpenIddictConstants.Scopes.Email,
|
||||||
OpenIddictConstants.Scopes.Profile,
|
OpenIddictConstants.Scopes.Profile,
|
||||||
"newsletter:list.read",
|
"newsletter:list.read",
|
||||||
|
"newsletter:send.write",
|
||||||
|
"newsletter:send.read",
|
||||||
"newsletter:events.read",
|
"newsletter:events.read",
|
||||||
"newsletter:events.write",
|
"newsletter:events.write",
|
||||||
"newsletter:events.write.global");
|
"newsletter:events.write.global");
|
||||||
|
|
||||||
options.AddDevelopmentEncryptionCertificate();
|
options.AddDevelopmentEncryptionCertificate();
|
||||||
options.AddDevelopmentSigningCertificate();
|
options.AddDevelopmentSigningCertificate();
|
||||||
|
options.DisableAccessTokenEncryption();
|
||||||
|
|
||||||
var aspNetCore = options.UseAspNetCore()
|
var aspNetCore = options.UseAspNetCore()
|
||||||
.EnableAuthorizationEndpointPassthrough()
|
.EnableAuthorizationEndpointPassthrough()
|
||||||
|
|||||||
@ -7,6 +7,8 @@ public interface INewsletterService
|
|||||||
Task<PendingSubscriptionResult?> SubscribeAsync(Guid listId, string email, Dictionary<string, object>? preferences);
|
Task<PendingSubscriptionResult?> SubscribeAsync(Guid listId, string email, Dictionary<string, object>? preferences);
|
||||||
Task<SubscriptionDto?> ConfirmAsync(string token);
|
Task<SubscriptionDto?> ConfirmAsync(string token);
|
||||||
Task<string?> IssueUnsubscribeTokenAsync(Guid listId, string email);
|
Task<string?> IssueUnsubscribeTokenAsync(Guid listId, string email);
|
||||||
|
Task<string?> IssueOneClickUnsubscribeTokenAsync(Guid tenantId, Guid listId, Guid subscriberId);
|
||||||
|
Task<IReadOnlyList<OneClickUnsubscribeTokenResult>> IssueOneClickUnsubscribeTokensAsync(Guid tenantId, Guid listId, IReadOnlyList<Guid> subscriberIds);
|
||||||
Task<SubscriptionDto?> UnsubscribeAsync(string token);
|
Task<SubscriptionDto?> UnsubscribeAsync(string token);
|
||||||
Task<SubscriptionDto?> GetPreferencesAsync(Guid listId, string email);
|
Task<SubscriptionDto?> GetPreferencesAsync(Guid listId, string email);
|
||||||
Task<SubscriptionDto?> UpdatePreferencesAsync(Guid listId, string email, Dictionary<string, object> preferences);
|
Task<SubscriptionDto?> UpdatePreferencesAsync(Guid listId, string email, Dictionary<string, object> preferences);
|
||||||
|
|||||||
@ -11,3 +11,5 @@ public sealed record SubscriptionDto(
|
|||||||
DateTimeOffset CreatedAt);
|
DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
public sealed record PendingSubscriptionResult(SubscriptionDto Subscription, string ConfirmToken);
|
public sealed record PendingSubscriptionResult(SubscriptionDto Subscription, string ConfirmToken);
|
||||||
|
|
||||||
|
public sealed record OneClickUnsubscribeTokenResult(Guid SubscriberId, string? UnsubscribeToken, string Status);
|
||||||
|
|||||||
@ -15,8 +15,10 @@ public sealed class NewsletterService : INewsletterService
|
|||||||
{
|
{
|
||||||
private const string ConfirmTokenPurpose = "confirm";
|
private const string ConfirmTokenPurpose = "confirm";
|
||||||
private const string UnsubscribeTokenPurpose = "unsubscribe";
|
private const string UnsubscribeTokenPurpose = "unsubscribe";
|
||||||
|
private const string OneClickUnsubscribeTokenPurpose = "one_click_unsubscribe";
|
||||||
private const int ConfirmTokenTtlDays = 7;
|
private const int ConfirmTokenTtlDays = 7;
|
||||||
private const int UnsubscribeTokenTtlDays = 7;
|
private const int UnsubscribeTokenTtlDays = 7;
|
||||||
|
private const int OneClickUnsubscribeTokenTtlDays = 7;
|
||||||
|
|
||||||
private readonly MemberCenterDbContext _dbContext;
|
private readonly MemberCenterDbContext _dbContext;
|
||||||
private readonly IEmailBlacklistService _emailBlacklist;
|
private readonly IEmailBlacklistService _emailBlacklist;
|
||||||
@ -154,10 +156,8 @@ public sealed class NewsletterService : INewsletterService
|
|||||||
public async Task<SubscriptionDto?> UnsubscribeAsync(string token)
|
public async Task<SubscriptionDto?> UnsubscribeAsync(string token)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Newsletter unsubscribe requested.");
|
_logger.LogInformation("Newsletter unsubscribe requested.");
|
||||||
var tokenHash = HashToken(token, UnsubscribeTokenPurpose);
|
var unsubscribeToken = await FindTokenAsync(token, UnsubscribeTokenPurpose)
|
||||||
var unsubscribeToken = await _dbContext.UnsubscribeTokens
|
?? await FindTokenAsync(token, OneClickUnsubscribeTokenPurpose);
|
||||||
.Include(t => t.Subscription)
|
|
||||||
.FirstOrDefaultAsync(t => t.TokenHash == tokenHash && t.ConsumedAt == null);
|
|
||||||
|
|
||||||
if (unsubscribeToken?.Subscription is null)
|
if (unsubscribeToken?.Subscription is null)
|
||||||
{
|
{
|
||||||
@ -230,6 +230,84 @@ public sealed class NewsletterService : INewsletterService
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<string?> IssueOneClickUnsubscribeTokenAsync(Guid tenantId, Guid listId, Guid subscriberId)
|
||||||
|
{
|
||||||
|
var items = await IssueOneClickUnsubscribeTokensAsync(tenantId, listId, new[] { subscriberId });
|
||||||
|
return items.FirstOrDefault()?.UnsubscribeToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<OneClickUnsubscribeTokenResult>> IssueOneClickUnsubscribeTokensAsync(
|
||||||
|
Guid tenantId,
|
||||||
|
Guid listId,
|
||||||
|
IReadOnlyList<Guid> subscriberIds)
|
||||||
|
{
|
||||||
|
var requested = subscriberIds
|
||||||
|
.Where(x => x != Guid.Empty)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (requested.Count == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<OneClickUnsubscribeTokenResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscriptions = await (
|
||||||
|
from s in _dbContext.NewsletterSubscriptions
|
||||||
|
join l in _dbContext.NewsletterLists on s.ListId equals l.Id
|
||||||
|
where l.TenantId == tenantId && s.ListId == listId && requested.Contains(s.Id)
|
||||||
|
select s)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var byId = subscriptions.ToDictionary(x => x.Id, x => x);
|
||||||
|
var normalizedEmails = subscriptions
|
||||||
|
.Select(s => s.Email.ToLowerInvariant())
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var blacklistedEmails = await _dbContext.EmailBlacklist
|
||||||
|
.Where(x => normalizedEmails.Contains(x.Email))
|
||||||
|
.Select(x => x.Email)
|
||||||
|
.ToListAsync();
|
||||||
|
var blacklistedSet = blacklistedEmails.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var results = new List<OneClickUnsubscribeTokenResult>(requested.Count);
|
||||||
|
foreach (var subscriberId in requested)
|
||||||
|
{
|
||||||
|
if (!byId.TryGetValue(subscriberId, out var subscription))
|
||||||
|
{
|
||||||
|
results.Add(new OneClickUnsubscribeTokenResult(subscriberId, null, "not_found"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blacklistedSet.Contains(subscription.Email))
|
||||||
|
{
|
||||||
|
results.Add(new OneClickUnsubscribeTokenResult(subscriberId, null, "blacklisted"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = CreateToken();
|
||||||
|
_dbContext.UnsubscribeTokens.Add(new UnsubscribeToken
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
SubscriptionId = subscription.Id,
|
||||||
|
TokenHash = HashToken(token, OneClickUnsubscribeTokenPurpose),
|
||||||
|
ExpiresAt = DateTimeOffset.UtcNow.AddDays(OneClickUnsubscribeTokenTtlDays)
|
||||||
|
});
|
||||||
|
|
||||||
|
results.Add(new OneClickUnsubscribeTokenResult(subscriberId, token, "issued"));
|
||||||
|
}
|
||||||
|
|
||||||
|
await _dbContext.SaveChangesAsync();
|
||||||
|
_logger.LogInformation(
|
||||||
|
"One-click token batch issued. tenant_id={TenantId}, list_id={ListId}, requested={RequestedCount}, issued={IssuedCount}",
|
||||||
|
tenantId,
|
||||||
|
listId,
|
||||||
|
requested.Count,
|
||||||
|
results.Count(x => string.Equals(x.Status, "issued", StringComparison.OrdinalIgnoreCase)));
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<SubscriptionDto?> GetPreferencesAsync(Guid listId, string email)
|
public async Task<SubscriptionDto?> GetPreferencesAsync(Guid listId, string email)
|
||||||
{
|
{
|
||||||
if (await _emailBlacklist.IsBlacklistedAsync(email))
|
if (await _emailBlacklist.IsBlacklistedAsync(email))
|
||||||
|
|||||||
@ -61,7 +61,7 @@ public class OAuthClientsController : Controller
|
|||||||
{
|
{
|
||||||
if (!IsValidUsage(model.Usage))
|
if (!IsValidUsage(model.Usage))
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, webhook_outbound, or platform_service.");
|
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, send_api, webhook_outbound, or platform_service.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty))
|
if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty))
|
||||||
@ -75,6 +75,16 @@ public class OAuthClientsController : Controller
|
|||||||
ModelState.AddModelError(nameof(model.ClientType), "Client type must be confidential for this usage.");
|
ModelState.AddModelError(nameof(model.ClientType), "Client type must be confidential for this usage.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var redirectUris = NormalizeRedirectUris(model.RedirectUris, out var redirectUriError);
|
||||||
|
if (!string.IsNullOrWhiteSpace(redirectUriError))
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(nameof(model.RedirectUris), redirectUriError);
|
||||||
|
}
|
||||||
|
if (UsesAuthorizationCodeFlow(model.Usage) && redirectUris.Count == 0)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(nameof(model.RedirectUris), "Redirect URI is required for webhook_outbound usage.");
|
||||||
|
}
|
||||||
|
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
{
|
{
|
||||||
model.Tenants = await _tenantService.ListAsync();
|
model.Tenants = await _tenantService.ListAsync();
|
||||||
@ -92,7 +102,7 @@ public class OAuthClientsController : Controller
|
|||||||
descriptor.ClientSecret = clientSecret;
|
descriptor.ClientSecret = clientSecret;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var uri in model.RedirectUris.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
foreach (var uri in redirectUris)
|
||||||
{
|
{
|
||||||
descriptor.RedirectUris.Add(new Uri(uri));
|
descriptor.RedirectUris.Add(new Uri(uri));
|
||||||
}
|
}
|
||||||
@ -146,7 +156,7 @@ public class OAuthClientsController : Controller
|
|||||||
{
|
{
|
||||||
if (!IsValidUsage(model.Usage))
|
if (!IsValidUsage(model.Usage))
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, webhook_outbound, or platform_service.");
|
ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, send_api, webhook_outbound, or platform_service.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty))
|
if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty))
|
||||||
@ -160,6 +170,16 @@ public class OAuthClientsController : Controller
|
|||||||
ModelState.AddModelError(nameof(model.ClientType), "Client type must be confidential for this usage.");
|
ModelState.AddModelError(nameof(model.ClientType), "Client type must be confidential for this usage.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var redirectUris = NormalizeRedirectUris(model.RedirectUris, out var redirectUriError);
|
||||||
|
if (!string.IsNullOrWhiteSpace(redirectUriError))
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(nameof(model.RedirectUris), redirectUriError);
|
||||||
|
}
|
||||||
|
if (UsesAuthorizationCodeFlow(model.Usage) && redirectUris.Count == 0)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(nameof(model.RedirectUris), "Redirect URI is required for webhook_outbound usage.");
|
||||||
|
}
|
||||||
|
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
{
|
{
|
||||||
model.Tenants = await _tenantService.ListAsync();
|
model.Tenants = await _tenantService.ListAsync();
|
||||||
@ -179,7 +199,7 @@ public class OAuthClientsController : Controller
|
|||||||
descriptor.ClientType = model.ClientType;
|
descriptor.ClientType = model.ClientType;
|
||||||
ApplyPermissions(descriptor, model.Usage);
|
ApplyPermissions(descriptor, model.Usage);
|
||||||
descriptor.RedirectUris.Clear();
|
descriptor.RedirectUris.Clear();
|
||||||
foreach (var uri in model.RedirectUris.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
foreach (var uri in redirectUris)
|
||||||
{
|
{
|
||||||
descriptor.RedirectUris.Add(new Uri(uri));
|
descriptor.RedirectUris.Add(new Uri(uri));
|
||||||
}
|
}
|
||||||
@ -242,6 +262,7 @@ public class OAuthClientsController : Controller
|
|||||||
private static bool IsValidUsage(string usage)
|
private static bool IsValidUsage(string usage)
|
||||||
{
|
{
|
||||||
return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|
return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase)
|
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
|
|| string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
@ -254,7 +275,38 @@ public class OAuthClientsController : Controller
|
|||||||
private static bool RequiresClientCredentials(string usage)
|
private static bool RequiresClientCredentials(string usage)
|
||||||
{
|
{
|
||||||
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase);
|
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool UsesAuthorizationCodeFlow(string usage)
|
||||||
|
{
|
||||||
|
return string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> NormalizeRedirectUris(string redirectUrisText, out string? error)
|
||||||
|
{
|
||||||
|
error = null;
|
||||||
|
if (string.IsNullOrWhiteSpace(redirectUrisText))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var values = redirectUrisText
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var value in values)
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(value, UriKind.Absolute, out _))
|
||||||
|
{
|
||||||
|
error = "All redirect URIs must be absolute URIs.";
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static OpenIddictApplicationDescriptor BuildDescriptor(string clientId, string name, string clientType, string usage)
|
private static OpenIddictApplicationDescriptor BuildDescriptor(string clientId, string name, string clientType, string usage)
|
||||||
@ -276,13 +328,19 @@ public class OAuthClientsController : Controller
|
|||||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token);
|
||||||
|
|
||||||
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase))
|
|| string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
|
||||||
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
descriptor.Permissions.Add("scp:newsletter:events.write.global");
|
descriptor.Permissions.Add("scp:newsletter:events.write.global");
|
||||||
}
|
}
|
||||||
|
else if (string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
descriptor.Permissions.Add("scp:newsletter:send.write");
|
||||||
|
descriptor.Permissions.Add("scp:newsletter:send.read");
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
descriptor.Permissions.Add("scp:newsletter:events.write");
|
descriptor.Permissions.Add("scp:newsletter:events.write");
|
||||||
|
|||||||
@ -26,13 +26,53 @@
|
|||||||
<label>Usage</label>
|
<label>Usage</label>
|
||||||
<select asp-for="Usage">
|
<select asp-for="Usage">
|
||||||
<option value="tenant_api">tenant_api</option>
|
<option value="tenant_api">tenant_api</option>
|
||||||
|
<option value="send_api">send_api</option>
|
||||||
<option value="webhook_outbound">webhook_outbound</option>
|
<option value="webhook_outbound">webhook_outbound</option>
|
||||||
<option value="platform_service">platform_service</option>
|
<option value="platform_service">platform_service</option>
|
||||||
</select>
|
</select>
|
||||||
<span asp-validation-for="Usage"></span>
|
<span asp-validation-for="Usage"></span>
|
||||||
|
|
||||||
<label>Redirect URIs (comma-separated)</label>
|
<label>Redirect URIs (comma-separated, only for webhook_outbound)</label>
|
||||||
<input asp-for="RedirectUris" />
|
<input asp-for="RedirectUris" />
|
||||||
|
<span asp-validation-for="RedirectUris"></span>
|
||||||
|
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const usage = document.getElementById("Usage");
|
||||||
|
const redirect = document.getElementById("RedirectUris");
|
||||||
|
const clientType = document.getElementById("ClientType");
|
||||||
|
if (!usage || !redirect || !clientType) return;
|
||||||
|
|
||||||
|
function syncRedirectInputState() {
|
||||||
|
const usageValue = usage.value;
|
||||||
|
const needsRedirect = usageValue === "webhook_outbound";
|
||||||
|
const requiresConfidential = usageValue === "tenant_api"
|
||||||
|
|| usageValue === "send_api"
|
||||||
|
|| usageValue === "platform_service";
|
||||||
|
|
||||||
|
redirect.disabled = !needsRedirect;
|
||||||
|
if (!needsRedirect) {
|
||||||
|
redirect.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiresConfidential) {
|
||||||
|
clientType.value = "confidential";
|
||||||
|
const publicOption = clientType.querySelector('option[value="public"]');
|
||||||
|
if (publicOption) {
|
||||||
|
publicOption.disabled = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const publicOption = clientType.querySelector('option[value="public"]');
|
||||||
|
if (publicOption) {
|
||||||
|
publicOption.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usage.addEventListener("change", syncRedirectInputState);
|
||||||
|
syncRedirectInputState();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|||||||
@ -26,13 +26,53 @@
|
|||||||
<label>Usage</label>
|
<label>Usage</label>
|
||||||
<select asp-for="Usage">
|
<select asp-for="Usage">
|
||||||
<option value="tenant_api">tenant_api</option>
|
<option value="tenant_api">tenant_api</option>
|
||||||
|
<option value="send_api">send_api</option>
|
||||||
<option value="webhook_outbound">webhook_outbound</option>
|
<option value="webhook_outbound">webhook_outbound</option>
|
||||||
<option value="platform_service">platform_service</option>
|
<option value="platform_service">platform_service</option>
|
||||||
</select>
|
</select>
|
||||||
<span asp-validation-for="Usage"></span>
|
<span asp-validation-for="Usage"></span>
|
||||||
|
|
||||||
<label>Redirect URIs (comma-separated)</label>
|
<label>Redirect URIs (comma-separated, only for webhook_outbound)</label>
|
||||||
<input asp-for="RedirectUris" />
|
<input asp-for="RedirectUris" />
|
||||||
|
<span asp-validation-for="RedirectUris"></span>
|
||||||
|
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const usage = document.getElementById("Usage");
|
||||||
|
const redirect = document.getElementById("RedirectUris");
|
||||||
|
const clientType = document.getElementById("ClientType");
|
||||||
|
if (!usage || !redirect || !clientType) return;
|
||||||
|
|
||||||
|
function syncRedirectInputState() {
|
||||||
|
const usageValue = usage.value;
|
||||||
|
const needsRedirect = usageValue === "webhook_outbound";
|
||||||
|
const requiresConfidential = usageValue === "tenant_api"
|
||||||
|
|| usageValue === "send_api"
|
||||||
|
|| usageValue === "platform_service";
|
||||||
|
|
||||||
|
redirect.disabled = !needsRedirect;
|
||||||
|
if (!needsRedirect) {
|
||||||
|
redirect.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiresConfidential) {
|
||||||
|
clientType.value = "confidential";
|
||||||
|
const publicOption = clientType.querySelector('option[value="public"]');
|
||||||
|
if (publicOption) {
|
||||||
|
publicOption.disabled = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const publicOption = clientType.querySelector('option[value="public"]');
|
||||||
|
if (publicOption) {
|
||||||
|
publicOption.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usage.addEventListener("change", syncRedirectInputState);
|
||||||
|
syncRedirectInputState();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user