Refactor member center workflows and UI
This commit is contained in:
parent
2eff12d903
commit
d4d2f98296
56
.env.example
56
.env.example
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
129
docs/CONFIGURATION.md
Normal 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 filtering;Production 建議填實際 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 值皆須大於 0;File 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 endpoint;issuer 仍為 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 registry;audience 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;有效範圍 1–180。 |
|
||||
|
||||
## Reverse proxy
|
||||
|
||||
| Key | 預設值 | 說明 |
|
||||
|---|---:|---|
|
||||
| `ReverseProxy:KnownProxies` | 空 | 逗號分隔可信 proxy IP。 |
|
||||
| `ReverseProxy:KnownNetworks` | 空 | 逗號分隔可信 CIDR;禁止 `/0`。 |
|
||||
| `ReverseProxy:ForwardLimit` | `1` | Proxy hop;限制 1–5。 |
|
||||
|
||||
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 分鐘;5–1440。 |
|
||||
| Refresh token lifetime | 30 天;1–365。 |
|
||||
| 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` 為準。
|
||||
@ -108,6 +108,7 @@
|
||||
- API 與 Web 需共用 DataProtection application name `MemberCenter`,使 API authorize endpoint 可讀取 Web login cookie。
|
||||
- 若 API 與 Web 位於不同子網域,需設定 `Auth:CookieDomain`,例如 `.example.com`。
|
||||
- 若 API 與 Web 不同 origin,Web 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-closed:token 缺少、格式錯誤或不相符的 `tenant_id` 時拒絕;跨 tenant 操作必須使用獨立 global scope
|
||||
|
||||
### 租戶端取 Token(Client 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 限 `5–1440` 分鐘,refresh 限 `1–365` 天
|
||||
- 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 ring;key ring 與密文同存 DB 只能避免設定值直接裸露,不能抵抗完整 DB 外洩
|
||||
- Production API 必須以設定注入的 PFX 作為 OpenIddict signing / encryption certificates;API 與 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-Policy;Razor 不保留 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
|
||||
|
||||
@ -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 或未註冊 client;refresh token 統一送至 `/oauth/token`
|
||||
- [Auth] 新簽發 token 套用後台安全設定:access token `5–1440` 分鐘、refresh token `1–365` 天
|
||||
|
||||
## 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-closed;Bearer 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 會員資料維護
|
||||
|
||||
@ -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 與 HSTS;Member 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` 不是 HTTPS,API 會拒絕啟動;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 數,允許範圍 `1–5`。
|
||||
- 不可填入 `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`(允許 `1–180`);進入期限後 API / Web 啟動會寫入 warning log,Production 應以 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
|
||||
|
||||
@ -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 @@
|
||||
- Web:login / register / forgot password / resend verification
|
||||
- API:register / forgot password / resend verification
|
||||
- Newsletter API:public 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 Center;File 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 / CloudFront;API / 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 已有 lockout;service API token flow 與更細觀測仍待續作
|
||||
|
||||
### 11. MFA 與非本期項目
|
||||
|
||||
@ -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:訂閱/確認/退訂/偏好
|
||||
- Admin:Tenants/Lists/OAuth Clients(MVP CRUD)
|
||||
@ -19,14 +21,21 @@
|
||||
- Bearer JWT(API 使用)
|
||||
|
||||
## 補充說明
|
||||
- `/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 `5–1440` 分鐘、refresh token `1–365` 天;設定儲存後下一次簽發立即生效
|
||||
- Access token 以 JWT(JWS)簽發,建議驗證 `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-scoped,SES 回寫用)
|
||||
- `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 驗簽 JWT(JWS),並驗證 `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
113
docs/SCOPES.md
Normal 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 endpoint(register、forgot/reset、email verify)亦不屬於 scope 授權模型。
|
||||
@ -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,不取代完整自動化測試。
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
|
||||
options.AddDevelopmentEncryptionCertificate();
|
||||
options.AddDevelopmentSigningCertificate();
|
||||
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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
namespace MemberCenter.Application.Models.Admin;
|
||||
|
||||
public sealed record TokenLifetimeSettingsDto(
|
||||
int AccessTokenMinutes,
|
||||
int RefreshTokenDays);
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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" />
|
||||
|
||||
@ -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))
|
||||
{
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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()
|
||||
{
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -75,5 +75,6 @@
|
||||
<main class="container">
|
||||
@RenderBody()
|
||||
</main>
|
||||
<script src="~/js/site.js" asp-append-version="true"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -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();
|
||||
})();
|
||||
|
||||
@ -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>
|
||||
128
tests/MemberCenter.Security.Tests/SecurityConfigurationTests.cs
Normal file
128
tests/MemberCenter.Security.Tests/SecurityConfigurationTests.cs
Normal 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();
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user