diff --git a/README.md b/README.md index 8f4009a..459def5 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ - `docs/openapi.yaml`:OpenAPI 3.1 正式規格檔 - `docs/SCHEMA.sql`:資料庫 schema(PostgreSQL) - `docs/SEED.sql`:初始化/測試資料 +- `docs/SEND_ENGINE.md`:自建發送引擎摘要(規劃) +- `docs/MESSMAIL.md`:租戶站台介接指引(訂閱/退訂/發信) - `docs/TECH_STACK.md`:技術棧與選型 - `docs/INSTALL.md`:安裝、初始化與維運指令 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 3131dab..632cda8 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -39,7 +39,7 @@ - tenants - id, name, domains, status, created_at - users (ASP.NET Core Identity) - - id, user_name, email, password_hash, email_confirmed, lockout, created_at + - id, user_name, email, password_hash, email_confirmed, lockout, is_blacklisted, blacklisted_at, blacklisted_by, created_at - roles / user_roles (Identity) - id, name, created_at - OpenIddictApplications @@ -54,6 +54,8 @@ - id, tenant_id, name, status, created_at - newsletter_subscriptions - id, list_id, email, user_id (nullable), status, preferences, created_at +- email_blacklist + - id, email, reason, blacklisted_at, blacklisted_by - email_verifications - id, email, tenant_id, token_hash, purpose, expires_at, consumed_at - unsubscribe_tokens @@ -67,6 +69,7 @@ - newsletter_subscriptions.email 與 users.email 維持唯一性關聯 - 使用者註冊時,如 email 存在訂閱紀錄,補上 user_id - 單一清單退訂:unsubscribe token 綁定 subscription_id +- blacklist 記錄於 email_blacklist(全租戶共用) ## 6. 核心流程 @@ -94,6 +97,16 @@ 2) 驗證 token 後將該訂閱標記為 `unsubscribed` 3) 會員中心發出事件 `subscription.unsubscribed` 到 event/queue +### 6.6 Send Engine 事件同步(Member Center → Send Engine) +1) Member Center 發出事件(`subscription.activated` / `subscription.unsubscribed` / `preferences.updated`) +2) 以 webhook 推送至 Send Engine(簽章與重放防護) +3) Send Engine 驗證 tenant scope,更新本地名單快照 + +### 6.7 Send Engine 退信/黑名單回寫(選用) +1) Send Engine 判定黑名單(例如 hard bounce / complaint) +2) 呼叫 Member Center API 將 email 加入 `email_blacklist` +3) Member Center 對該 email 的訂閱事件全部忽略且不再推送 + ### 6.5 註冊後銜接 1) 使用者完成註冊 2) 系統搜尋 `newsletter_subscriptions.email` @@ -112,6 +125,27 @@ - POST `/newsletter/unsubscribe-token` - GET `/newsletter/preferences` - POST `/newsletter/preferences` +- POST `/webhooks/subscriptions`(Send Engine 端點,Member Center 呼叫) +- POST `/webhooks/lists/full-sync`(Send Engine 端點,Member Center 呼叫) +- POST `/api/subscriptions/disable`(Member Center 端點,Send Engine 呼叫) + +## 7.1 待新增 API / Auth(規劃中) +### API +- `GET /newsletter/subscriptions?list_id=...`:供發送引擎同步訂閱清單 +- `POST /webhooks/subscriptions`:Member Center → Send Engine 事件推送 +- `POST /webhooks/lists/full-sync`:Member Center → Send Engine 全量同步 +- `POST /api/subscriptions/disable`:Send Engine → Member Center 黑名單回寫 + +### Auth / Scope +- OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界 +- 新增 scope:`newsletter:list.read`、`newsletter:events.read` +- 新增 scope:`newsletter:events.write` + +### 租戶端取 Token(Client Credentials) +- 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token +- token 內含 `tenant_id` 與 scope +- Send Engine 收到租戶請求後以 JWKS 驗簽(建議)或向 Member Center 進行 introspection +- 驗簽通過後將 `tenant_id` 固定在 request context,不接受 body 覆寫 ## 8. 安全與合規 - 密碼強度與防暴力破解(rate limit + lockout) diff --git a/docs/FLOWS.md b/docs/FLOWS.md index aee2907..ac7d5e1 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -36,6 +36,16 @@ - [API] 站點以 `list_id + email` 更新 `/newsletter/preferences` - [UI] 會員中心提供偏好頁(可選) +## F-10 Send Engine 事件同步(Member Center → Send Engine) +- [API] Member Center 以 webhook 推送 `subscription.activated/unsubscribed/preferences.updated`(scope: `newsletter:events.write`) +- [API] Send Engine 驗證 tenant scope + 簽章/重放防護 +- [API] Send Engine 更新名單快照 + +## F-11 黑名單回寫(Send Engine → Member Center) +- [API] Send Engine 判定黑名單(例如 hard bounce / complaint) +- [API] 呼叫 `POST /api/subscriptions/disable`(scope: `newsletter:events.write`) +- [API] Member Center 將 email 寫入 `email_blacklist`,停用寄送並停止事件推送 + ## F-07 會員資料查看 - [API] 站點讀取 `/user/profile` - [UI] 會員中心提供個人資料頁 diff --git a/docs/MESSMAIL.md b/docs/MESSMAIL.md new file mode 100644 index 0000000..6b56613 --- /dev/null +++ b/docs/MESSMAIL.md @@ -0,0 +1,154 @@ +# 租戶站台介接指引(Member Center + Send Engine) + +此文件給**租戶站台**使用,描述如何: +- 訂閱 / 確認 / 退訂 / 偏好管理(Member Center) +- 建立發信任務(Send Engine) + +不包含 Member Center ↔ Send Engine 的內部事件同步細節。 + +--- + +## 1. 系統流程圖(租戶站台角度) + +``` +Tenant Site + | + | (1) Subscribe + v +Member Center + | + | (2) Send confirmation email + v +End User + | + | (3) Confirm link + v +Member Center + +Tenant Site + | + | (4) Request unsubscribe token + v +Member Center + | + | (5) Unsubscribe with token + v +Member Center + +Tenant Site + | + | (6) Create send job + v +Send Engine + | + | (7) Deliver via ESP + v +End User +``` + +--- + +## 2. Auth / Scope(租戶站台) + +租戶站台沒有真人帳號,使用 **Client Credentials** 取得 access token。 + +- 向 Member Center 申請 OAuth Client +- scope 依用途最小化 + +建議 scopes: +- `newsletter:send.write`(Send Engine 建立發信任務) +- `newsletter:send.read`(查詢發信狀態,若需要) + +> Send Engine 會驗證 token 內的 `tenant_id` 與 scope。 + +### 取得 Token(範例) +```bash +curl -s -X POST https://{member-center}/oauth/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials&client_id=&client_secret=&scope=newsletter:send.write" +``` + +### 使用 Token(範例) +```bash +curl -s -X POST https://{send-engine}/api/send-jobs \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ \"tenant_id\": \"\", \"list_id\": \"\", \"subject\": \"Weekly\", \"body_text\": \"Hello\" }' +``` + +--- + +## 3. API 表格(租戶站台 → Member Center) + +| 功能 | Method | Path | 必填 | 說明 | +|---|---|---|---|---| +| 訂閱 | POST | `/newsletter/subscribe` | `list_id`, `email` | 回傳 `confirm_token` | +| 確認訂閱 | GET | `/newsletter/confirm` | `token` | double opt-in 確認 | +| 申請退訂 token | POST | `/newsletter/unsubscribe-token` | `list_id`, `email` | 回傳 `unsubscribe_token` | +| 退訂 | POST | `/newsletter/unsubscribe` | `token` | 單一清單退訂 | +| 讀取偏好 | GET | `/newsletter/preferences` | `list_id`, `email` | 需 list_id + email | +| 更新偏好 | POST | `/newsletter/preferences` | `list_id`, `email`, `preferences` | 需 list_id + email | + +### 訂閱(範例) +```bash +curl -s -X POST https://{member-center}/newsletter/subscribe \ + -H "Content-Type: application/json" \ + -d '{ + "list_id": "", + "email": "user@example.com", + "preferences": {"lang": "zh-TW"} + }' +``` + +### 申請退訂 token(範例) +```bash +curl -s -X POST https://{member-center}/newsletter/unsubscribe-token \ + -H "Content-Type: application/json" \ + -d '{ + "list_id": "", + "email": "user@example.com" + }' +``` + +### 退訂(範例) +```bash +curl -s -X POST https://{member-center}/newsletter/unsubscribe \ + -H "Content-Type: application/json" \ + -d '{ + "token": "" + }' +``` + +--- + +## 4. API 表格(租戶站台 → Send Engine) + +| 功能 | Method | Path | 必填 | 說明 | +|---|---|---|---|---| +| 建立發信任務 | POST | `/api/send-jobs` | `list_id`, `subject`, `body_html/body_text` | 發送排程 | +| 查詢發信任務 | GET | `/api/send-jobs/{id}` | `id` | 讀取狀態 | +| 取消發信任務 | POST | `/api/send-jobs/{id}/cancel` | `id` | 取消發送 | + +### 建立發信任務(範例) +```bash +curl -s -X POST https://{send-engine}/api/send-jobs \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "tenant_id": "", + "list_id": "", + "name": "Weekly Update", + "subject": "Weekly Update", + "body_html": "

Hello

", + "body_text": "Hello", + "scheduled_at": "2026-02-11T02:00:00Z" + }' +``` + +--- + +## 5. 安全注意事項(租戶站台) + +- `list_id + email` 為租戶隔離最小邊界,必須一併提供 +- 不要保存退訂 token;使用時再向 Member Center 申請 +- Token 必須妥善保管,避免跨租戶濫用 diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index d4d8bd2..33403fc 100644 --- a/docs/OPENAPI.md +++ b/docs/OPENAPI.md @@ -24,6 +24,34 @@ - `/newsletter/unsubscribe-token` 需要 `list_id + email` 才能申請 `unsubscribe_token` - `/newsletter/preferences`(GET/POST)需要 `list_id + email`,避免跨租戶資料讀取/更新 +## 通用欄位 +- `occurred_at`:RFC3339(例:`2026-02-10T09:30:00Z`) +- `event_id`、`request_id`:UUID + +## 通用錯誤格式 +```json +{ + "error": "string_code", + "message": "human readable message", + "request_id": "uuid" +} +``` + ## 多租戶資料隔離原則 - 與訂閱者資料(preferences、unsubscribe token)相關的查詢與寫入,一律必須帶 `list_id + email` 做租戶邊界約束。 - 不提供僅靠 `email` 或單純 `subscription_id` 的公開查詢/操作端點。 + +## 待新增 API / Auth(規劃中) +### API +- `GET /newsletter/subscriptions?list_id=...`:回傳清單內所有訂閱(供發送引擎同步用) +- `POST /webhooks/subscriptions`:Member Center → Send Engine 事件推送(Send Engine 端點,scope: `newsletter:events.write`) +- `POST /webhooks/lists/full-sync`:Member Center → Send Engine 全量同步(Send Engine 端點,scope: `newsletter:events.write`) +- `POST /api/subscriptions/disable`:Send Engine → Member Center 黑名單回寫(全租戶 email,scope: `newsletter:events.write`) + +### Auth / Scope +- OAuth Client 需綁定 `tenant_id` +- 新增 scope: + - `newsletter:list.read` + - `newsletter:events.read` + - `newsletter:events.write` +- 發送引擎僅能用上述 scope,禁止 admin 權限 diff --git a/docs/SCHEMA.sql b/docs/SCHEMA.sql index fa55a2c..723cf87 100644 --- a/docs/SCHEMA.sql +++ b/docs/SCHEMA.sql @@ -27,6 +27,9 @@ CREATE TABLE users ( lockout_end TIMESTAMPTZ, lockout_enabled BOOLEAN NOT NULL DEFAULT FALSE, access_failed_count INTEGER NOT NULL DEFAULT 0, + is_blacklisted BOOLEAN NOT NULL DEFAULT FALSE, + blacklisted_at TIMESTAMPTZ, + blacklisted_by TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); @@ -160,6 +163,14 @@ CREATE TABLE newsletter_subscriptions ( UNIQUE (list_id, email) ); +CREATE TABLE email_blacklist ( + id UUID PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + reason TEXT NOT NULL, + blacklisted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + blacklisted_by TEXT NOT NULL +); + CREATE TABLE email_verifications ( id UUID PRIMARY KEY, email TEXT NOT NULL, diff --git a/docs/UI.md b/docs/UI.md index be94990..69074c2 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -11,7 +11,7 @@ ### 管理者端 - 租戶管理(Tenant CRUD) -- OAuth Client 管理(redirect_uris / scopes) +- OAuth Client 管理(redirect_uris / scopes / client_id / client_secret) - 電子報清單管理(Lists CRUD) - 訂閱查詢 / 匯出 - 審計紀錄查詢 @@ -48,7 +48,7 @@ ### 管理者端(統一 UI) - UC-11 租戶管理: `/admin/tenants` -- UC-12 OAuth Client 管理: `/admin/oauth-clients` +- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret,可旋轉) - UC-13 電子報清單管理: `/admin/newsletter-lists` - UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export` - UC-15 審計紀錄查詢: `/admin/audit-logs` diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e6b6c5c..5b41f60 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -228,6 +228,18 @@ paths: application/json: schema: $ref: '#/components/schemas/PendingSubscriptionResponse' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /newsletter/confirm: get: @@ -245,6 +257,18 @@ paths: application/json: schema: $ref: '#/components/schemas/Subscription' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /newsletter/unsubscribe: post: @@ -266,6 +290,18 @@ paths: application/json: schema: $ref: '#/components/schemas/Subscription' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /newsletter/unsubscribe-token: post: @@ -290,6 +326,18 @@ paths: type: object properties: unsubscribe_token: { type: string } + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /newsletter/preferences: get: @@ -310,6 +358,18 @@ paths: application/json: schema: $ref: '#/components/schemas/Subscription' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' post: summary: Update preferences @@ -331,6 +391,107 @@ paths: application/json: schema: $ref: '#/components/schemas/Subscription' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /newsletter/subscriptions: + get: + summary: List subscriptions by list + security: [{ BearerAuth: [] }] + description: Requires scope `newsletter:list.read`. + parameters: + - in: query + name: list_id + required: true + schema: { type: string } + responses: + '200': + description: List + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Subscription' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /api/subscriptions/disable: + post: + summary: Disable email (global blacklist) + security: [{ BearerAuth: [] }] + description: Requires scope `newsletter:events.write`. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DisableSubscriptionRequest' + responses: + '200': + description: Disabled + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /webhooks/subscriptions: + post: + summary: (NOTE) Member Center -> Send Engine subscription events webhook + description: This endpoint is implemented by Send Engine; listed here as an integration note. Require HMAC signature + timestamp/nonce to prevent replay. + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SendEngineSubscriptionEvent' + responses: + '200': + description: Accepted + + /webhooks/lists/full-sync: + post: + summary: (NOTE) Member Center -> Send Engine full list sync webhook + description: This endpoint is implemented by Send Engine; listed here as an integration note. Require HMAC signature + timestamp/nonce to prevent replay. + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SendEngineListFullSync' + responses: + '200': + description: Accepted /admin/tenants: get: @@ -632,6 +793,62 @@ components: properties: confirm_token: { type: string } + DisableSubscriptionRequest: + type: object + required: [email, reason, disabled_by, occurred_at] + properties: + email: { type: string, format: email } + reason: { type: string } + disabled_by: { type: string } + occurred_at: { type: string, format: date-time } + + ErrorResponse: + type: object + required: [error, message, request_id] + properties: + error: { type: string } + message: { type: string } + request_id: { type: string } + + SendEngineSubscriptionEvent: + type: object + required: [event_id, event_type, tenant_id, list_id, subscriber, occurred_at] + properties: + event_id: { type: string } + event_type: { type: string, enum: [subscription.activated, subscription.unsubscribed, preferences.updated] } + tenant_id: { type: string } + list_id: { type: string } + subscriber: + type: object + required: [id, email, status] + properties: + id: { type: string } + email: { type: string, format: email } + status: { type: string, enum: [pending, active, unsubscribed] } + preferences: { type: object } + occurred_at: { type: string, format: date-time } + + SendEngineListFullSync: + type: object + required: [sync_id, batch_no, batch_total, tenant_id, list_id, subscribers, occurred_at] + properties: + sync_id: { type: string } + batch_no: { type: integer } + batch_total: { type: integer } + tenant_id: { type: string } + list_id: { type: string } + subscribers: + type: array + items: + type: object + required: [id, email, status] + properties: + id: { type: string } + email: { type: string, format: email } + status: { type: string, enum: [pending, active, unsubscribed] } + preferences: { type: object } + occurred_at: { type: string, format: date-time } + Tenant: type: object properties: diff --git a/src/MemberCenter.Api/Contracts/NewsletterRequests.cs b/src/MemberCenter.Api/Contracts/NewsletterRequests.cs index df1ad37..b425692 100644 --- a/src/MemberCenter.Api/Contracts/NewsletterRequests.cs +++ b/src/MemberCenter.Api/Contracts/NewsletterRequests.cs @@ -19,3 +19,9 @@ public sealed record UpdatePreferencesRequest( [property: JsonPropertyName("list_id")] Guid ListId, [property: JsonPropertyName("email")] string Email, [property: JsonPropertyName("preferences")] Dictionary Preferences); + +public sealed record DisableSubscriptionRequest( + [property: JsonPropertyName("email")] string Email, + [property: JsonPropertyName("reason")] string Reason, + [property: JsonPropertyName("disabled_by")] string DisabledBy, + [property: JsonPropertyName("occurred_at")] DateTimeOffset OccurredAt); diff --git a/src/MemberCenter.Api/Controllers/NewsletterController.cs b/src/MemberCenter.Api/Controllers/NewsletterController.cs index 79da4cf..e33ca97 100644 --- a/src/MemberCenter.Api/Controllers/NewsletterController.cs +++ b/src/MemberCenter.Api/Controllers/NewsletterController.cs @@ -1,6 +1,8 @@ using MemberCenter.Api.Contracts; using MemberCenter.Application.Abstractions; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using OpenIddict.Abstractions; namespace MemberCenter.Api.Controllers; @@ -140,4 +142,37 @@ public class NewsletterController : ControllerBase preferences = subscription.Preferences }); } + + [Authorize] + [HttpGet("subscriptions")] + public async Task ListSubscriptions([FromQuery(Name = "list_id")] Guid listId) + { + if (!HasScope(User, "newsletter:list.read")) + { + return Forbid(); + } + + if (listId == Guid.Empty) + { + return BadRequest("list_id is required."); + } + + var subscriptions = await _newsletterService.ListSubscriptionsAsync(listId); + return Ok(subscriptions.Select(s => new + { + id = s.Id, + list_id = s.ListId, + email = s.Email, + status = s.Status, + preferences = s.Preferences, + created_at = s.CreatedAt + })); + } + + private static bool HasScope(System.Security.Claims.ClaimsPrincipal user, string scope) + { + var values = user.FindAll(OpenIddictConstants.Claims.Scope) + .SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + return values.Contains(scope, StringComparer.Ordinal); + } } diff --git a/src/MemberCenter.Api/Controllers/SubscriptionsController.cs b/src/MemberCenter.Api/Controllers/SubscriptionsController.cs new file mode 100644 index 0000000..40df088 --- /dev/null +++ b/src/MemberCenter.Api/Controllers/SubscriptionsController.cs @@ -0,0 +1,51 @@ +using MemberCenter.Api.Contracts; +using MemberCenter.Application.Abstractions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OpenIddict.Abstractions; + +namespace MemberCenter.Api.Controllers; + +[ApiController] +[Route("api/subscriptions")] +public class SubscriptionsController : ControllerBase +{ + private readonly IEmailBlacklistService _emailBlacklistService; + + public SubscriptionsController(IEmailBlacklistService emailBlacklistService) + { + _emailBlacklistService = emailBlacklistService; + } + + [Authorize] + [HttpPost("disable")] + public async Task Disable([FromBody] DisableSubscriptionRequest request) + { + if (!HasScope(User, "newsletter:events.write")) + { + return Forbid(); + } + + if (string.IsNullOrWhiteSpace(request.Email) || + string.IsNullOrWhiteSpace(request.Reason) || + string.IsNullOrWhiteSpace(request.DisabledBy)) + { + return BadRequest("email, reason, disabled_by are required."); + } + + await _emailBlacklistService.AddOrUpdateAsync( + request.Email, + request.Reason, + request.DisabledBy, + request.OccurredAt); + + return Ok(new { email = request.Email, status = "blacklisted" }); + } + + private static bool HasScope(System.Security.Claims.ClaimsPrincipal user, string scope) + { + var values = user.FindAll(OpenIddictConstants.Claims.Scope) + .SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + return values.Contains(scope, StringComparer.Ordinal); + } +} diff --git a/src/MemberCenter.Api/Program.cs b/src/MemberCenter.Api/Program.cs index 706474e..d13c99b 100644 --- a/src/MemberCenter.Api/Program.cs +++ b/src/MemberCenter.Api/Program.cs @@ -87,6 +87,7 @@ builder.Services.AddAuthorization(options => builder.Services.AddControllers(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/MemberCenter.Application/Abstractions/IEmailBlacklistService.cs b/src/MemberCenter.Application/Abstractions/IEmailBlacklistService.cs new file mode 100644 index 0000000..678fe95 --- /dev/null +++ b/src/MemberCenter.Application/Abstractions/IEmailBlacklistService.cs @@ -0,0 +1,8 @@ +namespace MemberCenter.Application.Abstractions; + +public interface IEmailBlacklistService +{ + Task IsBlacklistedAsync(string email); + Task AddOrUpdateAsync(string email, string reason, string blacklistedBy, DateTimeOffset occurredAt); + Task> ListAsync(int take = 200); +} diff --git a/src/MemberCenter.Application/Abstractions/INewsletterService.cs b/src/MemberCenter.Application/Abstractions/INewsletterService.cs index 5da3786..f7d7c58 100644 --- a/src/MemberCenter.Application/Abstractions/INewsletterService.cs +++ b/src/MemberCenter.Application/Abstractions/INewsletterService.cs @@ -10,4 +10,5 @@ public interface INewsletterService Task UnsubscribeAsync(string token); Task GetPreferencesAsync(Guid listId, string email); Task UpdatePreferencesAsync(Guid listId, string email, Dictionary preferences); + Task> ListSubscriptionsAsync(Guid listId); } diff --git a/src/MemberCenter.Application/Models/Admin/EmailBlacklistDto.cs b/src/MemberCenter.Application/Models/Admin/EmailBlacklistDto.cs new file mode 100644 index 0000000..14ca63c --- /dev/null +++ b/src/MemberCenter.Application/Models/Admin/EmailBlacklistDto.cs @@ -0,0 +1,7 @@ +namespace MemberCenter.Application.Models.Admin; + +public sealed record EmailBlacklistDto( + string Email, + string Reason, + string BlacklistedBy, + DateTimeOffset BlacklistedAt); diff --git a/src/MemberCenter.Domain/Entities/EmailBlacklist.cs b/src/MemberCenter.Domain/Entities/EmailBlacklist.cs new file mode 100644 index 0000000..444a3c7 --- /dev/null +++ b/src/MemberCenter.Domain/Entities/EmailBlacklist.cs @@ -0,0 +1,10 @@ +namespace MemberCenter.Domain.Entities; + +public sealed class EmailBlacklist +{ + public Guid Id { get; set; } + public string Email { get; set; } = string.Empty; + public string Reason { get; set; } = string.Empty; + public DateTimeOffset BlacklistedAt { get; set; } = DateTimeOffset.UtcNow; + public string BlacklistedBy { get; set; } = string.Empty; +} diff --git a/src/MemberCenter.Infrastructure/Identity/ApplicationUser.cs b/src/MemberCenter.Infrastructure/Identity/ApplicationUser.cs index 4b6c00b..6792d8a 100644 --- a/src/MemberCenter.Infrastructure/Identity/ApplicationUser.cs +++ b/src/MemberCenter.Infrastructure/Identity/ApplicationUser.cs @@ -5,4 +5,7 @@ namespace MemberCenter.Infrastructure.Identity; public class ApplicationUser : IdentityUser { public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public bool IsBlacklisted { get; set; } + public DateTimeOffset? BlacklistedAt { get; set; } + public string? BlacklistedBy { get; set; } } diff --git a/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs b/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs index b4c40ec..6b01269 100644 --- a/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs +++ b/src/MemberCenter.Infrastructure/Persistence/MemberCenterDbContext.cs @@ -16,6 +16,7 @@ public class MemberCenterDbContext public DbSet Tenants => Set(); public DbSet NewsletterLists => Set(); public DbSet NewsletterSubscriptions => Set(); + public DbSet EmailBlacklist => Set(); public DbSet EmailVerifications => Set(); public DbSet UnsubscribeTokens => Set(); public DbSet AuditLogs => Set(); @@ -75,6 +76,17 @@ public class MemberCenterDbContext .OnDelete(DeleteBehavior.SetNull); }); + builder.Entity(entity => + { + entity.ToTable("email_blacklist"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Email).IsRequired(); + entity.Property(x => x.Reason).IsRequired(); + entity.Property(x => x.BlacklistedAt).HasDefaultValueSql("now()"); + entity.Property(x => x.BlacklistedBy).IsRequired(); + entity.HasIndex(x => x.Email).IsUnique(); + }); + builder.Entity(entity => { entity.ToTable("email_verifications"); @@ -128,6 +140,7 @@ public class MemberCenterDbContext { entity.ToTable("users"); entity.Property(x => x.CreatedAt).HasDefaultValueSql("now()"); + entity.Property(x => x.IsBlacklisted).HasDefaultValue(false); }); builder.Entity(entity => diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260210170000_AddEmailBlacklist.Designer.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260210170000_AddEmailBlacklist.Designer.cs new file mode 100644 index 0000000..8149572 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260210170000_AddEmailBlacklist.Designer.cs @@ -0,0 +1,796 @@ +// +using System; +using System.Collections.Generic; +using MemberCenter.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MemberCenter.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(MemberCenterDbContext))] + [Migration("20260210170000_AddEmailBlacklist")] + partial class AddEmailBlacklist + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MemberCenter.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("ActorType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.ToTable("audit_logs", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlacklistedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("BlacklistedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("email_blacklist", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Purpose") + .IsRequired() + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .HasDatabaseName("idx_email_verifications_email"); + + b.HasIndex("TenantId"); + + b.ToTable("email_verifications", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("newsletter_lists", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("ListId") + .HasColumnType("uuid"); + + b.Property("Preferences") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("pending"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .HasDatabaseName("idx_newsletter_subscriptions_email"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_newsletter_subscriptions_list_id"); + + b.HasIndex("UserId"); + + b.HasIndex("ListId", "Email") + .IsUnique(); + + b.ToTable("newsletter_subscriptions", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.SystemFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("system_flags", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UnsubscribeToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubscriptionId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("unsubscribe_tokens", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property>("Domains") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("active"); + + b.HasKey("Id"); + + b.ToTable("tenants", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("BlacklistedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("BlacklistedBy") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlacklisted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("role_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("user_roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("user_tokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId"); + + b.HasIndex("AuthorizationId"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b => + { + b.HasOne("MemberCenter.Domain.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterList", b => + { + b.HasOne("MemberCenter.Domain.Entities.Tenant", "Tenant") + .WithMany("NewsletterLists") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterSubscription", b => + { + b.HasOne("MemberCenter.Domain.Entities.NewsletterList", "List") + .WithMany("Subscriptions") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("List"); + }); + + modelBuilder.Entity("MemberCenter.Domain.Entities.UnsubscribeToken", b => + { + b.HasOne("MemberCenter.Domain.Entities.NewsletterSubscription", "Subscription") + .WithMany() + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", null) + .WithMany() + .HasForeignKey("ApplicationId"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", null) + .WithMany() + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", null) + .WithMany() + .HasForeignKey("AuthorizationId"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/20260210170000_AddEmailBlacklist.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260210170000_AddEmailBlacklist.cs new file mode 100644 index 0000000..945f5c9 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/20260210170000_AddEmailBlacklist.cs @@ -0,0 +1,71 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MemberCenter.Infrastructure.Persistence.Migrations +{ + public partial class AddEmailBlacklist : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsBlacklisted", + table: "users", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "BlacklistedAt", + table: "users", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "BlacklistedBy", + table: "users", + type: "text", + nullable: true); + + migrationBuilder.CreateTable( + name: "email_blacklist", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Email = table.Column(type: "text", nullable: false), + Reason = table.Column(type: "text", nullable: false), + BlacklistedAt = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + BlacklistedBy = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_email_blacklist", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_email_blacklist_Email", + table: "email_blacklist", + column: "Email", + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "email_blacklist"); + + migrationBuilder.DropColumn( + name: "IsBlacklisted", + table: "users"); + + migrationBuilder.DropColumn( + name: "BlacklistedAt", + table: "users"); + + migrationBuilder.DropColumn( + name: "BlacklistedBy", + table: "users"); + } + } +} diff --git a/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs b/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs index e89b999..71f445f 100644 --- a/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs +++ b/src/MemberCenter.Infrastructure/Persistence/Migrations/MemberCenterDbContextModelSnapshot.cs @@ -54,6 +54,37 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations b.ToTable("audit_logs", (string)null); }); + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BlacklistedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("now()"); + + b.Property("BlacklistedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("email_blacklist", (string)null); + }); + modelBuilder.Entity("MemberCenter.Domain.Entities.EmailVerification", b => { b.Property("Id") @@ -293,6 +324,12 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations b.Property("AccessFailedCount") .HasColumnType("integer"); + b.Property("BlacklistedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("BlacklistedBy") + .HasColumnType("text"); + b.Property("ConcurrencyStamp") .IsConcurrencyToken() .HasColumnType("text"); @@ -309,6 +346,11 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations b.Property("EmailConfirmed") .HasColumnType("boolean"); + b.Property("IsBlacklisted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + b.Property("LockoutEnabled") .HasColumnType("boolean"); diff --git a/src/MemberCenter.Infrastructure/Services/EmailBlacklistService.cs b/src/MemberCenter.Infrastructure/Services/EmailBlacklistService.cs new file mode 100644 index 0000000..9b406bc --- /dev/null +++ b/src/MemberCenter.Infrastructure/Services/EmailBlacklistService.cs @@ -0,0 +1,77 @@ +using MemberCenter.Application.Abstractions; +using MemberCenter.Application.Models.Admin; +using MemberCenter.Domain.Entities; +using MemberCenter.Infrastructure.Identity; +using MemberCenter.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace MemberCenter.Infrastructure.Services; + +public sealed class EmailBlacklistService : IEmailBlacklistService +{ + private readonly MemberCenterDbContext _dbContext; + + public EmailBlacklistService(MemberCenterDbContext dbContext) + { + _dbContext = dbContext; + } + + public Task IsBlacklistedAsync(string email) + { + var normalized = Normalize(email); + return _dbContext.EmailBlacklist.AnyAsync(x => x.Email == normalized); + } + + public async Task AddOrUpdateAsync(string email, string reason, string blacklistedBy, DateTimeOffset occurredAt) + { + var normalized = Normalize(email); + var existing = await _dbContext.EmailBlacklist.FirstOrDefaultAsync(x => x.Email == normalized); + if (existing is null) + { + _dbContext.EmailBlacklist.Add(new EmailBlacklist + { + Id = Guid.NewGuid(), + Email = normalized, + Reason = reason, + BlacklistedAt = occurredAt, + BlacklistedBy = blacklistedBy + }); + } + else + { + existing.Reason = reason; + existing.BlacklistedAt = occurredAt; + existing.BlacklistedBy = blacklistedBy; + } + + var user = await _dbContext.Set() + .FirstOrDefaultAsync(u => u.Email != null && u.Email.ToLower() == normalized); + if (user is not null) + { + user.IsBlacklisted = true; + user.BlacklistedAt = occurredAt; + user.BlacklistedBy = blacklistedBy; + } + + await _dbContext.SaveChangesAsync(); + } + + public async Task> ListAsync(int take = 200) + { + var items = await _dbContext.EmailBlacklist + .OrderByDescending(x => x.BlacklistedAt) + .Take(take) + .ToListAsync(); + + return items.Select(x => new EmailBlacklistDto( + x.Email, + x.Reason, + x.BlacklistedBy, + x.BlacklistedAt)).ToList(); + } + + private static string Normalize(string email) + { + return email.Trim().ToLowerInvariant(); + } +} diff --git a/src/MemberCenter.Infrastructure/Services/NewsletterService.cs b/src/MemberCenter.Infrastructure/Services/NewsletterService.cs index e6eefaf..4a03fcb 100644 --- a/src/MemberCenter.Infrastructure/Services/NewsletterService.cs +++ b/src/MemberCenter.Infrastructure/Services/NewsletterService.cs @@ -18,14 +18,21 @@ public sealed class NewsletterService : INewsletterService private const int UnsubscribeTokenTtlDays = 7; private readonly MemberCenterDbContext _dbContext; + private readonly IEmailBlacklistService _emailBlacklist; - public NewsletterService(MemberCenterDbContext dbContext) + public NewsletterService(MemberCenterDbContext dbContext, IEmailBlacklistService emailBlacklist) { _dbContext = dbContext; + _emailBlacklist = emailBlacklist; } public async Task SubscribeAsync(Guid listId, string email, Dictionary? preferences) { + if (await _emailBlacklist.IsBlacklistedAsync(email)) + { + return null; + } + var list = await _dbContext.NewsletterLists.FirstOrDefaultAsync(l => l.Id == listId); if (list is null) { @@ -84,6 +91,11 @@ public sealed class NewsletterService : INewsletterService return null; } + if (await _emailBlacklist.IsBlacklistedAsync(confirmToken.Subscription.Email)) + { + return null; + } + if (confirmToken.ExpiresAt < DateTimeOffset.UtcNow) { return null; @@ -108,6 +120,11 @@ public sealed class NewsletterService : INewsletterService return null; } + if (await _emailBlacklist.IsBlacklistedAsync(unsubscribeToken.Subscription.Email)) + { + return null; + } + unsubscribeToken.Subscription.Status = SubscriptionStatus.Unsubscribed; unsubscribeToken.ConsumedAt = DateTimeOffset.UtcNow; await _dbContext.SaveChangesAsync(); @@ -117,6 +134,11 @@ public sealed class NewsletterService : INewsletterService public async Task IssueUnsubscribeTokenAsync(Guid listId, string email) { + if (await _emailBlacklist.IsBlacklistedAsync(email)) + { + return null; + } + var subscription = await _dbContext.NewsletterSubscriptions .Where(s => s.ListId == listId && s.Email == email) .OrderByDescending(s => s.CreatedAt) @@ -142,6 +164,11 @@ public sealed class NewsletterService : INewsletterService public async Task GetPreferencesAsync(Guid listId, string email) { + if (await _emailBlacklist.IsBlacklistedAsync(email)) + { + return null; + } + var subscription = await _dbContext.NewsletterSubscriptions .Where(s => s.ListId == listId && s.Email == email) .OrderByDescending(s => s.CreatedAt) @@ -152,6 +179,11 @@ public sealed class NewsletterService : INewsletterService public async Task UpdatePreferencesAsync(Guid listId, string email, Dictionary preferences) { + if (await _emailBlacklist.IsBlacklistedAsync(email)) + { + return null; + } + var subscription = await _dbContext.NewsletterSubscriptions .Where(s => s.ListId == listId && s.Email == email) .OrderByDescending(s => s.CreatedAt) @@ -167,6 +199,17 @@ public sealed class NewsletterService : INewsletterService return MapSubscription(subscription); } + public async Task> ListSubscriptionsAsync(Guid listId) + { + var blacklisted = _dbContext.EmailBlacklist.Select(x => x.Email); + var subscriptions = await _dbContext.NewsletterSubscriptions + .Where(s => s.ListId == listId && !blacklisted.Contains(s.Email.ToLower())) + .OrderByDescending(s => s.CreatedAt) + .ToListAsync(); + + return subscriptions.Select(MapSubscription).ToList(); + } + private static string CreateToken() { var bytes = RandomNumberGenerator.GetBytes(32); diff --git a/src/MemberCenter.Web/Controllers/Admin/BlacklistController.cs b/src/MemberCenter.Web/Controllers/Admin/BlacklistController.cs new file mode 100644 index 0000000..213e94a --- /dev/null +++ b/src/MemberCenter.Web/Controllers/Admin/BlacklistController.cs @@ -0,0 +1,48 @@ +using MemberCenter.Application.Abstractions; +using MemberCenter.Web.Models.Admin; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace MemberCenter.Web.Controllers.Admin; + +[Authorize(Policy = "Admin")] +[Route("admin/blacklist")] +public class BlacklistController : Controller +{ + private readonly IEmailBlacklistService _blacklistService; + + public BlacklistController(IEmailBlacklistService blacklistService) + { + _blacklistService = blacklistService; + } + + [HttpGet("")] + public async Task Index() + { + var items = await _blacklistService.ListAsync(); + return View(items); + } + + [HttpGet("create")] + public IActionResult Create() + { + return View(new EmailBlacklistFormViewModel()); + } + + [HttpPost("create")] + public async Task Create(EmailBlacklistFormViewModel model) + { + if (!ModelState.IsValid) + { + return View(model); + } + + await _blacklistService.AddOrUpdateAsync( + model.Email, + model.Reason, + "admin", + DateTimeOffset.UtcNow); + + return RedirectToAction("Index"); + } +} diff --git a/src/MemberCenter.Web/Controllers/Admin/OAuthClientsController.cs b/src/MemberCenter.Web/Controllers/Admin/OAuthClientsController.cs index 8911410..efc3820 100644 --- a/src/MemberCenter.Web/Controllers/Admin/OAuthClientsController.cs +++ b/src/MemberCenter.Web/Controllers/Admin/OAuthClientsController.cs @@ -49,9 +49,14 @@ public class OAuthClientsController : Controller return View(model); } + var clientId = Guid.NewGuid().ToString("N"); + var clientSecret = model.ClientType == "confidential" + ? Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)) + : null; + var descriptor = new OpenIddictApplicationDescriptor { - ClientId = Guid.NewGuid().ToString("N"), + ClientId = clientId, DisplayName = model.Name, ClientType = model.ClientType, Permissions = @@ -66,6 +71,10 @@ public class OAuthClientsController : Controller "scp:openid" } }; + if (!string.IsNullOrWhiteSpace(clientSecret)) + { + descriptor.ClientSecret = clientSecret; + } foreach (var uri in model.RedirectUris.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { @@ -75,6 +84,13 @@ public class OAuthClientsController : Controller descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.ToString()); await _applicationManager.CreateAsync(descriptor); + + TempData["CreatedClientId"] = clientId; + if (!string.IsNullOrWhiteSpace(clientSecret)) + { + TempData["CreatedClientSecret"] = clientSecret; + } + return RedirectToAction("Index"); } @@ -142,4 +158,33 @@ public class OAuthClientsController : Controller await _applicationManager.DeleteAsync(app); return RedirectToAction("Index"); } + + [HttpPost("rotate-secret/{id}")] + public async Task RotateSecret(string id) + { + var app = await _applicationManager.FindByIdAsync(id); + if (app is null) + { + return NotFound(); + } + + var clientType = await _applicationManager.GetClientTypeAsync(app); + if (!string.Equals(clientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase)) + { + return BadRequest("Only confidential clients have secrets."); + } + + var descriptor = new OpenIddictApplicationDescriptor(); + await _applicationManager.PopulateAsync(descriptor, app); + + var newSecret = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)); + descriptor.ClientSecret = newSecret; + + await _applicationManager.UpdateAsync(app, descriptor); + + TempData["RotatedClientId"] = await _applicationManager.GetClientIdAsync(app); + TempData["RotatedClientSecret"] = newSecret; + + return RedirectToAction("Index"); + } } diff --git a/src/MemberCenter.Web/Models/Admin/EmailBlacklistFormViewModel.cs b/src/MemberCenter.Web/Models/Admin/EmailBlacklistFormViewModel.cs new file mode 100644 index 0000000..93ce967 --- /dev/null +++ b/src/MemberCenter.Web/Models/Admin/EmailBlacklistFormViewModel.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace MemberCenter.Web.Models.Admin; + +public sealed class EmailBlacklistFormViewModel +{ + [Required] + [EmailAddress] + public string Email { get; set; } = string.Empty; + + [Required] + public string Reason { get; set; } = string.Empty; +} diff --git a/src/MemberCenter.Web/Models/Admin/OAuthClientFormViewModel.cs b/src/MemberCenter.Web/Models/Admin/OAuthClientFormViewModel.cs index 1030a85..a821189 100644 --- a/src/MemberCenter.Web/Models/Admin/OAuthClientFormViewModel.cs +++ b/src/MemberCenter.Web/Models/Admin/OAuthClientFormViewModel.cs @@ -14,4 +14,7 @@ public sealed class OAuthClientFormViewModel public string ClientType { get; set; } = "public"; public string RedirectUris { get; set; } = string.Empty; + + public string? ClientId { get; set; } + public string? ClientSecret { get; set; } } diff --git a/src/MemberCenter.Web/Program.cs b/src/MemberCenter.Web/Program.cs index 68bc283..95445a2 100644 --- a/src/MemberCenter.Web/Program.cs +++ b/src/MemberCenter.Web/Program.cs @@ -44,6 +44,7 @@ builder.Services.AddAuthorization(options => }); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/MemberCenter.Web/Views/Admin/Blacklist/Create.cshtml b/src/MemberCenter.Web/Views/Admin/Blacklist/Create.cshtml new file mode 100644 index 0000000..2c71091 --- /dev/null +++ b/src/MemberCenter.Web/Views/Admin/Blacklist/Create.cshtml @@ -0,0 +1,14 @@ +@model MemberCenter.Web.Models.Admin.EmailBlacklistFormViewModel + +

Add Email Blacklist

+
+ + + + + + + + + +
diff --git a/src/MemberCenter.Web/Views/Admin/Blacklist/Index.cshtml b/src/MemberCenter.Web/Views/Admin/Blacklist/Index.cshtml new file mode 100644 index 0000000..bc119d0 --- /dev/null +++ b/src/MemberCenter.Web/Views/Admin/Blacklist/Index.cshtml @@ -0,0 +1,20 @@ +@model IReadOnlyList + +

Email Blacklist

+

Add

+ + + + + + @foreach (var item in Model) + { + + + + + + + } + +
EmailReasonByAt
@item.Email@item.Reason@item.BlacklistedBy@item.BlacklistedAt
diff --git a/src/MemberCenter.Web/Views/Admin/OAuthClients/Index.cshtml b/src/MemberCenter.Web/Views/Admin/OAuthClients/Index.cshtml index c127c8c..c9a7b3c 100644 --- a/src/MemberCenter.Web/Views/Admin/OAuthClients/Index.cshtml +++ b/src/MemberCenter.Web/Views/Admin/OAuthClients/Index.cshtml @@ -2,6 +2,28 @@

OAuth Clients

Create

+@if (TempData["CreatedClientId"] is string createdId) +{ +
+ Client Created
+
Client ID: @createdId
+ @if (TempData["CreatedClientSecret"] is string createdSecret) + { +
Client Secret (show once): @createdSecret
+ } +
+} +@if (TempData["RotatedClientId"] is string rotatedId) +{ +
+ Client Secret Rotated
+
Client ID: @rotatedId
+ @if (TempData["RotatedClientSecret"] is string rotatedSecret) + { +
New Client Secret (show once): @rotatedSecret
+ } +
+} @@ -19,6 +41,12 @@
NameClient IdType
@clientType Edit +@if (string.Equals(clientType, "confidential", StringComparison.OrdinalIgnoreCase)) +{ +
+ +
+}
diff --git a/src/MemberCenter.Web/Views/Shared/_Layout.cshtml b/src/MemberCenter.Web/Views/Shared/_Layout.cshtml index 0abbdaf..e1a92d8 100644 --- a/src/MemberCenter.Web/Views/Shared/_Layout.cshtml +++ b/src/MemberCenter.Web/Views/Shared/_Layout.cshtml @@ -15,6 +15,7 @@ OAuth Clients Audit Logs Security + Blacklist Login