Compare commits

..

No commits in common. "master" and "develop" have entirely different histories.

22 changed files with 86 additions and 263 deletions

View File

@ -3,6 +3,7 @@ ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgr
Auth__Issuer=http://localhost:7850/
Auth__WebLoginUrl=http://localhost:5080/account/login
Auth__AllowedLoginReturnUrlPrefixes=http://localhost:7850/
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
Auth__AllowInternalHttpTokenEndpoint=false
# Optional PFX file overrides. Production normally reads app-managed certificates provisioned by installer into DB.
Auth__Certificates__Signing__Path=

View File

@ -38,7 +38,7 @@ API、Web 與 Installer 共用上述規則。
| `RateLimits:Web:AuthRegister` | 5 | 900 | Web register |
| `RateLimits:Web:AuthRecovery` | 5 | 900 | Web forgot/resend |
| `RateLimits:Web:AuthTokenConsumption` | 10 | 600 | Web reset/verify |
| `RateLimits:Api:AuthRegister` | 5 | 900 | 保留設定;API register endpoint 目前未開放。 |
| `RateLimits:Api:AuthRegister` | 5 | 900 | API register |
| `RateLimits:Api:AuthRecovery` | 5 | 900 | API forgot/resend |
| `RateLimits:Api:AuthTokenConsumption` | 10 | 600 | API reset/verify |
| `RateLimits:Api:NewsletterSubscribe` | 20 | 600 | Newsletter subscribe |
@ -67,6 +67,7 @@ Newsletter 值皆須大於 0File Access 必須符合 `0 < minimum <= default
| `Auth:Issuer` | Development 可空 | Production 必填 HTTPS。 |
| `Auth:WebLoginUrl` | `/account/login` | Authorize 未登入時的 Web login URL。 |
| `Auth:AllowedLoginReturnUrlPrefixes` | 空 | 逗號分隔 login allowlist。 |
| `Auth:AllowedLogoutReturnUrlPrefixes` | 空 | 逗號分隔 logout allowlist。 |
| `Auth:AllowInternalHttpTokenEndpoint` | `false` | 允許 VPC 私有 HTTP OAuth endpointissuer 仍為 HTTPS。 |
| `Auth:Resources:MemberCenter:Audience` | `member_center_api` | Member Center audience seed。 |
| `Auth:Resources:SendEngine:Audience` | `send_engine_api` | Send Engine audience seed。 |
@ -75,7 +76,6 @@ Newsletter 值皆須大於 0File Access 必須符合 `0 < minimum <= default
OAuth usage/scope mapping 的正式來源為 DB registryaudience key 只作 seed / 相容來源。
完整現有 scope catalog 與 usage mapping 請見 `docs/SCOPES.md`
Web logout 的外部 `returnUrl` allowlist 由 `usage=web_login` OAuth client 的 `post_logout_redirect_uris` 管理;舊 `Auth:AllowedLogoutReturnUrlPrefixes` 不再使用。
## 憑證與 Data Protection
@ -101,8 +101,6 @@ Web logout 的外部 `returnUrl` allowlist 由 `usage=web_login` OAuth client
`ReverseProxy:TrustForwardedHeaders=true` 會接受 `X-Forwarded-For``X-Forwarded-Proto``X-Forwarded-Host`。此模式適合 AWS ALB / managed reverse proxy private IP 會變動,但 app security group 已只允許該 proxy 連入的環境。
接受 `X-Forwarded-Host` 是刻意支援 AWS ALB 後方的登入、OAuth callback 與 public URL 產生。Production 必須同時由 ALB / router 覆寫外部 forwarded headers、以 Security Group 阻止繞過 proxy並將 DB `public_base_url` 設為 canonical HTTPS URL。部署驗證需包含偽造 `Host` / `X-Forwarded-Host` 的 redirect 與 email link 測試。
未啟用 `TrustForwardedHeaders` 時,必須設定 `KnownProxies``KnownNetworks` 才會接受 forwarded headersallowlist 都為空時完全忽略 forwarded headers。
## 外部整合與測試旗標

View File

@ -98,7 +98,7 @@
### 6.1 OAuth2/OIDC Redirect 登入Authorization Code + PKCE
狀態:已支援 `usage=web_login`
1) 站點建立 OAuth client`usage=web_login`,設定 `redirect_uris``post_logout_redirect_uris`
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`
@ -108,7 +108,7 @@
- API 與 Web 需共用 DataProtection application name `MemberCenter`,使 API authorize endpoint 可讀取 Web login cookie。
- 若 API 與 Web 位於不同子網域,需設定 `Auth:CookieDomain`,例如 `.example.com`
- 若 API 與 Web 不同 originWeb login 僅允許導回 `Auth:Issuer``Auth:AllowedLoginReturnUrlPrefixes` 內的 return URL。
- Login / Logout return URL allowlist 分開驗證,採 URI origin 與 path segment prefix 比對,不使用原始字串 `StartsWith`Logout allowlist 來自 `web_login` OAuth client 的 `post_logout_redirect_uris`
- Login / Logout return URL allowlist 分開驗證,採 URI origin 與 path segment prefix 比對,不使用原始字串 `StartsWith`
- API 可用 `Auth:WebLoginUrl` 指定登入頁位置;預設為 `/account/login`
- `web_login` 可使用 public client + PKCE不要求 client secret。
- `web_login` client 可使用 `openid email profile` 與 current-user `profile:*` scopes這些 scopes 僅能操作 access token subject 自己的資料。

View File

@ -47,6 +47,7 @@ ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgr
Auth__Issuer=http://localhost:7850/
Auth__WebLoginUrl=http://localhost:5080/account/login
Auth__AllowedLoginReturnUrlPrefixes=http://localhost:7850/
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
Auth__AllowInternalHttpTokenEndpoint=false
# Optional certificate file overrides. Normally installer provisions app certificates into DB.
Auth__Certificates__Signing__Path=
@ -79,10 +80,9 @@ SendEngine__WebhookSecret=change-me
OIDC / Redirect login 設定說明:
- `Auth__WebLoginUrl`: API `/oauth/authorize` 未登入時導向的 Web login URL。
- `Auth__AllowedLoginReturnUrlPrefixes`: Web login 成功後允許 redirect 回去的 URL prefix通常填 API issuer/base URL。
- Web logout 後允許 redirect 的 URL prefix 由 `usage=web_login` OAuth client 的 `post_logout_redirect_uris` 設定,不再使用 env allowlist。
- Web CSP `form-action` 會自動包含 `Auth__Issuer``Auth__AllowedLoginReturnUrlPrefixes``web_login` client `redirect_uris` / `post_logout_redirect_uris` 的 origin讓 login form POST 成功後可經 API `/oauth/authorize` 302 回外部登入站。
- `Auth__AllowedLogoutReturnUrlPrefixes`: Web logout 後允許 redirect 的 URL prefix不會同時成為 login allowlist。
- Return URL allowlist 會結構化比對 `scheme + host + port + path segment prefix`,設定值不得含 userinfo、query 或 fragment例如 `https://example.com/app` 不會允許 `https://example.com.attacker.tld``/application`
- Identity cookie 在 Development 使用 `SameSite=Lax` 與 request scheme支援 localhost HTTP 測試;非 Development 固定使用 `SameSite=None``Secure=Always``Path=/`,因此 stage/prod 必須使用 HTTPS。
- Identity cookie 固定使用 `SameSite=None``Secure=Always``Path=/`,因此 stage/prod 必須使用 HTTPS。
- AWS 架構由 ALB / CloudFront 終止 TLS並負責 HTTP→HTTPS redirect 與 HSTSMember Center 私有 listener 使用 HTTP不在應用程式層 redirect避免私有網域、health check 與 S2S 呼叫形成循環。
- 非 Development 的 `Auth__Issuer` 為必填且必須使用 canonical HTTPS URL缺少或使用 HTTP 時 API 拒絕啟動。
- 同 VPC 服務若需直接呼叫私有 HTTP `/oauth/token`,設定 `Auth__AllowInternalHttpTokenEndpoint=true`;此設定只放寬 OAuth endpoint transport不放寬 issuer 或外部 return URL。
@ -95,9 +95,6 @@ Reverse proxy 信任設定:
- `ReverseProxy__TrustForwardedHeaders=true`
- `ReverseProxy__ForwardLimit=1`
- `ReverseProxy__TrustForwardedHeaders=true` 會接受 `X-Forwarded-For``X-Forwarded-Proto``X-Forwarded-Host`;只有在 app inbound 已由 Security Group / 私有網路限制為可信 proxy 時才可使用。
- 接受 `X-Forwarded-Host` 是為了讓 AWS ALB 後方的登入、OAuth callback 與外部 URL 產生使用原始 public hostALB / router 必須覆寫外部傳入的 forwarded headers不可直接沿用任意 client 值。
- Production 的 `/admin/security` `Public base URL` 必須設定為 canonical HTTPS URL讓驗證信與密碼重設信不依賴 request host。
- 部署後必須以偽造 `Host` / `X-Forwarded-Host` 實測登入 redirect、OAuth callback 與 email link確認不會導向非預期網域。
- 若不使用 `TrustForwardedHeaders`,未設定 `ReverseProxy__KnownProxies` / `ReverseProxy__KnownNetworks`API 與 Web 完全忽略 forwarded headers。
- `ReverseProxy__KnownProxies` 使用逗號分隔 IP例如 `10.0.0.10,10.0.0.11`
- `ReverseProxy__KnownNetworks` 使用逗號分隔 CIDR例如 `10.0.0.0/24,fd00::/64`

View File

@ -89,7 +89,6 @@
- 供外部網站使用 Member Center 統一登入 UI
- 使用 Authorization Code + PKCE
- 需設定 `redirect_uris`
- 需設定 `post_logout_redirect_uris` 作為 Web logout `returnUrl` allowlist
- 可使用 `client_type=public`
- 允許 scope`openid``email``profile`、全部 current-user `profile:*`
- `usage=webhook_outbound`
@ -102,7 +101,6 @@
- 可不綁定 `tenant_id`scope 使用 `newsletter:events.write.global`
- `tenant_api` / `send_api` / `platform_service` / `file_api` 建議(且實作要求)`client_type=confidential`
- `redirect_uris``web_login` / `webhook_outbound` 需要;其他 usage 可為空
- `post_logout_redirect_uris``web_login` logout return 使用;其他 usage 可為空
- 管理規則:
- 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`
- 平台級流程另建 `platform_service` 憑證

View File

@ -39,7 +39,6 @@ Repo 曾包含的 TestSite service client secret 必須在對應環境撤銷r
- `usage=web_login`
- `client_type=public`
- redirect URI: `http://localhost:5243/auth/callback`
- post logout redirect URI: `http://localhost:5243/`
- scopes: `openid email profile profile:basic.read profile:basic.write profile:addresses.read profile:addresses.write profile:subscriptions.read profile:subscriptions.write`
service OAuth client
@ -52,12 +51,10 @@ service OAuth client
```text
Auth__WebLoginUrl=<Member Center Web login URL>
Auth__AllowedLoginReturnUrlPrefixes=<Member Center API issuer/base URL>
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
Auth__CookieDomain=<shared cookie domain, production subdomain SSO only>
```
Development 的 Member Center cookie 允許 localhost HTTP redirect loginStage/Production 仍必須使用 HTTPS。
Web CSP `form-action` 會允許上述 login return origin 與 `web_login` client redirect origins避免 login form POST 後的 302 chain 被瀏覽器擋下。
## 第一批 Happy Path
測試站目前包含:

View File

@ -12,7 +12,7 @@
### 管理者端
- 租戶管理Tenant CRUD
- OAuth Client 管理usage / redirect_uris / post_logout_redirect_uris / client_id / client_secretscope 由 usage 自動配置)
- OAuth Client 管理usage / redirect_uris / client_id / client_secretscope 由 usage 自動配置)
- 電子報清單管理Lists CRUD
- 訂閱查詢 / 匯出
- 審計紀錄查詢
@ -59,7 +59,6 @@
- UC-11.1 Tenant 可設定 `Send Engine Webhook Client Id`UUID
- UC-12 OAuth Client 管理: `/admin/oauth-clients`(建立時顯示一次 client_secret可旋轉可選 `usage=tenant_api` / `send_api` / `web_login` / `webhook_outbound` / `platform_service` / `file_api``platform_service` / `web_login` 可不指定 tenant
- `redirect_uris``web_login` / `webhook_outbound` 需要;其餘 usage 不需要
- `post_logout_redirect_uris``web_login` logout return 需要
- `tenant_api` / `send_api` / `platform_service` / `file_api` 強制 `client_type=confidential`
- 既有 public client 改為 confidential 時會自動產生新的 client_secret並只顯示一次之後需使用 rotate secret 重新產生
- UC-13 電子報清單管理: `/admin/newsletter-lists`

View File

@ -84,6 +84,24 @@ paths:
'200':
description: JSON Web Key Set
/auth/register:
post:
summary: Register user
security: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterRequest'
responses:
'200':
description: Registered
content:
application/json:
schema:
$ref: '#/components/schemas/UserProfile'
/auth/logout:
post:
summary: Logout current authenticated session
@ -883,6 +901,13 @@ components:
token_type: { type: string, example: Bearer }
expires_in: { type: integer }
RegisterRequest:
type: object
required: [email, password]
properties:
email: { type: string, format: email }
password: { type: string }
AuthorizationCodeTokenRequest:
type: object
required: [grant_type, code, redirect_uri, code_verifier]
@ -1133,5 +1158,4 @@ components:
name: { type: string }
usage: { type: string, enum: [tenant_api, send_api, web_login, webhook_outbound, platform_service, file_api] }
redirect_uris: { type: array, items: { type: string } }
post_logout_redirect_uris: { type: array, items: { type: string } }
client_type: { type: string, enum: [public, confidential] }

View File

@ -4,10 +4,4 @@ public sealed record TenantRequest(string Name, List<string> Domains, string Sta
public sealed record NewsletterListRequest(Guid TenantId, string Name, string Status);
public sealed record OAuthClientRequest(
Guid? TenantId,
string Name,
List<string>? RedirectUris,
string ClientType,
string Usage = "tenant_api",
List<string>? PostLogoutRedirectUris = null);
public sealed record OAuthClientRequest(Guid? TenantId, string Name, List<string>? RedirectUris, string ClientType, string Usage = "tenant_api");

View File

@ -1,5 +1,7 @@
namespace MemberCenter.Api.Contracts;
public sealed record RegisterRequest(string Email, string Password);
public sealed record RefreshRequest(string RefreshToken);
public sealed record ForgotPasswordRequest(string Email);

View File

@ -40,7 +40,6 @@ public class AdminOAuthClientsController : ControllerBase
client_id = await _applicationManager.GetClientIdAsync(application),
client_type = await _applicationManager.GetClientTypeAsync(application),
redirect_uris = await _applicationManager.GetRedirectUrisAsync(application),
post_logout_redirect_uris = await _applicationManager.GetPostLogoutRedirectUrisAsync(application),
properties = await _applicationManager.GetPropertiesAsync(application)
});
}
@ -73,15 +72,6 @@ public class AdminOAuthClientsController : ControllerBase
{
return BadRequest(redirectUriError);
}
var (postLogoutRedirectUris, postLogoutRedirectUriError) = IsPostLogoutRedirectUsage(request.Usage)
? NormalizeRedirectUris(request.PostLogoutRedirectUris)
: ([], null);
if (!string.IsNullOrWhiteSpace(postLogoutRedirectUriError))
{
return BadRequest(postLogoutRedirectUriError.Replace("redirect_uris", "post_logout_redirect_uris", StringComparison.Ordinal));
}
if (UsesAuthorizationCodeFlow(request.Usage) && redirectUris.Count == 0)
{
return BadRequest("redirect_uris is required for web_login or webhook_outbound usage.");
@ -108,11 +98,6 @@ public class AdminOAuthClientsController : ControllerBase
descriptor.RedirectUris.Add(new Uri(uri));
}
foreach (var uri in postLogoutRedirectUris)
{
descriptor.PostLogoutRedirectUris.Add(new Uri(uri));
}
if (!IsTenantOptionalUsage(request.Usage) && request.TenantId.HasValue)
{
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.Value.ToString());
@ -127,8 +112,7 @@ public class AdminOAuthClientsController : ControllerBase
descriptor.DisplayName,
descriptor.ClientType,
client_secret = clientSecret,
redirect_uris = descriptor.RedirectUris.Select(u => u.ToString()),
post_logout_redirect_uris = descriptor.PostLogoutRedirectUris.Select(u => u.ToString())
redirect_uris = descriptor.RedirectUris.Select(u => u.ToString())
});
}
@ -149,7 +133,6 @@ public class AdminOAuthClientsController : ControllerBase
client_id = await _applicationManager.GetClientIdAsync(app),
client_type = await _applicationManager.GetClientTypeAsync(app),
redirect_uris = await _applicationManager.GetRedirectUrisAsync(app),
post_logout_redirect_uris = await _applicationManager.GetPostLogoutRedirectUrisAsync(app),
properties = await _applicationManager.GetPropertiesAsync(app)
});
}
@ -179,15 +162,6 @@ public class AdminOAuthClientsController : ControllerBase
{
return BadRequest(redirectUriError);
}
var (postLogoutRedirectUris, postLogoutRedirectUriError) = IsPostLogoutRedirectUsage(request.Usage)
? NormalizeRedirectUris(request.PostLogoutRedirectUris)
: ([], null);
if (!string.IsNullOrWhiteSpace(postLogoutRedirectUriError))
{
return BadRequest(postLogoutRedirectUriError.Replace("redirect_uris", "post_logout_redirect_uris", StringComparison.Ordinal));
}
if (UsesAuthorizationCodeFlow(request.Usage) && redirectUris.Count == 0)
{
return BadRequest("redirect_uris is required for web_login or webhook_outbound usage.");
@ -225,13 +199,6 @@ public class AdminOAuthClientsController : ControllerBase
{
descriptor.RedirectUris.Add(new Uri(uri));
}
descriptor.PostLogoutRedirectUris.Clear();
foreach (var uri in postLogoutRedirectUris)
{
descriptor.PostLogoutRedirectUris.Add(new Uri(uri));
}
if (!IsTenantOptionalUsage(request.Usage) && request.TenantId.HasValue)
{
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.Value.ToString());
@ -250,8 +217,7 @@ public class AdminOAuthClientsController : ControllerBase
descriptor.DisplayName,
descriptor.ClientType,
client_secret = generatedClientSecret,
redirect_uris = descriptor.RedirectUris.Select(u => u.ToString()),
post_logout_redirect_uris = descriptor.PostLogoutRedirectUris.Select(u => u.ToString())
redirect_uris = descriptor.RedirectUris.Select(u => u.ToString())
});
}
@ -299,11 +265,6 @@ public class AdminOAuthClientsController : ControllerBase
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
}
private static bool IsPostLogoutRedirectUsage(string usage)
{
return string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase);
}
private static string GenerateClientSecret() =>
Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));

View File

@ -13,23 +13,51 @@ namespace MemberCenter.Api.Controllers;
[Route("auth")]
public class AuthController : ControllerBase
{
private readonly IAccountProvisioningService _accountProvisioningService;
private readonly IAccountEmailService _accountEmailService;
private readonly IAuditLogWriter _auditLogWriter;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
public AuthController(
IAccountProvisioningService accountProvisioningService,
IAccountEmailService accountEmailService,
IAuditLogWriter auditLogWriter,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager)
{
_accountProvisioningService = accountProvisioningService;
_accountEmailService = accountEmailService;
_auditLogWriter = auditLogWriter;
_userManager = userManager;
_signInManager = signInManager;
}
[HttpPost("register")]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRegister)]
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
var result = await _accountProvisioningService.RegisterLocalAsync(request.Email, request.Password);
if (!result.Succeeded)
{
return BadRequest(result.Errors);
}
var user = await _userManager.FindByEmailAsync(request.Email);
if (user is not null)
{
await _accountEmailService.SendVerificationEmailAsync(user.Id, GetBaseUrl());
}
return Ok(new
{
id = result.UserId,
email = result.Email,
email_verified = result.EmailConfirmed,
linked_subscriptions = result.LinkedSubscriptionsCount
});
}
[HttpPost("password/forgot")]
[EnableRateLimiting(RateLimitPolicyNames.PublicAuthRecovery)]
public async Task<IActionResult> ForgotPassword([FromBody] ForgotPasswordRequest request)

View File

@ -120,12 +120,8 @@ builder.Services.AddAuthentication(options =>
builder.Services.ConfigureApplicationCookie(options =>
{
options.Cookie.Path = "/";
options.Cookie.SameSite = builder.Environment.IsDevelopment()
? SameSiteMode.Lax
: SameSiteMode.None;
options.Cookie.SecurePolicy = builder.Environment.IsDevelopment()
? CookieSecurePolicy.SameAsRequest
: CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.None;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});
builder.Services.AddOpenIddict()

View File

@ -20,7 +20,6 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="5.7.0" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="8.0.3" />
</ItemGroup>
<PropertyGroup>

View File

@ -47,8 +47,7 @@ public class OAuthClientsController : Controller
client_id = await _applicationManager.GetClientIdAsync(application),
client_type = await _applicationManager.GetClientTypeAsync(application),
usage,
redirect_uris = await _applicationManager.GetRedirectUrisAsync(application),
post_logout_redirect_uris = await _applicationManager.GetPostLogoutRedirectUrisAsync(application)
redirect_uris = await _applicationManager.GetRedirectUrisAsync(application)
});
}
@ -92,15 +91,6 @@ public class OAuthClientsController : Controller
ModelState.AddModelError(nameof(model.RedirectUris), redirectUriError);
}
string? postLogoutRedirectUriError = null;
var postLogoutRedirectUris = IsPostLogoutRedirectUsage(model.Usage)
? NormalizeRedirectUris(model.PostLogoutRedirectUris, out postLogoutRedirectUriError)
: new List<string>();
if (!string.IsNullOrWhiteSpace(postLogoutRedirectUriError))
{
ModelState.AddModelError(nameof(model.PostLogoutRedirectUris), postLogoutRedirectUriError);
}
if (UsesAuthorizationCodeFlow(model.Usage) && redirectUris.Count == 0)
{
ModelState.AddModelError(nameof(model.RedirectUris), "Redirect URI is required for web_login or webhook_outbound usage.");
@ -128,11 +118,6 @@ public class OAuthClientsController : Controller
descriptor.RedirectUris.Add(new Uri(uri));
}
foreach (var uri in postLogoutRedirectUris)
{
descriptor.PostLogoutRedirectUris.Add(new Uri(uri));
}
if (!IsTenantOptionalUsage(model.Usage) && model.TenantId.HasValue)
{
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString());
@ -162,7 +147,6 @@ public class OAuthClientsController : Controller
}
var redirectUris = await _applicationManager.GetRedirectUrisAsync(app);
var postLogoutRedirectUris = await _applicationManager.GetPostLogoutRedirectUrisAsync(app);
var properties = await _applicationManager.GetPropertiesAsync(app);
var tenantId = properties.TryGetValue("tenant_id", out var value) ? value.GetString() : string.Empty;
var usage = properties.TryGetValue("usage", out var usageValue) ? usageValue.GetString() : "tenant_api";
@ -176,7 +160,6 @@ public class OAuthClientsController : Controller
ClientType = await _applicationManager.GetClientTypeAsync(app) ?? "public",
Usage = string.IsNullOrWhiteSpace(usage) ? "tenant_api" : usage,
RedirectUris = string.Join(",", redirectUris.Select(u => u.ToString())),
PostLogoutRedirectUris = string.Join(",", postLogoutRedirectUris.Select(u => u.ToString())),
Tenants = tenants
});
}
@ -207,15 +190,6 @@ public class OAuthClientsController : Controller
ModelState.AddModelError(nameof(model.RedirectUris), redirectUriError);
}
string? postLogoutRedirectUriError = null;
var postLogoutRedirectUris = IsPostLogoutRedirectUsage(model.Usage)
? NormalizeRedirectUris(model.PostLogoutRedirectUris, out postLogoutRedirectUriError)
: new List<string>();
if (!string.IsNullOrWhiteSpace(postLogoutRedirectUriError))
{
ModelState.AddModelError(nameof(model.PostLogoutRedirectUris), postLogoutRedirectUriError);
}
if (UsesAuthorizationCodeFlow(model.Usage) && redirectUris.Count == 0)
{
ModelState.AddModelError(nameof(model.RedirectUris), "Redirect URI is required for web_login or webhook_outbound usage.");
@ -260,12 +234,6 @@ public class OAuthClientsController : Controller
descriptor.RedirectUris.Add(new Uri(uri));
}
descriptor.PostLogoutRedirectUris.Clear();
foreach (var uri in postLogoutRedirectUris)
{
descriptor.PostLogoutRedirectUris.Add(new Uri(uri));
}
if (!IsTenantOptionalUsage(model.Usage) && model.TenantId.HasValue)
{
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString());
@ -340,9 +308,6 @@ public class OAuthClientsController : Controller
private static bool UsesAuthorizationCodeFlow(string usage) =>
usage is "web_login" or "webhook_outbound";
private static bool IsPostLogoutRedirectUsage(string usage) =>
usage is "web_login";
private static bool RequiresClientCredentials(string usage) =>
usage is "tenant_api" or "send_api" or "platform_service" or "file_api";

View File

@ -42,9 +42,5 @@
<input asp-for="RedirectUris" />
<span asp-validation-for="RedirectUris"></span>
<label>@L["Post logout redirect URIs (comma-separated, web_login logout return allowlist)"]</label>
<input asp-for="PostLogoutRedirectUris" />
<span asp-validation-for="PostLogoutRedirectUris"></span>
<button type="submit" class="account-action-button">@L["Save"]</button>
</form>

View File

@ -42,10 +42,6 @@
<input asp-for="RedirectUris" />
<span asp-validation-for="RedirectUris"></span>
<label>@L["Post logout redirect URIs (comma-separated, web_login logout return allowlist)"]</label>
<input asp-for="PostLogoutRedirectUris" />
<span asp-validation-for="PostLogoutRedirectUris"></span>
<div class="profile-form-actions">
<a class="profile-pill-link" asp-action="Index">@L["Cancel"]</a>
<button type="submit" class="profile-pill-button">@L["Save"]</button>

View File

@ -11,7 +11,6 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Localization;
using OpenIddict.Abstractions;
namespace MemberCenter.Web.Controllers;
@ -23,7 +22,6 @@ public class AccountController : Controller
private readonly IAuditLogWriter _auditLogWriter;
private readonly IConfiguration _configuration;
private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider;
private readonly IOpenIddictApplicationManager _applicationManager;
private readonly bool _allowInsecureReturnUrls;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
@ -36,7 +34,6 @@ public class AccountController : Controller
IAuditLogWriter auditLogWriter,
IConfiguration configuration,
IAuthenticationSchemeProvider authenticationSchemeProvider,
IOpenIddictApplicationManager applicationManager,
IWebHostEnvironment environment,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
@ -48,7 +45,6 @@ public class AccountController : Controller
_auditLogWriter = auditLogWriter;
_configuration = configuration;
_authenticationSchemeProvider = authenticationSchemeProvider;
_applicationManager = applicationManager;
_allowInsecureReturnUrls = environment.IsDevelopment();
_userManager = userManager;
_signInManager = signInManager;
@ -102,7 +98,7 @@ public class AccountController : Controller
await UpdateSignInMetadataAsync(loginUser);
}
if (await IsAllowedReturnUrlAsync(model.ReturnUrl, ReturnUrlPurpose.Login))
if (IsAllowedReturnUrl(model.ReturnUrl, ReturnUrlPurpose.Login))
{
return Redirect(model.ReturnUrl!);
}
@ -198,7 +194,7 @@ public class AccountController : Controller
await _signInManager.SignInAsync(user, rememberMe, info.LoginProvider);
await UpdateSignInMetadataAsync(user);
if (await IsAllowedReturnUrlAsync(returnUrl, ReturnUrlPurpose.Login))
if (IsAllowedReturnUrl(returnUrl, ReturnUrlPurpose.Login))
{
return Redirect(returnUrl!);
}
@ -268,7 +264,7 @@ public class AccountController : Controller
await _signInManager.SignInAsync(user, model.RememberMe, info.LoginProvider);
await UpdateSignInMetadataAsync(user);
if (await IsAllowedReturnUrlAsync(model.ReturnUrl, ReturnUrlPurpose.Login))
if (IsAllowedReturnUrl(model.ReturnUrl, ReturnUrlPurpose.Login))
{
return Redirect(model.ReturnUrl!);
}
@ -285,7 +281,7 @@ public class AccountController : Controller
await _signInManager.SignOutAsync();
}
if (await IsAllowedReturnUrlAsync(returnUrl, ReturnUrlPurpose.Logout))
if (IsAllowedReturnUrl(returnUrl, ReturnUrlPurpose.Logout))
{
return Redirect(returnUrl!);
}
@ -301,7 +297,7 @@ public class AccountController : Controller
{
await _signInManager.SignOutAsync();
if (await IsAllowedReturnUrlAsync(returnUrl, ReturnUrlPurpose.Logout))
if (IsAllowedReturnUrl(returnUrl, ReturnUrlPurpose.Logout))
{
return Redirect(returnUrl!);
}
@ -533,7 +529,7 @@ public class AccountController : Controller
await _userManager.UpdateAsync(user);
}
private async Task<bool> IsAllowedReturnUrlAsync(string? returnUrl, ReturnUrlPurpose purpose)
private bool IsAllowedReturnUrl(string? returnUrl, ReturnUrlPurpose purpose)
{
if (string.IsNullOrWhiteSpace(returnUrl))
{
@ -553,7 +549,7 @@ public class AccountController : Controller
var allowedPrefixes = purpose == ReturnUrlPurpose.Login
? new[] { _configuration["Auth:Issuer"] }
.Concat(GetConfiguredReturnUrls("Auth:AllowedLoginReturnUrlPrefixes"))
: await GetConfiguredPostLogoutReturnUrlsAsync();
: GetConfiguredReturnUrls("Auth:AllowedLogoutReturnUrlPrefixes");
return ReturnUrlValidator.IsAllowedExternal(parsed, allowedPrefixes, _allowInsecureReturnUrls);
}
@ -562,28 +558,6 @@ public class AccountController : Controller
(_configuration[key] ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
private async Task<IEnumerable<string>> GetConfiguredPostLogoutReturnUrlsAsync()
{
var results = new List<string>();
await foreach (var application in _applicationManager.ListAsync())
{
var properties = await _applicationManager.GetPropertiesAsync(application);
var usage = properties.TryGetValue("usage", out var usageValue)
? usageValue.GetString()
: null;
if (!string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var postLogoutRedirectUris = await _applicationManager.GetPostLogoutRedirectUrisAsync(application);
results.AddRange(postLogoutRedirectUris.Select(uri => uri.ToString()));
}
return results;
}
private enum ReturnUrlPurpose
{
Login,

View File

@ -19,8 +19,6 @@ public sealed class OAuthClientFormViewModel
public string Usage { get; set; } = "tenant_api";
public string RedirectUris { get; set; } = string.Empty;
public string PostLogoutRedirectUris { get; set; } = string.Empty;
public IReadOnlyList<MemberCenter.Application.Models.Admin.TenantDto> Tenants { get; set; }
= Array.Empty<MemberCenter.Application.Models.Admin.TenantDto>();

View File

@ -19,7 +19,6 @@ using MemberCenter.Web.Localization;
using MemberCenter.Web.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using OpenIddict.Abstractions;
EnvLoader.LoadDotEnvIfDevelopment();
@ -113,12 +112,8 @@ builder.Services.ConfigureApplicationCookie(options =>
{
options.LoginPath = "/account/login";
options.Cookie.Path = "/";
options.Cookie.SameSite = builder.Environment.IsDevelopment()
? SameSiteMode.Lax
: SameSiteMode.None;
options.Cookie.SecurePolicy = builder.Environment.IsDevelopment()
? CookieSecurePolicy.SameAsRequest
: CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.None;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Events = new CookieAuthenticationEvents
{
@ -239,11 +234,6 @@ CertificateLoader.LogExpirationWarning(
await EnsureAuthRegistryDefaultsAsync(app.Services);
await EnsureAdminPermissionDefaultsAsync(app.Services);
var cspFormActionSources = await BuildCspFormActionSourcesAsync(
app.Services,
builder.Configuration,
googleLoginEnabled,
builder.Environment.IsDevelopment());
if (!app.Environment.IsDevelopment())
{
@ -256,8 +246,11 @@ app.Use(async (context, next) =>
context.Response.OnStarting(() =>
{
var headers = context.Response.Headers;
var formAction = googleLoginEnabled
? "'self' https://accounts.google.com"
: "'self'";
headers.TryAdd("Content-Security-Policy",
$"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action {cspFormActionSources}; frame-ancestors 'none'");
$"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action {formAction}; frame-ancestors 'none'");
headers.TryAdd("X-Content-Type-Options", "nosniff");
headers.TryAdd("X-Frame-Options", "DENY");
headers.TryAdd("Referrer-Policy", "no-referrer");
@ -297,69 +290,6 @@ static Task HandleAdminAuthRedirectAsync(RedirectContext<CookieAuthenticationOpt
return Task.CompletedTask;
}
static async Task<string> BuildCspFormActionSourcesAsync(
IServiceProvider services,
IConfiguration configuration,
bool googleLoginEnabled,
bool allowInsecureHttp)
{
var sources = new List<string> { "'self'" };
if (googleLoginEnabled)
{
sources.Add("https://accounts.google.com");
}
foreach (var value in GetConfiguredLoginReturnUrls(configuration))
{
AddCspFormActionSource(sources, value, allowInsecureHttp);
}
await using var scope = services.CreateAsyncScope();
var applicationManager = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
await foreach (var application in applicationManager.ListAsync())
{
var properties = await applicationManager.GetPropertiesAsync(application);
var usage = properties.TryGetValue("usage", out var usageValue)
? usageValue.GetString()
: null;
if (!string.Equals(usage, "web_login", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var redirectUris = await applicationManager.GetRedirectUrisAsync(application);
foreach (var uri in redirectUris)
{
AddCspFormActionSource(sources, uri.ToString(), allowInsecureHttp);
}
var postLogoutRedirectUris = await applicationManager.GetPostLogoutRedirectUrisAsync(application);
foreach (var uri in postLogoutRedirectUris)
{
AddCspFormActionSource(sources, uri.ToString(), allowInsecureHttp);
}
}
return string.Join(' ', sources.Distinct(StringComparer.OrdinalIgnoreCase));
}
static void AddCspFormActionSource(List<string> sources, string? value, bool allowInsecureHttp)
{
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp) ||
(!allowInsecureHttp && uri.Scheme != Uri.UriSchemeHttps))
{
return;
}
sources.Add(uri.GetLeftPart(UriPartial.Authority));
}
static IEnumerable<string?> GetConfiguredLoginReturnUrls(IConfiguration configuration) =>
new[] { configuration["Auth:Issuer"] }
.Concat((configuration["Auth:AllowedLoginReturnUrlPrefixes"] ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
static async Task ValidatePrincipalAsync(CookieValidatePrincipalContext context)
{
await SecurityStampValidator.ValidatePrincipalAsync(context);

View File

@ -9,14 +9,12 @@
(() => {
const usage = document.getElementById("Usage");
const redirect = document.getElementById("RedirectUris");
const postLogoutRedirect = document.getElementById("PostLogoutRedirectUris");
const clientType = document.getElementById("ClientType");
if (!usage || !redirect || !clientType) return;
const syncRedirectInputState = () => {
const usageValue = usage.value;
const needsRedirect = usageValue === "web_login" || usageValue === "webhook_outbound";
const needsPostLogoutRedirect = usageValue === "web_login";
const requiresConfidential = usageValue === "tenant_api"
|| usageValue === "send_api"
|| usageValue === "platform_service"
@ -24,10 +22,6 @@
redirect.disabled = !needsRedirect;
if (!needsRedirect) redirect.value = "";
if (postLogoutRedirect) {
postLogoutRedirect.disabled = !needsPostLogoutRedirect;
if (!needsPostLogoutRedirect) postLogoutRedirect.value = "";
}
const publicOption = clientType.querySelector('option[value="public"]');
if (publicOption) publicOption.disabled = requiresConfidential;

View File

@ -42,7 +42,7 @@ public sealed class SecurityConfigurationTests
}
[Fact]
public void TrustedProxyAllowlistIncludesForwardedHost()
public void TrustedProxyAllowlistEnablesExpectedHeaders()
{
var options = new ForwardedHeadersOptions();
TrustedForwardedHeaders.Configure(options, Configuration(new()
@ -51,35 +51,11 @@ public sealed class SecurityConfigurationTests
["ReverseProxy:KnownNetworks"] = "10.1.0.0/16"
}));
Assert.Equal(
ForwardedHeaders.XForwardedFor |
ForwardedHeaders.XForwardedProto |
ForwardedHeaders.XForwardedHost,
options.ForwardedHeaders);
Assert.Equal(ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, options.ForwardedHeaders);
Assert.Single(options.KnownProxies);
Assert.Single(options.KnownNetworks);
}
[Fact]
public void ManagedProxyModeIncludesForwardedHostForExternalUrlGeneration()
{
var options = new ForwardedHeadersOptions();
TrustedForwardedHeaders.Configure(options, Configuration(new()
{
["ReverseProxy:TrustForwardedHeaders"] = "true",
["ReverseProxy:ForwardLimit"] = "1"
}));
Assert.Equal(
ForwardedHeaders.XForwardedFor |
ForwardedHeaders.XForwardedProto |
ForwardedHeaders.XForwardedHost,
options.ForwardedHeaders);
Assert.Equal(1, options.ForwardLimit);
Assert.Empty(options.KnownProxies);
Assert.Empty(options.KnownNetworks);
}
[Theory]
[InlineData("0.0.0.0/0")]
[InlineData("::/0")]