Files
DocprintService/src/YhCore.WebApi/Auth/Cas/CasAuthenticationHandler.cs
T
wanghu a73fb4a214
build / build-push (push) Successful in 2m3s
1
2026-09-04 13:36:57 +08:00

328 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 携带页面地址)时
/// 把回跳地址以 Java notice 同款分号矩阵段插入 path 与 query 之间:
/// {path};redirectToReferer=&lt;hex(回跳地址)&gt;[?{原 query}]——不再以 {SignInCallbackPath} 回调端点作
/// service(signin-cas 形态已弃用);hex 按 char 维度编码(对齐前端 strToHexCharCode / Java HexUtils),
/// 分号段不参与 query 结构,含中文/特殊字符的页面地址也可无歧义承载。ASP.NET Core 对回调中分号段
/// 的处理:带 ticket 校验成功后由中间件短路(不落 MVC 路由),路径分号段仅用于解析回跳目标。</item>
/// <item>Authenticate:任意受保护路径带 ticket 的回调均按「path + 除 ticket 外的 query」重建 service
/// 调 /serviceValidate 校验(service 即登录时的原访问 URL,含分号段则一并重建,CAS 在末尾追加
/// ?ticket=/&amp;ticket= 回跳)。成功后写入 Cookie 登录态并 302 到分号段 redirectToReferer(hex 解码,
/// 当前形态)/ query redirectToReferer(旧 query 形态兼容)/ returnUrl(更早中继形态兼容)/
/// 原 URL 去 ticket 版(无回跳目标);显式回跳目标(白名单通过)302 时透传一次性 ticket
/// (目标已有 query 用 &amp;ticket=,否则 ?ticket=——按联调约定供目标页作登录完成信号)。</item>
/// </list>
/// </summary>
public class CasAuthenticationHandler : AuthenticationHandler<CasAuthenticationOptions>
{
/// <summary>旧中继形态(service={回调端点}?returnUrl=…)的回跳参数——已弃用,仅兼容历史 URL/缓存。</summary>
private const string ReturnUrlParam = "returnUrl";
/// <summary>旧 query 形态(service=原访问 URL?redirectToReferer=&lt;hex&gt;,986b241 曾用)的回跳参数——
/// 已改分号矩阵段承载,此处仅兼容读取历史 URL/缓存。</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 时 &amp;ticket=),校验时必须还原为登录时的
/// 原始 service,故剥掉 ticket;其余参数按 EscapeDataString 重新编码,与 Challenge 时构造保持一致。
/// path 取 ResolveOriginalPath():分号段被 PathMatrixSegmentMiddleware 从 Request.Path 剥离后,
/// 重建必须用 Items 保存的原始路径(含 ;redirectToReferer=&lt;hex&gt; 段),与登录 service 逐字节一致;
/// {root} 取自 AppBaseUrl(含 /docprint 等模块前缀),拼接结果天然带模块名,不受反向代理剥离前缀影响。
/// Challenge 侧构造新 service 用当前(已剥离的)路由路径,目标以分号段另行追加,见 HandleChallengeAsync。
/// </summary>
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() + 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()
{
if (!Context.Request.Query.TryGetValue(ReturnUrlParam, out var value))
{
return null;
}
var url = value.ToString();
return CasHex.IsAllowedRedirectTarget(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 CasHex.Decode(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 = BuildServiceUrlFromCurrentPath();
}
else
{
// 显式回跳语境(前端 yschreferer 携带页面地址等):service 仍 = 原访问 URL(notice 形态,
// 不再以 {SignInCallbackPath} 回调端点作 service),把回跳地址以 Java notice 同款的分号矩阵段
// 形式插入 path 与 query 之间:{path};redirectToReferer=<hex>[?{原 query}]——CAS 会把它作为
// service 一部分在回调时原样带回,回调侧 PathMatrixSegmentMiddleware 剥离分号段后由
// ResolveRedirectToReferer 从 Items 的 hex 解码即得回跳目标(query 形态 ?redirectToReferer=<hex>
// 的旧 URL/缓存仍兼容读取)。hex 按 char 维度编码(对齐前端 strToHexCharCode / Java HexUtils);
// 分号段内不参与 query 结构,含中文/特殊字符的页面地址也可无歧义承载。
service = BuildServiceUrlFromCurrentPath();
var q = service.IndexOf('?');
var segment = YhCasDefaults.RedirectToRefererMarker + CasHex.Encode(target);
service = q < 0
? service + segment
: service.Substring(0, q) + segment + service.Substring(q);
}
// 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 登录态直接放行。
// 例外:已登录状态下又收到带 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!);
}
// 2. CAS 回调:校验 ticket
var ticketValue = Context.Request.Query["ticket"].ToString();
if (string.IsNullOrEmpty(ticketValue))
{
return AuthenticateResult.NoResult();
}
// service 必须与登录时完全一致:CAS 是在登录 service 后追加 ?ticket=(已有 query 时 &ticket=)回调。
// 分号段已被 PathMatrixSegmentMiddleware 从 Request.Path 剥离,须以 Items 保存的原始路径重建。
var service = BuildServiceUrlFromOriginalPath();
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. 路径分号段 ;redirectToReferer=<hex>(当前形态:service=原 URL;redirectToReferer=<hex>,
// CAS 原样回带)→ 解码跳该目标;
// 2. query redirectToReferer=<hex>(ff31293 前的 query 形态 URL/缓存,兼容);
// 3. query returnUrl(更早中继形态 service={回调端点}?returnUrl=…,兼容);
// 4. 都没有(service=纯净原访问 URL 的 notice 形态:CAS 在 service 后追加 ?ticket= 回跳,
// 本请求 Path + 除 ticket 外 query 即登录时原 URL)→ 302 去掉一次性 ticket 落回原地址。
// 允许本地相对路径或同源完整 http(s) URL;非法(外站 host / 协议相对等)回根路径,防开放重定向。
// 显式回跳目标(1/2/3)白名单通过时透传一次性票据:目标页按联调约定可凭 ticket 自证登录成功——
// 注意票据已被本次 /serviceValidate 消费(一次性),目标侧不得再拿去 CAS 校验,仅作登录完成信号。
var referer = ResolveRedirectToReferer()
?? ResolveQueryRedirectToReferer()
?? ResolveReturnUrl();
string redirect;
if (referer != null)
{
redirect = CasHex.IsAllowedRedirectTarget(referer, AllowedHost()) ? referer : "/";
if (redirect == referer)
{
redirect = AppendTicket(redirect, ticketValue);
}
}
else
{
redirect = BuildServiceUrlFromOriginalPath();
redirect = CasHex.IsAllowedRedirectTarget(redirect, AllowedHost()) ? redirect : "/";
}
Response.Redirect(redirect);
return AuthenticateResult.NoResult();
}
/// <summary>给回跳目标追加一次性票据(目标已有 query 用 &amp;,否则用 ?)。</summary>
private static string AppendTicket(string url, string ticket)
{
if (string.IsNullOrEmpty(ticket))
{
return url;
}
return url + (url.IndexOf('?') < 0 ? "?" : "&")
+ "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)
{
if (string.IsNullOrEmpty(path))
{
return null;
}
var marker = path.IndexOf(YhCasDefaults.RedirectToRefererMarker, StringComparison.OrdinalIgnoreCase);
if (marker < 0)
{
return null;
}
var hex = path.Substring(marker + YhCasDefaults.RedirectToRefererMarker.Length);
var term = hex.IndexOfAny(new[] { ';', '?', '#' });
if (term >= 0)
{
hex = hex.Substring(0, term);
}
return CasHex.Decode(hex);
}
/// <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;
}
}
}