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; // SameSite=Lax:登录/回调全部是顶层导航,Lax 足够。 // 【勿改 None】HTTP 部署下无 Secure 属性的 SameSite=None Cookie 会被浏览器直接丢弃 // (Chrome/Edge 强制 None 必须 Secure),导致登录 Cookie 永远种不上 → 无限重定向循环 // (2026-09-07 线上复现:验票成功但下一请求无 Cookie → ERR_TOO_MANY_REDIRECTS)。 // 跨 host 登录态需求由 DebugUser 头方案解决(见 docs/本地前端联调线上后端SSO方案.md)。 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; } } }