feat: Implement email blacklist functionality and completed auth for subscription sending flow

- Added EmailBlacklist service and controller for managing blacklisted emails.
- Created EmailBlacklistDto for data transfer and EmailBlacklistFormViewModel for form handling.
- Implemented views for listing and adding emails to the blacklist.
- Updated database schema with new EmailBlacklist entity and related migrations.
- Enhanced OAuthClientFormViewModel to include ClientId and ClientSecret properties.
- Added EmailBlacklistService to handle email blacklisting logic.
- Integrated email blacklist service into the application with necessary dependencies.
This commit is contained in:
warrenchen 2026-02-10 18:05:03 +09:00
parent e4af8f067f
commit 33102d536e
32 changed files with 1798 additions and 5 deletions

View File

@ -41,6 +41,8 @@
- `docs/openapi.yaml`OpenAPI 3.1 正式規格檔
- `docs/SCHEMA.sql`:資料庫 schemaPostgreSQL
- `docs/SEED.sql`:初始化/測試資料
- `docs/SEND_ENGINE.md`:自建發送引擎摘要(規劃)
- `docs/MESSMAIL.md`:租戶站台介接指引(訂閱/退訂/發信)
- `docs/TECH_STACK.md`:技術棧與選型
- `docs/INSTALL.md`:安裝、初始化與維運指令

View File

@ -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`
### 租戶端取 TokenClient 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

View File

@ -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] 會員中心提供個人資料頁

154
docs/MESSMAIL.md Normal file
View File

@ -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_ID>&client_secret=<CLIENT_SECRET>&scope=newsletter:send.write"
```
### 使用 Token範例
```bash
curl -s -X POST https://{send-engine}/api/send-jobs \
-H "Authorization: Bearer <ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{ \"tenant_id\": \"<TENANT_ID>\", \"list_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": "<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": "<LIST_ID>",
"email": "user@example.com"
}'
```
### 退訂(範例)
```bash
curl -s -X POST https://{member-center}/newsletter/unsubscribe \
-H "Content-Type: application/json" \
-d '{
"token": "<UNSUBSCRIBE_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 <ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"tenant_id": "<TENANT_ID>",
"list_id": "<LIST_ID>",
"name": "Weekly Update",
"subject": "Weekly Update",
"body_html": "<p>Hello</p>",
"body_text": "Hello",
"scheduled_at": "2026-02-11T02:00:00Z"
}'
```
---
## 5. 安全注意事項(租戶站台)
- `list_id + email` 為租戶隔離最小邊界,必須一併提供
- 不要保存退訂 token使用時再向 Member Center 申請
- Token 必須妥善保管,避免跨租戶濫用

View File

@ -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 黑名單回寫(全租戶 emailscope: `newsletter:events.write`
### Auth / Scope
- OAuth Client 需綁定 `tenant_id`
- 新增 scope
- `newsletter:list.read`
- `newsletter:events.read`
- `newsletter:events.write`
- 發送引擎僅能用上述 scope禁止 admin 權限

View File

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

View File

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

View File

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

View File

@ -19,3 +19,9 @@ public sealed record UpdatePreferencesRequest(
[property: JsonPropertyName("list_id")] Guid ListId,
[property: JsonPropertyName("email")] string Email,
[property: JsonPropertyName("preferences")] Dictionary<string, object> 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);

View File

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

View File

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

View File

@ -87,6 +87,7 @@ builder.Services.AddAuthorization(options =>
builder.Services.AddControllers();
builder.Services.AddScoped<INewsletterService, NewsletterService>();
builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>();
builder.Services.AddScoped<ITenantService, TenantService>();
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();

View File

@ -0,0 +1,8 @@
namespace MemberCenter.Application.Abstractions;
public interface IEmailBlacklistService
{
Task<bool> IsBlacklistedAsync(string email);
Task AddOrUpdateAsync(string email, string reason, string blacklistedBy, DateTimeOffset occurredAt);
Task<IReadOnlyList<MemberCenter.Application.Models.Admin.EmailBlacklistDto>> ListAsync(int take = 200);
}

View File

@ -10,4 +10,5 @@ public interface INewsletterService
Task<SubscriptionDto?> UnsubscribeAsync(string token);
Task<SubscriptionDto?> GetPreferencesAsync(Guid listId, string email);
Task<SubscriptionDto?> UpdatePreferencesAsync(Guid listId, string email, Dictionary<string, object> preferences);
Task<IReadOnlyList<SubscriptionDto>> ListSubscriptionsAsync(Guid listId);
}

View File

@ -0,0 +1,7 @@
namespace MemberCenter.Application.Models.Admin;
public sealed record EmailBlacklistDto(
string Email,
string Reason,
string BlacklistedBy,
DateTimeOffset BlacklistedAt);

View File

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

View File

@ -5,4 +5,7 @@ namespace MemberCenter.Infrastructure.Identity;
public class ApplicationUser : IdentityUser<Guid>
{
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public bool IsBlacklisted { get; set; }
public DateTimeOffset? BlacklistedAt { get; set; }
public string? BlacklistedBy { get; set; }
}

View File

@ -16,6 +16,7 @@ public class MemberCenterDbContext
public DbSet<Tenant> Tenants => Set<Tenant>();
public DbSet<NewsletterList> NewsletterLists => Set<NewsletterList>();
public DbSet<NewsletterSubscription> NewsletterSubscriptions => Set<NewsletterSubscription>();
public DbSet<EmailBlacklist> EmailBlacklist => Set<EmailBlacklist>();
public DbSet<EmailVerification> EmailVerifications => Set<EmailVerification>();
public DbSet<UnsubscribeToken> UnsubscribeTokens => Set<UnsubscribeToken>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
@ -75,6 +76,17 @@ public class MemberCenterDbContext
.OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<EmailBlacklist>(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<EmailVerification>(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<ApplicationRole>(entity =>

View File

@ -0,0 +1,796 @@
// <auto-generated />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ActorId")
.HasColumnType("uuid");
b.Property<string>("ActorType")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("Payload")
.IsRequired()
.HasColumnType("jsonb");
b.HasKey("Id");
b.ToTable("audit_logs", (string)null);
});
modelBuilder.Entity("MemberCenter.Domain.Entities.EmailBlacklist", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("BlacklistedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("BlacklistedBy")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("ConsumedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Purpose")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("active");
b.Property<Guid>("TenantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TenantId");
b.ToTable("newsletter_lists", (string)null);
});
modelBuilder.Entity("MemberCenter.Domain.Entities.NewsletterSubscription", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("ListId")
.HasColumnType("uuid");
b.Property<string>("Preferences")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("pending");
b.Property<Guid?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("ConsumedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("SubscriptionId")
.HasColumnType("uuid");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<List<string>>("Domains")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("active");
b.HasKey("Id");
b.ToTable("tenants", (string)null);
});
modelBuilder.Entity("MemberCenter.Infrastructure.Identity.ApplicationRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("BlacklistedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("BlacklistedBy")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsBlacklisted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("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<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("role_claims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("user_claims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("user_logins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("user_roles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("user_tokens", (string)null);
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication<string>", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ApplicationType")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("ClientId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ClientSecret")
.HasColumnType("text");
b.Property<string>("ClientType")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("ConcurrencyToken")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("ConsentType")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<string>("DisplayNames")
.HasColumnType("text");
b.Property<string>("JsonWebKeySet")
.HasColumnType("text");
b.Property<string>("Permissions")
.HasColumnType("text");
b.Property<string>("PostLogoutRedirectUris")
.HasColumnType("text");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<string>("RedirectUris")
.HasColumnType("text");
b.Property<string>("Requirements")
.HasColumnType("text");
b.Property<string>("Settings")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ClientId")
.IsUnique();
b.ToTable("OpenIddictApplications", (string)null);
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization<string>", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ApplicationId")
.HasColumnType("text");
b.Property<string>("ConcurrencyToken")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTimeOffset?>("CreationDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<string>("Scopes")
.HasColumnType("text");
b.Property<string>("Status")
.HasColumnType("character varying(50)");
b.Property<string>("Subject")
.HasColumnType("text");
b.Property<string>("Type")
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("ApplicationId");
b.ToTable("OpenIddictAuthorizations", (string)null);
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope<string>", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyToken")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Descriptions")
.HasColumnType("text");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<string>("DisplayNames")
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<string>("Resources")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("OpenIddictScopes", (string)null);
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken<string>", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ApplicationId")
.HasColumnType("text");
b.Property<string>("AuthorizationId")
.HasColumnType("text");
b.Property<DateTimeOffset?>("CreationDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("ExpirationDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Payload")
.HasColumnType("text");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<DateTimeOffset?>("RedemptionDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReferenceId")
.HasColumnType("text");
b.Property<string>("Status")
.HasColumnType("character varying(50)");
b.Property<string>("Subject")
.HasColumnType("text");
b.Property<string>("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<System.Guid>", b =>
{
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("MemberCenter.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", 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<System.Guid>", 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<string>", b =>
{
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication<string>", null)
.WithMany()
.HasForeignKey("ApplicationId");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken<string>", b =>
{
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication<string>", null)
.WithMany()
.HasForeignKey("ApplicationId");
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization<string>", null)
.WithMany()
.HasForeignKey("AuthorizationId");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -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<bool>(
name: "IsBlacklisted",
table: "users",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "BlacklistedAt",
table: "users",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "BlacklistedBy",
table: "users",
type: "text",
nullable: true);
migrationBuilder.CreateTable(
name: "email_blacklist",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Email = table.Column<string>(type: "text", nullable: false),
Reason = table.Column<string>(type: "text", nullable: false),
BlacklistedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
BlacklistedBy = table.Column<string>(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");
}
}
}

View File

@ -54,6 +54,37 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
b.ToTable("audit_logs", (string)null);
});
modelBuilder.Entity("MemberCenter.Domain.Entities.EmailBlacklist", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("BlacklistedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasDefaultValueSql("now()");
b.Property<string>("BlacklistedBy")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<string>("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<Guid>("Id")
@ -293,6 +324,12 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("BlacklistedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("BlacklistedBy")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
@ -309,6 +346,11 @@ namespace MemberCenter.Infrastructure.Persistence.Migrations
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsBlacklisted")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");

View File

@ -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<bool> 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<ApplicationUser>()
.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<IReadOnlyList<EmailBlacklistDto>> 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();
}
}

View File

@ -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<PendingSubscriptionResult?> SubscribeAsync(Guid listId, string email, Dictionary<string, object>? 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<string?> 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<SubscriptionDto?> 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<SubscriptionDto?> UpdatePreferencesAsync(Guid listId, string email, Dictionary<string, object> 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<IReadOnlyList<SubscriptionDto>> 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);

View File

@ -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<IActionResult> Index()
{
var items = await _blacklistService.ListAsync();
return View(items);
}
[HttpGet("create")]
public IActionResult Create()
{
return View(new EmailBlacklistFormViewModel());
}
[HttpPost("create")]
public async Task<IActionResult> Create(EmailBlacklistFormViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
await _blacklistService.AddOrUpdateAsync(
model.Email,
model.Reason,
"admin",
DateTimeOffset.UtcNow);
return RedirectToAction("Index");
}
}

View File

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

View File

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

View File

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

View File

@ -44,6 +44,7 @@ builder.Services.AddAuthorization(options =>
});
builder.Services.AddScoped<INewsletterService, NewsletterService>();
builder.Services.AddScoped<IEmailBlacklistService, EmailBlacklistService>();
builder.Services.AddScoped<ITenantService, TenantService>();
builder.Services.AddScoped<INewsletterListService, NewsletterListService>();
builder.Services.AddScoped<IAuditLogService, AuditLogService>();

View File

@ -0,0 +1,14 @@
@model MemberCenter.Web.Models.Admin.EmailBlacklistFormViewModel
<h1>Add Email Blacklist</h1>
<form method="post">
<label>Email</label>
<input asp-for="Email" />
<span asp-validation-for="Email"></span>
<label>Reason</label>
<input asp-for="Reason" />
<span asp-validation-for="Reason"></span>
<button type="submit">Save</button>
</form>

View File

@ -0,0 +1,20 @@
@model IReadOnlyList<MemberCenter.Application.Models.Admin.EmailBlacklistDto>
<h1>Email Blacklist</h1>
<p><a href="/admin/blacklist/create">Add</a></p>
<table>
<thead>
<tr><th>Email</th><th>Reason</th><th>By</th><th>At</th></tr>
</thead>
<tbody>
@foreach (var item in Model)
{
<tr>
<td>@item.Email</td>
<td>@item.Reason</td>
<td>@item.BlacklistedBy</td>
<td>@item.BlacklistedAt</td>
</tr>
}
</tbody>
</table>

View File

@ -2,6 +2,28 @@
<h1>OAuth Clients</h1>
<p><a href="/admin/oauth-clients/create">Create</a></p>
@if (TempData["CreatedClientId"] is string createdId)
{
<div>
<strong>Client Created</strong><br />
<div>Client ID: <code>@createdId</code></div>
@if (TempData["CreatedClientSecret"] is string createdSecret)
{
<div>Client Secret (show once): <code>@createdSecret</code></div>
}
</div>
}
@if (TempData["RotatedClientId"] is string rotatedId)
{
<div>
<strong>Client Secret Rotated</strong><br />
<div>Client ID: <code>@rotatedId</code></div>
@if (TempData["RotatedClientSecret"] is string rotatedSecret)
{
<div>New Client Secret (show once): <code>@rotatedSecret</code></div>
}
</div>
}
<table>
<thead>
<tr><th>Name</th><th>Client Id</th><th>Type</th><th></th></tr>
@ -19,6 +41,12 @@
<td>@clientType</td>
<td>
<a href="/admin/oauth-clients/edit/@id">Edit</a>
@if (string.Equals(clientType, "confidential", StringComparison.OrdinalIgnoreCase))
{
<form method="post" action="/admin/oauth-clients/rotate-secret/@id" style="display:inline">
<button type="submit">Rotate Secret</button>
</form>
}
<form method="post" action="/admin/oauth-clients/delete/@id" style="display:inline">
<button type="submit">Delete</button>
</form>

View File

@ -15,6 +15,7 @@
<a href="/admin/oauth-clients">OAuth Clients</a>
<a href="/admin/audit-logs">Audit Logs</a>
<a href="/admin/security">Security</a>
<a href="/admin/blacklist">Blacklist</a>
<a href="/account/login">Login</a>
<form method="post" action="/account/logout" style="display:inline">
<button type="submit">Logout</button>