From 77da92989557f15e9189a388983aba761f75126d Mon Sep 17 00:00:00 2001 From: wanghu Date: Thu, 3 Sep 2026 21:57:21 +0800 Subject: [PATCH] 4 --- .../Middleware/SsoProtectionMiddleware.cs | 169 +++++++++++------- src/YhCore.WebApi/appsettings.json | 2 +- 2 files changed, 104 insertions(+), 67 deletions(-) diff --git a/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs b/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs index 1dc4ff8..5e2ac44 100644 --- a/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs +++ b/src/YhCore.WebApi/Middleware/SsoProtectionMiddleware.cs @@ -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( - "登录失效" + - "" + - "

登录票据校验失败或已过期

" + - "

CAS 登录票据为一次性凭证,刷新或重复使用同一地址会导致校验失败。

" + - "

重新登录

"); - 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); } + /// + /// 触发框架 Challenge 生成 CAS 登录地址(returnUrl 回跳 + URL 编码 + service 构造统一由 + /// CasAuthenticationHandler.BuildServiceUrl 完成,本中间件不再手写 URL 拼接)。 + /// 受保护 API 的 AJAX/XHR 请求:拦截框架写入的 302 + Location,改写为 200 + JSON,避免 XHR + /// 跟随跨域 302 跳转 CAS 登录页时被 CORS 拦截;文档导航 / 根路径 / 回调仍保持 302。 + /// + 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 + "\"}"); + } + + /// 票据校验失败 401 错误页(带重新登录入口)。 + private static Task WriteTicketErrorAsync(HttpContext context) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.ContentType = "text/html; charset=utf-8"; + return context.Response.WriteAsync( + "登录失效" + + "" + + "

登录票据校验失败或已过期

" + + "

CAS 登录票据为一次性凭证,刷新或重复使用同一地址会导致校验失败。

" + + "

重新登录

"); + } + + /// 是否为 CAS 回调路径。 + private static bool IsCasCallback(PathString path, IConfigurationSection sso) + => path.StartsWithSegments(sso["signInCallbackPath"] ?? "/signin-cas"); + + /// 响应是否已经是框架写入的 302 重定向(用于回调校验成功后短路检查)。 + private static bool IsRedirect(HttpResponse response) + => response.StatusCode == StatusCodes.Status302Found + && !string.IsNullOrEmpty(response.Headers["Location"]); + /// /// 判断请求是否为 AJAX/XHR(而非浏览器整页文档导航)。任一命中即视为 AJAX: /// 1. X-Requested-With: XMLHttpRequest(jQuery/axios/原生 XHR 常见); @@ -190,4 +227,4 @@ namespace YhCore.WebApi.Middleware return string.IsNullOrEmpty(query) ? path : path + query; } } -} +} \ No newline at end of file diff --git a/src/YhCore.WebApi/appsettings.json b/src/YhCore.WebApi/appsettings.json index 776f3c1..6391676 100644 --- a/src/YhCore.WebApi/appsettings.json +++ b/src/YhCore.WebApi/appsettings.json @@ -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/*",