321 lines
16 KiB
C#
321 lines
16 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Globalization;
|
||
using System.Linq;
|
||
using System.Security.Claims;
|
||
using System.Text;
|
||
using System.Text.Encodings.Web;
|
||
using System.Threading.Tasks;
|
||
using Microsoft.AspNetCore.Authentication;
|
||
using Microsoft.AspNetCore.Http;
|
||
using Microsoft.Extensions.Logging;
|
||
using Microsoft.Extensions.Options;
|
||
|
||
namespace YhCore.WebApi.Auth
|
||
{
|
||
/// <summary>
|
||
/// 自研 CAS 远程认证 handler(本地化自 Yh.Platform.Auth,改造为平台旧版登录 URL 格式):
|
||
/// <list type="bullet">
|
||
/// <item>Challenge:302 到 CAS 登录页,service = {AppBaseUrl}{当前 path}[?除 ticket 外 query],即原访问 URL,
|
||
/// 与 Java notice 的 service 形态一致(service=http://host/module/api/xxx);AppBaseUrl 含模块前缀
|
||
/// (如 /docprint)时拼接结果天然带模块名。显式回跳语境(前端 yschreferer/Referer 携带页面地址)时
|
||
/// 在该 service 上追加 query 参数 redirectToReferer=<hex(回跳地址)> 携带「登录后回页面」信息——
|
||
/// 不再以 {SignInCallbackPath} 回调端点作 service(signin-cas 形态已弃用);hex 按 char 维度编码
|
||
/// (对齐前端 strToHexCharCode / Java HexUtils),避免 query 内嵌完整 URL 需多层 urlencode。
|
||
/// ASP.NET Core 无 Java 路径矩阵参数机制,回跳目标只能放 query。</item>
|
||
/// <item>Authenticate:任意受保护路径带 ticket 的回调均按「path + 除 ticket 外的 query」重建 service
|
||
/// 调 /serviceValidate 校验(service 即登录时的原访问 URL,CAS 在末尾追加 ?ticket=/&ticket= 回跳)。
|
||
/// 成功后写入 Cookie 登录态并 302 到 redirectToReferer(hex 解码,当前形态)/ returnUrl(旧中继形态
|
||
/// 兼容)/ 原 URL 去 ticket 版(无回跳目标),兼容更早的 ;redirectToReferer=<hex> 路径段形态。</item>
|
||
/// </list>
|
||
/// </summary>
|
||
public class CasAuthenticationHandler : AuthenticationHandler<CasAuthenticationOptions>
|
||
{
|
||
private const string RedirectToRefererMarker = ";redirectToReferer=";
|
||
|
||
/// <summary>旧中继形态(service={回调端点}?returnUrl=…)的回跳参数——已弃用,仅兼容历史 URL/缓存。</summary>
|
||
private const string ReturnUrlParam = "returnUrl";
|
||
|
||
/// <summary>当前形态回跳参数:service=原访问 URL 时追加 ?redirectToReferer=<hex(回跳地址)>,登录后 CAS 原样回带。</summary>
|
||
private const string RedirectToRefererQueryParam = "redirectToReferer";
|
||
|
||
private readonly CasServiceTicketValidator _validator;
|
||
|
||
public CasAuthenticationHandler(
|
||
IOptionsMonitor<CasAuthenticationOptions> options,
|
||
ILoggerFactory logger,
|
||
UrlEncoder encoder,
|
||
ISystemClock clock,
|
||
CasServiceTicketValidator validator)
|
||
: base(options, logger, encoder, clock)
|
||
{
|
||
_validator = validator;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 按当前请求重建登录时的 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 收到的接口路径,拼接结果天然带模块名,不受反向代理剥离前缀影响。
|
||
/// </summary>
|
||
private string BuildServiceUrlFromRequest()
|
||
{
|
||
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;
|
||
return query.Count > 0 ? service + "?" + string.Join("&", query) : service;
|
||
}
|
||
|
||
/// <summary>取回调 query 中的 returnUrl(旧中继形态兼容:service={回调端点}?returnUrl=… 的历史 URL/缓存;
|
||
/// 本地相对路径或同源完整 http(s) URL,非法返回 null)。</summary>
|
||
private string ResolveReturnUrl()
|
||
{
|
||
if (!Context.Request.Query.TryGetValue(ReturnUrlParam, out var value))
|
||
{
|
||
return null;
|
||
}
|
||
var url = value.ToString();
|
||
return IsAllowedReturnTarget(url, AllowedHost()) ? url : null;
|
||
}
|
||
|
||
/// <summary>读回调 query 的 redirectToReferer(当前形态:service=原访问 URL 时由 CAS 原样回带的
|
||
/// hex 回跳地址)并解码;缺失或 hex 非法返回 null。合法性由调用方统一 IsAllowedReturnTarget 把关。</summary>
|
||
private string ResolveQueryRedirectToReferer()
|
||
{
|
||
if (!Context.Request.Query.TryGetValue(RedirectToRefererQueryParam, out var value))
|
||
{
|
||
return null;
|
||
}
|
||
return HexDecode(value.ToString());
|
||
}
|
||
|
||
/// <summary>service 根地址:优先 AppBaseUrl 配置,为空时从当前请求推断。</summary>
|
||
private string BuildCallbackRoot()
|
||
{
|
||
var root = Options.AppBaseUrl?.TrimEnd('/');
|
||
if (string.IsNullOrEmpty(root))
|
||
{
|
||
root = Context.Request.Scheme + "://" + Context.Request.Host.Value;
|
||
}
|
||
return root;
|
||
}
|
||
|
||
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
|
||
{
|
||
var casBase = Options.CasServerUrlPrefix;
|
||
if (string.IsNullOrWhiteSpace(casBase))
|
||
{
|
||
Response.StatusCode = 500;
|
||
return Response.WriteAsync("未配置 CAS 服务端地址(yh:sso:cas-server-url-prefix)");
|
||
}
|
||
properties.Items.TryGetValue("returnUrl", out var returnUrl);
|
||
var target = returnUrl ?? properties.RedirectUri;
|
||
string service;
|
||
if (string.IsNullOrEmpty(target))
|
||
{
|
||
// 无显式回跳语境(浏览器/工具直访受保护接口,未带 yschreferer / Referer):
|
||
// service = 原访问 URL(AppBaseUrl 前缀 + 当前 path + 除 ticket 外 query),
|
||
// 与 Java notice 的 service 形态一致(service=http://host/module/api/xxx,无附加参数)。
|
||
// AppBaseUrl 带模块前缀(如 http://host/docprint)时,拼接结果天然含模块名,
|
||
// 不受反向代理剥离前缀影响;登录成功后 CAS 回跳该 URL + ticket,由本 handler 校验并
|
||
// 302 去掉 ticket 落回原地址。
|
||
service = BuildServiceUrlFromRequest();
|
||
}
|
||
else
|
||
{
|
||
// 显式回跳语境(前端 yschreferer 携带页面地址等):service 仍 = 原访问 URL(notice 形态,
|
||
// 不再以 {SignInCallbackPath} 回调端点作 service),仅追加 query 参数
|
||
// redirectToReferer=<hex(回跳地址)> 携带「登录后回页面」信息——CAS 会把它作为 service 一部分
|
||
// 在回调时原样带回,本 handler 校验成功后 hex 解码即得回跳目标。hex 按 char 维度编码,避免
|
||
// query 内嵌完整 URL 需多层 urlencode(对齐前端 strToHexCharCode / Java HexUtils)。
|
||
service = BuildServiceUrlFromRequest();
|
||
service += (service.IndexOf('?') < 0 ? "?" : "&")
|
||
+ RedirectToRefererQueryParam + "=" + HexEncode(target);
|
||
}
|
||
// Uri.EscapeDataString 会把 ; / : = 全部编码(%3B/%2F/%3A/%3D),与平台旧版系统生成的
|
||
// CAS 登录 URL 逐字节一致(UrlEncoder.Encode 会保留 ; 字面量)
|
||
var loginUrl = (casBase.EndsWith('/') ? casBase : casBase + "/") + "login?service=" + Uri.EscapeDataString(service);
|
||
Response.Redirect(loginUrl);
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||
{
|
||
// 1. 已有 Cookie 登录态直接放行
|
||
var cookieResult = await Context.AuthenticateAsync(YhCasDefaults.CookieScheme);
|
||
if (cookieResult.Succeeded && cookieResult.Principal != null)
|
||
{
|
||
return AuthenticateResult.Success(cookieResult.Ticket!);
|
||
}
|
||
|
||
// 2. CAS 回调:校验 ticket
|
||
var ticketValue = Context.Request.Query["ticket"].ToString();
|
||
if (string.IsNullOrEmpty(ticketValue))
|
||
{
|
||
return AuthenticateResult.NoResult();
|
||
}
|
||
|
||
// service 必须与登录时完全一致:CAS 是在登录 service 后追加 ?ticket=(已有 query 时 &ticket=)回调。
|
||
var service = BuildServiceUrlFromRequest();
|
||
string loginName;
|
||
try
|
||
{
|
||
loginName = await _validator.ValidateAsync(ticketValue, service, Context.RequestAborted);
|
||
}
|
||
catch (Yh.Platform.Auth.AuthException e)
|
||
{
|
||
return AuthenticateResult.Fail(e);
|
||
}
|
||
if (string.IsNullOrEmpty(loginName))
|
||
{
|
||
return AuthenticateResult.Fail("CAS ticket 校验失败或已失效");
|
||
}
|
||
|
||
var claims = new List<Claim>
|
||
{
|
||
new(ClaimTypes.Name, loginName),
|
||
new(ClaimTypes.NameIdentifier, loginName),
|
||
new("loginName", loginName),
|
||
};
|
||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||
var principal = new ClaimsPrincipal(identity);
|
||
|
||
// 写入 Cookie 登录态(会话级:IsPersistent=false,关闭浏览器即失效;票据过期由 SessionStore + ExpireTimeSpan 控制)
|
||
var props = new AuthenticationProperties { IsPersistent = false };
|
||
await Context.SignInAsync(YhCasDefaults.CookieScheme, principal, props);
|
||
// 同步当前请求上下文身份:SignInAsync 只写 Cookie 不改 context.User,
|
||
// 后续中间件(SsoProtectionMiddleware)需据此识别“刚登录成功”,避免被误判未认证再次 Challenge
|
||
Context.User = principal;
|
||
|
||
// 回跳原地址:
|
||
// 1. query redirectToReferer=<hex>(当前形态:service=原访问 URL 时由 CAS 原样回带)→ 解码跳该目标;
|
||
// 2. query returnUrl(旧中继形态 service={回调端点}?returnUrl=… 的历史 URL/缓存,兼容);
|
||
// 3. 旧 ;redirectToReferer=<hex> 路径段(更早形态,兼容);
|
||
// 4. 都没有(service=纯净原访问 URL 的 notice 形态:CAS 在 service 后追加 ?ticket= 回跳,
|
||
// 本请求 Path + 除 ticket 外 query 即登录时原 URL)→ 302 去掉一次性 ticket 落回原地址。
|
||
// 允许本地相对路径或同源完整 http(s) URL;非法(外站 host / 协议相对等)回根路径,防开放重定向。
|
||
var referer = ResolveQueryRedirectToReferer()
|
||
?? ResolveReturnUrl()
|
||
?? ParseRedirectToReferer(Context.Request.Path.Value);
|
||
string redirect;
|
||
if (referer != null)
|
||
{
|
||
redirect = IsAllowedReturnTarget(referer, AllowedHost()) ? referer : "/";
|
||
}
|
||
else
|
||
{
|
||
redirect = BuildServiceUrlFromRequest();
|
||
redirect = IsAllowedReturnTarget(redirect, AllowedHost()) ? redirect : "/";
|
||
}
|
||
Response.Redirect(redirect);
|
||
return AuthenticateResult.NoResult();
|
||
}
|
||
|
||
/// <summary>从回调路径中解析 ;redirectToReferer= 后的 hex 并解码;无该段或编码非法返回 null。</summary>
|
||
private static string ParseRedirectToReferer(string path)
|
||
{
|
||
if (string.IsNullOrEmpty(path))
|
||
{
|
||
return null;
|
||
}
|
||
var marker = path.IndexOf(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)
|
||
{
|
||
sb.Append(((int)ch).ToString("x2"));
|
||
}
|
||
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;
|
||
}
|
||
|
||
/// <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;
|
||
}
|
||
}
|
||
}
|