@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 自研 CAS 远程认证 handler(本地化自 Yh.Platform.Auth,改造为平台旧版登录 URL 格式):
|
||||
/// <list type="bullet">
|
||||
/// <item>Challenge:302 到 CAS 登录页,service = {AppBaseUrl}{SignInCallbackPath}[?returnUrl=<回跳地址>];
|
||||
/// 回跳地址走 query 而非 Java 的路径矩阵参数 ;redirectToReferer=<hex>——ASP.NET Core 不支持矩阵参数,
|
||||
/// 分号会使 MVC 路由与 PathString.StartsWithSegments 双双失配,回调 404 覆盖掉本 handler 的 302 回跳;</item>
|
||||
/// <item>Authenticate:回调({SignInCallbackPath}?returnUrl=…&ticket=…,无 query 时为 ?ticket=)按
|
||||
/// path + 除 ticket 外的 query 重建 service 调 /serviceValidate 校验,成功后写入 Cookie 登录态并
|
||||
/// 302 到 returnUrl(兼容旧 ;redirectToReferer=<hex> 形态)。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class CasAuthenticationHandler : AuthenticationHandler<CasAuthenticationOptions>
|
||||
{
|
||||
private const string RedirectToRefererMarker = ";redirectToReferer=";
|
||||
|
||||
private const string ReturnUrlParam = "returnUrl";
|
||||
|
||||
private readonly CasServiceTicketValidator _validator;
|
||||
|
||||
public CasAuthenticationHandler(
|
||||
IOptionsMonitor<CasAuthenticationOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ISystemClock clock,
|
||||
CasServiceTicketValidator validator)
|
||||
: base(options, logger, encoder, clock)
|
||||
{
|
||||
_validator = validator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构造 service 回调地址:{root}{SignInCallbackPath}[?returnUrl=<urlencoded(returnUrl)>]。
|
||||
/// 回跳地址只允许本地相对路径(/xxx),杜绝开放重定向。
|
||||
/// 注意:回跳地址走 query 而非路径矩阵参数——ASP.NET Core 不识别矩阵参数,分号会破坏
|
||||
/// 路由匹配与 StartsWithSegments 的段比较(Java Spring 因 UrlPathHelper 默认
|
||||
/// removeSemicolonContent=true 剥离分号内容才可用矩阵参数,.NET 无此机制)。
|
||||
/// </summary>
|
||||
private string BuildServiceUrl(string returnUrl)
|
||||
{
|
||||
var callback = BuildCallbackRoot() + Options.SignInCallbackPath;
|
||||
if (string.IsNullOrEmpty(returnUrl) || !IsLocalUrl(returnUrl))
|
||||
{
|
||||
return callback;
|
||||
}
|
||||
return callback + "?" + ReturnUrlParam + "=" + Uri.EscapeDataString(returnUrl);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按当前请求重建登录时的 service:{root}{path}[?{除 ticket 外的 query}]。
|
||||
/// CAS 回调会在原 service 后追加 ?ticket=(已有 query 时 &ticket=),校验时必须还原为登录时的
|
||||
/// 原始 service,故剥掉 ticket;其余参数按 EscapeDataString 重新编码,与 BuildServiceUrl 保持一致。
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>取回调 query 中的 returnUrl(仅本地相对路径,非法返回 null)。</summary>
|
||||
private string ResolveReturnUrl()
|
||||
{
|
||||
if (!Context.Request.Query.TryGetValue(ReturnUrlParam, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var url = value.ToString();
|
||||
return IsLocalUrl(url) ? url : null;
|
||||
}
|
||||
|
||||
/// <summary>service 根地址:优先 AppBaseUrl 配置,为空时从当前请求推断。</summary>
|
||||
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<AuthenticateResult> 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<Claim>
|
||||
{
|
||||
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=<hex> 回调。
|
||||
// 仅限本地相对路径,防开放重定向;非法回根路径
|
||||
var referer = ResolveReturnUrl() ?? ParseRedirectToReferer(Context.Request.Path.Value);
|
||||
var redirect = referer != null && IsLocalUrl(referer) ? referer : "/";
|
||||
Response.Redirect(redirect);
|
||||
return AuthenticateResult.NoResult();
|
||||
}
|
||||
|
||||
/// <summary>从回调路径中解析 ;redirectToReferer= 后的 hex 并解码;无该段或编码非法返回 null。</summary>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>UTF-8 字节 → 小写十六进制(对齐平台旧版登录 URL 的 redirectToReferer 编码)。</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>小写/大写十六进制 → UTF-8 字符串;长度为奇数或含非十六进制字符时返回 null。</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>仅放行本地相对路径(/xxx);拒绝外站绝对地址(http(s)://…)、协议相对地址(//host)、反斜杠变体。</summary>
|
||||
private static bool IsLocalUrl(string url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url) || url[0] != '/')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return !url.StartsWith("//", StringComparison.Ordinal)
|
||||
&& !url.StartsWith("/\\", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 轻量 CAS service ticket 校验器(对齐 Java CasServiceTicketValidator,不依赖 CAS 客户端库)。
|
||||
/// 调用 CAS 的 /serviceValidate 端点,从响应 XML 中提取 <cas:user>。
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>校验 ticket,成功返回登录账号,失败/无效返回 null。</summary>
|
||||
public async Task<string> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 CAS 响应 XML 提取用户名。
|
||||
/// 成功响应形如 <cas:serviceResponse><cas:authenticationSuccess><cas:user>NAME</cas:user>...
|
||||
/// </summary>
|
||||
public static string ExtractUser(string xml)
|
||||
{
|
||||
if (string.IsNullOrEmpty(xml))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var start = xml.IndexOf("<cas:user>", StringComparison.Ordinal);
|
||||
if (start < 0)
|
||||
{
|
||||
start = xml.IndexOf("<cas:user ", StringComparison.Ordinal); // 容错带属性
|
||||
}
|
||||
if (start < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var tagEnd = xml.IndexOf('>', start);
|
||||
var end = xml.IndexOf("</cas:user>", tagEnd, StringComparison.Ordinal);
|
||||
if (tagEnd < 0 || end < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return xml.Substring(tagEnd + 1, end - tagEnd - 1).Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
|
||||
namespace YhCore.WebApi.Auth
|
||||
{
|
||||
/// <summary>
|
||||
/// 会话票据数据格式(自定义 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),不可枚举猜测;查表失败/过期即无效。
|
||||
/// </summary>
|
||||
public class SessionTicketDataFormat : ISecureDataFormat<AuthenticationTicket>
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AuthenticationTicket> _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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 吊销指定票据:显式退出(SignOut,如 /api/Logout)时服务端票据立即失效,无需等待过期兜底。
|
||||
/// </summary>
|
||||
public void Revoke(string key)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(key))
|
||||
{
|
||||
_tickets.TryRemove(key, out _);
|
||||
}
|
||||
CleanupExpiredIfDue();
|
||||
}
|
||||
|
||||
/// <summary>分钟级摊还清理过期票据,防内存膨胀(Interlocked 门闩,最多每分钟一次全量扫描)。</summary>
|
||||
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 _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
|
||||
namespace YhCore.WebApi.Auth
|
||||
{
|
||||
/// <summary>本地化 CAS 认证方案名常量(值与 Yh.Platform.Auth.YhAuthDefaults 保持一致,管线行为不变)。</summary>
|
||||
public static class YhCasDefaults
|
||||
{
|
||||
/// <summary>Cookie 认证方案(登录态载体)</summary>
|
||||
public const string CookieScheme = "YhCookie";
|
||||
|
||||
/// <summary>CAS 认证方案(challenge 时重定向 CAS 登录页)</summary>
|
||||
public const string CasScheme = "YhCas";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CAS 认证配置(service 指向本应用回调接口 /api/base/GetUserInfo,回跳地址以 query 参数
|
||||
/// returnUrl 携带;兼容平台旧版 ;redirectToReferer=<hex> 形态的已发出链接)。
|
||||
/// </summary>
|
||||
public class CasAuthenticationOptions : AuthenticationSchemeOptions
|
||||
{
|
||||
/// <summary>CAS 服务端地址前缀,如 http://sso.yunhaixingtong.com(无 /cas 上下文,拼接规则为前缀 + /login)</summary>
|
||||
public string CasServerUrlPrefix { get; set; }
|
||||
|
||||
/// <summary>本应用根地址(用于拼 service 回调地址),为空时从当前请求推断</summary>
|
||||
public string AppBaseUrl { get; set; }
|
||||
|
||||
/// <summary>ticket 回调路径(service = AppBaseUrl + 本路径[?returnUrl=<urlencoded>])</summary>
|
||||
public string SignInCallbackPath { get; set; } = "/api/base/GetUserInfo";
|
||||
|
||||
/// <summary>ticket 校验超时(毫秒)</summary>
|
||||
public int TicketValidateTimeoutMs { get; set; } = 5000;
|
||||
}
|
||||
|
||||
/// <summary>通用鉴权配置(配置节 yh:auth)。</summary>
|
||||
public class YhAuthOptions
|
||||
{
|
||||
public const string SectionName = "yh:auth";
|
||||
|
||||
/// <summary>是否启用(默认 true)</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>登录态有效期(分钟),固定过期,不滑动续期</summary>
|
||||
public int CookieExpireMinutes { get; set; } = 30;
|
||||
|
||||
/// <summary>Cookie 名</summary>
|
||||
public string CookieName { get; set; } = ".yh.auth";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 本地化 CAS 认证装配(对齐 Yh.Platform.Auth 的 AddYhAuth 认证部分;service 构造为
|
||||
/// {root}{SignInCallbackPath}[?returnUrl=<urlencoded>]——回跳走 query 而非平台旧版
|
||||
/// ;redirectToReferer=<hex> 矩阵参数(ASP.NET Core 不支持矩阵参数,见 CasAuthenticationHandler)。
|
||||
/// 不含 AddYhOpenApi,OpenApi/AppAuth 由宿主另行注册)。
|
||||
/// 配置节:<c>yh:auth</c>(通用)与 <c>yh:sso</c>(CAS 服务端)。
|
||||
/// </summary>
|
||||
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<YhAuthOptions>().Bind(configuration.GetSection(YhAuthOptions.SectionName));
|
||||
// 注:AddScheme 的 configure 才是 handler 实际读取的命名 options(AuthenticationHandler<TOptions>.Options);
|
||||
// 必须在此绑定 yh:sso,否则 handler 拿到的 CasAuthenticationOptions 为空(Challenge 时 CasServerUrlPrefix 缺失)。
|
||||
services.AddOptions<CasAuthenticationOptions>().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<YhAuthOptions>() ?? 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<CasAuthenticationOptions, CasAuthenticationHandler>(YhCasDefaults.CasScheme,
|
||||
o => configuration.GetSection("yh:sso").Bind(o));
|
||||
|
||||
services.AddSingleton<CasServiceTicketValidator>(sp =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<IOptionsMonitor<CasAuthenticationOptions>>().CurrentValue;
|
||||
return new CasServiceTicketValidator(opts.CasServerUrlPrefix, opts.TicketValidateTimeoutMs);
|
||||
});
|
||||
// 用户明细 SPI 默认实现(调 UserBase /oapi/user/byLoginName),UserContextMiddleware 依赖
|
||||
services.AddScoped<Yh.Platform.Auth.IUserDetailsProvider, Yh.Platform.Auth.OpenApiUserDetailsProvider>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user