feat(sso): 治理面 CAS 登录 + 机器面内部令牌门 + 注册表重载双口
build / build-push (push) Successful in 48s

- 治理面浏览器 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 原样采用)+ 双形态应答 + 令牌三态/灰度窗 + 重载器
This commit is contained in:
chenxing
2026-10-11 12:46:55 +08:00
parent 1ae94d7100
commit 7e3690e523
26 changed files with 1793 additions and 22 deletions
+13 -1
View File
@@ -37,7 +37,12 @@
</dependencyManagement> </dependencyManagement>
<dependencies> <dependencies>
<!-- 白名单(开放接口中心 T0.8):本清单外依赖不得引入 --> <!-- 白名单(开放接口中心 T0.8):本清单外依赖不得引入。
2026-10-09 例外授权:org.jasig.cas.client:cas-client-core(治理面浏览器 SSO 票据校验,
版本与 Notice/UserBase 逐字一致,见下)。仍不得引 yh-auth starter——平台注入的
YH_SSO_SERVER_URL_PREFIX 已在容器 env 中,starter 的
{@code @ConditionalOnProperty(yh.sso.server-url-prefix)} 会被动点燃并以默认
/api/* 圈住机器面,且其 serverName() 正则会丢掉外链前缀导致回跳 404。 -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
@@ -103,6 +108,13 @@
<artifactId>springdoc-openapi-ui</artifactId> <artifactId>springdoc-openapi-ui</artifactId>
<version>1.7.0</version> <version>1.7.0</version>
</dependency> </dependency>
<!-- CAS 客户端(治理面浏览器 SSO 的票据校验)。只用 cas-client-core、不引平台 yh-auth starter,
接法见 com.yunhai.platform.gateway.sso 包注释。版本与 Notice/UserBase 及本地 .m2 一致。 -->
<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-core</artifactId>
<version>3.5.1</version>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -10,6 +10,7 @@ import com.yunhai.platform.gateway.repository.OpenApiAppRepository;
import com.yunhai.platform.gateway.repository.OpenApiLogRepository; import com.yunhai.platform.gateway.repository.OpenApiLogRepository;
import com.yunhai.platform.gateway.repository.OpenApiRouteRepository; import com.yunhai.platform.gateway.repository.OpenApiRouteRepository;
import com.yunhai.platform.gateway.repository.OpenApiTargetRepository; 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.RouteChangeBroadcaster;
import com.yunhai.platform.gateway.route.RouteRegistry; import com.yunhai.platform.gateway.route.RouteRegistry;
import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.exception.ErrorCode;
@@ -98,6 +99,10 @@ public class InternalAdminController {
@Autowired(required = false) @Autowired(required = false)
private RouteChangeBroadcaster broadcaster; private RouteChangeBroadcaster broadcaster;
/** 注册表全量重载(与机器面 {@code /api/internal/routes/refresh} 共用实现) */
@Autowired
private RegistryReloader reloader;
/** 测试/显式装配入口 */ /** 测试/显式装配入口 */
void setRepos(OpenApiRouteRepository routeRepo, OpenApiTargetRepository targetRepo, void setRepos(OpenApiRouteRepository routeRepo, OpenApiTargetRepository targetRepo,
OpenApiLogRepository logRepo, RouteRegistry routeRegistry, OpenApiLogRepository logRepo, RouteRegistry routeRegistry,
@@ -110,6 +115,25 @@ public class InternalAdminController {
this.appAuthRegistry = appAuthRegistry; this.appAuthRegistry = appAuthRegistry;
} }
/** 测试/显式装配入口 */
void setReloader(RegistryReloader reloader) {
this.reloader = reloader;
}
/**
* 控制台「重载注册表」按钮:浏览器面入口,与机器面 {@code POST /api/internal/routes/refresh}
* 同实现({@link RegistryReloader})、同返回体形态。
* <p>
* 为什么要两个口:按钮此前直接调机器面那个路径,而机器面收口成「只认内部令牌」后,
* 浏览器带 CAS 会话去打它会被 401 拦死。平台不变量是一个接口面一种认证、互不串门,
* 所以给浏览器面单开入口,而不是在同一端点上做「令牌 OR 会话」。
*/
@Operation(operationId = "GatewayAdmin.reload", summary = "热重载注册表(控制台按钮)")
@PostMapping("/routes/reload")
public Map<String, Object> reload() {
return reloader.reloadAll();
}
/** 路由表分页(治理对象全量含禁用行;targetCode+pathPattern 稳定排序) */ /** 路由表分页(治理对象全量含禁用行;targetCode+pathPattern 稳定排序) */
@Operation(operationId = "GatewayAdmin.routes", summary = "路由注册表分页") @Operation(operationId = "GatewayAdmin.routes", summary = "路由注册表分页")
@GetMapping("/routes") @GetMapping("/routes")
@@ -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/**} 中除浏览器面之外的一切):只认共享内部令牌。
* <p>
* <b>为什么必须有这一层,而不是「CAS 面里放个令牌的 OR 分支」</b>:基础平台的 CAS 过滤器
* ({@code CasSsoAutoConfiguration})对六个 bean 各自只 {@code addUrlPatterns(单值)},
* 也不读 {@code ignorePattern}——<b>过滤器层根本不存在豁免机制</b>。平台对机器流量的做法是
* 一律靠路径前缀在过滤器之外分流({@code /mobiapi}、{@code /oapi})。本闸门同此范式:
* CAS 面收窄到 {@link #BROWSER_SURFACE_PREFIX},本闸门接管 {@code /api/internal/*} 的其余部分。
* <p>
* <b>判定顺序=默认拒绝</b>:落在 {@code /api/internal/*} 内、又不在浏览器面前缀下的路径,
* 一律要求令牌——将来新增的内部端点不会因为「没人想到要保护」而裸奔。
* <p>
* <b>实测调用方清单(本次侦察结论,关键字 {@code api/internal} 全平台扫描)</b>:仅
* 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/**} 是它自己的内部密钥端点,均与本闸门无关。
* <p>
* 令牌值只来自配置({@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);
}
}
@@ -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 之前)。
* <p>
* <b>不受 {@code yh.gateway.sso.enabled} 开关影响</b>:令牌门是独立的一层防护,网关关着 CAS 面
* (回滚态)时它照样收口机器面;反过来 CAS 面点开时浏览器面仍归 CAS 管。
* <p>
* 圈定 {@link InternalTokenFilter#URL_PATTERN}({@code /api/internal/*}):{@code /oapi/**} 转发链、
* 静态面、探活与文档端点都不在该前缀下,逐字节不受影响。
*/
@Configuration
public class InternalTokenFilterConfiguration {
@Bean
public FilterRegistrationBean<InternalTokenFilter> internalTokenFilter(GatewayProperties properties) {
FilterRegistrationBean<InternalTokenFilter> bean =
new FilterRegistrationBean<>(new InternalTokenFilter(properties));
bean.addUrlPatterns(InternalTokenFilter.URL_PATTERN);
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 3);
bean.setName("internalTokenFilter");
return bean;
}
}
@@ -50,4 +50,23 @@ public final class EnvelopeWriter {
node.putNull("data"); node.putNull("data");
response.getWriter().write(MAPPER.writeValueAsString(node)); response.getWriter().write(MAPPER.writeValueAsString(node));
} }
/**
* 管理面鉴权失败:HTTP 401 + 治理面信封 {@code {code:401, msg, data:null}}。
* <p>
* 与上面「鉴权错 = 裸 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();
}
} }
@@ -16,7 +16,8 @@ import java.util.Map;
* <li>observe:观察模式(T1.6 消费;默认 true = 只记不拦,R4);</li> * <li>observe:观察模式(T1.6 消费;默认 true = 只记不拦,R4);</li>
* <li>trustedProxies:外部入口判定的可信来源(T4.3,CIDR 列表)——仅这些来源的 * <li>trustedProxies:外部入口判定的可信来源(T4.3,CIDR 列表)——仅这些来源的
* {@code X-Entry: external} 生效;留空 = 不存在外部入口;</li> * {@code X-Entry: external} 生效;留空 = 不存在外部入口;</li>
* <li>anonPerIpQps:ANON 路由未配 {@code rate_limit_qps} 时的 per-IP 缺省限额(T3.1 消费)。</li> * <li>anonPerIpQps:ANON 路由未配 {@code rate_limit_qps} 时的 per-IP 缺省限额(T3.1 消费);</li>
* <li>internalToken:机器面({@code /api/internal/**} 除浏览器面)的共享内部令牌。</li>
* </ul> * </ul>
*/ */
@Component @Component
@@ -39,6 +40,14 @@ public class GatewayProperties {
/** ANON 路由未配 route.rate_limit_qps 时的 per-IP 缺省限额(&gt;0) */ /** ANON 路由未配 route.rate_limit_qps 时的 per-IP 缺省限额(&gt;0) */
private int anonPerIpQps = 10; 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<String, String> getRoutes() { public Map<String, String> getRoutes() {
return routes; return routes;
} }
@@ -70,4 +79,12 @@ public class GatewayProperties {
public void setAnonPerIpQps(int anonPerIpQps) { public void setAnonPerIpQps(int anonPerIpQps) {
this.anonPerIpQps = anonPerIpQps; this.anonPerIpQps = anonPerIpQps;
} }
public String getInternalToken() {
return internalToken;
}
public void setInternalToken(String internalToken) {
this.internalToken = internalToken;
}
} }
@@ -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 + 变更广播)——机器面与浏览器面两个触发口共用的唯一实现。
* <p>
* <b>为什么要抽这一层</b>:这段「三张快照各自 reload + 广播」此前只有一份,长在
* {@code RouteRefreshController}(机器面 {@code POST /api/internal/routes/refresh},
* projman Apply 后调用)上。但治理页的「重载注册表」按钮调的<b>也是</b>那一个路径——
* 本次把机器面收成「只认内部令牌」后,浏览器带着 CAS 会话去打令牌门口会被 401 拦死。
* 按平台不变量「一个接口面一种认证,互不串门」,正确解法是给浏览器面单开一个入口,
* 两处共用本组件;而不是在同一个端点上做「令牌 OR 会话」的两制式判定。
* <p>
* 返回体形态({@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<String, Object> reloadAll() {
Map<String, Object> 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;
}
}
@@ -1,6 +1,5 @@
package com.yunhai.platform.gateway.route; 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.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired; 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.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
/** /**
* 网关内部管理端点(T0.10):/api/internal/** 属网关自身路径,收网后仅容器网内可达, * 网关内部管理端点(T0.10):/api/internal/** 属网关自身路径,收网后仅容器网内可达,
* 不注册进 open_api_route(注册表只治理 /{target}/oapi/**)。 * 不注册进 open_api_route(注册表只治理 /{target}/oapi/**)。
* <p> * <p>
* 重载后向 {@link RouteChangeBroadcaster} 发一条变更广播:本实例已就地重载, * <b>本端点属机器面</b>:只认内部令牌头 {@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 = "网关治理:路由旋钮 / 目标地址 / 访问台账") @Tag(name = "GatewayAdmin", description = "网关治理:路由旋钮 / 目标地址 / 访问台账")
@RestController @RestController
@@ -24,26 +25,17 @@ import java.util.Map;
public class RouteRefreshController { public class RouteRefreshController {
@Autowired @Autowired
private RouteRegistry routeRegistry; private RegistryReloader reloader;
@Autowired
private AppAuthRegistry appAuthRegistry;
/** 变更广播(多实例同步);广播开关关闭时该 bean 不存在,故 required=false */
@Autowired(required = false)
private RouteChangeBroadcaster broadcaster;
/** 清缓存重 load,返回加载数(routes = 路由条目,targets = DB 地址行,apps = ANON 应用行) */ /** 清缓存重 load,返回加载数(routes = 路由条目,targets = DB 地址行,apps = ANON 应用行) */
@Operation(operationId = "GatewayAdmin.refresh", summary = "热重载路由注册表与目标地址") @Operation(operationId = "GatewayAdmin.refresh", summary = "热重载路由注册表与目标地址")
@PostMapping("/routes/refresh") @PostMapping("/routes/refresh")
public Map<String, Object> refresh() { public Map<String, Object> refresh() {
Map<String, Object> body = new LinkedHashMap<>(); return reloader.reloadAll();
body.put("loaded", routeRegistry.reload());
body.put("targets", routeRegistry.reloadTargets());
body.put("apps", appAuthRegistry.reload());
if (broadcaster != null) {
broadcaster.publish();
} }
return body;
/** 测试/显式装配入口 */
void setReloader(RegistryReloader reloader) {
this.reloader = reloader;
} }
} }
@@ -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})。
* <p>
* 有意<b>不</b>装 cas-client 的 {@code AuthenticationFilter}:它的 302-到-登录页会把容器侧
* {@code request.getRequestURI()} 拼进 service(网关 ctx 为空、外链前缀被 Caddy 剥掉,
* 拼出来必是 404 地址),且它对裸 curl 出 HTML 302、与治理面「401 json」契约冲突。
* 登录地址因此由 {@link SsoAddressing#loginUrl()} 自己按 Notice 同式拼固定 service。
* <p>
* 也有意<b>不</b>装 {@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;
}
}
@@ -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 的原样子类,只改「校验失败」的出口。
* <p>
* cas-client 3.5.1 的失败路径是 {@code onFailedValidation(req,res)} 之后 <b>链就断了</b>
* ({@code doFilter} 是 final:失败分支不再 {@code chain.doFilter}),默认实现什么都不写,
* 于是客户端拿到一个空 200;而 {@code exceptionOnValidationFailure=true}(3.5.1 默认)
* 会抛 {@code ServletException} → 500 HTML。两种都不符合治理面契约。
* <p>
* 这里把失败出口换成与门一致的<b>双形态未登录应答</b>(票据过期/重放即未登录,让 SPA 重新起跳、
* 让裸调用方拿到 401 json),同时注册时显式设 {@code exceptionOnValidationFailure=false}
* ——顺序是先本方法写体并 flush、后 cas-client 的 {@code sendError},响应已提交,
* {@code sendError} 自然失效。
* <p>
* 成功路径按 {@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);
}
}
@@ -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 / 回跳地址。
* <p>
* 两个来源键的<b>值语义与 Notice 完全一致</b>(同键同名,由 projman 按平台设置注入
* {@code YH_SSO_SERVER_URL_PREFIX}、{@code YH_SSO_CLIENT_HOST_URL}):
* <ul>
* <li>{@code yh.sso.server-url-prefix} = CAS 服务基址,如 {@code http://sso.yunhaixingtong.com/};</li>
* <li>{@code yh.sso.client-host-url} = 本应用的<b>外部带前缀</b>地址,如
* {@code http://base.yunhaixingtong.com/open-gateway/}。</li>
* </ul>
* 与 starter 的唯一区别:这里<b>保留</b> {@code client-host-url} 的路径段。starter 用正则
* {@code ^(https?://[^/]+)} 把它丢掉、路径改由 {@code request.getRequestURI()} 供给——那只对
* 「ctx 与外链前缀同名」的应用成立(Notice 的 ctx=/notice)。网关 ctx 为空、前缀被 Caddy 剥掉,
* 容器侧 URI 里没有 {@code /open-gateway},那条路会把回跳送到 base 域根的 {@code /api/...}(实测 404)。
* <p>
* 因此网关的 CAS 面用<b>固定 service</b>:登录与票据校验都指向 {@link #serviceUrl()} 这一个
* 外部可达的会话建立端点,与请求路径无关,前缀问题从根上不成立。
* <p>
* 键值一律经 {@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);
}
}
}
@@ -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 回跳靶点)。
* <p>
* 它存在的唯一理由:网关 ctx 为空、外链前缀 {@code /open-gateway} 由 Caddy 剥离,容器侧
* {@code request.getRequestURI()} 里没有前缀,所以 cas-client/starter 那种「按当前请求路径拼
* service」的作法在这里必然把浏览器送回 base 域根的 {@code /api/...}(实测 404)。
* 治理面因此改用<b>固定 service</b>:登录地址与票据校验都指向这一个外部可达、且在 CAS 面圈内的
* 地址,回跳一定进得来;进来时校验过滤器已把断言写进会话,本端点只负责把浏览器送回控制台。
* <p>
* 落点是控制台默认页({@code /front/index.html},静态面不鉴权)。<b>页签(hash 路由)由前端自己
* 换回</b>:整页跳去 SSO 前记在 sessionStorage,回到壳页时 {@code router.replace} 回去
* ({@code front/src/utils/ssoReturn.ts},那里写了为什么不能照 Notice 把目标拼进 service——
* 固定 service 与每次不同的目标不可共存,会造成票据校验失败后的 302 死循环)。
* <p>
* 直连本端点(未登录、无 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());
}
}
@@ -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 换成固定值)。
* <p>
* 两个过滤器都只挂 {@link SsoProperties#getUrlPattern()}(默认 {@code /api/internal/admin/*},
* 是 {@code /api/*} 的子集):{@code /oapi/**} 转发链、{@code /front/**} 静态面、
* {@code /actuator/**} 探活、{@code /v3/api-docs} 文档一律在面外,行为逐字节不变。
* <p>
* 序位取 {@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<GatewayCasValidationFilter> 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<GatewayCasValidationFilter> bean = new FilterRegistrationBean<>(filter);
for (Map.Entry<String, String> 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},且因此<b>不得</b>再附 {@code serverName},二者互斥)。
* 抽成方法是为了让测试能用同一份参数真跑一次 {@code init()},而不是重复抄一遍断言。
*/
static Map<String, String> validationInitParams(SsoAddressing addressing) {
Map<String, String> params = new LinkedHashMap<>();
params.put("casServerUrlPrefix", addressing.casServerUrlPrefix());
params.put("service", addressing.serviceUrl());
params.put("redirectAfterValidation", "false");
return params;
}
/** 鉴权门(+5):无断言即按调用方形态出未登录应答 */
@Bean
public FilterRegistrationBean<BrowserAuthGateFilter> browserAuthGateFilter(
SsoAddressing addressing, SsoProperties properties) {
FilterRegistrationBean<BrowserAuthGateFilter> 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);
}
}
}
@@ -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.*})。
* <p>
* <b>为什么不复用 {@code yh.sso.*} 做全部键</b>:{@code yh.sso.server-url-prefix} 与
* {@code yh.sso.client-host-url} 的<b>值语义与 Notice 逐字一致</b>(由平台按「平台设置」注入容器 env,
* 本类不重复定义、直接经 {@link SsoAddressing} 从 Environment 读),但基础平台 starter 的
* {@code CasSsoAutoConfiguration} 不能在本网关装配——它以 {@code yh.sso.server-url-prefix} 的
* <b>存在</b>为启用条件,而该 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 的票据校验,其余按网关自己的路径面手工装配。
* <p>
* 环境值一律走 {@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;
}
}
@@ -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 面的「未登录」双形态应答(网关治理面专用)。
* <p>
* 两个形态各有必须成立的读者,缺一不可:
* <ul>
* <li><b>带 SPA 标记头</b>({@code yschapirequest: 1})→ <b>HTTP 200</b> +
* {@code {code:302, urlToRedirectTo}},与基础平台 {@code CasSsoApiRedirectFilter}
* 逐字同构;控制台前端 alova 的 {@code case 302} 分支据此整页跳 SSO。</li>
* <li><b>不带标记头</b>(裸 curl、脚本、误打到治理面的机器调用)→ <b>HTTP 401</b> +
* 治理面信封 {@code {code:401, msg, data:null}}。不返回 302 HTML:那会让调用方
* 把「跳登录页」当成拿到了数据。</li>
* </ul>
* <b>为什么这里是信封而不是裸码</b>:{@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());
}
}
@@ -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 登出地址)。
* <p>
* <b>为什么要有这个接口</b>:Notice 前端的 {@code ssoHost} 来自 {@code /api/env-config}
* 运行时配置;网关后端没有 env-config 端点,前端 {@code conf.ssoHost} 恒为空,
* 于是 Notice 同款的 {@code <ssoHost>/logout?service=...} 无处可取。把 CAS 基址由<b>已登录的
* 会话自己</b>吐出来,既不必把环境值烧进前端产物(§0 规则 8),也不新增任何匿名面。
* <p>
* 本路径在 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<String, Object> session(HttpServletRequest request) {
Map<String, Object> 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();
}
}
+28
View File
@@ -1,6 +1,15 @@
server: server:
# 网关端口(注册档案 port=10013;10010 已被 dormnotice 占用。实际端口由平台配置层 env 注入,本值仅本地兜底) # 网关端口(注册档案 port=10013;10010 已被 dormnotice 占用。实际端口由平台配置层 env 注入,本值仅本地兜底)
port: 10013 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: spring:
application: application:
@@ -69,3 +78,22 @@ yh:
# anon-per-ip-qps: 10 # anon-per-ip-qps: 10
# 注册表变更广播(多实例同步治理页改动):关掉退化为单实例语义,判定不受影响 # 注册表变更广播(多实例同步治理页改动):关掉退化为单实例语义,判定不受影响
# redis-broadcast-enabled: true # 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"
@@ -9,6 +9,7 @@ import com.yunhai.platform.gateway.repository.OpenApiAppRepository;
import com.yunhai.platform.gateway.repository.OpenApiLogRepository; import com.yunhai.platform.gateway.repository.OpenApiLogRepository;
import com.yunhai.platform.gateway.repository.OpenApiRouteRepository; import com.yunhai.platform.gateway.repository.OpenApiRouteRepository;
import com.yunhai.platform.gateway.repository.OpenApiTargetRepository; import com.yunhai.platform.gateway.repository.OpenApiTargetRepository;
import com.yunhai.platform.gateway.route.RegistryReloader;
import com.yunhai.platform.gateway.route.RouteRegistry; import com.yunhai.platform.gateway.route.RouteRegistry;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -67,6 +68,8 @@ class InternalAdminControllerTest {
InternalAdminController controller = new InternalAdminController(); InternalAdminController controller = new InternalAdminController();
controller.setRepos(routeRepo, targetRepo, logRepo, registry, appRepo, appRegistry); controller.setRepos(routeRepo, targetRepo, logRepo, registry, appRepo, appRegistry);
// 浏览器面的「重载注册表」入口与机器面共用 RegistryReloader(同一实现的两个口)
controller.setReloader(new RegistryReloader(registry, appRegistry));
mvc = MockMvcBuilders.standaloneSetup(controller).build(); mvc = MockMvcBuilders.standaloneSetup(controller).build();
when(routeRepo.findAll(any(Pageable.class))) when(routeRepo.findAll(any(Pageable.class)))
@@ -192,6 +195,26 @@ class InternalAdminControllerTest {
verify(registry).reloadTargets(); 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 @Test
void targetUpdate_blankBaseUrl_bizErrorNoSave() throws Exception { void targetUpdate_blankBaseUrl_bizErrorNoSave() throws Exception {
when(targetRepo.findById(5L)).thenReturn(java.util.Optional.of(target(5L))); when(targetRepo.findById(5L)).thenReturn(java.util.Optional.of(target(5L)));
@@ -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;
/**
* 机器面闸门三态(有效 / 无效 / 缺失)+ 灰度窗口态 + 默认拒绝的覆盖面。
* <p>
* 唯一实测调用方是 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<String> 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("<html");
}
@Test
void 缺失令牌_401json() throws Exception {
MockHttpServletResponse res = new MockHttpServletResponse();
new InternalTokenFilter(properties(TOKEN)).doFilter(post(REFRESH, null), res, chain);
assertThat(passed).isEmpty();
assertThat(res.getStatus()).isEqualTo(401);
}
@Test
void 浏览器面放过_控制台归CAS门管() throws Exception {
// 不带任何令牌也必须放行到下一环(CAS 门),否则登录好的控制台会被本门拦死
MockHttpServletRequest req = post("/api/internal/admin/routes", null);
MockHttpServletResponse res = new MockHttpServletResponse();
new InternalTokenFilter(properties(TOKEN)).doFilter(req, res, chain);
assertThat(passed).containsExactly("/api/internal/admin/routes");
}
@Test
void 浏览器面前缀本身也放过() throws Exception {
MockHttpServletRequest req = post(InternalTokenFilter.BROWSER_SURFACE_PREFIX, null);
new InternalTokenFilter(properties(TOKEN)).doFilter(req, new MockHttpServletResponse(), chain);
assertThat(passed).containsExactly(InternalTokenFilter.BROWSER_SURFACE_PREFIX);
}
@Test
void 新加的内部端点默认要令牌_不靠人想起来保护() throws Exception {
// 默认拒绝:落在 /api/internal/* 又不在浏览器面前缀下的任何路径都要令牌
MockHttpServletResponse res = new MockHttpServletResponse();
new InternalTokenFilter(properties(TOKEN)).doFilter(post("/api/internal/some-future-hook", null), res, chain);
assertThat(passed).isEmpty();
assertThat(res.getStatus()).isEqualTo(401);
}
@Test
void 未配令牌是灰度窗口态_放行且只出声一次() throws Exception {
InternalTokenFilter filter = new InternalTokenFilter(properties(""));
MockHttpServletResponse first = new MockHttpServletResponse();
MockHttpServletResponse second = new MockHttpServletResponse();
filter.doFilter(post(REFRESH, null), first, chain);
filter.doFilter(post(REFRESH, "任何值"), second, chain);
// 配好 env 即自动收口(下一个用例的 properties(TOKEN) 就是收口后的形态)
assertThat(passed).containsExactly(REFRESH, REFRESH);
assertThat(first.getStatus()).isEqualTo(200);
assertThat(second.getStatus()).isEqualTo(200);
}
@Test
void 令牌值空白等同未配置() throws Exception {
MockHttpServletResponse res = new MockHttpServletResponse();
new InternalTokenFilter(properties(" ")).doFilter(post(REFRESH, null), res, chain);
assertThat(passed).containsExactly(REFRESH);
}
@Test
void 圈定前缀是api的子集() {
// 红线 1:闸门只准圈 /api/*;改成 /** 会拦到 /oapi 转发链
assertThat(InternalTokenFilter.URL_PATTERN).startsWith("/api/");
assertThat(InternalTokenFilter.URL_PATTERN).isEqualTo("/api/internal/*");
}
}
@@ -0,0 +1,60 @@
package com.yunhai.platform.gateway.route;
import com.yunhai.platform.gateway.auth.AppAuthRegistry;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 注册表重载的唯一实现(机器面 refresh 与浏览器面 reload 共用):三张快照各自重 load、
* 返回体三个键、并按需广播。
* <p>
* 两个触发口共用本组件是<b>刻意的</b>:过去浏览器按钮直接打机器面路径,令牌收口后会被 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<String, Object> 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();
}
}
@@ -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}。
* <p>
* 「401 必须是 json、不许是 302 HTML」是本次验收的核心判据:裸 curl 从 200 读得到路由表,
* 变成 401 才算堵上;而 302 HTML 会让调用方以为拿到了页面。
*/
class BrowserAuthGateFilterTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private final List<String> 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("<html");
JsonNode node = MAPPER.readTree(res.getContentAsString());
assertThat(node.get("code").asInt()).isEqualTo(401);
assertThat(node.get("data").isNull()).isTrue();
assertThat(passed).isEmpty();
}
@Test
void 标头值不对也算裸调用() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/internal/admin/routes");
req.addHeader("yschapirequest", "0");
MockHttpServletResponse res = new MockHttpServletResponse();
new BrowserAuthGateFilter(liveAddressing()).doFilter(req, res, chain);
assertThat(res.getStatus()).isEqualTo(401);
}
@Test
void 会话里有断言_放行到业务() throws Exception {
MockHttpServletRequest req = request("/api/internal/admin/routes", true);
// 真实链路里断言由 cas-client 校验成功后写入 session(useSession 默认 true)
Assertion assertion = Mockito.mock(Assertion.class);
req.getSession(true).setAttribute(AbstractCasFilter.CONST_CAS_ASSERTION, assertion);
MockHttpServletResponse res = new MockHttpServletResponse();
new BrowserAuthGateFilter(liveAddressing()).doFilter(req, res, chain);
assertThat(passed).containsExactly("/api/internal/admin/routes");
assertThat(res.getContentAsString()).isEmpty();
assertThat(res.getStatus()).isEqualTo(200);
}
@Test
void 同请求attribute断言也认_校验过滤器成功路径双写() throws Exception {
MockHttpServletRequest req = request("/api/internal/admin/routes", true);
req.setAttribute(AbstractCasFilter.CONST_CAS_ASSERTION, Mockito.mock(Assertion.class));
MockHttpServletResponse res = new MockHttpServletResponse();
new BrowserAuthGateFilter(liveAddressing()).doFilter(req, res, chain);
assertThat(passed).containsExactly("/api/internal/admin/routes");
}
@Test
void 无会话时不去新建会话() throws Exception {
// 门本身不建会话:否则每个裸 curl 都会在网关堆一个空会话(DoS 面)
MockHttpServletRequest req = request("/api/internal/admin/routes", false);
new BrowserAuthGateFilter(liveAddressing()).doFilter(req, new MockHttpServletResponse(), chain);
assertThat(req.getSession(false)).isNull();
}
}
@@ -0,0 +1,59 @@
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.springframework.mock.env.MockEnvironment;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 票据校验失败出口(stale/重放 ticket):必须仍是双形态未登录应答,而不是 cas-client 默认的
* 空 200({@code onFailedValidation} 空实现 + 链断)或 500 HTML({@code exceptionOnValidationFailure}
* 默认 true 抛 ServletException)。
* <p>
* 只测本类拥有的那一个覆写点——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("<html");
assertThat(res.getContentAsString()).contains("\"code\":401");
}
}
@@ -0,0 +1,86 @@
package com.yunhai.platform.gateway.sso;
import org.junit.jupiter.api.Test;
import org.springframework.mock.env.MockEnvironment;
import static org.assertj.core.api.Assertions.assertThat;
/**
* CAS 地址推导(本次接入的命门):service / 登录地址必须带上 Caddy 剥掉、容器侧不存在的外链前缀。
* <p>
* 取值用 <b>160 容器里的实测值</b>({@code docker exec open-gateway-service env} 读到的
* {@code YH_SSO_SERVER_URL_PREFIX} / {@code YH_SSO_CLIENT_HOST_URL}),不是随手写的样例——
* 平台注入形态(带不带尾斜杠、带不带前缀)本身就是被测对象。
* <p>
* 反例锚点:基础平台 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();
}
}
@@ -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)。
* <p>
* 直接读 {@code application.yml} 而不是断言运行期行为:这几条的失效方式正是「配错了自己不知道」——
* 加了 ctx 会让对外路径整体错位,cookie 不改名会让治理面会话与 base 域其他应用同名叠放,
* 开关被烧成 {@code true} 会让本地跑测与未上线环境直接点上鉴权。
*/
class SsoConfigBaselineTest {
private static PropertySource<?> jarYml() throws Exception {
List<PropertySource<?>> 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);
}
}
@@ -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 的装配。
* <p>
* 关键一条:<b>默认(不带 OPENGW_SSO_ENABLED)时两个注册都是 disabled</b>——这保证
* 「代码已合入但没点开关」的网关与今天逐字节相同,也让回滚只需清 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<String, String> 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<GatewayCasValidationFilter> validation =
configuration.casTicketValidationFilter(addressing, properties);
FilterRegistrationBean<BrowserAuthGateFilter> 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");
}
}
@@ -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 映射必须落进<b>已声明鉴权归属</b>的面之一,
* 且 CAS 面圈定路径必须是 {@code /api/*} 的子集、绝不能圈到 {@code /oapi}、静态面、探活与文档。
* <p>
* 为什么扫全仓控制器而不是逐个点名:CAS 的 url-pattern 被刻意收窄到
* {@code /api/internal/admin/*}(为把机器面 {@code routes/refresh} 留在面外),
* 于是「新加一个 {@code /api/xxx} 控制器」不再天然受保护。本测试把这件事从人工 review
* 变成测试期失败——新映射要么落在治理面前缀内,要么必须先在这里登记自己的鉴权面。
*/
class SsoSurfaceGuardTest {
/** 机器面:不进 CAS 面,由内部令牌门(Stage 2)负责 */
private static final Set<String> 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/*} 是<b>前缀</b>映射,匹配任意层数;
* 与 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<String> 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<String> out = new LinkedHashSet<>();
if (picked.length == 0) {
out.add("");
} else {
Collections.addAll(out, picked);
}
return out;
}
/** 全仓 @RestController 的「类前缀 + 方法路径」全集(无映射注解的方法不参与) */
private Set<String> allControllerPaths() throws Exception {
ClassPathScanningCandidateComponentProvider scanner =
new ClassPathScanningCandidateComponentProvider(false);
scanner.addIncludeFilter(new AnnotationTypeFilter(RestController.class));
Set<String> paths = new LinkedHashSet<>();
for (BeanDefinition candidate : scanner.findCandidateComponents("com.yunhai.platform.gateway")) {
Class<?> type = Class.forName(candidate.getBeanClassName());
Set<String> 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<String> methodPaths = valuesOf(mapping);
for (String base : bases) {
for (String methodPath : methodPaths) {
paths.add(join(base, methodPath));
}
}
}
}
}
return paths;
}
@Test
void 全仓控制器映射都已声明鉴权归属() throws Exception {
Set<String> 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<String> paths = allControllerPaths();
assertThat(paths).contains(properties.getEntryPath());
assertThat(paths).contains("/api/internal/admin/session");
}
}