diff --git a/.env.example b/.env.example index 6105eba..0a73ed0 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,6 @@ ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgr 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 # Optional PFX file overrides. Production normally reads app-managed certificates provisioned by installer into DB. Auth__Certificates__Signing__Path= diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 001542a..2bb407a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -67,7 +67,6 @@ Newsletter 值皆須大於 0;File Access 必須符合 `0 < minimum <= default | `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。 | @@ -76,6 +75,7 @@ Newsletter 值皆須大於 0;File Access 必須符合 `0 < minimum <= default OAuth usage/scope mapping 的正式來源為 DB registry;audience key 只作 seed / 相容來源。 完整現有 scope catalog 與 usage mapping 請見 `docs/SCOPES.md`。 +Web logout 的外部 `returnUrl` allowlist 由 `usage=web_login` OAuth client 的 `post_logout_redirect_uris` 管理;舊 `Auth:AllowedLogoutReturnUrlPrefixes` 不再使用。 ## 憑證與 Data Protection diff --git a/docs/DESIGN.md b/docs/DESIGN.md index bdd54d3..a83d4df 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -98,7 +98,7 @@ ### 6.1 OAuth2/OIDC Redirect 登入(Authorization Code + PKCE) 狀態:已支援 `usage=web_login`。 -1) 站點建立 OAuth client,`usage=web_login`,設定 `redirect_uris` +1) 站點建立 OAuth client,`usage=web_login`,設定 `redirect_uris` 與 `post_logout_redirect_uris` 2) 站點導向 `/oauth/authorize`,帶 `client_id`, `redirect_uri`, `code_challenge`, `code_challenge_method=S256`, `response_type=code`, `scope=openid email profile` 3) 若使用者尚未登入,`/oauth/authorize` 會導向會員中心 Web login,登入後回到原 authorize request 4) 成功後導回 `redirect_uri` 並附 `code` @@ -108,7 +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`。 +- Login / Logout return URL allowlist 分開驗證,採 URI origin 與 path segment prefix 比對,不使用原始字串 `StartsWith`;Logout allowlist 來自 `web_login` OAuth client 的 `post_logout_redirect_uris`。 - API 可用 `Auth:WebLoginUrl` 指定登入頁位置;預設為 `/account/login`。 - `web_login` 可使用 public client + PKCE,不要求 client secret。 - `web_login` client 可使用 `openid email profile` 與 current-user `profile:*` scopes;這些 scopes 僅能操作 access token subject 自己的資料。 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 77c1852..ea47bd2 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -47,7 +47,6 @@ ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgr 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 # Optional certificate file overrides. Normally installer provisions app certificates into DB. Auth__Certificates__Signing__Path= @@ -80,9 +79,10 @@ 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;不會同時成為 login allowlist。 +- Web logout 後允許 redirect 的 URL prefix 由 `usage=web_login` OAuth client 的 `post_logout_redirect_uris` 設定,不再使用 env allowlist。 +- Web CSP `form-action` 會自動包含 `Auth__Issuer`、`Auth__AllowedLoginReturnUrlPrefixes`、`web_login` client `redirect_uris` / `post_logout_redirect_uris` 的 origin,讓 login form POST 成功後可經 API `/oauth/authorize` 302 回外部登入站。 - 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。 +- Identity cookie 在 Development 使用 `SameSite=Lax` 與 request scheme,支援 localhost HTTP 測試;非 Development 固定使用 `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。 diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index 338b75c..4d11132 100644 --- a/docs/OPENAPI.md +++ b/docs/OPENAPI.md @@ -89,6 +89,7 @@ - 供外部網站使用 Member Center 統一登入 UI - 使用 Authorization Code + PKCE - 需設定 `redirect_uris` + - 需設定 `post_logout_redirect_uris` 作為 Web logout `returnUrl` allowlist - 可使用 `client_type=public` - 允許 scope:`openid`、`email`、`profile`、全部 current-user `profile:*` - `usage=webhook_outbound`: @@ -101,6 +102,7 @@ - 可不綁定 `tenant_id`,scope 使用 `newsletter:events.write.global` - `tenant_api` / `send_api` / `platform_service` / `file_api` 建議(且實作要求)`client_type=confidential` - `redirect_uris` 僅 `web_login` / `webhook_outbound` 需要;其他 usage 可為空 +- `post_logout_redirect_uris` 僅 `web_login` logout return 使用;其他 usage 可為空 - 管理規則: - 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`) - 平台級流程另建 `platform_service` 憑證 diff --git a/docs/TEST_SITE.md b/docs/TEST_SITE.md index 070e92e..b175bff 100644 --- a/docs/TEST_SITE.md +++ b/docs/TEST_SITE.md @@ -39,6 +39,7 @@ Repo 曾包含的 TestSite service client secret 必須在對應環境撤銷/r - `usage=web_login` - `client_type=public` - redirect URI: `http://localhost:5243/auth/callback` +- post logout redirect URI: `http://localhost:5243/` - scopes: `openid email profile profile:basic.read profile:basic.write profile:addresses.read profile:addresses.write profile:subscriptions.read profile:subscriptions.write` service OAuth client: @@ -51,10 +52,12 @@ service OAuth client: ```text Auth__WebLoginUrl= Auth__AllowedLoginReturnUrlPrefixes= -Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/ Auth__CookieDomain= ``` +Development 的 Member Center cookie 允許 localhost HTTP redirect login;Stage/Production 仍必須使用 HTTPS。 +Web CSP `form-action` 會允許上述 login return origin 與 `web_login` client redirect origins,避免 login form POST 後的 302 chain 被瀏覽器擋下。 + ## 第一批 Happy Path 測試站目前包含: diff --git a/docs/UI.md b/docs/UI.md index c1cabd1..ac496a7 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -12,7 +12,7 @@ ### 管理者端 - 租戶管理(Tenant CRUD) -- OAuth Client 管理(usage / redirect_uris / client_id / client_secret;scope 由 usage 自動配置) +- OAuth Client 管理(usage / redirect_uris / post_logout_redirect_uris / client_id / client_secret;scope 由 usage 自動配置) - 電子報清單管理(Lists CRUD) - 訂閱查詢 / 匯出 - 審計紀錄查詢 @@ -59,6 +59,7 @@ - UC-11.1 Tenant 可設定 `Send Engine Webhook Client Id`(UUID) - 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 不需要 + - `post_logout_redirect_uris` 僅 `web_login` logout return 需要 - `tenant_api` / `send_api` / `platform_service` / `file_api` 強制 `client_type=confidential` - 既有 public client 改為 confidential 時會自動產生新的 client_secret,並只顯示一次;之後需使用 rotate secret 重新產生 - UC-13 電子報清單管理: `/admin/newsletter-lists` diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 55e23a6..5321a81 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1133,4 +1133,5 @@ components: name: { type: string } usage: { type: string, enum: [tenant_api, send_api, web_login, webhook_outbound, platform_service, file_api] } redirect_uris: { type: array, items: { type: string } } + post_logout_redirect_uris: { type: array, items: { type: string } } client_type: { type: string, enum: [public, confidential] } diff --git a/src/MemberCenter.Api/Contracts/AdminRequests.cs b/src/MemberCenter.Api/Contracts/AdminRequests.cs index f3c002d..9e75442 100644 --- a/src/MemberCenter.Api/Contracts/AdminRequests.cs +++ b/src/MemberCenter.Api/Contracts/AdminRequests.cs @@ -4,4 +4,10 @@ public sealed record TenantRequest(string Name, List Domains, string Sta public sealed record NewsletterListRequest(Guid TenantId, string Name, string Status); -public sealed record OAuthClientRequest(Guid? TenantId, string Name, List? RedirectUris, string ClientType, string Usage = "tenant_api"); +public sealed record OAuthClientRequest( + Guid? TenantId, + string Name, + List? RedirectUris, + string ClientType, + string Usage = "tenant_api", + List? PostLogoutRedirectUris = null); diff --git a/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs b/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs index f4f517a..9d68ff9 100644 --- a/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs +++ b/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs @@ -40,6 +40,7 @@ public class AdminOAuthClientsController : ControllerBase client_id = await _applicationManager.GetClientIdAsync(application), client_type = await _applicationManager.GetClientTypeAsync(application), redirect_uris = await _applicationManager.GetRedirectUrisAsync(application), + post_logout_redirect_uris = await _applicationManager.GetPostLogoutRedirectUrisAsync(application), properties = await _applicationManager.GetPropertiesAsync(application) }); } @@ -72,6 +73,15 @@ public class AdminOAuthClientsController : ControllerBase { return BadRequest(redirectUriError); } + + var (postLogoutRedirectUris, postLogoutRedirectUriError) = IsPostLogoutRedirectUsage(request.Usage) + ? NormalizeRedirectUris(request.PostLogoutRedirectUris) + : ([], null); + if (!string.IsNullOrWhiteSpace(postLogoutRedirectUriError)) + { + return BadRequest(postLogoutRedirectUriError.Replace("redirect_uris", "post_logout_redirect_uris", StringComparison.Ordinal)); + } + if (UsesAuthorizationCodeFlow(request.Usage) && redirectUris.Count == 0) { return BadRequest("redirect_uris is required for web_login or webhook_outbound usage."); @@ -98,6 +108,11 @@ public class AdminOAuthClientsController : ControllerBase descriptor.RedirectUris.Add(new Uri(uri)); } + foreach (var uri in postLogoutRedirectUris) + { + descriptor.PostLogoutRedirectUris.Add(new Uri(uri)); + } + if (!IsTenantOptionalUsage(request.Usage) && request.TenantId.HasValue) { descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.Value.ToString()); @@ -112,7 +127,8 @@ public class AdminOAuthClientsController : ControllerBase descriptor.DisplayName, descriptor.ClientType, client_secret = clientSecret, - redirect_uris = descriptor.RedirectUris.Select(u => u.ToString()) + redirect_uris = descriptor.RedirectUris.Select(u => u.ToString()), + post_logout_redirect_uris = descriptor.PostLogoutRedirectUris.Select(u => u.ToString()) }); } @@ -133,6 +149,7 @@ public class AdminOAuthClientsController : ControllerBase client_id = await _applicationManager.GetClientIdAsync(app), client_type = await _applicationManager.GetClientTypeAsync(app), redirect_uris = await _applicationManager.GetRedirectUrisAsync(app), + post_logout_redirect_uris = await _applicationManager.GetPostLogoutRedirectUrisAsync(app), properties = await _applicationManager.GetPropertiesAsync(app) }); } @@ -162,6 +179,15 @@ public class AdminOAuthClientsController : ControllerBase { return BadRequest(redirectUriError); } + + var (postLogoutRedirectUris, postLogoutRedirectUriError) = IsPostLogoutRedirectUsage(request.Usage) + ? NormalizeRedirectUris(request.PostLogoutRedirectUris) + : ([], null); + if (!string.IsNullOrWhiteSpace(postLogoutRedirectUriError)) + { + return BadRequest(postLogoutRedirectUriError.Replace("redirect_uris", "post_logout_redirect_uris", StringComparison.Ordinal)); + } + if (UsesAuthorizationCodeFlow(request.Usage) && redirectUris.Count == 0) { return BadRequest("redirect_uris is required for web_login or webhook_outbound usage."); @@ -199,6 +225,13 @@ public class AdminOAuthClientsController : ControllerBase { descriptor.RedirectUris.Add(new Uri(uri)); } + + descriptor.PostLogoutRedirectUris.Clear(); + foreach (var uri in postLogoutRedirectUris) + { + descriptor.PostLogoutRedirectUris.Add(new Uri(uri)); + } + if (!IsTenantOptionalUsage(request.Usage) && request.TenantId.HasValue) { descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.Value.ToString()); @@ -217,7 +250,8 @@ public class AdminOAuthClientsController : ControllerBase descriptor.DisplayName, descriptor.ClientType, client_secret = generatedClientSecret, - redirect_uris = descriptor.RedirectUris.Select(u => u.ToString()) + redirect_uris = descriptor.RedirectUris.Select(u => u.ToString()), + post_logout_redirect_uris = descriptor.PostLogoutRedirectUris.Select(u => u.ToString()) }); } @@ -265,6 +299,11 @@ public class AdminOAuthClientsController : ControllerBase || string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase); } + private static bool IsPostLogoutRedirectUsage(string usage) + { + return string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase); + } + private static string GenerateClientSecret() => Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)); diff --git a/src/MemberCenter.Api/Program.cs b/src/MemberCenter.Api/Program.cs index f4bb819..f9b69a4 100644 --- a/src/MemberCenter.Api/Program.cs +++ b/src/MemberCenter.Api/Program.cs @@ -120,8 +120,12 @@ builder.Services.AddAuthentication(options => builder.Services.ConfigureApplicationCookie(options => { options.Cookie.Path = "/"; - options.Cookie.SameSite = SameSiteMode.None; - options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.SameSite = builder.Environment.IsDevelopment() + ? SameSiteMode.Lax + : SameSiteMode.None; + options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() + ? CookieSecurePolicy.SameAsRequest + : CookieSecurePolicy.Always; }); builder.Services.AddOpenIddict() diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs index af6fd8f..2ec481f 100644 --- a/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs @@ -47,7 +47,8 @@ public class OAuthClientsController : Controller client_id = await _applicationManager.GetClientIdAsync(application), client_type = await _applicationManager.GetClientTypeAsync(application), usage, - redirect_uris = await _applicationManager.GetRedirectUrisAsync(application) + redirect_uris = await _applicationManager.GetRedirectUrisAsync(application), + post_logout_redirect_uris = await _applicationManager.GetPostLogoutRedirectUrisAsync(application) }); } @@ -91,6 +92,15 @@ public class OAuthClientsController : Controller ModelState.AddModelError(nameof(model.RedirectUris), redirectUriError); } + string? postLogoutRedirectUriError = null; + var postLogoutRedirectUris = IsPostLogoutRedirectUsage(model.Usage) + ? NormalizeRedirectUris(model.PostLogoutRedirectUris, out postLogoutRedirectUriError) + : new List(); + if (!string.IsNullOrWhiteSpace(postLogoutRedirectUriError)) + { + ModelState.AddModelError(nameof(model.PostLogoutRedirectUris), postLogoutRedirectUriError); + } + if (UsesAuthorizationCodeFlow(model.Usage) && redirectUris.Count == 0) { ModelState.AddModelError(nameof(model.RedirectUris), "Redirect URI is required for web_login or webhook_outbound usage."); @@ -118,6 +128,11 @@ public class OAuthClientsController : Controller descriptor.RedirectUris.Add(new Uri(uri)); } + foreach (var uri in postLogoutRedirectUris) + { + descriptor.PostLogoutRedirectUris.Add(new Uri(uri)); + } + if (!IsTenantOptionalUsage(model.Usage) && model.TenantId.HasValue) { descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString()); @@ -147,6 +162,7 @@ public class OAuthClientsController : Controller } var redirectUris = await _applicationManager.GetRedirectUrisAsync(app); + var postLogoutRedirectUris = await _applicationManager.GetPostLogoutRedirectUrisAsync(app); var properties = await _applicationManager.GetPropertiesAsync(app); var tenantId = properties.TryGetValue("tenant_id", out var value) ? value.GetString() : string.Empty; var usage = properties.TryGetValue("usage", out var usageValue) ? usageValue.GetString() : "tenant_api"; @@ -160,6 +176,7 @@ public class OAuthClientsController : Controller ClientType = await _applicationManager.GetClientTypeAsync(app) ?? "public", Usage = string.IsNullOrWhiteSpace(usage) ? "tenant_api" : usage, RedirectUris = string.Join(",", redirectUris.Select(u => u.ToString())), + PostLogoutRedirectUris = string.Join(",", postLogoutRedirectUris.Select(u => u.ToString())), Tenants = tenants }); } @@ -190,6 +207,15 @@ public class OAuthClientsController : Controller ModelState.AddModelError(nameof(model.RedirectUris), redirectUriError); } + string? postLogoutRedirectUriError = null; + var postLogoutRedirectUris = IsPostLogoutRedirectUsage(model.Usage) + ? NormalizeRedirectUris(model.PostLogoutRedirectUris, out postLogoutRedirectUriError) + : new List(); + if (!string.IsNullOrWhiteSpace(postLogoutRedirectUriError)) + { + ModelState.AddModelError(nameof(model.PostLogoutRedirectUris), postLogoutRedirectUriError); + } + if (UsesAuthorizationCodeFlow(model.Usage) && redirectUris.Count == 0) { ModelState.AddModelError(nameof(model.RedirectUris), "Redirect URI is required for web_login or webhook_outbound usage."); @@ -234,6 +260,12 @@ public class OAuthClientsController : Controller descriptor.RedirectUris.Add(new Uri(uri)); } + descriptor.PostLogoutRedirectUris.Clear(); + foreach (var uri in postLogoutRedirectUris) + { + descriptor.PostLogoutRedirectUris.Add(new Uri(uri)); + } + if (!IsTenantOptionalUsage(model.Usage) && model.TenantId.HasValue) { descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString()); @@ -308,6 +340,9 @@ public class OAuthClientsController : Controller private static bool UsesAuthorizationCodeFlow(string usage) => usage is "web_login" or "webhook_outbound"; + private static bool IsPostLogoutRedirectUsage(string usage) => + usage is "web_login"; + private static bool RequiresClientCredentials(string usage) => usage is "tenant_api" or "send_api" or "platform_service" or "file_api"; diff --git a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml index f5ef964..4d89d2b 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml @@ -42,5 +42,9 @@ + + + + diff --git a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Edit.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Edit.cshtml index c0227e5..bd08d03 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Edit.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Edit.cshtml @@ -42,6 +42,10 @@ + + + +
@L["Cancel"] diff --git a/src/MemberCenter.Web/Controllers/AccountController.cs b/src/MemberCenter.Web/Controllers/AccountController.cs index f1d7bb6..5529e5a 100644 --- a/src/MemberCenter.Web/Controllers/AccountController.cs +++ b/src/MemberCenter.Web/Controllers/AccountController.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Localization; +using OpenIddict.Abstractions; namespace MemberCenter.Web.Controllers; @@ -22,6 +23,7 @@ public class AccountController : Controller private readonly IAuditLogWriter _auditLogWriter; private readonly IConfiguration _configuration; private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider; + private readonly IOpenIddictApplicationManager _applicationManager; private readonly bool _allowInsecureReturnUrls; private readonly UserManager _userManager; private readonly SignInManager _signInManager; @@ -34,6 +36,7 @@ public class AccountController : Controller IAuditLogWriter auditLogWriter, IConfiguration configuration, IAuthenticationSchemeProvider authenticationSchemeProvider, + IOpenIddictApplicationManager applicationManager, IWebHostEnvironment environment, UserManager userManager, SignInManager signInManager, @@ -45,6 +48,7 @@ public class AccountController : Controller _auditLogWriter = auditLogWriter; _configuration = configuration; _authenticationSchemeProvider = authenticationSchemeProvider; + _applicationManager = applicationManager; _allowInsecureReturnUrls = environment.IsDevelopment(); _userManager = userManager; _signInManager = signInManager; @@ -98,7 +102,7 @@ public class AccountController : Controller await UpdateSignInMetadataAsync(loginUser); } - if (IsAllowedReturnUrl(model.ReturnUrl, ReturnUrlPurpose.Login)) + if (await IsAllowedReturnUrlAsync(model.ReturnUrl, ReturnUrlPurpose.Login)) { return Redirect(model.ReturnUrl!); } @@ -194,7 +198,7 @@ public class AccountController : Controller await _signInManager.SignInAsync(user, rememberMe, info.LoginProvider); await UpdateSignInMetadataAsync(user); - if (IsAllowedReturnUrl(returnUrl, ReturnUrlPurpose.Login)) + if (await IsAllowedReturnUrlAsync(returnUrl, ReturnUrlPurpose.Login)) { return Redirect(returnUrl!); } @@ -264,7 +268,7 @@ public class AccountController : Controller await _signInManager.SignInAsync(user, model.RememberMe, info.LoginProvider); await UpdateSignInMetadataAsync(user); - if (IsAllowedReturnUrl(model.ReturnUrl, ReturnUrlPurpose.Login)) + if (await IsAllowedReturnUrlAsync(model.ReturnUrl, ReturnUrlPurpose.Login)) { return Redirect(model.ReturnUrl!); } @@ -281,7 +285,7 @@ public class AccountController : Controller await _signInManager.SignOutAsync(); } - if (IsAllowedReturnUrl(returnUrl, ReturnUrlPurpose.Logout)) + if (await IsAllowedReturnUrlAsync(returnUrl, ReturnUrlPurpose.Logout)) { return Redirect(returnUrl!); } @@ -297,7 +301,7 @@ public class AccountController : Controller { await _signInManager.SignOutAsync(); - if (IsAllowedReturnUrl(returnUrl, ReturnUrlPurpose.Logout)) + if (await IsAllowedReturnUrlAsync(returnUrl, ReturnUrlPurpose.Logout)) { return Redirect(returnUrl!); } @@ -529,7 +533,7 @@ public class AccountController : Controller await _userManager.UpdateAsync(user); } - private bool IsAllowedReturnUrl(string? returnUrl, ReturnUrlPurpose purpose) + private async Task IsAllowedReturnUrlAsync(string? returnUrl, ReturnUrlPurpose purpose) { if (string.IsNullOrWhiteSpace(returnUrl)) { @@ -549,7 +553,7 @@ public class AccountController : Controller var allowedPrefixes = purpose == ReturnUrlPurpose.Login ? new[] { _configuration["Auth:Issuer"] } .Concat(GetConfiguredReturnUrls("Auth:AllowedLoginReturnUrlPrefixes")) - : GetConfiguredReturnUrls("Auth:AllowedLogoutReturnUrlPrefixes"); + : await GetConfiguredPostLogoutReturnUrlsAsync(); return ReturnUrlValidator.IsAllowedExternal(parsed, allowedPrefixes, _allowInsecureReturnUrls); } @@ -558,6 +562,28 @@ public class AccountController : Controller (_configuration[key] ?? string.Empty) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + private async Task> GetConfiguredPostLogoutReturnUrlsAsync() + { + var results = new List(); + await foreach (var application in _applicationManager.ListAsync()) + { + var properties = await _applicationManager.GetPropertiesAsync(application); + var usage = properties.TryGetValue("usage", out var usageValue) + ? usageValue.GetString() + : null; + + if (!string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var postLogoutRedirectUris = await _applicationManager.GetPostLogoutRedirectUrisAsync(application); + results.AddRange(postLogoutRedirectUris.Select(uri => uri.ToString())); + } + + return results; + } + private enum ReturnUrlPurpose { Login, diff --git a/src/MemberCenter.Web/Models/Admin/OAuthClientFormViewModel.cs b/src/MemberCenter.Web/Models/Admin/OAuthClientFormViewModel.cs index dd23fc5..d1274d6 100644 --- a/src/MemberCenter.Web/Models/Admin/OAuthClientFormViewModel.cs +++ b/src/MemberCenter.Web/Models/Admin/OAuthClientFormViewModel.cs @@ -19,6 +19,8 @@ public sealed class OAuthClientFormViewModel public string Usage { get; set; } = "tenant_api"; public string RedirectUris { get; set; } = string.Empty; + public string PostLogoutRedirectUris { get; set; } = string.Empty; + public IReadOnlyList Tenants { get; set; } = Array.Empty(); diff --git a/src/MemberCenter.Web/Program.cs b/src/MemberCenter.Web/Program.cs index 3421fe1..5e7b53c 100644 --- a/src/MemberCenter.Web/Program.cs +++ b/src/MemberCenter.Web/Program.cs @@ -19,6 +19,7 @@ using MemberCenter.Web.Localization; using MemberCenter.Web.Services; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using OpenIddict.Abstractions; EnvLoader.LoadDotEnvIfDevelopment(); @@ -112,8 +113,12 @@ builder.Services.ConfigureApplicationCookie(options => { options.LoginPath = "/account/login"; options.Cookie.Path = "/"; - options.Cookie.SameSite = SameSiteMode.None; - options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.SameSite = builder.Environment.IsDevelopment() + ? SameSiteMode.Lax + : SameSiteMode.None; + options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() + ? CookieSecurePolicy.SameAsRequest + : CookieSecurePolicy.Always; options.Events = new CookieAuthenticationEvents { @@ -234,6 +239,11 @@ CertificateLoader.LogExpirationWarning( await EnsureAuthRegistryDefaultsAsync(app.Services); await EnsureAdminPermissionDefaultsAsync(app.Services); +var cspFormActionSources = await BuildCspFormActionSourcesAsync( + app.Services, + builder.Configuration, + googleLoginEnabled, + builder.Environment.IsDevelopment()); if (!app.Environment.IsDevelopment()) { @@ -246,11 +256,8 @@ app.Use(async (context, next) => context.Response.OnStarting(() => { var headers = context.Response.Headers; - var formAction = googleLoginEnabled - ? "'self' https://accounts.google.com" - : "'self'"; headers.TryAdd("Content-Security-Policy", - $"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action {formAction}; frame-ancestors 'none'"); + $"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action {cspFormActionSources}; frame-ancestors 'none'"); headers.TryAdd("X-Content-Type-Options", "nosniff"); headers.TryAdd("X-Frame-Options", "DENY"); headers.TryAdd("Referrer-Policy", "no-referrer"); @@ -290,6 +297,69 @@ static Task HandleAdminAuthRedirectAsync(RedirectContext BuildCspFormActionSourcesAsync( + IServiceProvider services, + IConfiguration configuration, + bool googleLoginEnabled, + bool allowInsecureHttp) +{ + var sources = new List { "'self'" }; + if (googleLoginEnabled) + { + sources.Add("https://accounts.google.com"); + } + + foreach (var value in GetConfiguredLoginReturnUrls(configuration)) + { + AddCspFormActionSource(sources, value, allowInsecureHttp); + } + + await using var scope = services.CreateAsyncScope(); + var applicationManager = scope.ServiceProvider.GetRequiredService(); + await foreach (var application in applicationManager.ListAsync()) + { + var properties = await applicationManager.GetPropertiesAsync(application); + var usage = properties.TryGetValue("usage", out var usageValue) + ? usageValue.GetString() + : null; + if (!string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var redirectUris = await applicationManager.GetRedirectUrisAsync(application); + foreach (var uri in redirectUris) + { + AddCspFormActionSource(sources, uri.ToString(), allowInsecureHttp); + } + + var postLogoutRedirectUris = await applicationManager.GetPostLogoutRedirectUrisAsync(application); + foreach (var uri in postLogoutRedirectUris) + { + AddCspFormActionSource(sources, uri.ToString(), allowInsecureHttp); + } + } + + return string.Join(' ', sources.Distinct(StringComparer.OrdinalIgnoreCase)); +} + +static void AddCspFormActionSource(List sources, string? value, bool allowInsecureHttp) +{ + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp) || + (!allowInsecureHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + return; + } + + sources.Add(uri.GetLeftPart(UriPartial.Authority)); +} + +static IEnumerable GetConfiguredLoginReturnUrls(IConfiguration configuration) => + new[] { configuration["Auth:Issuer"] } + .Concat((configuration["Auth:AllowedLoginReturnUrlPrefixes"] ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + static async Task ValidatePrincipalAsync(CookieValidatePrincipalContext context) { await SecurityStampValidator.ValidatePrincipalAsync(context); diff --git a/src/MemberCenter.Web/wwwroot/js/site.js b/src/MemberCenter.Web/wwwroot/js/site.js index 7062990..f183944 100644 --- a/src/MemberCenter.Web/wwwroot/js/site.js +++ b/src/MemberCenter.Web/wwwroot/js/site.js @@ -9,12 +9,14 @@ (() => { const usage = document.getElementById("Usage"); const redirect = document.getElementById("RedirectUris"); + const postLogoutRedirect = document.getElementById("PostLogoutRedirectUris"); 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 needsPostLogoutRedirect = usageValue === "web_login"; const requiresConfidential = usageValue === "tenant_api" || usageValue === "send_api" || usageValue === "platform_service" @@ -22,6 +24,10 @@ redirect.disabled = !needsRedirect; if (!needsRedirect) redirect.value = ""; + if (postLogoutRedirect) { + postLogoutRedirect.disabled = !needsPostLogoutRedirect; + if (!needsPostLogoutRedirect) postLogoutRedirect.value = ""; + } const publicOption = clientType.querySelector('option[value="public"]'); if (publicOption) publicOption.disabled = requiresConfidential;