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
{
///
/// 自研 CAS 远程认证 handler(本地化自 Yh.Platform.Auth,改造为平台旧版登录 URL 格式):
///
/// - 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。
/// - Authenticate:任意受保护路径带 ticket 的回调均按「path + 除 ticket 外的 query」重建 service
/// 调 /serviceValidate 校验(service 即登录时的原访问 URL,CAS 在末尾追加 ?ticket=/&ticket= 回跳)。
/// 成功后写入 Cookie 登录态并 302 到 redirectToReferer(hex 解码,当前形态)/ returnUrl(旧中继形态
/// 兼容)/ 原 URL 去 ticket 版(无回跳目标),兼容更早的 ;redirectToReferer=<hex> 路径段形态。
///
///
public class CasAuthenticationHandler : AuthenticationHandler
{
private const string RedirectToRefererMarker = ";redirectToReferer=";
/// 旧中继形态(service={回调端点}?returnUrl=…)的回跳参数——已弃用,仅兼容历史 URL/缓存。
private const string ReturnUrlParam = "returnUrl";
/// 当前形态回跳参数:service=原访问 URL 时追加 ?redirectToReferer=<hex(回跳地址)>,登录后 CAS 原样回带。
private const string RedirectToRefererQueryParam = "redirectToReferer";
private readonly CasServiceTicketValidator _validator;
public CasAuthenticationHandler(
IOptionsMonitor options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock,
CasServiceTicketValidator validator)
: base(options, logger, encoder, clock)
{
_validator = validator;
}
///
/// 按当前请求重建登录时的 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 收到的接口路径,拼接结果天然带模块名,不受反向代理剥离前缀影响。
///
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;
}
/// 取回调 query 中的 returnUrl(旧中继形态兼容:service={回调端点}?returnUrl=… 的历史 URL/缓存;
/// 本地相对路径或同源完整 http(s) URL,非法返回 null)。
private string ResolveReturnUrl()
{
if (!Context.Request.Query.TryGetValue(ReturnUrlParam, out var value))
{
return null;
}
var url = value.ToString();
return IsAllowedReturnTarget(url, AllowedHost()) ? url : null;
}
/// 读回调 query 的 redirectToReferer(当前形态:service=原访问 URL 时由 CAS 原样回带的
/// hex 回跳地址)并解码;缺失或 hex 非法返回 null。合法性由调用方统一 IsAllowedReturnTarget 把关。
private string ResolveQueryRedirectToReferer()
{
if (!Context.Request.Query.TryGetValue(RedirectToRefererQueryParam, out var value))
{
return null;
}
return HexDecode(value.ToString());
}
/// service 根地址:优先 AppBaseUrl 配置,为空时从当前请求推断。
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= 携带「登录后回页面」信息——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 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
{
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=(当前形态:service=原访问 URL 时由 CAS 原样回带)→ 解码跳该目标;
// 2. query returnUrl(旧中继形态 service={回调端点}?returnUrl=… 的历史 URL/缓存,兼容);
// 3. 旧 ;redirectToReferer= 路径段(更早形态,兼容);
// 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();
}
/// 从回调路径中解析 ;redirectToReferer= 后的 hex 并解码;无该段或编码非法返回 null。
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));
}
/// 字符串 → 小写十六进制(char 维度,与前端 strToHexCharCode / Java HexUtils.stringToHex 一致:
/// 逐字符取码点转两位 hex;ASCII 范围内结果与 UTF-8 字节编码相同)。
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();
}
/// 小写/大写十六进制 → 字符串(char 维度解码,对齐前端 yschreferer / Java hexToString);
/// 长度为奇数或含非十六进制字符时返回 null。
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();
}
///
/// 判定回跳目标是否允许:
/// 1. 本地相对路径(/xxx 开头):拒绝协议相对地址(//host)与反斜杠变体(/\…)——原有安全口径;
/// 2. 完整 http(s) URL:host 必须与 service 根 host(AppBaseUrl 或当前请求)一致——供前端页完整
/// 地址(yschreferer 携带,含 #/ 路由片段)作回跳目标,杜绝开放重定向到任意外站。
/// 其余情况(null/空、非 http(s) 协议、外站 host)一律拒绝。
///
private static bool IsAllowedReturnTarget(string url, string allowedHost)
{
if (string.IsNullOrEmpty(url))
{
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);
}
/// service 根地址的 host(回跳白名单基准):AppBaseUrl 未配置时取当前请求 host。
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;
}
}
}