+ * 为什么要两个口:按钮此前直接调机器面那个路径,而机器面收口成「只认内部令牌」后,
+ * 浏览器带 CAS 会话去打它会被 401 拦死。平台不变量是一个接口面一种认证、互不串门,
+ * 所以给浏览器面单开入口,而不是在同一端点上做「令牌 OR 会话」。
+ */
+ @Operation(operationId = "GatewayAdmin.reload", summary = "热重载注册表(控制台按钮)")
+ @PostMapping("/routes/reload")
+ public Map
+ * 为什么必须有这一层,而不是「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
+ * 与上面「鉴权错 = 裸 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;
*
+ * 为什么要抽这一层:这段「三张快照各自 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
- * 重载后向 {@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
+ * 有意不装 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}):
+ *
+ * 因此网关的 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
+ * 为什么不复用 {@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 面的「未登录」双形态应答(网关治理面专用)。
+ *
+ * 两个形态各有必须成立的读者,缺一不可:
+ *
+ * 为什么要有这个接口:Notice 前端的 {@code ssoHost} 来自 {@code /api/env-config}
+ * 运行时配置;网关后端没有 env-config 端点,前端 {@code conf.ssoHost} 恒为空,
+ * 于是 Notice 同款的 {@code
+ * 本路径在 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
+ * 唯一实测调用方是 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
+ * 「401 必须是 json、不许是 302 HTML」是本次验收的核心判据:裸 curl 从 200 读得到路由表,
+ * 变成 401 才算堵上;而 302 HTML 会让调用方以为拿到了页面。
+ */
+class BrowserAuthGateFilterTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private final List
+ * 反例锚点:基础平台 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
+ * 关键一条:默认(不带 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
+ * 为什么扫全仓控制器而不是逐个点名:CAS 的 url-pattern 被刻意收窄到
+ * {@code /api/internal/admin/*}(为把机器面 {@code routes/refresh} 留在面外),
+ * 于是「新加一个 {@code /api/xxx} 控制器」不再天然受保护。本测试把这件事从人工 review
+ * 变成测试期失败——新映射要么落在治理面前缀内,要么必须先在这里登记自己的鉴权面。
+ */
+class SsoSurfaceGuardTest {
+
+ /** 机器面:不进 CAS 面,由内部令牌门(Stage 2)负责 */
+ private static final Set
+ *
+ * 与 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)。
+ *
+ *
+ * 为什么这里是信封而不是裸码:{@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 登出地址)。
+ *