Add web login OAuth redirect flow
This commit is contained in:
parent
c9c0396ad2
commit
8585190123
@ -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 不同 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)
|
### 6.2 OAuth2 API 使用(站點自行 UI)
|
||||||
1) 站點以 API 驗證使用者登入(會員中心提供 login API)
|
1) 站點以 API 驗證使用者登入(會員中心提供 login API)
|
||||||
|
|||||||
@ -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 Engine(Client Credentials + JWT 驗簽)
|
## F-02b 內容站台呼叫 Send Engine(Client Credentials + JWT 驗簽)
|
||||||
- [API] 內容站台以 `client_credentials` 呼叫 `POST /oauth/token` 取得 access_token(`usage=send_api`)
|
- [API] 內容站台以 `client_credentials` 呼叫 `POST /oauth/token` 取得 access_token(`usage=send_api`)
|
||||||
|
|||||||
@ -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 的畫面目前為最小可用版本,尚未優化樣式與完整驗證提示
|
||||||
|
|||||||
@ -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` 憑證
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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,15 +315,24 @@ 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 + 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");
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.Scope + "newsletter:events.write");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
|
descriptor.Permissions.Add(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials);
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user