From 7e3690e523e3d2bb8f966ee99b66011423ad1998 Mon Sep 17 00:00:00 2001 From: chenxing Date: Sun, 11 Oct 2026 12:46:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(sso):=20=E6=B2=BB=E7=90=86=E9=9D=A2=20CAS?= =?UTF-8?q?=20=E7=99=BB=E5=BD=95=20+=20=E6=9C=BA=E5=99=A8=E9=9D=A2?= =?UTF-8?q?=E5=86=85=E9=83=A8=E4=BB=A4=E7=89=8C=E9=97=A8=20+=20=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E8=A1=A8=E9=87=8D=E8=BD=BD=E5=8F=8C=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 治理面浏览器 SSO(/api/internal/admin/*):cas-client-core 3.5.1 固定 service 票据校验 (ctx 为空、外链前缀由 Caddy 剥离,按请求路径拼 service 必 404,故用固定回跳靶点); 未登录双形态应答——带 yschapirequest:1 出 200+{code:302,urlToRedirectTo}(平台同构, alova 直接整页跳),裸调用出 401 治理面信封;OPENGW_SSO_ENABLED 默认关, 回滚=清 env 重新 Apply,不回退镜像 - 机器面(/api/internal/* 其余)收口 InternalTokenFilter:只认 X-Internal-Token, 值走 OPENGW_INTERNAL_TOKEN env,空=灰度放行(projman 路由同步不打断); 比较用 XOR 折叠常量时间(String.equals 提前短路有时序侧信道;MessageDigest.isEqual 在 JDK8 非 const-time,JDK9 才修) - 注册表重载抽 RegistryReloader:机器面 /routes/refresh 与控制台按钮 /admin/routes/reload 共用同一实现(一接口面一种认证,不做令牌 OR 会话混判) - 治理会话 cookie 改名 OPENGW_SESSION:ctx 为空时默认 JSESSIONID(Path=/) 会与 base 域各应用同名叠放进同一个 Cookie 头 - session 端点在 CAS 面未装配时回空 ssoHost:前端据此隐藏「退出」,不在匿名端点 泄漏 CAS 基址、灰度期不出现平台级注销入口 - 新增 44 用例:面守卫机械钉桩(全仓控制器映射必须声明鉴权归属)+ 装配红线 (默认关/序位/固定 service 原样采用)+ 双形态应答 + 令牌三态/灰度窗 + 重载器 --- pom.xml | 14 +- .../admin/InternalAdminController.java | 24 +++ .../gateway/auth/InternalTokenFilter.java | 122 +++++++++++ .../InternalTokenFilterConfiguration.java | 30 +++ .../gateway/envelope/EnvelopeWriter.java | 19 ++ .../gateway/route/GatewayProperties.java | 19 +- .../gateway/route/RegistryReloader.java | 55 +++++ .../gateway/route/RouteRefreshController.java | 32 ++- .../gateway/sso/BrowserAuthGateFilter.java | 64 ++++++ .../sso/GatewayCasValidationFilter.java | 56 +++++ .../platform/gateway/sso/SsoAddressing.java | 127 ++++++++++++ .../gateway/sso/SsoEntryController.java | 46 ++++ .../gateway/sso/SsoFilterConfiguration.java | 97 +++++++++ .../platform/gateway/sso/SsoProperties.java | 82 ++++++++ .../platform/gateway/sso/SsoResponses.java | 63 ++++++ .../gateway/sso/SsoSessionController.java | 63 ++++++ src/main/resources/application.yml | 28 +++ .../admin/InternalAdminControllerTest.java | 23 ++ .../gateway/auth/InternalTokenFilterTest.java | 137 ++++++++++++ .../gateway/route/RegistryReloaderTest.java | 60 ++++++ .../sso/BrowserAuthGateFilterTest.java | 128 ++++++++++++ .../sso/GatewayCasValidationFilterTest.java | 59 ++++++ .../gateway/sso/SsoAddressingTest.java | 86 ++++++++ .../gateway/sso/SsoConfigBaselineTest.java | 56 +++++ .../gateway/sso/SsoFilterWiringTest.java | 129 ++++++++++++ .../gateway/sso/SsoSurfaceGuardTest.java | 196 ++++++++++++++++++ 26 files changed, 1793 insertions(+), 22 deletions(-) create mode 100644 src/main/java/com/yunhai/platform/gateway/auth/InternalTokenFilter.java create mode 100644 src/main/java/com/yunhai/platform/gateway/auth/InternalTokenFilterConfiguration.java create mode 100644 src/main/java/com/yunhai/platform/gateway/route/RegistryReloader.java create mode 100644 src/main/java/com/yunhai/platform/gateway/sso/BrowserAuthGateFilter.java create mode 100644 src/main/java/com/yunhai/platform/gateway/sso/GatewayCasValidationFilter.java create mode 100644 src/main/java/com/yunhai/platform/gateway/sso/SsoAddressing.java create mode 100644 src/main/java/com/yunhai/platform/gateway/sso/SsoEntryController.java create mode 100644 src/main/java/com/yunhai/platform/gateway/sso/SsoFilterConfiguration.java create mode 100644 src/main/java/com/yunhai/platform/gateway/sso/SsoProperties.java create mode 100644 src/main/java/com/yunhai/platform/gateway/sso/SsoResponses.java create mode 100644 src/main/java/com/yunhai/platform/gateway/sso/SsoSessionController.java create mode 100644 src/test/java/com/yunhai/platform/gateway/auth/InternalTokenFilterTest.java create mode 100644 src/test/java/com/yunhai/platform/gateway/route/RegistryReloaderTest.java create mode 100644 src/test/java/com/yunhai/platform/gateway/sso/BrowserAuthGateFilterTest.java create mode 100644 src/test/java/com/yunhai/platform/gateway/sso/GatewayCasValidationFilterTest.java create mode 100644 src/test/java/com/yunhai/platform/gateway/sso/SsoAddressingTest.java create mode 100644 src/test/java/com/yunhai/platform/gateway/sso/SsoConfigBaselineTest.java create mode 100644 src/test/java/com/yunhai/platform/gateway/sso/SsoFilterWiringTest.java create mode 100644 src/test/java/com/yunhai/platform/gateway/sso/SsoSurfaceGuardTest.java diff --git a/pom.xml b/pom.xml index f928a8c..e8027c3 100644 --- a/pom.xml +++ b/pom.xml @@ -37,7 +37,12 @@ - + org.springframework.boot spring-boot-starter-web @@ -103,6 +108,13 @@ springdoc-openapi-ui 1.7.0 + + + org.jasig.cas.client + cas-client-core + 3.5.1 + diff --git a/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java b/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java index 6bfdaab..246d589 100644 --- a/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java +++ b/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java @@ -10,6 +10,7 @@ import com.yunhai.platform.gateway.repository.OpenApiAppRepository; import com.yunhai.platform.gateway.repository.OpenApiLogRepository; import com.yunhai.platform.gateway.repository.OpenApiRouteRepository; import com.yunhai.platform.gateway.repository.OpenApiTargetRepository; +import com.yunhai.platform.gateway.route.RegistryReloader; import com.yunhai.platform.gateway.route.RouteChangeBroadcaster; import com.yunhai.platform.gateway.route.RouteRegistry; import com.yunhai.platform.utils.exception.ErrorCode; @@ -98,6 +99,10 @@ public class InternalAdminController { @Autowired(required = false) private RouteChangeBroadcaster broadcaster; + /** 注册表全量重载(与机器面 {@code /api/internal/routes/refresh} 共用实现) */ + @Autowired + private RegistryReloader reloader; + /** 测试/显式装配入口 */ void setRepos(OpenApiRouteRepository routeRepo, OpenApiTargetRepository targetRepo, OpenApiLogRepository logRepo, RouteRegistry routeRegistry, @@ -110,6 +115,25 @@ public class InternalAdminController { this.appAuthRegistry = appAuthRegistry; } + /** 测试/显式装配入口 */ + void setReloader(RegistryReloader reloader) { + this.reloader = reloader; + } + + /** + * 控制台「重载注册表」按钮:浏览器面入口,与机器面 {@code POST /api/internal/routes/refresh} + * 同实现({@link RegistryReloader})、同返回体形态。 + *

+ * 为什么要两个口:按钮此前直接调机器面那个路径,而机器面收口成「只认内部令牌」后, + * 浏览器带 CAS 会话去打它会被 401 拦死。平台不变量是一个接口面一种认证、互不串门, + * 所以给浏览器面单开入口,而不是在同一端点上做「令牌 OR 会话」。 + */ + @Operation(operationId = "GatewayAdmin.reload", summary = "热重载注册表(控制台按钮)") + @PostMapping("/routes/reload") + public Map reload() { + return reloader.reloadAll(); + } + /** 路由表分页(治理对象全量含禁用行;targetCode+pathPattern 稳定排序) */ @Operation(operationId = "GatewayAdmin.routes", summary = "路由注册表分页") @GetMapping("/routes") diff --git a/src/main/java/com/yunhai/platform/gateway/auth/InternalTokenFilter.java b/src/main/java/com/yunhai/platform/gateway/auth/InternalTokenFilter.java new file mode 100644 index 0000000..c10ab8f --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/auth/InternalTokenFilter.java @@ -0,0 +1,122 @@ +package com.yunhai.platform.gateway.auth; + +import com.yunhai.platform.gateway.envelope.EnvelopeWriter; +import com.yunhai.platform.gateway.route.GatewayProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** + * 机器面闸门({@code /api/internal/**} 中除浏览器面之外的一切):只认共享内部令牌。 + *

+ * 为什么必须有这一层,而不是「CAS 面里放个令牌的 OR 分支」:基础平台的 CAS 过滤器 + * ({@code CasSsoAutoConfiguration})对六个 bean 各自只 {@code addUrlPatterns(单值)}, + * 也不读 {@code ignorePattern}——过滤器层根本不存在豁免机制。平台对机器流量的做法是 + * 一律靠路径前缀在过滤器之外分流({@code /mobiapi}、{@code /oapi})。本闸门同此范式: + * CAS 面收窄到 {@link #BROWSER_SURFACE_PREFIX},本闸门接管 {@code /api/internal/*} 的其余部分。 + *

+ * 判定顺序=默认拒绝:落在 {@code /api/internal/*} 内、又不在浏览器面前缀下的路径, + * 一律要求令牌——将来新增的内部端点不会因为「没人想到要保护」而裸奔。 + *

+ * 实测调用方清单(本次侦察结论,关键字 {@code api/internal} 全平台扫描):仅 + * projman 一家——{@code OpenApiTargetSyncService} 在 Apply 后 POST + * {@code http://open-gateway-service:10013/api/internal/routes/refresh}。 + * Notice 的 {@code /oapi/flow/callback} 与 flow-center 的回调都打 {@code /oapi/**}(应用签名面), + * UserBase 的 {@code /oapi/internal/**} 是它自己的内部密钥端点,均与本闸门无关。 + *

+ * 令牌值只来自配置({@code yh.gateway.internal-token} ← 平台应用 env),不落代码、不落库。 + */ +public class InternalTokenFilter implements Filter { + + /** 调用方约定的头名(projman 侧同值;改这里必须同步改发送端) */ + public static final String HEADER = "X-Internal-Token"; + + /** 本闸门的圈定路径:浏览器面也在这个前缀下,由 {@link #BROWSER_SURFACE_PREFIX} 例外放行 */ + public static final String URL_PATTERN = "/api/internal/*"; + + /** 浏览器面(CAS 会话门负责)——本闸门必须放过它,否则控制台会被要求带内部令牌 */ + public static final String BROWSER_SURFACE_PREFIX = "/api/internal/admin"; + + private static final Logger LOG = LoggerFactory.getLogger(InternalTokenFilter.class); + + private final GatewayProperties properties; + + /** 未配令牌只出声一次:每次内部调用都 WARN 会在灰度期把日志刷成噪声,反而看不见 */ + private volatile boolean warnedBlankToken; + + public InternalTokenFilter(GatewayProperties properties) { + this.properties = properties; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) { + chain.doFilter(request, response); + return; + } + HttpServletRequest req = (HttpServletRequest) request; + HttpServletResponse res = (HttpServletResponse) response; + String path = req.getServletPath(); + if (isBrowserSurface(path)) { + chain.doFilter(request, response); + return; + } + String expected = properties.getInternalToken(); + if (expected == null || expected.trim().isEmpty()) { + // 灰度窗口:网关先上、令牌 env 后配(D3 决议)。此状态下机器面维持接入前的可达性, + // 不因「先发网关」而打断 projman 的路由同步;配好 env 即自动收口。 + warnBlankToken(); + chain.doFilter(request, response); + return; + } + if (!constantTimeEquals(expected, req.getHeader(HEADER))) { + EnvelopeWriter.writeUnauthorized(res, "内部令牌缺失或不匹配"); + return; + } + chain.doFilter(request, response); + } + + /** + * 常量时间比较:共享令牌经明文头走网络,{@code String.equals} 逐字节提前短路的时序 + * 侧信道在这里没有代价可免。XOR 折叠到单个差值再判定,不走 + * {@code MessageDigest.isEqual}——它在 JDK 8(平台当前运行时)上首字节失配即返回, + * JDK 9 才修成常量时间。 + */ + private static boolean constantTimeEquals(String expected, String provided) { + if (provided == null) { + return false; + } + byte[] a = expected.getBytes(StandardCharsets.UTF_8); + byte[] b = provided.getBytes(StandardCharsets.UTF_8); + int diff = a.length ^ b.length; + int n = Math.min(a.length, b.length); + for (int i = 0; i < n; i++) { + diff |= a[i] ^ b[i]; + } + return diff == 0; + } + + /** 浏览器面判定:前缀本身或其下级路径 */ + private static boolean isBrowserSurface(String path) { + return path.equals(BROWSER_SURFACE_PREFIX) || path.startsWith(BROWSER_SURFACE_PREFIX + "/"); + } + + private void warnBlankToken() { + if (warnedBlankToken) { + return; + } + warnedBlankToken = true; + LOG.warn("yh.gateway.internal-token 未配置:机器面 {} 暂不校验 {}(灰度窗口态,配好应用 env 即收口)", + URL_PATTERN, HEADER); + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/auth/InternalTokenFilterConfiguration.java b/src/main/java/com/yunhai/platform/gateway/auth/InternalTokenFilterConfiguration.java new file mode 100644 index 0000000..20e89b7 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/auth/InternalTokenFilterConfiguration.java @@ -0,0 +1,30 @@ +package com.yunhai.platform.gateway.auth; + +import com.yunhai.platform.gateway.route.GatewayProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; + +/** + * 机器面闸门装配(序位 {@code HIGHEST_PRECEDENCE + 3},在 CAS 两件套 +4/+5 之前)。 + *

+ * 不受 {@code yh.gateway.sso.enabled} 开关影响:令牌门是独立的一层防护,网关关着 CAS 面 + * (回滚态)时它照样收口机器面;反过来 CAS 面点开时浏览器面仍归 CAS 管。 + *

+ * 圈定 {@link InternalTokenFilter#URL_PATTERN}({@code /api/internal/*}):{@code /oapi/**} 转发链、 + * 静态面、探活与文档端点都不在该前缀下,逐字节不受影响。 + */ +@Configuration +public class InternalTokenFilterConfiguration { + + @Bean + public FilterRegistrationBean internalTokenFilter(GatewayProperties properties) { + FilterRegistrationBean bean = + new FilterRegistrationBean<>(new InternalTokenFilter(properties)); + bean.addUrlPatterns(InternalTokenFilter.URL_PATTERN); + bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 3); + bean.setName("internalTokenFilter"); + return bean; + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/envelope/EnvelopeWriter.java b/src/main/java/com/yunhai/platform/gateway/envelope/EnvelopeWriter.java index bda1368..f54b765 100644 --- a/src/main/java/com/yunhai/platform/gateway/envelope/EnvelopeWriter.java +++ b/src/main/java/com/yunhai/platform/gateway/envelope/EnvelopeWriter.java @@ -50,4 +50,23 @@ public final class EnvelopeWriter { node.putNull("data"); response.getWriter().write(MAPPER.writeValueAsString(node)); } + + /** + * 管理面鉴权失败:HTTP 401 + 治理面信封 {@code {code:401, msg, data:null}}。 + *

+ * 与上面「鉴权错 = 裸 HTTP 无响应体」的口径分属两个面:裸码属 {@code /oapi} 转发链(F5), + * 401 带 json 属 {@code /api/internal/**}(调用方是控制台 SPA 与容器网内的程序, + * 两者都要能判别「未授权」而不是收到 302 HTML 或空体)。 + */ + public static void writeUnauthorized(HttpServletResponse response, String msg) throws IOException { + response.reset(); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + ObjectNode node = MAPPER.createObjectNode(); + node.put("code", 401); + node.put("msg", msg); + node.putNull("data"); + response.getWriter().write(MAPPER.writeValueAsString(node)); + response.getWriter().flush(); + } } diff --git a/src/main/java/com/yunhai/platform/gateway/route/GatewayProperties.java b/src/main/java/com/yunhai/platform/gateway/route/GatewayProperties.java index c3c96f5..46ec6b5 100644 --- a/src/main/java/com/yunhai/platform/gateway/route/GatewayProperties.java +++ b/src/main/java/com/yunhai/platform/gateway/route/GatewayProperties.java @@ -16,7 +16,8 @@ import java.util.Map; *

  • observe:观察模式(T1.6 消费;默认 true = 只记不拦,R4);
  • *
  • trustedProxies:外部入口判定的可信来源(T4.3,CIDR 列表)——仅这些来源的 * {@code X-Entry: external} 生效;留空 = 不存在外部入口;
  • - *
  • anonPerIpQps:ANON 路由未配 {@code rate_limit_qps} 时的 per-IP 缺省限额(T3.1 消费)。
  • + *
  • anonPerIpQps:ANON 路由未配 {@code rate_limit_qps} 时的 per-IP 缺省限额(T3.1 消费);
  • + *
  • internalToken:机器面({@code /api/internal/**} 除浏览器面)的共享内部令牌。
  • * */ @Component @@ -39,6 +40,14 @@ public class GatewayProperties { /** ANON 路由未配 route.rate_limit_qps 时的 per-IP 缺省限额(>0) */ private int anonPerIpQps = 10; + /** + * 机器面({@code /api/internal/**} 除浏览器面)的共享内部令牌,走平台应用 env + * {@code OPENGW_INTERNAL_TOKEN}。空 = 不校验(灰度窗口态,见 + * {@link com.yunhai.platform.gateway.auth.InternalTokenFilter});值只在两侧应用 env, + * 不进代码、不进库。 + */ + private String internalToken = ""; + public Map getRoutes() { return routes; } @@ -70,4 +79,12 @@ public class GatewayProperties { public void setAnonPerIpQps(int anonPerIpQps) { this.anonPerIpQps = anonPerIpQps; } + + public String getInternalToken() { + return internalToken; + } + + public void setInternalToken(String internalToken) { + this.internalToken = internalToken; + } } diff --git a/src/main/java/com/yunhai/platform/gateway/route/RegistryReloader.java b/src/main/java/com/yunhai/platform/gateway/route/RegistryReloader.java new file mode 100644 index 0000000..b793706 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/route/RegistryReloader.java @@ -0,0 +1,55 @@ +package com.yunhai.platform.gateway.route; + +import com.yunhai.platform.gateway.auth.AppAuthRegistry; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 注册表全量重载(快照 load + 变更广播)——机器面与浏览器面两个触发口共用的唯一实现。 + *

    + * 为什么要抽这一层:这段「三张快照各自 reload + 广播」此前只有一份,长在 + * {@code RouteRefreshController}(机器面 {@code POST /api/internal/routes/refresh}, + * projman Apply 后调用)上。但治理页的「重载注册表」按钮调的也是那一个路径—— + * 本次把机器面收成「只认内部令牌」后,浏览器带着 CAS 会话去打令牌门口会被 401 拦死。 + * 按平台不变量「一个接口面一种认证,互不串门」,正确解法是给浏览器面单开一个入口, + * 两处共用本组件;而不是在同一个端点上做「令牌 OR 会话」的两制式判定。 + *

    + * 返回体形态({@code loaded/targets/apps})与原机器面端点逐字段一致——两个口的响应契约同源, + * 前端与 projman 各自按现有解析走即可。 + */ +@Component +public class RegistryReloader { + + private final RouteRegistry routeRegistry; + private final AppAuthRegistry appAuthRegistry; + + /** 变更广播(多实例同步);广播开关关闭时该 bean 不存在,故 required=false */ + @Autowired(required = false) + private RouteChangeBroadcaster broadcaster; + + @Autowired + public RegistryReloader(RouteRegistry routeRegistry, AppAuthRegistry appAuthRegistry) { + this.routeRegistry = routeRegistry; + this.appAuthRegistry = appAuthRegistry; + } + + /** 清缓存重 load:routes = 路由条目,targets = DB 地址行,apps = ANON 应用行 */ + public Map reloadAll() { + Map body = new LinkedHashMap<>(); + body.put("loaded", routeRegistry.reload()); + body.put("targets", routeRegistry.reloadTargets()); + body.put("apps", appAuthRegistry.reload()); + if (broadcaster != null) { + broadcaster.publish(); + } + return body; + } + + /** 测试/显式装配入口 */ + void setBroadcaster(RouteChangeBroadcaster broadcaster) { + this.broadcaster = broadcaster; + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java b/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java index 840c29a..e33b4bd 100644 --- a/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java +++ b/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java @@ -1,6 +1,5 @@ package com.yunhai.platform.gateway.route; -import com.yunhai.platform.gateway.auth.AppAuthRegistry; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.beans.factory.annotation.Autowired; @@ -8,15 +7,17 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.LinkedHashMap; import java.util.Map; /** * 网关内部管理端点(T0.10):/api/internal/** 属网关自身路径,收网后仅容器网内可达, * 不注册进 open_api_route(注册表只治理 /{target}/oapi/**)。 *

    - * 重载后向 {@link RouteChangeBroadcaster} 发一条变更广播:本实例已就地重载, - * 其余实例收到广播各自重载(多实例部署下不需要逐实例点「重载注册表」)。 + * 本端点属机器面:只认内部令牌头 {@code X-Internal-Token} + * ({@link com.yunhai.platform.gateway.auth.InternalTokenFilter}),不进 CAS 浏览器面; + * 实测唯一调用方是 projman 的 {@code OpenApiTargetSyncService}(Apply 后通知)。 + * 控制台「重载注册表」按钮走浏览器面的孪生入口 + * {@code POST /api/internal/admin/routes/reload}(同一实现,见 {@link RegistryReloader})。 */ @Tag(name = "GatewayAdmin", description = "网关治理:路由旋钮 / 目标地址 / 访问台账") @RestController @@ -24,26 +25,17 @@ import java.util.Map; public class RouteRefreshController { @Autowired - private RouteRegistry routeRegistry; - - @Autowired - private AppAuthRegistry appAuthRegistry; - - /** 变更广播(多实例同步);广播开关关闭时该 bean 不存在,故 required=false */ - @Autowired(required = false) - private RouteChangeBroadcaster broadcaster; + private RegistryReloader reloader; /** 清缓存重 load,返回加载数(routes = 路由条目,targets = DB 地址行,apps = ANON 应用行) */ @Operation(operationId = "GatewayAdmin.refresh", summary = "热重载路由注册表与目标地址") @PostMapping("/routes/refresh") public Map refresh() { - Map body = new LinkedHashMap<>(); - body.put("loaded", routeRegistry.reload()); - body.put("targets", routeRegistry.reloadTargets()); - body.put("apps", appAuthRegistry.reload()); - if (broadcaster != null) { - broadcaster.publish(); - } - return body; + return reloader.reloadAll(); + } + + /** 测试/显式装配入口 */ + void setReloader(RegistryReloader reloader) { + this.reloader = reloader; } } diff --git a/src/main/java/com/yunhai/platform/gateway/sso/BrowserAuthGateFilter.java b/src/main/java/com/yunhai/platform/gateway/sso/BrowserAuthGateFilter.java new file mode 100644 index 0000000..d05d437 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/sso/BrowserAuthGateFilter.java @@ -0,0 +1,64 @@ +package com.yunhai.platform.gateway.sso; + +import org.jasig.cas.client.util.AbstractCasFilter; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.io.IOException; + +/** + * 治理面浏览器鉴权门(CAS 面最后一环,排在校验过滤器之后): + * 有会话断言 → 放行;没有 → 按调用方形态出双形态未登录应答(见 {@link SsoResponses})。 + *

    + * 有意不装 cas-client 的 {@code AuthenticationFilter}:它的 302-到-登录页会把容器侧 + * {@code request.getRequestURI()} 拼进 service(网关 ctx 为空、外链前缀被 Caddy 剥掉, + * 拼出来必是 404 地址),且它对裸 curl 出 HTML 302、与治理面「401 json」契约冲突。 + * 登录地址因此由 {@link SsoAddressing#loginUrl()} 自己按 Notice 同式拼固定 service。 + *

    + * 也有意不装 {@code SingleSignOutFilter}(本期不做平台级 SLO)与 + * {@code HttpServletRequestWrapperFilter}(只用于 {@code getRemoteUser()}, + * 身份从会话断言直读,见 {@link SsoSessionController})——少一环就少一处能误圈 {@code /oapi} 的地方。 + */ +public class BrowserAuthGateFilter implements Filter { + + private final SsoAddressing addressing; + + public BrowserAuthGateFilter(SsoAddressing addressing) { + this.addressing = addressing; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) { + chain.doFilter(request, response); + return; + } + HttpServletRequest req = (HttpServletRequest) request; + HttpServletResponse res = (HttpServletResponse) response; + if (isAuthenticated(req)) { + chain.doFilter(request, response); + return; + } + SsoResponses.writeUnauthorized(res, SsoResponses.isAjax(req, addressing), addressing.loginUrl()); + } + + /** + * 已登录 = cas-client 校验成功后落在会话里的断言。 + * 同时认本请求的 request attribute:校验过滤器成功路径会双写(attribute + session), + * 同一请求内紧随其后的本过滤器读 attribute 即可,无须等 session 生效。 + */ + private static boolean isAuthenticated(HttpServletRequest req) { + if (req.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION) != null) { + return true; + } + HttpSession session = req.getSession(false); + return session != null && session.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION) != null; + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/sso/GatewayCasValidationFilter.java b/src/main/java/com/yunhai/platform/gateway/sso/GatewayCasValidationFilter.java new file mode 100644 index 0000000..71612ef --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/sso/GatewayCasValidationFilter.java @@ -0,0 +1,56 @@ +package com.yunhai.platform.gateway.sso; + +import org.jasig.cas.client.validation.Cas20ProxyReceivingTicketValidationFilter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * 网关版 CAS 票据校验过滤器:cas-client 的原样子类,只改「校验失败」的出口。 + *

    + * cas-client 3.5.1 的失败路径是 {@code onFailedValidation(req,res)} 之后 链就断了 + * ({@code doFilter} 是 final:失败分支不再 {@code chain.doFilter}),默认实现什么都不写, + * 于是客户端拿到一个空 200;而 {@code exceptionOnValidationFailure=true}(3.5.1 默认) + * 会抛 {@code ServletException} → 500 HTML。两种都不符合治理面契约。 + *

    + * 这里把失败出口换成与门一致的双形态未登录应答(票据过期/重放即未登录,让 SPA 重新起跳、 + * 让裸调用方拿到 401 json),同时注册时显式设 {@code exceptionOnValidationFailure=false} + * ——顺序是先本方法写体并 flush、后 cas-client 的 {@code sendError},响应已提交, + * {@code sendError} 自然失效。 + *

    + * 成功路径按 {@code redirectAfterValidation=false} 继续链,交给 {@link BrowserAuthGateFilter} + * 放行到 {@link SsoEntryController},由它把浏览器送回控制台——回跳地址因此始终带得上 + * Caddy 剥掉的外链前缀。 + */ +public class GatewayCasValidationFilter extends Cas20ProxyReceivingTicketValidationFilter { + + private static final Logger LOG = LoggerFactory.getLogger(GatewayCasValidationFilter.class); + + private final SsoAddressing addressing; + + public GatewayCasValidationFilter(SsoAddressing addressing) { + this.addressing = addressing; + } + + @Override + protected void onFailedValidation(HttpServletRequest request, HttpServletResponse response) { + try { + SsoResponses.writeUnauthorized(response, SsoResponses.isAjax(request, addressing), + addressing.loginUrl()); + } catch (Exception e) { + // 写不出去就只剩容器兜底,不会回到业务逻辑;留一行 WARN 供排障定位 + LOG.warn("CAS 票据校验失败的应答写不出去:{}", e.toString()); + } + } + + /** + * 测试/装配可见:cas-client 自己解析出的 service({@code AbstractCasFilter} 的 final 方法)。 + * 固定 service 是否真的被采用,取决于 init-param 名与 setter 谁赢(3.5.1 两处都读、读法不一), + * 只断言装配代码写了什么不算数——所以把它取出来直接验。 + */ + String resolvedServiceUrl(HttpServletRequest request, HttpServletResponse response) { + return constructServiceUrl(request, response); + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/sso/SsoAddressing.java b/src/main/java/com/yunhai/platform/gateway/sso/SsoAddressing.java new file mode 100644 index 0000000..6894bcf --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/sso/SsoAddressing.java @@ -0,0 +1,127 @@ +package com.yunhai.platform.gateway.sso; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; + +/** + * CAS 地址推导:把平台注入的 {@code yh.sso.*} 值换算成本网关的 service / login / 回跳地址。 + *

    + * 两个来源键的值语义与 Notice 完全一致(同键同名,由 projman 按平台设置注入 + * {@code YH_SSO_SERVER_URL_PREFIX}、{@code YH_SSO_CLIENT_HOST_URL}): + *

      + *
    • {@code yh.sso.server-url-prefix} = CAS 服务基址,如 {@code http://sso.yunhaixingtong.com/};
    • + *
    • {@code yh.sso.client-host-url} = 本应用的外部带前缀地址,如 + * {@code http://base.yunhaixingtong.com/open-gateway/}。
    • + *
    + * 与 starter 的唯一区别:这里保留 {@code client-host-url} 的路径段。starter 用正则 + * {@code ^(https?://[^/]+)} 把它丢掉、路径改由 {@code request.getRequestURI()} 供给——那只对 + * 「ctx 与外链前缀同名」的应用成立(Notice 的 ctx=/notice)。网关 ctx 为空、前缀被 Caddy 剥掉, + * 容器侧 URI 里没有 {@code /open-gateway},那条路会把回跳送到 base 域根的 {@code /api/...}(实测 404)。 + *

    + * 因此网关的 CAS 面用固定 service:登录与票据校验都指向 {@link #serviceUrl()} 这一个 + * 外部可达的会话建立端点,与请求路径无关,前缀问题从根上不成立。 + *

    + * 键值一律经 {@link Environment#getProperty} 显式读取,不依赖宽松绑定——同 starter 的 + * {@code YhSsoProperties.bind()} 结论:Nacos/云环境下宽松绑定曾出现字段为 null。 + */ +@Component +public class SsoAddressing { + + static final String KEY_SERVER_URL_PREFIX = "yh.sso.server-url-prefix"; + static final String KEY_CLIENT_HOST_URL = "yh.sso.client-host-url"; + + private final Environment environment; + private final SsoProperties properties; + + @Autowired + public SsoAddressing(Environment environment, SsoProperties properties) { + this.environment = environment; + this.properties = properties; + } + + /** 生效条件:开关开 + 两个平台键都在(缺一即不装配过滤器,避免半吊子链路) */ + public boolean active() { + return properties.isEnabled() && !blank(serverUrlPrefixRaw()) && !blank(clientHostUrlRaw()); + } + + /** 配置写了开关但缺平台键 = 装不起来,需要能立刻看出来的配置错 */ + public boolean enabledButUnconfigured() { + return properties.isEnabled() && (blank(serverUrlPrefixRaw()) || blank(clientHostUrlRaw())); + } + + /** CAS 基址,去掉尾斜杠(拼 /login、交 cas-client 时都由它自己补斜杠,与 Notice 同形) */ + public String casServerUrlPrefix() { + return stripTrailingSlashes(serverUrlPrefixRaw()); + } + + /** 本应用外部基址(含外链前缀),去尾斜杠 */ + public String externalBaseUrl() { + return stripTrailingSlashes(clientHostUrlRaw()); + } + + /** 固定 service:CAS 登录后回跳的唯一靶点,外部可达且在 CAS 面圈内 */ + public String serviceUrl() { + return externalBaseUrl() + properties.getEntryPath(); + } + + /** 登录地址:与 Notice 的 CasSsoApiRedirectFilter 同式(prefix + /login?service=urlEncode(...)) */ + public String loginUrl() { + return casServerUrlPrefix() + "/login?service=" + encode(serviceUrl()); + } + + /** 登录成功后要落去的控制台地址(静态面,不鉴权) */ + public String consoleUrl() { + return externalBaseUrl() + "/front/index.html"; + } + + public String urlPattern() { + return properties.getUrlPattern(); + } + + public String entryPath() { + return properties.getEntryPath(); + } + + public String ajaxHeaderName() { + return properties.getAjaxHeaderName(); + } + + public String ajaxHeaderValue() { + return properties.getAjaxHeaderValue(); + } + + private String serverUrlPrefixRaw() { + return environment.getProperty(KEY_SERVER_URL_PREFIX); + } + + private String clientHostUrlRaw() { + return environment.getProperty(KEY_CLIENT_HOST_URL); + } + + private static String stripTrailingSlashes(String v) { + if (v == null) { + return ""; + } + String s = v.trim(); + while (s.endsWith("/")) { + s = s.substring(0, s.length() - 1); + } + return s; + } + + private static boolean blank(String s) { + return s == null || s.trim().isEmpty(); + } + + private static String encode(String v) { + try { + return URLEncoder.encode(v, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException("UTF-8 不可用", e); + } + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/sso/SsoEntryController.java b/src/main/java/com/yunhai/platform/gateway/sso/SsoEntryController.java new file mode 100644 index 0000000..911680d --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/sso/SsoEntryController.java @@ -0,0 +1,46 @@ +package com.yunhai.platform.gateway.sso; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * CAS 会话建立端点(固定 service 回跳靶点)。 + *

    + * 它存在的唯一理由:网关 ctx 为空、外链前缀 {@code /open-gateway} 由 Caddy 剥离,容器侧 + * {@code request.getRequestURI()} 里没有前缀,所以 cas-client/starter 那种「按当前请求路径拼 + * service」的作法在这里必然把浏览器送回 base 域根的 {@code /api/...}(实测 404)。 + * 治理面因此改用固定 service:登录地址与票据校验都指向这一个外部可达、且在 CAS 面圈内的 + * 地址,回跳一定进得来;进来时校验过滤器已把断言写进会话,本端点只负责把浏览器送回控制台。 + *

    + * 落点是控制台默认页({@code /front/index.html},静态面不鉴权)。页签(hash 路由)由前端自己 + * 换回:整页跳去 SSO 前记在 sessionStorage,回到壳页时 {@code router.replace} 回去 + * ({@code front/src/utils/ssoReturn.ts},那里写了为什么不能照 Notice 把目标拼进 service—— + * 固定 service 与每次不同的目标不可共存,会造成票据校验失败后的 302 死循环)。 + *

    + * 直连本端点(未登录、无 ticket)会被 {@link BrowserAuthGateFilter} 按调用方形态挡在门外: + * 它不因为「是用来建会话的」而放宽。 + */ +@Tag(name = "GatewaySso", description = "网关治理面浏览器 SSO 会话") +@RestController +public class SsoEntryController { + + private final SsoAddressing addressing; + + @Autowired + public SsoEntryController(SsoAddressing addressing) { + this.addressing = addressing; + } + + /** 路径必须与 {@code yh.gateway.sso.entry-path} 逐字一致,且落在 CAS 面圈内(由测试钉住) */ + @Operation(operationId = "GatewaySso.entry", summary = "CAS 登录回跳落点(送浏览器回控制台)") + @GetMapping("/api/internal/admin/sso-entry") + public void entry(HttpServletResponse response) throws IOException { + response.sendRedirect(addressing.consoleUrl()); + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/sso/SsoFilterConfiguration.java b/src/main/java/com/yunhai/platform/gateway/sso/SsoFilterConfiguration.java new file mode 100644 index 0000000..1bcc44f --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/sso/SsoFilterConfiguration.java @@ -0,0 +1,97 @@ +package com.yunhai.platform.gateway.sso; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 治理面 CAS 两件套的手工装配(顺序、init-param 全部照基础平台 + * {@code CasSsoAutoConfiguration} 的已验证形态,只是 service 换成固定值)。 + *

    + * 两个过滤器都只挂 {@link SsoProperties#getUrlPattern()}(默认 {@code /api/internal/admin/*}, + * 是 {@code /api/*} 的子集):{@code /oapi/**} 转发链、{@code /front/**} 静态面、 + * {@code /actuator/**} 探活、{@code /v3/api-docs} 文档一律在面外,行为逐字节不变。 + *

    + * 序位取 {@code HIGHEST_PRECEDENCE + 4/+5}(与平台一致),因而排在网关自有链 + * ({@code @Order(0..5)} 的 Sanitize/Routing/SignVerify/RateLimit/Dispatch)之前—— + * 治理面本就被 {@code RoutingFilter.isGatewayInternal} 旁路,不经转发链,两处不交叉。 + */ +@Configuration +public class SsoFilterConfiguration { + + private static final Logger LOG = LoggerFactory.getLogger(SsoFilterConfiguration.class); + + /** 票据校验(+4):把 CAS 回跳带来的 ticket 换成会话断言 */ + @Bean + public FilterRegistrationBean casTicketValidationFilter( + SsoAddressing addressing, SsoProperties properties) { + warnIfMisconfigured(addressing); + GatewayCasValidationFilter filter = new GatewayCasValidationFilter(addressing); + // cas-client 3.5.1 的教训(同 starter 注释):部分字段只认 init-parameter、set 进去的值 + // 在 init 时被覆盖,另一部分只认 setter——两处都写、取同值,不赌哪一种生效。 + // 但 serverName 与 service 是【互斥】的(AbstractCasFilter.init 直接 assert "MUST ONLY set + // one",两个都给 = 启动即 IllegalArgumentException)。治理面用固定 service, + // 因此这里只给 service、不给 serverName——starter 给 serverName 是因为它按请求路径推 service。 + filter.setService(addressing.serviceUrl()); + filter.setRedirectAfterValidation(false); + filter.setExceptionOnValidationFailure(false); + // 关掉 response.encodeURL:cas-client 拼 service 时会做 URL 重写,会话未由 cookie 稳定 + // 建立时会在地址里塞进 ;jsessionid=...,Boot 2.7 的 PathPatternParser 不剥分号 → 404 + // (平台为此专门写了 JSessionIdStripFilter;本网关不引 starter,就地把重写关掉) + filter.setEncodeServiceUrl(false); + + FilterRegistrationBean bean = new FilterRegistrationBean<>(filter); + for (Map.Entry param : validationInitParams(addressing).entrySet()) { + bean.addInitParameter(param.getKey(), param.getValue()); + } + bean.addUrlPatterns(properties.getUrlPattern()); + bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 4); + bean.setName("casTicketValidationFilter"); + bean.setEnabled(addressing.active()); + return bean; + } + + /** + * 校验过滤器的 init-parameter 集({@code casServerUrlPrefix} 照 + * {@code CasSsoAutoConfiguration} 的已验证写法;{@code service} 是本网关的新增项——固定回跳 + * 靶点,见 {@link SsoEntryController},且因此不得再附 {@code serverName},二者互斥)。 + * 抽成方法是为了让测试能用同一份参数真跑一次 {@code init()},而不是重复抄一遍断言。 + */ + static Map validationInitParams(SsoAddressing addressing) { + Map params = new LinkedHashMap<>(); + params.put("casServerUrlPrefix", addressing.casServerUrlPrefix()); + params.put("service", addressing.serviceUrl()); + params.put("redirectAfterValidation", "false"); + return params; + } + + /** 鉴权门(+5):无断言即按调用方形态出未登录应答 */ + @Bean + public FilterRegistrationBean browserAuthGateFilter( + SsoAddressing addressing, SsoProperties properties) { + FilterRegistrationBean bean = + new FilterRegistrationBean<>(new BrowserAuthGateFilter(addressing)); + bean.addUrlPatterns(properties.getUrlPattern()); + bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 5); + bean.setName("browserAuthGateFilter"); + bean.setEnabled(addressing.active()); + return bean; + } + + /** + * 开关点了、平台键没到位 = 面装不起来,而 {@code enabled=false} 时的沉默是正常的 + * (本地跑测/未上线),所以只在这一种反常组合上出声,否则启动日志里没人能区分两者。 + */ + private static void warnIfMisconfigured(SsoAddressing addressing) { + if (addressing.enabledButUnconfigured()) { + LOG.warn("yh.gateway.sso.enabled=true 但 {} / {} 有缺,CAS 面未装配(治理面当前无鉴权)", + SsoAddressing.KEY_SERVER_URL_PREFIX, SsoAddressing.KEY_CLIENT_HOST_URL); + } + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/sso/SsoProperties.java b/src/main/java/com/yunhai/platform/gateway/sso/SsoProperties.java new file mode 100644 index 0000000..1c7b6ce --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/sso/SsoProperties.java @@ -0,0 +1,82 @@ +package com.yunhai.platform.gateway.sso; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 治理面浏览器 SSO(CAS 会话)的网关本地开关(配置键 {@code yh.gateway.sso.*})。 + *

    + * 为什么不复用 {@code yh.sso.*} 做全部键:{@code yh.sso.server-url-prefix} 与 + * {@code yh.sso.client-host-url} 的值语义与 Notice 逐字一致(由平台按「平台设置」注入容器 env, + * 本类不重复定义、直接经 {@link SsoAddressing} 从 Environment 读),但基础平台 starter 的 + * {@code CasSsoAutoConfiguration} 不能在本网关装配——它以 {@code yh.sso.server-url-prefix} 的 + * 存在为启用条件,而该 env 早已注入本网关容器;一旦装配即以默认 {@code /api/*} 圈住全部 + * {@code /api/**}(含机器面 {@code routes/refresh}),且它的 {@code serverName()} 会丢掉 + * {@code client-host-url} 的路径段(本网关 ctx 为空、外链前缀由 Caddy 剥离,容器只看到 + * {@code /api/**}),CAS 回跳将落到 base 域根的 {@code /api/...} 而 404。 + * 因此本包只借 cas-client-core 的票据校验,其余按网关自己的路径面手工装配。 + *

    + * 环境值一律走 {@code ${ENV:默认}} 占位(§0 规则 8:环境值不得打进 jar)。 + */ +@Component +@ConfigurationProperties(prefix = "yh.gateway.sso") +public class SsoProperties { + + /** 总开关:false = 两个过滤器都不注册,治理面行为与接入前逐字节相同(回滚绳) */ + private boolean enabled = false; + + /** + * CAS 面圈定的 servlet 路径模式(必须是 {@code /api/*} 的子集)。 + * 只圈治理面,机器面与 {@code /oapi/**}、{@code /front/**}、{@code /actuator/**} 天然在面外。 + */ + private String urlPattern = "/api/internal/admin/*"; + + /** 会话建立端点路径(容器侧路径,必须落在 {@link #urlPattern} 圈内) */ + private String entryPath = "/api/internal/admin/sso-entry"; + + /** SPA 标记头名:带它且值匹配 → 未登录返 HTTP 200 + {code:302,urlToRedirectTo}(Notice 同构) */ + private String ajaxHeaderName = "yschapirequest"; + + /** SPA 标记头值 */ + private String ajaxHeaderValue = "1"; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getUrlPattern() { + return urlPattern; + } + + public void setUrlPattern(String urlPattern) { + this.urlPattern = urlPattern; + } + + public String getEntryPath() { + return entryPath; + } + + public void setEntryPath(String entryPath) { + this.entryPath = entryPath; + } + + public String getAjaxHeaderName() { + return ajaxHeaderName; + } + + public void setAjaxHeaderName(String ajaxHeaderName) { + this.ajaxHeaderName = ajaxHeaderName; + } + + public String getAjaxHeaderValue() { + return ajaxHeaderValue; + } + + public void setAjaxHeaderValue(String ajaxHeaderValue) { + this.ajaxHeaderValue = ajaxHeaderValue; + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/sso/SsoResponses.java b/src/main/java/com/yunhai/platform/gateway/sso/SsoResponses.java new file mode 100644 index 0000000..a7d9583 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/sso/SsoResponses.java @@ -0,0 +1,63 @@ +package com.yunhai.platform.gateway.sso; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.yunhai.platform.gateway.envelope.EnvelopeWriter; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +/** + * CAS 面的「未登录」双形态应答(网关治理面专用)。 + *

    + * 两个形态各有必须成立的读者,缺一不可: + *

      + *
    • 带 SPA 标记头({@code yschapirequest: 1})→ HTTP 200 + + * {@code {code:302, urlToRedirectTo}},与基础平台 {@code CasSsoApiRedirectFilter} + * 逐字同构;控制台前端 alova 的 {@code case 302} 分支据此整页跳 SSO。
    • + *
    • 不带标记头(裸 curl、脚本、误打到治理面的机器调用)→ HTTP 401 + + * 治理面信封 {@code {code:401, msg, data:null}}。不返回 302 HTML:那会让调用方 + * 把「跳登录页」当成拿到了数据。
    • + *
    + * 为什么这里是信封而不是裸码:{@link com.yunhai.platform.gateway.envelope.EnvelopeWriter} + * 的「鉴权错 = 裸 HTTP 无响应体」口径属 {@code /oapi} 转发链(F5);治理面走 + * {@code yh-api-std} 的 {@code {code,msg,data}} 信封(与 notice/userbase 前端一致), + * 两个面各按各的契约,不互相污染。 + */ +public final class SsoResponses { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private SsoResponses() { + } + + /** + * 按调用方形态写未登录应答。 + * + * @param ajax 是否带 SPA 标记头(由过滤器判定,见 {@link #isAjax(HttpServletRequest, SsoAddressing)}) + * @param loginUrl 完整的 SSO 登录地址(含 service 参数) + */ + public static void writeUnauthorized(HttpServletResponse response, boolean ajax, String loginUrl) + throws IOException { + if (ajax) { + response.reset(); + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType("application/json;charset=UTF-8"); + ObjectNode node = MAPPER.createObjectNode(); + node.put("code", 302); + node.put("urlToRedirectTo", loginUrl); + response.getWriter().write(MAPPER.writeValueAsString(node)); + response.getWriter().flush(); + return; + } + // 非 SPA 调用方:与内部令牌门共用同一个 401 信封出口,两个面的口径不会各自漂移 + EnvelopeWriter.writeUnauthorized(response, "未登录或登录已过期"); + } + + /** 调用方是否声明自己是 SPA(同基础平台判定:头值 trim 后相等) */ + public static boolean isAjax(HttpServletRequest request, SsoAddressing addressing) { + String flag = request.getHeader(addressing.ajaxHeaderName()); + return flag != null && flag.trim().equals(addressing.ajaxHeaderValue()); + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/sso/SsoSessionController.java b/src/main/java/com/yunhai/platform/gateway/sso/SsoSessionController.java new file mode 100644 index 0000000..c0bb9ea --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/sso/SsoSessionController.java @@ -0,0 +1,63 @@ +package com.yunhai.platform.gateway.sso; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.jasig.cas.client.authentication.AttributePrincipal; +import org.jasig.cas.client.util.AbstractCasFilter; +import org.jasig.cas.client.validation.Assertion; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 治理面当前会话信息(供前端拼 SSO 登出地址)。 + *

    + * 为什么要有这个接口:Notice 前端的 {@code ssoHost} 来自 {@code /api/env-config} + * 运行时配置;网关后端没有 env-config 端点,前端 {@code conf.ssoHost} 恒为空, + * 于是 Notice 同款的 {@code /logout?service=...} 无处可取。把 CAS 基址由已登录的 + * 会话自己吐出来,既不必把环境值烧进前端产物(§0 规则 8),也不新增任何匿名面。 + *

    + * 本路径在 CAS 面圈内 ⇒ 未登录根本读不到,回滚(开关关掉)时它与治理面其余接口一同回到无鉴权态。 + */ +@Tag(name = "GatewaySso", description = "网关治理面浏览器 SSO 会话") +@RestController +@RequestMapping("/api/internal/admin") +public class SsoSessionController { + + private final SsoAddressing addressing; + + public SsoSessionController(SsoAddressing addressing) { + this.addressing = addressing; + } + + /** 成功响应由 yh-api-std 的 advice 包成 {code:0,msg,data:{ssoHost,principal}} */ + @Operation(operationId = "GatewaySso.session", summary = "当前 SSO 会话(登出地址推导用)") + @GetMapping("/session") + public Map session(HttpServletRequest request) { + Map out = new LinkedHashMap<>(); + // CAS 面未装配时(开关关/平台键缺)回空串:前端据此隐藏「退出」——否则灰度/回滚窗口里 + // 会凭空出现一个能平台级注销的入口,且把 CAS 基址泄漏在无鉴权端点上 + out.put("ssoHost", addressing.active() ? addressing.casServerUrlPrefix() : ""); + out.put("principal", principalOf(request)); + return out; + } + + /** 会话断言里的登录名;无会话(本地关掉 CAS 面跑测)返回 null */ + private static String principalOf(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return null; + } + Object attribute = session.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION); + if (!(attribute instanceof Assertion)) { + return null; + } + AttributePrincipal principal = ((Assertion) attribute).getPrincipal(); + return principal == null ? null : principal.getName(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 67e837d..a021aae 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,15 @@ server: # 网关端口(注册档案 port=10013;10010 已被 dormnotice 占用。实际端口由平台配置层 env 注入,本值仅本地兜底) port: 10013 + servlet: + # 注意:这里【不】配 context-path——网关对外前缀 /open-gateway 由 Caddy `uri strip_prefix` 剥离, + # 容器侧只见 /api/**、/oapi/**(ctx 必须保持空,见「控制台命名空间 方案 A」)。 + session: + cookie: + # 治理面 CAS 会话 cookie 显式改名:默认 JSESSIONID 的 Path=/(ctx 为空)会散到 base 域 + # 全部应用,与 Notice 等自带 ctx 应用的 JSESSIONID(Path=/notice) 同名叠放在一个 Cookie 头里。 + # Notice 靠 context-path 天然隔离,网关没有 ctx,必须靠名字隔离。 + name: OPENGW_SESSION spring: application: @@ -69,3 +78,22 @@ yh: # anon-per-ip-qps: 10 # 注册表变更广播(多实例同步治理页改动):关掉退化为单实例语义,判定不受影响 # redis-broadcast-enabled: true + # 机器面(/api/internal/** 除浏览器面)的共享内部令牌;值只在应用 env,不进代码不进库。 + # 空 = 不校验(灰度窗口态:网关先上、env 后配,期间不打断 projman 的路由同步)。 + internal-token: ${OPENGW_INTERNAL_TOKEN:} + sso: + # 治理面浏览器 SSO(CAS 会话)总开关。默认关——由平台应用 env OPENGW_SSO_ENABLED=true 点燃, + # 回滚 = 清掉该 env 重新 Apply(一次重启,不回退镜像、不改 Caddy)。 + # 环境值不进 jar(§0 规则 8:环境值打进 jar 曾造成全站事故)。 + enabled: ${OPENGW_SSO_ENABLED:false} + # CAS 过滤器圈的路径:只圈治理面(/api/* 的子集)。机器面 /api/internal/** 其余路径 + # 走内部令牌门,/oapi/** 与 /front/**、/actuator/**、/v3/api-docs 一律不进本面。 + url-pattern: /api/internal/admin/* + # 会话建立端点(CAS 登录后的固定回跳靶点,必须落在 url-pattern 圈内)。 + # 完整 service = 平台注入的 yh.sso.client-host-url(含外链前缀)+ 本路径。 + entry-path: /api/internal/admin/sso-entry + # SPA 标记头:带它 → HTTP 200 + {code:302,urlToRedirectTo}(与 Notice 平台 + # CasSsoApiRedirectFilter 逐字同构,前端 alova-core 的 case 302 直接可用); + # 不带(裸 curl / 脚本)→ HTTP 401 + 治理面信封 {code:401,msg,data:null}。 + ajax-header-name: yschapirequest + ajax-header-value: "1" diff --git a/src/test/java/com/yunhai/platform/gateway/admin/InternalAdminControllerTest.java b/src/test/java/com/yunhai/platform/gateway/admin/InternalAdminControllerTest.java index 50e8d0f..a7598ce 100644 --- a/src/test/java/com/yunhai/platform/gateway/admin/InternalAdminControllerTest.java +++ b/src/test/java/com/yunhai/platform/gateway/admin/InternalAdminControllerTest.java @@ -9,6 +9,7 @@ import com.yunhai.platform.gateway.repository.OpenApiAppRepository; import com.yunhai.platform.gateway.repository.OpenApiLogRepository; import com.yunhai.platform.gateway.repository.OpenApiRouteRepository; import com.yunhai.platform.gateway.repository.OpenApiTargetRepository; +import com.yunhai.platform.gateway.route.RegistryReloader; import com.yunhai.platform.gateway.route.RouteRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -67,6 +68,8 @@ class InternalAdminControllerTest { InternalAdminController controller = new InternalAdminController(); controller.setRepos(routeRepo, targetRepo, logRepo, registry, appRepo, appRegistry); + // 浏览器面的「重载注册表」入口与机器面共用 RegistryReloader(同一实现的两个口) + controller.setReloader(new RegistryReloader(registry, appRegistry)); mvc = MockMvcBuilders.standaloneSetup(controller).build(); when(routeRepo.findAll(any(Pageable.class))) @@ -192,6 +195,26 @@ class InternalAdminControllerTest { verify(registry).reloadTargets(); } + /** + * 控制台「重载注册表」按钮走浏览器面入口,且与机器面 {@code /api/internal/routes/refresh} + * 共用同一实现(返回体三个键形态一致)。此前该按钮直接打机器面路径,令牌收口后会被 401 拦死。 + */ + @Test + void consoleReload_onBrowserSurface_usesSharedReloader() throws Exception { + when(registry.reloadTargets()).thenReturn(4); + when(appRegistry.reload()).thenReturn(1); + + mvc.perform(post("/api/internal/admin/routes/reload")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.loaded").value(22)) + .andExpect(jsonPath("$.targets").value(4)) + .andExpect(jsonPath("$.apps").value(1)); + + verify(registry).reload(); + verify(registry).reloadTargets(); + verify(appRegistry).reload(); + } + @Test void targetUpdate_blankBaseUrl_bizErrorNoSave() throws Exception { when(targetRepo.findById(5L)).thenReturn(java.util.Optional.of(target(5L))); diff --git a/src/test/java/com/yunhai/platform/gateway/auth/InternalTokenFilterTest.java b/src/test/java/com/yunhai/platform/gateway/auth/InternalTokenFilterTest.java new file mode 100644 index 0000000..75ad40c --- /dev/null +++ b/src/test/java/com/yunhai/platform/gateway/auth/InternalTokenFilterTest.java @@ -0,0 +1,137 @@ +package com.yunhai.platform.gateway.auth; + +import com.yunhai.platform.gateway.route.GatewayProperties; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import javax.servlet.FilterChain; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 机器面闸门三态(有效 / 无效 / 缺失)+ 灰度窗口态 + 默认拒绝的覆盖面。 + *

    + * 唯一实测调用方是 projman 的 {@code OpenApiTargetSyncService}(容器网内直连 + * {@code POST /api/internal/routes/refresh}),所以「有效令牌必须放行」这条不是装饰性用例, + * 而是本次上线不能打断的功能;「浏览器面放过」同理——否则控制台会被要求带内部令牌。 + */ +class InternalTokenFilterTest { + + private static final String TOKEN = "0f1e2d3c4b5a6978"; + private static final String REFRESH = "/api/internal/routes/refresh"; + + private final List passed = new ArrayList<>(); + private final FilterChain chain = (req, res) -> passed.add(((MockHttpServletRequest) req).getServletPath()); + + private static GatewayProperties properties(String token) { + GatewayProperties properties = new GatewayProperties(); + properties.setInternalToken(token); + return properties; + } + + private static MockHttpServletRequest post(String path, String token) { + MockHttpServletRequest req = new MockHttpServletRequest("POST", path); + req.setServletPath(path); + if (token != null) { + req.addHeader(InternalTokenFilter.HEADER, token); + } + return req; + } + + @Test + void 有效令牌放行() throws Exception { + MockHttpServletResponse res = new MockHttpServletResponse(); + + new InternalTokenFilter(properties(TOKEN)).doFilter(post(REFRESH, TOKEN), res, chain); + + assertThat(passed).containsExactly(REFRESH); + assertThat(res.getStatus()).isEqualTo(200); + assertThat(res.getContentAsString()).isEmpty(); + } + + @Test + void 无效令牌_401json() throws Exception { + MockHttpServletResponse res = new MockHttpServletResponse(); + + new InternalTokenFilter(properties(TOKEN)).doFilter(post(REFRESH, "wrong"), res, chain); + + assertThat(passed).isEmpty(); + assertThat(res.getStatus()).isEqualTo(401); + assertThat(res.getContentAsString()).contains("\"code\":401").doesNotContain(" + * 两个触发口共用本组件是刻意的:过去浏览器按钮直接打机器面路径,令牌收口后会被 401 + * 拦死(见 InternalAdminController#reload 的说明)。逻辑长在一处,两个口就不会各自漂移。 + */ +class RegistryReloaderTest { + + @Test + void 三张快照各自重load且返回体三个键() { + RouteRegistry routeRegistry = Mockito.mock(RouteRegistry.class); + AppAuthRegistry appRegistry = Mockito.mock(AppAuthRegistry.class); + when(routeRegistry.reload()).thenReturn(7); + when(routeRegistry.reloadTargets()).thenReturn(3); + when(appRegistry.reload()).thenReturn(2); + RegistryReloader reloader = new RegistryReloader(routeRegistry, appRegistry); + + Map body = reloader.reloadAll(); + + assertThat(body).containsEntry("loaded", 7).containsEntry("targets", 3).containsEntry("apps", 2); + } + + @Test + void 广播存在时重载后发一条变更() { + RouteRegistry routeRegistry = Mockito.mock(RouteRegistry.class); + AppAuthRegistry appRegistry = Mockito.mock(AppAuthRegistry.class); + RouteChangeBroadcaster broadcaster = Mockito.mock(RouteChangeBroadcaster.class); + RegistryReloader reloader = new RegistryReloader(routeRegistry, appRegistry); + reloader.setBroadcaster(broadcaster); + + reloader.reloadAll(); + + verify(broadcaster).publish(); + } + + @Test + void 广播开关关掉时退化为单实例语义不炸() { + RouteRegistry routeRegistry = Mockito.mock(RouteRegistry.class); + AppAuthRegistry appRegistry = Mockito.mock(AppAuthRegistry.class); + when(routeRegistry.reload()).thenReturn(1); + // broadcaster 不注入(= redis-broadcast-enabled:false 时该 bean 不存在) + RegistryReloader reloader = new RegistryReloader(routeRegistry, appRegistry); + + assertThat(reloader.reloadAll()).containsEntry("loaded", 1); + verify(routeRegistry).reload(); + } +} diff --git a/src/test/java/com/yunhai/platform/gateway/sso/BrowserAuthGateFilterTest.java b/src/test/java/com/yunhai/platform/gateway/sso/BrowserAuthGateFilterTest.java new file mode 100644 index 0000000..75e4a07 --- /dev/null +++ b/src/test/java/com/yunhai/platform/gateway/sso/BrowserAuthGateFilterTest.java @@ -0,0 +1,128 @@ +package com.yunhai.platform.gateway.sso; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.jasig.cas.client.util.AbstractCasFilter; +import org.jasig.cas.client.validation.Assertion; + +import javax.servlet.FilterChain; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 治理面未登录双形态(D1 决议):SPA 标记头 → HTTP 200 + {code:302,urlToRedirectTo}(Notice 同构), + * 裸 curl / 脚本 → HTTP 401 + 治理面信封 {code:401,msg,data:null}。 + *

    + * 「401 必须是 json、不许是 302 HTML」是本次验收的核心判据:裸 curl 从 200 读得到路由表, + * 变成 401 才算堵上;而 302 HTML 会让调用方以为拿到了页面。 + */ +class BrowserAuthGateFilterTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final List passed = new ArrayList<>(); + private final FilterChain chain = (req, res) -> passed.add(((MockHttpServletRequest) req).getServletPath()); + + private static SsoAddressing liveAddressing() { + MockEnvironment env = new MockEnvironment(); + env.setProperty(SsoAddressing.KEY_SERVER_URL_PREFIX, "http://sso.yunhaixingtong.com/"); + env.setProperty(SsoAddressing.KEY_CLIENT_HOST_URL, "http://base.yunhaixingtong.com/open-gateway/"); + SsoProperties properties = new SsoProperties(); + properties.setEnabled(true); + return new SsoAddressing(env, properties); + } + + private static MockHttpServletRequest request(String path, boolean ajax) { + MockHttpServletRequest req = new MockHttpServletRequest("GET", path); + req.setServletPath(path); + if (ajax) { + req.addHeader("yschapirequest", "1"); + } + return req; + } + + @Test + void 未登录带标头_200加code302() throws Exception { + MockHttpServletRequest req = request("/api/internal/admin/routes", true); + MockHttpServletResponse res = new MockHttpServletResponse(); + + new BrowserAuthGateFilter(liveAddressing()).doFilter(req, res, chain); + + assertThat(res.getStatus()).isEqualTo(200); + assertThat(res.getContentType()).isEqualTo("application/json;charset=UTF-8"); + assertThat(passed).isEmpty(); + JsonNode node = MAPPER.readTree(res.getContentAsString()); + assertThat(node.get("code").asInt()).isEqualTo(302); + assertThat(node.get("urlToRedirectTo").asText()) + .isEqualTo("http://sso.yunhaixingtong.com/login?service=" + + "http%3A%2F%2Fbase.yunhaixingtong.com%2Fopen-gateway%2Fapi%2Finternal%2Fadmin%2Fsso-entry"); + } + + @Test + void 未登录裸调用_401加json信封() throws Exception { + MockHttpServletRequest req = request("/api/internal/admin/routes", false); + MockHttpServletResponse res = new MockHttpServletResponse(); + + new BrowserAuthGateFilter(liveAddressing()).doFilter(req, res, chain); + + assertThat(res.getStatus()).isEqualTo(401); + assertThat(res.getContentAsString()).doesNotContain(" + * 只测本类拥有的那一个覆写点——cas-client 自身的 init-param 装配由生产 Notice 的同一套写法背书, + * 不在这里重造断言。 + */ +class GatewayCasValidationFilterTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static SsoAddressing liveAddressing() { + MockEnvironment env = new MockEnvironment(); + env.setProperty(SsoAddressing.KEY_SERVER_URL_PREFIX, "http://sso.yunhaixingtong.com/"); + env.setProperty(SsoAddressing.KEY_CLIENT_HOST_URL, "http://base.yunhaixingtong.com/open-gateway/"); + SsoProperties properties = new SsoProperties(); + properties.setEnabled(true); + return new SsoAddressing(env, properties); + } + + @Test + void SPA带废票_code302让它重新起跳() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/internal/admin/sso-entry"); + req.addHeader("yschapirequest", "1"); + req.setParameter("ticket", "ST-already-used"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + new GatewayCasValidationFilter(liveAddressing()).onFailedValidation(req, res); + + assertThat(res.getStatus()).isEqualTo(200); + JsonNode node = MAPPER.readTree(res.getContentAsString()); + assertThat(node.get("code").asInt()).isEqualTo(302); + assertThat(node.get("urlToRedirectTo").asText()).startsWith("http://sso.yunhaixingtong.com/login?service="); + } + + @Test + void 裸调用带废票_401json不是500HTML() throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/internal/admin/sso-entry"); + MockHttpServletResponse res = new MockHttpServletResponse(); + + new GatewayCasValidationFilter(liveAddressing()).onFailedValidation(req, res); + + assertThat(res.getStatus()).isEqualTo(401); + assertThat(res.getContentAsString()).doesNotContain(" + * 取值用 160 容器里的实测值({@code docker exec open-gateway-service env} 读到的 + * {@code YH_SSO_SERVER_URL_PREFIX} / {@code YH_SSO_CLIENT_HOST_URL}),不是随手写的样例—— + * 平台注入形态(带不带尾斜杠、带不带前缀)本身就是被测对象。 + *

    + * 反例锚点:基础平台 starter 会推导出 {@code http://base.yunhaixingtong.com/api/...}(丢前缀), + * 该地址在 base 域实测 404;Notice 之所以没事,是它 ctx=/notice、容器 URI 自带前缀。 + */ +class SsoAddressingTest { + + /** 160 实测注入值(注意都带尾斜杠) */ + private static final String SSO_PREFIX = "http://sso.yunhaixingtong.com/"; + private static final String CLIENT_HOST = "http://base.yunhaixingtong.com/open-gateway/"; + + private static SsoAddressing addressing(String serverUrlPrefix, String clientHostUrl, boolean enabled) { + MockEnvironment env = new MockEnvironment(); + if (serverUrlPrefix != null) { + env.setProperty(SsoAddressing.KEY_SERVER_URL_PREFIX, serverUrlPrefix); + } + if (clientHostUrl != null) { + env.setProperty(SsoAddressing.KEY_CLIENT_HOST_URL, clientHostUrl); + } + SsoProperties properties = new SsoProperties(); + properties.setEnabled(enabled); + return new SsoAddressing(env, properties); + } + + private static SsoAddressing live() { + return addressing(SSO_PREFIX, CLIENT_HOST, true); + } + + @Test + void 尾斜杠归一与外链前缀保留() { + SsoAddressing a = live(); + assertThat(a.casServerUrlPrefix()).isEqualTo("http://sso.yunhaixingtong.com"); + assertThat(a.externalBaseUrl()).isEqualTo("http://base.yunhaixingtong.com/open-gateway"); + // 不给 cas-client 的 serverName(与 service 互斥,AbstractCasFilter.init 直接 assert): + // 治理面的 service 全靠 externalBaseUrl 拼,路径段必须原样保住 + assertThat(a.externalBaseUrl()).endsWith("/open-gateway"); + } + + @Test + void 固定service是外部可达且带前缀的会话建立端点() { + assertThat(live().serviceUrl()) + .isEqualTo("http://base.yunhaixingtong.com/open-gateway/api/internal/admin/sso-entry"); + } + + @Test + void 登录地址与Notice同式() { + // 实测 Notice 的返回形态:http://sso.yunhaixingtong.com/login?service=http%3A%2F%2Fbase...%2Fnotice%2Fapi%2F__probe + assertThat(live().loginUrl()).isEqualTo( + "http://sso.yunhaixingtong.com/login?service=" + + "http%3A%2F%2Fbase.yunhaixingtong.com%2Fopen-gateway%2Fapi%2Finternal%2Fadmin%2Fsso-entry"); + } + + @Test + void 登录后落控制台静态面() { + assertThat(live().consoleUrl()).isEqualTo("http://base.yunhaixingtong.com/open-gateway/front/index.html"); + } + + @Test + void 开关与平台键齐了才生效() { + assertThat(live().active()).isTrue(); + assertThat(addressing(SSO_PREFIX, CLIENT_HOST, false).active()).isFalse(); + assertThat(addressing(null, CLIENT_HOST, true).active()).isFalse(); + assertThat(addressing(SSO_PREFIX, null, true).active()).isFalse(); + assertThat(addressing(" ", CLIENT_HOST, true).active()).isFalse(); + } + + @Test + void 点了开关却缺平台键是可被告警的反常组合() { + assertThat(addressing(SSO_PREFIX, null, true).enabledButUnconfigured()).isTrue(); + assertThat(live().enabledButUnconfigured()).isFalse(); + // 没点开关的沉默是正常态(本地/未上线),不报警 + assertThat(addressing(null, null, false).enabledButUnconfigured()).isFalse(); + } +} diff --git a/src/test/java/com/yunhai/platform/gateway/sso/SsoConfigBaselineTest.java b/src/test/java/com/yunhai/platform/gateway/sso/SsoConfigBaselineTest.java new file mode 100644 index 0000000..0c73ffe --- /dev/null +++ b/src/test/java/com/yunhai/platform/gateway/sso/SsoConfigBaselineTest.java @@ -0,0 +1,56 @@ +package com.yunhai.platform.gateway.sso; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.PropertySource; +import org.springframework.core.io.ClassPathResource; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * jar 内配置的红线快照(本次接入只动 jar 自身,不碰 Caddy、不碰 proj39、不给网关注入 context-path)。 + *

    + * 直接读 {@code application.yml} 而不是断言运行期行为:这几条的失效方式正是「配错了自己不知道」—— + * 加了 ctx 会让对外路径整体错位,cookie 不改名会让治理面会话与 base 域其他应用同名叠放, + * 开关被烧成 {@code true} 会让本地跑测与未上线环境直接点上鉴权。 + */ +class SsoConfigBaselineTest { + + private static PropertySource jarYml() throws Exception { + List> loaded = + new YamlPropertySourceLoader().load("app", new ClassPathResource("application.yml")); + assertThat(loaded).hasSize(1); + return loaded.get(0); + } + + @Test + void 治理面会话cookie改名且无contextPath() throws Exception { + PropertySource yml = jarYml(); + assertThat(yml.getProperty("server.servlet.session.cookie.name")).isEqualTo("OPENGW_SESSION"); + // ctx 必须保持空:对外前缀 /open-gateway 由 Caddy 剥离,一旦给 ctx 就双段错位 + assertThat(yml.containsProperty("server.servlet.context-path")).isFalse(); + } + + @Test + void SSO开关走env且默认关环境值不进jar() throws Exception { + assertThat(jarYml().getProperty("yh.gateway.sso.enabled")).isEqualTo("${OPENGW_SSO_ENABLED:false}"); + } + + @Test + void 配置里的面路径与代码默认值逐字一致() throws Exception { + // 两处不一致时,配置层改动会静默覆盖代码里的面定义 + PropertySource yml = jarYml(); + SsoProperties properties = new SsoProperties(); + assertThat(yml.getProperty("yh.gateway.sso.url-pattern")).isEqualTo(properties.getUrlPattern()); + assertThat(yml.getProperty("yh.gateway.sso.entry-path")).isEqualTo(properties.getEntryPath()); + } + + @Test + void 开放面验签开关仍是关闭态() throws Exception { + // 网关自有 SignVerifyFilter 是唯一验签面;starter 的 AppAuthFilter 一旦打开会抢在链首 + // 按「含 target 段的完整 URI」验签 → /oapi 全量 401(红线 1) + assertThat(jarYml().getProperty("yh.app-auth.enabled")).isEqualTo(false); + } +} diff --git a/src/test/java/com/yunhai/platform/gateway/sso/SsoFilterWiringTest.java b/src/test/java/com/yunhai/platform/gateway/sso/SsoFilterWiringTest.java new file mode 100644 index 0000000..04cf31a --- /dev/null +++ b/src/test/java/com/yunhai/platform/gateway/sso/SsoFilterWiringTest.java @@ -0,0 +1,129 @@ +package com.yunhai.platform.gateway.sso; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.core.Ordered; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.mock.web.MockFilterConfig; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * CAS 两件套的装配面(红线 1 的结构化钉桩):序位、圈定路径、开关态,以及用真实 init-param + * 跑一遍 cas-client 的装配。 + *

    + * 关键一条:默认(不带 OPENGW_SSO_ENABLED)时两个注册都是 disabled——这保证 + * 「代码已合入但没点开关」的网关与今天逐字节相同,也让回滚只需清 env、不需回退镜像。 + */ +class SsoFilterWiringTest { + + /** 160 容器实测的两个平台键 */ + private static final String SSO_PREFIX = "http://sso.yunhaixingtong.com/"; + private static final String CLIENT_HOST = "http://base.yunhaixingtong.com/open-gateway/"; + + private static SsoAddressing addressing(boolean enabled, String serverPrefix, String clientHost) { + MockEnvironment env = new MockEnvironment(); + if (serverPrefix != null) { + env.setProperty(SsoAddressing.KEY_SERVER_URL_PREFIX, serverPrefix); + } + if (clientHost != null) { + env.setProperty(SsoAddressing.KEY_CLIENT_HOST_URL, clientHost); + } + SsoProperties properties = new SsoProperties(); + properties.setEnabled(enabled); + return new SsoAddressing(env, properties); + } + + private static SsoProperties properties(boolean enabled) { + SsoProperties properties = new SsoProperties(); + properties.setEnabled(enabled); + return properties; + } + + /** 用装配代码写下的同一份 init-param 构造 FilterConfig(不在此重复抄参数名) */ + private static MockFilterConfig filterConfig(SsoAddressing addressing) { + MockFilterConfig config = new MockFilterConfig(); + for (Map.Entry param + : SsoFilterConfiguration.validationInitParams(addressing).entrySet()) { + config.addInitParameter(param.getKey(), param.getValue()); + } + return config; + } + + @Test + void 两个过滤器都只挂治理面且排在网关链首之前() { + SsoProperties properties = properties(true); + SsoAddressing addressing = addressing(true, SSO_PREFIX, CLIENT_HOST); + SsoFilterConfiguration configuration = new SsoFilterConfiguration(); + + FilterRegistrationBean validation = + configuration.casTicketValidationFilter(addressing, properties); + FilterRegistrationBean gate = + configuration.browserAuthGateFilter(addressing, properties); + + assertThat(validation.getUrlPatterns()).containsExactly("/api/internal/admin/*"); + assertThat(gate.getUrlPatterns()).containsExactly("/api/internal/admin/*"); + // 与基础平台 CasSsoAutoConfiguration 同序位段,确保先于 @Order(0..5) 的网关自有链 + assertThat(validation.getOrder()).isEqualTo(Ordered.HIGHEST_PRECEDENCE + 4); + assertThat(gate.getOrder()).isEqualTo(Ordered.HIGHEST_PRECEDENCE + 5); + } + + @Test + void 圈定路径必须是api的子集() { + // 红线 1:CAS 的 url-pattern 只准圈 /api/*。有人把它放宽成 /** 或 /oapi/* 时立刻断在这里 + String pattern = new SsoProperties().getUrlPattern(); + assertThat(pattern).startsWith("/api/"); + assertThat(pattern).isNotEqualTo("/api/*"); + assertThat(pattern).isEqualTo("/api/internal/admin/*"); + } + + @Test + void 开关没点_两个注册都是disabled等于零行为变化() { + SsoProperties properties = properties(false); + SsoAddressing addressing = addressing(false, SSO_PREFIX, CLIENT_HOST); + SsoFilterConfiguration configuration = new SsoFilterConfiguration(); + + assertThat(configuration.casTicketValidationFilter(addressing, properties).isEnabled()).isFalse(); + assertThat(configuration.browserAuthGateFilter(addressing, properties).isEnabled()).isFalse(); + } + + @Test + void 平台键缺失时即使点了开关也不装配并出声告警() { + SsoProperties properties = properties(true); + SsoAddressing addressing = addressing(true, null, CLIENT_HOST); + SsoFilterConfiguration configuration = new SsoFilterConfiguration(); + + assertThat(addressing.active()).isFalse(); + assertThat(addressing.enabledButUnconfigured()).isTrue(); + assertThat(configuration.casTicketValidationFilter(addressing, properties).isEnabled()).isFalse(); + } + + @Test + void 照真实init参数跑通cas客户端的装配() throws Exception { + // 参数名写错在 cas-client 里要么静默要么启动即抛;这里用同一份参数真跑一次 init() + SsoAddressing addressing = addressing(true, SSO_PREFIX, CLIENT_HOST); + GatewayCasValidationFilter filter = new GatewayCasValidationFilter(addressing); + + assertThatCode(() -> filter.init(filterConfig(addressing))).doesNotThrowAnyException(); + } + + @Test + void 固定service被cas客户端原样采用_不掺容器路径() throws Exception { + SsoAddressing addressing = addressing(true, SSO_PREFIX, CLIENT_HOST); + GatewayCasValidationFilter filter = new GatewayCasValidationFilter(addressing); + filter.init(filterConfig(addressing)); + + // 容器侧看到的是被 Caddy 剥掉前缀的路径(ctx 为空),解析结果必须仍是外部带前缀的固定 service + MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/internal/admin/sso-entry"); + req.setContextPath(""); + req.setParameter("ticket", "ST-test"); + + assertThat(filter.resolvedServiceUrl(req, new MockHttpServletResponse())) + .isEqualTo("http://base.yunhaixingtong.com/open-gateway/api/internal/admin/sso-entry"); + } +} diff --git a/src/test/java/com/yunhai/platform/gateway/sso/SsoSurfaceGuardTest.java b/src/test/java/com/yunhai/platform/gateway/sso/SsoSurfaceGuardTest.java new file mode 100644 index 0000000..e3fd9c0 --- /dev/null +++ b/src/test/java/com/yunhai/platform/gateway/sso/SsoSurfaceGuardTest.java @@ -0,0 +1,196 @@ +package com.yunhai.platform.gateway.sso; + +import com.yunhai.platform.gateway.auth.InternalTokenFilter; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.TreeSet; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 面守卫(红线 1 的机械钉桩):网关全部 MVC 映射必须落进已声明鉴权归属的面之一, + * 且 CAS 面圈定路径必须是 {@code /api/*} 的子集、绝不能圈到 {@code /oapi}、静态面、探活与文档。 + *

    + * 为什么扫全仓控制器而不是逐个点名:CAS 的 url-pattern 被刻意收窄到 + * {@code /api/internal/admin/*}(为把机器面 {@code routes/refresh} 留在面外), + * 于是「新加一个 {@code /api/xxx} 控制器」不再天然受保护。本测试把这件事从人工 review + * 变成测试期失败——新映射要么落在治理面前缀内,要么必须先在这里登记自己的鉴权面。 + */ +class SsoSurfaceGuardTest { + + /** 机器面:不进 CAS 面,由内部令牌门(Stage 2)负责 */ + private static final Set MACHINE_PATHS = + new TreeSet<>(Collections.singletonList("/api/internal/routes/refresh")); + + /** 必须留在鉴权面之外的路径(本次线上探针实测过的形态) */ + private static final String[] MUST_STAY_OUTSIDE = { + "/notice/oapi/x", + "/userbase/oapi/app/list", + "/userclass/oapi/x", + "/oapi/x", + "/front/index.html", + "/open-gateway/front/index.html", + "/actuator/health", + "/v3/api-docs", + "/swagger-ui/index.html", + "/swagger-ui.html", + "/upload/file/a.png", + }; + + private final SsoProperties properties = new SsoProperties(); + + /** + * servlet 过滤器映射口径(Tomcat):{@code /prefix/*} 是前缀映射,匹配任意层数; + * 与 Spring {@code AntPathMatcher}({@code *} 只吃一段)不同——按 Ant 断言会误判 + * {@code /api/internal/admin/apps/{id}} 不在面内。 + */ + private static boolean matched(String pattern, String path) { + if (pattern.endsWith("*")) { + return path.startsWith(pattern.substring(0, pattern.length() - 1)); + } + return pattern.equals(path); + } + + private static String join(String base, String path) { + String head = base.endsWith("/") ? base.substring(0, base.length() - 1) : base; + String tail = path.isEmpty() || path.startsWith("/") ? path : "/" + path; + String joined = head + tail; + return joined.isEmpty() ? "/" : joined; + } + + private static Set valuesOf(Annotation mapping) { + if (mapping == null) { + return Collections.emptySet(); + } + String[] direct; + String[] alias; + if (mapping instanceof RequestMapping) { + direct = ((RequestMapping) mapping).value(); + alias = ((RequestMapping) mapping).path(); + } else if (mapping instanceof GetMapping) { + direct = ((GetMapping) mapping).value(); + alias = ((GetMapping) mapping).path(); + } else if (mapping instanceof PutMapping) { + direct = ((PutMapping) mapping).value(); + alias = ((PutMapping) mapping).path(); + } else if (mapping instanceof PostMapping) { + direct = ((PostMapping) mapping).value(); + alias = ((PostMapping) mapping).path(); + } else if (mapping instanceof DeleteMapping) { + direct = ((DeleteMapping) mapping).value(); + alias = ((DeleteMapping) mapping).path(); + } else { + return Collections.emptySet(); + } + String[] picked = direct.length > 0 ? direct : alias; + Set out = new LinkedHashSet<>(); + if (picked.length == 0) { + out.add(""); + } else { + Collections.addAll(out, picked); + } + return out; + } + + /** 全仓 @RestController 的「类前缀 + 方法路径」全集(无映射注解的方法不参与) */ + private Set allControllerPaths() throws Exception { + ClassPathScanningCandidateComponentProvider scanner = + new ClassPathScanningCandidateComponentProvider(false); + scanner.addIncludeFilter(new AnnotationTypeFilter(RestController.class)); + Set paths = new LinkedHashSet<>(); + for (BeanDefinition candidate : scanner.findCandidateComponents("com.yunhai.platform.gateway")) { + Class type = Class.forName(candidate.getBeanClassName()); + Set bases = valuesOf(type.getAnnotation(RequestMapping.class)); + if (bases.isEmpty()) { + bases = new LinkedHashSet<>(Collections.singletonList("")); + } + for (Method method : type.getDeclaredMethods()) { + for (Annotation mapping : method.getAnnotations()) { + Set methodPaths = valuesOf(mapping); + for (String base : bases) { + for (String methodPath : methodPaths) { + paths.add(join(base, methodPath)); + } + } + } + } + } + return paths; + } + + @Test + void 全仓控制器映射都已声明鉴权归属() throws Exception { + Set paths = allControllerPaths(); + assertThat(paths).isNotEmpty(); + for (String path : paths) { + assertThat(matched(properties.getUrlPattern(), path) || MACHINE_PATHS.contains(path)) + .as("映射 %s 既不在 CAS 治理面(%s)内,也未登记为机器面", path, properties.getUrlPattern()) + .isTrue(); + } + } + + @Test + void 治理面与机器面互不重叠() { + for (String machine : MACHINE_PATHS) { + assertThat(matched(properties.getUrlPattern(), machine)) + .as("机器面 %s 被 CAS 面圈住了(会导致 Projman 的内部调用被 302/401 挡掉)", machine) + .isFalse(); + } + } + + @Test + void 回跳靶点必须在CAS面内() { + // 固定 service 指向 entry-path;落在面外的话票据校验过滤器根本不运行,登录永远建不起会话 + assertThat(matched(properties.getUrlPattern(), properties.getEntryPath())).isTrue(); + } + + @Test + void CAS面圈定路径是api的子集() { + assertThat(properties.getUrlPattern()).startsWith("/api/"); + assertThat(properties.getUrlPattern()).isNotEqualTo("/api/*"); + } + + @Test + void 开放面静态面探活文档一律在面外() { + // 两道门(CAS 面 + 内部令牌门)都不许碰到这些前缀——红线 1 的正面断言 + String[] patterns = {properties.getUrlPattern(), InternalTokenFilter.URL_PATTERN}; + for (String path : MUST_STAY_OUTSIDE) { + for (String pattern : patterns) { + assertThat(matched(pattern, path)) + .as("门模式 %s 圈到了 %s(会打断开放面/静态面)", pattern, path) + .isFalse(); + } + } + } + + @Test + void 机器面必须被令牌门圈住且不被CAS面圈住() { + for (String machine : MACHINE_PATHS) { + assertThat(matched(InternalTokenFilter.URL_PATTERN, machine)) + .as("机器面 %s 落在令牌门之外,等于裸奔", machine).isTrue(); + assertThat(machine).doesNotStartWith(InternalTokenFilter.BROWSER_SURFACE_PREFIX); + } + } + + @Test + void 新增端点路径与配置字面一致() throws Exception { + // 注解里的路径是编译期字面量、配置里的 entry-path 可被覆盖:两者一旦漂移,回跳 404 且启动无报错 + Set paths = allControllerPaths(); + assertThat(paths).contains(properties.getEntryPath()); + assertThat(paths).contains("/api/internal/admin/session"); + } +}