Use OAuth client logout redirect allowlists
This commit is contained in:
parent
473ac6e889
commit
9bb10ea1e9
@ -3,7 +3,6 @@ ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgr
|
|||||||
Auth__Issuer=http://localhost:7850/
|
Auth__Issuer=http://localhost:7850/
|
||||||
Auth__WebLoginUrl=http://localhost:5080/account/login
|
Auth__WebLoginUrl=http://localhost:5080/account/login
|
||||||
Auth__AllowedLoginReturnUrlPrefixes=http://localhost:7850/
|
Auth__AllowedLoginReturnUrlPrefixes=http://localhost:7850/
|
||||||
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
|
|
||||||
Auth__AllowInternalHttpTokenEndpoint=false
|
Auth__AllowInternalHttpTokenEndpoint=false
|
||||||
# Optional PFX file overrides. Production normally reads app-managed certificates provisioned by installer into DB.
|
# Optional PFX file overrides. Production normally reads app-managed certificates provisioned by installer into DB.
|
||||||
Auth__Certificates__Signing__Path=
|
Auth__Certificates__Signing__Path=
|
||||||
|
|||||||
@ -67,7 +67,6 @@ Newsletter 值皆須大於 0;File Access 必須符合 `0 < minimum <= default
|
|||||||
| `Auth:Issuer` | Development 可空 | Production 必填 HTTPS。 |
|
| `Auth:Issuer` | Development 可空 | Production 必填 HTTPS。 |
|
||||||
| `Auth:WebLoginUrl` | `/account/login` | Authorize 未登入時的 Web login URL。 |
|
| `Auth:WebLoginUrl` | `/account/login` | Authorize 未登入時的 Web login URL。 |
|
||||||
| `Auth:AllowedLoginReturnUrlPrefixes` | 空 | 逗號分隔 login allowlist。 |
|
| `Auth:AllowedLoginReturnUrlPrefixes` | 空 | 逗號分隔 login allowlist。 |
|
||||||
| `Auth:AllowedLogoutReturnUrlPrefixes` | 空 | 逗號分隔 logout allowlist。 |
|
|
||||||
| `Auth:AllowInternalHttpTokenEndpoint` | `false` | 允許 VPC 私有 HTTP OAuth endpoint;issuer 仍為 HTTPS。 |
|
| `Auth:AllowInternalHttpTokenEndpoint` | `false` | 允許 VPC 私有 HTTP OAuth endpoint;issuer 仍為 HTTPS。 |
|
||||||
| `Auth:Resources:MemberCenter:Audience` | `member_center_api` | Member Center audience seed。 |
|
| `Auth:Resources:MemberCenter:Audience` | `member_center_api` | Member Center audience seed。 |
|
||||||
| `Auth:Resources:SendEngine:Audience` | `send_engine_api` | Send Engine audience seed。 |
|
| `Auth:Resources:SendEngine:Audience` | `send_engine_api` | Send Engine audience seed。 |
|
||||||
@ -76,6 +75,7 @@ Newsletter 值皆須大於 0;File Access 必須符合 `0 < minimum <= default
|
|||||||
|
|
||||||
OAuth usage/scope mapping 的正式來源為 DB registry;audience key 只作 seed / 相容來源。
|
OAuth usage/scope mapping 的正式來源為 DB registry;audience key 只作 seed / 相容來源。
|
||||||
完整現有 scope catalog 與 usage mapping 請見 `docs/SCOPES.md`。
|
完整現有 scope catalog 與 usage mapping 請見 `docs/SCOPES.md`。
|
||||||
|
Web logout 的外部 `returnUrl` allowlist 由 `usage=web_login` OAuth client 的 `post_logout_redirect_uris` 管理;舊 `Auth:AllowedLogoutReturnUrlPrefixes` 不再使用。
|
||||||
|
|
||||||
## 憑證與 Data Protection
|
## 憑證與 Data Protection
|
||||||
|
|
||||||
|
|||||||
@ -98,7 +98,7 @@
|
|||||||
### 6.1 OAuth2/OIDC Redirect 登入(Authorization Code + PKCE)
|
### 6.1 OAuth2/OIDC Redirect 登入(Authorization Code + PKCE)
|
||||||
狀態:已支援 `usage=web_login`。
|
狀態:已支援 `usage=web_login`。
|
||||||
|
|
||||||
1) 站點建立 OAuth client,`usage=web_login`,設定 `redirect_uris`
|
1) 站點建立 OAuth client,`usage=web_login`,設定 `redirect_uris` 與 `post_logout_redirect_uris`
|
||||||
2) 站點導向 `/oauth/authorize`,帶 `client_id`, `redirect_uri`, `code_challenge`, `code_challenge_method=S256`, `response_type=code`, `scope=openid email profile`
|
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
|
3) 若使用者尚未登入,`/oauth/authorize` 會導向會員中心 Web login,登入後回到原 authorize request
|
||||||
4) 成功後導回 `redirect_uri` 並附 `code`
|
4) 成功後導回 `redirect_uri` 並附 `code`
|
||||||
@ -108,7 +108,7 @@
|
|||||||
- API 與 Web 需共用 DataProtection application name `MemberCenter`,使 API authorize endpoint 可讀取 Web login cookie。
|
- API 與 Web 需共用 DataProtection application name `MemberCenter`,使 API authorize endpoint 可讀取 Web login cookie。
|
||||||
- 若 API 與 Web 位於不同子網域,需設定 `Auth:CookieDomain`,例如 `.example.com`。
|
- 若 API 與 Web 位於不同子網域,需設定 `Auth:CookieDomain`,例如 `.example.com`。
|
||||||
- 若 API 與 Web 不同 origin,Web login 僅允許導回 `Auth:Issuer` 或 `Auth:AllowedLoginReturnUrlPrefixes` 內的 return URL。
|
- 若 API 與 Web 不同 origin,Web login 僅允許導回 `Auth:Issuer` 或 `Auth:AllowedLoginReturnUrlPrefixes` 內的 return URL。
|
||||||
- Login / Logout return URL allowlist 分開驗證,採 URI origin 與 path segment prefix 比對,不使用原始字串 `StartsWith`。
|
- Login / Logout return URL allowlist 分開驗證,採 URI origin 與 path segment prefix 比對,不使用原始字串 `StartsWith`;Logout allowlist 來自 `web_login` OAuth client 的 `post_logout_redirect_uris`。
|
||||||
- API 可用 `Auth:WebLoginUrl` 指定登入頁位置;預設為 `/account/login`。
|
- API 可用 `Auth:WebLoginUrl` 指定登入頁位置;預設為 `/account/login`。
|
||||||
- `web_login` 可使用 public client + PKCE,不要求 client secret。
|
- `web_login` 可使用 public client + PKCE,不要求 client secret。
|
||||||
- `web_login` client 可使用 `openid email profile` 與 current-user `profile:*` scopes;這些 scopes 僅能操作 access token subject 自己的資料。
|
- `web_login` client 可使用 `openid email profile` 與 current-user `profile:*` scopes;這些 scopes 僅能操作 access token subject 自己的資料。
|
||||||
|
|||||||
@ -47,7 +47,6 @@ ConnectionStrings__Default=Host=localhost;Database=member_center;Username=postgr
|
|||||||
Auth__Issuer=http://localhost:7850/
|
Auth__Issuer=http://localhost:7850/
|
||||||
Auth__WebLoginUrl=http://localhost:5080/account/login
|
Auth__WebLoginUrl=http://localhost:5080/account/login
|
||||||
Auth__AllowedLoginReturnUrlPrefixes=http://localhost:7850/
|
Auth__AllowedLoginReturnUrlPrefixes=http://localhost:7850/
|
||||||
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
|
|
||||||
Auth__AllowInternalHttpTokenEndpoint=false
|
Auth__AllowInternalHttpTokenEndpoint=false
|
||||||
# Optional certificate file overrides. Normally installer provisions app certificates into DB.
|
# Optional certificate file overrides. Normally installer provisions app certificates into DB.
|
||||||
Auth__Certificates__Signing__Path=
|
Auth__Certificates__Signing__Path=
|
||||||
@ -80,9 +79,10 @@ SendEngine__WebhookSecret=change-me
|
|||||||
OIDC / Redirect login 設定說明:
|
OIDC / Redirect login 設定說明:
|
||||||
- `Auth__WebLoginUrl`: API `/oauth/authorize` 未登入時導向的 Web login URL。
|
- `Auth__WebLoginUrl`: API `/oauth/authorize` 未登入時導向的 Web login URL。
|
||||||
- `Auth__AllowedLoginReturnUrlPrefixes`: Web login 成功後允許 redirect 回去的 URL prefix,通常填 API issuer/base URL。
|
- `Auth__AllowedLoginReturnUrlPrefixes`: Web login 成功後允許 redirect 回去的 URL prefix,通常填 API issuer/base URL。
|
||||||
- `Auth__AllowedLogoutReturnUrlPrefixes`: Web logout 後允許 redirect 的 URL prefix;不會同時成為 login allowlist。
|
- 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 回外部登入站。
|
||||||
- Return URL allowlist 會結構化比對 `scheme + host + port + path segment prefix`,設定值不得含 userinfo、query 或 fragment;例如 `https://example.com/app` 不會允許 `https://example.com.attacker.tld` 或 `/application`。
|
- Return URL allowlist 會結構化比對 `scheme + host + port + path segment prefix`,設定值不得含 userinfo、query 或 fragment;例如 `https://example.com/app` 不會允許 `https://example.com.attacker.tld` 或 `/application`。
|
||||||
- Identity cookie 固定使用 `SameSite=None`、`Secure=Always`、`Path=/`,因此 stage/prod 必須使用 HTTPS。
|
- Identity cookie 在 Development 使用 `SameSite=Lax` 與 request scheme,支援 localhost HTTP 測試;非 Development 固定使用 `SameSite=None`、`Secure=Always`、`Path=/`,因此 stage/prod 必須使用 HTTPS。
|
||||||
- AWS 架構由 ALB / CloudFront 終止 TLS,並負責 HTTP→HTTPS redirect 與 HSTS;Member Center 私有 listener 使用 HTTP,不在應用程式層 redirect,避免私有網域、health check 與 S2S 呼叫形成循環。
|
- AWS 架構由 ALB / CloudFront 終止 TLS,並負責 HTTP→HTTPS redirect 與 HSTS;Member Center 私有 listener 使用 HTTP,不在應用程式層 redirect,避免私有網域、health check 與 S2S 呼叫形成循環。
|
||||||
- 非 Development 的 `Auth__Issuer` 為必填且必須使用 canonical HTTPS URL;缺少或使用 HTTP 時 API 拒絕啟動。
|
- 非 Development 的 `Auth__Issuer` 為必填且必須使用 canonical HTTPS URL;缺少或使用 HTTP 時 API 拒絕啟動。
|
||||||
- 同 VPC 服務若需直接呼叫私有 HTTP `/oauth/token`,設定 `Auth__AllowInternalHttpTokenEndpoint=true`;此設定只放寬 OAuth endpoint transport,不放寬 issuer 或外部 return URL。
|
- 同 VPC 服務若需直接呼叫私有 HTTP `/oauth/token`,設定 `Auth__AllowInternalHttpTokenEndpoint=true`;此設定只放寬 OAuth endpoint transport,不放寬 issuer 或外部 return URL。
|
||||||
|
|||||||
@ -89,6 +89,7 @@
|
|||||||
- 供外部網站使用 Member Center 統一登入 UI
|
- 供外部網站使用 Member Center 統一登入 UI
|
||||||
- 使用 Authorization Code + PKCE
|
- 使用 Authorization Code + PKCE
|
||||||
- 需設定 `redirect_uris`
|
- 需設定 `redirect_uris`
|
||||||
|
- 需設定 `post_logout_redirect_uris` 作為 Web logout `returnUrl` allowlist
|
||||||
- 可使用 `client_type=public`
|
- 可使用 `client_type=public`
|
||||||
- 允許 scope:`openid`、`email`、`profile`、全部 current-user `profile:*`
|
- 允許 scope:`openid`、`email`、`profile`、全部 current-user `profile:*`
|
||||||
- `usage=webhook_outbound`:
|
- `usage=webhook_outbound`:
|
||||||
@ -101,6 +102,7 @@
|
|||||||
- 可不綁定 `tenant_id`,scope 使用 `newsletter:events.write.global`
|
- 可不綁定 `tenant_id`,scope 使用 `newsletter:events.write.global`
|
||||||
- `tenant_api` / `send_api` / `platform_service` / `file_api` 建議(且實作要求)`client_type=confidential`
|
- `tenant_api` / `send_api` / `platform_service` / `file_api` 建議(且實作要求)`client_type=confidential`
|
||||||
- `redirect_uris` 僅 `web_login` / `webhook_outbound` 需要;其他 usage 可為空
|
- `redirect_uris` 僅 `web_login` / `webhook_outbound` 需要;其他 usage 可為空
|
||||||
|
- `post_logout_redirect_uris` 僅 `web_login` logout return 使用;其他 usage 可為空
|
||||||
- 管理規則:
|
- 管理規則:
|
||||||
- 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`)
|
- 每個 tenant 至少 2 組憑證(`tenant_api` / `webhook_outbound`)
|
||||||
- 平台級流程另建 `platform_service` 憑證
|
- 平台級流程另建 `platform_service` 憑證
|
||||||
|
|||||||
@ -39,6 +39,7 @@ Repo 曾包含的 TestSite service client secret 必須在對應環境撤銷/r
|
|||||||
- `usage=web_login`
|
- `usage=web_login`
|
||||||
- `client_type=public`
|
- `client_type=public`
|
||||||
- redirect URI: `http://localhost:5243/auth/callback`
|
- 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`
|
- 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:
|
service OAuth client:
|
||||||
@ -51,10 +52,12 @@ service OAuth client:
|
|||||||
```text
|
```text
|
||||||
Auth__WebLoginUrl=<Member Center Web login URL>
|
Auth__WebLoginUrl=<Member Center Web login URL>
|
||||||
Auth__AllowedLoginReturnUrlPrefixes=<Member Center API issuer/base URL>
|
Auth__AllowedLoginReturnUrlPrefixes=<Member Center API issuer/base URL>
|
||||||
Auth__AllowedLogoutReturnUrlPrefixes=http://localhost:5243/
|
|
||||||
Auth__CookieDomain=<shared cookie domain, production subdomain SSO only>
|
Auth__CookieDomain=<shared cookie domain, production subdomain SSO only>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Development 的 Member Center cookie 允許 localhost HTTP redirect login;Stage/Production 仍必須使用 HTTPS。
|
||||||
|
Web CSP `form-action` 會允許上述 login return origin 與 `web_login` client redirect origins,避免 login form POST 後的 302 chain 被瀏覽器擋下。
|
||||||
|
|
||||||
## 第一批 Happy Path
|
## 第一批 Happy Path
|
||||||
|
|
||||||
測試站目前包含:
|
測試站目前包含:
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
### 管理者端
|
### 管理者端
|
||||||
- 租戶管理(Tenant CRUD)
|
- 租戶管理(Tenant CRUD)
|
||||||
- OAuth Client 管理(usage / redirect_uris / client_id / client_secret;scope 由 usage 自動配置)
|
- OAuth Client 管理(usage / redirect_uris / post_logout_redirect_uris / client_id / client_secret;scope 由 usage 自動配置)
|
||||||
- 電子報清單管理(Lists CRUD)
|
- 電子報清單管理(Lists CRUD)
|
||||||
- 訂閱查詢 / 匯出
|
- 訂閱查詢 / 匯出
|
||||||
- 審計紀錄查詢
|
- 審計紀錄查詢
|
||||||
@ -59,6 +59,7 @@
|
|||||||
- 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` / `web_login` / `webhook_outbound` / `platform_service` / `file_api`;`platform_service` / `web_login` 可不指定 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` 僅 `web_login` / `webhook_outbound` 需要;其餘 usage 不需要
|
- `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`
|
- `tenant_api` / `send_api` / `platform_service` / `file_api` 強制 `client_type=confidential`
|
||||||
- 既有 public client 改為 confidential 時會自動產生新的 client_secret,並只顯示一次;之後需使用 rotate secret 重新產生
|
- 既有 public client 改為 confidential 時會自動產生新的 client_secret,並只顯示一次;之後需使用 rotate secret 重新產生
|
||||||
- UC-13 電子報清單管理: `/admin/newsletter-lists`
|
- UC-13 電子報清單管理: `/admin/newsletter-lists`
|
||||||
|
|||||||
@ -1133,4 +1133,5 @@ components:
|
|||||||
name: { type: string }
|
name: { type: string }
|
||||||
usage: { type: string, enum: [tenant_api, send_api, web_login, webhook_outbound, platform_service, file_api] }
|
usage: { type: string, enum: [tenant_api, send_api, web_login, webhook_outbound, platform_service, file_api] }
|
||||||
redirect_uris: { type: array, items: { type: string } }
|
redirect_uris: { type: array, items: { type: string } }
|
||||||
|
post_logout_redirect_uris: { type: array, items: { type: string } }
|
||||||
client_type: { type: string, enum: [public, confidential] }
|
client_type: { type: string, enum: [public, confidential] }
|
||||||
|
|||||||
@ -4,4 +4,10 @@ public sealed record TenantRequest(string Name, List<string> Domains, string Sta
|
|||||||
|
|
||||||
public sealed record NewsletterListRequest(Guid TenantId, string Name, string Status);
|
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");
|
public sealed record OAuthClientRequest(
|
||||||
|
Guid? TenantId,
|
||||||
|
string Name,
|
||||||
|
List<string>? RedirectUris,
|
||||||
|
string ClientType,
|
||||||
|
string Usage = "tenant_api",
|
||||||
|
List<string>? PostLogoutRedirectUris = null);
|
||||||
|
|||||||
@ -40,6 +40,7 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
client_id = await _applicationManager.GetClientIdAsync(application),
|
client_id = await _applicationManager.GetClientIdAsync(application),
|
||||||
client_type = await _applicationManager.GetClientTypeAsync(application),
|
client_type = await _applicationManager.GetClientTypeAsync(application),
|
||||||
redirect_uris = await _applicationManager.GetRedirectUrisAsync(application),
|
redirect_uris = await _applicationManager.GetRedirectUrisAsync(application),
|
||||||
|
post_logout_redirect_uris = await _applicationManager.GetPostLogoutRedirectUrisAsync(application),
|
||||||
properties = await _applicationManager.GetPropertiesAsync(application)
|
properties = await _applicationManager.GetPropertiesAsync(application)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -72,6 +73,15 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
{
|
{
|
||||||
return BadRequest(redirectUriError);
|
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)
|
if (UsesAuthorizationCodeFlow(request.Usage) && redirectUris.Count == 0)
|
||||||
{
|
{
|
||||||
return BadRequest("redirect_uris is required for web_login or webhook_outbound usage.");
|
return BadRequest("redirect_uris is required for web_login or webhook_outbound usage.");
|
||||||
@ -98,6 +108,11 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
descriptor.RedirectUris.Add(new Uri(uri));
|
descriptor.RedirectUris.Add(new Uri(uri));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var uri in postLogoutRedirectUris)
|
||||||
|
{
|
||||||
|
descriptor.PostLogoutRedirectUris.Add(new Uri(uri));
|
||||||
|
}
|
||||||
|
|
||||||
if (!IsTenantOptionalUsage(request.Usage) && request.TenantId.HasValue)
|
if (!IsTenantOptionalUsage(request.Usage) && request.TenantId.HasValue)
|
||||||
{
|
{
|
||||||
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.Value.ToString());
|
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.Value.ToString());
|
||||||
@ -112,7 +127,8 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
descriptor.DisplayName,
|
descriptor.DisplayName,
|
||||||
descriptor.ClientType,
|
descriptor.ClientType,
|
||||||
client_secret = clientSecret,
|
client_secret = clientSecret,
|
||||||
redirect_uris = descriptor.RedirectUris.Select(u => u.ToString())
|
redirect_uris = descriptor.RedirectUris.Select(u => u.ToString()),
|
||||||
|
post_logout_redirect_uris = descriptor.PostLogoutRedirectUris.Select(u => u.ToString())
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -133,6 +149,7 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
client_id = await _applicationManager.GetClientIdAsync(app),
|
client_id = await _applicationManager.GetClientIdAsync(app),
|
||||||
client_type = await _applicationManager.GetClientTypeAsync(app),
|
client_type = await _applicationManager.GetClientTypeAsync(app),
|
||||||
redirect_uris = await _applicationManager.GetRedirectUrisAsync(app),
|
redirect_uris = await _applicationManager.GetRedirectUrisAsync(app),
|
||||||
|
post_logout_redirect_uris = await _applicationManager.GetPostLogoutRedirectUrisAsync(app),
|
||||||
properties = await _applicationManager.GetPropertiesAsync(app)
|
properties = await _applicationManager.GetPropertiesAsync(app)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -162,6 +179,15 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
{
|
{
|
||||||
return BadRequest(redirectUriError);
|
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)
|
if (UsesAuthorizationCodeFlow(request.Usage) && redirectUris.Count == 0)
|
||||||
{
|
{
|
||||||
return BadRequest("redirect_uris is required for web_login or webhook_outbound usage.");
|
return BadRequest("redirect_uris is required for web_login or webhook_outbound usage.");
|
||||||
@ -199,6 +225,13 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
{
|
{
|
||||||
descriptor.RedirectUris.Add(new Uri(uri));
|
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)
|
if (!IsTenantOptionalUsage(request.Usage) && request.TenantId.HasValue)
|
||||||
{
|
{
|
||||||
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.Value.ToString());
|
descriptor.Properties["tenant_id"] = JsonSerializer.SerializeToElement(request.TenantId.Value.ToString());
|
||||||
@ -217,7 +250,8 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
descriptor.DisplayName,
|
descriptor.DisplayName,
|
||||||
descriptor.ClientType,
|
descriptor.ClientType,
|
||||||
client_secret = generatedClientSecret,
|
client_secret = generatedClientSecret,
|
||||||
redirect_uris = descriptor.RedirectUris.Select(u => u.ToString())
|
redirect_uris = descriptor.RedirectUris.Select(u => u.ToString()),
|
||||||
|
post_logout_redirect_uris = descriptor.PostLogoutRedirectUris.Select(u => u.ToString())
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -265,6 +299,11 @@ public class AdminOAuthClientsController : ControllerBase
|
|||||||
|| string.Equals(usage, "webhook_outbound", StringComparison.OrdinalIgnoreCase);
|
|| 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() =>
|
private static string GenerateClientSecret() =>
|
||||||
Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));
|
Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32));
|
||||||
|
|
||||||
|
|||||||
@ -120,8 +120,12 @@ builder.Services.AddAuthentication(options =>
|
|||||||
builder.Services.ConfigureApplicationCookie(options =>
|
builder.Services.ConfigureApplicationCookie(options =>
|
||||||
{
|
{
|
||||||
options.Cookie.Path = "/";
|
options.Cookie.Path = "/";
|
||||||
options.Cookie.SameSite = SameSiteMode.None;
|
options.Cookie.SameSite = builder.Environment.IsDevelopment()
|
||||||
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
? SameSiteMode.Lax
|
||||||
|
: SameSiteMode.None;
|
||||||
|
options.Cookie.SecurePolicy = builder.Environment.IsDevelopment()
|
||||||
|
? CookieSecurePolicy.SameAsRequest
|
||||||
|
: CookieSecurePolicy.Always;
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddOpenIddict()
|
builder.Services.AddOpenIddict()
|
||||||
|
|||||||
@ -47,7 +47,8 @@ public class OAuthClientsController : Controller
|
|||||||
client_id = await _applicationManager.GetClientIdAsync(application),
|
client_id = await _applicationManager.GetClientIdAsync(application),
|
||||||
client_type = await _applicationManager.GetClientTypeAsync(application),
|
client_type = await _applicationManager.GetClientTypeAsync(application),
|
||||||
usage,
|
usage,
|
||||||
redirect_uris = await _applicationManager.GetRedirectUrisAsync(application)
|
redirect_uris = await _applicationManager.GetRedirectUrisAsync(application),
|
||||||
|
post_logout_redirect_uris = await _applicationManager.GetPostLogoutRedirectUrisAsync(application)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,6 +92,15 @@ public class OAuthClientsController : Controller
|
|||||||
ModelState.AddModelError(nameof(model.RedirectUris), redirectUriError);
|
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)
|
if (UsesAuthorizationCodeFlow(model.Usage) && redirectUris.Count == 0)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(nameof(model.RedirectUris), "Redirect URI is required for web_login or webhook_outbound usage.");
|
ModelState.AddModelError(nameof(model.RedirectUris), "Redirect URI is required for web_login or webhook_outbound usage.");
|
||||||
@ -118,6 +128,11 @@ public class OAuthClientsController : Controller
|
|||||||
descriptor.RedirectUris.Add(new Uri(uri));
|
descriptor.RedirectUris.Add(new Uri(uri));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var uri in postLogoutRedirectUris)
|
||||||
|
{
|
||||||
|
descriptor.PostLogoutRedirectUris.Add(new Uri(uri));
|
||||||
|
}
|
||||||
|
|
||||||
if (!IsTenantOptionalUsage(model.Usage) && model.TenantId.HasValue)
|
if (!IsTenantOptionalUsage(model.Usage) && model.TenantId.HasValue)
|
||||||
{
|
{
|
||||||
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString());
|
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString());
|
||||||
@ -147,6 +162,7 @@ public class OAuthClientsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
var redirectUris = await _applicationManager.GetRedirectUrisAsync(app);
|
var redirectUris = await _applicationManager.GetRedirectUrisAsync(app);
|
||||||
|
var postLogoutRedirectUris = await _applicationManager.GetPostLogoutRedirectUrisAsync(app);
|
||||||
var properties = await _applicationManager.GetPropertiesAsync(app);
|
var properties = await _applicationManager.GetPropertiesAsync(app);
|
||||||
var tenantId = properties.TryGetValue("tenant_id", out var value) ? value.GetString() : string.Empty;
|
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";
|
var usage = properties.TryGetValue("usage", out var usageValue) ? usageValue.GetString() : "tenant_api";
|
||||||
@ -160,6 +176,7 @@ public class OAuthClientsController : Controller
|
|||||||
ClientType = await _applicationManager.GetClientTypeAsync(app) ?? "public",
|
ClientType = await _applicationManager.GetClientTypeAsync(app) ?? "public",
|
||||||
Usage = string.IsNullOrWhiteSpace(usage) ? "tenant_api" : usage,
|
Usage = string.IsNullOrWhiteSpace(usage) ? "tenant_api" : usage,
|
||||||
RedirectUris = string.Join(",", redirectUris.Select(u => u.ToString())),
|
RedirectUris = string.Join(",", redirectUris.Select(u => u.ToString())),
|
||||||
|
PostLogoutRedirectUris = string.Join(",", postLogoutRedirectUris.Select(u => u.ToString())),
|
||||||
Tenants = tenants
|
Tenants = tenants
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -190,6 +207,15 @@ public class OAuthClientsController : Controller
|
|||||||
ModelState.AddModelError(nameof(model.RedirectUris), redirectUriError);
|
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)
|
if (UsesAuthorizationCodeFlow(model.Usage) && redirectUris.Count == 0)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(nameof(model.RedirectUris), "Redirect URI is required for web_login or webhook_outbound usage.");
|
ModelState.AddModelError(nameof(model.RedirectUris), "Redirect URI is required for web_login or webhook_outbound usage.");
|
||||||
@ -234,6 +260,12 @@ public class OAuthClientsController : Controller
|
|||||||
descriptor.RedirectUris.Add(new Uri(uri));
|
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)
|
if (!IsTenantOptionalUsage(model.Usage) && model.TenantId.HasValue)
|
||||||
{
|
{
|
||||||
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString());
|
descriptor.Properties["tenant_id"] = System.Text.Json.JsonSerializer.SerializeToElement(model.TenantId.Value.ToString());
|
||||||
@ -308,6 +340,9 @@ public class OAuthClientsController : Controller
|
|||||||
private static bool UsesAuthorizationCodeFlow(string usage) =>
|
private static bool UsesAuthorizationCodeFlow(string usage) =>
|
||||||
usage is "web_login" or "webhook_outbound";
|
usage is "web_login" or "webhook_outbound";
|
||||||
|
|
||||||
|
private static bool IsPostLogoutRedirectUsage(string usage) =>
|
||||||
|
usage is "web_login";
|
||||||
|
|
||||||
private static bool RequiresClientCredentials(string usage) =>
|
private static bool RequiresClientCredentials(string usage) =>
|
||||||
usage is "tenant_api" or "send_api" or "platform_service" or "file_api";
|
usage is "tenant_api" or "send_api" or "platform_service" or "file_api";
|
||||||
|
|
||||||
|
|||||||
@ -42,5 +42,9 @@
|
|||||||
<input asp-for="RedirectUris" />
|
<input asp-for="RedirectUris" />
|
||||||
<span asp-validation-for="RedirectUris"></span>
|
<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>
|
<button type="submit" class="account-action-button">@L["Save"]</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -42,6 +42,10 @@
|
|||||||
<input asp-for="RedirectUris" />
|
<input asp-for="RedirectUris" />
|
||||||
<span asp-validation-for="RedirectUris"></span>
|
<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">
|
<div class="profile-form-actions">
|
||||||
<a class="profile-pill-link" asp-action="Index">@L["Cancel"]</a>
|
<a class="profile-pill-link" asp-action="Index">@L["Cancel"]</a>
|
||||||
<button type="submit" class="profile-pill-button">@L["Save"]</button>
|
<button type="submit" class="profile-pill-button">@L["Save"]</button>
|
||||||
|
|||||||
@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Identity;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
|
using OpenIddict.Abstractions;
|
||||||
|
|
||||||
namespace MemberCenter.Web.Controllers;
|
namespace MemberCenter.Web.Controllers;
|
||||||
|
|
||||||
@ -22,6 +23,7 @@ public class AccountController : Controller
|
|||||||
private readonly IAuditLogWriter _auditLogWriter;
|
private readonly IAuditLogWriter _auditLogWriter;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider;
|
private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider;
|
||||||
|
private readonly IOpenIddictApplicationManager _applicationManager;
|
||||||
private readonly bool _allowInsecureReturnUrls;
|
private readonly bool _allowInsecureReturnUrls;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||||
@ -34,6 +36,7 @@ public class AccountController : Controller
|
|||||||
IAuditLogWriter auditLogWriter,
|
IAuditLogWriter auditLogWriter,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IAuthenticationSchemeProvider authenticationSchemeProvider,
|
IAuthenticationSchemeProvider authenticationSchemeProvider,
|
||||||
|
IOpenIddictApplicationManager applicationManager,
|
||||||
IWebHostEnvironment environment,
|
IWebHostEnvironment environment,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
SignInManager<ApplicationUser> signInManager,
|
SignInManager<ApplicationUser> signInManager,
|
||||||
@ -45,6 +48,7 @@ public class AccountController : Controller
|
|||||||
_auditLogWriter = auditLogWriter;
|
_auditLogWriter = auditLogWriter;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_authenticationSchemeProvider = authenticationSchemeProvider;
|
_authenticationSchemeProvider = authenticationSchemeProvider;
|
||||||
|
_applicationManager = applicationManager;
|
||||||
_allowInsecureReturnUrls = environment.IsDevelopment();
|
_allowInsecureReturnUrls = environment.IsDevelopment();
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_signInManager = signInManager;
|
_signInManager = signInManager;
|
||||||
@ -98,7 +102,7 @@ public class AccountController : Controller
|
|||||||
await UpdateSignInMetadataAsync(loginUser);
|
await UpdateSignInMetadataAsync(loginUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsAllowedReturnUrl(model.ReturnUrl, ReturnUrlPurpose.Login))
|
if (await IsAllowedReturnUrlAsync(model.ReturnUrl, ReturnUrlPurpose.Login))
|
||||||
{
|
{
|
||||||
return Redirect(model.ReturnUrl!);
|
return Redirect(model.ReturnUrl!);
|
||||||
}
|
}
|
||||||
@ -194,7 +198,7 @@ public class AccountController : Controller
|
|||||||
await _signInManager.SignInAsync(user, rememberMe, info.LoginProvider);
|
await _signInManager.SignInAsync(user, rememberMe, info.LoginProvider);
|
||||||
await UpdateSignInMetadataAsync(user);
|
await UpdateSignInMetadataAsync(user);
|
||||||
|
|
||||||
if (IsAllowedReturnUrl(returnUrl, ReturnUrlPurpose.Login))
|
if (await IsAllowedReturnUrlAsync(returnUrl, ReturnUrlPurpose.Login))
|
||||||
{
|
{
|
||||||
return Redirect(returnUrl!);
|
return Redirect(returnUrl!);
|
||||||
}
|
}
|
||||||
@ -264,7 +268,7 @@ public class AccountController : Controller
|
|||||||
await _signInManager.SignInAsync(user, model.RememberMe, info.LoginProvider);
|
await _signInManager.SignInAsync(user, model.RememberMe, info.LoginProvider);
|
||||||
await UpdateSignInMetadataAsync(user);
|
await UpdateSignInMetadataAsync(user);
|
||||||
|
|
||||||
if (IsAllowedReturnUrl(model.ReturnUrl, ReturnUrlPurpose.Login))
|
if (await IsAllowedReturnUrlAsync(model.ReturnUrl, ReturnUrlPurpose.Login))
|
||||||
{
|
{
|
||||||
return Redirect(model.ReturnUrl!);
|
return Redirect(model.ReturnUrl!);
|
||||||
}
|
}
|
||||||
@ -281,7 +285,7 @@ public class AccountController : Controller
|
|||||||
await _signInManager.SignOutAsync();
|
await _signInManager.SignOutAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsAllowedReturnUrl(returnUrl, ReturnUrlPurpose.Logout))
|
if (await IsAllowedReturnUrlAsync(returnUrl, ReturnUrlPurpose.Logout))
|
||||||
{
|
{
|
||||||
return Redirect(returnUrl!);
|
return Redirect(returnUrl!);
|
||||||
}
|
}
|
||||||
@ -297,7 +301,7 @@ public class AccountController : Controller
|
|||||||
{
|
{
|
||||||
await _signInManager.SignOutAsync();
|
await _signInManager.SignOutAsync();
|
||||||
|
|
||||||
if (IsAllowedReturnUrl(returnUrl, ReturnUrlPurpose.Logout))
|
if (await IsAllowedReturnUrlAsync(returnUrl, ReturnUrlPurpose.Logout))
|
||||||
{
|
{
|
||||||
return Redirect(returnUrl!);
|
return Redirect(returnUrl!);
|
||||||
}
|
}
|
||||||
@ -529,7 +533,7 @@ public class AccountController : Controller
|
|||||||
await _userManager.UpdateAsync(user);
|
await _userManager.UpdateAsync(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsAllowedReturnUrl(string? returnUrl, ReturnUrlPurpose purpose)
|
private async Task<bool> IsAllowedReturnUrlAsync(string? returnUrl, ReturnUrlPurpose purpose)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(returnUrl))
|
if (string.IsNullOrWhiteSpace(returnUrl))
|
||||||
{
|
{
|
||||||
@ -549,7 +553,7 @@ public class AccountController : Controller
|
|||||||
var allowedPrefixes = purpose == ReturnUrlPurpose.Login
|
var allowedPrefixes = purpose == ReturnUrlPurpose.Login
|
||||||
? new[] { _configuration["Auth:Issuer"] }
|
? new[] { _configuration["Auth:Issuer"] }
|
||||||
.Concat(GetConfiguredReturnUrls("Auth:AllowedLoginReturnUrlPrefixes"))
|
.Concat(GetConfiguredReturnUrls("Auth:AllowedLoginReturnUrlPrefixes"))
|
||||||
: GetConfiguredReturnUrls("Auth:AllowedLogoutReturnUrlPrefixes");
|
: await GetConfiguredPostLogoutReturnUrlsAsync();
|
||||||
|
|
||||||
return ReturnUrlValidator.IsAllowedExternal(parsed, allowedPrefixes, _allowInsecureReturnUrls);
|
return ReturnUrlValidator.IsAllowedExternal(parsed, allowedPrefixes, _allowInsecureReturnUrls);
|
||||||
}
|
}
|
||||||
@ -558,6 +562,28 @@ public class AccountController : Controller
|
|||||||
(_configuration[key] ?? string.Empty)
|
(_configuration[key] ?? string.Empty)
|
||||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
.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
|
private enum ReturnUrlPurpose
|
||||||
{
|
{
|
||||||
Login,
|
Login,
|
||||||
|
|||||||
@ -19,6 +19,8 @@ public sealed class OAuthClientFormViewModel
|
|||||||
public string Usage { get; set; } = "tenant_api";
|
public string Usage { get; set; } = "tenant_api";
|
||||||
|
|
||||||
public string RedirectUris { get; set; } = string.Empty;
|
public string RedirectUris { get; set; } = string.Empty;
|
||||||
|
public string PostLogoutRedirectUris { get; set; } = string.Empty;
|
||||||
|
|
||||||
public IReadOnlyList<MemberCenter.Application.Models.Admin.TenantDto> Tenants { get; set; }
|
public IReadOnlyList<MemberCenter.Application.Models.Admin.TenantDto> Tenants { get; set; }
|
||||||
= Array.Empty<MemberCenter.Application.Models.Admin.TenantDto>();
|
= Array.Empty<MemberCenter.Application.Models.Admin.TenantDto>();
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ using MemberCenter.Web.Localization;
|
|||||||
using MemberCenter.Web.Services;
|
using MemberCenter.Web.Services;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using OpenIddict.Abstractions;
|
||||||
|
|
||||||
EnvLoader.LoadDotEnvIfDevelopment();
|
EnvLoader.LoadDotEnvIfDevelopment();
|
||||||
|
|
||||||
@ -112,8 +113,12 @@ builder.Services.ConfigureApplicationCookie(options =>
|
|||||||
{
|
{
|
||||||
options.LoginPath = "/account/login";
|
options.LoginPath = "/account/login";
|
||||||
options.Cookie.Path = "/";
|
options.Cookie.Path = "/";
|
||||||
options.Cookie.SameSite = SameSiteMode.None;
|
options.Cookie.SameSite = builder.Environment.IsDevelopment()
|
||||||
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
? SameSiteMode.Lax
|
||||||
|
: SameSiteMode.None;
|
||||||
|
options.Cookie.SecurePolicy = builder.Environment.IsDevelopment()
|
||||||
|
? CookieSecurePolicy.SameAsRequest
|
||||||
|
: CookieSecurePolicy.Always;
|
||||||
|
|
||||||
options.Events = new CookieAuthenticationEvents
|
options.Events = new CookieAuthenticationEvents
|
||||||
{
|
{
|
||||||
@ -234,6 +239,11 @@ CertificateLoader.LogExpirationWarning(
|
|||||||
|
|
||||||
await EnsureAuthRegistryDefaultsAsync(app.Services);
|
await EnsureAuthRegistryDefaultsAsync(app.Services);
|
||||||
await EnsureAdminPermissionDefaultsAsync(app.Services);
|
await EnsureAdminPermissionDefaultsAsync(app.Services);
|
||||||
|
var cspFormActionSources = await BuildCspFormActionSourcesAsync(
|
||||||
|
app.Services,
|
||||||
|
builder.Configuration,
|
||||||
|
googleLoginEnabled,
|
||||||
|
builder.Environment.IsDevelopment());
|
||||||
|
|
||||||
if (!app.Environment.IsDevelopment())
|
if (!app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
@ -246,11 +256,8 @@ app.Use(async (context, next) =>
|
|||||||
context.Response.OnStarting(() =>
|
context.Response.OnStarting(() =>
|
||||||
{
|
{
|
||||||
var headers = context.Response.Headers;
|
var headers = context.Response.Headers;
|
||||||
var formAction = googleLoginEnabled
|
|
||||||
? "'self' https://accounts.google.com"
|
|
||||||
: "'self'";
|
|
||||||
headers.TryAdd("Content-Security-Policy",
|
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 {formAction}; 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 {cspFormActionSources}; frame-ancestors 'none'");
|
||||||
headers.TryAdd("X-Content-Type-Options", "nosniff");
|
headers.TryAdd("X-Content-Type-Options", "nosniff");
|
||||||
headers.TryAdd("X-Frame-Options", "DENY");
|
headers.TryAdd("X-Frame-Options", "DENY");
|
||||||
headers.TryAdd("Referrer-Policy", "no-referrer");
|
headers.TryAdd("Referrer-Policy", "no-referrer");
|
||||||
@ -290,6 +297,69 @@ static Task HandleAdminAuthRedirectAsync(RedirectContext<CookieAuthenticationOpt
|
|||||||
return Task.CompletedTask;
|
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)
|
static async Task ValidatePrincipalAsync(CookieValidatePrincipalContext context)
|
||||||
{
|
{
|
||||||
await SecurityStampValidator.ValidatePrincipalAsync(context);
|
await SecurityStampValidator.ValidatePrincipalAsync(context);
|
||||||
|
|||||||
@ -9,12 +9,14 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const usage = document.getElementById("Usage");
|
const usage = document.getElementById("Usage");
|
||||||
const redirect = document.getElementById("RedirectUris");
|
const redirect = document.getElementById("RedirectUris");
|
||||||
|
const postLogoutRedirect = document.getElementById("PostLogoutRedirectUris");
|
||||||
const clientType = document.getElementById("ClientType");
|
const clientType = document.getElementById("ClientType");
|
||||||
if (!usage || !redirect || !clientType) return;
|
if (!usage || !redirect || !clientType) return;
|
||||||
|
|
||||||
const syncRedirectInputState = () => {
|
const syncRedirectInputState = () => {
|
||||||
const usageValue = usage.value;
|
const usageValue = usage.value;
|
||||||
const needsRedirect = usageValue === "web_login" || usageValue === "webhook_outbound";
|
const needsRedirect = usageValue === "web_login" || usageValue === "webhook_outbound";
|
||||||
|
const needsPostLogoutRedirect = usageValue === "web_login";
|
||||||
const requiresConfidential = usageValue === "tenant_api"
|
const requiresConfidential = usageValue === "tenant_api"
|
||||||
|| usageValue === "send_api"
|
|| usageValue === "send_api"
|
||||||
|| usageValue === "platform_service"
|
|| usageValue === "platform_service"
|
||||||
@ -22,6 +24,10 @@
|
|||||||
|
|
||||||
redirect.disabled = !needsRedirect;
|
redirect.disabled = !needsRedirect;
|
||||||
if (!needsRedirect) redirect.value = "";
|
if (!needsRedirect) redirect.value = "";
|
||||||
|
if (postLogoutRedirect) {
|
||||||
|
postLogoutRedirect.disabled = !needsPostLogoutRedirect;
|
||||||
|
if (!needsPostLogoutRedirect) postLogoutRedirect.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
const publicOption = clientType.querySelector('option[value="public"]');
|
const publicOption = clientType.querySelector('option[value="public"]');
|
||||||
if (publicOption) publicOption.disabled = requiresConfidential;
|
if (publicOption) publicOption.disabled = requiresConfidential;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user