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; } } }