1
build / build-push (push) Successful in 2m3s

This commit is contained in:
wanghu
2026-09-04 13:36:57 +08:00
parent b91fd1b514
commit a73fb4a214
5 changed files with 245 additions and 227 deletions
@@ -34,8 +34,6 @@ namespace YhCore.WebApi.Auth
/// </summary> /// </summary>
public class CasAuthenticationHandler : AuthenticationHandler<CasAuthenticationOptions> public class CasAuthenticationHandler : AuthenticationHandler<CasAuthenticationOptions>
{ {
private const string RedirectToRefererMarker = ";redirectToReferer=";
/// <summary>旧中继形态(service={回调端点}?returnUrl=…)的回跳参数——已弃用,仅兼容历史 URL/缓存。</summary> /// <summary>旧中继形态(service={回调端点}?returnUrl=…)的回跳参数——已弃用,仅兼容历史 URL/缓存。</summary>
private const string ReturnUrlParam = "returnUrl"; private const string ReturnUrlParam = "returnUrl";
@@ -60,20 +58,44 @@ namespace YhCore.WebApi.Auth
/// 按当前请求重建登录时的 service:{root}{path}[?{除 ticket 外的 query}],即原访问 URL(notice 形态)。 /// 按当前请求重建登录时的 service:{root}{path}[?{除 ticket 外的 query}],即原访问 URL(notice 形态)。
/// CAS 回调会在原 service 后追加 ?ticket=(已有 query 时 &amp;ticket=),校验时必须还原为登录时的 /// CAS 回调会在原 service 后追加 ?ticket=(已有 query 时 &amp;ticket=),校验时必须还原为登录时的
/// 原始 service,故剥掉 ticket;其余参数按 EscapeDataString 重新编码,与 Challenge 时构造保持一致。 /// 原始 service,故剥掉 ticket;其余参数按 EscapeDataString 重新编码,与 Challenge 时构造保持一致。
/// 本方法是 Challenge 与 Authenticate 两侧共用的 service 基准:Challenge 侧可能再追加 /// path 取 ResolveOriginalPath():分号段被 PathMatrixSegmentMiddleware 从 Request.Path 剥离后,
/// redirectToReferer=&lt;hex&gt;(显式回跳语境);{root} 取自 AppBaseUrl(含 /docprint 等模块前缀), /// 重建必须用 Items 保存的原始路径(含 ;redirectToReferer=&lt;hex&gt; 段),与登录 service 逐字节一致;
/// {path} 为 Kestrel 收到的接口路径,拼接结果天然带模块名,不受反向代理剥离前缀影响。 /// {root} 取自 AppBaseUrl(含 /docprint 等模块前缀),拼接结果天然带模块名,不受反向代理剥离前缀影响。
/// Challenge 侧构造新 service 用当前(已剥离的)路由路径,目标以分号段另行追加,见 HandleChallengeAsync。
/// </summary> /// </summary>
private string BuildServiceUrlFromRequest() private string BuildServiceUrlFromOriginalPath()
{
return BuildServiceUrl(ResolveOriginalPath());
}
/// <summary>Challenge 侧 service 基准:当前(已剥离分号段的)路由路径 + 除 ticket 外 query。</summary>
private string BuildServiceUrlFromCurrentPath()
{
return BuildServiceUrl(Context.Request.Path.Value);
}
private string BuildServiceUrl(string path)
{ {
var query = Context.Request.Query var query = Context.Request.Query
.Where(kv => !string.Equals(kv.Key, "ticket", StringComparison.OrdinalIgnoreCase)) .Where(kv => !string.Equals(kv.Key, "ticket", StringComparison.OrdinalIgnoreCase))
.Select(kv => Uri.EscapeDataString(kv.Key) + "=" + Uri.EscapeDataString(kv.Value.ToString())) .Select(kv => Uri.EscapeDataString(kv.Key) + "=" + Uri.EscapeDataString(kv.Value.ToString()))
.ToList(); .ToList();
var service = BuildCallbackRoot() + Context.Request.Path.Value; var service = BuildCallbackRoot() + path;
return query.Count > 0 ? service + "?" + string.Join("&", query) : service; return query.Count > 0 ? service + "?" + string.Join("&", query) : service;
} }
/// <summary>原始完整路径(含 ;redirectToReferer= 分号段):优先 PathMatrixSegmentMiddleware 存入
/// Items 的值,未经过该中间件时回退当前请求路径。</summary>
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 ?? "/";
}
/// <summary>取回调 query 中的 returnUrl(旧中继形态兼容:service={回调端点}?returnUrl=… 的历史 URL/缓存; /// <summary>取回调 query 中的 returnUrl(旧中继形态兼容:service={回调端点}?returnUrl=… 的历史 URL/缓存;
/// 本地相对路径或同源完整 http(s) URL,非法返回 null)。</summary> /// 本地相对路径或同源完整 http(s) URL,非法返回 null)。</summary>
private string ResolveReturnUrl() private string ResolveReturnUrl()
@@ -83,7 +105,7 @@ namespace YhCore.WebApi.Auth
return null; return null;
} }
var url = value.ToString(); var url = value.ToString();
return IsAllowedReturnTarget(url, AllowedHost()) ? url : null; return CasHex.IsAllowedRedirectTarget(url, AllowedHost()) ? url : null;
} }
/// <summary>读回调 query 的 redirectToReferer(当前形态:service=原访问 URL 时由 CAS 原样回带的 /// <summary>读回调 query 的 redirectToReferer(当前形态:service=原访问 URL 时由 CAS 原样回带的
@@ -94,7 +116,7 @@ namespace YhCore.WebApi.Auth
{ {
return null; return null;
} }
return HexDecode(value.ToString()); return CasHex.Decode(value.ToString());
} }
/// <summary>service 根地址:优先 AppBaseUrl 配置,为空时从当前请求推断。</summary> /// <summary>service 根地址:优先 AppBaseUrl 配置,为空时从当前请求推断。</summary>
@@ -127,20 +149,20 @@ namespace YhCore.WebApi.Auth
// AppBaseUrl 带模块前缀(如 http://host/docprint)时,拼接结果天然含模块名, // AppBaseUrl 带模块前缀(如 http://host/docprint)时,拼接结果天然含模块名,
// 不受反向代理剥离前缀影响;登录成功后 CAS 回跳该 URL + ticket,由本 handler 校验并 // 不受反向代理剥离前缀影响;登录成功后 CAS 回跳该 URL + ticket,由本 handler 校验并
// 302 去掉 ticket 落回原地址。 // 302 去掉 ticket 落回原地址。
service = BuildServiceUrlFromRequest(); service = BuildServiceUrlFromCurrentPath();
} }
else else
{ {
// 显式回跳语境(前端 yschreferer 携带页面地址等):service 仍 = 原访问 URL(notice 形态, // 显式回跳语境(前端 yschreferer 携带页面地址等):service 仍 = 原访问 URL(notice 形态,
// 不再以 {SignInCallbackPath} 回调端点作 service),把回跳地址以 Java notice 同款的分号矩阵段 // 不再以 {SignInCallbackPath} 回调端点作 service),把回跳地址以 Java notice 同款的分号矩阵段
// 形式插入 path 与 query 之间:{path};redirectToReferer=<hex>[?{原 query}]——CAS 会把它作为 // 形式插入 path 与 query 之间:{path};redirectToReferer=<hex>[?{原 query}]——CAS 会把它作为
// service 一部分在回调时原样带回,本 handler 校验成功后由 ParseRedirectToReferer 从路径段 // service 一部分在回调时原样带回,回调侧 PathMatrixSegmentMiddleware 剥离分号段后由
// hex 解码即得回跳目标(query 形态 ?redirectToReferer=<hex> 的旧 URL/缓存仍兼容读取)。 // ResolveRedirectToReferer 从 Items 的 hex 解码即得回跳目标(query 形态 ?redirectToReferer=<hex>
// hex 按 char 维度编码(对齐前端 strToHexCharCode / Java HexUtils);分号段内不参与 query 结构, // 的旧 URL/缓存仍兼容读取)。hex 按 char 维度编码(对齐前端 strToHexCharCode / Java HexUtils);
// 含中文/特殊字符的页面地址也可无歧义承载。 // 分号段内不参与 query 结构,含中文/特殊字符的页面地址也可无歧义承载。
service = BuildServiceUrlFromRequest(); service = BuildServiceUrlFromCurrentPath();
var q = service.IndexOf('?'); var q = service.IndexOf('?');
var segment = RedirectToRefererMarker + HexEncode(target); var segment = YhCasDefaults.RedirectToRefererMarker + CasHex.Encode(target);
service = q < 0 service = q < 0
? service + segment ? service + segment
: service.Substring(0, q) + segment + service.Substring(q); : service.Substring(0, q) + segment + service.Substring(q);
@@ -154,10 +176,22 @@ namespace YhCore.WebApi.Auth
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() protected override async Task<AuthenticateResult> 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); var cookieResult = await Context.AuthenticateAsync(YhCasDefaults.CookieScheme);
if (cookieResult.Succeeded && cookieResult.Principal != null) 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!); return AuthenticateResult.Success(cookieResult.Ticket!);
} }
@@ -169,7 +203,8 @@ namespace YhCore.WebApi.Auth
} }
// service 必须与登录时完全一致:CAS 是在登录 service 后追加 ?ticket=(已有 query 时 &ticket=)回调。 // service 必须与登录时完全一致:CAS 是在登录 service 后追加 ?ticket=(已有 query 时 &ticket=)回调。
var service = BuildServiceUrlFromRequest(); // 分号段已被 PathMatrixSegmentMiddleware 从 Request.Path 剥离,须以 Items 保存的原始路径重建。
var service = BuildServiceUrlFromOriginalPath();
string loginName; string loginName;
try try
{ {
@@ -210,13 +245,13 @@ namespace YhCore.WebApi.Auth
// 允许本地相对路径或同源完整 http(s) URL;非法(外站 host / 协议相对等)回根路径,防开放重定向。 // 允许本地相对路径或同源完整 http(s) URL;非法(外站 host / 协议相对等)回根路径,防开放重定向。
// 显式回跳目标(1/2/3)白名单通过时透传一次性票据:目标页按联调约定可凭 ticket 自证登录成功—— // 显式回跳目标(1/2/3)白名单通过时透传一次性票据:目标页按联调约定可凭 ticket 自证登录成功——
// 注意票据已被本次 /serviceValidate 消费(一次性),目标侧不得再拿去 CAS 校验,仅作登录完成信号。 // 注意票据已被本次 /serviceValidate 消费(一次性),目标侧不得再拿去 CAS 校验,仅作登录完成信号。
var referer = ParseRedirectToReferer(Context.Request.Path.Value) var referer = ResolveRedirectToReferer()
?? ResolveQueryRedirectToReferer() ?? ResolveQueryRedirectToReferer()
?? ResolveReturnUrl(); ?? ResolveReturnUrl();
string redirect; string redirect;
if (referer != null) if (referer != null)
{ {
redirect = IsAllowedReturnTarget(referer, AllowedHost()) ? referer : "/"; redirect = CasHex.IsAllowedRedirectTarget(referer, AllowedHost()) ? referer : "/";
if (redirect == referer) if (redirect == referer)
{ {
redirect = AppendTicket(redirect, ticketValue); redirect = AppendTicket(redirect, ticketValue);
@@ -224,8 +259,8 @@ namespace YhCore.WebApi.Auth
} }
else else
{ {
redirect = BuildServiceUrlFromRequest(); redirect = BuildServiceUrlFromOriginalPath();
redirect = IsAllowedReturnTarget(redirect, AllowedHost()) ? redirect : "/"; redirect = CasHex.IsAllowedRedirectTarget(redirect, AllowedHost()) ? redirect : "/";
} }
Response.Redirect(redirect); Response.Redirect(redirect);
return AuthenticateResult.NoResult(); return AuthenticateResult.NoResult();
@@ -242,6 +277,21 @@ namespace YhCore.WebApi.Auth
+ "ticket=" + Uri.EscapeDataString(ticket); + "ticket=" + Uri.EscapeDataString(ticket);
} }
/// <summary>
/// 解析 ;redirectToReferer= 分号段的回跳目标:优先 PathMatrixSegmentMiddleware 存入 Items 的
/// hex(分号段已从路由路径剥离,回调重建 service 仍用原始路径);未经过该中间件时回退解析当前
/// 路径。无该段或 hex 非法返回 null。
/// </summary>
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);
}
/// <summary>从回调路径中解析 ;redirectToReferer= 后的 hex 并解码;无该段或编码非法返回 null。</summary> /// <summary>从回调路径中解析 ;redirectToReferer= 后的 hex 并解码;无该段或编码非法返回 null。</summary>
private static string ParseRedirectToReferer(string path) private static string ParseRedirectToReferer(string path)
{ {
@@ -249,88 +299,18 @@ namespace YhCore.WebApi.Auth
{ {
return null; return null;
} }
var marker = path.IndexOf(RedirectToRefererMarker, StringComparison.OrdinalIgnoreCase); var marker = path.IndexOf(YhCasDefaults.RedirectToRefererMarker, StringComparison.OrdinalIgnoreCase);
if (marker < 0) if (marker < 0)
{ {
return null; return null;
} }
return HexDecode(path.Substring(marker + RedirectToRefererMarker.Length)); var hex = path.Substring(marker + YhCasDefaults.RedirectToRefererMarker.Length);
} var term = hex.IndexOfAny(new[] { ';', '?', '#' });
if (term >= 0)
/// <summary>字符串 → 小写十六进制(char 维度,与前端 strToHexCharCode / Java HexUtils.stringToHex 一致:
/// 逐字符取码点转两位 hex;ASCII 范围内结果与 UTF-8 字节编码相同)。</summary>
private static string HexEncode(string value)
{
var sb = new StringBuilder();
foreach (var ch in value ?? string.Empty)
{ {
sb.Append(((int)ch).ToString("x2")); hex = hex.Substring(0, term);
} }
return sb.ToString(); return CasHex.Decode(hex);
}
/// <summary>小写/大写十六进制 → 字符串(char 维度解码,对齐前端 yschreferer / Java hexToString);
/// 长度为奇数或含非十六进制字符时返回 null。</summary>
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();
}
/// <summary>
/// 判定回跳目标是否允许:
/// 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)。
/// </summary>
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;
} }
/// <summary>service 根地址的 host(回跳白名单基准):AppBaseUrl 未配置时取当前请求 host。</summary> /// <summary>service 根地址的 host(回跳白名单基准):AppBaseUrl 未配置时取当前请求 host。</summary>
+100
View File
@@ -0,0 +1,100 @@
using System;
using System.Globalization;
using System.Text;
namespace YhCore.WebApi.Auth
{
/// <summary>
/// redirectToReferer 的十六进制编解码与回跳目标白名单(收敛 handler / PathMatrixSegmentMiddleware /
/// SsoProtectionMiddleware 三处同口径逻辑的公共实现)。
/// 编解码对齐 Java 平台 HexUtils 与前端 strToHexCharCode:按 char(Unicode 码点)维度,
/// 每字符至少两位小写十六进制(ASCII 范围内与 UTF-8 字节编码结果相同)。
/// </summary>
public static class CasHex
{
/// <summary>字符串 → 十六进制(char 维度、小写)。</summary>
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();
}
/// <summary>十六进制 → 字符串(每两位还原一个 char;奇数长度或含非十六进制字符返回 null)。
/// 逐字符校验 Uri.IsHexDigit,比 int.TryParse(NumberStyles.HexNumber) 严格——后者会放行
/// 前导空白/符号,与 Java Integer.parseInt(hex, 16) 的非法输入语义不一致。</summary>
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();
}
/// <summary>
/// 回跳目标是否允许(统一白名单口径):
/// - 拒绝含控制字符(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 限制。
/// </summary>
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;
}
/// <summary>从 AppBaseUrl(如 http://host/docprint)提取 host 作为回跳白名单基准;非法返回 null。</summary>
public static string HostOf(string appBaseUrl)
{
if (string.IsNullOrWhiteSpace(appBaseUrl) || !Uri.TryCreate(appBaseUrl, UriKind.Absolute, out var uri))
{
return null;
}
return uri.Host;
}
}
}
@@ -10,6 +10,15 @@ namespace YhCore.WebApi.Auth
/// <summary>CAS 认证方案(challenge 时重定向 CAS 登录页)</summary> /// <summary>CAS 认证方案(challenge 时重定向 CAS 登录页)</summary>
public const string CasScheme = "YhCas"; public const string CasScheme = "YhCas";
/// <summary>;redirectToReferer= 段标记(service 路径与 query 之间携带 hex 回跳地址,对齐 Java notice)</summary>
public const string RedirectToRefererMarker = ";redirectToReferer=";
/// <summary>HttpContext.Items 键:从路径分号段提取的回跳地址 hex(PathMatrixSegmentMiddleware 写入,handler/保护中间件消费)</summary>
public const string RefererHexItemKey = "CasRedirectToRefererHex";
/// <summary>HttpContext.Items 键:剥离分号段前的原始完整路径(serviceValidate 重建 service 必须与登录时逐字节一致)</summary>
public const string OriginalPathItemKey = "CasOriginalPath";
} }
/// <summary> /// <summary>
@@ -1,9 +1,7 @@
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using System; using System;
using System.Globalization;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using YhCore.WebApi.Auth;
namespace YhCore.WebApi.Middleware namespace YhCore.WebApi.Middleware
{ {
@@ -12,127 +10,54 @@ namespace YhCore.WebApi.Middleware
/// 对齐 Java notice 行为:Spring 路由前默认 UrlPathHelper.removeSemicolonContent=true 会剥离 /// 对齐 Java notice 行为:Spring 路由前默认 UrlPathHelper.removeSemicolonContent=true 会剥离
/// 分号内容(矩阵参数),ASP.NET Core 不做——带 ';' 的路径进入 UseRouting 会匹配不到任何 action /// 分号内容(矩阵参数),ASP.NET Core 不做——带 ';' 的路径进入 UseRouting 会匹配不到任何 action
/// 而 404。本中间件在路由前模拟该语义: /// 而 404。本中间件在路由前模拟该语义:
/// - 路径含 ;redirectToReferer=&lt;hex&gt;(service 分号矩阵段,CAS 登录引导时写入):按 notice /// <list type="bullet">
/// 语义解析 hex 并 302 到解码地址——覆盖"已登录刷新/直访带分号 URL"等 handler 未接管的场景, /// <item>路径含 ;redirectToReferer=&lt;hex&gt;:提取 hex 与原始路径存入 HttpContext.Items
/// 不再 404;hex 解码失败/目标非法则剥离分号段继续(杜绝开放重定向)。 /// (键 <see cref="YhCasDefaults.RefererHexItemKey"/> / <see cref="YhCasDefaults.OriginalPathItemKey"/>),
/// - 其余含 ';' 的路径:剥离首个 ';' 起的分号段后继续,正常路由/保护判定不再 404。 /// 再从 Request.Path 剥离该段。提取对带 ticket 的 CAS 回调同样生效(不再放行原始路径):
/// 例外:请求带 ticket(CAS 登录回跳在途)→ 原样放行,不在此 302/剥离——须先由认证链路校验 ticket、 /// serviceValidate 重建 service 由 CasAuthenticationHandler 改用 Items 中的原始路径,
/// 写入登录 Cookie(成功后由 CasAuthenticationHandler 302 + SsoProtectionMiddleware 短路); /// 与登录 service 逐字节一致;回跳目标(解码地址 + ?ticket=)由 handler / SsoProtectionMiddleware
/// 剥离会破坏 serviceValidate 重建一致性,提前 302 会跳过 Cookie 建立导致死循环。 /// 在认证完成后统一 302——已登录(Cookie 恢复)收到回调也跳目标页,不再落入 MVC 路由 404。</item>
/// <item>其余含 ';' 的路径:剥离首个 ';' 起的分号段后继续,正常路由/保护判定不再 404。</item>
/// </list>
/// </summary> /// </summary>
public class PathMatrixSegmentMiddleware public class PathMatrixSegmentMiddleware
{ {
private const string RedirectToRefererMarker = ";redirectToReferer=";
private readonly RequestDelegate _next; private readonly RequestDelegate _next;
private readonly IConfiguration _configuration;
public PathMatrixSegmentMiddleware(RequestDelegate next, IConfiguration configuration) public PathMatrixSegmentMiddleware(RequestDelegate next)
{ {
_next = next; _next = next;
_configuration = configuration;
} }
public async Task InvokeAsync(HttpContext context) public async Task InvokeAsync(HttpContext context)
{ {
var rawPath = context.Request.Path.Value ?? string.Empty; var rawPath = context.Request.Path.Value ?? string.Empty;
var semi = rawPath.IndexOf(';'); var marker = rawPath.IndexOf(YhCasDefaults.RedirectToRefererMarker, StringComparison.OrdinalIgnoreCase);
if (semi < 0)
{
await _next(context);
return;
}
// CAS 登录回跳在途(service 含分号段 + ticket):原样放行给认证链路。
if (context.Request.Query.ContainsKey("ticket"))
{
await _next(context);
return;
}
var marker = rawPath.IndexOf(RedirectToRefererMarker, StringComparison.OrdinalIgnoreCase);
if (marker >= 0) if (marker >= 0)
{ {
var hex = rawPath.Substring(marker + RedirectToRefererMarker.Length); // hex 终止于 ';'(后续矩阵段)、'?'、'#'(hash 路由防御)或串尾
var term = hex.IndexOfAny(new[] { ';', '?', '#' }); var start = marker + YhCasDefaults.RedirectToRefererMarker.Length;
if (term >= 0) var end = start;
while (end < rawPath.Length && rawPath[end] != ';' && rawPath[end] != '?' && rawPath[end] != '#')
{ {
hex = hex.Substring(0, term); end++;
} }
var target = HexToString(hex); context.Items[YhCasDefaults.RefererHexItemKey] = rawPath.Substring(start, end - start);
if (!string.IsNullOrEmpty(target) && IsAllowedMatrixTarget(target, _configuration["yh:sso:AppBaseUrl"])) 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); context.Request.Path = new PathString(rawPath.Substring(0, semi));
return;
} }
// 解码失败/非法目标:不跳转(防开放重定向),落入下方剥离分号段走正常流程。
} }
context.Request.Path = new PathString(rawPath.Substring(0, semi));
await _next(context); await _next(context);
} }
/// <summary>
/// 矩阵分号段回跳目标合法性(与 CasAuthenticationHandler.IsAllowedReturnTarget 同口径):
/// 拒绝含控制字符目标;本地相对路径(拒绝协议相对 // 与反斜杠变体);完整 http(s) URL 须与
/// AppBaseUrl host 一致或为本机 loopback(localhost/127.0.0.1/::1 联调);AppBaseUrl 未配置则不做
/// host 白名单。
/// </summary>
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;
}
/// <summary>十六进制 → 字符串(char 维度,每两位还原一个字符;奇数长/非法 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();
}
} }
} }
@@ -2,11 +2,10 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using System; using System;
using System.Globalization;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using Yh.Platform.Abstractions; using Yh.Platform.Abstractions;
using Yh.Platform.Auth; using Yh.Platform.Auth;
using YhCore.WebApi.Auth;
namespace YhCore.WebApi.Middleware namespace YhCore.WebApi.Middleware
{ {
@@ -61,6 +60,20 @@ namespace YhCore.WebApi.Middleware
{ {
return; return;
} }
// 已登录直访带 ;redirectToReferer=<hex> 分号段的地址(无 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); await _next(context);
return; return;
} }
@@ -134,11 +147,25 @@ namespace YhCore.WebApi.Middleware
// - 回调路径直访防呆(访问的恰是回调端点自身且无 ticket 未认证、无打标):回 "/", // - 回调路径直访防呆(访问的恰是回调端点自身且无 ticket 未认证、无打标):回 "/",
// 避免登录后回跳回调自身再触发 Challenge(前端打标场景仍回前端页——回调路径本身常是前端 // 避免登录后回跳回调自身再触发 Challenge(前端打标场景仍回前端页——回调路径本身常是前端
// 登录检测接口 /api/base/GetUserInfo,必须回到用户所在页面)。 // 登录检测接口 /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 hasYschReferer = !string.IsNullOrWhiteSpace(request.Headers["yschreferer"].ToString());
var hasReferer = !string.IsNullOrWhiteSpace(request.Headers["Referer"].ToString()); var hasReferer = !string.IsNullOrWhiteSpace(request.Headers["Referer"].ToString());
var appBasePath = ExtractBasePath(sso["AppBaseUrl"]); var appBasePath = ExtractBasePath(sso["AppBaseUrl"]);
string returnTarget; 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; returnTarget = isCasCallback ? "/" : null;
} }
@@ -275,7 +302,7 @@ namespace YhCore.WebApi.Middleware
var ysch = request.Headers["yschreferer"].ToString(); var ysch = request.Headers["yschreferer"].ToString();
if (!string.IsNullOrWhiteSpace(ysch)) if (!string.IsNullOrWhiteSpace(ysch))
{ {
var decoded = HexToString(ysch.Trim()); var decoded = CasHex.Decode(ysch.Trim());
if (!string.IsNullOrEmpty(decoded)) if (!string.IsNullOrEmpty(decoded))
{ {
return decoded; return decoded;
@@ -314,28 +341,5 @@ namespace YhCore.WebApi.Middleware
var path = uri.AbsolutePath; var path = uri.AbsolutePath;
return path.Length == 0 ? "/" : path; return path.Length == 0 ? "/" : path;
} }
/// <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();
}
} }
} }