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