From a73fb4a2144dbb1db7b3cf77c3339b5159206f1f Mon Sep 17 00:00:00 2001 From: wanghu Date: Fri, 4 Sep 2026 13:36:57 +0800 Subject: [PATCH] 1 --- .../Auth/Cas/CasAuthenticationHandler.cs | 176 ++++++++---------- src/YhCore.WebApi/Auth/Cas/CasHex.cs | 100 ++++++++++ .../Auth/Cas/YhCasAuthOptions.cs | 9 + .../Middleware/PathMatrixSegmentMiddleware.cs | 129 +++---------- .../Middleware/SsoProtectionMiddleware.cs | 58 +++--- 5 files changed, 245 insertions(+), 227 deletions(-) create mode 100644 src/YhCore.WebApi/Auth/Cas/CasHex.cs diff --git a/src/YhCore.WebApi/Auth/Cas/CasAuthenticationHandler.cs b/src/YhCore.WebApi/Auth/Cas/CasAuthenticationHandler.cs index a41e96b..c39c325 100644 --- a/src/YhCore.WebApi/Auth/Cas/CasAuthenticationHandler.cs +++ b/src/YhCore.WebApi/Auth/Cas/CasAuthenticationHandler.cs @@ -34,8 +34,6 @@ namespace YhCore.WebApi.Auth /// public class CasAuthenticationHandler : AuthenticationHandler { - private const string RedirectToRefererMarker = ";redirectToReferer="; - /// 旧中继形态(service={回调端点}?returnUrl=…)的回跳参数——已弃用,仅兼容历史 URL/缓存。 private const string ReturnUrlParam = "returnUrl"; @@ -60,20 +58,44 @@ namespace YhCore.WebApi.Auth /// 按当前请求重建登录时的 service:{root}{path}[?{除 ticket 外的 query}],即原访问 URL(notice 形态)。 /// CAS 回调会在原 service 后追加 ?ticket=(已有 query 时 &ticket=),校验时必须还原为登录时的 /// 原始 service,故剥掉 ticket;其余参数按 EscapeDataString 重新编码,与 Challenge 时构造保持一致。 - /// 本方法是 Challenge 与 Authenticate 两侧共用的 service 基准:Challenge 侧可能再追加 - /// redirectToReferer=<hex>(显式回跳语境);{root} 取自 AppBaseUrl(含 /docprint 等模块前缀), - /// {path} 为 Kestrel 收到的接口路径,拼接结果天然带模块名,不受反向代理剥离前缀影响。 + /// path 取 ResolveOriginalPath():分号段被 PathMatrixSegmentMiddleware 从 Request.Path 剥离后, + /// 重建必须用 Items 保存的原始路径(含 ;redirectToReferer=<hex> 段),与登录 service 逐字节一致; + /// {root} 取自 AppBaseUrl(含 /docprint 等模块前缀),拼接结果天然带模块名,不受反向代理剥离前缀影响。 + /// Challenge 侧构造新 service 用当前(已剥离的)路由路径,目标以分号段另行追加,见 HandleChallengeAsync。 /// - private string BuildServiceUrlFromRequest() + private string BuildServiceUrlFromOriginalPath() + { + return BuildServiceUrl(ResolveOriginalPath()); + } + + /// Challenge 侧 service 基准:当前(已剥离分号段的)路由路径 + 除 ticket 外 query。 + private string BuildServiceUrlFromCurrentPath() + { + return BuildServiceUrl(Context.Request.Path.Value); + } + + private string BuildServiceUrl(string path) { var query = Context.Request.Query .Where(kv => !string.Equals(kv.Key, "ticket", StringComparison.OrdinalIgnoreCase)) .Select(kv => Uri.EscapeDataString(kv.Key) + "=" + Uri.EscapeDataString(kv.Value.ToString())) .ToList(); - var service = BuildCallbackRoot() + Context.Request.Path.Value; + var service = BuildCallbackRoot() + path; return query.Count > 0 ? service + "?" + string.Join("&", query) : service; } + /// 原始完整路径(含 ;redirectToReferer= 分号段):优先 PathMatrixSegmentMiddleware 存入 + /// Items 的值,未经过该中间件时回退当前请求路径。 + private string ResolveOriginalPath() + { + if (Context.Items.TryGetValue(YhCasDefaults.OriginalPathItemKey, out var v) + && v is string original && !string.IsNullOrEmpty(original)) + { + return original; + } + return Context.Request.Path.Value ?? "/"; + } + /// 取回调 query 中的 returnUrl(旧中继形态兼容:service={回调端点}?returnUrl=… 的历史 URL/缓存; /// 本地相对路径或同源完整 http(s) URL,非法返回 null)。 private string ResolveReturnUrl() @@ -83,7 +105,7 @@ namespace YhCore.WebApi.Auth return null; } var url = value.ToString(); - return IsAllowedReturnTarget(url, AllowedHost()) ? url : null; + return CasHex.IsAllowedRedirectTarget(url, AllowedHost()) ? url : null; } /// 读回调 query 的 redirectToReferer(当前形态:service=原访问 URL 时由 CAS 原样回带的 @@ -94,7 +116,7 @@ namespace YhCore.WebApi.Auth { return null; } - return HexDecode(value.ToString()); + return CasHex.Decode(value.ToString()); } /// service 根地址:优先 AppBaseUrl 配置,为空时从当前请求推断。 @@ -127,20 +149,20 @@ namespace YhCore.WebApi.Auth // AppBaseUrl 带模块前缀(如 http://host/docprint)时,拼接结果天然含模块名, // 不受反向代理剥离前缀影响;登录成功后 CAS 回跳该 URL + ticket,由本 handler 校验并 // 302 去掉 ticket 落回原地址。 - service = BuildServiceUrlFromRequest(); + service = BuildServiceUrlFromCurrentPath(); } else { // 显式回跳语境(前端 yschreferer 携带页面地址等):service 仍 = 原访问 URL(notice 形态, // 不再以 {SignInCallbackPath} 回调端点作 service),把回跳地址以 Java notice 同款的分号矩阵段 // 形式插入 path 与 query 之间:{path};redirectToReferer=[?{原 query}]——CAS 会把它作为 - // service 一部分在回调时原样带回,本 handler 校验成功后由 ParseRedirectToReferer 从路径段 - // hex 解码即得回跳目标(query 形态 ?redirectToReferer= 的旧 URL/缓存仍兼容读取)。 - // hex 按 char 维度编码(对齐前端 strToHexCharCode / Java HexUtils);分号段内不参与 query 结构, - // 含中文/特殊字符的页面地址也可无歧义承载。 - service = BuildServiceUrlFromRequest(); + // service 一部分在回调时原样带回,回调侧 PathMatrixSegmentMiddleware 剥离分号段后由 + // ResolveRedirectToReferer 从 Items 的 hex 解码即得回跳目标(query 形态 ?redirectToReferer= + // 的旧 URL/缓存仍兼容读取)。hex 按 char 维度编码(对齐前端 strToHexCharCode / Java HexUtils); + // 分号段内不参与 query 结构,含中文/特殊字符的页面地址也可无歧义承载。 + service = BuildServiceUrlFromCurrentPath(); var q = service.IndexOf('?'); - var segment = RedirectToRefererMarker + HexEncode(target); + var segment = YhCasDefaults.RedirectToRefererMarker + CasHex.Encode(target); service = q < 0 ? service + segment : service.Substring(0, q) + segment + service.Substring(q); @@ -154,10 +176,22 @@ namespace YhCore.WebApi.Auth protected override async Task HandleAuthenticateAsync() { - // 1. 已有 Cookie 登录态直接放行 + // 1. 已有 Cookie 登录态直接放行。 + // 例外:已登录状态下又收到带 ticket 的 CAS 回调(刷新/复用回调地址、自动登录型 CAS 对同一 + // service 重复签发)——票据对该会话已无意义(且常已被消费),不重走验票;若带回跳分号段则 + // 直接 302 到「解码地址 + ?ticket=」,避免落入 MVC 路由 404。Context.User 手动同步: + // NoResult 返回时 AuthenticationMiddleware 不会赋值,SsoProtectionMiddleware 需据此识别已认证。 var cookieResult = await Context.AuthenticateAsync(YhCasDefaults.CookieScheme); if (cookieResult.Succeeded && cookieResult.Principal != null) { + var revisitTicket = Context.Request.Query["ticket"].ToString(); + var revisitReferer = ResolveRedirectToReferer(); + if (!string.IsNullOrEmpty(revisitTicket) && CasHex.IsAllowedRedirectTarget(revisitReferer, AllowedHost())) + { + Context.User = cookieResult.Principal; + Response.Redirect(AppendTicket(revisitReferer, revisitTicket)); + return AuthenticateResult.NoResult(); + } return AuthenticateResult.Success(cookieResult.Ticket!); } @@ -169,7 +203,8 @@ namespace YhCore.WebApi.Auth } // service 必须与登录时完全一致:CAS 是在登录 service 后追加 ?ticket=(已有 query 时 &ticket=)回调。 - var service = BuildServiceUrlFromRequest(); + // 分号段已被 PathMatrixSegmentMiddleware 从 Request.Path 剥离,须以 Items 保存的原始路径重建。 + var service = BuildServiceUrlFromOriginalPath(); string loginName; try { @@ -210,13 +245,13 @@ namespace YhCore.WebApi.Auth // 允许本地相对路径或同源完整 http(s) URL;非法(外站 host / 协议相对等)回根路径,防开放重定向。 // 显式回跳目标(1/2/3)白名单通过时透传一次性票据:目标页按联调约定可凭 ticket 自证登录成功—— // 注意票据已被本次 /serviceValidate 消费(一次性),目标侧不得再拿去 CAS 校验,仅作登录完成信号。 - var referer = ParseRedirectToReferer(Context.Request.Path.Value) + var referer = ResolveRedirectToReferer() ?? ResolveQueryRedirectToReferer() ?? ResolveReturnUrl(); string redirect; if (referer != null) { - redirect = IsAllowedReturnTarget(referer, AllowedHost()) ? referer : "/"; + redirect = CasHex.IsAllowedRedirectTarget(referer, AllowedHost()) ? referer : "/"; if (redirect == referer) { redirect = AppendTicket(redirect, ticketValue); @@ -224,8 +259,8 @@ namespace YhCore.WebApi.Auth } else { - redirect = BuildServiceUrlFromRequest(); - redirect = IsAllowedReturnTarget(redirect, AllowedHost()) ? redirect : "/"; + redirect = BuildServiceUrlFromOriginalPath(); + redirect = CasHex.IsAllowedRedirectTarget(redirect, AllowedHost()) ? redirect : "/"; } Response.Redirect(redirect); return AuthenticateResult.NoResult(); @@ -242,6 +277,21 @@ namespace YhCore.WebApi.Auth + "ticket=" + Uri.EscapeDataString(ticket); } + /// + /// 解析 ;redirectToReferer= 分号段的回跳目标:优先 PathMatrixSegmentMiddleware 存入 Items 的 + /// hex(分号段已从路由路径剥离,回调重建 service 仍用原始路径);未经过该中间件时回退解析当前 + /// 路径。无该段或 hex 非法返回 null。 + /// + private string ResolveRedirectToReferer() + { + if (Context.Items.TryGetValue(YhCasDefaults.RefererHexItemKey, out var v) + && v is string hex && hex.Length > 0) + { + return CasHex.Decode(hex); + } + return ParseRedirectToReferer(Context.Request.Path.Value); + } + /// 从回调路径中解析 ;redirectToReferer= 后的 hex 并解码;无该段或编码非法返回 null。 private static string ParseRedirectToReferer(string path) { @@ -249,88 +299,18 @@ namespace YhCore.WebApi.Auth { return null; } - var marker = path.IndexOf(RedirectToRefererMarker, StringComparison.OrdinalIgnoreCase); + var marker = path.IndexOf(YhCasDefaults.RedirectToRefererMarker, StringComparison.OrdinalIgnoreCase); if (marker < 0) { return null; } - return HexDecode(path.Substring(marker + RedirectToRefererMarker.Length)); - } - - /// 字符串 → 小写十六进制(char 维度,与前端 strToHexCharCode / Java HexUtils.stringToHex 一致: - /// 逐字符取码点转两位 hex;ASCII 范围内结果与 UTF-8 字节编码相同)。 - private static string HexEncode(string value) - { - var sb = new StringBuilder(); - foreach (var ch in value ?? string.Empty) + var hex = path.Substring(marker + YhCasDefaults.RedirectToRefererMarker.Length); + var term = hex.IndexOfAny(new[] { ';', '?', '#' }); + if (term >= 0) { - sb.Append(((int)ch).ToString("x2")); + hex = hex.Substring(0, term); } - return sb.ToString(); - } - - /// 小写/大写十六进制 → 字符串(char 维度解码,对齐前端 yschreferer / Java hexToString); - /// 长度为奇数或含非十六进制字符时返回 null。 - private static string HexDecode(string hex) - { - if (string.IsNullOrEmpty(hex) || hex.Length % 2 != 0) - { - return null; - } - var sb = new StringBuilder(hex.Length / 2); - for (var i = 0; i < hex.Length; i += 2) - { - if (!int.TryParse(hex.Substring(i, 2), NumberStyles.HexNumber, - CultureInfo.InvariantCulture, out var code)) - { - return null; - } - sb.Append((char)code); - } - return sb.ToString(); - } - - /// - /// 判定回跳目标是否允许: - /// 1. 本地相对路径(/xxx 开头):拒绝协议相对地址(//host)与反斜杠变体(/\…)——原有安全口径; - /// 2. 完整 http(s) URL:host 须与 service 根 host(AppBaseUrl 或当前请求)一致,或为本机联调地址 - /// (localhost / 127.0.0.1 / ::1,Uri.IsLoopback)——前端页完整地址(yschreferer 携带,含 #/ - /// 路由片段)作回跳目标;放行本机地址以支持"本地页面调线上 docprint API"的联调登录链 - /// (redirectToReferer 解析后为 http://localhost:PORT/),外站 host 仍拒绝,杜绝开放重定向。 - /// 其余情况(null/空、非 http(s) 协议、外站 host)一律拒绝。 - /// 防御:含控制字符(0x00-0x1F / 0x7F)的目标一律拒绝——hex 解码产物(平台 char 维度 hex 对 - /// 非 ASCII 码点有损,可能还原出控制字符)或手工构造的畸形 URL,Uri 解析/302 Location 序列化 - /// 都可能抛异常导致 500,此处前置拦截并安全降级(调用方回根路径或原 URL)。 - /// - private static bool IsAllowedReturnTarget(string url, string allowedHost) - { - if (string.IsNullOrEmpty(url)) - { - return false; - } - foreach (var ch in url) - { - if (ch < 0x20 || ch == 0x7f) - { - return false; - } - } - if (url[0] == '/') - { - return !url.StartsWith("//", StringComparison.Ordinal) - && !url.StartsWith("/\\", StringComparison.Ordinal); - } - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) - { - return false; - } - if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) - { - return false; - } - return string.IsNullOrEmpty(allowedHost) - || string.Equals(uri.Host, allowedHost, StringComparison.OrdinalIgnoreCase) - || uri.IsLoopback; + return CasHex.Decode(hex); } /// service 根地址的 host(回跳白名单基准):AppBaseUrl 未配置时取当前请求 host。 diff --git a/src/YhCore.WebApi/Auth/Cas/CasHex.cs b/src/YhCore.WebApi/Auth/Cas/CasHex.cs new file mode 100644 index 0000000..bd03244 --- /dev/null +++ b/src/YhCore.WebApi/Auth/Cas/CasHex.cs @@ -0,0 +1,100 @@ +using System; +using System.Globalization; +using System.Text; + +namespace YhCore.WebApi.Auth +{ + /// + /// redirectToReferer 的十六进制编解码与回跳目标白名单(收敛 handler / PathMatrixSegmentMiddleware / + /// SsoProtectionMiddleware 三处同口径逻辑的公共实现)。 + /// 编解码对齐 Java 平台 HexUtils 与前端 strToHexCharCode:按 char(Unicode 码点)维度, + /// 每字符至少两位小写十六进制(ASCII 范围内与 UTF-8 字节编码结果相同)。 + /// + public static class CasHex + { + /// 字符串 → 十六进制(char 维度、小写)。 + public static string Encode(string value) + { + if (value == null) + { + return null; + } + var sb = new StringBuilder(value.Length * 2); + foreach (var c in value) + { + sb.Append(((int)c).ToString("x2", CultureInfo.InvariantCulture)); + } + return sb.ToString(); + } + + /// 十六进制 → 字符串(每两位还原一个 char;奇数长度或含非十六进制字符返回 null)。 + /// 逐字符校验 Uri.IsHexDigit,比 int.TryParse(NumberStyles.HexNumber) 严格——后者会放行 + /// 前导空白/符号,与 Java Integer.parseInt(hex, 16) 的非法输入语义不一致。 + public static string Decode(string hex) + { + if (string.IsNullOrEmpty(hex) || hex.Length % 2 != 0) + { + return null; + } + var sb = new StringBuilder(hex.Length / 2); + for (var i = 0; i < hex.Length; i += 2) + { + if (!Uri.IsHexDigit(hex[i]) || !Uri.IsHexDigit(hex[i + 1])) + { + return null; + } + sb.Append((char)Convert.ToInt32(hex.Substring(i, 2), 16)); + } + return sb.ToString(); + } + + /// + /// 回跳目标是否允许(统一白名单口径): + /// - 拒绝含控制字符(0x00-0x1F/0x7F)与空白字符(对齐 Java isValidHttpUrl 的 ^https?://[^\s]*$, + /// 防 \n 等注入 302 Location;平台 char 维度 hex 对非 ASCII 码点有损,可能还原出控制字符); + /// - 本地相对路径(/xxx):拒绝协议相对地址(//host)与反斜杠变体(/\host); + /// - 完整 http(s) URL:host 须与 allowedHost 一致或为本机 loopback(localhost/127.0.0.1/::1, + /// 支持"本地前端页调线上 API"的联调登录链);allowedHost 为空(未配置 AppBaseUrl)则不做 host 限制。 + /// + public static bool IsAllowedRedirectTarget(string url, string allowedHost) + { + if (string.IsNullOrEmpty(url)) + { + return false; + } + foreach (var c in url) + { + if (c < 0x20 || c == 0x7f || char.IsWhiteSpace(c)) + { + return false; + } + } + if (url[0] == '/') + { + return !url.StartsWith("//", StringComparison.Ordinal) + && !url.StartsWith("/\\", StringComparison.Ordinal); + } + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + return false; + } + if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + { + return false; + } + return string.IsNullOrEmpty(allowedHost) + || string.Equals(uri.Host, allowedHost, StringComparison.OrdinalIgnoreCase) + || uri.IsLoopback; + } + + /// 从 AppBaseUrl(如 http://host/docprint)提取 host 作为回跳白名单基准;非法返回 null。 + public static string HostOf(string appBaseUrl) + { + if (string.IsNullOrWhiteSpace(appBaseUrl) || !Uri.TryCreate(appBaseUrl, UriKind.Absolute, out var uri)) + { + return null; + } + return uri.Host; + } + } +} diff --git a/src/YhCore.WebApi/Auth/Cas/YhCasAuthOptions.cs b/src/YhCore.WebApi/Auth/Cas/YhCasAuthOptions.cs index 626fef5..bda5178 100644 --- a/src/YhCore.WebApi/Auth/Cas/YhCasAuthOptions.cs +++ b/src/YhCore.WebApi/Auth/Cas/YhCasAuthOptions.cs @@ -10,6 +10,15 @@ namespace YhCore.WebApi.Auth /// CAS 认证方案(challenge 时重定向 CAS 登录页) public const string CasScheme = "YhCas"; + + /// ;redirectToReferer= 段标记(service 路径与 query 之间携带 hex 回跳地址,对齐 Java notice) + public const string RedirectToRefererMarker = ";redirectToReferer="; + + /// HttpContext.Items 键:从路径分号段提取的回跳地址 hex(PathMatrixSegmentMiddleware 写入,handler/保护中间件消费) + public const string RefererHexItemKey = "CasRedirectToRefererHex"; + + /// HttpContext.Items 键:剥离分号段前的原始完整路径(serviceValidate 重建 service 必须与登录时逐字节一致) + public const string OriginalPathItemKey = "CasOriginalPath"; } /// diff --git a/src/YhCore.WebApi/Middleware/PathMatrixSegmentMiddleware.cs b/src/YhCore.WebApi/Middleware/PathMatrixSegmentMiddleware.cs index 9e72710..17a6f5d 100644 --- a/src/YhCore.WebApi/Middleware/PathMatrixSegmentMiddleware.cs +++ b/src/YhCore.WebApi/Middleware/PathMatrixSegmentMiddleware.cs @@ -1,9 +1,7 @@ using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Configuration; using System; -using System.Globalization; -using System.Text; using System.Threading.Tasks; +using YhCore.WebApi.Auth; namespace YhCore.WebApi.Middleware { @@ -12,127 +10,54 @@ namespace YhCore.WebApi.Middleware /// 对齐 Java notice 行为:Spring 路由前默认 UrlPathHelper.removeSemicolonContent=true 会剥离 /// 分号内容(矩阵参数),ASP.NET Core 不做——带 ';' 的路径进入 UseRouting 会匹配不到任何 action /// 而 404。本中间件在路由前模拟该语义: - /// - 路径含 ;redirectToReferer=<hex>(service 分号矩阵段,CAS 登录引导时写入):按 notice - /// 语义解析 hex 并 302 到解码地址——覆盖"已登录刷新/直访带分号 URL"等 handler 未接管的场景, - /// 不再 404;hex 解码失败/目标非法则剥离分号段继续(杜绝开放重定向)。 - /// - 其余含 ';' 的路径:剥离首个 ';' 起的分号段后继续,正常路由/保护判定不再 404。 - /// 例外:请求带 ticket(CAS 登录回跳在途)→ 原样放行,不在此 302/剥离——须先由认证链路校验 ticket、 - /// 写入登录 Cookie(成功后由 CasAuthenticationHandler 302 + SsoProtectionMiddleware 短路); - /// 剥离会破坏 serviceValidate 重建一致性,提前 302 会跳过 Cookie 建立导致死循环。 + /// + /// 路径含 ;redirectToReferer=<hex>:提取 hex 与原始路径存入 HttpContext.Items + /// (键 / ), + /// 再从 Request.Path 剥离该段。提取对带 ticket 的 CAS 回调同样生效(不再放行原始路径): + /// serviceValidate 重建 service 由 CasAuthenticationHandler 改用 Items 中的原始路径, + /// 与登录 service 逐字节一致;回跳目标(解码地址 + ?ticket=)由 handler / SsoProtectionMiddleware + /// 在认证完成后统一 302——已登录(Cookie 恢复)收到回调也跳目标页,不再落入 MVC 路由 404。 + /// 其余含 ';' 的路径:剥离首个 ';' 起的分号段后继续,正常路由/保护判定不再 404。 + /// /// public class PathMatrixSegmentMiddleware { - private const string RedirectToRefererMarker = ";redirectToReferer="; - private readonly RequestDelegate _next; - private readonly IConfiguration _configuration; - public PathMatrixSegmentMiddleware(RequestDelegate next, IConfiguration configuration) + public PathMatrixSegmentMiddleware(RequestDelegate next) { _next = next; - _configuration = configuration; } public async Task InvokeAsync(HttpContext context) { var rawPath = context.Request.Path.Value ?? string.Empty; - var semi = rawPath.IndexOf(';'); - if (semi < 0) - { - await _next(context); - return; - } + var marker = rawPath.IndexOf(YhCasDefaults.RedirectToRefererMarker, StringComparison.OrdinalIgnoreCase); - // CAS 登录回跳在途(service 含分号段 + ticket):原样放行给认证链路。 - if (context.Request.Query.ContainsKey("ticket")) - { - await _next(context); - return; - } - - var marker = rawPath.IndexOf(RedirectToRefererMarker, StringComparison.OrdinalIgnoreCase); if (marker >= 0) { - var hex = rawPath.Substring(marker + RedirectToRefererMarker.Length); - var term = hex.IndexOfAny(new[] { ';', '?', '#' }); - if (term >= 0) + // hex 终止于 ';'(后续矩阵段)、'?'、'#'(hash 路由防御)或串尾 + var start = marker + YhCasDefaults.RedirectToRefererMarker.Length; + var end = start; + while (end < rawPath.Length && rawPath[end] != ';' && rawPath[end] != '?' && rawPath[end] != '#') { - hex = hex.Substring(0, term); + end++; } - var target = HexToString(hex); - if (!string.IsNullOrEmpty(target) && IsAllowedMatrixTarget(target, _configuration["yh:sso:AppBaseUrl"])) + context.Items[YhCasDefaults.RefererHexItemKey] = rawPath.Substring(start, end - start); + context.Items[YhCasDefaults.OriginalPathItemKey] = rawPath; + var stripped = rawPath.Substring(0, marker) + rawPath.Substring(end); + context.Request.Path = stripped.Length > 0 ? new PathString(stripped) : new PathString("/"); + } + else + { + var semi = rawPath.IndexOf(';'); + if (semi >= 0) { - context.Response.Redirect(target); - return; + context.Request.Path = new PathString(rawPath.Substring(0, semi)); } - // 解码失败/非法目标:不跳转(防开放重定向),落入下方剥离分号段走正常流程。 } - context.Request.Path = new PathString(rawPath.Substring(0, semi)); await _next(context); } - - /// - /// 矩阵分号段回跳目标合法性(与 CasAuthenticationHandler.IsAllowedReturnTarget 同口径): - /// 拒绝含控制字符目标;本地相对路径(拒绝协议相对 // 与反斜杠变体);完整 http(s) URL 须与 - /// AppBaseUrl host 一致或为本机 loopback(localhost/127.0.0.1/::1 联调);AppBaseUrl 未配置则不做 - /// host 白名单。 - /// - private static bool IsAllowedMatrixTarget(string url, string appBaseUrl) - { - if (string.IsNullOrEmpty(url)) - { - return false; - } - foreach (var ch in url) - { - if (ch < 0x20 || ch == 0x7f) - { - return false; - } - } - if (url[0] == '/') - { - return !url.StartsWith("//", StringComparison.Ordinal) - && !url.StartsWith("/\\", StringComparison.Ordinal); - } - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) - { - return false; - } - if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) - { - return false; - } - string allowedHost = null; - if (!string.IsNullOrWhiteSpace(appBaseUrl) - && Uri.TryCreate(appBaseUrl, UriKind.Absolute, out var baseUri)) - { - allowedHost = baseUri.Host; - } - return string.IsNullOrEmpty(allowedHost) - || string.Equals(uri.Host, allowedHost, StringComparison.OrdinalIgnoreCase) - || uri.IsLoopback; - } - - /// 十六进制 → 字符串(char 维度,每两位还原一个字符;奇数长/非法 hex 返回 null)。 - private static string HexToString(string hex) - { - if (string.IsNullOrEmpty(hex) || hex.Length % 2 != 0) - { - return null; - } - var sb = new StringBuilder(hex.Length / 2); - for (var i = 0; i < hex.Length; i += 2) - { - if (!int.TryParse(hex.Substring(i, 2), NumberStyles.HexNumber, - CultureInfo.InvariantCulture, out var code)) - { - return null; - } - sb.Append((char)code); - } - return sb.ToString(); - } } } diff --git a/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs b/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs index 3192158..a057610 100644 --- a/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs +++ b/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs @@ -2,11 +2,10 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using System; -using System.Globalization; -using System.Text; using System.Threading.Tasks; using Yh.Platform.Abstractions; using Yh.Platform.Auth; +using YhCore.WebApi.Auth; namespace YhCore.WebApi.Middleware { @@ -61,6 +60,20 @@ namespace YhCore.WebApi.Middleware { return; } + // 已登录直访带 ;redirectToReferer= 分号段的地址(无 ticket,认证 handler 未接管, + // PathMatrixSegmentMiddleware 已把 hex 存入 Items 并剥离路由路径):302 到解码地址, + // 对齐 Java CasSsoRefererRedirectFilter「识别分号段即跳回前端页」语义,覆盖刷新/收藏场景。 + if (!hasTicket + && context.Items.TryGetValue(YhCasDefaults.RefererHexItemKey, out var hexObj) + && hexObj is string hex && hex.Length > 0) + { + var target = CasHex.Decode(hex); + if (CasHex.IsAllowedRedirectTarget(target, CasHex.HostOf(sso["AppBaseUrl"]))) + { + context.Response.Redirect(target); + return; + } + } await _next(context); return; } @@ -134,11 +147,25 @@ namespace YhCore.WebApi.Middleware // - 回调路径直访防呆(访问的恰是回调端点自身且无 ticket 未认证、无打标):回 "/", // 避免登录后回跳回调自身再触发 Challenge(前端打标场景仍回前端页——回调路径本身常是前端 // 登录检测接口 /api/base/GetUserInfo,必须回到用户所在页面)。 + // 回跳目标决策(对齐 Java notice 的 service 语义差异),优先级: + // 1. Items 中的分号段 hex(直访/重登录带 ;redirectToReferer 的地址):解码后作回跳目标, + // 重新登录后回到原意页面而非接口本身; + // 2. yschreferer 头(前端打标的页面地址,hex 解码); + // 3. Referer 头明文; + // 4. 无显式回跳语境:回调路径直访防呆回 "/",其余 null(handler 以纯净原访问 URL 作 service, + // 登录后落回原地址)。 var hasYschReferer = !string.IsNullOrWhiteSpace(request.Headers["yschreferer"].ToString()); var hasReferer = !string.IsNullOrWhiteSpace(request.Headers["Referer"].ToString()); var appBasePath = ExtractBasePath(sso["AppBaseUrl"]); string returnTarget; - if (!hasYschReferer && !hasReferer) + string itemsHex = context.Items.TryGetValue(YhCasDefaults.RefererHexItemKey, out var ih) && ih is string ihs + ? CasHex.Decode(ihs) + : null; + if (!string.IsNullOrEmpty(itemsHex)) + { + returnTarget = itemsHex; + } + else if (!hasYschReferer && !hasReferer) { returnTarget = isCasCallback ? "/" : null; } @@ -275,7 +302,7 @@ namespace YhCore.WebApi.Middleware var ysch = request.Headers["yschreferer"].ToString(); if (!string.IsNullOrWhiteSpace(ysch)) { - var decoded = HexToString(ysch.Trim()); + var decoded = CasHex.Decode(ysch.Trim()); if (!string.IsNullOrEmpty(decoded)) { return decoded; @@ -314,28 +341,5 @@ namespace YhCore.WebApi.Middleware var path = uri.AbsolutePath; return path.Length == 0 ? "/" : path; } - - /// - /// 十六进制 → 字符串(char 维度,与前端 strToHexCharCode / Java HexUtils.hexToString 一致: - /// 逐字符取码点,每两位 hex 还原一个字符)。长度为奇数或含非法 hex 时返回 null。 - /// - private static string HexToString(string hex) - { - if (string.IsNullOrEmpty(hex) || hex.Length % 2 != 0) - { - return null; - } - var sb = new StringBuilder(hex.Length / 2); - for (var i = 0; i < hex.Length; i += 2) - { - if (!int.TryParse(hex.Substring(i, 2), NumberStyles.HexNumber, - CultureInfo.InvariantCulture, out var code)) - { - return null; - } - sb.Append((char)code); - } - return sb.ToString(); - } } } \ No newline at end of file