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.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 不同 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
1) 站點以 API 驗證使用者登入(會員中心提供 login API

View File

@ -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 EngineClient Credentials + JWT 驗簽)
- [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`
- 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 的畫面目前為最小可用版本,尚未優化樣式與完整驗證提示

View File

@ -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` 憑證

View File

@ -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`

View File

@ -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<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.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)

View File

@ -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<ApplicationUser> _userManager;
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;
_signInManager = signInManager;
}
[HttpGet("/oauth/authorize")]
[Authorize]
public async Task<IActionResult> Authorize()
{
var request = HttpContext.Features.Get<OpenIddictServerAspNetCoreFeature>()?.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)}";
}
}

View File

@ -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<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)
{
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.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<MemberCenterDbContext>(options =>
{
var connectionString = builder.Configuration.GetConnectionString("Default")

View File

@ -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
{

View File

@ -27,12 +27,13 @@
<select asp-for="Usage">
<option value="tenant_api">tenant_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="platform_service">platform_service</option>
</select>
<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" />
<span asp-validation-for="RedirectUris"></span>
@ -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";

View File

@ -27,12 +27,13 @@
<select asp-for="Usage">
<option value="tenant_api">tenant_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="platform_service">platform_service</option>
</select>
<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" />
<span asp-validation-for="RedirectUris"></span>
@ -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";

View File

@ -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<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
@ -22,12 +23,14 @@ public class AccountController : Controller
IAccountProvisioningService accountProvisioningService,
IAccountEmailService accountEmailService,
IAuditLogWriter auditLogWriter,
IConfiguration configuration,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> 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<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 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<MemberCenterDbContext>(options =>
{
var connectionString = builder.Configuration.GetConnectionString("Default")