@@ -18,6 +18,8 @@ namespace YhCore.WebApi.Middleware
|
|||||||
/// 直接访问回调(无 ticket)进入登录流程;携带失效/已消费 ticket 的回调则返回 401 错误页,
|
/// 直接访问回调(无 ticket)进入登录流程;携带失效/已消费 ticket 的回调则返回 401 错误页,
|
||||||
/// 不再次 Challenge——避免“回调→登录→新票据→再失败”无限重定向循环(自动登录型 CAS 下必现);
|
/// 不再次 Challenge——避免“回调→登录→新票据→再失败”无限重定向循环(自动登录型 CAS 下必现);
|
||||||
/// 回调校验成功(已认证 + 302)则短路管道,防止 MVC 端点匹配 404 覆盖 handler 的回跳 302;
|
/// 回调校验成功(已认证 + 302)则短路管道,防止 MVC 端点匹配 404 覆盖 handler 的回跳 302;
|
||||||
|
/// - 受保护 API 的 AJAX/XHR 请求:复用电框架 Challenge 生成 CAS 登录 URL 后改写为 200 + JSON
|
||||||
|
/// (code:302 + urlToRedirectTo),避免 XHR 跟随跨域 302 被 CORS 拦截;文档导航仍走 302;
|
||||||
/// - Swagger 路径始终放行,方便本地接口调试;
|
/// - Swagger 路径始终放行,方便本地接口调试;
|
||||||
/// - /oapi 开放渠道始终放行:其准入由 AppAuthMiddleware 签名验签负责(X-App-Key / X-Timestamp /
|
/// - /oapi 开放渠道始终放行:其准入由 AppAuthMiddleware 签名验签负责(X-App-Key / X-Timestamp /
|
||||||
/// X-Signature 票据),无需用户登录态,SSO 登录保护永不覆盖该渠道(即使 yh:sso:urlPattern 误配
|
/// X-Signature 票据),无需用户登录态,SSO 登录保护永不覆盖该渠道(即使 yh:sso:urlPattern 误配
|
||||||
@@ -38,19 +40,15 @@ namespace YhCore.WebApi.Middleware
|
|||||||
public async Task InvokeAsync(HttpContext context)
|
public async Task InvokeAsync(HttpContext context)
|
||||||
{
|
{
|
||||||
var sso = _configuration.GetSection("yh:sso");
|
var sso = _configuration.GetSection("yh:sso");
|
||||||
var isSwaggerPath = context.Request.Path.StartsWithSegments("/swagger");
|
var path = context.Request.Path;
|
||||||
var isOapiPath = context.Request.Path.StartsWithSegments("/oapi");
|
|
||||||
var isRootEntry = context.Request.Path == "/";
|
|
||||||
var isCasCallback = context.Request.Path.StartsWithSegments(sso["signInCallbackPath"] ?? "/signin-cas");
|
|
||||||
|
|
||||||
// 已认证(含 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)
|
if (context.User?.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
// 回调路径刚完成票据校验:CasAuthenticationHandler 已写入 Cookie 并 Response.Redirect(302 + Location),
|
if (IsCasCallback(path, sso) && IsRedirect(context.Response))
|
||||||
// 但 handler 返回 NoResult 不短路管道——若继续 _next 走 MVC 端点匹配,/signin-cas 无对应 action,
|
|
||||||
// 会返回 404 覆盖 302,浏览器看到"登录成功却 404"。此处已认证 + 已 302 即短路,保住回跳响应。
|
|
||||||
if (isCasCallback && context.Response.StatusCode == 302 &&
|
|
||||||
!string.IsNullOrEmpty(context.Response.Headers["Location"]))
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -58,53 +56,82 @@ namespace YhCore.WebApi.Middleware
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// oapi 开放渠道:无需用户登录,准入由 AppAuthMiddleware 签名验签负责(票据 = 签名头),
|
// 2. oapi 开放渠道:无需用户登录,准入由 AppAuthMiddleware 签名验签负责(票据 = 签名头),
|
||||||
// 未带票据的请求由 AppAuthMiddleware 返回 401(而非 SSO 302),保证直接访问语义。
|
// 未带票据的请求由 AppAuthMiddleware 返回 401(而非 SSO 302),保证直接访问语义。
|
||||||
if (isOapiPath)
|
if (path.StartsWithSegments("/oapi"))
|
||||||
{
|
{
|
||||||
await _next(context);
|
await _next(context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var enabled = sso["enabled"] != null
|
// 3. Swagger 路径始终放行,方便本地接口调试。
|
||||||
? bool.TryParse(sso["enabled"], out var en) && en
|
if (path.StartsWithSegments("/swagger"))
|
||||||
: _configuration.GetValue("Yh:Sso:Enabled", false);
|
|
||||||
|
|
||||||
if (enabled && !isSwaggerPath &&
|
|
||||||
(isRootEntry || isCasCallback || IsProtectedPath(context.Request.Path.Value, sso)))
|
|
||||||
{
|
{
|
||||||
// 请求携带 ticket 却仍未认证 → 说明 CAS 票据校验失败(一次性票据已被消费/过期,
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 会立刻签发
|
// 或 service 与服务端登记不一致)。此时若再次 Challenge,自动登录型 CAS 会立刻签发
|
||||||
// 新票据跳回 → 再次失败 → 无限重定向循环。直接返回 401 错误页(带重新登录入口)打破循环。
|
// 新票据跳回 → 再次失败 → 无限重定向循环。直接返回 401 错误页(带重新登录入口)打破循环。
|
||||||
if (context.Request.Query.ContainsKey("ticket"))
|
if (context.Request.Query.ContainsKey("ticket"))
|
||||||
{
|
{
|
||||||
context.Response.StatusCode = 401;
|
await WriteTicketErrorAsync(context);
|
||||||
context.Response.ContentType = "text/html; charset=utf-8";
|
return;
|
||||||
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\">" +
|
// 7. 未认证 → Challenge → 交框架 CasAuthenticationHandler 处理(302 到 CAS 或改写 JSON)。
|
||||||
"<h2>登录票据校验失败或已过期</h2>" +
|
await ChallengeAsync(context, sso, isCasCallback);
|
||||||
"<p>CAS 登录票据为一次性凭证,刷新或重复使用同一地址会导致校验失败。</p>" +
|
}
|
||||||
"<p><a href=\"/\">重新登录</a></p></body></html>");
|
|
||||||
|
/// <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;
|
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();
|
var location = context.Response.Headers["Location"].ToString();
|
||||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||||
context.Response.Headers.Remove("Location");
|
context.Response.Headers.Remove("Location");
|
||||||
@@ -112,17 +139,27 @@ namespace YhCore.WebApi.Middleware
|
|||||||
await context.Response.WriteAsync("{\"code\":302,\"urlToRedirectTo\":\"" + location + "\"}");
|
await context.Response.WriteAsync("{\"code\":302,\"urlToRedirectTo\":\"" + location + "\"}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 未认证 → Challenge → 302 重定向 CAS 登录页(ticket 校验与回跳由新框架 CasAuthenticationHandler 处理)。
|
/// <summary>票据校验失败 401 错误页(带重新登录入口)。</summary>
|
||||||
// RedirectUri 传入当前请求地址,实现登录成功后回跳原页面(对齐框架 returnUrl 语义)。
|
private static Task WriteTicketErrorAsync(HttpContext context)
|
||||||
await context.ChallengeAsync(YhAuthDefaults.CasScheme, new AuthenticationProperties
|
|
||||||
{
|
{
|
||||||
RedirectUri = BuildReturnUrl(context.Request)
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
});
|
context.Response.ContentType = "text/html; charset=utf-8";
|
||||||
return;
|
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>");
|
||||||
}
|
}
|
||||||
|
|
||||||
await _next(context);
|
/// <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>
|
/// <summary>
|
||||||
/// 判断请求是否为 AJAX/XHR(而非浏览器整页文档导航)。任一命中即视为 AJAX:
|
/// 判断请求是否为 AJAX/XHR(而非浏览器整页文档导航)。任一命中即视为 AJAX:
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"sso": {
|
"sso": {
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
"CasServerUrlPrefix": "http://sso.yunhaixingtong.com/cas",
|
"CasServerUrlPrefix": "http://sso.yunhaixingtong.com/cas",
|
||||||
"AppBaseUrl": "http://localhost:51835",
|
"AppBaseUrl": "http://localhost:5000",
|
||||||
"SignInCallbackPath": "/signin-cas",
|
"SignInCallbackPath": "/signin-cas",
|
||||||
"TicketValidateTimeoutMs": 10000,
|
"TicketValidateTimeoutMs": 10000,
|
||||||
"UrlPattern": "/api/*",
|
"UrlPattern": "/api/*",
|
||||||
|
|||||||
Reference in New Issue
Block a user