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>
public class CasAuthenticationHandler : AuthenticationHandler<CasAuthenticationOptions>
{
private const string RedirectToRefererMarker = ";redirectToReferer=";
/// <summary>旧中继形态(service={回调端点}?returnUrl=…)的回跳参数——已弃用,仅兼容历史 URL/缓存。</summary>
private const string ReturnUrlParam = "returnUrl";
@@ -60,20 +58,44 @@ namespace YhCore.WebApi.Auth
/// 按当前请求重建登录时的 service:{root}{path}[?{除 ticket 外的 query}],即原访问 URL(notice 形态)。
/// CAS 回调会在原 service 后追加 ?ticket=(已有 query 时 &amp;ticket=),校验时必须还原为登录时的
/// 原始 service,故剥掉 ticket;其余参数按 EscapeDataString 重新编码,与 Challenge 时构造保持一致。
/// 本方法是 Challenge 与 Authenticate 两侧共用的 service 基准:Challenge 侧可能再追加
/// redirectToReferer=&lt;hex&gt;(显式回跳语境);{root} 取自 AppBaseUrl(含 /docprint 等模块前缀),
/// {path} 为 Kestrel 收到的接口路径,拼接结果天然带模块名,不受反向代理剥离前缀影响。
/// path 取 ResolveOriginalPath():分号段被 PathMatrixSegmentMiddleware 从 Request.Path 剥离后,
/// 重建必须用 Items 保存的原始路径(含 ;redirectToReferer=&lt;hex&gt; 段),与登录 service 逐字节一致;
/// {root} 取自 AppBaseUrl(含 /docprint 等模块前缀),拼接结果天然带模块名,不受反向代理剥离前缀影响。
/// Challenge 侧构造新 service 用当前(已剥离的)路由路径,目标以分号段另行追加,见 HandleChallengeAsync。
/// </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
.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;
}
/// <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/缓存;
/// 本地相对路径或同源完整 http(s) URL,非法返回 null)。</summary>
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;
}
/// <summary>读回调 query 的 redirectToReferer(当前形态:service=原访问 URL 时由 CAS 原样回带的
@@ -94,7 +116,7 @@ namespace YhCore.WebApi.Auth
{
return null;
}
return HexDecode(value.ToString());
return CasHex.Decode(value.ToString());
}
/// <summary>service 根地址:优先 AppBaseUrl 配置,为空时从当前请求推断。</summary>
@@ -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=<hex>[?{原 query}]——CAS 会把它作为
// service 一部分在回调时原样带回,本 handler 校验成功后由 ParseRedirectToReferer 从路径段
// hex 解码即得回跳目标(query 形态 ?redirectToReferer=<hex> 的旧 URL/缓存仍兼容读取)。
// hex 按 char 维度编码(对齐前端 strToHexCharCode / Java HexUtils);分号段内不参与 query 结构,
// 含中文/特殊字符的页面地址也可无歧义承载。
service = BuildServiceUrlFromRequest();
// service 一部分在回调时原样带回,回调侧 PathMatrixSegmentMiddleware 剥离分号段后由
// ResolveRedirectToReferer 从 Items 的 hex 解码即得回跳目标(query 形态 ?redirectToReferer=<hex>
// 的旧 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<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);
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);
}
/// <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>
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));
}
/// <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)
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();
}
/// <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;
return CasHex.Decode(hex);
}
/// <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>
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>