328 lines
18 KiB
C#
328 lines
18 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 携带页面地址)时
|
||
/// 把回跳地址以 Java notice 同款分号矩阵段插入 path 与 query 之间:
|
||
/// {path};redirectToReferer=<hex(回跳地址)>[?{原 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=/&ticket= 回跳)。成功后写入 Cookie 登录态并 302 到分号段 redirectToReferer(hex 解码,
|
||
/// 当前形态)/ query redirectToReferer(旧 query 形态兼容)/ returnUrl(更早中继形态兼容)/
|
||
/// 原 URL 去 ticket 版(无回跳目标);显式回跳目标(白名单通过)302 时透传一次性 ticket
|
||
/// (目标已有 query 用 &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=<hex>,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 时 &ticket=),校验时必须还原为登录时的
|
||
/// 原始 service,故剥掉 ticket;其余参数按 EscapeDataString 重新编码,与 Challenge 时构造保持一致。
|
||
/// path 取 ResolveOriginalPath():分号段被 PathMatrixSegmentMiddleware 从 Request.Path 剥离后,
|
||
/// 重建必须用 Items 保存的原始路径(含 ;redirectToReferer=<hex> 段),与登录 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 用 &,否则用 ?)。</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;
|
||
}
|
||
}
|
||
}
|