diff --git a/src/YhCore.WebApi/Auth/Cas/CasAuthenticationHandler.cs b/src/YhCore.WebApi/Auth/Cas/CasAuthenticationHandler.cs new file mode 100644 index 0000000..7448942 --- /dev/null +++ b/src/YhCore.WebApi/Auth/Cas/CasAuthenticationHandler.cs @@ -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 +{ + /// + /// 自研 CAS 远程认证 handler(本地化自 Yh.Platform.Auth,改造为平台旧版登录 URL 格式): + /// + /// Challenge:302 到 CAS 登录页,service = {AppBaseUrl}{SignInCallbackPath}[?returnUrl=<回跳地址>]; + /// 回跳地址走 query 而非 Java 的路径矩阵参数 ;redirectToReferer=<hex>——ASP.NET Core 不支持矩阵参数, + /// 分号会使 MVC 路由与 PathString.StartsWithSegments 双双失配,回调 404 覆盖掉本 handler 的 302 回跳; + /// Authenticate:回调({SignInCallbackPath}?returnUrl=…&ticket=…,无 query 时为 ?ticket=)按 + /// path + 除 ticket 外的 query 重建 service 调 /serviceValidate 校验,成功后写入 Cookie 登录态并 + /// 302 到 returnUrl(兼容旧 ;redirectToReferer=<hex> 形态)。 + /// + /// + public class CasAuthenticationHandler : AuthenticationHandler + { + private const string RedirectToRefererMarker = ";redirectToReferer="; + + private const string ReturnUrlParam = "returnUrl"; + + 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}{SignInCallbackPath}[?returnUrl=<urlencoded(returnUrl)>]。 + /// 回跳地址只允许本地相对路径(/xxx),杜绝开放重定向。 + /// 注意:回跳地址走 query 而非路径矩阵参数——ASP.NET Core 不识别矩阵参数,分号会破坏 + /// 路由匹配与 StartsWithSegments 的段比较(Java Spring 因 UrlPathHelper 默认 + /// removeSemicolonContent=true 剥离分号内容才可用矩阵参数,.NET 无此机制)。 + /// + private string BuildServiceUrl(string returnUrl) + { + var callback = BuildCallbackRoot() + Options.SignInCallbackPath; + if (string.IsNullOrEmpty(returnUrl) || !IsLocalUrl(returnUrl)) + { + return callback; + } + return callback + "?" + ReturnUrlParam + "=" + Uri.EscapeDataString(returnUrl); + } + + /// + /// 按当前请求重建登录时的 service:{root}{path}[?{除 ticket 外的 query}]。 + /// CAS 回调会在原 service 后追加 ?ticket=(已有 query 时 &ticket=),校验时必须还原为登录时的 + /// 原始 service,故剥掉 ticket;其余参数按 EscapeDataString 重新编码,与 BuildServiceUrl 保持一致。 + /// + 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(仅本地相对路径,非法返回 null)。 + private string ResolveReturnUrl() + { + if (!Context.Request.Query.TryGetValue(ReturnUrlParam, out var value)) + { + return null; + } + var url = value.ToString(); + return IsLocalUrl(url) ? url : null; + } + + /// 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 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 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; + + // 回跳原地址:优先 query 的 returnUrl(当前形态),兼容旧 ;redirectToReferer= 回调。 + // 仅限本地相对路径,防开放重定向;非法回根路径 + var referer = ResolveReturnUrl() ?? ParseRedirectToReferer(Context.Request.Path.Value); + var redirect = referer != null && IsLocalUrl(referer) ? referer : "/"; + 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)); + } + + /// UTF-8 字节 → 小写十六进制(对齐平台旧版登录 URL 的 redirectToReferer 编码)。 + 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(); + } + + /// 小写/大写十六进制 → UTF-8 字符串;长度为奇数或含非十六进制字符时返回 null。 + 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); + } + + /// 仅放行本地相对路径(/xxx);拒绝外站绝对地址(http(s)://…)、协议相对地址(//host)、反斜杠变体。 + private static bool IsLocalUrl(string url) + { + if (string.IsNullOrEmpty(url) || url[0] != '/') + { + return false; + } + return !url.StartsWith("//", StringComparison.Ordinal) + && !url.StartsWith("/\\", StringComparison.Ordinal); + } + } +} diff --git a/src/YhCore.WebApi/Auth/Cas/CasServiceTicketValidator.cs b/src/YhCore.WebApi/Auth/Cas/CasServiceTicketValidator.cs new file mode 100644 index 0000000..27779a0 --- /dev/null +++ b/src/YhCore.WebApi/Auth/Cas/CasServiceTicketValidator.cs @@ -0,0 +1,81 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Yh.Platform.Auth; + +namespace YhCore.WebApi.Auth +{ + /// + /// 轻量 CAS service ticket 校验器(对齐 Java CasServiceTicketValidator,不依赖 CAS 客户端库)。 + /// 调用 CAS 的 /serviceValidate 端点,从响应 XML 中提取 <cas:user>。 + /// + public class CasServiceTicketValidator + { + private readonly string _casServerUrl; + private readonly int _timeoutMs; + + public CasServiceTicketValidator(string casServerUrl, int timeoutMs = 5000) + { + _casServerUrl = casServerUrl; + _timeoutMs = timeoutMs > 0 ? timeoutMs : 5000; + } + + /// 校验 ticket,成功返回登录账号,失败/无效返回 null。 + public async Task ValidateAsync(string ticket, string service, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(_casServerUrl)) + { + throw new AuthException("未配置 CasServerUrlPrefix(yh:sso:cas-server-url-prefix),无法进行 ticket 校验"); + } + var base_ = _casServerUrl.EndsWith('/') ? _casServerUrl : _casServerUrl + "/"; + var endpoint = base_ + "serviceValidate" + + "?ticket=" + Uri.EscapeDataString(ticket ?? "") + + "&service=" + Uri.EscapeDataString(service ?? ""); + try + { + using var handler = new SocketsHttpHandler { ConnectTimeout = TimeSpan.FromMilliseconds(_timeoutMs) }; + using var http = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(_timeoutMs) }; + using var resp = await http.GetAsync(endpoint, ct).ConfigureAwait(false); + var body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (!resp.IsSuccessStatusCode || body == null) + { + return null; + } + return ExtractUser(body); + } + catch (Exception e) when (e is HttpRequestException or TaskCanceledException) + { + throw new AuthException("CAS serviceValidate 请求失败: " + e.Message, e); + } + } + + /// + /// 从 CAS 响应 XML 提取用户名。 + /// 成功响应形如 <cas:serviceResponse><cas:authenticationSuccess><cas:user>NAME</cas:user>... + /// + public static string ExtractUser(string xml) + { + if (string.IsNullOrEmpty(xml)) + { + return null; + } + var start = xml.IndexOf("", StringComparison.Ordinal); + if (start < 0) + { + start = xml.IndexOf("', start); + var end = xml.IndexOf("", tagEnd, StringComparison.Ordinal); + if (tagEnd < 0 || end < 0) + { + return null; + } + return xml.Substring(tagEnd + 1, end - tagEnd - 1).Trim(); + } + } +} diff --git a/src/YhCore.WebApi/Auth/Cas/SessionTicketDataFormat.cs b/src/YhCore.WebApi/Auth/Cas/SessionTicketDataFormat.cs new file mode 100644 index 0000000..529b740 --- /dev/null +++ b/src/YhCore.WebApi/Auth/Cas/SessionTicketDataFormat.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using Microsoft.AspNetCore.Authentication; + +namespace YhCore.WebApi.Auth +{ + /// + /// 会话票据数据格式(自定义 ISecureDataFormat<AuthenticationTicket>): + /// 登录票据(AuthenticationTicket)保存在服务端内存,Cookie 只写 32 位 key(Guid N 格式), + /// 实现「Cookie 短值 + 服务端可控过期」。 + /// 语义: + /// - 关闭浏览器失效:AuthenticationProperties.IsPersistent=false(会话 Cookie,无持久化头); + /// - 固定过期:服务端票据按 Properties.ExpiresUtc(由 ExpireTimeSpan 决定)过期,Unprotect 过期返回 null; + /// - 显式退出立即失效:Revoke 删除指定 key(由 Cookie 注册的 OnSigningOut 事件触发); + /// - 安全:key 为 128 位随机熵(Guid),不可枚举猜测;查表失败/过期即无效。 + /// + public class SessionTicketDataFormat : ISecureDataFormat + { + private readonly ConcurrentDictionary _tickets = new(StringComparer.Ordinal); + private long _lastCleanupUtc; + + public string Protect(AuthenticationTicket data) => Protect(data, null); + + public string Protect(AuthenticationTicket data, string purpose) + { + var key = Guid.NewGuid().ToString("N"); // 32 个十六进制字符 + _tickets[key] = data; + return key; + } + + public AuthenticationTicket Unprotect(string protectedText) => Unprotect(protectedText, null); + + public AuthenticationTicket Unprotect(string protectedText, string purpose) + { + if (protectedText is null || !_tickets.TryGetValue(protectedText, out var ticket)) + { + CleanupExpiredIfDue(); + return null; + } + + // 服务端票据过期:视为无效并清理(配合 SlidingExpiration=false 实现固定过期) + if (ticket.Properties.ExpiresUtc.HasValue && ticket.Properties.ExpiresUtc.Value < DateTimeOffset.UtcNow) + { + _tickets.TryRemove(protectedText, out _); + return null; + } + + return ticket; + } + + /// + /// 吊销指定票据:显式退出(SignOut,如 /api/Logout)时服务端票据立即失效,无需等待过期兜底。 + /// + public void Revoke(string key) + { + if (!string.IsNullOrEmpty(key)) + { + _tickets.TryRemove(key, out _); + } + CleanupExpiredIfDue(); + } + + /// 分钟级摊还清理过期票据,防内存膨胀(Interlocked 门闩,最多每分钟一次全量扫描)。 + private void CleanupExpiredIfDue() + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var last = Volatile.Read(ref _lastCleanupUtc); + if (now - last < 60) + { + return; + } + if (Interlocked.CompareExchange(ref _lastCleanupUtc, now, last) != last) + { + return; + } + foreach (var kvp in _tickets) + { + if (kvp.Value.Properties.ExpiresUtc is { } exp && exp < DateTimeOffset.UtcNow) + { + _tickets.TryRemove(kvp.Key, out _); + } + } + } + } +} diff --git a/src/YhCore.WebApi/Auth/Cas/YhCasAuthOptions.cs b/src/YhCore.WebApi/Auth/Cas/YhCasAuthOptions.cs new file mode 100644 index 0000000..626fef5 --- /dev/null +++ b/src/YhCore.WebApi/Auth/Cas/YhCasAuthOptions.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Authentication; + +namespace YhCore.WebApi.Auth +{ + /// 本地化 CAS 认证方案名常量(值与 Yh.Platform.Auth.YhAuthDefaults 保持一致,管线行为不变)。 + public static class YhCasDefaults + { + /// Cookie 认证方案(登录态载体) + public const string CookieScheme = "YhCookie"; + + /// CAS 认证方案(challenge 时重定向 CAS 登录页) + public const string CasScheme = "YhCas"; + } + + /// + /// CAS 认证配置(service 指向本应用回调接口 /api/base/GetUserInfo,回跳地址以 query 参数 + /// returnUrl 携带;兼容平台旧版 ;redirectToReferer=<hex> 形态的已发出链接)。 + /// + public class CasAuthenticationOptions : AuthenticationSchemeOptions + { + /// CAS 服务端地址前缀,如 http://sso.yunhaixingtong.com(无 /cas 上下文,拼接规则为前缀 + /login) + public string CasServerUrlPrefix { get; set; } + + /// 本应用根地址(用于拼 service 回调地址),为空时从当前请求推断 + public string AppBaseUrl { get; set; } + + /// ticket 回调路径(service = AppBaseUrl + 本路径[?returnUrl=<urlencoded>]) + public string SignInCallbackPath { get; set; } = "/api/base/GetUserInfo"; + + /// ticket 校验超时(毫秒) + public int TicketValidateTimeoutMs { get; set; } = 5000; + } + + /// 通用鉴权配置(配置节 yh:auth)。 + public class YhAuthOptions + { + public const string SectionName = "yh:auth"; + + /// 是否启用(默认 true) + public bool Enabled { get; set; } = true; + + /// 登录态有效期(分钟),固定过期,不滑动续期 + public int CookieExpireMinutes { get; set; } = 30; + + /// Cookie 名 + public string CookieName { get; set; } = ".yh.auth"; + } +} diff --git a/src/YhCore.WebApi/Auth/Cas/YhCasAuthServiceCollectionExtensions.cs b/src/YhCore.WebApi/Auth/Cas/YhCasAuthServiceCollectionExtensions.cs new file mode 100644 index 0000000..20fee70 --- /dev/null +++ b/src/YhCore.WebApi/Auth/Cas/YhCasAuthServiceCollectionExtensions.cs @@ -0,0 +1,86 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace YhCore.WebApi.Auth +{ + /// + /// 本地化 CAS 认证装配(对齐 Yh.Platform.Auth 的 AddYhAuth 认证部分;service 构造为 + /// {root}{SignInCallbackPath}[?returnUrl=<urlencoded>]——回跳走 query 而非平台旧版 + /// ;redirectToReferer=<hex> 矩阵参数(ASP.NET Core 不支持矩阵参数,见 CasAuthenticationHandler)。 + /// 不含 AddYhOpenApi,OpenApi/AppAuth 由宿主另行注册)。 + /// 配置节:yh:auth(通用)与 yh:sso(CAS 服务端)。 + /// + public static class YhCasAuthServiceCollectionExtensions + { + public static IServiceCollection AddYhCasAuth(this IServiceCollection services, IConfiguration configuration) + { + // 会话票据表单例:Cookie TicketDataFormat 与 OnSigningOut 退出吊销必须共享同一实例(同一票据表) + var sessionTicketFormat = new SessionTicketDataFormat(); + services.AddSingleton(sessionTicketFormat); + + services.AddOptions().Bind(configuration.GetSection(YhAuthOptions.SectionName)); + // 注:AddScheme 的 configure 才是 handler 实际读取的命名 options(AuthenticationHandler.Options); + // 必须在此绑定 yh:sso,否则 handler 拿到的 CasAuthenticationOptions 为空(Challenge 时 CasServerUrlPrefix 缺失)。 + services.AddOptions().Bind(configuration.GetSection("yh:sso")); + + services.AddAuthentication(options => + { + // DefaultScheme 必须为 CasScheme(而非 CookieScheme): + // UseAuthentication 每请求调用 AuthenticateAsync(默认 scheme),若默认是 CookieScheme, + // CAS 票据校验(CasAuthenticationHandler.HandleAuthenticateAsync 的 ticket 分支)永远没有触发点—— + // 回调带 ticket 时认证不校验、SsoProtectionMiddleware 见未认证直接 401,CAS 登录永远失败。 + // CasAuthenticationHandler.HandleAuthenticateAsync 第一步就是 Context.AuthenticateAsync(CookieScheme) + // 恢复 Cookie 登录态,因此 CasScheme 作为默认认证 scheme 语义等价"先 Cookie 恢复、后 ticket 校验"。 + options.DefaultScheme = YhCasDefaults.CasScheme; + options.DefaultChallengeScheme = YhCasDefaults.CasScheme; + }) + .AddCookie(YhCasDefaults.CookieScheme, options => + { + var auth = configuration.GetSection(YhAuthOptions.SectionName).Get() ?? new YhAuthOptions(); + options.Cookie.Name = auth.CookieName; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax; + // 会话票据:TicketDataFormat 把票据存服务端,Cookie 只写 32 位 key; + // IsPersistent=false(CasAuthenticationHandler)会话 Cookie,关闭浏览器即失效; + // SlidingExpiration=false 固定过期,不滑动续期。 + options.TicketDataFormat = sessionTicketFormat; + options.ExpireTimeSpan = TimeSpan.FromMinutes(auth.CookieExpireMinutes > 0 ? auth.CookieExpireMinutes : 1); + options.SlidingExpiration = false; + options.LoginPath = "/api/base/GetUserInfo"; + // 仅 Cookie 自身 Challenge(未登录访问需授权页 → 跳 LoginPath)时使用;实际登录走 CasScheme, + // 参数名须与 CasAuthenticationHandler.ReturnUrlParam("returnUrl") 一致,避免双参数名并存误导。 + options.ReturnUrlParameter = "returnUrl"; + // 退出即吊销:SignOutAsync(如业务 GET /api/Logout)清除 Cookie 时, + // 从请求 Cookie 取 32 位 key 立即删除服务端票据——否则票据残留到 ExpireTimeSpan 过期才失效。 + options.Events = new CookieAuthenticationEvents + { + OnSigningOut = context => + { + if (context.HttpContext.Request.Cookies.TryGetValue(auth.CookieName, out var rawKey)) + { + sessionTicketFormat.Revoke(rawKey); + } + return Task.CompletedTask; + }, + }; + }) + .AddScheme(YhCasDefaults.CasScheme, + o => configuration.GetSection("yh:sso").Bind(o)); + + services.AddSingleton(sp => + { + var opts = sp.GetRequiredService>().CurrentValue; + return new CasServiceTicketValidator(opts.CasServerUrlPrefix, opts.TicketValidateTimeoutMs); + }); + // 用户明细 SPI 默认实现(调 UserBase /oapi/user/byLoginName),UserContextMiddleware 依赖 + services.AddScoped(); + + return services; + } + } +}