1
build / build-push (push) Successful in 1m15s

This commit is contained in:
wanghu
2026-09-04 10:38:41 +08:00
parent 9fe45c810e
commit 2ea71d90e1
2 changed files with 107 additions and 28 deletions
@@ -45,7 +45,8 @@ namespace YhCore.WebApi.Auth
/// <summary>
/// 构造 service 回调地址:{root}{SignInCallbackPath}[?returnUrl=&lt;urlencoded(returnUrl)&gt;]。
/// 回跳地址只允许本地相对路径(/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;
}
/// <summary>取回调 query 中的 returnUrl(仅本地相对路径,非法返回 null)。</summary>
/// <summary>取回调 query 中的 returnUrl(本地相对路径或同源完整 http(s) URL,非法返回 null)。</summary>
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;
}
/// <summary>service 根地址:优先 AppBaseUrl 配置,为空时从当前请求推断。</summary>
@@ -163,9 +164,9 @@ namespace YhCore.WebApi.Auth
Context.User = principal;
// 回跳原地址:优先 query 的 returnUrl(当前形态),兼容旧 ;redirectToReferer=<hex> 回调。
// 仅限本地相对路径,防开放重定向;非法回根路径
// 允许本地相对路径或同源完整 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));
}
/// <summary>UTF-8 字节 → 小写十六进制(对齐平台旧版登录 URL 的 redirectToReferer 编码)。</summary>
/// <summary>字符串 → 小写十六进制(char 维度,与前端 strToHexCharCode / Java HexUtils.stringToHex 一致:
/// 逐字符取码点转两位 hex;ASCII 范围内结果与 UTF-8 字节编码相同)。</summary>
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();
}
/// <summary>小写/大写十六进制 → UTF-8 字符串;长度为奇数或含非十六进制字符时返回 null。</summary>
/// <summary>小写/大写十六进制 → 字符串(char 维度解码,对齐前端 yschreferer / Java hexToString);
/// 长度为奇数或含非十六进制字符时返回 null。</summary>
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();
}
/// <summary>仅放行本地相对路径(/xxx);拒绝外站绝对地址(http(s)://…)、协议相对地址(//host)、反斜杠变体。</summary>
private static bool IsLocalUrl(string url)
/// <summary>
/// 判定回跳目标是否允许:
/// 1. 本地相对路径(/xxx 开头):拒绝协议相对地址(//host)与反斜杠变体(/\…)——原有安全口径;
/// 2. 完整 http(s) URL:host 必须与 service 根 host(AppBaseUrl 或当前请求)一致——供前端页完整
/// 地址(yschreferer 携带,含 #/ 路由片段)作回跳目标,杜绝开放重定向到任意外站。
/// 其余情况(null/空、非 http(s) 协议、外站 host)一律拒绝。
/// </summary>
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);
}
/// <summary>service 根地址的 host(回跳白名单基准):AppBaseUrl 未配置时取当前请求 host。</summary>
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;
}
}
}
@@ -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
}
/// <summary>
/// 构造登录后回跳原地址(本地相对路径,含 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 与登录后回跳时统一把关。
/// </summary>
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;
}
/// <summary>
/// 十六进制 → 字符串(char 维度,与前端 strToHexCharCode / Java HexUtils.hexToString 一致:
/// 逐字符取码点,每两位 hex 还原一个字符)。长度为奇数或含非法 hex 时返回 null。
/// </summary>
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();
}
}
}