From 858519012311f5653d07c393ff80db61c89372fa Mon Sep 17 00:00:00 2001 From: Warren Chen Date: Thu, 23 Apr 2026 00:15:53 +0900 Subject: [PATCH] Add web login OAuth redirect flow --- docs/DESIGN.md | 18 +++++++-- docs/FLOWS.md | 7 +++- docs/MEMBER_UPGRADE_PLAN.md | 7 ++++ docs/OPENAPI.md | 8 +++- docs/UI.md | 4 +- .../AdminOAuthClientsController.cs | 19 ++++++--- .../Controllers/OAuthController.cs | 40 ++++++++++++++++--- .../Controllers/TokenController.cs | 27 ++++++++++++- src/MemberCenter.Api/Program.cs | 4 ++ .../Controllers/OAuthClientsController.cs | 27 ++++++++----- .../Admin/Views/OAuthClients/Create.cshtml | 5 ++- .../Admin/Views/OAuthClients/Edit.cshtml | 5 ++- .../Controllers/AccountController.cs | 38 ++++++++++++++++-- src/MemberCenter.Web/Program.cs | 4 ++ 14 files changed, 175 insertions(+), 38 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index a1afa08..c6dbce2 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -94,10 +94,20 @@ ## 6. 核心流程 ### 6.1 OAuth2/OIDC Redirect 登入(Authorization Code + PKCE) -1) 站點導向 `/oauth/authorize`,帶 `client_id`, `redirect_uri`, `code_challenge`, `scope=openid email` -2) 使用者於會員中心登入 -3) 成功後導回 `redirect_uri` 並附 `code` -4) 站點以 `code` + `code_verifier` 向 `/oauth/token` 換取 token + `id_token` +狀態:已支援 `usage=web_login`。 + +1) 站點建立 OAuth client,`usage=web_login`,設定 `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` +5) 站點以 `code` + `code_verifier` 向 `/oauth/token` 換取 token + +實作註記: +- API 與 Web 需共用 DataProtection application name `MemberCenter`,使 API authorize endpoint 可讀取 Web login cookie。 +- 若 API 與 Web 不同 origin,Web login 僅允許導回 `Auth:Issuer` 或 `Auth:AllowedLoginReturnUrlPrefixes` 內的 return URL。 +- API 可用 `Auth:WebLoginUrl` 指定登入頁位置;預設為 `/account/login`。 +- `web_login` 可使用 public client + PKCE,不要求 client secret。 +- `web_login` client 可使用 `openid email profile`,並預留 `profile:basic.read`。 ### 6.2 OAuth2 API 使用(站點自行 UI) 1) 站點以 API 驗證使用者登入(會員中心提供 login API) diff --git a/docs/FLOWS.md b/docs/FLOWS.md index d4c1910..8d7ee3e 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -11,8 +11,11 @@ ## F-02 登入(OAuth2 + OIDC) - [API] 站點送出 `POST /auth/login` 取得 access_token + id_token - [API] 站點建立自身 session -- [UI] 導向 `/oauth/authorize` 完成授權碼流程 -- [UI] 站點用 code 換 token + id_token +- [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` ## F-02b 內容站台呼叫 Send Engine(Client Credentials + JWT 驗簽) - [API] 內容站台以 `client_credentials` 呼叫 `POST /oauth/token` 取得 access_token(`usage=send_api`) diff --git a/docs/MEMBER_UPGRADE_PLAN.md b/docs/MEMBER_UPGRADE_PLAN.md index fac9e3d..78bd3bc 100644 --- a/docs/MEMBER_UPGRADE_PLAN.md +++ b/docs/MEMBER_UPGRADE_PLAN.md @@ -111,6 +111,13 @@ - Web login / external login / password grant login 成功後更新 `last_login_at` / `last_seen_at` - disabled 帳號無法透過 Web login、external login、password grant 取得登入 - Web cookie 與 API authenticated request 會檢查 disabled 狀態 +- 已補 redirect 型登入 `web_login`: + - OAuth client usage 新增 `web_login` + - 支援 Authorization Code + PKCE + - `/oauth/authorize` 未登入時會導向 Web login,登入後回到原 authorize request + - `/oauth/token` 已支援 authorization code exchange + - API / Web 共用 DataProtection application name `MemberCenter` + - 支援 `Auth:WebLoginUrl` 與 `Auth:AllowedLoginReturnUrlPrefixes` 處理 Web / API 不同 origin 的 redirect login 進行中: - profile / addresses / subscriptions 的畫面目前為最小可用版本,尚未優化樣式與完整驗證提示 diff --git a/docs/OPENAPI.md b/docs/OPENAPI.md index 36f1429..bc55f81 100644 --- a/docs/OPENAPI.md +++ b/docs/OPENAPI.md @@ -72,6 +72,12 @@ - `usage=send_api`: - 供租戶站台呼叫 Send Engine 發信流程 - 內建 scope:`newsletter:send.write`、`newsletter:send.read` +- `usage=web_login`: + - 供外部網站使用 Member Center 統一登入 UI + - 使用 Authorization Code + PKCE + - 需設定 `redirect_uris` + - 可使用 `client_type=public` + - 允許 scope:`openid`、`email`、`profile`、`profile:basic.read` - `usage=webhook_outbound`: - 供 Member Center 內部標記「對外 webhook 用」的租戶憑證用途 - 不可用於租戶 API 呼叫 @@ -81,7 +87,7 @@ - 供平台級 S2S(例如 SES 聚合事件回寫) - 可不綁定 `tenant_id`,scope 使用 `newsletter:events.write.global` - `tenant_api` / `send_api` / `platform_service` 建議(且實作要求)`client_type=confidential` -- `redirect_uris` 僅 `webhook_outbound` 需要;其他 usage 可為空 +- `redirect_uris` 僅 `web_login` / `webhook_outbound` 需要;其他 usage 可為空 - 管理規則: - 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`) - 平台級流程另建 `platform_service` 憑證 diff --git a/docs/UI.md b/docs/UI.md index 2348134..bd54fd3 100644 --- a/docs/UI.md +++ b/docs/UI.md @@ -57,8 +57,8 @@ ### 管理者端(統一 UI) - UC-11 租戶管理: `/admin/tenants` - UC-11.1 Tenant 可設定 `Send Engine Webhook Client Id`(UUID) -- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret,可旋轉;可選 `usage=tenant_api` / `send_api` / `webhook_outbound` / `platform_service` / `file_api`;`platform_service` 可不指定 tenant) - - `redirect_uris` 僅 `webhook_outbound` 需要;其餘 usage 不需要 +- 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` - UC-13 電子報清單管理: `/admin/newsletter-lists` - UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export` diff --git a/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs b/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs index d1f21d2..b331b08 100644 --- a/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs +++ b/src/MemberCenter.Api/Controllers/AdminOAuthClientsController.cs @@ -43,7 +43,7 @@ public class AdminOAuthClientsController : ControllerBase { if (!IsValidUsage(request.Usage)) { - return BadRequest("usage must be tenant_api, send_api, webhook_outbound, or platform_service."); + return BadRequest("usage must be tenant_api, send_api, web_login, webhook_outbound, or platform_service."); } if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty)) @@ -64,7 +64,7 @@ public class AdminOAuthClientsController : ControllerBase } if (UsesAuthorizationCodeFlow(request.Usage) && redirectUris.Count == 0) { - return BadRequest("redirect_uris is required for webhook_outbound usage."); + return BadRequest("redirect_uris is required for web_login or webhook_outbound usage."); } var descriptor = new OpenIddictApplicationDescriptor @@ -122,7 +122,7 @@ public class AdminOAuthClientsController : ControllerBase { if (!IsValidUsage(request.Usage)) { - return BadRequest("usage must be tenant_api, send_api, webhook_outbound, or platform_service."); + return BadRequest("usage must be tenant_api, send_api, web_login, webhook_outbound, or platform_service."); } if (!IsTenantOptionalUsage(request.Usage) && (!request.TenantId.HasValue || request.TenantId.Value == Guid.Empty)) @@ -143,7 +143,7 @@ public class AdminOAuthClientsController : ControllerBase } if (UsesAuthorizationCodeFlow(request.Usage) && redirectUris.Count == 0) { - return BadRequest("redirect_uris is required for webhook_outbound usage."); + return BadRequest("redirect_uris is required for web_login or webhook_outbound usage."); } var app = await _applicationManager.FindByIdAsync(id); @@ -201,13 +201,15 @@ public class AdminOAuthClientsController : ControllerBase { return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase) || string.Equals(usage, "send_api", StringComparison.OrdinalIgnoreCase) + || string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase) || string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase) || string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase); } private static bool IsTenantOptionalUsage(string usage) { - return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase); + return string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase) + || string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase); } private static bool RequiresClientCredentials(string usage) @@ -219,7 +221,8 @@ public class AdminOAuthClientsController : ControllerBase private static bool UsesAuthorizationCodeFlow(string usage) { - return string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase); + return string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase) + || string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase); } private static (List Uris, string? Error) NormalizeRedirectUris(List? redirectUris) @@ -281,6 +284,10 @@ public class AdminOAuthClientsController : ControllerBase descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Email); descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Profile); descriptor.Permissions.Add("scp:openid"); + if (string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase)) + { + descriptor.Permissions.Add("scp:profile:basic.read"); + } } private static void AddProfileScopePermissions(OpenIddictApplicationDescriptor descriptor) diff --git a/src/MemberCenter.Api/Controllers/OAuthController.cs b/src/MemberCenter.Api/Controllers/OAuthController.cs index a182a2d..7a85c27 100644 --- a/src/MemberCenter.Api/Controllers/OAuthController.cs +++ b/src/MemberCenter.Api/Controllers/OAuthController.cs @@ -1,28 +1,35 @@ using MemberCenter.Api.Extensions; using MemberCenter.Infrastructure.Identity; -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using OpenIddict.Abstractions; using OpenIddict.Server.AspNetCore; -using System.Security.Claims; +using System.Web; namespace MemberCenter.Api.Controllers; [ApiController] 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; - public OAuthController(UserManager userManager, SignInManager signInManager) + public OAuthController( + IConfiguration configuration, + UserManager userManager, + SignInManager signInManager) { + _memberCenterAudience = configuration["Auth:MemberCenterAudience"] ?? "member_center_api"; + _webLoginUrl = configuration["Auth:WebLoginUrl"] ?? "/account/login"; _userManager = userManager; _signInManager = signInManager; } [HttpGet("/oauth/authorize")] - [Authorize] public async Task Authorize() { var request = HttpContext.Features.Get()?.Transaction?.Request; @@ -31,14 +38,30 @@ public class OAuthController : ControllerBase return BadRequest("Invalid OpenIddict request."); } - var user = await _userManager.GetUserAsync(User); + var cookie = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); + if (!cookie.Succeeded || cookie.Principal is null) + { + return Redirect(BuildLoginRedirectUrl()); + } + + var user = await _userManager.GetUserAsync(cookie.Principal); if (user is null) { return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } + if (user.DisabledAt.HasValue) + { + return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + var principal = await _signInManager.CreateUserPrincipalAsync(user); + if (!string.IsNullOrWhiteSpace(user.SecurityStamp)) + { + principal.SetClaim(SecurityStampClaimType, user.SecurityStamp); + } principal.SetScopes(request.GetScopes()); + principal.SetResources(_memberCenterAudience); foreach (var claim in principal.Claims) { claim.SetDestinations(ClaimsExtensions.GetDestinations(claim)); @@ -46,4 +69,11 @@ public class OAuthController : ControllerBase return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } + + private string BuildLoginRedirectUrl() + { + var returnUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}{Request.Path}{Request.QueryString}"; + var separator = _webLoginUrl.Contains('?', StringComparison.Ordinal) ? '&' : '?'; + return $"{_webLoginUrl}{separator}returnUrl={HttpUtility.UrlEncode(returnUrl)}"; + } } diff --git a/src/MemberCenter.Api/Controllers/TokenController.cs b/src/MemberCenter.Api/Controllers/TokenController.cs index 79631b2..1900f4a 100644 --- a/src/MemberCenter.Api/Controllers/TokenController.cs +++ b/src/MemberCenter.Api/Controllers/TokenController.cs @@ -79,7 +79,7 @@ public class TokenController : ControllerBase return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } - if (request.IsRefreshTokenGrantType()) + if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType()) { var authenticateResult = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); if (!authenticateResult.Succeeded || authenticateResult.Principal is null) @@ -88,6 +88,11 @@ public class TokenController : ControllerBase } var principal = authenticateResult.Principal; + if (!await ValidateUserPrincipalAsync(principal)) + { + return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } @@ -151,6 +156,26 @@ public class TokenController : ControllerBase return BadRequest("Unsupported grant type."); } + private async Task ValidateUserPrincipalAsync(ClaimsPrincipal principal) + { + var subject = principal.GetClaim(OpenIddictConstants.Claims.Subject) + ?? principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (!Guid.TryParse(subject, out var userId)) + { + return true; + } + + var user = await _userManager.FindByIdAsync(userId.ToString()); + if (user is null || user.DisabledAt.HasValue) + { + return false; + } + + var tokenSecurityStamp = principal.FindFirst(SecurityStampClaimType)?.Value; + return string.IsNullOrWhiteSpace(tokenSecurityStamp) + || string.Equals(tokenSecurityStamp, user.SecurityStamp, StringComparison.Ordinal); + } + private IEnumerable ResolveResources(IEnumerable scopes) { var scopeSet = scopes as ISet ?? new HashSet(scopes, StringComparer.Ordinal); diff --git a/src/MemberCenter.Api/Program.cs b/src/MemberCenter.Api/Program.cs index 69df521..8d08277 100644 --- a/src/MemberCenter.Api/Program.cs +++ b/src/MemberCenter.Api/Program.cs @@ -6,6 +6,7 @@ using MemberCenter.Infrastructure.Persistence; using MemberCenter.Infrastructure.Services; using MemberCenter.Application.Abstractions; using MemberCenter.Application.Constants; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; @@ -23,6 +24,9 @@ var issuer = builder.Configuration["Auth:Issuer"]; var issuerUri = ParseAbsoluteUriOrThrow(issuer, "Auth:Issuer"); var allowInsecureHttp = builder.Configuration.GetValue("Auth:AllowInsecureHttp", false); +builder.Services.AddDataProtection() + .SetApplicationName("MemberCenter"); + builder.Services.AddDbContext(options => { var connectionString = builder.Configuration.GetConnectionString("Default") diff --git a/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs b/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs index decf46a..dae0f26 100644 --- a/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs +++ b/src/MemberCenter.Web/Areas/Admin/Controllers/OAuthClientsController.cs @@ -62,7 +62,7 @@ public class OAuthClientsController : Controller { if (!IsValidUsage(model.Usage)) { - ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, send_api, webhook_outbound, or platform_service."); + ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, send_api, web_login, webhook_outbound, or platform_service."); } if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty)) @@ -84,7 +84,7 @@ public class OAuthClientsController : Controller if (UsesAuthorizationCodeFlow(model.Usage) && redirectUris.Count == 0) { - ModelState.AddModelError(nameof(model.RedirectUris), "Redirect URI is required for webhook_outbound usage."); + ModelState.AddModelError(nameof(model.RedirectUris), "Redirect URI is required for web_login or webhook_outbound usage."); } if (!ModelState.IsValid) @@ -159,7 +159,7 @@ public class OAuthClientsController : Controller { if (!IsValidUsage(model.Usage)) { - ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, send_api, webhook_outbound, or platform_service."); + ModelState.AddModelError(nameof(model.Usage), "Usage must be tenant_api, send_api, web_login, webhook_outbound, or platform_service."); } if (!IsTenantOptionalUsage(model.Usage) && (!model.TenantId.HasValue || model.TenantId.Value == Guid.Empty)) @@ -181,7 +181,7 @@ public class OAuthClientsController : Controller if (UsesAuthorizationCodeFlow(model.Usage) && redirectUris.Count == 0) { - ModelState.AddModelError(nameof(model.RedirectUris), "Redirect URI is required for webhook_outbound usage."); + ModelState.AddModelError(nameof(model.RedirectUris), "Redirect URI is required for web_login or webhook_outbound usage."); } if (!ModelState.IsValid) @@ -266,13 +266,13 @@ public class OAuthClientsController : Controller } private static bool IsValidUsage(string usage) => - usage is "tenant_api" or "send_api" or "webhook_outbound" or "platform_service"; + usage is "tenant_api" or "send_api" or "web_login" or "webhook_outbound" or "platform_service"; private static bool IsTenantOptionalUsage(string usage) => - usage == "platform_service"; + usage is "platform_service" or "web_login"; private static bool UsesAuthorizationCodeFlow(string usage) => - usage == "webhook_outbound"; + usage is "web_login" or "webhook_outbound"; private static bool RequiresClientCredentials(string usage) => usage is "tenant_api" or "send_api" or "platform_service"; @@ -315,14 +315,23 @@ public class OAuthClientsController : Controller descriptor.Permissions.Clear(); descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token); - if (usage == "webhook_outbound") + if (usage is "web_login" or "webhook_outbound") { descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Authorization); descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode); + descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.RefreshToken); descriptor.Permissions.Add(OpenIddictConstants.Permissions.ResponseTypes.Code); descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + OpenIddictConstants.Scopes.OpenId); descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + OpenIddictConstants.Scopes.Email); - descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "newsletter:events.write"); + descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + OpenIddictConstants.Scopes.Profile); + if (usage == "web_login") + { + descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "profile:basic.read"); + } + else + { + descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "newsletter:events.write"); + } } else { diff --git a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml index 52d7897..183673a 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Create.cshtml @@ -27,12 +27,13 @@ - + @@ -48,7 +49,7 @@ function syncRedirectInputState() { const usageValue = usage.value; - const needsRedirect = usageValue === "webhook_outbound"; + const needsRedirect = usageValue === "web_login" || usageValue === "webhook_outbound"; const requiresConfidential = usageValue === "tenant_api" || usageValue === "send_api" || usageValue === "platform_service"; diff --git a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Edit.cshtml b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Edit.cshtml index c7db0d2..468b168 100644 --- a/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Edit.cshtml +++ b/src/MemberCenter.Web/Areas/Admin/Views/OAuthClients/Edit.cshtml @@ -27,12 +27,13 @@ - + @@ -48,7 +49,7 @@ function syncRedirectInputState() { const usageValue = usage.value; - const needsRedirect = usageValue === "webhook_outbound"; + const needsRedirect = usageValue === "web_login" || usageValue === "webhook_outbound"; const requiresConfidential = usageValue === "tenant_api" || usageValue === "send_api" || usageValue === "platform_service"; diff --git a/src/MemberCenter.Web/Controllers/AccountController.cs b/src/MemberCenter.Web/Controllers/AccountController.cs index 0c5470c..59dad50 100644 --- a/src/MemberCenter.Web/Controllers/AccountController.cs +++ b/src/MemberCenter.Web/Controllers/AccountController.cs @@ -15,6 +15,7 @@ public class AccountController : Controller private readonly IAccountProvisioningService _accountProvisioningService; private readonly IAccountEmailService _accountEmailService; private readonly IAuditLogWriter _auditLogWriter; + private readonly IConfiguration _configuration; private readonly UserManager _userManager; private readonly SignInManager _signInManager; @@ -22,12 +23,14 @@ public class AccountController : Controller IAccountProvisioningService accountProvisioningService, IAccountEmailService accountEmailService, IAuditLogWriter auditLogWriter, + IConfiguration configuration, UserManager userManager, SignInManager signInManager) { _accountProvisioningService = accountProvisioningService; _accountEmailService = accountEmailService; _auditLogWriter = auditLogWriter; + _configuration = configuration; _userManager = userManager; _signInManager = signInManager; } @@ -72,9 +75,9 @@ public class AccountController : Controller await UpdateSignInMetadataAsync(loginUser); } - if (!string.IsNullOrWhiteSpace(model.ReturnUrl)) + if (IsAllowedReturnUrl(model.ReturnUrl)) { - return LocalRedirect(model.ReturnUrl); + return Redirect(model.ReturnUrl!); } return RedirectToAction("Index", "Home"); @@ -139,9 +142,9 @@ public class AccountController : Controller await _signInManager.SignInAsync(user, false, info.LoginProvider); await UpdateSignInMetadataAsync(user); - if (!string.IsNullOrWhiteSpace(returnUrl)) + if (IsAllowedReturnUrl(returnUrl)) { - return LocalRedirect(returnUrl); + return Redirect(returnUrl!); } return RedirectToAction("Index", "Home"); @@ -346,4 +349,31 @@ public class AccountController : Controller user.LastSeenAt = user.LastLoginAt; await _userManager.UpdateAsync(user); } + + private bool IsAllowedReturnUrl(string? returnUrl) + { + if (string.IsNullOrWhiteSpace(returnUrl)) + { + return false; + } + + if (Url.IsLocalUrl(returnUrl)) + { + return true; + } + + if (!Uri.TryCreate(returnUrl, UriKind.Absolute, out var parsed)) + { + return false; + } + + var allowedPrefixes = new List(); + allowedPrefixes.Add(_configuration["Auth:Issuer"]); + allowedPrefixes.AddRange((_configuration["Auth:AllowedLoginReturnUrlPrefixes"] ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + + return allowedPrefixes + .Where(prefix => !string.IsNullOrWhiteSpace(prefix)) + .Any(prefix => returnUrl.StartsWith(prefix!, StringComparison.OrdinalIgnoreCase)); + } } diff --git a/src/MemberCenter.Web/Program.cs b/src/MemberCenter.Web/Program.cs index 871e951..12b5f37 100644 --- a/src/MemberCenter.Web/Program.cs +++ b/src/MemberCenter.Web/Program.cs @@ -2,6 +2,7 @@ using System.Security.Claims; using System.Threading.RateLimiting; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.RateLimiting; using MemberCenter.Application.Abstractions; @@ -17,6 +18,9 @@ EnvLoader.LoadDotEnvIfDevelopment(); var builder = WebApplication.CreateBuilder(args); +builder.Services.AddDataProtection() + .SetApplicationName("MemberCenter"); + builder.Services.AddDbContext(options => { var connectionString = builder.Configuration.GetConnectionString("Default")