@@ -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=<回跳地址>];
|
||||
/// 回跳地址走 query 而非 Java 的路径矩阵参数 ;redirectToReferer=<hex>——ASP.NET Core 不支持矩阵参数,
|
||||
/// 分号会使 MVC 路由与 PathString.StartsWithSegments 双双失配,回调 404 覆盖掉本 handler 的 302 回跳;</item>
|
||||
/// <item>Authenticate:回调({SignInCallbackPath}?returnUrl=…&ticket=…,无 query 时为 ?ticket=)按
|
||||
/// path + 除 ticket 外的 query 重建 service 调 /serviceValidate 校验,成功后写入 Cookie 登录态并
|
||||
/// 302 到 returnUrl(兼容旧 ;redirectToReferer=<hex> 形态)。</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=<urlencoded(returnUrl)>]。
|
||||
/// 回跳地址只允许本地相对路径(/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 时 &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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user