diff --git a/.env.example b/.env.example index 200641a..1156732 100644 --- a/.env.example +++ b/.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 diff --git a/MemberCenter.sln b/MemberCenter.sln index 65b6f95..9368250 100644 --- a/MemberCenter.sln +++ b/MemberCenter.sln @@ -1,69 +1,78 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{150D3A20-BF61-4012-BD40-05D408749112}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Domain", "src\MemberCenter.Domain\MemberCenter.Domain.csproj", "{7733733D-22EB-431D-A8AA-833486C3E0E2}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Application", "src\MemberCenter.Application\MemberCenter.Application.csproj", "{90EC27FD-E72D-4506-A81A-BD81F4D555CF}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Infrastructure", "src\MemberCenter.Infrastructure\MemberCenter.Infrastructure.csproj", "{28015B2B-16F2-4DA0-9DA6-D79C94330A4D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Api", "src\MemberCenter.Api\MemberCenter.Api.csproj", "{051ECE48-E49B-4E42-BE08-6E9AAB7262BC}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Installer", "src\MemberCenter.Installer\MemberCenter.Installer.csproj", "{5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Web", "src\MemberCenter.Web\MemberCenter.Web.csproj", "{91DF0CEA-698F-4DF5-A44C-89AB38AA2561}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.TestSite", "src\MemberCenter.TestSite\MemberCenter.TestSite.csproj", "{ABA4996C-B48F-444C-BEF2-83BDED5D74B2}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {7733733D-22EB-431D-A8AA-833486C3E0E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7733733D-22EB-431D-A8AA-833486C3E0E2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7733733D-22EB-431D-A8AA-833486C3E0E2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7733733D-22EB-431D-A8AA-833486C3E0E2}.Release|Any CPU.Build.0 = Release|Any CPU - {90EC27FD-E72D-4506-A81A-BD81F4D555CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {90EC27FD-E72D-4506-A81A-BD81F4D555CF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {90EC27FD-E72D-4506-A81A-BD81F4D555CF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {90EC27FD-E72D-4506-A81A-BD81F4D555CF}.Release|Any CPU.Build.0 = Release|Any CPU - {28015B2B-16F2-4DA0-9DA6-D79C94330A4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {28015B2B-16F2-4DA0-9DA6-D79C94330A4D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {28015B2B-16F2-4DA0-9DA6-D79C94330A4D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {28015B2B-16F2-4DA0-9DA6-D79C94330A4D}.Release|Any CPU.Build.0 = Release|Any CPU - {051ECE48-E49B-4E42-BE08-6E9AAB7262BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {051ECE48-E49B-4E42-BE08-6E9AAB7262BC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {051ECE48-E49B-4E42-BE08-6E9AAB7262BC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {051ECE48-E49B-4E42-BE08-6E9AAB7262BC}.Release|Any CPU.Build.0 = Release|Any CPU - {5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}.Release|Any CPU.Build.0 = Release|Any CPU - {91DF0CEA-698F-4DF5-A44C-89AB38AA2561}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {91DF0CEA-698F-4DF5-A44C-89AB38AA2561}.Debug|Any CPU.Build.0 = Debug|Any CPU - {91DF0CEA-698F-4DF5-A44C-89AB38AA2561}.Release|Any CPU.ActiveCfg = Release|Any CPU - {91DF0CEA-698F-4DF5-A44C-89AB38AA2561}.Release|Any CPU.Build.0 = Release|Any CPU - {ABA4996C-B48F-444C-BEF2-83BDED5D74B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {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 - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {7733733D-22EB-431D-A8AA-833486C3E0E2} = {150D3A20-BF61-4012-BD40-05D408749112} - {90EC27FD-E72D-4506-A81A-BD81F4D555CF} = {150D3A20-BF61-4012-BD40-05D408749112} - {28015B2B-16F2-4DA0-9DA6-D79C94330A4D} = {150D3A20-BF61-4012-BD40-05D408749112} - {051ECE48-E49B-4E42-BE08-6E9AAB7262BC} = {150D3A20-BF61-4012-BD40-05D408749112} - {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} - EndGlobalSection -EndGlobal + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{150D3A20-BF61-4012-BD40-05D408749112}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Domain", "src\MemberCenter.Domain\MemberCenter.Domain.csproj", "{7733733D-22EB-431D-A8AA-833486C3E0E2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Application", "src\MemberCenter.Application\MemberCenter.Application.csproj", "{90EC27FD-E72D-4506-A81A-BD81F4D555CF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Infrastructure", "src\MemberCenter.Infrastructure\MemberCenter.Infrastructure.csproj", "{28015B2B-16F2-4DA0-9DA6-D79C94330A4D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Api", "src\MemberCenter.Api\MemberCenter.Api.csproj", "{051ECE48-E49B-4E42-BE08-6E9AAB7262BC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Installer", "src\MemberCenter.Installer\MemberCenter.Installer.csproj", "{5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MemberCenter.Web", "src\MemberCenter.Web\MemberCenter.Web.csproj", "{91DF0CEA-698F-4DF5-A44C-89AB38AA2561}" +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 + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7733733D-22EB-431D-A8AA-833486C3E0E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7733733D-22EB-431D-A8AA-833486C3E0E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7733733D-22EB-431D-A8AA-833486C3E0E2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7733733D-22EB-431D-A8AA-833486C3E0E2}.Release|Any CPU.Build.0 = Release|Any CPU + {90EC27FD-E72D-4506-A81A-BD81F4D555CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {90EC27FD-E72D-4506-A81A-BD81F4D555CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {90EC27FD-E72D-4506-A81A-BD81F4D555CF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {90EC27FD-E72D-4506-A81A-BD81F4D555CF}.Release|Any CPU.Build.0 = Release|Any CPU + {28015B2B-16F2-4DA0-9DA6-D79C94330A4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {28015B2B-16F2-4DA0-9DA6-D79C94330A4D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {28015B2B-16F2-4DA0-9DA6-D79C94330A4D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {28015B2B-16F2-4DA0-9DA6-D79C94330A4D}.Release|Any CPU.Build.0 = Release|Any CPU + {051ECE48-E49B-4E42-BE08-6E9AAB7262BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {051ECE48-E49B-4E42-BE08-6E9AAB7262BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {051ECE48-E49B-4E42-BE08-6E9AAB7262BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {051ECE48-E49B-4E42-BE08-6E9AAB7262BC}.Release|Any CPU.Build.0 = Release|Any CPU + {5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5FAA2380-3354-4FC8-BDFE-2E31E8AD9EE2}.Release|Any CPU.Build.0 = Release|Any CPU + {91DF0CEA-698F-4DF5-A44C-89AB38AA2561}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {91DF0CEA-698F-4DF5-A44C-89AB38AA2561}.Debug|Any CPU.Build.0 = Debug|Any CPU + {91DF0CEA-698F-4DF5-A44C-89AB38AA2561}.Release|Any CPU.ActiveCfg = Release|Any CPU + {91DF0CEA-698F-4DF5-A44C-89AB38AA2561}.Release|Any CPU.Build.0 = Release|Any CPU + {ABA4996C-B48F-444C-BEF2-83BDED5D74B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {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} + {90EC27FD-E72D-4506-A81A-BD81F4D555CF} = {150D3A20-BF61-4012-BD40-05D408749112} + {28015B2B-16F2-4DA0-9DA6-D79C94330A4D} = {150D3A20-BF61-4012-BD40-05D408749112} + {051ECE48-E49B-4E42-BE08-6E9AAB7262BC} = {150D3A20-BF61-4012-BD40-05D408749112} + {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 diff --git a/README.md b/README.md index 60c9be9..40599b6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..dfd92f7 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -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` 為準。 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index d7410f2..96aa6e2 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 diff --git a/docs/FLOWS.md b/docs/FLOWS.md index e3e87cb..1e0b075 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -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 會員資料維護 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 5313971..787141d 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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 diff --git a/docs/MEMBER_UPGRADE_PLAN.md b/docs/MEMBER_UPGRADE_PLAN.md index 31e7d3c..80af7a8 100644 --- a/docs/MEMBER_UPGRADE_PLAN.md +++ b/docs/MEMBER_UPGRADE_PLAN.md @@ -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 與非本期項目 diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index 990ab1b..57617a3 100644 --- a/docs/OPENAPI.md +++ b/docs/OPENAPI.md @@ -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 diff --git a/docs/SCOPES.md b/docs/SCOPES.md new file mode 100644 index 0000000..63a932a --- /dev/null +++ b/docs/SCOPES.md @@ -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 授權模型。 diff --git a/docs/TEST_SITE.md b/docs/TEST_SITE.md index 443ea68..070e92e 100644 --- a/docs/TEST_SITE.md +++ b/docs/TEST_SITE.md @@ -25,11 +25,16 @@ http://localhost:5243 "WebBaseUrl": "http://localhost:5080", "WebLoginClientId": "", "ServiceClientId": "", - "ServiceClientSecret": "" + "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= 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,不取代完整自動化測試。 diff --git a/docs/UI.md b/docs/UI.md index f8da928..c1cabd1 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -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` diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a31dfdb..4cfa9fa 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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: diff --git a/src/MemberCenter.Api/Contracts/AuthRequests.cs b/src/MemberCenter.Api/Contracts/AuthRequests.cs index 5daa459..5f63e65 100644 --- a/src/MemberCenter.Api/Contracts/AuthRequests.cs +++ b/src/MemberCenter.Api/Contracts/AuthRequests.cs @@ -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); diff --git a/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs b/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs index 488931c..f4f517a 100644 --- a/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs +++ b/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs @@ -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 Uris, string? Error) NormalizeRedirectUris(List? redirectUris) { if (redirectUris is null || redirectUris.Count == 0) diff --git a/src/MemberCenter.Api/Controllers/AuthController.cs b/src/MemberCenter.Api/Controllers/AuthController.cs index d8557ef..7bbedab 100644 --- a/src/MemberCenter.Api/Controllers/AuthController.cs +++ b/src/MemberCenter.Api/Controllers/AuthController.cs @@ -73,6 +73,7 @@ public class AuthController : ControllerBase } [HttpPost("password/reset")] + [EnableRateLimiting(RateLimitPolicyNames.PublicAuthTokenConsumption)] public async Task 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 VerifyEmail([FromQuery] string token, [FromQuery] string email) { var user = await _userManager.FindByEmailAsync(email); diff --git a/src/MemberCenter.Api/Controllers/FileAccessController.cs b/src/MemberCenter.Api/Controllers/FileAccessController.cs index 036716c..1a47b20 100644 --- a/src/MemberCenter.Api/Controllers/FileAccessController.cs +++ b/src/MemberCenter.Api/Controllers/FileAccessController.cs @@ -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 _userManager; + private readonly FileAccessTokenOptions _tokenOptions; - public FileAccessController(MemberCenterDbContext dbContext, UserManager userManager) + public FileAccessController( + MemberCenterDbContext dbContext, + UserManager userManager, + IOptions 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) diff --git a/src/MemberCenter.Api/Controllers/NewsletterController.cs b/src/MemberCenter.Api/Controllers/NewsletterController.cs index 62189e0..35a5aff 100644 --- a/src/MemberCenter.Api/Controllers/NewsletterController.cs +++ b/src/MemberCenter.Api/Controllers/NewsletterController.cs @@ -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 tokenOptions) { _newsletterService = newsletterService; + _newsletterListService = newsletterListService; + _tokenOptions = tokenOptions.Value; } [HttpPost("subscribe")] + [Authorize] [EnableRateLimiting(RateLimitPolicyNames.PublicNewsletterSubscribe)] public async Task 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 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 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 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 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 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 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 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; diff --git a/src/MemberCenter.Api/Controllers/OAuthController.cs b/src/MemberCenter.Api/Controllers/OAuthController.cs index 1815517..bdbb275 100644 --- a/src/MemberCenter.Api/Controllers/OAuthController.cs +++ b/src/MemberCenter.Api/Controllers/OAuthController.cs @@ -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 _userManager; private readonly SignInManager _signInManager; + private readonly IOpenIddictApplicationManager _applicationManager; + private readonly IAuthResourceRegistryService _authResourceRegistry; public OAuthController( IConfiguration configuration, UserManager userManager, - SignInManager signInManager) + SignInManager 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)); diff --git a/src/MemberCenter.Api/Controllers/SubscriptionsController.cs b/src/MemberCenter.Api/Controllers/SubscriptionsController.cs index 5914521..34f5b75 100644 --- a/src/MemberCenter.Api/Controllers/SubscriptionsController.cs +++ b/src/MemberCenter.Api/Controllers/SubscriptionsController.cs @@ -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(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 diff --git a/src/MemberCenter.Api/Controllers/TokenController.cs b/src/MemberCenter.Api/Controllers/TokenController.cs index a8cab7f..d81b1ec 100644 --- a/src/MemberCenter.Api/Controllers/TokenController.cs +++ b/src/MemberCenter.Api/Controllers/TokenController.cs @@ -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 _userManager; - private readonly SignInManager _signInManager; private readonly IOpenIddictApplicationManager _applicationManager; private readonly IAuthResourceRegistryService _authResourceRegistry; + private readonly ISecuritySettingsService _securitySettingsService; public TokenController( UserManager userManager, - SignInManager 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 Exchange() { var request = HttpContext.Features.Get()?.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)); + } } diff --git a/src/MemberCenter.Api/Controllers/UserController.cs b/src/MemberCenter.Api/Controllers/UserController.cs index c4314dd..708f7b9 100644 --- a/src/MemberCenter.Api/Controllers/UserController.cs +++ b/src/MemberCenter.Api/Controllers/UserController.cs @@ -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 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 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 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 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 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 SubscriptionsByEmail([FromQuery] string email) - { - return Ok(await _newsletterService.ListSubscriptionsByEmailAsync(email)); - } - - [Authorize(Policy = "ProfileSubscriptionsWrite")] - [HttpPost("subscriptions/by-email/{id:guid}/unsubscribe")] - public async Task UnsubscribeByEmail(Guid id, [FromQuery] string email) - { - var subscription = await _newsletterService.UnsubscribeByEmailAsync(email, id); - return subscription is null ? NotFound() : Ok(subscription); - } } diff --git a/src/MemberCenter.Api/Extensions/ClaimsExtensions.cs b/src/MemberCenter.Api/Extensions/ClaimsExtensions.cs index 0a561d0..a500da9 100644 --- a/src/MemberCenter.Api/Extensions/ClaimsExtensions.cs +++ b/src/MemberCenter.Api/Extensions/ClaimsExtensions.cs @@ -5,21 +5,6 @@ namespace MemberCenter.Api.Extensions; public static class ClaimsExtensions { - public static IEnumerable 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 GetDestinations(System.Security.Claims.Claim claim) { return claim.Type switch diff --git a/src/MemberCenter.Api/Program.cs b/src/MemberCenter.Api/Program.cs index 5977ecd..3e21729 100644 --- a/src/MemberCenter.Api/Program.cs +++ b/src/MemberCenter.Api/Program.cs @@ -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() ?? new(); +var rateLimits = builder.Configuration.GetSection("RateLimits").Get() ?? new(); +var newsletterTokens = builder.Configuration.GetSection("NewsletterTokens").Get() ?? new(); +var fileAccessTokens = builder.Configuration.GetSection("FileAccessTokens").Get() ?? 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(); +if (dataProtectionCertificate is not null) +{ + dataProtectionBuilder.ProtectKeysWithCertificate(dataProtectionCertificate); + if (previousDataProtectionCertificates.Count > 0) + { + dataProtectionBuilder.UnprotectKeysWithAnyCertificate( + new[] { dataProtectionCertificate }.Concat(previousDataProtectionCertificates).ToArray()); + } +} builder.Services.AddDbContext(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(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() .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(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(); builder.Services.AddScoped(services => services.GetRequiredService()); builder.Services.AddScoped(services => services.GetRequiredService()); builder.Services.AddHttpContextAccessor(); +builder.Services.Configure(builder.Configuration.GetSection("NewsletterTokens")); +builder.Services.Configure(builder.Configuration.GetSection("FileAccessTokens")); builder.Services.Configure(builder.Configuration.GetSection("SendEngine")); builder.Services.AddHttpClient(); builder.Services.AddScoped(); 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 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 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 diff --git a/src/MemberCenter.Application/Abstractions/INewsletterService.cs b/src/MemberCenter.Application/Abstractions/INewsletterService.cs index 1d945fa..48b673c 100644 --- a/src/MemberCenter.Application/Abstractions/INewsletterService.cs +++ b/src/MemberCenter.Application/Abstractions/INewsletterService.cs @@ -16,6 +16,4 @@ public interface INewsletterService Task> ListSubscriptionsAsync(Guid listId); Task> ListSubscriptionsForUserAsync(Guid userId); Task UnsubscribeForUserAsync(Guid userId, Guid subscriptionId); - Task> ListSubscriptionsByEmailAsync(string email); - Task UnsubscribeByEmailAsync(string email, Guid subscriptionId); } diff --git a/src/MemberCenter.Application/Abstractions/ISecuritySettingsService.cs b/src/MemberCenter.Application/Abstractions/ISecuritySettingsService.cs index 33fc2a5..f9cf681 100644 --- a/src/MemberCenter.Application/Abstractions/ISecuritySettingsService.cs +++ b/src/MemberCenter.Application/Abstractions/ISecuritySettingsService.cs @@ -5,6 +5,7 @@ namespace MemberCenter.Application.Abstractions; public interface ISecuritySettingsService { Task GetAsync(); + Task GetTokenLifetimeSettingsAsync(); Task GetSmtpSettingsAsync(); Task SaveAsync(SecuritySettingsDto settings, Guid? actorUserId = null); Task SendTestEmailAsync(string toEmail, Guid? actorUserId = null); diff --git a/src/MemberCenter.Application/Constants/RateLimitPolicyNames.cs b/src/MemberCenter.Application/Constants/RateLimitPolicyNames.cs index 4d60279..6f84941 100644 --- a/src/MemberCenter.Application/Constants/RateLimitPolicyNames.cs +++ b/src/MemberCenter.Application/Constants/RateLimitPolicyNames.cs @@ -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"; } diff --git a/src/MemberCenter.Application/Models/Admin/TokenLifetimeSettingsDto.cs b/src/MemberCenter.Application/Models/Admin/TokenLifetimeSettingsDto.cs new file mode 100644 index 0000000..574c98f --- /dev/null +++ b/src/MemberCenter.Application/Models/Admin/TokenLifetimeSettingsDto.cs @@ -0,0 +1,5 @@ +namespace MemberCenter.Application.Models.Admin; + +public sealed record TokenLifetimeSettingsDto( + int AccessTokenMinutes, + int RefreshTokenDays); diff --git a/src/MemberCenter.Infrastructure/Configuration/CertificateLoader.cs b/src/MemberCenter.Infrastructure/Configuration/CertificateLoader.cs new file mode 100644 index 0000000..bb1053d --- /dev/null +++ b/src/MemberCenter.Infrastructure/Configuration/CertificateLoader.cs @@ -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 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; + } +} diff --git a/src/MemberCenter.Infrastructure/Configuration/ReturnUrlValidator.cs b/src/MemberCenter.Infrastructure/Configuration/ReturnUrlValidator.cs new file mode 100644 index 0000000..c433d4e --- /dev/null +++ b/src/MemberCenter.Infrastructure/Configuration/ReturnUrlValidator.cs @@ -0,0 +1,43 @@ +namespace MemberCenter.Infrastructure.Configuration; + +public static class ReturnUrlValidator +{ + public static bool IsAllowedExternal(Uri candidate, IEnumerable 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); +} diff --git a/src/MemberCenter.Infrastructure/Configuration/SecurityRuntimeOptions.cs b/src/MemberCenter.Infrastructure/Configuration/SecurityRuntimeOptions.cs new file mode 100644 index 0000000..a73dd3f --- /dev/null +++ b/src/MemberCenter.Infrastructure/Configuration/SecurityRuntimeOptions.cs @@ -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."); + } + } +} diff --git a/src/MemberCenter.Infrastructure/Configuration/TrustedForwardedHeaders.cs b/src/MemberCenter.Infrastructure/Configuration/TrustedForwardedHeaders.cs new file mode 100644 index 0000000..237b334 --- /dev/null +++ b/src/MemberCenter.Infrastructure/Configuration/TrustedForwardedHeaders.cs @@ -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("ReverseProxy:ForwardLimit") ?? 1, 1, 5); + } + + private static IEnumerable Split(string? value) => + (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +} diff --git a/src/MemberCenter.Infrastructure/MemberCenter.Infrastructure.csproj b/src/MemberCenter.Infrastructure/MemberCenter.Infrastructure.csproj index 8cade53..56b5de7 100644 --- a/src/MemberCenter.Infrastructure/MemberCenter.Infrastructure.csproj +++ b/src/MemberCenter.Infrastructure/MemberCenter.Infrastructure.csproj @@ -5,6 +5,10 @@ + + + + diff --git a/src/MemberCenter.Infrastructure/Services/AuthResourceRegistryService.cs b/src/MemberCenter.Infrastructure/Services/AuthResourceRegistryService.cs index be95a5c..b3b39f7 100644 --- a/src/MemberCenter.Infrastructure/Services/AuthResourceRegistryService.cs +++ b/src/MemberCenter.Infrastructure/Services/AuthResourceRegistryService.cs @@ -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 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)) { diff --git a/src/MemberCenter.Infrastructure/Services/NewsletterService.cs b/src/MemberCenter.Infrastructure/Services/NewsletterService.cs index 0dbe32f..c30de31 100644 --- a/src/MemberCenter.Infrastructure/Services/NewsletterService.cs +++ b/src/MemberCenter.Infrastructure/Services/NewsletterService.cs @@ -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 _logger; + private readonly NewsletterTokenOptions _tokenOptions; public NewsletterService( MemberCenterDbContext dbContext, IEmailBlacklistService emailBlacklist, ISendEngineWebhookPublisher webhookPublisher, - ILogger logger) + ILogger logger, + IOptions tokenOptions) { _dbContext = dbContext; _emailBlacklist = emailBlacklist; _webhookPublisher = webhookPublisher; _logger = logger; + _tokenOptions = tokenOptions.Value; } public async Task SubscribeAsync(Guid listId, string email, Dictionary? 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> 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 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); diff --git a/src/MemberCenter.Infrastructure/Services/SecuritySettingsService.cs b/src/MemberCenter.Infrastructure/Services/SecuritySettingsService.cs index 216bf08..1e1d819 100644 --- a/src/MemberCenter.Infrastructure/Services/SecuritySettingsService.cs +++ b/src/MemberCenter.Infrastructure/Services/SecuritySettingsService.cs @@ -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 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 GetTokenLifetimeSettingsAsync() + { + var access = await GetFlagAsync(AccessTokenKey, 60); + var refresh = await GetFlagAsync(RefreshTokenKey, 30); + return new TokenLifetimeSettingsDto(access, refresh); + } + public async Task 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 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; } diff --git a/src/MemberCenter.Installer/Program.cs b/src/MemberCenter.Installer/Program.cs index 0b462b2..a90ddbe 100644 --- a/src/MemberCenter.Installer/Program.cs +++ b/src/MemberCenter.Installer/Program.cs @@ -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(new ConfigurationBuilder() + var configuration = new ConfigurationBuilder() .AddEnvironmentVariables() - .Build()); + .Build(); + var identitySecurity = configuration.GetSection("IdentitySecurity").Get() ?? new(); + SecurityRuntimeOptionsValidator.Validate(identitySecurity); + services.AddLogging(builder => builder.AddConsole()); + services.AddSingleton(configuration); services.AddDbContext(options => { options.UseNpgsql(connectionString); @@ -370,11 +373,14 @@ static IServiceProvider BuildServices(string connectionString) services.AddIdentity(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() .AddDefaultTokenProviders(); diff --git a/src/MemberCenter.TestSite/Controllers/HomeController.cs b/src/MemberCenter.TestSite/Controllers/HomeController.cs index a7d6e03..d0b4784 100644 --- a/src/MemberCenter.TestSite/Controllers/HomeController.cs +++ b/src/MemberCenter.TestSite/Controllers/HomeController.cs @@ -128,22 +128,6 @@ public class HomeController : Controller return RedirectToAction(nameof(Index)); } - [HttpPost("auth/api-login")] - public async Task ApiLogin(string email, string password) - { - var options = GetOptions(); - var form = new Dictionary - { - ["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 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 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 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() { diff --git a/src/MemberCenter.TestSite/Views/Home/Index.cshtml b/src/MemberCenter.TestSite/Views/Home/Index.cshtml index d8af594..42bf9ea 100644 --- a/src/MemberCenter.TestSite/Views/Home/Index.cshtml +++ b/src/MemberCenter.TestSite/Views/Home/Index.cshtml @@ -47,25 +47,14 @@ -
-

2. API Login

-
- - - - - -
-
-

Service Token

-

Required for checks 9 and 10.

+

Uses a registered confidential client. Granted scopes are bounded by its configured usage.

- + @@ -99,21 +88,6 @@
-
-

Service Token Happy Paths

-

Use a service token with at least profile:basic.read and profile:addresses.read.

-
- - - -
-
- - - -
-
-

Last Response

@(Model.LastResponseTitle ?? "No request yet")

diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs index 11f5d30..af6fd8f 100644 --- a/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs @@ -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 NormalizeRedirectUris(string? value, out string? error) { error = null; diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Blacklist/Create.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Blacklist/Create.cshtml index 2c71091..dfefaae 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Blacklist/Create.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Blacklist/Create.cshtml @@ -2,6 +2,7 @@

Add Email Blacklist

+ @Html.AntiForgeryToken() diff --git a/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Create.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Create.cshtml index ef4e527..29117fd 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Create.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Create.cshtml @@ -2,6 +2,7 @@

Create Newsletter List

+ @Html.AntiForgeryToken() diff --git a/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Index.cshtml index f22694c..b50c660 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/NewsletterLists/Index.cshtml @@ -24,7 +24,8 @@ } @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.NewsletterListsDelete)) { - + + @Html.AntiForgeryToken()
} diff --git a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml index 7112b51..63f1845 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml @@ -2,6 +2,7 @@

Create OAuth Client

+ @Html.AntiForgeryToken() @@ -40,42 +41,3 @@
- - diff --git a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Index.cshtml index e753dbe..52d7a89 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Index.cshtml @@ -27,6 +27,17 @@ } } +@if (TempData["GeneratedClientId"] is string generatedId) +{ +
+ Client Secret Generated
+
Client ID: @generatedId
+ @if (TempData["GeneratedClientSecret"] is string generatedSecret) + { +
New Client Secret (show once): @generatedSecret
+ } +
+} @@ -52,13 +63,15 @@ @if (string.Equals(clientType, "confidential", StringComparison.OrdinalIgnoreCase) && await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsRotateSecret)) { - + + @Html.AntiForgeryToken() } @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.OAuthClientsDelete)) { - + + @Html.AntiForgeryToken() } diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Security/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Security/Index.cshtml index 130c9bc..51aa689 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Security/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Security/Index.cshtml @@ -9,10 +9,10 @@ @Html.AntiForgeryToken()
- + - + diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Create.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Create.cshtml index d242ba8..88a89be 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Create.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Create.cshtml @@ -2,6 +2,7 @@

Create Tenant

+ @Html.AntiForgeryToken() diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Edit.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Edit.cshtml index 8a6129f..72c127e 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Edit.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Edit.cshtml @@ -2,6 +2,7 @@

Edit Tenant

+ @Html.AntiForgeryToken() diff --git a/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Index.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Index.cshtml index eaae04d..d836f41 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Index.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/Tenants/Index.cshtml @@ -25,7 +25,8 @@ } @if (await AdminPermissionChecker.HasPermissionAsync(User, AdminPermissions.TenantsDelete)) { - + + @Html.AntiForgeryToken() } diff --git a/src/MemberCenter.Web/Controllers/AccountController.cs b/src/MemberCenter.Web/Controllers/AccountController.cs index f841a65..4478788 100644 --- a/src/MemberCenter.Web/Controllers/AccountController.cs +++ b/src/MemberCenter.Web/Controllers/AccountController.cs @@ -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 _userManager; private readonly SignInManager _signInManager; @@ -24,6 +26,7 @@ public class AccountController : Controller IAccountEmailService accountEmailService, IAuditLogWriter auditLogWriter, IConfiguration configuration, + IWebHostEnvironment environment, UserManager userManager, SignInManager 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 ResetPassword(ResetPasswordViewModel model) { if (!ModelState.IsValid) @@ -327,6 +332,7 @@ public class AccountController : Controller } [HttpGet] + [EnableRateLimiting(RateLimitPolicyNames.PublicAuthTokenConsumption)] public async Task 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(); - 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 GetConfiguredReturnUrls(string key) => + (_configuration[key] ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + private enum ReturnUrlPurpose + { + Login, + Logout } } diff --git a/src/MemberCenter.Web/Program.cs b/src/MemberCenter.Web/Program.cs index de118db..a12d7aa 100644 --- a/src/MemberCenter.Web/Program.cs +++ b/src/MemberCenter.Web/Program.cs @@ -18,17 +18,42 @@ using Microsoft.EntityFrameworkCore; EnvLoader.LoadDotEnvIfDevelopment(); var builder = WebApplication.CreateBuilder(args); +var identitySecurity = builder.Configuration.GetSection("IdentitySecurity").Get() ?? new(); +var rateLimits = builder.Configuration.GetSection("RateLimits").Get() ?? new(); +var newsletterTokens = builder.Configuration.GetSection("NewsletterTokens").Get() ?? 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(); +if (dataProtectionCertificate is not null) +{ + dataProtectionBuilder.ProtectKeysWithCertificate(dataProtectionCertificate); + if (previousDataProtectionCertificates.Count > 0) + { + dataProtectionBuilder.UnprotectKeysWithAnyCertificate( + new[] { dataProtectionCertificate }.Concat(previousDataProtectionCertificates).ToArray()); + } +} builder.Services.AddDbContext(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(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() .AddDefaultTokenProviders(); @@ -92,11 +117,8 @@ builder.Services.AddAuthorization(options => builder.Services.Configure(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(); @@ -138,6 +163,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(services => services.GetRequiredService()); builder.Services.AddScoped(services => services.GetRequiredService()); +builder.Services.Configure(builder.Configuration.GetSection("NewsletterTokens")); builder.Services.Configure(builder.Configuration.GetSection("SendEngine")); builder.Services.AddHttpClient(); builder.Services.AddScoped(); @@ -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 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 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 diff --git a/src/MemberCenter.Web/Views/Profile/Addresses.cshtml b/src/MemberCenter.Web/Views/Profile/Addresses.cshtml index ae64f0d..b1b0ed2 100644 --- a/src/MemberCenter.Web/Views/Profile/Addresses.cshtml +++ b/src/MemberCenter.Web/Views/Profile/Addresses.cshtml @@ -33,7 +33,7 @@ else
NameClient IdTypeUsage
@(address.IsDefault ? "Yes" : "No") Edit -
+ @Html.AntiForgeryToken()
diff --git a/src/MemberCenter.Web/Views/Shared/_Layout.cshtml b/src/MemberCenter.Web/Views/Shared/_Layout.cshtml index c9499c4..de86a95 100644 --- a/src/MemberCenter.Web/Views/Shared/_Layout.cshtml +++ b/src/MemberCenter.Web/Views/Shared/_Layout.cshtml @@ -75,5 +75,6 @@
@RenderBody()
+ diff --git a/src/MemberCenter.Web/wwwroot/js/site.js b/src/MemberCenter.Web/wwwroot/js/site.js index dcc7262..5da4907 100644 --- a/src/MemberCenter.Web/wwwroot/js/site.js +++ b/src/MemberCenter.Web/wwwroot/js/site.js @@ -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. - -// Write your JavaScript code. +(() => { + const usage = document.getElementById("Usage"); + const redirect = document.getElementById("RedirectUris"); + const clientType = document.getElementById("ClientType"); + if (!usage || !redirect || !clientType) return; + + 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(); +})(); diff --git a/tests/MemberCenter.Security.Tests/MemberCenter.Security.Tests.csproj b/tests/MemberCenter.Security.Tests/MemberCenter.Security.Tests.csproj new file mode 100644 index 0000000..1bab81a --- /dev/null +++ b/tests/MemberCenter.Security.Tests/MemberCenter.Security.Tests.csproj @@ -0,0 +1,20 @@ + + + net8.0 + enable + enable + false + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/tests/MemberCenter.Security.Tests/SecurityConfigurationTests.cs b/tests/MemberCenter.Security.Tests/SecurityConfigurationTests.cs new file mode 100644 index 0000000..27ed15c --- /dev/null +++ b/tests/MemberCenter.Security.Tests/SecurityConfigurationTests.cs @@ -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(() => TrustedForwardedHeaders.Configure( + options, Configuration(new() { ["ReverseProxy:KnownNetworks"] = network }))); + } + + [Fact] + public void RequiredCertificateMustBeConfigured() + { + Assert.Throws(() => CertificateLoader.LoadFromConfiguration( + Configuration(), "Auth:Certificates:Signing", required: true)); + } + + [Fact] + public void MissingCertificateFileIsRejected() + { + Assert.Throws(() => 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(() => SecurityRuntimeOptionsValidator.Validate(new IdentitySecurityOptions + { + Password = new PasswordPolicyOptions { RequiredLength = 7 } + })); + Assert.Throws(() => SecurityRuntimeOptionsValidator.Validate(new FileAccessTokenOptions + { + MinimumLifetimeSeconds = 60, + DefaultLifetimeSeconds = 30, + MaximumLifetimeSeconds = 900 + })); + } + + private static IConfiguration Configuration(Dictionary? values = null) => + new ConfigurationBuilder().AddInMemoryCollection(values).Build(); +}