Files
DocprintService/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs
T
2026-09-02 10:23:24 +08:00

129 lines
6.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Yh.Platform.Auth;
namespace YhCore.WebApi.Middleware
{
/// <summary>
/// SSO 路径保护中间件(对应 Java yh-auth-spring-boot-starter 的 /api/* 保护行为)。
/// 规则(对齐 yh:sso 配置节):
/// - yh:sso:enabled=false(默认):所有路径匿名放行,不影响开发联调;
/// - yh:sso:enabled=true:匹配 /api/* / /mobiapi/* / 根路径(/, 浏览器 SSO 入口) 且未认证的请求
/// → Challenge → Yh.Platform.Auth CasAuthenticationHandler 302 重定向到 CAS 登录页;
/// - CAS 回调路径(yh:sso:signInCallbackPath,默认 /signin-cas)未认证时同样 Challenge:
/// 直接访问回调(无 ticket)进入登录流程;携带失效/已消费 ticket 的回调则返回 401 错误页,
/// 不再次 Challenge——避免“回调→登录→新票据→再失败”无限重定向循环(自动登录型 CAS 下必现);
/// 回调校验成功(已认证 + 302)则短路管道,防止 MVC 端点匹配 404 覆盖 handler 的回跳 302;
/// - Swagger 路径始终放行,方便本地接口调试;
/// - /oapi 开放渠道始终放行:其准入由 AppAuthMiddleware 签名验签负责(X-App-Key / X-Timestamp /
/// X-Signature 票据),无需用户登录态,SSO 登录保护永不覆盖该渠道(即使 yh:sso:urlPattern 误配
/// /oapi/* 也不拦截),保证应用间调用不受浏览器登录流程影响。
/// 调用方:Startup.Configure 中 UseAuthentication + UseYhUserContext 之后、UseAuthorization 之前。
/// </summary>
public class SsoProtectionMiddleware
{
private readonly RequestDelegate _next;
private readonly IConfiguration _configuration;
public SsoProtectionMiddleware(RequestDelegate next, IConfiguration configuration)
{
_next = next;
_configuration = configuration;
}
public async Task InvokeAsync(HttpContext context)
{
var sso = _configuration.GetSection("yh:sso");
var isSwaggerPath = context.Request.Path.StartsWithSegments("/swagger");
var isOapiPath = context.Request.Path.StartsWithSegments("/oapi");
var isRootEntry = context.Request.Path == "/";
var isCasCallback = context.Request.Path.StartsWithSegments(sso["signInCallbackPath"] ?? "/signin-cas");
// 已认证(含 Cookie 恢复登录态)直接放行
if (context.User?.Identity?.IsAuthenticated == true)
{
// 回调路径刚完成票据校验:CasAuthenticationHandler 已写入 Cookie 并 Response.Redirect(302 + Location),
// 但 handler 返回 NoResult 不短路管道——若继续 _next 走 MVC 端点匹配,/signin-cas 无对应 action,
// 会返回 404 覆盖 302,浏览器看到"登录成功却 404"。此处已认证 + 已 302 即短路,保住回跳响应。
if (isCasCallback && context.Response.StatusCode == 302 &&
!string.IsNullOrEmpty(context.Response.Headers["Location"]))
{
return;
}
await _next(context);
return;
}
// oapi 开放渠道:无需用户登录,准入由 AppAuthMiddleware 签名验签负责(票据 = 签名头),
// 未带票据的请求由 AppAuthMiddleware 返回 401(而非 SSO 302),保证直接访问语义。
if (isOapiPath)
{
await _next(context);
return;
}
var enabled = sso["enabled"] != null
? bool.TryParse(sso["enabled"], out var en) && en
: _configuration.GetValue("Yh:Sso:Enabled", false);
if (enabled && !isSwaggerPath &&
(isRootEntry || isCasCallback || IsProtectedPath(context.Request.Path.Value, sso)))
{
// 请求携带 ticket 却仍未认证 → 说明 CAS 票据校验失败(一次性票据已被消费/过期,
// 或 service 与服务端登记不一致)。此时若再次 Challenge,自动登录型 CAS 会立刻签发
// 新票据跳回 → 再次失败 → 无限重定向循环。直接返回 401 错误页(带重新登录入口)打破循环。
if (context.Request.Query.ContainsKey("ticket"))
{
context.Response.StatusCode = 401;
context.Response.ContentType = "text/html; charset=utf-8";
await context.Response.WriteAsync(
"<!DOCTYPE html><html lang=\"zh\"><head><meta charset=\"utf-8\"><title>登录失效</title></head>" +
"<body style=\"font-family:system-ui;text-align:center;padding-top:80px\">" +
"<h2>登录票据校验失败或已过期</h2>" +
"<p>CAS 登录票据为一次性凭证,刷新或重复使用同一地址会导致校验失败。</p>" +
"<p><a href=\"/\">重新登录</a></p></body></html>");
return;
}
// 未认证 → Challenge → 302 重定向 CAS 登录页(ticket 校验与回跳由新框架 CasAuthenticationHandler 处理)
await context.ChallengeAsync(YhAuthDefaults.CasScheme);
return;
}
await _next(context);
}
/// <summary>路径是否命中保护模式(yh:sso:urlPattern / yh:sso:mobiApiPattern,默认 /api/*、/mobiapi/*,通配后缀匹配,忽略大小写)</summary>
private static bool IsProtectedPath(string path, IConfigurationSection sso)
{
if (string.IsNullOrEmpty(path))
{
return false;
}
var urlPattern = sso["urlPattern"] ?? "/api/*";
var mobiPattern = sso["mobiApiPattern"] ?? "/mobiapi/*";
return MatchesPattern(path, urlPattern) || MatchesPattern(path, mobiPattern);
}
private static bool MatchesPattern(string path, string pattern)
{
if (string.IsNullOrEmpty(pattern) || string.IsNullOrEmpty(path))
{
return false;
}
// 形如 /api/* → 前缀匹配;否则精确匹配(忽略大小写)
if (pattern.EndsWith("/*", StringComparison.Ordinal))
{
var prefix = pattern.Substring(0, pattern.Length - 1); // 去掉 *
return path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
}
return path.Equals(pattern, StringComparison.OrdinalIgnoreCase);
}
}
}