Add web login OAuth redirect flow

This commit is contained in:
Warren Chen 2026-04-23 00:15:53 +09:00
parent c9c0396ad2
commit 8585190123
14 changed files with 175 additions and 38 deletions

View File

@ -94,10 +94,20 @@
## 6. 核心流程 ## 6. 核心流程
### 6.1 OAuth2/OIDC Redirect 登入Authorization Code + PKCE ### 6.1 OAuth2/OIDC Redirect 登入Authorization Code + PKCE
1) 站點導向 `/oauth/authorize`,帶 `client_id`, `redirect_uri`, `code_challenge`, `scope=openid email` 狀態:已支援 `usage=web_login`
2) 使用者於會員中心登入
3) 成功後導回 `redirect_uri` 並附 `code` 1) 站點建立 OAuth client`usage=web_login`,設定 `redirect_uris`
4) 站點以 `code` + `code_verifier``/oauth/token` 換取 token + `id_token` 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 不同 originWeb 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 ### 6.2 OAuth2 API 使用(站點自行 UI
1) 站點以 API 驗證使用者登入(會員中心提供 login API 1) 站點以 API 驗證使用者登入(會員中心提供 login API

View File

@ -11,8 +11,11 @@
## F-02 登入OAuth2 + OIDC ## F-02 登入OAuth2 + OIDC
- [API] 站點送出 `POST /auth/login` 取得 access_token + id_token - [API] 站點送出 `POST /auth/login` 取得 access_token + id_token
- [API] 站點建立自身 session - [API] 站點建立自身 session
- [UI] 導向 `/oauth/authorize` 完成授權碼流程 - [UI] 使用 `usage=web_login` OAuth client導向 `/oauth/authorize` 完成 Authorization Code + PKCE
- [UI] 站點用 code 換 token + id_token - [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 EngineClient Credentials + JWT 驗簽) ## F-02b 內容站台呼叫 Send EngineClient Credentials + JWT 驗簽)
- [API] 內容站台以 `client_credentials` 呼叫 `POST /oauth/token` 取得 access_token`usage=send_api` - [API] 內容站台以 `client_credentials` 呼叫 `POST /oauth/token` 取得 access_token`usage=send_api`

View File

@ -111,6 +111,13 @@
- Web login / external login / password grant login 成功後更新 `last_login_at` / `last_seen_at` - Web login / external login / password grant login 成功後更新 `last_login_at` / `last_seen_at`
- disabled 帳號無法透過 Web login、external login、password grant 取得登入 - disabled 帳號無法透過 Web login、external login、password grant 取得登入
- Web cookie 與 API authenticated request 會檢查 disabled 狀態 - 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 的畫面目前為最小可用版本,尚未優化樣式與完整驗證提示 - profile / addresses / subscriptions 的畫面目前為最小可用版本,尚未優化樣式與完整驗證提示

View File

@ -72,6 +72,12 @@
- `usage=send_api` - `usage=send_api`
- 供租戶站台呼叫 Send Engine 發信流程 - 供租戶站台呼叫 Send Engine 發信流程
- 內建 scope`newsletter:send.write``newsletter:send.read` - 內建 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` - `usage=webhook_outbound`
- 供 Member Center 內部標記「對外 webhook 用」的租戶憑證用途 - 供 Member Center 內部標記「對外 webhook 用」的租戶憑證用途
- 不可用於租戶 API 呼叫 - 不可用於租戶 API 呼叫
@ -81,7 +87,7 @@
- 供平台級 S2S例如 SES 聚合事件回寫) - 供平台級 S2S例如 SES 聚合事件回寫)
- 可不綁定 `tenant_id`scope 使用 `newsletter:events.write.global` - 可不綁定 `tenant_id`scope 使用 `newsletter:events.write.global`
- `tenant_api` / `send_api` / `platform_service` 建議(且實作要求)`client_type=confidential` - `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` - 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`
- 平台級流程另建 `platform_service` 憑證 - 平台級流程另建 `platform_service` 憑證

View File

@ -57,8 +57,8 @@
### 管理者端(統一 UI ### 管理者端(統一 UI
- UC-11 租戶管理: `/admin/tenants` - UC-11 租戶管理: `/admin/tenants`
- UC-11.1 Tenant 可設定 `Send Engine Webhook Client Id`UUID - 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 - 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``webhook_outbound` 需要;其餘 usage 不需要 - `redirect_uris``web_login` / `webhook_outbound` 需要;其餘 usage 不需要
- `tenant_api` / `send_api` / `platform_service` / `file_api` 強制 `client_type=confidential` - `tenant_api` / `send_api` / `platform_service` / `file_api` 強制 `client_type=confidential`
- UC-13 電子報清單管理: `/admin/newsletter-lists` - UC-13 電子報清單管理: `/admin/newsletter-lists`
- UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export` - UC-14 訂閱查詢 / 匯出: `/admin/subscriptions`, `/admin/subscriptions/export`

View File

@ -43,7 +43,7 @@ public class AdminOAuthClientsController : ControllerBase
{ {
if (!IsValidUsage(request.Usage)) 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)) 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) 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 var descriptor = new OpenIddictApplicationDescriptor
@ -122,7 +122,7 @@ public class AdminOAuthClientsController : ControllerBase
{ {
if (!IsValidUsage(request.Usage)) 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)) 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) 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); var app = await _applicationManager.FindByIdAsync(id);
@ -201,13 +201,15 @@ public class AdminOAuthClientsController : ControllerBase
{ {
return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase) return string.Equals(usage, "tenant_api", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "send_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, "webhook_outbound", StringComparison.OrdinalIgnoreCase)
|| string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase); || string.Equals(usage, "platform_service", StringComparison.OrdinalIgnoreCase);
} }
private static bool IsTenantOptionalUsage(string usage) 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) private static bool RequiresClientCredentials(string usage)
@ -219,7 +221,8 @@ public class AdminOAuthClientsController : ControllerBase
private static bool UsesAuthorizationCodeFlow(string usage) 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<string> Uris, string? Error) NormalizeRedirectUris(List<string>? redirectUris) private static (List<string> Uris, string? Error) NormalizeRedirectUris(List<string>? redirectUris)
@ -281,6 +284,10 @@ public class AdminOAuthClientsController : ControllerBase
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Email); descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Email);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Profile); descriptor.Permissions.Add(OpenIddictConstants.Permissions.Scopes.Profile);
descriptor.Permissions.Add("scp:openid"); 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) private static void AddProfileScopePermissions(OpenIddictApplicationDescriptor descriptor)

View File

@ -1,28 +1,35 @@
using MemberCenter.Api.Extensions; using MemberCenter.Api.Extensions;
using MemberCenter.Infrastructure.Identity; using MemberCenter.Infrastructure.Identity;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions; using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore; using OpenIddict.Server.AspNetCore;
using System.Security.Claims; using System.Web;
namespace MemberCenter.Api.Controllers; namespace MemberCenter.Api.Controllers;
[ApiController] [ApiController]
public class OAuthController : ControllerBase public class OAuthController : ControllerBase
{ {
private const string SecurityStampClaimType = "AspNet.Identity.SecurityStamp";
private readonly string _memberCenterAudience;
private readonly string _webLoginUrl;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager; private readonly SignInManager<ApplicationUser> _signInManager;
public OAuthController(UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager) public OAuthController(
IConfiguration configuration,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager)
{ {
_memberCenterAudience = configuration["Auth:MemberCenterAudience"] ?? "member_center_api";
_webLoginUrl = configuration["Auth:WebLoginUrl"] ?? "/account/login";
_userManager = userManager; _userManager = userManager;
_signInManager = signInManager; _signInManager = signInManager;
} }
[HttpGet("/oauth/authorize")] [HttpGet("/oauth/authorize")]
[Authorize]
public async Task<IActionResult> Authorize() public async Task<IActionResult> Authorize()
{ {
var request = HttpContext.Features.Get<OpenIddictServerAspNetCoreFeature>()?.Transaction?.Request; var request = HttpContext.Features.Get<OpenIddictServerAspNetCoreFeature>()?.Transaction?.Request;
@ -31,14 +38,30 @@ public class OAuthController : ControllerBase
return BadRequest("Invalid OpenIddict request."); 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) if (user is null)
{ {
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
} }
if (user.DisabledAt.HasValue)
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
var principal = await _signInManager.CreateUserPrincipalAsync(user); var principal = await _signInManager.CreateUserPrincipalAsync(user);
if (!string.IsNullOrWhiteSpace(user.SecurityStamp))
{
principal.SetClaim(SecurityStampClaimType, user.SecurityStamp);
}
principal.SetScopes(request.GetScopes()); principal.SetScopes(request.GetScopes());
principal.SetResources(_memberCenterAudience);
foreach (var claim in principal.Claims) foreach (var claim in principal.Claims)
{ {
claim.SetDestinations(ClaimsExtensions.GetDestinations(claim)); claim.SetDestinations(ClaimsExtensions.GetDestinations(claim));
@ -46,4 +69,11 @@ public class OAuthController : ControllerBase
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); 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)}";
}
} }

View File

@ -79,7 +79,7 @@ public class TokenController : ControllerBase
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
} }
if (request.IsRefreshTokenGrantType()) if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType())
{ {
var authenticateResult = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); var authenticateResult = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
if (!authenticateResult.Succeeded || authenticateResult.Principal is null) if (!authenticateResult.Succeeded || authenticateResult.Principal is null)
@ -88,6 +88,11 @@ public class TokenController : ControllerBase
} }
var principal = authenticateResult.Principal; var principal = authenticateResult.Principal;
if (!await ValidateUserPrincipalAsync(principal))
{
return Forbid(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
} }
@ -151,6 +156,26 @@ public class TokenController : ControllerBase
return BadRequest("Unsupported grant type."); return BadRequest("Unsupported grant type.");
} }
private async Task<bool> 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<string> ResolveResources(IEnumerable<string> scopes) private IEnumerable<string> ResolveResources(IEnumerable<string> scopes)
{ {
var scopeSet = scopes as ISet<string> ?? new HashSet<string>(scopes, StringComparer.Ordinal); var scopeSet = scopes as ISet<string> ?? new HashSet<string>(scopes, StringComparer.Ordinal);

View File

@ -6,6 +6,7 @@ using MemberCenter.Infrastructure.Persistence;
using MemberCenter.Infrastructure.Services; using MemberCenter.Infrastructure.Services;
using MemberCenter.Application.Abstractions; using MemberCenter.Application.Abstractions;
using MemberCenter.Application.Constants; using MemberCenter.Application.Constants;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@ -23,6 +24,9 @@ var issuer = builder.Configuration["Auth:Issuer"];
var issuerUri = ParseAbsoluteUriOrThrow(issuer, "Auth:Issuer"); var issuerUri = ParseAbsoluteUriOrThrow(issuer, "Auth:Issuer");
var allowInsecureHttp = builder.Configuration.GetValue("Auth:AllowInsecureHttp", false); var allowInsecureHttp = builder.Configuration.GetValue("Auth:AllowInsecureHttp", false);
builder.Services.AddDataProtection()
.SetApplicationName("MemberCenter");
builder.Services.AddDbContext<MemberCenterDbContext>(options => builder.Services.AddDbContext<MemberCenterDbContext>(options =>
{ {
var connectionString = builder.Configuration.GetConnectionString("Default") var connectionString = builder.Configuration.GetConnectionString("Default")

View File

@ -62,7 +62,7 @@ public class OAuthClientsController : Controller
{ {
if (!IsValidUsage(model.Usage)) 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)) 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) 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) if (!ModelState.IsValid)
@ -159,7 +159,7 @@ public class OAuthClientsController : Controller
{ {
if (!IsValidUsage(model.Usage)) 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)) 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) 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) if (!ModelState.IsValid)
@ -266,13 +266,13 @@ public class OAuthClientsController : Controller
} }
private static bool IsValidUsage(string usage) => 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) => private static bool IsTenantOptionalUsage(string usage) =>
usage == "platform_service"; usage is "platform_service" or "web_login";
private static bool UsesAuthorizationCodeFlow(string usage) => private static bool UsesAuthorizationCodeFlow(string usage) =>
usage == "webhook_outbound"; usage is "web_login" or "webhook_outbound";
private static bool RequiresClientCredentials(string usage) => private static bool RequiresClientCredentials(string usage) =>
usage is "tenant_api" or "send_api" or "platform_service"; usage is "tenant_api" or "send_api" or "platform_service";
@ -315,14 +315,23 @@ public class OAuthClientsController : Controller
descriptor.Permissions.Clear(); descriptor.Permissions.Clear();
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Endpoints.Token); 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.Endpoints.Authorization);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode); 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.ResponseTypes.Code);
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + OpenIddictConstants.Scopes.OpenId); 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 + 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 else
{ {

View File

@ -27,12 +27,13 @@
<select asp-for="Usage"> <select asp-for="Usage">
<option value="tenant_api">tenant_api</option> <option value="tenant_api">tenant_api</option>
<option value="send_api">send_api</option> <option value="send_api">send_api</option>
<option value="web_login">web_login</option>
<option value="webhook_outbound">webhook_outbound</option> <option value="webhook_outbound">webhook_outbound</option>
<option value="platform_service">platform_service</option> <option value="platform_service">platform_service</option>
</select> </select>
<span asp-validation-for="Usage"></span> <span asp-validation-for="Usage"></span>
<label>Redirect URIs (comma-separated, only for webhook_outbound)</label> <label>Redirect URIs (comma-separated, required for web_login / webhook_outbound)</label>
<input asp-for="RedirectUris" /> <input asp-for="RedirectUris" />
<span asp-validation-for="RedirectUris"></span> <span asp-validation-for="RedirectUris"></span>
@ -48,7 +49,7 @@
function syncRedirectInputState() { function syncRedirectInputState() {
const usageValue = usage.value; const usageValue = usage.value;
const needsRedirect = usageValue === "webhook_outbound"; const needsRedirect = usageValue === "web_login" || usageValue === "webhook_outbound";
const requiresConfidential = usageValue === "tenant_api" const requiresConfidential = usageValue === "tenant_api"
|| usageValue === "send_api" || usageValue === "send_api"
|| usageValue === "platform_service"; || usageValue === "platform_service";

View File

@ -27,12 +27,13 @@
<select asp-for="Usage"> <select asp-for="Usage">
<option value="tenant_api">tenant_api</option> <option value="tenant_api">tenant_api</option>
<option value="send_api">send_api</option> <option value="send_api">send_api</option>
<option value="web_login">web_login</option>
<option value="webhook_outbound">webhook_outbound</option> <option value="webhook_outbound">webhook_outbound</option>
<option value="platform_service">platform_service</option> <option value="platform_service">platform_service</option>
</select> </select>
<span asp-validation-for="Usage"></span> <span asp-validation-for="Usage"></span>
<label>Redirect URIs (comma-separated, only for webhook_outbound)</label> <label>Redirect URIs (comma-separated, required for web_login / webhook_outbound)</label>
<input asp-for="RedirectUris" /> <input asp-for="RedirectUris" />
<span asp-validation-for="RedirectUris"></span> <span asp-validation-for="RedirectUris"></span>
@ -48,7 +49,7 @@
function syncRedirectInputState() { function syncRedirectInputState() {
const usageValue = usage.value; const usageValue = usage.value;
const needsRedirect = usageValue === "webhook_outbound"; const needsRedirect = usageValue === "web_login" || usageValue === "webhook_outbound";
const requiresConfidential = usageValue === "tenant_api" const requiresConfidential = usageValue === "tenant_api"
|| usageValue === "send_api" || usageValue === "send_api"
|| usageValue === "platform_service"; || usageValue === "platform_service";

View File

@ -15,6 +15,7 @@ public class AccountController : Controller
private readonly IAccountProvisioningService _accountProvisioningService; private readonly IAccountProvisioningService _accountProvisioningService;
private readonly IAccountEmailService _accountEmailService; private readonly IAccountEmailService _accountEmailService;
private readonly IAuditLogWriter _auditLogWriter; private readonly IAuditLogWriter _auditLogWriter;
private readonly IConfiguration _configuration;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager; private readonly SignInManager<ApplicationUser> _signInManager;
@ -22,12 +23,14 @@ public class AccountController : Controller
IAccountProvisioningService accountProvisioningService, IAccountProvisioningService accountProvisioningService,
IAccountEmailService accountEmailService, IAccountEmailService accountEmailService,
IAuditLogWriter auditLogWriter, IAuditLogWriter auditLogWriter,
IConfiguration configuration,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager) SignInManager<ApplicationUser> signInManager)
{ {
_accountProvisioningService = accountProvisioningService; _accountProvisioningService = accountProvisioningService;
_accountEmailService = accountEmailService; _accountEmailService = accountEmailService;
_auditLogWriter = auditLogWriter; _auditLogWriter = auditLogWriter;
_configuration = configuration;
_userManager = userManager; _userManager = userManager;
_signInManager = signInManager; _signInManager = signInManager;
} }
@ -72,9 +75,9 @@ public class AccountController : Controller
await UpdateSignInMetadataAsync(loginUser); await UpdateSignInMetadataAsync(loginUser);
} }
if (!string.IsNullOrWhiteSpace(model.ReturnUrl)) if (IsAllowedReturnUrl(model.ReturnUrl))
{ {
return LocalRedirect(model.ReturnUrl); return Redirect(model.ReturnUrl!);
} }
return RedirectToAction("Index", "Home"); return RedirectToAction("Index", "Home");
@ -139,9 +142,9 @@ public class AccountController : Controller
await _signInManager.SignInAsync(user, false, info.LoginProvider); await _signInManager.SignInAsync(user, false, info.LoginProvider);
await UpdateSignInMetadataAsync(user); await UpdateSignInMetadataAsync(user);
if (!string.IsNullOrWhiteSpace(returnUrl)) if (IsAllowedReturnUrl(returnUrl))
{ {
return LocalRedirect(returnUrl); return Redirect(returnUrl!);
} }
return RedirectToAction("Index", "Home"); return RedirectToAction("Index", "Home");
@ -346,4 +349,31 @@ public class AccountController : Controller
user.LastSeenAt = user.LastLoginAt; user.LastSeenAt = user.LastLoginAt;
await _userManager.UpdateAsync(user); 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<string?>();
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));
}
} }

View File

@ -2,6 +2,7 @@ using System.Security.Claims;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using MemberCenter.Application.Abstractions; using MemberCenter.Application.Abstractions;
@ -17,6 +18,9 @@ EnvLoader.LoadDotEnvIfDevelopment();
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDataProtection()
.SetApplicationName("MemberCenter");
builder.Services.AddDbContext<MemberCenterDbContext>(options => builder.Services.AddDbContext<MemberCenterDbContext>(options =>
{ {
var connectionString = builder.Configuration.GetConnectionString("Default") var connectionString = builder.Configuration.GetConnectionString("Default")