1
build / build-push (push) Successful in 1m14s

This commit is contained in:
wanghu
2026-09-04 09:58:52 +08:00
parent b7789a1914
commit 9fe45c810e
5 changed files with 532 additions and 0 deletions
@@ -0,0 +1,231 @@
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}{SignInCallbackPath}[?returnUrl=&lt;回跳地址&gt;];
/// 回跳地址走 query 而非 Java 的路径矩阵参数 ;redirectToReferer=&lt;hex&gt;——ASP.NET Core 不支持矩阵参数,
/// 分号会使 MVC 路由与 PathString.StartsWithSegments 双双失配,回调 404 覆盖掉本 handler 的 302 回跳;</item>
/// <item>Authenticate:回调({SignInCallbackPath}?returnUrl=…&amp;ticket=…,无 query 时为 ?ticket=)按
/// path + 除 ticket 外的 query 重建 service 调 /serviceValidate 校验,成功后写入 Cookie 登录态并
/// 302 到 returnUrl(兼容旧 ;redirectToReferer=&lt;hex&gt; 形态)。</item>
/// </list>
/// </summary>
public class CasAuthenticationHandler : AuthenticationHandler<CasAuthenticationOptions>
{
private const string RedirectToRefererMarker = ";redirectToReferer=";
private const string ReturnUrlParam = "returnUrl";
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}{SignInCallbackPath}[?returnUrl=&lt;urlencoded(returnUrl)&gt;]。
/// 回跳地址只允许本地相对路径(/xxx),杜绝开放重定向。
/// 注意:回跳地址走 query 而非路径矩阵参数——ASP.NET Core 不识别矩阵参数,分号会破坏
/// 路由匹配与 StartsWithSegments 的段比较(Java Spring 因 UrlPathHelper 默认
/// removeSemicolonContent=true 剥离分号内容才可用矩阵参数,.NET 无此机制)。
/// </summary>
private string BuildServiceUrl(string returnUrl)
{
var callback = BuildCallbackRoot() + Options.SignInCallbackPath;
if (string.IsNullOrEmpty(returnUrl) || !IsLocalUrl(returnUrl))
{
return callback;
}
return callback + "?" + ReturnUrlParam + "=" + Uri.EscapeDataString(returnUrl);
}
/// <summary>
/// 按当前请求重建登录时的 service:{root}{path}[?{除 ticket 外的 query}]。
/// CAS 回调会在原 service 后追加 ?ticket=(已有 query 时 &amp;ticket=),校验时必须还原为登录时的
/// 原始 service,故剥掉 ticket;其余参数按 EscapeDataString 重新编码,与 BuildServiceUrl 保持一致。
/// </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(仅本地相对路径,非法返回 null)。</summary>
private string ResolveReturnUrl()
{
if (!Context.Request.Query.TryGetValue(ReturnUrlParam, out var value))
{
return null;
}
var url = value.ToString();
return IsLocalUrl(url) ? url : null;
}
/// <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 service = BuildServiceUrl(returnUrl ?? properties.RedirectUri);
// 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;
// 回跳原地址:优先 query 的 returnUrl(当前形态),兼容旧 ;redirectToReferer=<hex> 回调。
// 仅限本地相对路径,防开放重定向;非法回根路径
var referer = ResolveReturnUrl() ?? ParseRedirectToReferer(Context.Request.Path.Value);
var redirect = referer != null && IsLocalUrl(referer) ? referer : "/";
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>UTF-8 字节 → 小写十六进制(对齐平台旧版登录 URL 的 redirectToReferer 编码)。</summary>
private static string HexEncode(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
var sb = new StringBuilder(bytes.Length * 2);
foreach (var b in bytes)
{
sb.Append(b.ToString("x2"));
}
return sb.ToString();
}
/// <summary>小写/大写十六进制 → UTF-8 字符串;长度为奇数或含非十六进制字符时返回 null。</summary>
private static string HexDecode(string hex)
{
if (string.IsNullOrEmpty(hex) || hex.Length % 2 != 0)
{
return null;
}
var bytes = new byte[hex.Length / 2];
for (var i = 0; i < bytes.Length; i++)
{
if (!byte.TryParse(hex.Substring(i * 2, 2), NumberStyles.HexNumber,
CultureInfo.InvariantCulture, out var b))
{
return null;
}
bytes[i] = b;
}
return Encoding.UTF8.GetString(bytes);
}
/// <summary>仅放行本地相对路径(/xxx);拒绝外站绝对地址(http(s)://…)、协议相对地址(//host)、反斜杠变体。</summary>
private static bool IsLocalUrl(string url)
{
if (string.IsNullOrEmpty(url) || url[0] != '/')
{
return false;
}
return !url.StartsWith("//", StringComparison.Ordinal)
&& !url.StartsWith("/\\", StringComparison.Ordinal);
}
}
}