diff --git a/src/YhCore.WebApi/Auth/Cas/CasAuthenticationHandler.cs b/src/YhCore.WebApi/Auth/Cas/CasAuthenticationHandler.cs index 32a38d3..4e2a255 100644 --- a/src/YhCore.WebApi/Auth/Cas/CasAuthenticationHandler.cs +++ b/src/YhCore.WebApi/Auth/Cas/CasAuthenticationHandler.cs @@ -16,25 +16,29 @@ namespace YhCore.WebApi.Auth /// /// 自研 CAS 远程认证 handler(本地化自 Yh.Platform.Auth,改造为平台旧版登录 URL 格式): /// - /// 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=<urlencoded 回跳地址>];回跳地址走 query - /// 而非 Java 的路径矩阵参数 ;redirectToReferer=<hex>——ASP.NET Core 不支持矩阵参数, - /// 分号会使 MVC 路由与 PathString.StartsWithSegments 双双失配,回调 404 覆盖掉本 handler 的 302 回跳; + /// Challenge:302 到 CAS 登录页,service = {AppBaseUrl}{当前 path}[?除 ticket 外 query],即原访问 URL, + /// 与 Java notice 的 service 形态一致(service=http://host/module/api/xxx);AppBaseUrl 含模块前缀 + /// (如 /docprint)时拼接结果天然带模块名。显式回跳语境(前端 yschreferer/Referer 携带页面地址)时 + /// 在该 service 上追加 query 参数 redirectToReferer=<hex(回跳地址)> 携带「登录后回页面」信息—— + /// 不再以 {SignInCallbackPath} 回调端点作 service(signin-cas 形态已弃用);hex 按 char 维度编码 + /// (对齐前端 strToHexCharCode / Java HexUtils),避免 query 内嵌完整 URL 需多层 urlencode。 + /// ASP.NET Core 无 Java 路径矩阵参数机制,回跳目标只能放 query。 /// Authenticate:任意受保护路径带 ticket 的回调均按「path + 除 ticket 外的 query」重建 service - /// 调 /serviceValidate 校验(登录时 service 为原 URL 时回调即原 URL + ticket;为回调端点时回调为 - /// {SignInCallbackPath}?returnUrl=…&ticket=…)。成功后写入 Cookie 登录态并 302 到 - /// returnUrl(显式回跳形态)/ 原 URL 去 ticket 版(原 URL 形态),兼容旧 ;redirectToReferer=<hex> 形态。 + /// 调 /serviceValidate 校验(service 即登录时的原访问 URL,CAS 在末尾追加 ?ticket=/&ticket= 回跳)。 + /// 成功后写入 Cookie 登录态并 302 到 redirectToReferer(hex 解码,当前形态)/ returnUrl(旧中继形态 + /// 兼容)/ 原 URL 去 ticket 版(无回跳目标),兼容更早的 ;redirectToReferer=<hex> 路径段形态。 /// /// public class CasAuthenticationHandler : AuthenticationHandler { private const string RedirectToRefererMarker = ";redirectToReferer="; + /// 旧中继形态(service={回调端点}?returnUrl=…)的回跳参数——已弃用,仅兼容历史 URL/缓存。 private const string ReturnUrlParam = "returnUrl"; + /// 当前形态回跳参数:service=原访问 URL 时追加 ?redirectToReferer=<hex(回跳地址)>,登录后 CAS 原样回带。 + private const string RedirectToRefererQueryParam = "redirectToReferer"; + private readonly CasServiceTicketValidator _validator; public CasAuthenticationHandler( @@ -49,30 +53,12 @@ namespace YhCore.WebApi.Auth } /// - /// 构造 service 回调地址:{root}{SignInCallbackPath}[?returnUrl=<urlencoded(returnUrl)>]。 - /// 回跳地址允许两种:本地相对路径(/xxx),或与 service 根 host 同源的完整 http(s) URL - /// (前端页完整地址,yschreferer 携带,含 #/ 路由片段——对齐 Java notice 语义),杜绝开放重定向。 - /// 注意:回跳地址走 query 而非路径矩阵参数——ASP.NET Core 不识别矩阵参数,分号会破坏 - /// 路由匹配与 StartsWithSegments 的段比较(Java Spring 因 UrlPathHelper 默认 - /// removeSemicolonContent=true 剥离分号内容才可用矩阵参数,.NET 无此机制)。 - /// - private string BuildServiceUrl(string returnUrl) - { - var callback = BuildCallbackRoot() + Options.SignInCallbackPath; - if (string.IsNullOrEmpty(returnUrl) || !IsAllowedReturnTarget(returnUrl, AllowedHost())) - { - return callback; - } - return callback + "?" + ReturnUrlParam + "=" + Uri.EscapeDataString(returnUrl); - } - - /// - /// 按当前请求重建登录时的 service:{root}{path}[?{除 ticket 外的 query}]。 + /// 按当前请求重建登录时的 service:{root}{path}[?{除 ticket 外的 query}],即原访问 URL(notice 形态)。 /// CAS 回调会在原 service 后追加 ?ticket=(已有 query 时 &ticket=),校验时必须还原为登录时的 - /// 原始 service,故剥掉 ticket;其余参数按 EscapeDataString 重新编码,与 BuildServiceUrl 保持一致。 - /// 本方法同时充当「无显式回跳语境」的 Challenge service 构造(service = 原访问 URL,notice 形态); - /// 此时 {root} 取自 AppBaseUrl(含 /docprint 等模块前缀),{path} 为 Kestrel 收到的接口路径, - /// 拼接结果天然带模块名,不受反向代理剥离前缀影响。 + /// 原始 service,故剥掉 ticket;其余参数按 EscapeDataString 重新编码,与 Challenge 时构造保持一致。 + /// 本方法是 Challenge 与 Authenticate 两侧共用的 service 基准:Challenge 侧可能再追加 + /// redirectToReferer=<hex>(显式回跳语境);{root} 取自 AppBaseUrl(含 /docprint 等模块前缀), + /// {path} 为 Kestrel 收到的接口路径,拼接结果天然带模块名,不受反向代理剥离前缀影响。 /// private string BuildServiceUrlFromRequest() { @@ -84,7 +70,8 @@ namespace YhCore.WebApi.Auth return query.Count > 0 ? service + "?" + string.Join("&", query) : service; } - /// 取回调 query 中的 returnUrl(本地相对路径或同源完整 http(s) URL,非法返回 null)。 + /// 取回调 query 中的 returnUrl(旧中继形态兼容:service={回调端点}?returnUrl=… 的历史 URL/缓存; + /// 本地相对路径或同源完整 http(s) URL,非法返回 null)。 private string ResolveReturnUrl() { if (!Context.Request.Query.TryGetValue(ReturnUrlParam, out var value)) @@ -95,6 +82,17 @@ namespace YhCore.WebApi.Auth return IsAllowedReturnTarget(url, AllowedHost()) ? url : null; } + /// 读回调 query 的 redirectToReferer(当前形态:service=原访问 URL 时由 CAS 原样回带的 + /// hex 回跳地址)并解码;缺失或 hex 非法返回 null。合法性由调用方统一 IsAllowedReturnTarget 把关。 + private string ResolveQueryRedirectToReferer() + { + if (!Context.Request.Query.TryGetValue(RedirectToRefererQueryParam, out var value)) + { + return null; + } + return HexDecode(value.ToString()); + } + /// service 根地址:优先 AppBaseUrl 配置,为空时从当前请求推断。 private string BuildCallbackRoot() { @@ -120,8 +118,8 @@ namespace YhCore.WebApi.Auth if (string.IsNullOrEmpty(target)) { // 无显式回跳语境(浏览器/工具直访受保护接口,未带 yschreferer / Referer): - // service 直接 = 原访问 URL(AppBaseUrl 前缀 + 当前 path + 除 ticket 外 query), - // 与 Java notice 的 service 形态一致(service=http://host/module/api/xxx,无回调中继)。 + // 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 落回原地址。 @@ -129,9 +127,14 @@ namespace YhCore.WebApi.Auth } else { - // 显式回跳语境(前端 yschreferer 携带页面地址等):走回调端点 + returnUrl 中继形态 - // (ASP.NET Core 无 Java 矩阵参数机制,回跳地址只能放 query)。 - service = BuildServiceUrl(target); + // 显式回跳语境(前端 yschreferer 携带页面地址等):service 仍 = 原访问 URL(notice 形态, + // 不再以 {SignInCallbackPath} 回调端点作 service),仅追加 query 参数 + // redirectToReferer= 携带「登录后回页面」信息——CAS 会把它作为 service 一部分 + // 在回调时原样带回,本 handler 校验成功后 hex 解码即得回跳目标。hex 按 char 维度编码,避免 + // query 内嵌完整 URL 需多层 urlencode(对齐前端 strToHexCharCode / Java HexUtils)。 + service = BuildServiceUrlFromRequest(); + service += (service.IndexOf('?') < 0 ? "?" : "&") + + RedirectToRefererQueryParam + "=" + HexEncode(target); } // Uri.EscapeDataString 会把 ; / : = 全部编码(%3B/%2F/%3A/%3D),与平台旧版系统生成的 // CAS 登录 URL 逐字节一致(UrlEncoder.Encode 会保留 ; 字面量) @@ -189,12 +192,15 @@ namespace YhCore.WebApi.Auth Context.User = principal; // 回跳原地址: - // 1. query returnUrl(显式回跳中继形态的回调携带)→ 跳该目标; - // 2. 旧 ;redirectToReferer= 回调(兼容); - // 3. 都没有(service=原访问 URL 的 notice 形态:CAS 在 service 后追加 ?ticket= 回跳, + // 1. query redirectToReferer=(当前形态:service=原访问 URL 时由 CAS 原样回带)→ 解码跳该目标; + // 2. query returnUrl(旧中继形态 service={回调端点}?returnUrl=… 的历史 URL/缓存,兼容); + // 3. 旧 ;redirectToReferer= 路径段(更早形态,兼容); + // 4. 都没有(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 referer = ResolveQueryRedirectToReferer() + ?? ResolveReturnUrl() + ?? ParseRedirectToReferer(Context.Request.Path.Value); string redirect; if (referer != null) { diff --git a/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs b/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs index 696b92b..2e853d1 100644 --- a/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs +++ b/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs @@ -16,8 +16,9 @@ namespace YhCore.WebApi.Middleware /// - yh:sso:enabled=false(默认):所有路径匿名放行,不影响开发联调; /// - yh:sso:enabled=true:匹配 /api/* / /mobiapi/* / 根路径(/, 浏览器 SSO 入口) 且未认证的请求 /// → Challenge → 本地化 CasAuthenticationHandler(Auth/Cas/)302 重定向到 CAS 登录页; -/// - CAS 回调路径(yh:sso:signInCallbackPath,默认 /api/base/GetUserInfo,回调形如 -/// {path}[?returnUrl=<回跳地址>]&{ticket};旧形态 ;redirectToReferer= 仍兼容)未认证时同样 Challenge: +/// - CAS 回调路径(yh:sso:signInCallbackPath,默认 /api/base/GetUserInfo;仅用于识别"回调直访防呆" +/// 与旧 URL 兼容。service=原 URL 形态下回调落在任意受保护 API 路径 + +/// ticket;历史 URL/缓存中的旧中继形态 {path}?returnUrl=…[&ticket] 仍可校验)未认证时同样 Challenge: /// 直接访问回调(无 ticket)进入登录流程;携带失效/已消费 ticket 的回调则返回 401 错误页, /// 不再次 Challenge——避免“回调→登录→新票据→再失败”无限重定向循环(自动登录型 CAS 下必现); /// 回调校验成功(已认证 + 302)则短路管道,防止 MVC 端点匹配 404 覆盖 handler 的回跳 302; @@ -110,8 +111,8 @@ namespace YhCore.WebApi.Middleware /// /// 触发 Challenge 生成 CAS 登录地址(service 构造由本地化 CasAuthenticationHandler 完成: - /// 无显式回跳语境 → service = 原访问 URL(notice 形态);显式回跳(yschreferer/Referer)→ - /// {SignInCallbackPath}[?returnUrl=<urlencoded>],回跳走 query 而非矩阵参数)。 + /// service 恒为原访问 URL(notice 形态);显式回跳(yschreferer/Referer)时仅在其上追加 + /// redirectToReferer=<hex> query,不再以 SignInCallbackPath 回调端点作 service)。 /// 受保护 API 的 AJAX/XHR 请求:拦截框架写入的 302 + Location,改写为 200 + JSON,避免 XHR /// 跟随跨域 302 跳转 CAS 登录页时被 CORS 拦截;文档导航 / 根路径 / 回调仍保持 302。 /// @@ -122,12 +123,13 @@ namespace YhCore.WebApi.Middleware // 只有受保护 API 路径的 AJAX 请求需要改写为 JSON;根路径 / 回调是文档导航语义,始终 302。 var isApiAjax = IsProtectedPath(request.Path.Value, sso) && IsAjaxRequest(request); // 回跳目标决策(对齐 Java notice 的 service 语义差异): - // - 无显式回跳语境(浏览器/工具直访受保护接口,无 yschreferer 无 Referer):returnUrl 不携带 - // (null)→ handler 以"原访问 URL"作 service(notice 形态 service=http://host/module/api/xxx), - // 登录后 CAS 回跳原 URL+ticket,由 handler 校验并 302 去掉 ticket 落回原地址,无需中继; - // - 显式回跳语境(前端 yschreferer 打标携带页面地址 / Referer):returnUrl = 页面地址, - // service 走 {SignInCallbackPath}?returnUrl=… 中继(ASP.NET Core 无 Java 矩阵参数机制, - // 回跳地址只能放 query)——前端登录后需回到所在页面而非接口本身; + // - 无显式回跳语境(浏览器/工具直访受保护接口,无 yschreferer 无 Referer):不携带回跳目标 + // (null)→ handler 以纯净"原访问 URL"作 service(notice 形态 service=http://host/module/api/xxx), + // 登录后 CAS 回跳原 URL+ticket,由 handler 校验并 302 去掉 ticket 落回原地址; + // - 显式回跳语境(前端 yschreferer 打标携带页面地址 / Referer):回跳目标 = 页面地址, + // handler 仍以原访问 URL 作 service 主体并追加 ?redirectToReferer=<hex(目标)> 携带回页面 + // 信息(ASP.NET Core 无 Java 路径矩阵参数机制,只能放 query)——前端登录后回到所在页面 + // 而非接口本身; // - 回调路径直访防呆(访问的恰是回调端点自身且无 ticket 未认证、无打标):回 "/", // 避免登录后回跳回调自身再触发 Challenge(前端打标场景仍回前端页——回调路径本身常是前端 // 登录检测接口 /api/base/GetUserInfo,必须回到用户所在页面)。