From 2ea71d90e13bbbe4b867ee2f05f575259de98538 Mon Sep 17 00:00:00 2001 From: wanghu Date: Fri, 4 Sep 2026 10:38:41 +0800 Subject: [PATCH] 1 --- .../Auth/Cas/CasAuthenticationHandler.cs | 78 +++++++++++++------ .../Middleware/SsoProtectionMiddleware.cs | 57 ++++++++++++-- 2 files changed, 107 insertions(+), 28 deletions(-) diff --git a/src/YhCore.WebApi/Auth/Cas/CasAuthenticationHandler.cs b/src/YhCore.WebApi/Auth/Cas/CasAuthenticationHandler.cs index 7448942..7941279 100644 --- a/src/YhCore.WebApi/Auth/Cas/CasAuthenticationHandler.cs +++ b/src/YhCore.WebApi/Auth/Cas/CasAuthenticationHandler.cs @@ -45,7 +45,8 @@ namespace YhCore.WebApi.Auth /// /// 构造 service 回调地址:{root}{SignInCallbackPath}[?returnUrl=<urlencoded(returnUrl)>]。 - /// 回跳地址只允许本地相对路径(/xxx),杜绝开放重定向。 + /// 回跳地址允许两种:本地相对路径(/xxx),或与 service 根 host 同源的完整 http(s) URL + /// (前端页完整地址,yschreferer 携带,含 #/ 路由片段——对齐 Java notice 语义),杜绝开放重定向。 /// 注意:回跳地址走 query 而非路径矩阵参数——ASP.NET Core 不识别矩阵参数,分号会破坏 /// 路由匹配与 StartsWithSegments 的段比较(Java Spring 因 UrlPathHelper 默认 /// removeSemicolonContent=true 剥离分号内容才可用矩阵参数,.NET 无此机制)。 @@ -53,7 +54,7 @@ namespace YhCore.WebApi.Auth private string BuildServiceUrl(string returnUrl) { var callback = BuildCallbackRoot() + Options.SignInCallbackPath; - if (string.IsNullOrEmpty(returnUrl) || !IsLocalUrl(returnUrl)) + if (string.IsNullOrEmpty(returnUrl) || !IsAllowedReturnTarget(returnUrl, AllowedHost())) { return callback; } @@ -75,7 +76,7 @@ namespace YhCore.WebApi.Auth return query.Count > 0 ? service + "?" + string.Join("&", query) : service; } - /// 取回调 query 中的 returnUrl(仅本地相对路径,非法返回 null)。 + /// 取回调 query 中的 returnUrl(本地相对路径或同源完整 http(s) URL,非法返回 null)。 private string ResolveReturnUrl() { if (!Context.Request.Query.TryGetValue(ReturnUrlParam, out var value)) @@ -83,7 +84,7 @@ namespace YhCore.WebApi.Auth return null; } var url = value.ToString(); - return IsLocalUrl(url) ? url : null; + return IsAllowedReturnTarget(url, AllowedHost()) ? url : null; } /// service 根地址:优先 AppBaseUrl 配置,为空时从当前请求推断。 @@ -163,9 +164,9 @@ namespace YhCore.WebApi.Auth Context.User = principal; // 回跳原地址:优先 query 的 returnUrl(当前形态),兼容旧 ;redirectToReferer= 回调。 - // 仅限本地相对路径,防开放重定向;非法回根路径 + // 允许本地相对路径或同源完整 http(s) URL;非法(外站 host / 协议相对等)回根路径,防开放重定向。 var referer = ResolveReturnUrl() ?? ParseRedirectToReferer(Context.Request.Path.Value); - var redirect = referer != null && IsLocalUrl(referer) ? referer : "/"; + var redirect = IsAllowedReturnTarget(referer, AllowedHost()) ? referer : "/"; Response.Redirect(redirect); return AuthenticateResult.NoResult(); } @@ -185,47 +186,78 @@ namespace YhCore.WebApi.Auth return HexDecode(path.Substring(marker + RedirectToRefererMarker.Length)); } - /// UTF-8 字节 → 小写十六进制(对齐平台旧版登录 URL 的 redirectToReferer 编码)。 + /// 字符串 → 小写十六进制(char 维度,与前端 strToHexCharCode / Java HexUtils.stringToHex 一致: + /// 逐字符取码点转两位 hex;ASCII 范围内结果与 UTF-8 字节编码相同)。 private static string HexEncode(string value) { - var bytes = Encoding.UTF8.GetBytes(value); - var sb = new StringBuilder(bytes.Length * 2); - foreach (var b in bytes) + var sb = new StringBuilder(); + foreach (var ch in value ?? string.Empty) { - sb.Append(b.ToString("x2")); + sb.Append(((int)ch).ToString("x2")); } return sb.ToString(); } - /// 小写/大写十六进制 → UTF-8 字符串;长度为奇数或含非十六进制字符时返回 null。 + /// 小写/大写十六进制 → 字符串(char 维度解码,对齐前端 yschreferer / Java hexToString); + /// 长度为奇数或含非十六进制字符时返回 null。 private static string HexDecode(string hex) { if (string.IsNullOrEmpty(hex) || hex.Length % 2 != 0) { return null; } - var bytes = new byte[hex.Length / 2]; - for (var i = 0; i < bytes.Length; i++) + var sb = new StringBuilder(hex.Length / 2); + for (var i = 0; i < hex.Length; i += 2) { - if (!byte.TryParse(hex.Substring(i * 2, 2), NumberStyles.HexNumber, - CultureInfo.InvariantCulture, out var b)) + if (!int.TryParse(hex.Substring(i, 2), NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out var code)) { return null; } - bytes[i] = b; + sb.Append((char)code); } - return Encoding.UTF8.GetString(bytes); + return sb.ToString(); } - /// 仅放行本地相对路径(/xxx);拒绝外站绝对地址(http(s)://…)、协议相对地址(//host)、反斜杠变体。 - private static bool IsLocalUrl(string url) + /// + /// 判定回跳目标是否允许: + /// 1. 本地相对路径(/xxx 开头):拒绝协议相对地址(//host)与反斜杠变体(/\…)——原有安全口径; + /// 2. 完整 http(s) URL:host 必须与 service 根 host(AppBaseUrl 或当前请求)一致——供前端页完整 + /// 地址(yschreferer 携带,含 #/ 路由片段)作回跳目标,杜绝开放重定向到任意外站。 + /// 其余情况(null/空、非 http(s) 协议、外站 host)一律拒绝。 + /// + private static bool IsAllowedReturnTarget(string url, string allowedHost) { - if (string.IsNullOrEmpty(url) || url[0] != '/') + if (string.IsNullOrEmpty(url)) { return false; } - return !url.StartsWith("//", StringComparison.Ordinal) - && !url.StartsWith("/\\", StringComparison.Ordinal); + 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); + } + + /// service 根地址的 host(回跳白名单基准):AppBaseUrl 未配置时取当前请求 host。 + private string AllowedHost() + { + var root = Options.AppBaseUrl; + if (string.IsNullOrEmpty(root)) + { + root = Context.Request.Scheme + "://" + Context.Request.Host.Value; + } + return Uri.TryCreate(root, UriKind.Absolute, out var uri) ? uri.Host : null; } } } diff --git a/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs b/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs index 2663ccd..c1a2d61 100644 --- a/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs +++ b/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs @@ -2,6 +2,8 @@ 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; @@ -113,11 +115,15 @@ namespace YhCore.WebApi.Middleware // 只有受保护 API 路径的 AJAX 请求需要改写为 JSON;根路径 / 回调是文档导航语义,始终 302。 var isApiAjax = IsProtectedPath(request.Path.Value, sso) && IsAjaxRequest(request); + // 回调路径直访(无 ticket 未认证)默认回根路径,避免登录后回跳回调自身再次触发 Challenge 死循环; + // 但若为前端打标请求(yschreferer 携带当前页地址,参照 notice 语义)则仍回前端页——回调路径 + // 本身就是前端登录检测接口(/api/base/GetUserInfo),这类场景必须回到用户所在页面而非根路径。 + var hasYschReferer = !string.IsNullOrWhiteSpace(request.Headers["yschreferer"].ToString()); + var returnTarget = isCasCallback && !hasYschReferer ? "/" : BuildReturnUrl(request); var properties = new AuthenticationProperties { - // 登录后回跳原地址;回调路径回跳根路径(避免登录后回跳回调自身再次触发 Challenge)。 - RedirectUri = isCasCallback ? "/" : BuildReturnUrl(request) + RedirectUri = returnTarget }; await context.ChallengeAsync(YhAuthDefaults.CasScheme, properties); @@ -230,15 +236,56 @@ namespace YhCore.WebApi.Middleware } /// - /// 构造登录后回跳原地址(本地相对路径,含 query,不含 ticket)。 - /// 与框架 CasAuthenticationHandler 的 returnUrl 语义一致:必须是 / 开头的本地路径, - /// 否则框架 BuildServiceUrl 的 IsLocalUrl 校验会拒绝并回退到纯回调地址(防开放重定向)。 + /// 构造登录后回跳目标(消费前端 yschreferer 头,对齐 Java notice 语义): + /// 1. yschreferer 头 —— 前端 strToHexCharCode(当前页完整 URL,含 #/ 路由片段) 的 hex, + /// char 维度解码即前端页地址,登录成功后应回到该页(参照 notice 行为,而非接口本身); + /// 2. Referer 头明文(完整 http(s) URL;注意浏览器发送 Referer 不含 fragment); + /// 3. 兜底当前请求的本地相对路径(含 query,不含 ticket)。 + /// 返回的可能是完整 URL 或相对路径;合法性(本地路径 / 与 service 同 host 的 http(s) URL) + /// 由 CasAuthenticationHandler.IsAllowedReturnTarget 在构造 service 与登录后回跳时统一把关。 /// private static string BuildReturnUrl(HttpRequest request) { + var ysch = request.Headers["yschreferer"].ToString(); + if (!string.IsNullOrWhiteSpace(ysch)) + { + var decoded = HexToString(ysch.Trim()); + if (!string.IsNullOrEmpty(decoded)) + { + return decoded; + } + } + var referer = request.Headers["Referer"].ToString(); + if (!string.IsNullOrWhiteSpace(referer)) + { + return referer.Trim(); + } var path = request.Path.Value ?? "/"; var query = request.QueryString.Value; return string.IsNullOrEmpty(query) ? path : path + query; } + + /// + /// 十六进制 → 字符串(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