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