登录回跳对齐 Java notice:消费 yschreferer 回前端页、service 直签原访问 URL(含子应用前缀)、回调 302 落回原地址
build / build-push (push) Successful in 28s

This commit is contained in:
wanghu
2026-09-04 11:06:06 +08:00
parent 2ea71d90e1
commit a697b85643
2 changed files with 103 additions and 18 deletions
@@ -16,12 +16,17 @@ namespace YhCore.WebApi.Auth
/// <summary>
/// 自研 CAS 远程认证 handler(本地化自 Yh.Platform.Auth,改造为平台旧版登录 URL 格式):
/// <list type="bullet">
/// <item>Challenge:302 到 CAS 登录页,service = {AppBaseUrl}{SignInCallbackPath}[?returnUrl=&lt;回跳地址&gt;];
/// 回跳地址走 query 而非 Java 的路径矩阵参数 ;redirectToReferer=&lt;hex&gt;——ASP.NET Core 不支持矩阵参数,
/// <item>Challenge:302 到 CAS 登录页。service 两种形态:
/// (a) 无显式回跳语境(浏览器直访接口)——service = {AppBaseUrl}{当前 path}[?除 ticket 外 query],
/// 即原访问 URL,与 Java notice 的 service 形态一致(service=http://host/module/api/xxx);
/// (b) 显式回跳语境(前端 yschreferer/Referer 携带页面地址)——service =
/// {AppBaseUrl}{SignInCallbackPath}[?returnUrl=&lt;urlencoded 回跳地址&gt;];回跳地址走 query
/// 而非 Java 的路径矩阵参数 ;redirectToReferer=&lt;hex&gt;——ASP.NET Core 不支持矩阵参数,
/// 分号会使 MVC 路由与 PathString.StartsWithSegments 双双失配,回调 404 覆盖掉本 handler 的 302 回跳;</item>
/// <item>Authenticate:回调({SignInCallbackPath}?returnUrl=…&amp;ticket=…,无 query 时为 ?ticket=)按
/// path + 除 ticket 外的 query 重建 service 调 /serviceValidate 校验,成功后写入 Cookie 登录态并
/// 302 到 returnUrl(兼容旧 ;redirectToReferer=&lt;hex&gt; 形态)。</item>
/// <item>Authenticate:任意受保护路径带 ticket 的回调均按「path + 除 ticket 外的 query」重建 service
/// 调 /serviceValidate 校验(登录时 service 为原 URL 时回调即原 URL + ticket;为回调端点时回调为
/// {SignInCallbackPath}?returnUrl=…&amp;ticket=…)。成功后写入 Cookie 登录态并 302 到
/// returnUrl(显式回跳形态)/ 原 URL 去 ticket 版(原 URL 形态),兼容旧 ;redirectToReferer=&lt;hex&gt; 形态。</item>
/// </list>
/// </summary>
public class CasAuthenticationHandler : AuthenticationHandler<CasAuthenticationOptions>
@@ -65,6 +70,9 @@ namespace YhCore.WebApi.Auth
/// 按当前请求重建登录时的 service:{root}{path}[?{除 ticket 外的 query}]。
/// CAS 回调会在原 service 后追加 ?ticket=(已有 query 时 &amp;ticket=),校验时必须还原为登录时的
/// 原始 service,故剥掉 ticket;其余参数按 EscapeDataString 重新编码,与 BuildServiceUrl 保持一致。
/// 本方法同时充当「无显式回跳语境」的 Challenge service 构造(service = 原访问 URL,notice 形态);
/// 此时 {root} 取自 AppBaseUrl(含 /docprint 等模块前缀),{path} 为 Kestrel 收到的接口路径,
/// 拼接结果天然带模块名,不受反向代理剥离前缀影响。
/// </summary>
private string BuildServiceUrlFromRequest()
{
@@ -107,7 +115,24 @@ namespace YhCore.WebApi.Auth
return Response.WriteAsync("未配置 CAS 服务端地址(yh:sso:cas-server-url-prefix)");
}
properties.Items.TryGetValue("returnUrl", out var returnUrl);
var service = BuildServiceUrl(returnUrl ?? properties.RedirectUri);
var target = returnUrl ?? properties.RedirectUri;
string service;
if (string.IsNullOrEmpty(target))
{
// 无显式回跳语境(浏览器/工具直访受保护接口,未带 yschreferer / Referer):
// service 直接 = 原访问 URL(AppBaseUrl 前缀 + 当前 path + 除 ticket 外 query),
// 与 Java notice 的 service 形态一致(service=http://host/module/api/xxx,无回调中继)。
// AppBaseUrl 带模块前缀(如 http://host/docprint)时,拼接结果天然含模块名,
// 不受反向代理剥离前缀影响;登录成功后 CAS 回跳该 URL + ticket,由本 handler 校验并
// 302 去掉 ticket 落回原地址。
service = BuildServiceUrlFromRequest();
}
else
{
// 显式回跳语境(前端 yschreferer 携带页面地址等):走回调端点 + returnUrl 中继形态
// (ASP.NET Core 无 Java 矩阵参数机制,回跳地址只能放 query)。
service = BuildServiceUrl(target);
}
// Uri.EscapeDataString 会把 ; / : = 全部编码(%3B/%2F/%3A/%3D),与平台旧版系统生成的
// CAS 登录 URL 逐字节一致(UrlEncoder.Encode 会保留 ; 字面量)
var loginUrl = (casBase.EndsWith('/') ? casBase : casBase + "/") + "login?service=" + Uri.EscapeDataString(service);
@@ -163,10 +188,23 @@ namespace YhCore.WebApi.Auth
// 后续中间件(SsoProtectionMiddleware)需据此识别“刚登录成功”,避免被误判未认证再次 Challenge
Context.User = principal;
// 回跳原地址:优先 query 的 returnUrl(当前形态),兼容旧 ;redirectToReferer=<hex> 回调。
// 回跳原地址:
// 1. query returnUrl(显式回跳中继形态的回调携带)→ 跳该目标;
// 2. 旧 ;redirectToReferer=<hex> 回调(兼容);
// 3. 都没有(service=原访问 URL 的 notice 形态:CAS 在 service 后追加 ?ticket= 回跳,
// 本请求 Path + 除 ticket 外 query 即登录时原 URL)→ 302 去掉一次性 ticket 落回原地址。
// 允许本地相对路径或同源完整 http(s) URL;非法(外站 host / 协议相对等)回根路径,防开放重定向。
var referer = ResolveReturnUrl() ?? ParseRedirectToReferer(Context.Request.Path.Value);
var redirect = IsAllowedReturnTarget(referer, AllowedHost()) ? referer : "/";
string redirect;
if (referer != null)
{
redirect = IsAllowedReturnTarget(referer, AllowedHost()) ? referer : "/";
}
else
{
redirect = BuildServiceUrlFromRequest();
redirect = IsAllowedReturnTarget(redirect, AllowedHost()) ? redirect : "/";
}
Response.Redirect(redirect);
return AuthenticateResult.NoResult();
}