@@ -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 时 &ticket=),校验时必须还原为登录时的
|
/// CAS 回调会在原 service 后追加 ?ticket=(已有 query 时 &ticket=),校验时必须还原为登录时的
|
||||||
/// 原始 service,故剥掉 ticket;其余参数按 EscapeDataString 重新编码,与 Challenge 时构造保持一致。
|
/// 原始 service,故剥掉 ticket;其余参数按 EscapeDataString 重新编码,与 Challenge 时构造保持一致。
|
||||||
/// 本方法是 Challenge 与 Authenticate 两侧共用的 service 基准:Challenge 侧可能再追加
|
/// path 取 ResolveOriginalPath():分号段被 PathMatrixSegmentMiddleware 从 Request.Path 剥离后,
|
||||||
/// redirectToReferer=<hex>(显式回跳语境);{root} 取自 AppBaseUrl(含 /docprint 等模块前缀),
|
/// 重建必须用 Items 保存的原始路径(含 ;redirectToReferer=<hex> 段),与登录 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>
|
||||||
|
|||||||
@@ -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=<hex>(service 分号矩阵段,CAS 登录引导时写入):按 notice
|
/// <list type="bullet">
|
||||||
/// 语义解析 hex 并 302 到解码地址——覆盖"已登录刷新/直访带分号 URL"等 handler 未接管的场景,
|
/// <item>路径含 ;redirectToReferer=<hex>:提取 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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user