@@ -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 时 &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。
|
||||
/// </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>
|
||||
|
||||
Reference in New Issue
Block a user