@@ -18,6 +18,8 @@ namespace YhCore.WebApi.Middleware
|
||||
/// 直接访问回调(无 ticket)进入登录流程;携带失效/已消费 ticket 的回调则返回 401 错误页,
|
||||
/// 不再次 Challenge——避免“回调→登录→新票据→再失败”无限重定向循环(自动登录型 CAS 下必现);
|
||||
/// 回调校验成功(已认证 + 302)则短路管道,防止 MVC 端点匹配 404 覆盖 handler 的回跳 302;
|
||||
/// - 受保护 API 的 AJAX/XHR 请求:复用电框架 Challenge 生成 CAS 登录 URL 后改写为 200 + JSON
|
||||
/// (code:302 + urlToRedirectTo),避免 XHR 跟随跨域 302 被 CORS 拦截;文档导航仍走 302;
|
||||
/// - Swagger 路径始终放行,方便本地接口调试;
|
||||
/// - /oapi 开放渠道始终放行:其准入由 AppAuthMiddleware 签名验签负责(X-App-Key / X-Timestamp /
|
||||
/// X-Signature 票据),无需用户登录态,SSO 登录保护永不覆盖该渠道(即使 yh:sso:urlPattern 误配
|
||||
@@ -38,19 +40,15 @@ namespace YhCore.WebApi.Middleware
|
||||
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");
|
||||
var path = context.Request.Path;
|
||||
|
||||
// 已认证(含 Cookie 恢复登录态)直接放行
|
||||
// 1. 已认证(含 Cookie 恢复登录态)直接放行。
|
||||
// 回调路径刚完成票据校验:CasAuthenticationHandler 已写入 Cookie 并 Response.Redirect(302 + Location),
|
||||
// 但 handler 返回 NoResult 不短路管道——若继续 _next 走 MVC,/signin-cas 无对应 action 会 404 覆盖 302,
|
||||
// 浏览器看到"登录成功却 404"。此处已认证 + 已 302 即短路,保住回跳响应。
|
||||
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"]))
|
||||
if (IsCasCallback(path, sso) && IsRedirect(context.Response))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -58,72 +56,111 @@ namespace YhCore.WebApi.Middleware
|
||||
return;
|
||||
}
|
||||
|
||||
// oapi 开放渠道:无需用户登录,准入由 AppAuthMiddleware 签名验签负责(票据 = 签名头),
|
||||
// 未带票据的请求由 AppAuthMiddleware 返回 401(而非 SSO 302),保证直接访问语义。
|
||||
if (isOapiPath)
|
||||
// 2. oapi 开放渠道:无需用户登录,准入由 AppAuthMiddleware 签名验签负责(票据 = 签名头),
|
||||
// 未带票据的请求由 AppAuthMiddleware 返回 401(而非 SSO 302),保证直接访问语义。
|
||||
if (path.StartsWithSegments("/oapi"))
|
||||
{
|
||||
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)))
|
||||
// 3. Swagger 路径始终放行,方便本地接口调试。
|
||||
if (path.StartsWithSegments("/swagger"))
|
||||
{
|
||||
// 请求携带 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;
|
||||
}
|
||||
// API 渠道(/api/*、/mobiapi/*)的 AJAX/XHR 请求:未认证时不直接 302,而是复用框架
|
||||
// CasAuthenticationHandler 生成 CAS 登录 URL 后改写为 200 + JSON(code:302 + urlToRedirectTo)。
|
||||
// 原因:浏览器 fetch/XHR 会跟随 302 跨域跳转到 sso.yunhaixingtong.com,而 CAS 登录页无
|
||||
// Access-Control-Allow-Origin 头,导致 CORS 拦截(Response to preflight request ... blocked)。
|
||||
// 前端据此 urlToRedirectTo 做整页跳转登录。文档导航(浏览器地址栏 / Accept:text/html)仍走 302。
|
||||
if (IsProtectedPath(context.Request.Path.Value, sso) && IsAjaxRequest(context.Request))
|
||||
{
|
||||
// 复用框架 Challenge 统一构造 CAS 登录 URL:与 CasAuthenticationHandler.HandleChallengeAsync
|
||||
// 使用同一 BuildServiceUrl 逻辑(AppBaseUrl + SignInCallbackPath + returnUrl 回跳),
|
||||
// 自动获得 yh:sso:cas-server-url-prefix 未配置时返回 500、returnUrl 回跳、URL 编码等一致性保障,
|
||||
// 避免本中间件重复手写 URL 拼接。RedirectUri 传入当前请求路径,登录成功后回跳原地址。
|
||||
await context.ChallengeAsync(YhAuthDefaults.CasScheme, new AuthenticationProperties
|
||||
{
|
||||
RedirectUri = BuildReturnUrl(context.Request)
|
||||
});
|
||||
|
||||
// 拦截框架写入的 302 + Location,改写为 200 + JSON,避免 XHR 跟随跨域 302 被 CORS 拦截
|
||||
var location = context.Response.Headers["Location"].ToString();
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
context.Response.Headers.Remove("Location");
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
await context.Response.WriteAsync("{\"code\":302,\"urlToRedirectTo\":\"" + location + "\"}");
|
||||
}
|
||||
|
||||
// 未认证 → Challenge → 302 重定向 CAS 登录页(ticket 校验与回跳由新框架 CasAuthenticationHandler 处理)。
|
||||
// RedirectUri 传入当前请求地址,实现登录成功后回跳原页面(对齐框架 returnUrl 语义)。
|
||||
await context.ChallengeAsync(YhAuthDefaults.CasScheme, new AuthenticationProperties
|
||||
{
|
||||
RedirectUri = BuildReturnUrl(context.Request)
|
||||
});
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
// 4. SSO 未启用 → 所有路径匿名放行。
|
||||
if (!_configuration.GetValue("yh:sso:enabled", false))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 仅保护根路径(浏览器 SSO 入口)、CAS 回调路径、以及匹配 urlPattern/mobiApiPattern 的 API 路径。
|
||||
var isRootEntry = path == "/";
|
||||
var isCasCallback = IsCasCallback(path, sso);
|
||||
if (!isRootEntry && !isCasCallback && !IsProtectedPath(path.Value, sso))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. 请求携带 ticket 却仍未认证 → 说明 CAS 票据校验失败(一次性票据已被消费/过期,
|
||||
// 或 service 与服务端登记不一致)。此时若再次 Challenge,自动登录型 CAS 会立刻签发
|
||||
// 新票据跳回 → 再次失败 → 无限重定向循环。直接返回 401 错误页(带重新登录入口)打破循环。
|
||||
if (context.Request.Query.ContainsKey("ticket"))
|
||||
{
|
||||
await WriteTicketErrorAsync(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// 7. 未认证 → Challenge → 交框架 CasAuthenticationHandler 处理(302 到 CAS 或改写 JSON)。
|
||||
await ChallengeAsync(context, sso, isCasCallback);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 触发框架 Challenge 生成 CAS 登录地址(returnUrl 回跳 + URL 编码 + service 构造统一由
|
||||
/// CasAuthenticationHandler.BuildServiceUrl 完成,本中间件不再手写 URL 拼接)。
|
||||
/// 受保护 API 的 AJAX/XHR 请求:拦截框架写入的 302 + Location,改写为 200 + JSON,避免 XHR
|
||||
/// 跟随跨域 302 跳转 CAS 登录页时被 CORS 拦截;文档导航 / 根路径 / 回调仍保持 302。
|
||||
/// </summary>
|
||||
private static async Task ChallengeAsync(HttpContext context, IConfigurationSection sso, bool isCasCallback)
|
||||
{
|
||||
var request = context.Request;
|
||||
|
||||
// 只有受保护 API 路径的 AJAX 请求需要改写为 JSON;根路径 / 回调是文档导航语义,始终 302。
|
||||
var isApiAjax = IsProtectedPath(request.Path.Value, sso) && IsAjaxRequest(request);
|
||||
|
||||
var properties = new AuthenticationProperties
|
||||
{
|
||||
// 登录后回跳原地址;回调路径回跳根路径(避免登录后回跳回调自身再次触发 Challenge)。
|
||||
RedirectUri = isCasCallback ? "/" : BuildReturnUrl(request)
|
||||
};
|
||||
|
||||
await context.ChallengeAsync(YhAuthDefaults.CasScheme, properties);
|
||||
|
||||
if (!isApiAjax)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 仅当框架成功写入 302 + Location 时才改写 JSON;若未配置 CAS(框架返回 500)保留原始错误响应,不误改写。
|
||||
if (context.Response.StatusCode != StatusCodes.Status302Found)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var location = context.Response.Headers["Location"].ToString();
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
context.Response.Headers.Remove("Location");
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
await context.Response.WriteAsync("{\"code\":302,\"urlToRedirectTo\":\"" + location + "\"}");
|
||||
}
|
||||
|
||||
/// <summary>票据校验失败 401 错误页(带重新登录入口)。</summary>
|
||||
private static Task WriteTicketErrorAsync(HttpContext context)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
return 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>");
|
||||
}
|
||||
|
||||
/// <summary>是否为 CAS 回调路径。</summary>
|
||||
private static bool IsCasCallback(PathString path, IConfigurationSection sso)
|
||||
=> path.StartsWithSegments(sso["signInCallbackPath"] ?? "/signin-cas");
|
||||
|
||||
/// <summary>响应是否已经是框架写入的 302 重定向(用于回调校验成功后短路检查)。</summary>
|
||||
private static bool IsRedirect(HttpResponse response)
|
||||
=> response.StatusCode == StatusCodes.Status302Found
|
||||
&& !string.IsNullOrEmpty(response.Headers["Location"]);
|
||||
|
||||
/// <summary>
|
||||
/// 判断请求是否为 AJAX/XHR(而非浏览器整页文档导航)。任一命中即视为 AJAX:
|
||||
/// 1. X-Requested-With: XMLHttpRequest(jQuery/axios/原生 XHR 常见);
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"sso": {
|
||||
"Enabled": true,
|
||||
"CasServerUrlPrefix": "http://sso.yunhaixingtong.com/cas",
|
||||
"AppBaseUrl": "http://localhost:51835",
|
||||
"AppBaseUrl": "http://localhost:5000",
|
||||
"SignInCallbackPath": "/signin-cas",
|
||||
"TicketValidateTimeoutMs": 10000,
|
||||
"UrlPattern": "/api/*",
|
||||
|
||||
Reference in New Issue
Block a user