Refactor member center workflows and UI

This commit is contained in:
Warren Chen 2026-07-07 13:27:49 +09:00
parent 2eff12d903
commit d4d2f98296
58 changed files with 1624 additions and 716 deletions

View File

@ -4,8 +4,64 @@ Auth__Issuer=http://localhost:7850/
Auth__WebLoginUrl=http://localhost:5080/account/login
Auth__AllowedLoginReturnUrlPrefixes=http://localhost:7850/
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
Auth__AllowInternalHttpTokenEndpoint=false
Auth__Certificates__Signing__Path=
Auth__Certificates__Signing__Password=
Auth__Certificates__Encryption__Path=
Auth__Certificates__Encryption__Password=
DataProtection__Certificate__Path=
DataProtection__Certificate__Password=
# Optional during rotation: repeat with __Previous__1__, etc.
Auth__Certificates__Signing__Previous__0__Path=
Auth__Certificates__Signing__Previous__0__Password=
Auth__Certificates__Encryption__Previous__0__Path=
Auth__Certificates__Encryption__Previous__0__Password=
DataProtection__Certificate__Previous__0__Path=
DataProtection__Certificate__Previous__0__Password=
Certificates__ExpirationWarningDays=30
ReverseProxy__KnownProxies=
ReverseProxy__KnownNetworks=
ReverseProxy__ForwardLimit=1
IdentitySecurity__Password__RequiredLength=8
IdentitySecurity__Password__RequireDigit=true
IdentitySecurity__Password__RequireLowercase=true
IdentitySecurity__Password__RequireUppercase=true
IdentitySecurity__Password__RequireNonAlphanumeric=false
IdentitySecurity__Lockout__AllowedForNewUsers=true
IdentitySecurity__Lockout__MaxFailedAccessAttempts=5
IdentitySecurity__Lockout__DefaultLockoutMinutes=15
RateLimits__Web__AuthLogin__PermitLimit=10
RateLimits__Web__AuthLogin__WindowSeconds=300
RateLimits__Web__AuthRegister__PermitLimit=5
RateLimits__Web__AuthRegister__WindowSeconds=900
RateLimits__Web__AuthRecovery__PermitLimit=5
RateLimits__Web__AuthRecovery__WindowSeconds=900
RateLimits__Web__AuthTokenConsumption__PermitLimit=10
RateLimits__Web__AuthTokenConsumption__WindowSeconds=600
RateLimits__Api__AuthRegister__PermitLimit=5
RateLimits__Api__AuthRegister__WindowSeconds=900
RateLimits__Api__AuthRecovery__PermitLimit=5
RateLimits__Api__AuthRecovery__WindowSeconds=900
RateLimits__Api__AuthTokenConsumption__PermitLimit=10
RateLimits__Api__AuthTokenConsumption__WindowSeconds=600
RateLimits__Api__NewsletterSubscribe__PermitLimit=20
RateLimits__Api__NewsletterSubscribe__WindowSeconds=600
RateLimits__Api__NewsletterUnsubscribeToken__PermitLimit=10
RateLimits__Api__NewsletterUnsubscribeToken__WindowSeconds=600
RateLimits__Api__NewsletterOneClickToken__PermitLimit=60
RateLimits__Api__NewsletterOneClickToken__WindowSeconds=60
RateLimits__Api__OAuthToken__PermitLimit=30
RateLimits__Api__OAuthToken__WindowSeconds=60
NewsletterTokens__ConfirmTokenLifetimeDays=7
NewsletterTokens__UnsubscribeTokenLifetimeDays=7
NewsletterTokens__OneClickTokenLifetimeDays=7
NewsletterTokens__OneClickBatchSizeLimit=1000
FileAccessTokens__DefaultLifetimeSeconds=300
FileAccessTokens__MinimumLifetimeSeconds=30
FileAccessTokens__MaximumLifetimeSeconds=900
Auth__MemberCenterAudience=member_center_api
Auth__SendEngineAudience=send_engine_api
SendEngine__BaseUrl=http://localhost:6060
SendEngine__SubscriptionEventsPath=/webhooks/subscriptions
SendEngine__WebhookSecret=change-me
Testing__DisableSubscriptionDryRunNoDb=false

View File

@ -19,6 +19,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Web", "src\Mem
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.TestSite", "src\MemberCenter.TestSite\MemberCenter.TestSite.csproj", "{ABA4996C-B48F-444C-BEF2-83BDED5D74B2}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{6199566B-62FC-4EBF-9BD3-44B69FDD5E8A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Security.Tests", "tests\MemberCenter.Security.Tests\MemberCenter.Security.Tests.csproj", "{33B55B6E-9ADD-4F23-8402-ECFAE5DFA3EE}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -56,6 +60,10 @@ Global
{ABA4996C-B48F-444C-BEF2-83BDED5D74B2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ABA4996C-B48F-444C-BEF2-83BDED5D74B2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ABA4996C-B48F-444C-BEF2-83BDED5D74B2}.Release|Any CPU.Build.0 = Release|Any CPU
{33B55B6E-9ADD-4F23-8402-ECFAE5DFA3EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{33B55B6E-9ADD-4F23-8402-ECFAE5DFA3EE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{33B55B6E-9ADD-4F23-8402-ECFAE5DFA3EE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{33B55B6E-9ADD-4F23-8402-ECFAE5DFA3EE}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{7733733D-22EB-431D-A8AA-833486C3E0E2} = {150D3A20-BF61-4012-BD40-05D408749112}
@ -65,5 +73,6 @@ Global
{5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2} = {150D3A20-BF61-4012-BD40-05D408749112}
{91DF0CEA-698F-4DF5-A44C-89AB38AA2561} = {150D3A20-BF61-4012-BD40-05D408749112}
{ABA4996C-B48F-444C-BEF2-83BDED5D74B2} = {150D3A20-BF61-4012-BD40-05D408749112}
{33B55B6E-9ADD-4F23-8402-ECFAE5DFA3EE} = {6199566B-62FC-4EBF-9BD3-44B69FDD5E8A}
EndGlobalSection
EndGlobal

View File

@ -46,7 +46,7 @@
- `docs/INSTALL.md`:安裝、初始化與維運指令
- `docs/MEMBER_UPGRADE_PLAN.md`會員中心下一階段升級規劃設定畫面、SMTP、Email 驗證、忘記密碼、角色分級)
- `docs/ADMIN_AUTHORIZATION.md`:後台 Role / Permission 權限模型與管理原則
- `docs/TEST_SITE.md`:手動整合測試站說明(API login、redirect login、會員 API happy path
- `docs/TEST_SITE.md`手動整合測試站說明redirect login、service token、會員 API happy path
## 專案結構
```text

129
docs/CONFIGURATION.md Normal file
View File

@ -0,0 +1,129 @@
# Member Center 執行參數
所有 .NET configuration key 均可改用環境變數,將 `:` 改為 `__`。例如 `RateLimits:Api:OAuthToken:PermitLimit` 對應 `RateLimits__Api__OAuthToken__PermitLimit`。Production secret 必須由 Secrets Manager / Parameter Store 注入。
## 基礎啟動
| Key | 預設值 | 說明 |
|---|---:|---|
| `ASPNETCORE_ENVIRONMENT` / `DOTNET_ENVIRONMENT` | `Production` | `Development` 才讀 repo root `.env`,並允許開發憑證/本機 DB fallback。 |
| `ConnectionStrings:Default` | Development: localhost PostgreSQL | API/Web 非 Development 必填。 |
| `MEMBERCENTER_CONNECTION` | 無 | Installer / design-time DbContext 備用 connection string。 |
| `PathBase` | 空 | API 子路徑,例如 `/member-center`。 |
| `AllowedHosts` | Web: `*` | ASP.NET Core host filteringProduction 建議填實際 host。 |
| `Logging:LogLevel:*` | Default=`Information` | 標準 .NET logging level。 |
## Identity
| Key | 預設值 | 規則 |
|---|---:|---|
| `IdentitySecurity:Password:RequiredLength` | `8` | 不得小於 8。 |
| `IdentitySecurity:Password:RequireDigit` | `true` | 至少一個數字。 |
| `IdentitySecurity:Password:RequireLowercase` | `true` | 至少一個小寫字母。 |
| `IdentitySecurity:Password:RequireUppercase` | `true` | 至少一個大寫字母。 |
| `IdentitySecurity:Password:RequireNonAlphanumeric` | `false` | 暫不要求特殊字元。 |
| `IdentitySecurity:Lockout:AllowedForNewUsers` | `true` | 新帳號啟用 lockout。 |
| `IdentitySecurity:Lockout:MaxFailedAccessAttempts` | `5` | 必須大於 0。 |
| `IdentitySecurity:Lockout:DefaultLockoutMinutes` | `15` | 必須大於 0。 |
API、Web 與 Installer 共用上述規則。
## Rate limit
每個 prefix 都有 `PermitLimit``WindowSeconds`,兩者必須大於 0。限制為單 instance 記憶體計數;多 instance Production 仍需 AWS WAF。
| Prefix | Permit | 秒 | 適用端點 |
|---|---:|---:|---|
| `RateLimits:Web:AuthLogin` | 10 | 300 | Web login |
| `RateLimits:Web:AuthRegister` | 5 | 900 | Web register |
| `RateLimits:Web:AuthRecovery` | 5 | 900 | Web forgot/resend |
| `RateLimits:Web:AuthTokenConsumption` | 10 | 600 | Web reset/verify |
| `RateLimits:Api:AuthRegister` | 5 | 900 | API register |
| `RateLimits:Api:AuthRecovery` | 5 | 900 | API forgot/resend |
| `RateLimits:Api:AuthTokenConsumption` | 10 | 600 | API reset/verify |
| `RateLimits:Api:NewsletterSubscribe` | 20 | 600 | Newsletter subscribe |
| `RateLimits:Api:NewsletterUnsubscribeToken` | 10 | 600 | Unsubscribe token issuance |
| `RateLimits:Api:NewsletterOneClickToken` | 60 | 60 | One-click single/batch |
| `RateLimits:Api:OAuthToken` | 30 | 60 | `/oauth/token` |
## Newsletter / File Access token
| Key | 預設值 | 說明 |
|---|---:|---|
| `NewsletterTokens:ConfirmTokenLifetimeDays` | `7` | 訂閱確認 token。 |
| `NewsletterTokens:UnsubscribeTokenLifetimeDays` | `7` | 一般退訂 token。 |
| `NewsletterTokens:OneClickTokenLifetimeDays` | `7` | One-click token。 |
| `NewsletterTokens:OneClickBatchSizeLimit` | `1000` | 批次 subscriber id 上限。 |
| `FileAccessTokens:DefaultLifetimeSeconds` | `300` | Delegated download token 預設時效。 |
| `FileAccessTokens:MinimumLifetimeSeconds` | `30` | 呼叫端最短可要求時效。 |
| `FileAccessTokens:MaximumLifetimeSeconds` | `900` | 呼叫端最長可要求時效。 |
Newsletter 值皆須大於 0File Access 必須符合 `0 < minimum <= default <= maximum`
## OAuth / OIDC
| Key | 預設值 | 說明 |
|---|---:|---|
| `Auth:Issuer` | Development 可空 | Production 必填 HTTPS。 |
| `Auth:WebLoginUrl` | `/account/login` | Authorize 未登入時的 Web login URL。 |
| `Auth:AllowedLoginReturnUrlPrefixes` | 空 | 逗號分隔 login allowlist。 |
| `Auth:AllowedLogoutReturnUrlPrefixes` | 空 | 逗號分隔 logout allowlist。 |
| `Auth:AllowInternalHttpTokenEndpoint` | `false` | 允許 VPC 私有 HTTP OAuth endpointissuer 仍為 HTTPS。 |
| `Auth:Resources:MemberCenter:Audience` | `member_center_api` | Member Center audience seed。 |
| `Auth:Resources:SendEngine:Audience` | `send_engine_api` | Send Engine audience seed。 |
| `Auth:Resources:FileAccess:Audience` | `file_access_api` | File Access audience seed。 |
| `Auth:MemberCenterAudience` / `Auth:SendEngineAudience` | 無 | 舊版相容 seed。 |
OAuth usage/scope mapping 的正式來源為 DB registryaudience key 只作 seed / 相容來源。
完整現有 scope catalog 與 usage mapping 請見 `docs/SCOPES.md`
## 憑證與 Data Protection
| Key | 預設值 | 說明 |
|---|---:|---|
| `Auth:Certificates:Signing:Path/Password` | 無 | Production API 必填 PFX。 |
| `Auth:Certificates:Encryption:Path/Password` | 無 | Production API 必填 PFX。 |
| `DataProtection:Certificate:Path/Password` | 無 | Production API/Web 必填且共用 PFX。 |
| `*:Previous:0:Path/Password` | 無 | 前代憑證,可增加索引。 |
| `Certificates:ExpirationWarningDays` | `30` | 到期 warning有效範圍 1180。 |
## Reverse proxy
| Key | 預設值 | 說明 |
|---|---:|---|
| `ReverseProxy:KnownProxies` | 空 | 逗號分隔可信 proxy IP。 |
| `ReverseProxy:KnownNetworks` | 空 | 逗號分隔可信 CIDR禁止 `/0`。 |
| `ReverseProxy:ForwardLimit` | `1` | Proxy hop限制 15。 |
Allowlist 都為空時完全忽略 forwarded headers。
## 外部整合與測試旗標
| Key | 預設值 | 說明 |
|---|---:|---|
| `Authentication:Google:ClientId/ClientSecret` | 無 | 兩者都有才啟用 Google login。 |
| `SendEngine:BaseUrl` | 無 | Send Engine base URL。 |
| `SendEngine:SubscriptionEventsPath` | `/webhooks/subscriptions` | Subscription webhook path。 |
| `SendEngine:WebhookSecret` | 無 | Webhook HMAC secret。 |
| `Testing:DisableSubscriptionDryRunNoDb` | `false` | 僅測試使用Production 必須 false。 |
## 後台 DB 設定
`/admin/security` 儲存在 `system_flags`
| 設定 | 預設值 / 限制 |
|---|---|
| Access token lifetime | 60 分鐘51440。 |
| Refresh token lifetime | 30 天1365。 |
| Public base URL | 空Production 應為 HTTPS。 |
| SMTP relay / port | host 空port 587。 |
| SMTP TLS / SSL | TLS=true、SSL=false不得同時啟用。 |
| SMTP timeout | 15 秒。 |
| SMTP username/password | 空password 加密保存。 |
| Sender name / email | `Member Center` / 空。 |
## TestSite 與 Installer
TestSite 使用 `MemberCenter:*``ApiBaseUrl``WebBaseUrl``WebLoginClientId``WebLoginRedirectPath``WebLogoutRedirectPath``WebLoginScopes``ServiceClientId``ServiceClientSecret``ServiceScopes`。Secret 不得寫入 `appsettings.json`
Installer 共通參數:`--connection-string/-c``--appsettings``--no-prompt``--verbose`;另有 `--force``--admin-email``--admin-password``--admin-display-name``--target`。完整命令以 `dotnet run --project src/MemberCenter.Installer -- --help` 為準。

View File

@ -108,6 +108,7 @@
- API 與 Web 需共用 DataProtection application name `MemberCenter`,使 API authorize endpoint 可讀取 Web login cookie。
- 若 API 與 Web 位於不同子網域,需設定 `Auth:CookieDomain`,例如 `.example.com`
- 若 API 與 Web 不同 originWeb login 僅允許導回 `Auth:Issuer``Auth:AllowedLoginReturnUrlPrefixes` 內的 return URL。
- Login / Logout return URL allowlist 分開驗證,採 URI origin 與 path segment prefix 比對,不使用原始字串 `StartsWith`
- API 可用 `Auth:WebLoginUrl` 指定登入頁位置;預設為 `/account/login`
- `web_login` 可使用 public client + PKCE不要求 client secret。
- `web_login` client 可使用 `openid email profile`,並預留 `profile:basic.read`
@ -241,7 +242,7 @@ resource registry 至少需定義:
建議初始資源:
- `member_center_api`
- scopes`openid``email``profile``newsletter:list.read``newsletter:events.write`、`newsletter:events.write.global``profile:basic.read``profile:basic.write``profile:addresses.read``profile:addresses.write``profile:subscriptions.read``profile:subscriptions.write`
- scopes`openid``email``profile``newsletter:list.read``newsletter:subscriptions.read`、`newsletter:subscriptions.write``newsletter:events.write`、`newsletter:events.write.global``profile:basic.read``profile:basic.write``profile:addresses.read``profile:addresses.write``profile:subscriptions.read``profile:subscriptions.write`
- usages`tenant_api``platform_service`、互動式登入 client
- `send_engine_api`
- scopes`newsletter:send.write``newsletter:send.read`
@ -260,6 +261,8 @@ resource registry 至少需定義:
- DB registry 第一版已加入 `auth_resources``auth_resource_scopes``auth_client_usage_permissions`
- 預設 seed 已包含 `member_center_api``send_engine_api``file_access_api`
- OAuth client usage-scope matrix 已由 DB 驅動,包含 `file_api`
- Authorization Code 與 Client Credentials granted scopes 都由 OAuth application `usage` mapping 固定核發
- Usage mapping seed 採完整集合同步,會停用已從預設 mapping 移除的 scopes
- resource registry 管理 UI 仍待補
- delegated download token issuing / validation 已由 Member Center API 負責File Access agent 以 validation endpoint 確認 client GET request 帶來的 token 是否有效
@ -267,8 +270,7 @@ resource registry 至少需定義:
- GET `/oauth/authorize`
- POST `/oauth/token`
- GET `/.well-known/openid-configuration`
- POST `/auth/login` (API-only login)
- POST `/auth/refresh`
- Password grant 與 `/auth/login``/auth/refresh` aliases 已停用token exchange 統一使用 `/oauth/token`
- POST `/newsletter/subscribe`
- GET `/newsletter/confirm`
- POST `/newsletter/unsubscribe`
@ -293,7 +295,7 @@ resource registry 至少需定義:
### Auth / Scope
- `tenant_api` / `send_api` / `webhook_outbound` OAuth Client 綁定 `tenant_id`,所有清單/事件 API 需驗證租戶邊界
- OAuth Client 需區分用途:`tenant_api` / `send_api` / `webhook_outbound` / `platform_service` / `file_api`(禁止混用)
- 新增 scope`newsletter:list.read``newsletter:send.write`、`newsletter:send.read``newsletter:events.read`
- 新增 scope`newsletter:list.read``newsletter:list.read.global`、`newsletter:subscriptions.read``newsletter:subscriptions.write``newsletter:send.write`、`newsletter:send.read``newsletter:events.read`
- 新增 scope`newsletter:events.write`
- 新增 scope`newsletter:events.write.global`
- 規劃新增 profile scopes
@ -312,6 +314,7 @@ resource registry 至少需定義:
- 規劃新增 audience`file_access_api`
- JWT Access Token 已改為 JWS`DisableAccessTokenEncryption`),供 Send Engine 以 JWKS 驗簽
- `aud` 計算由 resource registry 驅動,不於 token 發放流程硬寫各服務 audience
- tenant-scoped API 採 fail-closedtoken 缺少、格式錯誤或不相符的 `tenant_id` 時拒絕;跨 tenant 操作必須使用獨立 global scope
### 租戶端取 TokenClient Credentials
- 租戶使用 OAuth Client Credentials 向 Member Center 取得 access token
@ -326,11 +329,22 @@ resource registry 至少需定義:
- `POST /webhooks/lists/full-sync`Member Center 端尚未發送此事件(僅保留契約)
- 註冊後訂閱綁定(`newsletter_subscriptions.user_id` 補值)已在註冊 / external login 流程落地
- `subscription.linked_to_user` 事件已發送
- 安全設定頁access/refresh 時效)目前僅存值,尚未實際套用到 OpenIddict token lifetime
- 安全設定頁的 access/refresh 時效已套用到每次 OpenIddict token 簽發access 限 `51440` 分鐘refresh 限 `1365`
- Audit Logs 目前以查詢為主,關鍵操作的寫入覆蓋率仍不足
- resource registry 已完成 DB 驅動第一版file access delegated token issuing / validation 已由 Member Center API 實作
## 8. 安全與合規
- API / Web 的 Production database connection string 採 fail-closed禁止缺少設定時退回 localhost 預設帳密
- Member Center Web 對所有不安全 HTTP method 啟用全域 antiforgery 驗證Admin POST 表單明確送出 antiforgery token
- SMTP 密碼以版本化 Data Protection ciphertext 儲存;既有明碼在首次讀取時自動轉換,不回傳至管理 UI
- Production 必須再以外部憑證保護 Data Protection key ringkey ring 與密文同存 DB 只能避免設定值直接裸露,不能抵抗完整 DB 外洩
- Production API 必須以設定注入的 PFX 作為 OpenIddict signing / encryption certificatesAPI 與 Web 使用同一張外部 PFX 保護共用 Data Protection key ring
- Forwarded headers 採 trusted proxy allowlist未設定 proxy IP/CIDR 時完全忽略,避免外部來源偽造 client IP 或 HTTPS scheme
- HTTPS redirect 與 HSTS 由 AWS ALB / CloudFront 邊界負責API / Web 私有 listener 使用 HTTP不在應用程式層 redirect
- Production issuer 固定為 canonical HTTPS URL同 VPC S2S 可用 `Auth:AllowInternalHttpTokenEndpoint` 直連私有 HTTP OAuth endpoint兩者語意分離
- `tests/MemberCenter.Security.Tests` 是標準 xUnit test project供 CI 以 `dotnet test` 驗證 redirect、proxy、憑證與 security options 邊界
- Member Center Web 全域套用 self-only CSP、anti-framing、nosniff、no-referrer 與 Permissions-PolicyRazor 不保留 inline script/style
- Identity 密碼/lockout、API/Web rate limits、Newsletter token lifetime/batch limit 與 File Access delegated token lifetime 均由強型別 runtime options 管理,預設值記錄於 `docs/CONFIGURATION.md`
- 密碼強度與防暴力破解rate limit + lockout
- Token rotation + refresh token revoke
- Redirect URI 白名單 + PKCE

View File

@ -9,19 +9,20 @@
- [UI] 會員中心寄送驗證信
## F-02 登入OAuth2 + OIDC
- [API] 站點送出 `POST /auth/login` 取得 access_token + id_token
- [API] 站點建立自身 session
- [UI] 使用 `usage=web_login` OAuth client導向 `/oauth/authorize` 完成 Authorization Code + PKCE
- [UI] 若未登入,會員中心會導向 Web login登入後回到原 authorize request
- [UI] 站點用 code + code_verifier 換 token
- [UI] `web_login` 可使用 public client不要求 client secret必須設定 redirect URI
- [UI] 若 Web 與 API 不同 origin需設定 `Auth:WebLoginUrl`,且 Web 端需允許導回 `Auth:Issuer``Auth:AllowedLoginReturnUrlPrefixes`
- [UI] 若 Web 與 API 位於不同子網域,需設定 `Auth:CookieDomain`,讓 authorize endpoint 可讀取 Web login cookie
- [Auth] 不支援 password grant 或未註冊 clientrefresh token 統一送至 `/oauth/token`
- [Auth] 新簽發 token 套用後台安全設定access token `51440` 分鐘、refresh token `1365`
## F-02a 登出(外站 direct logout
- [UI] 外站將瀏覽器導向 Member Center Web `/account/logout`
- [UI] Member Center 清除 Web login cookie / session
- [UI] 若帶 `returnUrl`Member Center 僅允許導回本站 local URL 或 allowlist 內的外站 URL
- [Auth] Login 與 Logout 使用各自 allowlist並結構化比對 scheme、host、port 與 path segment禁止單純字串 prefix
- [UI] 登出完成後導回外站 callback
- [UI] 外站 callback 清除本站 session / token
@ -34,7 +35,7 @@
## F-02c 檔案上傳A service -> File Space
- [API] `A service``client_credentials` 向 Member Center 取得 access token
- [API] token 需包含 `files:upload.write`
- [API] token 應包含 `tenant_id`,並以 `aud=file_access_api` 為目標資源
- [API] token 必須包含有效 `tenant_id`,並以 `aud=file_access_api` 為目標資源;缺少或不符時拒絕
- [API] `A service` 帶 token 呼叫 access agent / file space 上傳檔案
- [API] access agent 以 JWKS 驗簽 JWT並驗 `iss/aud/exp/scope/tenant_id`
@ -46,6 +47,7 @@
- [UI/API] `A service` 將帶短效 token 的下載 URL 回給 client
- [UI/API] client 直接向 access agent / file space 請求檔案
- [API] access agent 以 `files:download.read` 呼叫 Member Center `POST /file-access/download-tokens/validate`,確認 token 與實際 GET request 邊界一致後放行
- [API] issuing 與 validation 都採 tenant fail-closedBearer token 缺少或帶錯 `tenant_id` 時不得簽發或驗證成功
註記:下載流程不直接暴露一般 S2S token 給 client。
@ -63,13 +65,14 @@
- [UI] 更新成功後刷新登入狀態
## F-04 訂閱電子報(未登入)
- [API] 站點送出 `POST /newsletter/subscribe`
- [API] 站點以 `tenant_api` client credentials 取得含 `newsletter:subscriptions.write` 的 token
- [API] 站點帶 Bearer token 送出 `POST /newsletter/subscribe`token tenant 必須擁有指定清單
- [API] 會員中心建立 pending 訂閱並發送驗證信
- [UI] 使用者點擊驗證信連結 `/newsletter/confirm?token=...`
- [UI] 訂閱改為 active發出 event `subscription.activated`
## F-05 取消訂閱(單一清單)
- [API] 站點以 `list_id + email` 呼叫 `POST /newsletter/unsubscribe-token` 取得 token
- [API] 站點以 tenant Bearer token 與 `list_id + email` 呼叫 `POST /newsletter/unsubscribe-token` 取得 token;需 `newsletter:subscriptions.write`
- [UI] 使用者點擊退訂連結 `/newsletter/unsubscribe?token=...`
- [UI] 訂閱狀態改為 unsubscribed
- [API] 發出 event `subscription.unsubscribed`
@ -80,9 +83,10 @@
- [API] Member Center 回傳 one-click `unsubscribe_token`
- [API] Send Engine 將 token 置入信件 `List-Unsubscribe` URL
## F-06 訂閱偏好管理(登入後)
- [API] 站點以 `list_id + email` 讀取 `/newsletter/preferences`
- [API] 站點以 `list_id + email` 更新 `/newsletter/preferences`
## F-06 訂閱偏好管理(租戶站台 S2S
- [API] 站點以 tenant Bearer token 與 `list_id + email` 讀取 `/newsletter/preferences`;需 `newsletter:subscriptions.read`
- [API] 站點以 tenant Bearer token 與 `list_id + email` 更新 `/newsletter/preferences`;需 `newsletter:subscriptions.write`
- [API] Member Center 驗證 token tenant 擁有指定清單,缺少或不符時拒絕
- [UI] 會員中心提供偏好頁(可選)
## F-06b 我的電子報訂閱管理(登入後)
@ -102,6 +106,11 @@
- [API] Send Engine 驗證簽章 + timestamp + nonce重放防護後入庫
- [API] Send Engine 更新名單快照
## F-10b 訂閱名單讀取Send Engine → Member Center
- [API] tenant client 以 `newsletter:list.read` 呼叫 `GET /newsletter/subscriptions?list_id=...`
- [API] Member Center 驗證 token `tenant_id` 與清單 tenant 一致claim 缺少、格式錯誤或不符時拒絕
- [API] 平台級跨 tenant 同步必須使用獨立的 `newsletter:list.read.global`
## F-11 黑名單回寫Send Engine → Member Center
- [API] Send Engine 依事件規則處理:
- [API] `hard_bounce` / `soft_bounce_threshold` / `suppression`:回寫後由 Member Center 取消該 email 的所有訂閱(跨租戶)並加入黑名單
@ -119,6 +128,8 @@
## F-07 會員資料查看
- [API] 站點讀取 `/user/profile`
- [API] `/user/profile``/user/addresses``/user/subscriptions` 僅能存取 access token subject 對應的會員資料
- [API] Email 不是公開識別資料,不提供 by-email 跨會員查詢端點
- [UI] 會員中心提供個人資料頁
## F-07b 會員資料維護

View File

@ -2,6 +2,9 @@
此文件定義「首次安裝」與「既有環境管理」兩種情境,並以 EF Core Code First 為主。
完整環境變數、執行期 options、DB 管理設定與預設值請見 `docs/CONFIGURATION.md`
現有 OAuth scopes、resource 與 client usage mapping 請見 `docs/SCOPES.md`
## 目標
- 透過 EF Core migrations 建立/更新 DB schema
- 建立或更新 superuser 帳號
@ -32,6 +35,7 @@
### 環境變數(建議用於部署)
- `ConnectionStrings__Default`: 主要連線字串(優先)
- `MEMBERCENTER_CONNECTION`: 備用連線字串
- API / Web 在非 Development 環境必須提供 `ConnectionStrings__Default`缺少或空值時會拒絕啟動localhost 預設值僅供 Development。
若在開發環境(`ASPNETCORE_ENVIRONMENT=Development``DOTNET_ENVIRONMENT=Development`
可以建立 `.env` 檔案installer 與 API 會在啟動時讀取(僅限開發環境)。
@ -44,6 +48,16 @@ Auth__Issuer=http://localhost:7850/
Auth__WebLoginUrl=http://localhost:5080/account/login
Auth__AllowedLoginReturnUrlPrefixes=http://localhost:7850/
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
Auth__AllowInternalHttpTokenEndpoint=false
Auth__Certificates__Signing__Path=
Auth__Certificates__Signing__Password=
Auth__Certificates__Encryption__Path=
Auth__Certificates__Encryption__Password=
DataProtection__Certificate__Path=
DataProtection__Certificate__Password=
ReverseProxy__KnownProxies=
ReverseProxy__KnownNetworks=
ReverseProxy__ForwardLimit=1
Auth__Resources__MemberCenter__Audience=member_center_api
Auth__Resources__SendEngine__Audience=send_engine_api
Auth__Resources__FileAccess__Audience=file_access_api
@ -65,8 +79,48 @@ SendEngine__WebhookSecret=change-me
OIDC / Redirect login 設定說明:
- `Auth__WebLoginUrl`: API `/oauth/authorize` 未登入時導向的 Web login URL。
- `Auth__AllowedLoginReturnUrlPrefixes`: Web login 成功後允許 redirect 回去的 URL prefix通常填 API issuer/base URL。
- `Auth__AllowedLogoutReturnUrlPrefixes`: Web logout 後允許 redirect 的 URL prefix。
- `Auth__AllowedLogoutReturnUrlPrefixes`: Web logout 後允許 redirect 的 URL prefix不會同時成為 login allowlist。
- Return URL allowlist 會結構化比對 `scheme + host + port + path segment prefix`,設定值不得含 userinfo、query 或 fragment例如 `https://example.com/app` 不會允許 `https://example.com.attacker.tld``/application`
- Identity cookie 固定使用 `SameSite=None``Secure=Always``Path=/`,因此 stage/prod 必須使用 HTTPS。
- AWS 架構由 ALB / CloudFront 終止 TLS並負責 HTTP→HTTPS redirect 與 HSTSMember Center 私有 listener 使用 HTTP不在應用程式層 redirect避免私有網域、health check 與 S2S 呼叫形成循環。
- 非 Development 的 `Auth__Issuer` 為必填且必須使用 canonical HTTPS URL缺少或使用 HTTP 時 API 拒絕啟動。
- 同 VPC 服務若需直接呼叫私有 HTTP `/oauth/token`,設定 `Auth__AllowInternalHttpTokenEndpoint=true`;此設定只放寬 OAuth endpoint transport不放寬 issuer 或外部 return URL。
- 私有 HTTP listener 必須以 Security Group 限制來源CMS 可使用內部 HTTP token endpoint但驗證 token 時仍必須接受 canonical HTTPS issuer。
- 非 Development 若 `Auth__Issuer` 不是 HTTPSAPI 會拒絕啟動Login / Logout 的外部 HTTP return URL 也會被拒絕。
- TLS 終止於 reverse proxy 時,必須正確設定 trusted proxy讓應用程式只接受 ALB / ingress 提供的 `X-Forwarded-Proto`
Reverse proxy 信任設定:
- 未設定 `ReverseProxy__KnownProxies` / `ReverseProxy__KnownNetworks`API 與 Web 完全忽略 `X-Forwarded-For``X-Forwarded-Proto`
- `ReverseProxy__KnownProxies` 使用逗號分隔 IP例如 `10.0.0.10,10.0.0.11`
- `ReverseProxy__KnownNetworks` 使用逗號分隔 CIDR例如 `10.0.0.0/24,fd00::/64`
- `ReverseProxy__ForwardLimit` 預設為 `1`,只應設為實際 proxy hop 數,允許範圍 `15`
- 不可填入 `0.0.0.0/0``::/0`;正式環境只信任 load balancer / ingress 的固定 IP 或內部網段。
Web security headers
- Web 全域送出 self-only Content Security Policy禁止 object、外部 frame 與 inline script/style。
- 同時送出 `X-Content-Type-Options: nosniff``X-Frame-Options: DENY``Referrer-Policy: no-referrer` 與受限 `Permissions-Policy`
- 新增外部資產或第三方前端服務時,必須先明確調整 CSP不可直接加入 `'unsafe-inline'` 或萬用來源。
SMTP 密碼儲存:
- `smtp_password``protected:v1:` 開頭的 Data Protection ciphertext 儲存,不需要新增 migration 或重建既有 DB。
- 舊版明碼會在第一次讀取 SMTP 設定時自動加密;開啟 `/admin/security`、寄信或送測試信都會觸發。
- `DataProtectionKeys` 是解密必要資料不可任意清空Production 應再以外部憑證保護 key ring並將憑證納入備份與輪替程序。
Production 憑證:
- 非 Development 啟動 API 時必須提供 `Auth__Certificates__Signing__Path/Password``Auth__Certificates__Encryption__Path/Password`;不可再使用 development certificates。
- API 與 Web 都必須提供相同的 `DataProtection__Certificate__Path/Password`,用來保護共用 DB key ring。
- 憑證檔必須是含 private key、在有效期內的 PFX密碼只可由 secret manager / environment 注入,不可提交到 repo。
- signing、encryption 與 Data Protection 憑證建議分離;輪替時需保留仍用於驗證舊 token解密舊資料的前一代憑證完成相容輪替後才能移除。
- 前代憑證以 `__Previous__0__Path/Password` 設定,可依序增加 `Previous__1__...`;目前憑證用於新簽發/新 key前代憑證保留舊 token 驗證與 key 解密能力。
- 應監控憑證到期日並先在 Stage 驗證;路徑錯誤、缺 private key或已過期時應用程式會拒絕啟動。
- 此處監控的是 OpenIddict signing / encryption 與 Data Protection PFX對外 TLS/SSL 憑證若由 AWS ACM 管理,續期與告警由 ACM / AWS 邊界負責。
- `Certificates__ExpirationWarningDays` 預設 `30`(允許 `1180`);進入期限後 API / Web 啟動會寫入 warning logProduction 應以 CloudWatch metric filter / alarm 監控該 warning。
安全回歸檢查:
```bash
dotnet test tests/MemberCenter.Security.Tests/MemberCenter.Security.Tests.csproj
```
此標準 xUnit test project 目前涵蓋 return URL 邊界、HTTPS 限制、trusted proxy fail-closed / CIDR、憑證必要設定與 security options 預設/驗證。
`SendEngine` 設定說明:
- `SendEngine__BaseUrl`: Send Engine API base URL

View File

@ -42,14 +42,7 @@
- `profile:addresses.write`
- `profile:subscriptions.read`
- `profile:subscriptions.write`
- API 已接上 profile scope policies並補 service API 的 by-email 端點:
- `GET /user/profile/by-email`
- `POST /user/profile/by-email`
- `GET /user/addresses/by-email`
- `POST /user/addresses/by-email`
- `DELETE /user/addresses/by-email/{id}`
- `GET /user/subscriptions/by-email`
- `POST /user/subscriptions/by-email/{id}/unsubscribe`
- API 已接上 profile scope policies原有 by-email service API 已停用Email 不作為跨會員查詢鍵。
- token resource 映射已將 `profile:*` 納入 member center audience
- `/admin/security` 已擴充 SMTP 設定欄位:
- relay host / port
@ -82,7 +75,7 @@
- Weblogin / register / forgot password / resend verification
- APIregister / forgot password / resend verification
- Newsletter APIpublic subscribe / unsubscribe token
- password grant login 已改為走 `SignInManager.CheckPasswordSignInAsync(..., lockoutOnFailure: true)`,與 Web login 共用 lockout 行為
- password grant 與 anonymous OAuth client 已移除,使用者登入統一走已註冊 `web_login` client 的 Authorization Code + PKCE
- 已完成 `superuser` / `admin` 權限模型第一版落地:
- `Admin` policy 已擴為接受 `admin``superuser`
- 新增 `Superuser` policy
@ -108,8 +101,8 @@
- `system.security_settings_updated`
- `system.security_test_email_sent`
- 已補登入治理:
- Web login / external login / password grant login 成功後更新 `last_login_at` / `last_seen_at`
- disabled 帳號無法透過 Web login、external login、password grant 取得登入
- Web login / external login 成功後更新 `last_login_at` / `last_seen_at`
- disabled 帳號無法透過 Web login、external login 取得登入
- Web cookie 與 API authenticated request 會檢查 disabled 狀態
- 已補 redirect 型登入 `web_login`
- OAuth client usage 新增 `web_login`
@ -118,6 +111,7 @@
- `/oauth/token` 已支援 authorization code exchange
- API / Web 共用 DataProtection application name `MemberCenter`
- 支援 `Auth:WebLoginUrl``Auth:AllowedLoginReturnUrlPrefixes` 處理 Web / API 不同 origin 的 redirect login
- Login / Logout return URL 已拆分 allowlist 並改用結構化 URI 驗證,防止 host 與 path prefix 混淆
進行中:
- profile / addresses / subscriptions 的畫面目前為最小可用版本,尚未優化樣式與完整驗證提示
@ -180,7 +174,7 @@
- File Access 的 OAuth client usage、scope、audience 已落地delegated download token issuing / validation 已落在 Member Center。
- Token resource / audience 已抽象為 registry後續需補 resource registry 管理畫面。
- Email 樣板正式文案與會員 / 後台 UI 細節仍待整理。
- rate limit 仍缺少 `one-click unsubscribe token` 與更細的風控觀測
- one-click unsubscribe token 已有可設定的 rate limit預設每 client/IP 每分鐘 60 次);更細的風控觀測仍待補
## 功能規劃
@ -202,6 +196,8 @@
目前進度:
- 已完成 SMTP 與 token lifetime 設定 UI沿用 `/admin/security`
- SMTP 密碼已改為版本化 Data Protection ciphertext既有明碼會在首次讀取時自動轉換Production key ring 憑證保護仍待補
- token lifetime 已套用至 OpenIddict 簽發流程,設定更新後的新 token 立即使用新時效
- 已完成 SMTP 測試信
- 已完成 `PublicBaseUrl`
- `SendEngine__BaseUrl` / `SendEngine__WebhookSecret` 尚未進管理畫面
@ -513,7 +509,7 @@
- 若其他服務需要代會員寫入,必須有額外 scope 與審計規則。
### 6. 會員資料 API 與 Auth Scope 規範
狀態:`scope 已落地,資源抽象化待續作`
狀態:`current-user scope 已落地,公開資料與服務整合 scope 待拆分`
#### 6.1 規劃目的
- 讓其他服務可透過 API 取得會員中心的基本資料與地址資料。
@ -535,14 +531,16 @@
目前進度:
- `profile:*` scopes 已註冊並接上 policy
- current-user 與 by-email service API 已完成第一版
- current-user API 已完成第一版,且一律從 access token subject 解析目前會員
- by-email service API 已停用Email 不可用來查詢或修改其他會員資料
- audience / resource registry DB 驅動第一版已完成
#### 6.3 API 邊界建議
- 其他服務 API
- 目前規劃以 service API 為主
- 只要 Auth 設定有授與對應 scope該服務即可存取對應資料
- 讀寫能力完全由 scope 控制
- 不得沿用 current-user profile scopes 查詢其他會員
- 不得以 Email 作為跨會員 lookup key
- 公開社群資料應使用獨立 endpoint、公開 DTO 與 scope
- 未來有限個資整合應使用 S2S 專用 scope、穩定 user id、欄位投影與 audit log
- 預設以最小權限授權,不因 client 類型自動放寬資料邊界
- 會員本人 UI / API
- 可讀寫自己的 profile 與地址
@ -659,6 +657,9 @@
目前進度:
- Send Engine、Member Center profile/newsletter scopes、File Access scopes 已進 registry
- 訂閱名單讀取已拆分 tenant scope `newsletter:list.read` 與平台 scope `newsletter:list.read.global`tenant claim 採 fail-closed
- 建立訂閱與申請退訂 token 已要求 tenant S2S scope `newsletter:subscriptions.write`;確認與實際退訂仍以一次性 token 匿名完成
- Email 型訂閱偏好 API 已要求 tenant S2S讀取使用 `newsletter:subscriptions.read`,更新使用 `newsletter:subscriptions.write`
- `TokenController` 已以 registry 解析 audiences
- OAuth client usage-scope matrix 已以 `auth_client_usage_permissions` 驅動
- File Access delegated token issuing / validation API 已落在 Member CenterFile Access agent 尚需串接 validation endpoint
@ -684,6 +685,15 @@
目前進度:
- 帳號寄信、驗證、重設密碼、修改密碼、註冊、external login 綁定、角色變更、帳號停用 / 啟用、profile、地址、會員端退訂、系統設定修改均已有實作
- Member Center Web 已對不安全 HTTP method 啟用全域 antiforgery 驗證Admin POST 表單均明確帶 token
- API / Web forwarded headers 已改為 trusted proxy allowlist未設定 IP/CIDR 時 fail-closed
- HTTPS redirect / HSTS 已明確交由 AWS ALB / CloudFrontAPI / Web 不再對私有 HTTP listener 做應用程式層 redirect
- Production OpenIddict signing / encryption 與 Data Protection key-protection PFX 載入已完成;缺少、過期或無 private key 時拒絕啟動
- PFX 到期預警已加入啟動 log預設提前 30 天CloudWatch alarm 由部署環境設定
- API / Web 在非 Development 缺少 `ConnectionStrings:Default` 時會拒絕啟動localhost fallback 僅保留給 Development
- Production `Auth:Issuer` 已改為必填 HTTPS私有 HTTP OAuth 呼叫改由獨立的 `Auth:AllowInternalHttpTokenEndpoint` 控制
- Web security headers 已完成self-only CSP、frame deny、nosniff、no-referrer 與 Permissions-Policy
- 已加入標準 xUnit security tests涵蓋 return URL、HTTPS、trusted proxy、Production 憑證與 security options
- OAuth client 建立與 secret 旋轉等治理細節仍待續作
### 10. Rate Limit 與防濫用
@ -702,7 +712,9 @@
目前進度:
- 已完成 login / forgot password / resend verification / register / public subscribe / unsubscribe token 申請
- `one-click unsubscribe token` 申請仍待補
- `one-click unsubscribe token` 申請已補每 client/IP rate limit
- `/oauth/token` 已補可設定 rate limit預設每來源 IP 每分鐘 30 次Production 分散式限制由 AWS WAF 負責
- API / Web password reset 與 Email verify 已補可設定 rate limit預設每來源 IP 每 10 分鐘 10 次)
- 人類登入 flow 已有 lockoutservice API token flow 與更細觀測仍待續作
### 11. MFA 與非本期項目

View File

@ -3,13 +3,15 @@
已補上完整端點與資料結構,並提供 `docs/openapi.yaml` 作為可直接擴充的版本。
其中 `/webhooks/*` 為 Member Center 對外發送時遵循的整合契約(實際由 Send Engine 提供端點)。
現有 OAuth scope catalog、resource、client usage mapping 與 endpoint 對照請見 `docs/SCOPES.md`
## 版本
- OpenAPI: 3.1.0
- 檔案:`docs/openapi.yaml`
## 核心資源
- OAuth2/OIDC授權、token、discovery、JWKS
- Auth註冊、登入password grant、刷新、登出、忘記/重設密碼、Email 驗證
- Auth註冊、Authorization Code + PKCE 登入、刷新、登出、忘記/重設密碼、Email 驗證
- User個人資料
- Newsletter訂閱/確認/退訂/偏好
- AdminTenants/Lists/OAuth ClientsMVP CRUD
@ -19,14 +21,21 @@
- Bearer JWTAPI 使用)
## 補充說明
- `/oauth/token``/auth/login``/auth/refresh` 使用 `application/x-www-form-urlencoded`
- `/oauth/token` 使用 `application/x-www-form-urlencoded`,僅接受 authorization code、refresh token、client credentials
- `/oauth/token` 預設每來源 IP 每分鐘 30 次,透過 `RateLimits:Api:OAuthToken` 調整;超過回傳 `429`Production 仍需 AWS WAF
- Password grant 與 anonymous OAuth client 已停用;使用者登入必須走已註冊 `web_login` client 的 Authorization Code + PKCE
- Authorization Code 與 Client Credentials token 的實際 granted scopes 都由 application `usage` 對應的 DB mapping 固定核發,不採用呼叫端自行指定的 scope
- Usage scope seed 會同步啟用與停用狀態;從 catalog 移除的 scope 不會繼續留在既有 Stage/Production mapping
- Token endpoint 會套用後台安全設定access token `51440` 分鐘、refresh token `1365` 天;設定儲存後下一次簽發立即生效
- Access token 以 JWTJWS簽發建議驗證 `iss``aud`
- `/auth/email/verify` 需要 `token` + `email`
- `/newsletter/subscribe` 會回傳 `confirm_token`
- `/newsletter/unsubscribe-token` 需要 `list_id + email` 才能申請 `unsubscribe_token`
- Password reset 與 Email verify 預設每來源 IP 每 10 分鐘 10 次,透過 Web/API `AuthTokenConsumption` rate-limit options 調整
- `/newsletter/subscribe` 需 tenant S2S token成功後會回傳 `confirm_token`
- `/newsletter/unsubscribe-token` 需 tenant S2S token 與 `list_id + email` 才能申請 `unsubscribe_token`
- `/newsletter/one-click-unsubscribe-token` 提供 Send Engine 發信前取得 one-click 退訂 token`tenant_id + list_id + subscriber_id`
- `/newsletter/one-click-unsubscribe-tokens` 提供 Send Engine 批次取得 one-click 退訂 token`tenant_id + list_id + subscriber_ids[]`
- `/newsletter/preferences`GET/POST需要 `list_id + email`,避免跨租戶資料讀取/更新
- One-click token 單筆/批次端點預設每 client/IP 每分鐘 60 次,透過 `RateLimits:Api:NewsletterOneClickToken` 調整
- `/newsletter/preferences`GET/POST需要 tenant S2S token 與 `list_id + email`,避免跨租戶資料讀取/更新
## 通用欄位
- `occurred_at`RFC3339`2026-02-10T09:30:00Z`
@ -68,7 +77,7 @@
## OAuth Client 用途分離(強制)
- `usage=tenant_api`
- 供租戶站台拿 token 呼叫 Member Center / Send Engine API
- scope 僅給業務所需(如 `newsletter:events.write`
- scope 僅給業務所需(如 `newsletter:subscriptions.read`、`newsletter:subscriptions.write``newsletter:events.write`
- `usage=send_api`
- 供租戶站台呼叫 Send Engine 發信流程
- 內建 scope`newsletter:send.write``newsletter:send.read`
@ -112,6 +121,9 @@
- `platform_service` 可不綁定 `tenant_id`
- 新增 scope
- `newsletter:list.read`
- `newsletter:list.read.global`
- `newsletter:subscriptions.read`
- `newsletter:subscriptions.write`
- `newsletter:send.write`
- `newsletter:send.read`
- `newsletter:events.read`
@ -125,6 +137,10 @@
- `profile:subscriptions.read`
- `profile:subscriptions.write`
- profile 相關 API 以 service API 為主要整合模式,授權完全由 scope 控制
- 現有 `/user/profile``/user/addresses``/user/subscriptions` 為 current-user API資料主體固定為 access token `sub`
- `profile:*` scope 不授權查詢或修改其他會員by-email API 已停用
- 未來公開會員資料與有限個資整合必須使用獨立 endpoint、DTO 與 scope
- 公開會員 API 不在目前範圍;待外部網站有明確欄位與用途後,依最小揭露原則逐功能新增
- 規劃新增 file access scopes
- `files:upload.write`
- `files:download.read`
@ -137,6 +153,13 @@
- `POST /subscriptions/disable` 需 Bearer token 且包含下列其一:
- `newsletter:events.write`tenant-scoped
- `newsletter:events.write.global`platform-scopedSES 回寫用)
- `GET /newsletter/subscriptions` 需包含下列其一:
- `newsletter:list.read`token 必須含有效 `tenant_id`,且只能讀取該 tenant 的清單
- `newsletter:list.read.global`:允許平台服務跨 tenant 讀取
- tenant-scoped API 採 fail-closed缺少、格式錯誤或不相符的 `tenant_id` 一律拒絕
- `POST /newsletter/subscribe``POST /newsletter/unsubscribe-token``newsletter:subscriptions.write`token tenant 必須擁有指定 `list_id`
- `GET /newsletter/preferences``newsletter:subscriptions.read``POST /newsletter/preferences``newsletter:subscriptions.write`;兩者都驗證清單 tenant
- `/newsletter/confirm``/newsletter/unsubscribe` 是收件者的一次性 token 完成端點,維持匿名存取
- 建議 Send Engine 使用 client credentials 取 token不建議使用長效固定 token
- Send Engine 建議以 JWKS 驗簽 JWTJWS並驗證 `scope/tenant_id/exp`
- `iss``Auth:Issuer` 設定(例:`http://localhost:7850/`
@ -158,7 +181,7 @@
- Upload 採 S2S
- `A service` 使用 `client_credentials`
- token 至少需帶 `files:upload.write`
- token 應帶 `tenant_id`
- token 必須帶有效 `tenant_id`;缺少、格式錯誤或與 request 不符時拒絕
- access agent 驗 `iss/aud/exp/scope/tenant_id`
- Download 採 delegated short-lived token
- 不直接將一般 S2S token 暴露給 client

113
docs/SCOPES.md Normal file
View File

@ -0,0 +1,113 @@
# OAuth Scope 現況
本文只記錄目前程式已註冊與 seed 的 scope。Client Credentials 與 Authorization Code 實際核發的 scope 由 OAuth application `usage` 對應的 DB mapping 決定,不接受呼叫端自行擴權。修改預設 mapping 後,既有環境需執行 `sync-oauth-clients`
## 授權邊界
- `profile:*` 是 current-user scope資料主體固定為 access token 的 `sub`,不能查詢其他會員。
- Email 不是跨會員 lookup key目前沒有 by-email service API。
- Tenant scope 必須帶有效 `tenant_id`,且與 request/list tenant 相符;缺少或不符時 fail-closed。
- `.global` scope 不依賴 tenant claim只應授予平台級 confidential client。
- Superuser/Admin 後台權限不使用 OAuth scopes而使用 Web role + Admin Permission。
## Resource / Audience
| Resource | 預設 audience | Tenant | Scopes |
|---|---|---|---|
| `member_center_api` | `member_center_api` | scope 個別判定 | OIDC、`profile:*``newsletter:list.*``newsletter:subscriptions.*``newsletter:events.*` |
| `send_engine_api` | `send_engine_api` | 必須 | `newsletter:send.read``newsletter:send.write` |
| `file_access_api` | `file_access_api` | 必須 | `files:*` |
Token audience 由 scope → resource registry 計算。
## Scope Catalog
### OIDC
| Scope | 能力 |
|---|---|
| `openid` | OpenID Connect subject / ID token。 |
| `email` | Email claim。 |
| `profile` | 標準 OIDC profile scope實際會員 API 仍需細分的 `profile:*`。 |
### Current-user Profile
| Scope | 能力 |
|---|---|
| `profile:basic.read` | 讀取目前登入會員基本資料。 |
| `profile:basic.write` | 修改目前登入會員基本資料。 |
| `profile:addresses.read` | 讀取目前會員地址簿。 |
| `profile:addresses.write` | 新增、修改、刪除目前會員地址。 |
| `profile:subscriptions.read` | 讀取目前會員的電子報訂閱。 |
| `profile:subscriptions.write` | 取消目前會員自己的訂閱。 |
### Newsletter / Member Center
| Scope | Tenant 規則 | 能力 |
|---|---|---|
| `newsletter:list.read` | token tenant 必須擁有 list | 依 list 讀取訂閱名單。 |
| `newsletter:list.read.global` | 平台級 | 跨 tenant 讀取訂閱名單。 |
| `newsletter:subscriptions.read` | token tenant 必須擁有 list | 依 list + email 讀取訂閱偏好。 |
| `newsletter:subscriptions.write` | token tenant 必須擁有 list | 建立訂閱、簽發一般退訂 token、更新偏好。 |
| `newsletter:events.read` | 依 resource service 規則 | 讀取電子報事件;目前 Member Center 無直接 endpoint。 |
| `newsletter:events.write` | `tenant_id` 必須匹配 | Tenant 事件回寫、one-click token、disable subscription。 |
| `newsletter:events.write.global` | 平台級 | 跨 tenant 事件回寫、one-click token、disable subscription、webhook client mapping。 |
### Send Engine
| Scope | 能力 |
|---|---|
| `newsletter:send.write` | 在 Send Engine 建立/執行發送工作。 |
| `newsletter:send.read` | 在 Send Engine 讀取發送狀態。 |
上述 scope 的資源是 Send Engine不是 Member Center controller。
### File Access
| Scope | 能力 |
|---|---|
| `files:upload.write` | File Access service 上傳檔案。 |
| `files:download.read` | Access agent 呼叫 Member Center 驗證 delegated download token。 |
| `files:download.delegate` | 業務服務向 Member Center 簽發短效 download token。 |
| `files:metadata.read` | File Access service 讀取檔案 metadata。 |
| `files:delete` | File Access service 刪除檔案。 |
`files:upload.write``files:metadata.read``files:delete` 由 File Access service 驗證Member Center 目前只提供 delegated token issuing / validation。
## Client Usage 預設 Mapping
| Usage | Client 型態 / Tenant | 預設 scopes |
|---|---|---|
| `web_login` | public 或 confidential互動式登入 | `openid``email``profile``profile:basic.read` |
| `tenant_api` | confidential必須綁 tenant | `newsletter:events.write``newsletter:list.read``newsletter:subscriptions.read/write`、全部 `profile:*` |
| `platform_service` | confidential可不綁 tenant | `newsletter:events.write.global``newsletter:list.read.global`、全部 `profile:*` |
| `send_api` | confidential必須綁 tenant | `newsletter:send.read/write` |
| `file_api` | confidential必須綁 tenant | 全部 `files:*` |
| `webhook_outbound` | 現有 webhook credential usage | `openid``email``profile``newsletter:events.write` |
Mapping seed 採完整集合同步:從預設 mapping 移除的 scope 會在 DB mapping 停用。個別 OAuth application permissions 由 Installer `sync-oauth-clients` 更新。
現有 `tenant_api` / `platform_service` mapping 雖包含 `profile:*`,但 current-user endpoint 仍要求 `sub` 對應實際會員Client Credentials principal 不能用這些 endpoint 查詢其他會員。目前沒有 S2S 會員資料 endpoint。
## Member Center Endpoint 對照
| Endpoint | Scope |
|---|---|
| `GET /user/profile` | `profile:basic.read` |
| `POST /user/profile` | `profile:basic.write` |
| `GET /user/addresses` | `profile:addresses.read` |
| `POST /user/addresses``DELETE /user/addresses/{id}` | `profile:addresses.write` |
| `GET /user/subscriptions` | `profile:subscriptions.read` |
| `POST /user/subscriptions/{id}/unsubscribe` | `profile:subscriptions.write` |
| `POST /newsletter/subscribe` | `newsletter:subscriptions.write` |
| `POST /newsletter/unsubscribe-token` | `newsletter:subscriptions.write` |
| `GET /newsletter/preferences` | `newsletter:subscriptions.read` |
| `POST /newsletter/preferences` | `newsletter:subscriptions.write` |
| `GET /newsletter/subscriptions` | `newsletter:list.read``.global` |
| `POST /newsletter/one-click-unsubscribe-token(s)` | `newsletter:events.write``.global` |
| `POST /subscriptions/disable` | `newsletter:events.write``.global` |
| `POST /integrations/send-engine/webhook-clients/upsert` | `newsletter:events.write.global` |
| `POST /file-access/download-tokens` | `files:download.delegate` |
| `POST /file-access/download-tokens/validate` | `files:download.read` |
以下端點依一次性 token 操作,不要求 OAuth scope`GET /newsletter/confirm``POST /newsletter/unsubscribe`。公開 auth endpointregister、forgot/reset、email verify亦不屬於 scope 授權模型。

View File

@ -25,11 +25,16 @@ http://localhost:5243
"WebBaseUrl": "http://localhost:5080",
"WebLoginClientId": "<web_login client id>",
"ServiceClientId": "<service client id>",
"ServiceClientSecret": "<service client secret>"
"ServiceClientSecret": ""
}
}
```
不要將 OAuth client secret 寫入或提交到 `appsettings.json`。建議使用環境變數
`MemberCenter__ServiceClientSecret`,或僅在測試表單暫時輸入;設定於 server-side 的 secret 不會回填至 HTML。
Repo 曾包含的 TestSite service client secret 必須在對應環境撤銷rotate只從 Git 移除不會使舊 secret 失效。
`web_login` OAuth client
- `usage=web_login`
- `client_type=public`
@ -56,14 +61,11 @@ Auth__CookieDomain=<shared cookie domain, production subdomain SSO only>
1. Redirect login 拿 token
2. Redirect logout 清除 Member Center Web session 並回到 TestSite
3. API login 拿 token
4. `GET /user/profile`
5. `POST /user/profile`
6. `GET /user/addresses`
7. `POST /user/addresses`
8. `GET /user/subscriptions`
9. `POST /user/subscriptions/{id}/unsubscribe`
10. service token 呼叫 `GET /user/profile/by-email`
11. service token 呼叫 `GET /user/addresses/by-email`
3. `GET /user/profile`
4. `POST /user/profile`
5. `GET /user/addresses`
6. `POST /user/addresses`
7. `GET /user/subscriptions`
8. `POST /user/subscriptions/{id}/unsubscribe`
測試站只做 happy path不取代完整自動化測試。

View File

@ -60,6 +60,7 @@
- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret可旋轉可選 `usage=tenant_api` / `send_api` / `web_login` / `webhook_outbound` / `platform_service` / `file_api``platform_service` / `web_login` 可不指定 tenant
- `redirect_uris``web_login` / `webhook_outbound` 需要;其餘 usage 不需要
- `tenant_api` / `send_api` / `platform_service` / `file_api` 強制 `client_type=confidential`
- 既有 public client 改為 confidential 時會自動產生新的 client_secret並只顯示一次之後需使用 rotate secret 重新產生
- UC-13 電子報清單管理: `/admin/newsletter-lists`
- UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export`
- UC-15 審計紀錄查詢: `/admin/audit-logs`

View File

@ -67,6 +67,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/TokenResponse'
'429':
description: Too many token requests from this source IP
/.well-known/openid-configuration:
get:
@ -100,42 +102,6 @@ paths:
schema:
$ref: '#/components/schemas/UserProfile'
/auth/login:
post:
summary: API login
security: []
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/PasswordTokenRequest'
responses:
'200':
description: Token response
content:
application/json:
schema:
$ref: '#/components/schemas/TokenResponse'
/auth/refresh:
post:
summary: Refresh token
security: []
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/RefreshTokenRequest'
responses:
'200':
description: Token response
content:
application/json:
schema:
$ref: '#/components/schemas/TokenResponse'
/auth/logout:
post:
summary: Logout current authenticated session
@ -181,6 +147,8 @@ paths:
responses:
'204':
description: Password reset
'429':
description: Too many password reset token attempts from this source IP
/auth/email/verify:
get:
@ -198,10 +166,13 @@ paths:
responses:
'200':
description: Email verified
'429':
description: Too many email verification token attempts from this source IP
/user/profile:
get:
summary: Get current user profile
description: Returns only the profile owned by the access token subject. Email cannot be used to look up another member.
security:
- BearerAuth: []
responses:
@ -214,8 +185,9 @@ paths:
/newsletter/subscribe:
post:
summary: Subscribe (unauthenticated allowed)
security: []
summary: Create a pending subscription
security: [{ BearerAuth: [] }]
description: Requires tenant-scoped `newsletter:subscriptions.write`; the token tenant must own `list_id`.
requestBody:
required: true
content:
@ -307,7 +279,8 @@ paths:
/newsletter/unsubscribe-token:
post:
summary: Issue unsubscribe token
security: []
security: [{ BearerAuth: [] }]
description: Requires tenant-scoped `newsletter:subscriptions.write`; the token tenant must own `list_id`.
requestBody:
required: true
content:
@ -370,6 +343,8 @@ paths:
description: Unauthorized
'403':
description: Forbidden
'429':
description: Too many token requests for this client or IP
'404':
description: Not found
content:
@ -410,10 +385,14 @@ paths:
description: Unauthorized
'403':
description: Forbidden
'429':
description: Too many token requests for this client or IP
/newsletter/preferences:
get:
summary: Get preferences
security: [{ BearerAuth: [] }]
description: Requires tenant-scoped `newsletter:subscriptions.read`; the token tenant must own `list_id`.
parameters:
- in: query
name: list_id
@ -436,6 +415,10 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Unauthorized
'403':
description: Forbidden
'404':
description: Not found
content:
@ -445,6 +428,8 @@ paths:
post:
summary: Update preferences
security: [{ BearerAuth: [] }]
description: Requires tenant-scoped `newsletter:subscriptions.write`; the token tenant must own `list_id`.
requestBody:
required: true
content:
@ -469,6 +454,10 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Unauthorized
'403':
description: Forbidden
'404':
description: Not found
content:
@ -480,7 +469,9 @@ paths:
get:
summary: List subscriptions by list
security: [{ BearerAuth: [] }]
description: Requires scope `newsletter:list.read`.
description: |
Requires `newsletter:list.read` with a `tenant_id` claim matching the requested list,
or `newsletter:list.read.global` for platform-wide access. Missing tenant claims are denied.
parameters:
- in: query
name: list_id
@ -865,6 +856,9 @@ components:
email: Email
profile: Basic profile
newsletter:list.read: Read newsletter subscriptions by list
newsletter:list.read.global: Read newsletter subscriptions across tenants
newsletter:subscriptions.read: Read tenant subscription preferences
newsletter:subscriptions.write: Create subscriptions and issue unsubscribe tokens
newsletter:send.write: Create/send newsletter jobs
newsletter:send.read: Read newsletter send status
newsletter:events.read: Read newsletter events
@ -879,6 +873,9 @@ components:
tokenUrl: /oauth/token
scopes:
newsletter:list.read: Read newsletter subscriptions by list
newsletter:list.read.global: Read newsletter subscriptions across tenants
newsletter:subscriptions.read: Read tenant subscription preferences
newsletter:subscriptions.write: Create subscriptions and issue unsubscribe tokens
newsletter:send.write: Create/send newsletter jobs
newsletter:send.read: Read newsletter send status
newsletter:events.read: Read newsletter events
@ -911,16 +908,6 @@ components:
email: { type: string, format: email }
password: { type: string }
PasswordTokenRequest:
type: object
required: [grant_type, username, password]
properties:
grant_type: { type: string, enum: [password] }
username: { type: string, format: email }
password: { type: string }
scope: { type: string }
client_id: { type: string }
AuthorizationCodeTokenRequest:
type: object
required: [grant_type, code, redirect_uri, code_verifier]
@ -991,7 +978,7 @@ components:
file_id: { type: string, nullable: true }
object_key: { type: string, nullable: true }
method: { type: string, example: GET }
scope: { type: string, example: files:download.read }
scope: { type: string, example: "files:download.read" }
ValidateFileDownloadTokenRequest:
type: object
@ -1012,7 +999,7 @@ components:
file_id: { type: string, nullable: true }
object_key: { type: string, nullable: true }
method: { type: string, example: GET }
scope: { type: string, example: files:download.read }
scope: { type: string, example: "files:download.read" }
expires_at: { type: string, format: date-time }
UserProfile:

View File

@ -2,8 +2,6 @@ namespace MemberCenter.Api.Contracts;
public sealed record RegisterRequest(string Email, string Password);
public sealed record LoginRequest(string Email, string Password, string? Scope);
public sealed record RefreshRequest(string RefreshToken);
public sealed record ForgotPasswordRequest(string Email);

View File

@ -83,6 +83,14 @@ public class AdminOAuthClientsController : ControllerBase
DisplayName = request.Name,
ClientType = request.ClientType
};
var clientSecret = string.Equals(request.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase)
? GenerateClientSecret()
: null;
if (!string.IsNullOrWhiteSpace(clientSecret))
{
descriptor.ClientSecret = clientSecret;
}
await ApplyPermissionsAsync(descriptor, request.Usage);
foreach (var uri in redirectUris)
@ -103,6 +111,7 @@ public class AdminOAuthClientsController : ControllerBase
descriptor.ClientId,
descriptor.DisplayName,
descriptor.ClientType,
client_secret = clientSecret,
redirect_uris = descriptor.RedirectUris.Select(u => u.ToString())
});
}
@ -167,12 +176,22 @@ public class AdminOAuthClientsController : ControllerBase
var descriptor = new OpenIddictApplicationDescriptor();
await _applicationManager.PopulateAsync(descriptor, app);
var originalClientType = await _applicationManager.GetClientTypeAsync(app);
string? generatedClientSecret = null;
descriptor.DisplayName = request.Name;
descriptor.ClientType = request.ClientType;
if (string.Equals(request.ClientType, OpenIddictConstants.ClientTypes.Public, StringComparison.OrdinalIgnoreCase))
{
descriptor.ClientSecret = null;
}
else if (string.Equals(request.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase)
&& (!string.Equals(originalClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase)
|| string.IsNullOrWhiteSpace(descriptor.ClientSecret)))
{
generatedClientSecret = GenerateClientSecret();
descriptor.ClientSecret = generatedClientSecret;
}
await ApplyPermissionsAsync(descriptor, request.Usage);
descriptor.RedirectUris.Clear();
@ -197,6 +216,7 @@ public class AdminOAuthClientsController : ControllerBase
id,
descriptor.DisplayName,
descriptor.ClientType,
client_secret = generatedClientSecret,
redirect_uris = descriptor.RedirectUris.Select(u => u.ToString())
});
}
@ -245,6 +265,9 @@ public class AdminOAuthClientsController : ControllerBase
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
}
private static string GenerateClientSecret() =>
Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));
private static (List<string> Uris, string? Error) NormalizeRedirectUris(List<string>? redirectUris)
{
if (redirectUris is null || redirectUris.Count == 0)

View File

@ -73,6 +73,7 @@ public class AuthController : ControllerBase
}
[HttpPost("password/reset")]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthTokenConsumption)]
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request)
{
var user = await _userManager.FindByEmailAsync(request.Email);
@ -96,6 +97,7 @@ public class AuthController : ControllerBase
}
[HttpGet("email/verify")]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthTokenConsumption)]
public async Task<IActionResult> VerifyEmail([FromQuery] string token, [FromQuery] string email)
{
var user = await _userManager.FindByEmailAsync(email);

View File

@ -2,6 +2,7 @@ using System.Security.Cryptography;
using System.Text;
using MemberCenter.Api.Contracts;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Configuration;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
@ -10,6 +11,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
using OpenIddict.Abstractions;
using Microsoft.Extensions.Options;
namespace MemberCenter.Api.Controllers;
@ -19,17 +21,18 @@ public sealed class FileAccessController : ControllerBase
{
private const string DownloadScope = "files:download.read";
private const string TokenPurpose = "file_access_download";
private static readonly TimeSpan DefaultLifetime = TimeSpan.FromMinutes(5);
private static readonly TimeSpan MinimumLifetime = TimeSpan.FromSeconds(30);
private static readonly TimeSpan MaximumLifetime = TimeSpan.FromMinutes(15);
private readonly MemberCenterDbContext _dbContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly FileAccessTokenOptions _tokenOptions;
public FileAccessController(MemberCenterDbContext dbContext, UserManager<ApplicationUser> userManager)
public FileAccessController(
MemberCenterDbContext dbContext,
UserManager<ApplicationUser> userManager,
IOptions<FileAccessTokenOptions> tokenOptions)
{
_dbContext = dbContext;
_userManager = userManager;
_tokenOptions = tokenOptions.Value;
}
[Authorize(Policy = "FilesDownloadDelegate")]
@ -44,7 +47,7 @@ public sealed class FileAccessController : ControllerBase
if (!IsTenantAllowed(request.TenantId))
{
return BadRequest("tenant_id does not match token tenant scope.");
return Forbid();
}
if (request.UserId == Guid.Empty)
@ -151,8 +154,7 @@ public sealed class FileAccessController : ControllerBase
private bool IsTenantAllowed(Guid tenantId)
{
var tokenTenantId = User.FindFirst("tenant_id")?.Value;
return string.IsNullOrWhiteSpace(tokenTenantId)
|| (Guid.TryParse(tokenTenantId, out var parsed) && parsed == tenantId);
return Guid.TryParse(tokenTenantId, out var parsed) && parsed == tenantId;
}
private static string? ValidateBoundary(Guid tenantId, string? fileId, string? objectKey, string? method)
@ -175,20 +177,22 @@ public sealed class FileAccessController : ControllerBase
return null;
}
private static TimeSpan ResolveLifetime(int? expiresInSeconds)
private TimeSpan ResolveLifetime(int? expiresInSeconds)
{
if (!expiresInSeconds.HasValue)
{
return DefaultLifetime;
return TimeSpan.FromSeconds(_tokenOptions.DefaultLifetimeSeconds);
}
var requested = TimeSpan.FromSeconds(expiresInSeconds.Value);
if (requested < MinimumLifetime)
var minimum = TimeSpan.FromSeconds(_tokenOptions.MinimumLifetimeSeconds);
if (requested < minimum)
{
return MinimumLifetime;
return minimum;
}
return requested > MaximumLifetime ? MaximumLifetime : requested;
var maximum = TimeSpan.FromSeconds(_tokenOptions.MaximumLifetimeSeconds);
return requested > maximum ? maximum : requested;
}
private static string? NormalizeOptional(string? value)

View File

@ -1,10 +1,12 @@
using MemberCenter.Api.Contracts;
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using MemberCenter.Infrastructure.Configuration;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using OpenIddict.Abstractions;
using Microsoft.Extensions.Options;
namespace MemberCenter.Api.Controllers;
@ -13,16 +15,30 @@ namespace MemberCenter.Api.Controllers;
public class NewsletterController : ControllerBase
{
private readonly INewsletterService _newsletterService;
private readonly INewsletterListService _newsletterListService;
private readonly NewsletterTokenOptions _tokenOptions;
public NewsletterController(INewsletterService newsletterService)
public NewsletterController(
INewsletterService newsletterService,
INewsletterListService newsletterListService,
IOptions<NewsletterTokenOptions> tokenOptions)
{
_newsletterService = newsletterService;
_newsletterListService = newsletterListService;
_tokenOptions = tokenOptions.Value;
}
[HttpPost("subscribe")]
[Authorize]
[EnableRateLimiting(RateLimitPolicyNames.PublicNewsletterSubscribe)]
public async Task<IActionResult> Subscribe([FromBody] SubscribeRequest request)
{
var authorizationFailure = await ValidateTenantListAccessAsync(request.ListId, "newsletter:subscriptions.write");
if (authorizationFailure is not null)
{
return authorizationFailure;
}
var result = await _newsletterService.SubscribeAsync(request.ListId, request.Email, request.Preferences);
if (result is null)
{
@ -79,6 +95,7 @@ public class NewsletterController : ControllerBase
}
[HttpPost("unsubscribe-token")]
[Authorize]
[EnableRateLimiting(RateLimitPolicyNames.PublicNewsletterUnsubscribeToken)]
public async Task<IActionResult> IssueUnsubscribeToken([FromBody] IssueUnsubscribeTokenRequest request)
{
@ -87,6 +104,12 @@ public class NewsletterController : ControllerBase
return BadRequest("Both list_id and email are required.");
}
var authorizationFailure = await ValidateTenantListAccessAsync(request.ListId, "newsletter:subscriptions.write");
if (authorizationFailure is not null)
{
return authorizationFailure;
}
var token = await _newsletterService.IssueUnsubscribeTokenAsync(request.ListId, request.Email);
if (token is null)
{
@ -100,6 +123,7 @@ public class NewsletterController : ControllerBase
}
[Authorize]
[EnableRateLimiting(RateLimitPolicyNames.NewsletterOneClickToken)]
[HttpPost("one-click-unsubscribe-token")]
public async Task<IActionResult> IssueOneClickUnsubscribeToken([FromBody] IssueOneClickUnsubscribeTokenRequest request)
{
@ -115,9 +139,9 @@ public class NewsletterController : ControllerBase
return BadRequest("tenant_id, list_id, subscriber_id are required.");
}
if (!hasGlobalScope && TryGetTenantId(User, out var tokenTenantId) && tokenTenantId != request.TenantId)
if (!hasGlobalScope && (!TryGetTenantId(User, out var tokenTenantId) || tokenTenantId != request.TenantId))
{
return BadRequest("tenant_id does not match token tenant scope.");
return Forbid();
}
var token = await _newsletterService.IssueOneClickUnsubscribeTokenAsync(request.TenantId, request.ListId, request.SubscriberId);
@ -133,6 +157,7 @@ public class NewsletterController : ControllerBase
}
[Authorize]
[EnableRateLimiting(RateLimitPolicyNames.NewsletterOneClickToken)]
[HttpPost("one-click-unsubscribe-tokens")]
public async Task<IActionResult> IssueOneClickUnsubscribeTokens([FromBody] IssueOneClickUnsubscribeTokensRequest request)
{
@ -148,14 +173,14 @@ public class NewsletterController : ControllerBase
return BadRequest("tenant_id, list_id, subscriber_ids are required.");
}
if (request.SubscriberIds.Count > 1000)
if (request.SubscriberIds.Count > _tokenOptions.OneClickBatchSizeLimit)
{
return BadRequest("subscriber_ids exceeds maximum batch size (1000).");
return BadRequest($"subscriber_ids exceeds maximum batch size ({_tokenOptions.OneClickBatchSizeLimit}).");
}
if (!hasGlobalScope && TryGetTenantId(User, out var tokenTenantId) && tokenTenantId != request.TenantId)
if (!hasGlobalScope && (!TryGetTenantId(User, out var tokenTenantId) || tokenTenantId != request.TenantId))
{
return BadRequest("tenant_id does not match token tenant scope.");
return Forbid();
}
var items = await _newsletterService.IssueOneClickUnsubscribeTokensAsync(request.TenantId, request.ListId, request.SubscriberIds);
@ -171,6 +196,7 @@ public class NewsletterController : ControllerBase
}
[HttpGet("preferences")]
[Authorize]
public async Task<IActionResult> Preferences([FromQuery(Name = "list_id")] Guid? listId, [FromQuery] string? email)
{
if (!listId.HasValue || listId.Value == Guid.Empty || string.IsNullOrWhiteSpace(email))
@ -178,6 +204,12 @@ public class NewsletterController : ControllerBase
return BadRequest("Both list_id and email are required.");
}
var authorizationFailure = await ValidateTenantListAccessAsync(listId.Value, "newsletter:subscriptions.read");
if (authorizationFailure is not null)
{
return authorizationFailure;
}
var subscription = await _newsletterService.GetPreferencesAsync(listId.Value, email);
if (subscription is null)
{
@ -195,6 +227,7 @@ public class NewsletterController : ControllerBase
}
[HttpPost("preferences")]
[Authorize]
public async Task<IActionResult> UpdatePreferences([FromBody] UpdatePreferencesRequest request)
{
if (request.ListId == Guid.Empty || string.IsNullOrWhiteSpace(request.Email))
@ -202,6 +235,12 @@ public class NewsletterController : ControllerBase
return BadRequest("Both list_id and email are required.");
}
var authorizationFailure = await ValidateTenantListAccessAsync(request.ListId, "newsletter:subscriptions.write");
if (authorizationFailure is not null)
{
return authorizationFailure;
}
var subscription = await _newsletterService.UpdatePreferencesAsync(request.ListId, request.Email, request.Preferences);
if (subscription is null)
{
@ -222,7 +261,9 @@ public class NewsletterController : ControllerBase
[HttpGet("subscriptions")]
public async Task<IActionResult> ListSubscriptions([FromQuery(Name = "list_id")] Guid listId)
{
if (!HasScope(User, "newsletter:list.read"))
var hasTenantScope = HasScope(User, "newsletter:list.read");
var hasGlobalScope = HasScope(User, "newsletter:list.read.global");
if (!hasTenantScope && !hasGlobalScope)
{
return Forbid();
}
@ -232,6 +273,17 @@ public class NewsletterController : ControllerBase
return BadRequest("list_id is required.");
}
var list = await _newsletterListService.GetAsync(listId);
if (list is null)
{
return NotFound();
}
if (!hasGlobalScope && (!TryGetTenantId(User, out var tokenTenantId) || tokenTenantId != list.TenantId))
{
return Forbid();
}
var subscriptions = await _newsletterService.ListSubscriptionsAsync(listId);
return Ok(subscriptions.Select(s => new
{
@ -251,6 +303,29 @@ public class NewsletterController : ControllerBase
return values.Contains(scope, StringComparer.Ordinal);
}
private async Task<IActionResult?> ValidateTenantListAccessAsync(Guid listId, string scope)
{
if (!HasScope(User, scope))
{
return Forbid();
}
if (listId == Guid.Empty)
{
return BadRequest("list_id is required.");
}
var list = await _newsletterListService.GetAsync(listId);
if (list is null)
{
return NotFound("List not found.");
}
return TryGetTenantId(User, out var tenantId) && tenantId == list.TenantId
? null
: Forbid();
}
private static bool TryGetTenantId(System.Security.Claims.ClaimsPrincipal user, out Guid tenantId)
{
tenantId = Guid.Empty;

View File

@ -1,10 +1,12 @@
using MemberCenter.Api.Extensions;
using MemberCenter.Application.Abstractions;
using MemberCenter.Infrastructure.Identity;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using System.Text.Json;
using System.Web;
namespace MemberCenter.Api.Controllers;
@ -13,20 +15,24 @@ namespace MemberCenter.Api.Controllers;
public class OAuthController : ControllerBase
{
private const string SecurityStampClaimType = "AspNet.Identity.SecurityStamp";
private readonly string _memberCenterAudience;
private readonly string _webLoginUrl;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IOpenIddictApplicationManager _applicationManager;
private readonly IAuthResourceRegistryService _authResourceRegistry;
public OAuthController(
IConfiguration configuration,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager)
SignInManager<ApplicationUser> signInManager,
IOpenIddictApplicationManager applicationManager,
IAuthResourceRegistryService authResourceRegistry)
{
_memberCenterAudience = configuration["Auth:MemberCenterAudience"] ?? "member_center_api";
_webLoginUrl = configuration["Auth:WebLoginUrl"] ?? "/account/login";
_userManager = userManager;
_signInManager = signInManager;
_applicationManager = applicationManager;
_authResourceRegistry = authResourceRegistry;
}
[HttpGet("/oauth/authorize")]
@ -55,14 +61,33 @@ public class OAuthController : ControllerBase
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var application = string.IsNullOrWhiteSpace(request.ClientId)
? null
: await _applicationManager.FindByClientIdAsync(request.ClientId);
if (application is null)
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var properties = await _applicationManager.GetPropertiesAsync(application);
var usage = properties.TryGetValue("usage", out var usageValue)
? usageValue.ValueKind == JsonValueKind.String ? usageValue.GetString() : usageValue.ToString()
: null;
if (string.IsNullOrWhiteSpace(usage))
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var scopes = await _authResourceRegistry.GetAllowedScopesForUsageAsync(usage, HttpContext.RequestAborted);
var principal = await _signInManager.CreateUserPrincipalAsync(user);
principal.SetClaim(OpenIddictConstants.Claims.Subject, user.Id.ToString());
if (!string.IsNullOrWhiteSpace(user.SecurityStamp))
{
principal.SetClaim(SecurityStampClaimType, user.SecurityStamp);
}
principal.SetScopes(request.GetScopes());
principal.SetResources(_memberCenterAudience);
principal.SetScopes(scopes);
principal.SetResources(await _authResourceRegistry.ResolveAudiencesAsync(scopes, HttpContext.RequestAborted));
foreach (var claim in principal.Claims)
{
claim.SetDestinations(ClaimsExtensions.GetDestinations(claim));

View File

@ -74,6 +74,11 @@ public class SubscriptionsController : ControllerBase
return BadRequest("reason must be one of: hard_bounce, soft_bounce_threshold, complaint, suppression.");
}
if (!hasGlobalScope && (!TryGetTenantId(User, out var tokenTenantId) || tokenTenantId != request.TenantId))
{
return Forbid();
}
// TEST-ONLY BEHAVIOR: in dry-run mode, do not execute DB read/write; only emit planned operations.
if (_configuration.GetValue<bool>(DisableSubscriptionDryRunNoDbKey))
{
@ -102,11 +107,6 @@ public class SubscriptionsController : ControllerBase
});
}
if (!hasGlobalScope && TryGetTenantId(User, out var tokenTenantId) && tokenTenantId != request.TenantId)
{
return BadRequest("tenant_id does not match token tenant scope.");
}
var target = await (
from subscription in _dbContext.NewsletterSubscriptions
join list in _dbContext.NewsletterLists on subscription.ListId equals list.Id

View File

@ -1,9 +1,11 @@
using MemberCenter.Api.Extensions;
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using MemberCenter.Infrastructure.Identity;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using System.Security.Claims;
@ -16,25 +18,24 @@ public class TokenController : ControllerBase
{
private const string SecurityStampClaimType = "AspNet.Identity.SecurityStamp";
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IOpenIddictApplicationManager _applicationManager;
private readonly IAuthResourceRegistryService _authResourceRegistry;
private readonly ISecuritySettingsService _securitySettingsService;
public TokenController(
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
IOpenIddictApplicationManager applicationManager,
IAuthResourceRegistryService authResourceRegistry)
IAuthResourceRegistryService authResourceRegistry,
ISecuritySettingsService securitySettingsService)
{
_userManager = userManager;
_signInManager = signInManager;
_applicationManager = applicationManager;
_authResourceRegistry = authResourceRegistry;
_securitySettingsService = securitySettingsService;
}
[HttpPost("/oauth/token")]
[HttpPost("/auth/login")]
[HttpPost("/auth/refresh")]
[EnableRateLimiting(RateLimitPolicyNames.OAuthToken)]
public async Task<IActionResult> Exchange()
{
var request = HttpContext.Features.Get<OpenIddictServerAspNetCoreFeature>()?.Transaction?.Request;
@ -43,42 +44,6 @@ public class TokenController : ControllerBase
return BadRequest("Invalid OpenIddict request.");
}
if (request.IsPasswordGrantType())
{
var user = await _userManager.FindByEmailAsync(request.Username ?? string.Empty);
if (user is null || user.DisabledAt.HasValue)
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var signInResult = await _signInManager.CheckPasswordSignInAsync(user, request.Password ?? string.Empty, true);
if (!signInResult.Succeeded)
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var principal = await _signInManager.CreateUserPrincipalAsync(user);
principal.SetClaim(OpenIddictConstants.Claims.Subject, user.Id.ToString());
if (!string.IsNullOrWhiteSpace(user.SecurityStamp))
{
principal.SetClaim(SecurityStampClaimType, user.SecurityStamp);
}
var scopes = request.Scope.GetScopesOrDefault();
principal.SetScopes(scopes);
principal.SetResources(await ResolveResourcesAsync(scopes));
foreach (var claim in principal.Claims)
{
claim.SetDestinations(ClaimsExtensions.GetDestinations(claim));
}
user.LastLoginAt = DateTimeOffset.UtcNow;
user.LastSeenAt = user.LastLoginAt;
await _userManager.UpdateAsync(user);
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType())
{
var authenticateResult = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
@ -93,6 +58,7 @@ public class TokenController : ControllerBase
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
await ApplyTokenLifetimesAsync(principal);
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
@ -129,9 +95,10 @@ public class TokenController : ControllerBase
}
}
string? usage = null;
if (properties.TryGetValue("usage", out var usageValue))
{
var usage = usageValue.ValueKind == JsonValueKind.String
usage = usageValue.ValueKind == JsonValueKind.String
? usageValue.GetString()
: usageValue.ToString();
if (!string.IsNullOrWhiteSpace(usage))
@ -140,8 +107,13 @@ public class TokenController : ControllerBase
}
}
if (string.IsNullOrWhiteSpace(usage))
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var principal = new ClaimsPrincipal(identity);
var scopes = request.Scope.GetScopesOrDefault();
var scopes = await _authResourceRegistry.GetAllowedScopesForUsageAsync(usage, HttpContext.RequestAborted);
principal.SetScopes(scopes);
principal.SetResources(await ResolveResourcesAsync(scopes));
@ -150,6 +122,7 @@ public class TokenController : ControllerBase
claim.SetDestinations(ClaimsExtensions.GetDestinations(claim));
}
await ApplyTokenLifetimesAsync(principal);
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
@ -180,4 +153,13 @@ public class TokenController : ControllerBase
{
return await _authResourceRegistry.ResolveAudiencesAsync(scopes, HttpContext.RequestAborted);
}
private async Task ApplyTokenLifetimesAsync(ClaimsPrincipal principal)
{
var settings = await _securitySettingsService.GetTokenLifetimeSettingsAsync();
var accessMinutes = Math.Clamp(settings.AccessTokenMinutes, 5, 1440);
var refreshDays = Math.Clamp(settings.RefreshTokenDays, 1, 365);
principal.SetAccessTokenLifetime(TimeSpan.FromMinutes(accessMinutes));
principal.SetRefreshTokenLifetime(TimeSpan.FromDays(refreshDays));
}
}

View File

@ -179,138 +179,4 @@ public class UserController : ControllerBase
var subscription = await _newsletterService.UnsubscribeForUserAsync(user.Id, id);
return subscription is null ? NotFound() : Ok(subscription);
}
[Authorize(Policy = "ProfileBasicRead")]
[HttpGet("profile/by-email")]
public async Task<IActionResult> ProfileByEmail([FromQuery] string email)
{
var user = await _userManager.FindByEmailAsync(email);
if (user is null)
{
return NotFound();
}
var profile = await _profileService.GetProfileAsync(user.Id);
return Ok(profile);
}
[Authorize(Policy = "ProfileBasicWrite")]
[HttpPost("profile/by-email")]
public async Task<IActionResult> SaveProfileByEmail([FromQuery] string email, [FromBody] SaveProfileRequest request)
{
var user = await _userManager.FindByEmailAsync(email);
if (user is null)
{
return NotFound();
}
try
{
var profile = await _profileService.SaveProfileAsync(user.Id, new SaveUserProfileRequest(
request.LastName,
request.FirstName,
request.NickName,
request.MobilePhone,
request.LandlinePhone,
request.DateOfBirth,
request.Gender,
request.CompanyName,
request.Department,
request.JobTitle,
request.CompanyPhone,
request.TaxId,
request.InvoiceTitle,
request.Remark));
return Ok(profile);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[Authorize(Policy = "ProfileAddressesRead")]
[HttpGet("addresses/by-email")]
public async Task<IActionResult> AddressesByEmail([FromQuery] string email)
{
var user = await _userManager.FindByEmailAsync(email);
if (user is null)
{
return NotFound();
}
return Ok(await _profileService.ListAddressesAsync(user.Id));
}
[Authorize(Policy = "ProfileAddressesWrite")]
[HttpPost("addresses/by-email")]
public async Task<IActionResult> SaveAddressByEmail([FromQuery] string email, [FromBody] SaveAddressRequest request)
{
var user = await _userManager.FindByEmailAsync(email);
if (user is null)
{
return NotFound();
}
try
{
var address = await _profileService.SaveAddressAsync(user.Id, new SaveUserAddressRequest(
request.Id,
request.Label,
request.RecipientName,
request.RecipientPhone,
request.CountryCode,
request.PostalCode,
request.StateRegion,
request.City,
request.District,
request.AddressLine1,
request.AddressLine2,
request.CompanyName,
request.Usage,
request.IsDefault,
request.AddressMetaJson));
return Ok(address);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[Authorize(Policy = "ProfileAddressesWrite")]
[HttpDelete("addresses/by-email/{id:guid}")]
public async Task<IActionResult> DeleteAddressByEmail(Guid id, [FromQuery] string email)
{
var user = await _userManager.FindByEmailAsync(email);
if (user is null)
{
return NotFound();
}
try
{
await _profileService.DeleteAddressAsync(user.Id, id);
return NoContent();
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[Authorize(Policy = "ProfileSubscriptionsRead")]
[HttpGet("subscriptions/by-email")]
public async Task<IActionResult> SubscriptionsByEmail([FromQuery] string email)
{
return Ok(await _newsletterService.ListSubscriptionsByEmailAsync(email));
}
[Authorize(Policy = "ProfileSubscriptionsWrite")]
[HttpPost("subscriptions/by-email/{id:guid}/unsubscribe")]
public async Task<IActionResult> UnsubscribeByEmail(Guid id, [FromQuery] string email)
{
var subscription = await _newsletterService.UnsubscribeByEmailAsync(email, id);
return subscription is null ? NotFound() : Ok(subscription);
}
}

View File

@ -5,21 +5,6 @@ namespace MemberCenter.Api.Extensions;
public static class ClaimsExtensions
{
public static IEnumerable<string> GetScopesOrDefault(this string? scope)
{
if (string.IsNullOrWhiteSpace(scope))
{
return new[]
{
OpenIddictConstants.Scopes.OpenId,
OpenIddictConstants.Scopes.Email,
OpenIddictConstants.Scopes.Profile
};
}
return scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
public static IEnumerable<string> GetDestinations(System.Security.Claims.Claim claim)
{
return claim.Type switch

View File

@ -22,17 +22,62 @@ var builder = WebApplication.CreateBuilder(args);
var pathBase = NormalizePathBase(builder.Configuration["PathBase"]);
var issuer = builder.Configuration["Auth:Issuer"];
var issuerUri = ParseAbsoluteUriOrThrow(issuer, "Auth:Issuer");
var allowInsecureHttp = builder.Configuration.GetValue("Auth:AllowInsecureHttp", false);
var allowInternalHttpTokenEndpoint = builder.Configuration.GetValue("Auth:AllowInternalHttpTokenEndpoint", false);
var identitySecurity = builder.Configuration.GetSection("IdentitySecurity").Get<IdentitySecurityOptions>() ?? new();
var rateLimits = builder.Configuration.GetSection("RateLimits").Get<RateLimitSettings>() ?? new();
var newsletterTokens = builder.Configuration.GetSection("NewsletterTokens").Get<NewsletterTokenOptions>() ?? new();
var fileAccessTokens = builder.Configuration.GetSection("FileAccessTokens").Get<FileAccessTokenOptions>() ?? new();
SecurityRuntimeOptionsValidator.Validate(identitySecurity);
SecurityRuntimeOptionsValidator.Validate(rateLimits);
SecurityRuntimeOptionsValidator.Validate(newsletterTokens);
SecurityRuntimeOptionsValidator.Validate(fileAccessTokens);
var connectionString = builder.Configuration.GetConnectionString("Default");
if (string.IsNullOrWhiteSpace(connectionString))
{
if (!builder.Environment.IsDevelopment())
{
throw new InvalidOperationException("ConnectionStrings:Default is required outside Development.");
}
builder.Services.AddDataProtection()
connectionString = "Host=localhost;Database=member_center;Username=postgres;Password=postgres";
}
var requireProductionCertificates = !builder.Environment.IsDevelopment();
var signingCertificate = CertificateLoader.LoadFromConfiguration(
builder.Configuration, "Auth:Certificates:Signing", requireProductionCertificates);
var previousSigningCertificates = CertificateLoader.LoadPreviousFromConfiguration(
builder.Configuration, "Auth:Certificates:Signing");
var encryptionCertificate = CertificateLoader.LoadFromConfiguration(
builder.Configuration, "Auth:Certificates:Encryption", requireProductionCertificates);
var previousEncryptionCertificates = CertificateLoader.LoadPreviousFromConfiguration(
builder.Configuration, "Auth:Certificates:Encryption");
var dataProtectionCertificate = CertificateLoader.LoadFromConfiguration(
builder.Configuration, "DataProtection:Certificate", requireProductionCertificates);
var previousDataProtectionCertificates = CertificateLoader.LoadPreviousFromConfiguration(
builder.Configuration, "DataProtection:Certificate");
if (!builder.Environment.IsDevelopment() && issuerUri is null)
{
throw new InvalidOperationException("Auth:Issuer is required outside Development.");
}
if (!builder.Environment.IsDevelopment() && issuerUri?.Scheme != Uri.UriSchemeHttps)
{
throw new InvalidOperationException("Auth:Issuer must use HTTPS outside Development.");
}
var dataProtectionBuilder = builder.Services.AddDataProtection()
.SetApplicationName("MemberCenter")
.PersistKeysToDbContext<MemberCenterDbContext>();
if (dataProtectionCertificate is not null)
{
dataProtectionBuilder.ProtectKeysWithCertificate(dataProtectionCertificate);
if (previousDataProtectionCertificates.Count > 0)
{
dataProtectionBuilder.UnprotectKeysWithAnyCertificate(
new[] { dataProtectionCertificate }.Concat(previousDataProtectionCertificates).ToArray());
}
}
builder.Services.AddDbContext<MemberCenterDbContext>(options =>
{
var connectionString = builder.Configuration.GetConnectionString("Default")
?? "Host=localhost;Database=member_center;Username=postgres;Password=postgres";
options.UseNpgsql(connectionString);
options.UseOpenIddict();
});
@ -41,14 +86,14 @@ builder.Services
.AddIdentity<ApplicationUser, ApplicationRole>(options =>
{
options.User.RequireUniqueEmail = true;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8;
options.Lockout.AllowedForNewUsers = true;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
options.Password.RequireDigit = identitySecurity.Password.RequireDigit;
options.Password.RequireLowercase = identitySecurity.Password.RequireLowercase;
options.Password.RequireUppercase = identitySecurity.Password.RequireUppercase;
options.Password.RequireNonAlphanumeric = identitySecurity.Password.RequireNonAlphanumeric;
options.Password.RequiredLength = identitySecurity.Password.RequiredLength;
options.Lockout.AllowedForNewUsers = identitySecurity.Lockout.AllowedForNewUsers;
options.Lockout.MaxFailedAccessAttempts = identitySecurity.Lockout.MaxFailedAccessAttempts;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(identitySecurity.Lockout.DefaultLockoutMinutes);
})
.AddEntityFrameworkStores<MemberCenterDbContext>()
.AddDefaultTokenProviders();
@ -76,10 +121,7 @@ builder.Services.AddOpenIddict()
.AddServer(options =>
{
options.SetAuthorizationEndpointUris(WithPathBase(pathBase, "/oauth/authorize"));
options.SetTokenEndpointUris(
WithPathBase(pathBase, "/oauth/token"),
WithPathBase(pathBase, "/auth/login"),
WithPathBase(pathBase, "/auth/refresh"));
options.SetTokenEndpointUris(WithPathBase(pathBase, "/oauth/token"));
options.SetLogoutEndpointUris(WithPathBase(pathBase, "/auth/logout"));
if (issuerUri is not null)
{
@ -89,11 +131,8 @@ builder.Services.AddOpenIddict()
options.AllowAuthorizationCodeFlow()
.RequireProofKeyForCodeExchange();
options.AllowRefreshTokenFlow();
options.AllowPasswordFlow();
options.AllowClientCredentialsFlow();
options.AcceptAnonymousClients();
options.RegisterScopes(
OpenIddictConstants.Scopes.OpenId,
OpenIddictConstants.Scopes.Email,
@ -105,6 +144,9 @@ builder.Services.AddOpenIddict()
"profile:subscriptions.read",
"profile:subscriptions.write",
"newsletter:list.read",
"newsletter:list.read.global",
"newsletter:subscriptions.read",
"newsletter:subscriptions.write",
"newsletter:send.write",
"newsletter:send.read",
"newsletter:events.read",
@ -116,8 +158,24 @@ builder.Services.AddOpenIddict()
"files:metadata.read",
"files:delete");
if (signingCertificate is not null && encryptionCertificate is not null)
{
foreach (var certificate in previousSigningCertificates)
{
options.AddSigningCertificate(certificate);
}
foreach (var certificate in previousEncryptionCertificates)
{
options.AddEncryptionCertificate(certificate);
}
options.AddSigningCertificate(signingCertificate);
options.AddEncryptionCertificate(encryptionCertificate);
}
else
{
options.AddDevelopmentEncryptionCertificate();
options.AddDevelopmentSigningCertificate();
}
options.DisableAccessTokenEncryption();
var aspNetCore = options.UseAspNetCore()
@ -126,9 +184,9 @@ builder.Services.AddOpenIddict()
.EnableLogoutEndpointPassthrough()
.EnableStatusCodePagesIntegration();
if (builder.Environment.IsDevelopment() || allowInsecureHttp)
if (builder.Environment.IsDevelopment() || allowInternalHttpTokenEndpoint)
{
// Allows OIDC/OAuth endpoints to operate behind non-HTTPS internal networks/proxies.
// Allows direct OAuth calls over a VPC-private HTTP endpoint while retaining an HTTPS issuer.
aspNetCore.DisableTransportSecurityRequirement();
}
})
@ -154,11 +212,8 @@ builder.Services.AddAuthorization(options =>
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
TrustedForwardedHeaders.Configure(options, builder.Configuration);
});
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
@ -173,16 +228,25 @@ builder.Services.AddRateLimiter(options =>
};
options.AddPolicy(RateLimitPolicyNames.PublicAuthRegister, context =>
CreateFixedWindowLimiter(context, "api-auth-register", permitLimit: 5, TimeSpan.FromMinutes(15)));
CreateFixedWindowLimiter(context, "api-auth-register", rateLimits.Api.AuthRegister));
options.AddPolicy(RateLimitPolicyNames.PublicAuthRecovery, context =>
CreateFixedWindowLimiter(context, "api-auth-recovery", permitLimit: 5, TimeSpan.FromMinutes(15)));
CreateFixedWindowLimiter(context, "api-auth-recovery", rateLimits.Api.AuthRecovery));
options.AddPolicy(RateLimitPolicyNames.PublicAuthTokenConsumption, context =>
CreateFixedWindowLimiter(context, "api-auth-token-consumption", rateLimits.Api.AuthTokenConsumption));
options.AddPolicy(RateLimitPolicyNames.PublicNewsletterSubscribe, context =>
CreateFixedWindowLimiter(context, "api-newsletter-subscribe", permitLimit: 20, TimeSpan.FromMinutes(10)));
CreateFixedWindowLimiter(context, "api-newsletter-subscribe", rateLimits.Api.NewsletterSubscribe));
options.AddPolicy(RateLimitPolicyNames.PublicNewsletterUnsubscribeToken, context =>
CreateFixedWindowLimiter(context, "api-newsletter-unsubscribe-token", permitLimit: 10, TimeSpan.FromMinutes(10)));
CreateFixedWindowLimiter(context, "api-newsletter-unsubscribe-token", rateLimits.Api.NewsletterUnsubscribeToken));
options.AddPolicy(RateLimitPolicyNames.NewsletterOneClickToken, context =>
CreateFixedWindowLimiter(context, "api-newsletter-one-click-token", rateLimits.Api.NewsletterOneClickToken));
options.AddPolicy(RateLimitPolicyNames.OAuthToken, context =>
CreateFixedWindowLimiter(context, "api-oauth-token", rateLimits.Api.OAuthToken));
});
builder.Services.AddControllers();
@ -203,11 +267,17 @@ builder.Services.AddScoped<AdminPermissionService>();
builder.Services.AddScoped<IAdminPermissionChecker>(services => services.GetRequiredService<AdminPermissionService>());
builder.Services.AddScoped<IAdminPermissionSeeder>(services => services.GetRequiredService<AdminPermissionService>());
builder.Services.AddHttpContextAccessor();
builder.Services.Configure<NewsletterTokenOptions>(builder.Configuration.GetSection("NewsletterTokens"));
builder.Services.Configure<FileAccessTokenOptions>(builder.Configuration.GetSection("FileAccessTokens"));
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>();
var app = builder.Build();
var certificateWarningDays = builder.Configuration.GetValue("Certificates:ExpirationWarningDays", 30);
CertificateLoader.LogExpirationWarning(app.Logger, "OpenIddict signing", signingCertificate, certificateWarningDays);
CertificateLoader.LogExpirationWarning(app.Logger, "OpenIddict encryption", encryptionCertificate, certificateWarningDays);
CertificateLoader.LogExpirationWarning(app.Logger, "Data Protection", dataProtectionCertificate, certificateWarningDays);
await EnsureAuthRegistryDefaultsAsync(app.Services);
await EnsureAdminPermissionDefaultsAsync(app.Services);
@ -222,16 +292,19 @@ app.Use(async (context, next) =>
{
if (issuerUri is not null && IsOpenIddictRequest(context.Request.Path))
{
context.Request.Scheme = issuerUri.Scheme;
context.Request.Host = HostString.FromUriComponent(issuerUri);
if (context.Request.IsHttps || allowInternalHttpTokenEndpoint)
{
context.Request.Scheme = issuerUri.Scheme;
}
}
await next();
});
app.UseRouting();
app.UseRateLimiter();
app.UseAuthentication();
app.UseRateLimiter();
app.Use(async (context, next) =>
{
if (context.User.Identity?.IsAuthenticated == true)
@ -319,8 +392,7 @@ static bool IsOpenIddictRequest(PathString path)
static RateLimitPartition<string> CreateFixedWindowLimiter(
HttpContext context,
string policyPrefix,
int permitLimit,
TimeSpan window)
FixedWindowRateLimitSettings settings)
{
var identifier = context.User.Identity?.IsAuthenticated == true
? context.User.FindFirstValue(ClaimTypes.NameIdentifier)
@ -332,8 +404,8 @@ static RateLimitPartition<string> CreateFixedWindowLimiter(
var partitionKey = $"{policyPrefix}:{identifier}";
return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = permitLimit,
Window = window,
PermitLimit = settings.PermitLimit,
Window = TimeSpan.FromSeconds(settings.WindowSeconds),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0,
AutoReplenishment = true

View File

@ -16,6 +16,4 @@ public interface INewsletterService
Task<IReadOnlyList<SubscriptionDto>> ListSubscriptionsAsync(Guid listId);
Task<IReadOnlyList<UserSubscriptionSummaryDto>> ListSubscriptionsForUserAsync(Guid userId);
Task<UserSubscriptionSummaryDto?> UnsubscribeForUserAsync(Guid userId, Guid subscriptionId);
Task<IReadOnlyList<UserSubscriptionSummaryDto>> ListSubscriptionsByEmailAsync(string email);
Task<UserSubscriptionSummaryDto?> UnsubscribeByEmailAsync(string email, Guid subscriptionId);
}

View File

@ -5,6 +5,7 @@ namespace MemberCenter.Application.Abstractions;
public interface ISecuritySettingsService
{
Task<SecuritySettingsDto> GetAsync();
Task<TokenLifetimeSettingsDto> GetTokenLifetimeSettingsAsync();
Task<SmtpSettingsDto> GetSmtpSettingsAsync();
Task SaveAsync(SecuritySettingsDto settings, Guid? actorUserId = null);
Task<int> SendTestEmailAsync(string toEmail, Guid? actorUserId = null);

View File

@ -7,4 +7,7 @@ public static class RateLimitPolicyNames
public const string PublicAuthRecovery = "public-auth-recovery";
public const string PublicNewsletterSubscribe = "public-newsletter-subscribe";
public const string PublicNewsletterUnsubscribeToken = "public-newsletter-unsubscribe-token";
public const string NewsletterOneClickToken = "newsletter-one-click-token";
public const string OAuthToken = "oauth-token";
public const string PublicAuthTokenConsumption = "public-auth-token-consumption";
}

View File

@ -0,0 +1,5 @@
namespace MemberCenter.Application.Models.Admin;
public sealed record TokenLifetimeSettingsDto(
int AccessTokenMinutes,
int RefreshTokenDays);

View File

@ -0,0 +1,90 @@
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace MemberCenter.Infrastructure.Configuration;
public static class CertificateLoader
{
public static void LogExpirationWarning(
ILogger logger,
string name,
X509Certificate2? certificate,
int warningDays)
{
if (certificate is null)
{
return;
}
var remaining = certificate.NotAfter.ToUniversalTime() - DateTime.UtcNow;
if (remaining <= TimeSpan.FromDays(Math.Clamp(warningDays, 1, 180)))
{
logger.LogWarning(
"Certificate {CertificateName} ({Thumbprint}) expires at {ExpiresAtUtc} ({RemainingDays:F1} days remaining).",
name,
certificate.Thumbprint,
certificate.NotAfter.ToUniversalTime(),
remaining.TotalDays);
}
}
public static IReadOnlyList<X509Certificate2> LoadPreviousFromConfiguration(
IConfiguration configuration,
string sectionPath)
{
return configuration.GetSection($"{sectionPath}:Previous")
.GetChildren()
.Select(section => LoadFromConfiguration(configuration, section.Path, required: true)!)
.ToArray();
}
public static X509Certificate2? LoadFromConfiguration(
IConfiguration configuration,
string sectionPath,
bool required)
{
var path = configuration[$"{sectionPath}:Path"]?.Trim();
var password = configuration[$"{sectionPath}:Password"];
if (string.IsNullOrWhiteSpace(path))
{
if (required)
{
throw new InvalidOperationException($"{sectionPath}:Path is required outside Development.");
}
return null;
}
if (!File.Exists(path))
{
throw new InvalidOperationException($"Certificate file configured by {sectionPath}:Path does not exist: {path}");
}
X509Certificate2 certificate;
try
{
certificate = new X509Certificate2(path, password, X509KeyStorageFlags.EphemeralKeySet);
}
catch (Exception ex)
{
throw new InvalidOperationException($"Certificate configured by {sectionPath} could not be loaded.", ex);
}
var now = DateTime.UtcNow;
if (!certificate.HasPrivateKey)
{
certificate.Dispose();
throw new InvalidOperationException($"Certificate configured by {sectionPath} must contain a private key.");
}
if (now < certificate.NotBefore.ToUniversalTime() || now >= certificate.NotAfter.ToUniversalTime())
{
certificate.Dispose();
throw new InvalidOperationException($"Certificate configured by {sectionPath} is not currently valid.");
}
return certificate;
}
}

View File

@ -0,0 +1,43 @@
namespace MemberCenter.Infrastructure.Configuration;
public static class ReturnUrlValidator
{
public static bool IsAllowedExternal(Uri candidate, IEnumerable<string?> allowedValues, bool allowInsecureHttp) =>
allowedValues.Any(value => IsWithinAllowedPrefix(candidate, value, allowInsecureHttp));
public static bool IsWithinAllowedPrefix(Uri candidate, string? allowedValue, bool allowInsecureHttp)
{
if (string.IsNullOrWhiteSpace(allowedValue) ||
!Uri.TryCreate(allowedValue, UriKind.Absolute, out var allowed) ||
!IsHttpScheme(allowed.Scheme) ||
!string.IsNullOrEmpty(allowed.UserInfo) ||
!string.IsNullOrEmpty(allowed.Query) ||
!string.IsNullOrEmpty(allowed.Fragment))
{
return false;
}
if (!allowInsecureHttp && (candidate.Scheme != Uri.UriSchemeHttps || allowed.Scheme != Uri.UriSchemeHttps))
{
return false;
}
if (!IsHttpScheme(candidate.Scheme) ||
!string.IsNullOrEmpty(candidate.UserInfo) ||
!string.Equals(candidate.Scheme, allowed.Scheme, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(candidate.IdnHost, allowed.IdnHost, StringComparison.OrdinalIgnoreCase) ||
candidate.Port != allowed.Port)
{
return false;
}
var allowedPath = allowed.AbsolutePath.TrimEnd('/');
return string.IsNullOrEmpty(allowedPath) ||
string.Equals(candidate.AbsolutePath, allowedPath, StringComparison.Ordinal) ||
candidate.AbsolutePath.StartsWith($"{allowedPath}/", StringComparison.Ordinal);
}
private static bool IsHttpScheme(string scheme) =>
string.Equals(scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) ||
string.Equals(scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
}

View File

@ -0,0 +1,129 @@
namespace MemberCenter.Infrastructure.Configuration;
public sealed class IdentitySecurityOptions
{
public PasswordPolicyOptions Password { get; set; } = new();
public LockoutPolicyOptions Lockout { get; set; } = new();
}
public sealed class PasswordPolicyOptions
{
public int RequiredLength { get; set; } = 8;
public bool RequireDigit { get; set; } = true;
public bool RequireLowercase { get; set; } = true;
public bool RequireUppercase { get; set; } = true;
public bool RequireNonAlphanumeric { get; set; } = false;
}
public sealed class LockoutPolicyOptions
{
public bool AllowedForNewUsers { get; set; } = true;
public int MaxFailedAccessAttempts { get; set; } = 5;
public int DefaultLockoutMinutes { get; set; } = 15;
}
public sealed class FixedWindowRateLimitSettings
{
public int PermitLimit { get; set; }
public int WindowSeconds { get; set; }
public FixedWindowRateLimitSettings()
{
}
public FixedWindowRateLimitSettings(int permitLimit, int windowSeconds)
{
PermitLimit = permitLimit;
WindowSeconds = windowSeconds;
}
}
public sealed class RateLimitSettings
{
public WebRateLimitSettings Web { get; set; } = new();
public ApiRateLimitSettings Api { get; set; } = new();
}
public sealed class WebRateLimitSettings
{
public FixedWindowRateLimitSettings AuthLogin { get; set; } = new(10, 300);
public FixedWindowRateLimitSettings AuthRegister { get; set; } = new(5, 900);
public FixedWindowRateLimitSettings AuthRecovery { get; set; } = new(5, 900);
public FixedWindowRateLimitSettings AuthTokenConsumption { get; set; } = new(10, 600);
}
public sealed class ApiRateLimitSettings
{
public FixedWindowRateLimitSettings AuthRegister { get; set; } = new(5, 900);
public FixedWindowRateLimitSettings AuthRecovery { get; set; } = new(5, 900);
public FixedWindowRateLimitSettings AuthTokenConsumption { get; set; } = new(10, 600);
public FixedWindowRateLimitSettings NewsletterSubscribe { get; set; } = new(20, 600);
public FixedWindowRateLimitSettings NewsletterUnsubscribeToken { get; set; } = new(10, 600);
public FixedWindowRateLimitSettings NewsletterOneClickToken { get; set; } = new(60, 60);
public FixedWindowRateLimitSettings OAuthToken { get; set; } = new(30, 60);
}
public sealed class NewsletterTokenOptions
{
public int ConfirmTokenLifetimeDays { get; set; } = 7;
public int UnsubscribeTokenLifetimeDays { get; set; } = 7;
public int OneClickTokenLifetimeDays { get; set; } = 7;
public int OneClickBatchSizeLimit { get; set; } = 1000;
}
public sealed class FileAccessTokenOptions
{
public int DefaultLifetimeSeconds { get; set; } = 300;
public int MinimumLifetimeSeconds { get; set; } = 30;
public int MaximumLifetimeSeconds { get; set; } = 900;
}
public static class SecurityRuntimeOptionsValidator
{
public static void Validate(IdentitySecurityOptions options)
{
if (options.Password.RequiredLength < 8)
{
throw new InvalidOperationException("IdentitySecurity:Password:RequiredLength must be at least 8.");
}
if (options.Lockout.MaxFailedAccessAttempts <= 0 || options.Lockout.DefaultLockoutMinutes <= 0)
{
throw new InvalidOperationException("IdentitySecurity lockout values must be greater than zero.");
}
}
public static void Validate(RateLimitSettings options)
{
var policies = new[]
{
options.Web.AuthLogin, options.Web.AuthRegister, options.Web.AuthRecovery, options.Web.AuthTokenConsumption,
options.Api.AuthRegister, options.Api.AuthRecovery, options.Api.AuthTokenConsumption,
options.Api.NewsletterSubscribe, options.Api.NewsletterUnsubscribeToken,
options.Api.NewsletterOneClickToken, options.Api.OAuthToken
};
if (policies.Any(policy => policy.PermitLimit <= 0 || policy.WindowSeconds <= 0))
{
throw new InvalidOperationException("All RateLimits permit and window values must be greater than zero.");
}
}
public static void Validate(NewsletterTokenOptions options)
{
if (options.ConfirmTokenLifetimeDays <= 0 || options.UnsubscribeTokenLifetimeDays <= 0 ||
options.OneClickTokenLifetimeDays <= 0 || options.OneClickBatchSizeLimit <= 0)
{
throw new InvalidOperationException("All NewsletterTokens values must be greater than zero.");
}
}
public static void Validate(FileAccessTokenOptions options)
{
if (options.MinimumLifetimeSeconds <= 0 ||
options.DefaultLifetimeSeconds < options.MinimumLifetimeSeconds ||
options.MaximumLifetimeSeconds < options.DefaultLifetimeSeconds)
{
throw new InvalidOperationException(
"FileAccessTokens must satisfy 0 < MinimumLifetimeSeconds <= DefaultLifetimeSeconds <= MaximumLifetimeSeconds.");
}
}
}

View File

@ -0,0 +1,49 @@
using System.Net;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Configuration;
namespace MemberCenter.Infrastructure.Configuration;
public static class TrustedForwardedHeaders
{
public static void Configure(ForwardedHeadersOptions options, IConfiguration configuration)
{
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
foreach (var value in Split(configuration["ReverseProxy:KnownProxies"]))
{
if (!IPAddress.TryParse(value, out var address))
{
throw new InvalidOperationException($"ReverseProxy:KnownProxies contains an invalid IP address: {value}");
}
options.KnownProxies.Add(address);
}
foreach (var value in Split(configuration["ReverseProxy:KnownNetworks"]))
{
var parts = value.Split('/', 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2 ||
!IPAddress.TryParse(parts[0], out var address) ||
!int.TryParse(parts[1], out var prefixLength) ||
prefixLength < 1 ||
prefixLength > (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork ? 32 : 128))
{
throw new InvalidOperationException($"ReverseProxy:KnownNetworks contains an invalid CIDR: {value}");
}
options.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(address, prefixLength));
}
var hasTrustedProxy = options.KnownProxies.Count > 0 || options.KnownNetworks.Count > 0;
options.ForwardedHeaders = hasTrustedProxy
? ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
: ForwardedHeaders.None;
options.ForwardLimit = Math.Clamp(configuration.GetValue<int?>("ReverseProxy:ForwardLimit") ?? 1, 1, 5);
}
private static IEnumerable<string> Split(string? value) =>
(value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}

View File

@ -5,6 +5,10 @@
<ProjectReference Include="..\MemberCenter.Domain\MemberCenter.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />

View File

@ -57,6 +57,9 @@ public sealed class AuthResourceRegistryService : IAuthResourceRegistryService
"profile:subscriptions.read",
"profile:subscriptions.write",
"newsletter:list.read",
"newsletter:list.read.global",
"newsletter:subscriptions.read",
"newsletter:subscriptions.write",
"newsletter:events.read",
"newsletter:events.write",
"newsletter:events.write.global"
@ -79,12 +82,7 @@ public sealed class AuthResourceRegistryService : IAuthResourceRegistryService
OpenIddictConstants.Scopes.OpenId,
OpenIddictConstants.Scopes.Email,
OpenIddictConstants.Scopes.Profile,
"profile:basic.read",
"profile:basic.write",
"profile:addresses.read",
"profile:addresses.write",
"profile:subscriptions.read",
"profile:subscriptions.write"
"profile:basic.read"
], cancellationToken);
await EnsureUsagePermissionsAsync("webhook_outbound", [
@ -97,6 +95,8 @@ public sealed class AuthResourceRegistryService : IAuthResourceRegistryService
await EnsureUsagePermissionsAsync("tenant_api", [
"newsletter:events.write",
"newsletter:list.read",
"newsletter:subscriptions.read",
"newsletter:subscriptions.write",
"profile:basic.read",
"profile:basic.write",
"profile:addresses.read",
@ -107,7 +107,7 @@ public sealed class AuthResourceRegistryService : IAuthResourceRegistryService
await EnsureUsagePermissionsAsync("platform_service", [
"newsletter:events.write.global",
"newsletter:list.read",
"newsletter:list.read.global",
"profile:basic.read",
"profile:basic.write",
"profile:addresses.read",
@ -269,11 +269,17 @@ public sealed class AuthResourceRegistryService : IAuthResourceRegistryService
IEnumerable<string> scopes,
CancellationToken cancellationToken)
{
var desiredScopes = scopes.ToHashSet(StringComparer.Ordinal);
var existingPermissions = await _dbContext.AuthClientUsagePermissions
.Where(permission => permission.Usage == usage)
.ToDictionaryAsync(permission => permission.Scope, StringComparer.Ordinal, cancellationToken);
foreach (var scope in scopes.Distinct(StringComparer.Ordinal))
foreach (var permission in existingPermissions.Values)
{
permission.IsEnabled = desiredScopes.Contains(permission.Scope);
}
foreach (var scope in desiredScopes)
{
if (existingPermissions.TryGetValue(scope, out var existing))
{

View File

@ -3,9 +3,11 @@ using MemberCenter.Application.Models.Newsletter;
using MemberCenter.Application.Models.Profile;
using MemberCenter.Domain.Constants;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Configuration;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
@ -17,25 +19,24 @@ public sealed class NewsletterService : INewsletterService
private const string ConfirmTokenPurpose = "confirm";
private const string UnsubscribeTokenPurpose = "unsubscribe";
private const string OneClickUnsubscribeTokenPurpose = "one_click_unsubscribe";
private const int ConfirmTokenTtlDays = 7;
private const int UnsubscribeTokenTtlDays = 7;
private const int OneClickUnsubscribeTokenTtlDays = 7;
private readonly MemberCenterDbContext _dbContext;
private readonly IEmailBlacklistService _emailBlacklist;
private readonly ISendEngineWebhookPublisher _webhookPublisher;
private readonly ILogger<NewsletterService> _logger;
private readonly NewsletterTokenOptions _tokenOptions;
public NewsletterService(
MemberCenterDbContext dbContext,
IEmailBlacklistService emailBlacklist,
ISendEngineWebhookPublisher webhookPublisher,
ILogger<NewsletterService> logger)
ILogger<NewsletterService> logger,
IOptions<NewsletterTokenOptions> tokenOptions)
{
_dbContext = dbContext;
_emailBlacklist = emailBlacklist;
_webhookPublisher = webhookPublisher;
_logger = logger;
_tokenOptions = tokenOptions.Value;
}
public async Task<PendingSubscriptionResult?> SubscribeAsync(Guid listId, string email, Dictionary<string, object>? preferences)
@ -88,7 +89,7 @@ public sealed class NewsletterService : INewsletterService
Id = Guid.NewGuid(),
SubscriptionId = subscription.Id,
TokenHash = HashToken(confirmToken, ConfirmTokenPurpose),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(ConfirmTokenTtlDays)
ExpiresAt = DateTimeOffset.UtcNow.AddDays(_tokenOptions.ConfirmTokenLifetimeDays)
});
await _dbContext.SaveChangesAsync();
@ -219,7 +220,7 @@ public sealed class NewsletterService : INewsletterService
Id = Guid.NewGuid(),
SubscriptionId = subscription.Id,
TokenHash = HashToken(token, UnsubscribeTokenPurpose),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(UnsubscribeTokenTtlDays)
ExpiresAt = DateTimeOffset.UtcNow.AddDays(_tokenOptions.UnsubscribeTokenLifetimeDays)
});
await _dbContext.SaveChangesAsync();
@ -292,7 +293,7 @@ public sealed class NewsletterService : INewsletterService
Id = Guid.NewGuid(),
SubscriptionId = subscription.Id,
TokenHash = HashToken(token, OneClickUnsubscribeTokenPurpose),
ExpiresAt = DateTimeOffset.UtcNow.AddDays(OneClickUnsubscribeTokenTtlDays)
ExpiresAt = DateTimeOffset.UtcNow.AddDays(_tokenOptions.OneClickTokenLifetimeDays)
});
results.Add(new OneClickUnsubscribeTokenResult(subscriberId, token, "issued"));
@ -416,65 +417,6 @@ public sealed class NewsletterService : INewsletterService
return updated;
}
public async Task<IReadOnlyList<UserSubscriptionSummaryDto>> ListSubscriptionsByEmailAsync(string email)
{
var normalizedEmail = email.Trim().ToLowerInvariant();
return await (
from subscription in _dbContext.NewsletterSubscriptions
join list in _dbContext.NewsletterLists on subscription.ListId equals list.Id
join tenant in _dbContext.Tenants on list.TenantId equals tenant.Id
where subscription.Email.ToLower() == normalizedEmail
orderby tenant.Name, list.Name
select new UserSubscriptionSummaryDto(
subscription.Id,
subscription.ListId,
tenant.Id,
tenant.Name,
list.Name,
subscription.Email,
subscription.Status,
subscription.CreatedAt))
.ToListAsync();
}
public async Task<UserSubscriptionSummaryDto?> UnsubscribeByEmailAsync(string email, Guid subscriptionId)
{
var normalizedEmail = email.Trim().ToLowerInvariant();
var subscription = await _dbContext.NewsletterSubscriptions
.FirstOrDefaultAsync(x => x.Id == subscriptionId && x.Email.ToLower() == normalizedEmail);
if (subscription is null)
{
return null;
}
if (await _emailBlacklist.IsBlacklistedAsync(subscription.Email))
{
return null;
}
subscription.Status = SubscriptionStatus.Unsubscribed;
await _dbContext.SaveChangesAsync();
var updated = await (
from saved in _dbContext.NewsletterSubscriptions
join list in _dbContext.NewsletterLists on saved.ListId equals list.Id
join tenant in _dbContext.Tenants on list.TenantId equals tenant.Id
where saved.Id == subscriptionId
select new UserSubscriptionSummaryDto(
saved.Id,
saved.ListId,
tenant.Id,
tenant.Name,
list.Name,
saved.Email,
saved.Status,
saved.CreatedAt))
.FirstAsync();
await PublishSubscriptionEventSafeAsync("subscription.unsubscribed", MapSubscription(subscription));
return updated;
}
private static string CreateToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);

View File

@ -2,9 +2,11 @@ using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Models.Admin;
using MemberCenter.Domain.Entities;
using MemberCenter.Infrastructure.Persistence;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using System.Net;
using System.Net.Mail;
using System.Security.Cryptography;
namespace MemberCenter.Infrastructure.Services;
@ -20,28 +22,32 @@ public sealed class SecuritySettingsService : ISecuritySettingsService
private const string SmtpTimeoutSecondsKey = "smtp_timeout_seconds";
private const string SmtpUsernameKey = "smtp_username";
private const string SmtpPasswordKey = "smtp_password";
private const string ProtectedValuePrefix = "protected:v1:";
private const string SmtpPasswordProtectorPurpose = "MemberCenter.SecuritySettings.SmtpPassword.v1";
private const string SenderNameKey = "smtp_sender_name";
private const string SenderEmailKey = "smtp_sender_email";
private readonly MemberCenterDbContext _dbContext;
private readonly IAuditLogWriter _auditLogWriter;
private readonly IDataProtector _smtpPasswordProtector;
public SecuritySettingsService(
MemberCenterDbContext dbContext,
IAuditLogWriter auditLogWriter)
IAuditLogWriter auditLogWriter,
IDataProtectionProvider dataProtectionProvider)
{
_dbContext = dbContext;
_auditLogWriter = auditLogWriter;
_smtpPasswordProtector = dataProtectionProvider.CreateProtector(SmtpPasswordProtectorPurpose);
}
public async Task<SecuritySettingsDto> GetAsync()
{
var access = await GetFlagAsync(AccessTokenKey, 60);
var refresh = await GetFlagAsync(RefreshTokenKey, 30);
var tokenLifetime = await GetTokenLifetimeSettingsAsync();
var smtp = await GetSmtpSettingsAsync();
return new SecuritySettingsDto(
access,
refresh,
tokenLifetime.AccessTokenMinutes,
tokenLifetime.RefreshTokenDays,
smtp.PublicBaseUrl,
smtp.RelayHost,
smtp.RelayPort,
@ -56,6 +62,13 @@ public sealed class SecuritySettingsService : ISecuritySettingsService
string.Empty);
}
public async Task<TokenLifetimeSettingsDto> GetTokenLifetimeSettingsAsync()
{
var access = await GetFlagAsync(AccessTokenKey, 60);
var refresh = await GetFlagAsync(RefreshTokenKey, 30);
return new TokenLifetimeSettingsDto(access, refresh);
}
public async Task<SmtpSettingsDto> GetSmtpSettingsAsync()
{
var relayHost = await GetFlagAsync(SmtpRelayHostKey, string.Empty);
@ -65,7 +78,7 @@ public sealed class SecuritySettingsService : ISecuritySettingsService
var useSsl = await GetFlagAsync(SmtpUseSslKey, false);
var timeoutSeconds = await GetFlagAsync(SmtpTimeoutSecondsKey, 15);
var username = await GetFlagAsync(SmtpUsernameKey, string.Empty);
var password = await GetFlagAsync(SmtpPasswordKey, string.Empty);
var password = await GetSmtpPasswordAsync();
var senderName = await GetFlagAsync(SenderNameKey, "Member Center");
var senderEmail = await GetFlagAsync(SenderEmailKey, string.Empty);
return new SmtpSettingsDto(
@ -84,6 +97,16 @@ public sealed class SecuritySettingsService : ISecuritySettingsService
public async Task SaveAsync(SecuritySettingsDto settings, Guid? actorUserId = null)
{
if (settings.AccessTokenMinutes is < 5 or > 1440)
{
throw new InvalidOperationException("Access token lifetime must be between 5 and 1440 minutes.");
}
if (settings.RefreshTokenDays is < 1 or > 365)
{
throw new InvalidOperationException("Refresh token lifetime must be between 1 and 365 days.");
}
var publicBaseUrl = Normalize(settings.PublicBaseUrl);
var relayHost = Normalize(settings.SmtpRelayHost);
var username = Normalize(settings.SmtpUsername);
@ -106,7 +129,7 @@ public sealed class SecuritySettingsService : ISecuritySettingsService
await SetFlagAsync(SmtpUsernameKey, username);
if (!string.IsNullOrWhiteSpace(settings.SmtpPassword))
{
await SetFlagAsync(SmtpPasswordKey, settings.SmtpPassword);
await SetFlagAsync(SmtpPasswordKey, ProtectSmtpPassword(settings.SmtpPassword));
}
await SetFlagAsync(SenderNameKey, senderName);
await SetFlagAsync(SenderEmailKey, senderEmail);
@ -231,6 +254,36 @@ public sealed class SecuritySettingsService : ISecuritySettingsService
}
}
private async Task<string> GetSmtpPasswordAsync()
{
var flag = await _dbContext.SystemFlags.FirstOrDefaultAsync(f => f.Key == SmtpPasswordKey);
if (flag is null || string.IsNullOrEmpty(flag.Value))
{
return string.Empty;
}
if (!flag.Value.StartsWith(ProtectedValuePrefix, StringComparison.Ordinal))
{
var plaintext = flag.Value;
flag.Value = ProtectSmtpPassword(plaintext);
flag.UpdatedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync();
return plaintext;
}
try
{
return _smtpPasswordProtector.Unprotect(flag.Value[ProtectedValuePrefix.Length..]);
}
catch (CryptographicException ex)
{
throw new InvalidOperationException("SMTP password cannot be decrypted with the current Data Protection key ring.", ex);
}
}
private string ProtectSmtpPassword(string plaintext) =>
ProtectedValuePrefix + _smtpPasswordProtector.Protect(plaintext);
private static string Normalize(string? value) => value?.Trim() ?? string.Empty;
}

View File

@ -350,10 +350,13 @@ return await root.InvokeAsync(args);
static IServiceProvider BuildServices(string connectionString)
{
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddConsole());
services.AddSingleton<IConfiguration>(new ConfigurationBuilder()
var configuration = new ConfigurationBuilder()
.AddEnvironmentVariables()
.Build());
.Build();
var identitySecurity = configuration.GetSection("IdentitySecurity").Get<IdentitySecurityOptions>() ?? new();
SecurityRuntimeOptionsValidator.Validate(identitySecurity);
services.AddLogging(builder => builder.AddConsole());
services.AddSingleton<IConfiguration>(configuration);
services.AddDbContext<MemberCenterDbContext>(options =>
{
options.UseNpgsql(connectionString);
@ -370,11 +373,14 @@ static IServiceProvider BuildServices(string connectionString)
services.AddIdentity<ApplicationUser, ApplicationRole>(options =>
{
options.User.RequireUniqueEmail = true;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8;
options.Password.RequireDigit = identitySecurity.Password.RequireDigit;
options.Password.RequireLowercase = identitySecurity.Password.RequireLowercase;
options.Password.RequireUppercase = identitySecurity.Password.RequireUppercase;
options.Password.RequireNonAlphanumeric = identitySecurity.Password.RequireNonAlphanumeric;
options.Password.RequiredLength = identitySecurity.Password.RequiredLength;
options.Lockout.AllowedForNewUsers = identitySecurity.Lockout.AllowedForNewUsers;
options.Lockout.MaxFailedAccessAttempts = identitySecurity.Lockout.MaxFailedAccessAttempts;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(identitySecurity.Lockout.DefaultLockoutMinutes);
})
.AddEntityFrameworkStores<MemberCenterDbContext>()
.AddDefaultTokenProviders();

View File

@ -128,22 +128,6 @@ public class HomeController : Controller
return RedirectToAction(nameof(Index));
}
[HttpPost("auth/api-login")]
public async Task<IActionResult> ApiLogin(string email, string password)
{
var options = GetOptions();
var form = new Dictionary<string, string?>
{
["grant_type"] = "password",
["username"] = email,
["password"] = password,
["scope"] = options.WebLoginScopes
};
await ExchangeUserTokenAsync("API login token", form);
return RedirectToAction(nameof(Index));
}
[HttpPost("auth/service-token")]
public async Task<IActionResult> ServiceToken(string? clientId, string? clientSecret, string? scopes)
{
@ -253,20 +237,6 @@ public class HomeController : Controller
return RedirectToAction(nameof(Index));
}
[HttpPost("tests/service-profile-by-email")]
public async Task<IActionResult> ServiceProfileByEmail(string email)
{
await SendServiceAsync("GET /user/profile/by-email", HttpMethod.Get, $"/user/profile/by-email?email={Uri.EscapeDataString(email)}");
return RedirectToAction(nameof(Index));
}
[HttpPost("tests/service-addresses-by-email")]
public async Task<IActionResult> ServiceAddressesByEmail(string email)
{
await SendServiceAsync("GET /user/addresses/by-email", HttpMethod.Get, $"/user/addresses/by-email?email={Uri.EscapeDataString(email)}");
return RedirectToAction(nameof(Index));
}
[HttpPost("auth/clear")]
public IActionResult ClearTokens()
{

View File

@ -47,25 +47,14 @@
</form>
</article>
<article class="card">
<h2>2. API Login</h2>
<form method="post" asp-action="ApiLogin">
<label>Email</label>
<input name="email" type="email" autocomplete="username" required />
<label>Password</label>
<input name="password" type="password" autocomplete="current-password" required />
<button type="submit">Get User Token</button>
</form>
</article>
<article class="card">
<h2>Service Token</h2>
<p>Required for checks 9 and 10.</p>
<p>Uses a registered confidential client. Granted scopes are bounded by its configured usage.</p>
<form method="post" asp-action="ServiceToken">
<label>Client ID</label>
<input name="clientId" value="@Model.Options.ServiceClientId" />
<label>Client Secret</label>
<input name="clientSecret" type="password" value="@Model.Options.ServiceClientSecret" />
<input name="clientSecret" type="password" autocomplete="off" />
<label>Scopes</label>
<input name="scopes" value="@Model.Options.ServiceScopes" />
<button type="submit">Get Service Token</button>
@ -99,21 +88,6 @@
</form>
</section>
<section class="card">
<h2>Service Token Happy Paths</h2>
<p>Use a service token with at least <code>profile:basic.read</code> and <code>profile:addresses.read</code>.</p>
<form method="post" asp-action="ServiceProfileByEmail" class="inline-form">
<label>Email</label>
<input name="email" type="email" required />
<button type="submit">9. GET /user/profile/by-email</button>
</form>
<form method="post" asp-action="ServiceAddressesByEmail" class="inline-form">
<label>Email</label>
<input name="email" type="email" required />
<button type="submit">10. GET /user/addresses/by-email</button>
</form>
</section>
<section class="card response-card">
<h2>Last Response</h2>
<h3>@(Model.LastResponseTitle ?? "No request yet")</h3>

View File

@ -103,8 +103,8 @@ public class OAuthClientsController : Controller
}
var clientId = Guid.NewGuid().ToString("N");
var clientSecret = model.ClientType == "confidential"
? Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
var clientSecret = string.Equals(model.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase)
? GenerateClientSecret()
: null;
var descriptor = await BuildDescriptorAsync(clientId, model.Name, model.ClientType, model.Usage);
@ -210,12 +210,22 @@ public class OAuthClientsController : Controller
var descriptor = new OpenIddictApplicationDescriptor();
await _applicationManager.PopulateAsync(descriptor, app);
var originalClientType = await _applicationManager.GetClientTypeAsync(app);
string? generatedClientSecret = null;
descriptor.DisplayName = model.Name;
descriptor.ClientType = model.ClientType;
if (string.Equals(model.ClientType, OpenIddictConstants.ClientTypes.Public, StringComparison.OrdinalIgnoreCase))
{
descriptor.ClientSecret = null;
}
else if (string.Equals(model.ClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase)
&& (!string.Equals(originalClientType, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase)
|| string.IsNullOrWhiteSpace(descriptor.ClientSecret)))
{
generatedClientSecret = GenerateClientSecret();
descriptor.ClientSecret = generatedClientSecret;
}
await ApplyPermissionsAsync(descriptor, model.Usage);
descriptor.RedirectUris.Clear();
@ -236,6 +246,12 @@ public class OAuthClientsController : Controller
descriptor.Properties["usage"] = System.Text.Json.JsonSerializer.SerializeToElement(model.Usage);
await _applicationManager.UpdateAsync(app, descriptor);
if (!string.IsNullOrWhiteSpace(generatedClientSecret))
{
TempData["GeneratedClientId"] = await _applicationManager.GetClientIdAsync(app);
TempData["GeneratedClientSecret"] = generatedClientSecret;
}
return RedirectToAction("Index");
}
@ -272,7 +288,7 @@ public class OAuthClientsController : Controller
var descriptor = new OpenIddictApplicationDescriptor();
await _applicationManager.PopulateAsync(descriptor, app);
var newSecret = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));
var newSecret = GenerateClientSecret();
descriptor.ClientSecret = newSecret;
await _applicationManager.UpdateAsync(app, descriptor);
@ -295,6 +311,9 @@ public class OAuthClientsController : Controller
private static bool RequiresClientCredentials(string usage) =>
usage is "tenant_api" or "send_api" or "platform_service" or "file_api";
private static string GenerateClientSecret() =>
Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));
private static List<string> NormalizeRedirectUris(string? value, out string? error)
{
error = null;

View File

@ -2,6 +2,7 @@
<h1>Add Email Blacklist</h1>
<form method="post">
@Html.AntiForgeryToken()
<label>Email</label>
<input asp-for="Email" />
<span asp-validation-for="Email"></span>

View File

@ -2,6 +2,7 @@
<h1>Create Newsletter List</h1>
<form method="post">
@Html.AntiForgeryToken()
<label>Tenant Id</label>
<select asp-for="TenantId">
<option value="">Select a tenant</option>

View File

@ -2,6 +2,7 @@
<h1>Edit Newsletter List</h1>
<form method="post">
@Html.AntiForgeryToken()
<label>Tenant Id</label>
<select asp-for="TenantId">
<option value="">Select a tenant</option>

View File

@ -24,7 +24,8 @@
}
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.NewsletterListsDelete))
{
<form method="post" action="/admin/newsletter-lists/delete/@list.Id" style="display:inline">
<form method="post" action="/admin/newsletter-lists/delete/@list.Id" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit">Delete</button>
</form>
}

View File

@ -2,6 +2,7 @@
<h1>Create OAuth Client</h1>
<form method="post">
@Html.AntiForgeryToken()
<label>Tenant Id</label>
<select asp-for="TenantId">
<option value="">Select a tenant</option>
@ -40,42 +41,3 @@
<button type="submit">Save</button>
</form>
<script>
(function () {
const usage = document.getElementById("Usage");
const redirect = document.getElementById("RedirectUris");
const clientType = document.getElementById("ClientType");
if (!usage || !redirect || !clientType) return;
function syncRedirectInputState() {
const usageValue = usage.value;
const needsRedirect = usageValue === "web_login" || usageValue === "webhook_outbound";
const requiresConfidential = usageValue === "tenant_api"
|| usageValue === "send_api"
|| usageValue === "platform_service"
|| usageValue === "file_api";
redirect.disabled = !needsRedirect;
if (!needsRedirect) {
redirect.value = "";
}
if (requiresConfidential) {
clientType.value = "confidential";
const publicOption = clientType.querySelector('option[value="public"]');
if (publicOption) {
publicOption.disabled = true;
}
} else {
const publicOption = clientType.querySelector('option[value="public"]');
if (publicOption) {
publicOption.disabled = false;
}
}
}
usage.addEventListener("change", syncRedirectInputState);
syncRedirectInputState();
})();
</script>

View File

@ -2,6 +2,7 @@
<h1>Edit OAuth Client</h1>
<form method="post">
@Html.AntiForgeryToken()
<label>Tenant Id</label>
<select asp-for="TenantId">
<option value="">Select a tenant</option>
@ -40,42 +41,3 @@
<button type="submit">Save</button>
</form>
<script>
(function () {
const usage = document.getElementById("Usage");
const redirect = document.getElementById("RedirectUris");
const clientType = document.getElementById("ClientType");
if (!usage || !redirect || !clientType) return;
function syncRedirectInputState() {
const usageValue = usage.value;
const needsRedirect = usageValue === "web_login" || usageValue === "webhook_outbound";
const requiresConfidential = usageValue === "tenant_api"
|| usageValue === "send_api"
|| usageValue === "platform_service"
|| usageValue === "file_api";
redirect.disabled = !needsRedirect;
if (!needsRedirect) {
redirect.value = "";
}
if (requiresConfidential) {
clientType.value = "confidential";
const publicOption = clientType.querySelector('option[value="public"]');
if (publicOption) {
publicOption.disabled = true;
}
} else {
const publicOption = clientType.querySelector('option[value="public"]');
if (publicOption) {
publicOption.disabled = false;
}
}
}
usage.addEventListener("change", syncRedirectInputState);
syncRedirectInputState();
})();
</script>

View File

@ -27,6 +27,17 @@
}
</div>
}
@if (TempData["GeneratedClientId"] is string generatedId)
{
<div>
<strong>Client Secret Generated</strong><br />
<div>Client ID: <code>@generatedId</code></div>
@if (TempData["GeneratedClientSecret"] is string generatedSecret)
{
<div>New Client Secret (show once): <code>@generatedSecret</code></div>
}
</div>
}
<table>
<thead>
<tr><th>Name</th><th>Client Id</th><th>Type</th><th>Usage</th><th></th></tr>
@ -52,13 +63,15 @@
@if (string.Equals(clientType, "confidential", StringComparison.OrdinalIgnoreCase)
&& await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsRotateSecret))
{
<form method="post" action="/admin/oauth-clients/rotate-secret/@id" style="display:inline">
<form method="post" action="/admin/oauth-clients/rotate-secret/@id" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit">Rotate Secret</button>
</form>
}
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsDelete))
{
<form method="post" action="/admin/oauth-clients/delete/@id" style="display:inline">
<form method="post" action="/admin/oauth-clients/delete/@id" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit">Delete</button>
</form>
}

View File

@ -9,10 +9,10 @@
@Html.AntiForgeryToken()
<div asp-validation-summary="All"></div>
<label>Access token minutes</label>
<input asp-for="AccessTokenMinutes" />
<input asp-for="AccessTokenMinutes" type="number" min="5" max="1440" />
<label>Refresh token days</label>
<input asp-for="RefreshTokenDays" />
<input asp-for="RefreshTokenDays" type="number" min="1" max="365" />
<label asp-for="PublicBaseUrl">Public base URL</label>
<input asp-for="PublicBaseUrl" />

View File

@ -2,6 +2,7 @@
<h1>Create Tenant</h1>
<form method="post">
@Html.AntiForgeryToken()
<label>Name</label>
<input asp-for="Name" />
<span asp-validation-for="Name"></span>

View File

@ -2,6 +2,7 @@
<h1>Edit Tenant</h1>
<form method="post">
@Html.AntiForgeryToken()
<label>Name</label>
<input asp-for="Name" />
<span asp-validation-for="Name"></span>

View File

@ -25,7 +25,8 @@
}
@if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.TenantsDelete))
{
<form method="post" action="/admin/tenants/delete/@tenant.Id" style="display:inline">
<form method="post" action="/admin/tenants/delete/@tenant.Id" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit">Delete</button>
</form>
}

View File

@ -1,6 +1,7 @@
using System.Security.Claims;
using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants;
using MemberCenter.Infrastructure.Configuration;
using MemberCenter.Infrastructure.Identity;
using MemberCenter.Web.Models.Account;
using Microsoft.AspNetCore.Authorization;
@ -16,6 +17,7 @@ public class AccountController : Controller
private readonly IAccountEmailService _accountEmailService;
private readonly IAuditLogWriter _auditLogWriter;
private readonly IConfiguration _configuration;
private readonly bool _allowInsecureReturnUrls;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
@ -24,6 +26,7 @@ public class AccountController : Controller
IAccountEmailService accountEmailService,
IAuditLogWriter auditLogWriter,
IConfiguration configuration,
IWebHostEnvironment environment,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager)
{
@ -31,6 +34,7 @@ public class AccountController : Controller
_accountEmailService = accountEmailService;
_auditLogWriter = auditLogWriter;
_configuration = configuration;
_allowInsecureReturnUrls = environment.IsDevelopment();
_userManager = userManager;
_signInManager = signInManager;
}
@ -75,7 +79,7 @@ public class AccountController : Controller
await UpdateSignInMetadataAsync(loginUser);
}
if (IsAllowedReturnUrl(model.ReturnUrl))
if (IsAllowedReturnUrl(model.ReturnUrl, ReturnUrlPurpose.Login))
{
return Redirect(model.ReturnUrl!);
}
@ -145,7 +149,7 @@ public class AccountController : Controller
await _signInManager.SignInAsync(user, false, info.LoginProvider);
await UpdateSignInMetadataAsync(user);
if (IsAllowedReturnUrl(returnUrl))
if (IsAllowedReturnUrl(returnUrl, ReturnUrlPurpose.Login))
{
return Redirect(returnUrl!);
}
@ -161,7 +165,7 @@ public class AccountController : Controller
await _signInManager.SignOutAsync();
}
if (IsAllowedReturnUrl(returnUrl))
if (IsAllowedReturnUrl(returnUrl, ReturnUrlPurpose.Logout))
{
return Redirect(returnUrl!);
}
@ -177,7 +181,7 @@ public class AccountController : Controller
{
await _signInManager.SignOutAsync();
if (IsAllowedReturnUrl(returnUrl))
if (IsAllowedReturnUrl(returnUrl, ReturnUrlPurpose.Logout))
{
return Redirect(returnUrl!);
}
@ -295,6 +299,7 @@ public class AccountController : Controller
}
[HttpPost]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthTokenConsumption)]
public async Task<IActionResult> ResetPassword(ResetPasswordViewModel model)
{
if (!ModelState.IsValid)
@ -327,6 +332,7 @@ public class AccountController : Controller
}
[HttpGet]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthTokenConsumption)]
public async Task<IActionResult> VerifyEmail(string email, string token)
{
var user = await _userManager.FindByEmailAsync(email);
@ -377,7 +383,7 @@ public class AccountController : Controller
await _userManager.UpdateAsync(user);
}
private bool IsAllowedReturnUrl(string? returnUrl)
private bool IsAllowedReturnUrl(string? returnUrl, ReturnUrlPurpose purpose)
{
if (string.IsNullOrWhiteSpace(returnUrl))
{
@ -394,15 +400,21 @@ public class AccountController : Controller
return false;
}
var allowedPrefixes = new List<string?>();
allowedPrefixes.Add(_configuration["Auth:Issuer"]);
allowedPrefixes.AddRange((_configuration["Auth:AllowedLoginReturnUrlPrefixes"] ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
allowedPrefixes.AddRange((_configuration["Auth:AllowedLogoutReturnUrlPrefixes"] ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
var allowedPrefixes = purpose == ReturnUrlPurpose.Login
? new[] { _configuration["Auth:Issuer"] }
.Concat(GetConfiguredReturnUrls("Auth:AllowedLoginReturnUrlPrefixes"))
: GetConfiguredReturnUrls("Auth:AllowedLogoutReturnUrlPrefixes");
return allowedPrefixes
.Where(prefix => !string.IsNullOrWhiteSpace(prefix))
.Any(prefix => returnUrl.StartsWith(prefix!, StringComparison.OrdinalIgnoreCase));
return ReturnUrlValidator.IsAllowedExternal(parsed, allowedPrefixes, _allowInsecureReturnUrls);
}
private IEnumerable<string> GetConfiguredReturnUrls(string key) =>
(_configuration[key] ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
private enum ReturnUrlPurpose
{
Login,
Logout
}
}

View File

@ -18,17 +18,42 @@ using Microsoft.EntityFrameworkCore;
EnvLoader.LoadDotEnvIfDevelopment();
var builder = WebApplication.CreateBuilder(args);
var identitySecurity = builder.Configuration.GetSection("IdentitySecurity").Get<IdentitySecurityOptions>() ?? new();
var rateLimits = builder.Configuration.GetSection("RateLimits").Get<RateLimitSettings>() ?? new();
var newsletterTokens = builder.Configuration.GetSection("NewsletterTokens").Get<NewsletterTokenOptions>() ?? new();
SecurityRuntimeOptionsValidator.Validate(identitySecurity);
SecurityRuntimeOptionsValidator.Validate(rateLimits);
SecurityRuntimeOptionsValidator.Validate(newsletterTokens);
var connectionString = builder.Configuration.GetConnectionString("Default");
if (string.IsNullOrWhiteSpace(connectionString))
{
if (!builder.Environment.IsDevelopment())
{
throw new InvalidOperationException("ConnectionStrings:Default is required outside Development.");
}
builder.Services.AddDataProtection()
connectionString = "Host=localhost;Database=member_center;Username=postgres;Password=postgres";
}
var dataProtectionCertificate = CertificateLoader.LoadFromConfiguration(
builder.Configuration, "DataProtection:Certificate", required: !builder.Environment.IsDevelopment());
var previousDataProtectionCertificates = CertificateLoader.LoadPreviousFromConfiguration(
builder.Configuration, "DataProtection:Certificate");
var dataProtectionBuilder = builder.Services.AddDataProtection()
.SetApplicationName("MemberCenter")
.PersistKeysToDbContext<MemberCenterDbContext>();
if (dataProtectionCertificate is not null)
{
dataProtectionBuilder.ProtectKeysWithCertificate(dataProtectionCertificate);
if (previousDataProtectionCertificates.Count > 0)
{
dataProtectionBuilder.UnprotectKeysWithAnyCertificate(
new[] { dataProtectionCertificate }.Concat(previousDataProtectionCertificates).ToArray());
}
}
builder.Services.AddDbContext<MemberCenterDbContext>(options =>
{
var connectionString = builder.Configuration.GetConnectionString("Default")
?? Environment.GetEnvironmentVariable("ConnectionStrings__Default")
?? "Host=localhost;Database=member_center;Username=postgres;Password=postgres";
options.UseNpgsql(connectionString);
options.UseOpenIddict();
});
@ -37,14 +62,14 @@ builder.Services
.AddIdentity<ApplicationUser, ApplicationRole>(options =>
{
options.User.RequireUniqueEmail = true;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8;
options.Lockout.AllowedForNewUsers = true;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
options.Password.RequireDigit = identitySecurity.Password.RequireDigit;
options.Password.RequireLowercase = identitySecurity.Password.RequireLowercase;
options.Password.RequireUppercase = identitySecurity.Password.RequireUppercase;
options.Password.RequireNonAlphanumeric = identitySecurity.Password.RequireNonAlphanumeric;
options.Password.RequiredLength = identitySecurity.Password.RequiredLength;
options.Lockout.AllowedForNewUsers = identitySecurity.Lockout.AllowedForNewUsers;
options.Lockout.MaxFailedAccessAttempts = identitySecurity.Lockout.MaxFailedAccessAttempts;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(identitySecurity.Lockout.DefaultLockoutMinutes);
})
.AddEntityFrameworkStores<MemberCenterDbContext>()
.AddDefaultTokenProviders();
@ -92,11 +117,8 @@ builder.Services.AddAuthorization(options =>
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
TrustedForwardedHeaders.Configure(options, builder.Configuration);
});
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
@ -111,13 +133,16 @@ builder.Services.AddRateLimiter(options =>
};
options.AddPolicy(RateLimitPolicyNames.PublicAuthLogin, context =>
CreateFixedWindowLimiter(context, "web-auth-login", permitLimit: 10, TimeSpan.FromMinutes(5)));
CreateFixedWindowLimiter(context, "web-auth-login", rateLimits.Web.AuthLogin));
options.AddPolicy(RateLimitPolicyNames.PublicAuthRegister, context =>
CreateFixedWindowLimiter(context, "web-auth-register", permitLimit: 5, TimeSpan.FromMinutes(15)));
CreateFixedWindowLimiter(context, "web-auth-register", rateLimits.Web.AuthRegister));
options.AddPolicy(RateLimitPolicyNames.PublicAuthRecovery, context =>
CreateFixedWindowLimiter(context, "web-auth-recovery", permitLimit: 5, TimeSpan.FromMinutes(15)));
CreateFixedWindowLimiter(context, "web-auth-recovery", rateLimits.Web.AuthRecovery));
options.AddPolicy(RateLimitPolicyNames.PublicAuthTokenConsumption, context =>
CreateFixedWindowLimiter(context, "web-auth-token-consumption", rateLimits.Web.AuthTokenConsumption));
});
builder.Services.AddScoped<INewsletterService, NewsletterService>();
@ -138,6 +163,7 @@ builder.Services.AddScoped<IAuthResourceRegistryService, AuthResourceRegistrySer
builder.Services.AddScoped<AdminPermissionService>();
builder.Services.AddScoped<IAdminPermissionChecker>(services => services.GetRequiredService<AdminPermissionService>());
builder.Services.AddScoped<IAdminPermissionSeeder>(services => services.GetRequiredService<AdminPermissionService>());
builder.Services.Configure<NewsletterTokenOptions>(builder.Configuration.GetSection("NewsletterTokens"));
builder.Services.Configure<SendEngineWebhookOptions>(builder.Configuration.GetSection("SendEngine"));
builder.Services.AddHttpClient<SendEngineWebhookPublisher>();
builder.Services.AddScoped<ISendEngineWebhookPublisher, SendEngineWebhookPublisher>();
@ -152,10 +178,16 @@ builder.Services.AddOpenIddict()
builder.Services.AddControllersWithViews(options =>
{
options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;
options.Filters.Add(new Microsoft.AspNetCore.Mvc.AutoValidateAntiforgeryTokenAttribute());
});
builder.Services.AddHttpContextAccessor();
var app = builder.Build();
CertificateLoader.LogExpirationWarning(
app.Logger,
"Data Protection",
dataProtectionCertificate,
builder.Configuration.GetValue("Certificates:ExpirationWarningDays", 30));
await EnsureAuthRegistryDefaultsAsync(app.Services);
await EnsureAdminPermissionDefaultsAsync(app.Services);
@ -163,10 +195,26 @@ await EnsureAdminPermissionDefaultsAsync(app.Services);
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseForwardedHeaders();
app.Use(async (context, next) =>
{
context.Response.OnStarting(() =>
{
var headers = context.Response.Headers;
headers.TryAdd("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'");
headers.TryAdd("X-Content-Type-Options", "nosniff");
headers.TryAdd("X-Frame-Options", "DENY");
headers.TryAdd("Referrer-Policy", "no-referrer");
headers.TryAdd("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
return Task.CompletedTask;
});
await next();
});
app.UseStaticFiles();
app.UseRouting();
app.UseRateLimiter();
app.UseAuthentication();
@ -221,8 +269,7 @@ static async Task ValidatePrincipalAsync(CookieValidatePrincipalContext context)
static RateLimitPartition<string> CreateFixedWindowLimiter(
HttpContext context,
string policyPrefix,
int permitLimit,
TimeSpan window)
FixedWindowRateLimitSettings settings)
{
var identifier = context.User.Identity?.IsAuthenticated == true
? context.User.FindFirstValue(ClaimTypes.NameIdentifier)
@ -234,8 +281,8 @@ static RateLimitPartition<string> CreateFixedWindowLimiter(
var partitionKey = $"{policyPrefix}:{identifier}";
return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = permitLimit,
Window = window,
PermitLimit = settings.PermitLimit,
Window = TimeSpan.FromSeconds(settings.WindowSeconds),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0,
AutoReplenishment = true

View File

@ -33,7 +33,7 @@ else
<td>@(address.IsDefault ? "Yes" : "No")</td>
<td>
<a asp-action="Addresses" asp-route-id="@address.Id">Edit</a>
<form asp-action="DeleteAddress" asp-route-id="@address.Id" method="post" style="display:inline">
<form asp-action="DeleteAddress" asp-route-id="@address.Id" method="post" class="d-inline">
@Html.AntiForgeryToken()
<button type="submit">Delete</button>
</form>

View File

@ -75,5 +75,6 @@
<main class="container">
@RenderBody()
</main>
<script src="~/js/site.js" asp-append-version="true"></script>
</body>
</html>

View File

@ -1,4 +1,25 @@
// Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
// for details on configuring this project to bundle and minify static web assets.
(() => {
const usage = document.getElementById("Usage");
const redirect = document.getElementById("RedirectUris");
const clientType = document.getElementById("ClientType");
if (!usage || !redirect || !clientType) return;
// Write your JavaScript code.
const syncRedirectInputState = () => {
const usageValue = usage.value;
const needsRedirect = usageValue === "web_login" || usageValue === "webhook_outbound";
const requiresConfidential = usageValue === "tenant_api"
|| usageValue === "send_api"
|| usageValue === "platform_service"
|| usageValue === "file_api";
redirect.disabled = !needsRedirect;
if (!needsRedirect) redirect.value = "";
const publicOption = clientType.querySelector('option[value="public"]');
if (publicOption) publicOption.disabled = requiresConfidential;
if (requiresConfidential) clientType.value = "confidential";
};
usage.addEventListener("change", syncRedirectInputState);
syncRedirectInputState();
})();

View File

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/MemberCenter.Infrastructure/MemberCenter.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,128 @@
using MemberCenter.Infrastructure.Configuration;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Configuration;
using Xunit;
namespace MemberCenter.Security.Tests;
public sealed class SecurityConfigurationTests
{
[Theory]
[InlineData("https://example.com/app", true)]
[InlineData("https://example.com/app/callback?code=1", true)]
[InlineData("https://example.com/application", false)]
[InlineData("https://example.com.attacker.test/app", false)]
[InlineData("https://example.com:444/app", false)]
[InlineData("https://user@example.com/app", false)]
public void ReturnUrlRequiresMatchingOriginAndPathBoundary(string candidate, bool expected)
{
var result = ReturnUrlValidator.IsWithinAllowedPrefix(
new Uri(candidate), "https://example.com/app", allowInsecureHttp: false);
Assert.Equal(expected, result);
}
[Fact]
public void ReturnUrlRequiresHttpsWhenInsecureHttpIsDisabled()
{
Assert.False(ReturnUrlValidator.IsWithinAllowedPrefix(
new Uri("http://example.com/app"), "http://example.com/app", allowInsecureHttp: false));
}
[Fact]
public void ForwardedHeadersFailClosedWithoutTrustedProxy()
{
var options = new ForwardedHeadersOptions();
TrustedForwardedHeaders.Configure(options, Configuration());
Assert.Equal(ForwardedHeaders.None, options.ForwardedHeaders);
Assert.Empty(options.KnownNetworks);
Assert.Empty(options.KnownProxies);
}
[Fact]
public void TrustedProxyAllowlistEnablesExpectedHeaders()
{
var options = new ForwardedHeadersOptions();
TrustedForwardedHeaders.Configure(options, Configuration(new()
{
["ReverseProxy:KnownProxies"] = "10.0.0.10",
["ReverseProxy:KnownNetworks"] = "10.1.0.0/16"
}));
Assert.Equal(ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, options.ForwardedHeaders);
Assert.Single(options.KnownProxies);
Assert.Single(options.KnownNetworks);
}
[Theory]
[InlineData("0.0.0.0/0")]
[InlineData("::/0")]
[InlineData("not-a-network")]
[InlineData("10.0.0.0/33")]
public void InvalidOrGlobalProxyNetworkIsRejected(string network)
{
var options = new ForwardedHeadersOptions();
Assert.Throws<InvalidOperationException>(() => TrustedForwardedHeaders.Configure(
options, Configuration(new() { ["ReverseProxy:KnownNetworks"] = network })));
}
[Fact]
public void RequiredCertificateMustBeConfigured()
{
Assert.Throws<InvalidOperationException>(() => CertificateLoader.LoadFromConfiguration(
Configuration(), "Auth:Certificates:Signing", required: true));
}
[Fact]
public void MissingCertificateFileIsRejected()
{
Assert.Throws<InvalidOperationException>(() => CertificateLoader.LoadFromConfiguration(
Configuration(new() { ["Auth:Certificates:Signing:Path"] = "/missing/signing.pfx" }),
"Auth:Certificates:Signing", required: true));
}
[Fact]
public void SecurityOptionDefaultsRemainStable()
{
var identity = new IdentitySecurityOptions();
Assert.Equal(8, identity.Password.RequiredLength);
Assert.True(identity.Password.RequireDigit);
Assert.True(identity.Password.RequireLowercase);
Assert.True(identity.Password.RequireUppercase);
Assert.False(identity.Password.RequireNonAlphanumeric);
Assert.Equal(5, identity.Lockout.MaxFailedAccessAttempts);
Assert.Equal(15, identity.Lockout.DefaultLockoutMinutes);
var newsletter = new NewsletterTokenOptions();
Assert.Equal(7, newsletter.ConfirmTokenLifetimeDays);
Assert.Equal(7, newsletter.UnsubscribeTokenLifetimeDays);
Assert.Equal(7, newsletter.OneClickTokenLifetimeDays);
Assert.Equal(1000, newsletter.OneClickBatchSizeLimit);
var fileAccess = new FileAccessTokenOptions();
Assert.Equal(30, fileAccess.MinimumLifetimeSeconds);
Assert.Equal(300, fileAccess.DefaultLifetimeSeconds);
Assert.Equal(900, fileAccess.MaximumLifetimeSeconds);
}
[Fact]
public void InvalidSecurityOptionsAreRejected()
{
Assert.Throws<InvalidOperationException>(() => SecurityRuntimeOptionsValidator.Validate(new IdentitySecurityOptions
{
Password = new PasswordPolicyOptions { RequiredLength = 7 }
}));
Assert.Throws<InvalidOperationException>(() => SecurityRuntimeOptionsValidator.Validate(new FileAccessTokenOptions
{
MinimumLifetimeSeconds = 60,
DefaultLifetimeSeconds = 30,
MaximumLifetimeSeconds = 900
}));
}
private static IConfiguration Configuration(Dictionary<string, string?>? values = null) =>
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
}