From a697b85643c6eba7454fca807d86ef46d262d5da Mon Sep 17 00:00:00 2001 From: wanghu Date: Fri, 4 Sep 2026 11:06:06 +0800 Subject: [PATCH] =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=9B=9E=E8=B7=B3=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=20Java=20notice=EF=BC=9A=E6=B6=88=E8=B4=B9=20yschrefe?= =?UTF-8?q?rer=20=E5=9B=9E=E5=89=8D=E7=AB=AF=E9=A1=B5=E3=80=81service=20?= =?UTF-8?q?=E7=9B=B4=E7=AD=BE=E5=8E=9F=E8=AE=BF=E9=97=AE=20URL=EF=BC=88?= =?UTF-8?q?=E5=90=AB=E5=AD=90=E5=BA=94=E7=94=A8=E5=89=8D=E7=BC=80=EF=BC=89?= =?UTF-8?q?=E3=80=81=E5=9B=9E=E8=B0=83=20302=20=E8=90=BD=E5=9B=9E=E5=8E=9F?= =?UTF-8?q?=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Auth/Cas/CasAuthenticationHandler.cs | 54 ++++++++++++--- .../Middleware/SsoProtectionMiddleware.cs | 67 ++++++++++++++++--- 2 files changed, 103 insertions(+), 18 deletions(-) diff --git a/src/YhCore.WebApi/Auth/Cas/CasAuthenticationHandler.cs b/src/YhCore.WebApi/Auth/Cas/CasAuthenticationHandler.cs index 7941279..32a38d3 100644 --- a/src/YhCore.WebApi/Auth/Cas/CasAuthenticationHandler.cs +++ b/src/YhCore.WebApi/Auth/Cas/CasAuthenticationHandler.cs @@ -16,12 +16,17 @@ namespace YhCore.WebApi.Auth /// /// 自研 CAS 远程认证 handler(本地化自 Yh.Platform.Auth,改造为平台旧版登录 URL 格式): /// - /// Challenge:302 到 CAS 登录页,service = {AppBaseUrl}{SignInCallbackPath}[?returnUrl=<回跳地址>]; - /// 回跳地址走 query 而非 Java 的路径矩阵参数 ;redirectToReferer=<hex>——ASP.NET Core 不支持矩阵参数, + /// 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 回跳; - /// Authenticate:回调({SignInCallbackPath}?returnUrl=…&ticket=…,无 query 时为 ?ticket=)按 - /// path + 除 ticket 外的 query 重建 service 调 /serviceValidate 校验,成功后写入 Cookie 登录态并 - /// 302 到 returnUrl(兼容旧 ;redirectToReferer=<hex> 形态)。 + /// Authenticate:任意受保护路径带 ticket 的回调均按「path + 除 ticket 外的 query」重建 service + /// 调 /serviceValidate 校验(登录时 service 为原 URL 时回调即原 URL + ticket;为回调端点时回调为 + /// {SignInCallbackPath}?returnUrl=…&ticket=…)。成功后写入 Cookie 登录态并 302 到 + /// returnUrl(显式回跳形态)/ 原 URL 去 ticket 版(原 URL 形态),兼容旧 ;redirectToReferer=<hex> 形态。 /// /// public class CasAuthenticationHandler : AuthenticationHandler @@ -65,6 +70,9 @@ namespace YhCore.WebApi.Auth /// 按当前请求重建登录时的 service:{root}{path}[?{除 ticket 外的 query}]。 /// CAS 回调会在原 service 后追加 ?ticket=(已有 query 时 &ticket=),校验时必须还原为登录时的 /// 原始 service,故剥掉 ticket;其余参数按 EscapeDataString 重新编码,与 BuildServiceUrl 保持一致。 + /// 本方法同时充当「无显式回跳语境」的 Challenge service 构造(service = 原访问 URL,notice 形态); + /// 此时 {root} 取自 AppBaseUrl(含 /docprint 等模块前缀),{path} 为 Kestrel 收到的接口路径, + /// 拼接结果天然带模块名,不受反向代理剥离前缀影响。 /// 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= 回调。 + // 回跳原地址: + // 1. query returnUrl(显式回跳中继形态的回调携带)→ 跳该目标; + // 2. 旧 ;redirectToReferer= 回调(兼容); + // 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(); } diff --git a/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs b/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs index c1a2d61..696b92b 100644 --- a/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs +++ b/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs @@ -47,11 +47,16 @@ namespace YhCore.WebApi.Middleware // 1. 已认证(含 Cookie 恢复登录态)直接放行。 // 回调路径刚完成票据校验:CasAuthenticationHandler 已写入 Cookie 并 Response.Redirect(302 + Location), - // 但 handler 返回 NoResult 不短路管道——若继续 _next 走 MVC,/signin-cas 无对应 action 会 404 覆盖 302, - // 浏览器看到"登录成功却 404"。此处已认证 + 已 302 即短路,保住回跳响应。 + // 但 handler 返回 NoResult 不短路管道——若继续 _next 走 MVC,signin-cas 或原 URL 回调端点可能无 + // 对应 action 或已响应,404 会覆盖 302,浏览器看到"登录成功却 404"。此处已认证 + 已 302 即短路: + // - IsCasCallback:中继形态回调端点({SignInCallbackPath}?returnUrl=…&ticket=…); + // - 带 ticket:原 URL 形态回调(service=原访问 URL,CAS 追加 ?ticket= 回跳,落在任意受保护 API 路径), + // ticket 校验成功后本中间件无法用路径识别,只能按「携带 ticket」短路。 + // 已认证且未 302(如 Cookie 有效时刷新带已消费 ticket 的地址)不短路,正常走 MVC 业务响应。 if (context.User?.Identity?.IsAuthenticated == true) { - if (IsCasCallback(path, sso) && IsRedirect(context.Response)) + var hasTicket = context.Request.Query.ContainsKey("ticket"); + if (IsRedirect(context.Response) && (IsCasCallback(path, sso) || hasTicket)) { return; } @@ -104,8 +109,9 @@ namespace YhCore.WebApi.Middleware } /// - /// 触发 Challenge 生成 CAS 登录地址(本地化 CasAuthenticationHandler.BuildServiceUrl 完成 - /// service 构造:{SignInCallbackPath}[?returnUrl=<urlencoded>],回跳走 query 而非矩阵参数)。 + /// 触发 Challenge 生成 CAS 登录地址(service 构造由本地化 CasAuthenticationHandler 完成: + /// 无显式回跳语境 → service = 原访问 URL(notice 形态);显式回跳(yschreferer/Referer)→ + /// {SignInCallbackPath}[?returnUrl=<urlencoded>],回跳走 query 而非矩阵参数)。 /// 受保护 API 的 AJAX/XHR 请求:拦截框架写入的 302 + Location,改写为 200 + JSON,避免 XHR /// 跟随跨域 302 跳转 CAS 登录页时被 CORS 拦截;文档导航 / 根路径 / 回调仍保持 302。 /// @@ -115,11 +121,28 @@ namespace YhCore.WebApi.Middleware // 只有受保护 API 路径的 AJAX 请求需要改写为 JSON;根路径 / 回调是文档导航语义,始终 302。 var isApiAjax = IsProtectedPath(request.Path.Value, sso) && IsAjaxRequest(request); - // 回调路径直访(无 ticket 未认证)默认回根路径,避免登录后回跳回调自身再次触发 Challenge 死循环; - // 但若为前端打标请求(yschreferer 携带当前页地址,参照 notice 语义)则仍回前端页——回调路径 - // 本身就是前端登录检测接口(/api/base/GetUserInfo),这类场景必须回到用户所在页面而非根路径。 + // 回跳目标决策(对齐 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)——前端登录后需回到所在页面而非接口本身; + // - 回调路径直访防呆(访问的恰是回调端点自身且无 ticket 未认证、无打标):回 "/", + // 避免登录后回跳回调自身再触发 Challenge(前端打标场景仍回前端页——回调路径本身常是前端 + // 登录检测接口 /api/base/GetUserInfo,必须回到用户所在页面)。 var hasYschReferer = !string.IsNullOrWhiteSpace(request.Headers["yschreferer"].ToString()); - var returnTarget = isCasCallback && !hasYschReferer ? "/" : BuildReturnUrl(request); + var hasReferer = !string.IsNullOrWhiteSpace(request.Headers["Referer"].ToString()); + var appBasePath = ExtractBasePath(sso["AppBaseUrl"]); + string returnTarget; + if (!hasYschReferer && !hasReferer) + { + returnTarget = isCasCallback ? "/" : null; + } + else + { + returnTarget = BuildReturnUrl(request, appBasePath); + } var properties = new AuthenticationProperties { @@ -244,7 +267,7 @@ namespace YhCore.WebApi.Middleware /// 返回的可能是完整 URL 或相对路径;合法性(本地路径 / 与 service 同 host 的 http(s) URL) /// 由 CasAuthenticationHandler.IsAllowedReturnTarget 在构造 service 与登录后回跳时统一把关。 /// - private static string BuildReturnUrl(HttpRequest request) + private static string BuildReturnUrl(HttpRequest request, string appBasePath) { var ysch = request.Headers["yschreferer"].ToString(); if (!string.IsNullOrWhiteSpace(ysch)) @@ -260,11 +283,35 @@ namespace YhCore.WebApi.Middleware { return referer.Trim(); } + // 兜底:回跳当前请求路径。部署在子应用(如 /docprint)且反向代理剥离前缀时, + // Kestrel 收到的 request.Path 不含应用基路径,需补上 AppBaseUrl 的 path 段, + // 否则登录后回跳会丢失模块名(/api/GetUserInfo → 应为 /docprint/api/GetUserInfo)。 var path = request.Path.Value ?? "/"; + if (!string.IsNullOrEmpty(appBasePath) + && path.StartsWith("/") + && !path.StartsWith(appBasePath, StringComparison.OrdinalIgnoreCase)) + { + path = appBasePath.TrimEnd('/') + path; + } var query = request.QueryString.Value; return string.IsNullOrEmpty(query) ? path : path + query; } + /// 从 AppBaseUrl 配置提取应用基路径(host 之后的 path 段,如 http://x/docprint → /docprint)。 + private static string ExtractBasePath(string appBaseUrl) + { + if (string.IsNullOrWhiteSpace(appBaseUrl)) + { + return string.Empty; + } + if (!Uri.TryCreate(appBaseUrl, UriKind.Absolute, out var uri)) + { + return string.Empty; + } + var path = uri.AbsolutePath; + return path.Length == 0 ? "/" : path; + } + /// /// 十六进制 → 字符串(char 维度,与前端 strToHexCharCode / Java HexUtils.hexToString 一致: /// 逐字符取码点,每两位 hex 还原一个字符)。长度为奇数或含非法 hex 时返回 null。