Compare commits

...
14 Commits
Author SHA1 Message Date
ops 487a2e11d7 fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:24:28 +00:00
ops 235a44ff88 ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:36:59 +00:00
chenxing 5804ed3118 fix(sso): 外观端点两腿按平台统一信封 {code,msg,data} 解析
build / build-push (push) Successful in 56s
裸 HttpClient 不走 YschOpenCall 自动拆信封:userbase 顶层取 id 恒失败=登录态
恒 503(160 实录 2026-10-12);userhome 信封原样透传会被 init 当异常形态跳过
收敛。unwrap 镜像 OpenApiEnvelope 语义(code≠0→503 / code==0 取 data / 无 code
视为已裸),data 裸 payload 透传。单测 stub 从裸 JSON 对齐真实信封形态并补
4 例信封语义用例(6→10,全量 225/225 绿)。
2026-10-12 09:34:02 +08:00
chenxing a2c8905c7a 对接规范§3:外观偏好同源读端点 /api/appearance(自建同构哑管道,503铁律,回环验签链 userbase→userhome,env OPENGW_APPEARANCE_APP_KEY 点燃)
build / build-push (push) Successful in 2m11s
2026-10-11 21:58:54 +08:00
chenxing 7e3690e523 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 原样采用)+ 双形态应答 + 令牌三态/灰度窗 + 重载器
2026-10-11 12:46:55 +08:00
chenxing 1ae94d7100 feat(gateway): 应用级免签(ANON) + 签名限流/per-route 超时 + 代理测试页
build / build-push (push) Successful in 1m47s
- open_api_app 表(docs/sql/04_app.sql) + OpenApiApp/OpenApiAppRepository/AppAuthRegistry:
  authMode=ANON 应用级免签,支持 '*' 路径通配窗口,有效期 ≤31 天惰性到期;
  PUT 带 authMode=ANON 时 expireAt 必传
- SignedRateLimiter + RateLimitFilter:按签名键限流(fail-closed);
  RouteSpec 增加 per-route 超时与请求体积上限,StreamingForwarder 执行超时
- ProxyTestController:网关代理测试页后端接口
- SignVerifyFilter/DispatchFilter/InternalAdminController 及对应测试接线
- .gitignore 补 gateway-run.log / tmp_*.sql(本地运行产物不入库)

验证:全量 171 用例绿 + 双轮冒烟(2026-10-03)
2026-10-09 10:37:42 +08:00
chenxing 48625d0f11 fix(gateway): 修复 RouteChangeBroadcaster 的 Redis 注入歧义(线上启动崩溃)
build / build-push (push) Successful in 1m1s
线上 f456c857 启动失败、容器无限重启,根因为 StringRedisTemplate 注入歧义:

- Field redisTemplate in RouteChangeBroadcaster required a single bean, but 2 were found:
  stringRedisTemplate (RedisAutoConfiguration) 与
  signKeyStringRedisTemplate (yh-appauth SignKeyRedisAutoConfiguration)

修复:

- 显式 @Qualifier("stringRedisTemplate") 绑定应用主库模板。必须选主库:网关
  SPRING_REDIS_DATABASE=7,而 appauth 密钥模板直连 db0——选错会造成发布/订阅异库、
  广播静默失效,比启动失败更难排查。
- 订阅容器改用 redisTemplate.getConnectionFactory(),结构上保证发布与订阅同库,
  同时消除 RedisConnectionFactory 同类型多 bean 的隐患。

回归测试 RouteChangeBroadcasterWiringTest(3 例):

- 手工驱动 AutowiredAnnotationBeanPostProcessor 构造「stringRedisTemplate 与
  signKeyStringRedisTemplate 共存」的最小上下文(复刻 appauth 在场的现场)
- 断言装配成功;断言绑定的是主库模板而非密钥专用模板
- 反向断言:裸 @Autowired 在双 bean 下必然抛 UnsatisfiedDependencyException
  —— 钉死本次事故的成因,防止回退

既有测试为何漏掉:application-test.yml 的 redis-broadcast-enabled=false 把该 bean
整个关掉,装配分支从未被覆盖。

mvn -o test: 104/104 通过(新增 3 例)。
2026-09-24 10:57:42 +08:00
chenxing f456c85702 feat(gateway): 入口分流 + ANON 豁免接线,退役 admin.html 治理小页
build / build-push (push) Successful in 1m10s
判定接线(SignVerifyFilter 拆三段,内部入口逻辑逐字节不变):
- 内部入口(容器网直连,无标记)一律验签,不看 visibility/authMode
- 外部入口(来源可信 + X-Entry: external)→ INTERNAL 裸 403;EXTERNAL×SIGNED 验签;
  EXTERNAL×ANON 限流内免签放行(verdict=ANON / caller=anonymous / 不注入身份头),
  超限 429、限流未就绪 403(fail-closed)
- 外部入口的 403/429 属策略层拒绝,与 observe 正交

新增:
- auth/EntryDetector:可信来源 CIDR + X-Entry 双条件判定,仅认 IPv4,非法一律不可信
- auth/AnonRateLimiter:ALLOW/EXCEEDED/UNAVAILABLE 契约
- auth/UnavailableAnonRateLimiter:T3.1 前的占位实现,恒 fail-closed
- route/RouteChangeBroadcaster:频道 gw:route:version 变更广播(判定仍读本地 Caffeine,
  Redis 只做变更通知,Redis 异常回落本地快照,不存在“挂了就放行”)

其他:
- GatewayProperties 新增 trusted-proxies / anon-per-ip-qps
- StreamingForwarder 把 x-entry 加入剥除集(伪造值也到不了提供方)
- InternalAdminController 白名单 {SIGNED,ANON}、删 page()、写后广播;RouteRefreshController 广播
- 硬删 static/admin.html(治理面已前端化,重载注册表由 front 头部按钮承接)
- 测试:EntryDetectorTest、SignVerifyFilterEntryTest(入口×策略矩阵)等,101/101 全绿
2026-09-24 10:30:14 +08:00
chenxing a19ded48ef feat(ctx): SanitizeFilter 剥 context-path,业务路径空间与 ctx 解耦(ctx 为空零行为变化)
build / build-push (push) Successful in 47s
2026-09-23 16:50:34 +08:00
chenxing 2389d2fa9a feat(admin): 管理面信封化+分页化,OpenAPI 文档端点旁路(B1-B6)
build / build-push (push) Successful in 1m59s
- pom: 引 yh-platform-dependencies(import) / yh-api-std / springdoc-openapi-ui 1.7.0 + nexus-public 仓库
- RoutingFilter.isGatewayInternal: /v3/api-docs、/swagger-ui/**、/swagger-ui.html 纳入自身路径旁路,避免被 TargetRouter 当 target 裸 404
- InternalAdminController: page/routes/targets/logs 三接口分页化返回 BasePageResult;补 @Operation(operationId=GatewayAdmin.*);错误契约去 @ResponseStatus(400) 改 200+code=2;logs 兼容旧 limit 入参
- RouteRefreshController: 补 @Tag/@Operation(operationId=GatewayAdmin.refresh)
- static/admin.html: 适配 {code,msg,data} 信封与 content/total 分页
- InternalAdminControllerTest: 重写 12 用例,77 项测试全绿
2026-09-23 12:17:02 +08:00
chenxing 6e64acc0c7 fix(config): 本地兜底端口 10010→10013,更正陈旧注释(B3,v1.2 §8-L5 收编)
build / build-push (push) Successful in 46s
2026-09-23 11:42:42 +08:00
chenxing 3887892d4d feat(ops): 对齐平台日志策略——新增 logback-spring.xml(app.log 按天滚动/30天/5GB,root INFO)
build / build-push (push) Successful in 46s
此前无自定义 logback,走 Spring Boot 默认文件日志(spring.log 10MB 大小滚动、
仅留 7 天);现与 Notice/UserBase 一致:/app/logs/app.log 按天滚动。
日志级别由平台配置层管理(application-platform.yml root=INFO);私有配置中的
logging.level.root=DEBUG/show-sql 调试遗留已同步清除。
2026-09-23 09:46:53 +08:00
chenxing ce4c3cc5a6 feat(admin): 治理小页完整前端——路由/目标行内编辑+台账查询
build / build-push (push) Successful in 51s
单文件 vanilla JS 零外部依赖:路由表六旋钮下拉/数字输入+行保存(值域即下拉白名单),
目标表 base_url+启停,台账三筛选+status 着色,顶部注册表热重载;错误 toast 不打断。
2026-09-23 00:13:43 +08:00
chenxing 06b0f4ed23 feat(admin): 网关治理小页后端——路由/目标/台账查询与行内编辑
/api/internal/admin/**(RoutingFilter 旁路前缀,零过滤器改动):
- GET page(routes/targets/logs) + PUT routes/{id} / targets/{id}
- 值域白名单(visibility/authMode/cacheScope)非法 400 不落库;写后热重载注册表
- OpenApiLogRepository.pageLatest 两可空过滤+倒序分页
- 10 用例(page/列表/合法编辑/三态 400/limit 截断),全量 75/75 绿
2026-09-23 00:12:17 +08:00
67 changed files with 5928 additions and 69 deletions
+18 -9
View File
@@ -25,7 +25,10 @@ jobs:
options: --network infra-infra-net
steps:
- name: 安装 git / curl(alpine)
run: apk add --no-cache git curl
run: |
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
apk add --no-cache git curl
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
@@ -55,18 +58,24 @@ jobs:
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
run: |
set -e
docker rm -f mavenbuild 2>/dev/null || true
docker run -d --name mavenbuild \
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
docker run -d --name "$BUILD_CONTAINER" \
--network infra-infra-net \
-v /opt/docker/projman-m2:/root/.m2 \
-w /build maven:3.6.3-jdk-8 sleep 3600
docker cp "${PWD}/." mavenbuild:/build/
docker exec mavenbuild sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
docker exec mavenbuild sh -c 'cd /build && mvn -B -DskipTests clean package'
docker exec mavenbuild sh -c 'cd /build/target && cp *.jar app.jar'
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
docker exec "$BUILD_CONTAINER" sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
docker exec "$BUILD_CONTAINER" sh -c 'cd /build && mvn -B -DskipTests clean package'
docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
mkdir -p "${PWD}/target"
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar"
docker rm -f mavenbuild
docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
docker rm -f "$BUILD_CONTAINER"
- name: 构建并推送镜像
run: |
+2
View File
@@ -2,3 +2,5 @@ target/
.idea/
*.iml
logs/
gateway-run.log
tmp_*.sql
+29
View File
@@ -0,0 +1,29 @@
-- =====================================================================
-- 开放接口中心 应用治理表 open_api_app(应用级免签 C 方向,G1)
-- 语义:按调用方应用(appKey)维度登记治理属性——只管"免签与否",不管密钥;
-- 密钥链(appauth starter Redis sign:app:{appKey})职责不变,SIGNED 应用照旧从那里取 secret,
-- ANON 应用不要求密钥存在(免签应用可无密钥)。
-- 判定:表空 / 无行 / 禁用 / 过期 = 一律维持验签(fail-closed 缺省,存量 appKey 零行为变化);
-- auth_mode=ANON 的行仅内部入口免签(免 X-Timestamp/X-Signature 两头,仍须带 X-App-Key),
-- 到期判定是惰性的(AppAuthRegistry.admit 实时比对 expire_at,零定时任务),
-- 过期行即刻不再命中(等价恢复验签),行数据不清除,想再用走治理页续期(重新起算 ≤31 天)。
-- 哨兵:app_key='*' = 匿名窗口行(uk 保证全局至多一行)——内部入口无 X-App-Key 的请求也放行,
-- 强制生效周期 ≤31 天(O1 决议);该行只折算成"窗口到期时刻",不进 appKey 映射。
-- 操作注意:ANON 应用改回 SIGNED 前,须先确认密钥链有该 appKey 的键,否则即刻 401。
-- 写入方:治理面 API(/api/internal/admin/apps,保存后就地 reload + 广播)或手工 SQL;
-- 网关运行时只读,改行后热重载即生效(不重启)。
-- 可重复执行:CREATE TABLE IF NOT EXISTS(不 DROP——治理数据不可丢)
-- =====================================================================
CREATE TABLE IF NOT EXISTS open_api_app (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
app_key VARCHAR(64) NOT NULL COMMENT '调用方 appKey(与密钥链 sign:app:{appKey} 同键;ANON 应用无须密钥;*=匿名窗口行(内部入口无票据放行,至多一行)',
app_name VARCHAR(64) COMMENT '展示名(可选)',
auth_mode VARCHAR(16) NOT NULL DEFAULT 'SIGNED' COMMENT 'SIGNED=验签(缺省)/ANON=内部入口免签',
expire_at DATETIME COMMENT '免签生效到期(ANON 行必填,保存时校验 ≤31 天;过期=惰性恢复验签;SIGNED 行为 NULL)',
enabled TINYINT NOT NULL DEFAULT 1,
remark VARCHAR(256),
gmt_create DATETIME NOT NULL,
gmt_modified DATETIME NOT NULL,
UNIQUE KEY uk_app (app_key)
);
+49 -1
View File
@@ -23,8 +23,26 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencyManagement>
<!-- 基础平台版本管理(yh-* 架包与 spring-boot-dependencies 对齐);形态同 UserBase/Notice -->
<dependencies>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-platform-dependencies</artifactId>
<version>1.0.0-SNAPSHOT</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<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>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
@@ -77,6 +95,26 @@
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 接口规范:管理面(/api/internal/**)成功响应统一包装为 {code,msg,data},与 notice/userbase
前端解析形态一致;同时传递引入 yh-utils-core(ErrorCode / BasePageQuery / BasePageResult)。
oapi 转发链不经 Controller(Filter 直写 response),不受本 starter 的 ResponseBodyAdvice 影响。 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-api-std</artifactId>
</dependency>
<!-- Swagger / OpenAPI 文档(springdoc,兼容 Boot 2.7;版本对齐 UserBase/Notice) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.7.0</version>
</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>
<build>
@@ -95,4 +133,14 @@
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>nexus-public</id>
<name>bsch/nexus public</name>
<url>http://nexus.yunhaixingtong.com/repository/maven-public/</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
</repository>
</repositories>
</project>
@@ -0,0 +1,490 @@
package com.yunhai.platform.gateway.admin;
import com.yunhai.platform.apistd.ApiResponse;
import com.yunhai.platform.gateway.auth.AppAuthRegistry;
import com.yunhai.platform.gateway.entity.OpenApiApp;
import com.yunhai.platform.gateway.entity.OpenApiLog;
import com.yunhai.platform.gateway.entity.OpenApiRoute;
import com.yunhai.platform.gateway.entity.OpenApiTarget;
import com.yunhai.platform.gateway.repository.OpenApiAppRepository;
import com.yunhai.platform.gateway.repository.OpenApiLogRepository;
import com.yunhai.platform.gateway.repository.OpenApiRouteRepository;
import com.yunhai.platform.gateway.repository.OpenApiTargetRepository;
import com.yunhai.platform.gateway.route.RegistryReloader;
import com.yunhai.platform.gateway.route.RouteChangeBroadcaster;
import com.yunhai.platform.gateway.route.RouteRegistry;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BasePageResult;
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.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
/**
* 网关治理接口(/api/internal/admin/**,D5 治理面后端):路由/目标地址/应用的查询与行内编辑、
* 台账分页查询;写操作落库后立即热重载注册表(routes→reload / targets→reloadTargets /
* apps→appAuthRegistry.reload)并向 {@link RouteChangeBroadcaster} 发一条变更广播
* (其余实例收到即各自重载),无须重启网关。
* 与 {@code RouteRefreshController} 同前缀,被 RoutingFilter 旁路(不进 oapi 链)。
* <p>
* <b>前端</b>:治理面 UI 独立在 {@code open-gateway/front}(Vue3),本类不再下发 HTML
* (v1 内置 {@code static/admin.html} 已退役)。
* <p>
* <b>响应契约</b>:成功响应由 {@code yh-api-std} 的 {@code ApiResponseAdvice} 统一包成
* {@code {code:0, msg:"", data:...}}(与 notice/userbase 一致);非法入参走 {@link #badRequest}
* 返回 HTTP 200 + {@code {code:2, msg:...}}(平台规范:业务错误不改 HTTP 状态码)。
* <p>
* <b>收网前提示</b>:本前缀当前宿主端口可达(10013 未绑 127.0.0.1),页面用于 observe 期
* 治理与排障;T0.2 端口收网随下次重新部署自然收紧(届时仅容器网内可达)。
* 值域白名单与 {@code docs/sql/01_schema.sql} 注释对齐:visibility∈{INTERNAL,EXTERNAL}、
* authMode∈{SIGNED,ANON}(DDL 另定义 OPTIONAL,本期不开放——避免"配得出但语义未实现")、
* cacheScope∈{CALLER,GLOBAL};非法值返回 code=2 且不落库。
*/
@Tag(name = "GatewayAdmin", description = "网关治理:路由旋钮 / 目标地址 / 访问台账")
@RestController
@RequestMapping("/api/internal/admin")
public class InternalAdminController {
private static final Set<String> VISIBILITY = new HashSet<>(Arrays.asList("INTERNAL", "EXTERNAL"));
private static final Set<String> AUTH_MODE = new HashSet<>(Arrays.asList("SIGNED", "ANON"));
private static final Set<String> CACHE_SCOPE = new HashSet<>(Arrays.asList("CALLER", "GLOBAL"));
/** ANON 行(含 '*' 匿名窗口行)免签生效周期硬上限(O1 决议:覆盖所有月份的"1 个月") */
static final int MAX_ANON_TTL_DAYS = 31;
/** 应用免签模式值(与 AUTH_MODE 白名单同源;本类仅应用接口消费 ANON 分支) */
private static final String APP_MODE_ANON = "ANON";
/** expireAt 入参兼容两种写法:页面用 "yyyy-MM-dd HH:mm:ss",手工调用可用 ISO */
private static final DateTimeFormatter TS_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Autowired
private OpenApiRouteRepository routeRepo;
@Autowired
private OpenApiTargetRepository targetRepo;
@Autowired
private OpenApiLogRepository logRepo;
@Autowired
private OpenApiAppRepository appRepo;
@Autowired
private RouteRegistry routeRegistry;
@Autowired
private AppAuthRegistry appAuthRegistry;
/** 变更广播(多实例同步);广播开关关闭时该 bean 不存在,故 required=false */
@Autowired(required = false)
private RouteChangeBroadcaster broadcaster;
/** 注册表全量重载(与机器面 {@code /api/internal/routes/refresh} 共用实现) */
@Autowired
private RegistryReloader reloader;
/** 测试/显式装配入口 */
void setRepos(OpenApiRouteRepository routeRepo, OpenApiTargetRepository targetRepo,
OpenApiLogRepository logRepo, RouteRegistry routeRegistry,
OpenApiAppRepository appRepo, AppAuthRegistry appAuthRegistry) {
this.routeRepo = routeRepo;
this.targetRepo = targetRepo;
this.logRepo = logRepo;
this.routeRegistry = routeRegistry;
this.appRepo = appRepo;
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 稳定排序) */
@Operation(operationId = "GatewayAdmin.routes", summary = "路由注册表分页")
@GetMapping("/routes")
public BasePageResult<OpenApiRoute> routes(
@RequestParam(value = "pageIndex", required = false, defaultValue = "1") Integer pageIndex,
@RequestParam(value = "pageSize", required = false, defaultValue = "10") Integer pageSize) {
Sort sort = Sort.by("targetCode").and(Sort.by("pathPattern"));
return toResult(routeRepo.findAll(pageRequest(pageIndex, pageSize, sort)));
}
/**
* 行内编辑路由治理旋钮。只接受治理字段(不可改 target/pattern/method——那是路由身份,
* 改动应走 SQL 迁移评审);enabled 可整体停用一行。成功后热重载并返回加载数。
*/
@Operation(operationId = "GatewayAdmin.updateRoute", summary = "编辑路由治理旋钮")
@PutMapping("/routes/{id}")
public Map<String, Object> updateRoute(@PathVariable("id") Long id,
@RequestBody Map<String, Object> body) {
OpenApiRoute route = routeRepo.findById(id).orElse(null);
if (route == null) {
throw new IllegalArgumentException("路由不存在:" + id);
}
if (body.containsKey("visibility")) {
String v = text(body.get("visibility"));
if (!VISIBILITY.contains(v)) {
throw new IllegalArgumentException("visibility 非法:" + v + "(合法 " + VISIBILITY + ")");
}
route.setVisibility(v);
}
if (body.containsKey("authMode")) {
String v = text(body.get("authMode"));
if (!AUTH_MODE.contains(v)) {
throw new IllegalArgumentException("authMode 非法:" + v + "(合法 " + AUTH_MODE + ")");
}
route.setAuthMode(v);
}
if (body.containsKey("cacheTtlSeconds")) {
int v = intVal(body.get("cacheTtlSeconds"), "cacheTtlSeconds");
if (v < 0) {
throw new IllegalArgumentException("cacheTtlSeconds 不可为负:" + v);
}
route.setCacheTtlSeconds(v);
}
if (body.containsKey("cacheScope")) {
String v = text(body.get("cacheScope"));
if (!CACHE_SCOPE.contains(v)) {
throw new IllegalArgumentException("cacheScope 非法:" + v + "(合法 " + CACHE_SCOPE + ")");
}
route.setCacheScope(v);
}
if (body.containsKey("rateLimitQps")) {
int v = intVal(body.get("rateLimitQps"), "rateLimitQps");
if (v < 0) {
throw new IllegalArgumentException("rateLimitQps 不可为负:" + v);
}
route.setRateLimitQps(v);
}
if (body.containsKey("timeoutMs")) {
int v = intVal(body.get("timeoutMs"), "timeoutMs");
if (v < 1000 || v > 120000) {
throw new IllegalArgumentException("timeoutMs 须在 [1000,120000] 毫秒:" + v);
}
route.setTimeoutMs(v);
}
if (body.containsKey("requestBodyLimitMb")) {
int v = intVal(body.get("requestBodyLimitMb"), "requestBodyLimitMb");
if (v < 1 || v > 100) {
throw new IllegalArgumentException("requestBodyLimitMb 须在 [1,100] MB:" + v);
}
route.setRequestBodyLimitMb(v);
}
if (body.containsKey("enabled")) {
route.setEnabled(Boolean.TRUE.equals(body.get("enabled")));
}
if (body.containsKey("remark")) {
route.setRemark(text(body.get("remark")));
}
route.setGmtModified(LocalDateTime.now());
routeRepo.save(route);
Map<String, Object> out = new LinkedHashMap<>();
out.put("loaded", routeRegistry.reload());
broadcast();
return out;
}
/** 目标地址表分页 */
@Operation(operationId = "GatewayAdmin.targets", summary = "目标地址分页")
@GetMapping("/targets")
public BasePageResult<OpenApiTarget> targets(
@RequestParam(value = "pageIndex", required = false, defaultValue = "1") Integer pageIndex,
@RequestParam(value = "pageSize", required = false, defaultValue = "10") Integer pageSize) {
return toResult(targetRepo.findAll(pageRequest(pageIndex, pageSize, Sort.by("targetCode"))));
}
/** 行内编辑目标地址/启停;成功后热重载 target 快照(禁用/空白行按无行回落 yml 由 TargetRouter 承担) */
@Operation(operationId = "GatewayAdmin.updateTarget", summary = "编辑目标地址/启停")
@PutMapping("/targets/{id}")
public Map<String, Object> updateTarget(@PathVariable("id") Long id,
@RequestBody Map<String, Object> body) {
OpenApiTarget target = targetRepo.findById(id).orElse(null);
if (target == null) {
throw new IllegalArgumentException("目标不存在:" + id);
}
if (body.containsKey("baseUrl")) {
String v = text(body.get("baseUrl"));
if (v == null || v.trim().isEmpty()) {
throw new IllegalArgumentException("baseUrl 不可为空");
}
target.setBaseUrl(v.trim());
}
if (body.containsKey("enabled")) {
target.setEnabled(Boolean.TRUE.equals(body.get("enabled")));
}
target.setGmtModified(LocalDateTime.now());
targetRepo.save(target);
Map<String, Object> out = new LinkedHashMap<>();
out.put("targets", routeRegistry.reloadTargets());
broadcast();
return out;
}
/** 应用治理表分页(治理对象全量含禁用/过期行;appKey 稳定排序,'*' 窗口行按字典序自然置顶) */
@Operation(operationId = "GatewayAdmin.apps", summary = "应用治理表分页")
@GetMapping("/apps")
public BasePageResult<OpenApiApp> apps(
@RequestParam(value = "pageIndex", required = false, defaultValue = "1") Integer pageIndex,
@RequestParam(value = "pageSize", required = false, defaultValue = "10") Integer pageSize) {
return toResult(appRepo.findAll(pageRequest(pageIndex, pageSize, Sort.by("appKey"))));
}
/**
* 新增应用行(O3 决议:治理页闭环,不留"只有 UI 没有后端"断层)。
* 校验:appKey 非空 ≤64 且唯一(预查友好报错,uk 兜底收敛为 code=2);authMode 值域;
* authMode=ANON 时 expireAt 必填、&gt; now、≤ now+31 天(O1 时间锁,SIGNED 行不设到期)。
* 成功后就地 reload + 广播。
*/
@Operation(operationId = "GatewayAdmin.createApp", summary = "新增应用")
@PostMapping("/apps")
public Map<String, Object> createApp(@RequestBody Map<String, Object> body) {
String appKey = text(body.get("appKey"));
if (appKey == null || appKey.isEmpty()) {
throw new IllegalArgumentException("appKey 不可为空");
}
if (appKey.length() > 64) {
throw new IllegalArgumentException("appKey 长度不可超过 64:" + appKey.length());
}
if (appRepo.existsByAppKey(appKey)) {
throw new IllegalArgumentException(appKey.equals(AppAuthRegistry.WINDOW_APP_KEY)
? "匿名窗口行已存在(appKey='*' 全局至多一行),如需调整请编辑现有行"
: "appKey 已存在:" + appKey + "(如需重建请先禁用旧行)");
}
String authMode = requireAuthMode(body.get("authMode"));
OpenApiApp app = new OpenApiApp();
app.setAppKey(appKey);
app.setAppName(text(body.get("appName")));
app.setAuthMode(authMode);
app.setRemark(text(body.get("remark")));
if (APP_MODE_ANON.equals(authMode)) {
app.setExpireAt(requireAnonExpire(body.get("expireAt"))); // ANON 必配到期(含 '*' 窗口行)
}
LocalDateTime now = LocalDateTime.now();
app.setGmtCreate(now);
app.setGmtModified(now);
try {
appRepo.save(app);
} catch (DataIntegrityViolationException e) {
// 并发窗口下预查失效由 uk 兜底,同样收敛为业务错误而非 500
throw new IllegalArgumentException("appKey 已存在:" + appKey + "(如需重建请先禁用旧行)");
}
return savedAppResult();
}
/**
* 行内编辑应用。只接受 {@code appName/authMode/enabled/remark/expireAt};
* <b>appKey 不可改</b>(行身份,同路由"不可改 target/pattern"惯例,要换=禁用旧行+新增新行);
* 切 ANON(或对 ANON 行续期)时 expireAt 必填并同 POST 校验——每次保存重新起算 ≤31 天;
* 切回 SIGNED 时 expireAt 置 NULL(注意:须先确认密钥链有该 appKey,否则即刻 401)。
*/
@Operation(operationId = "GatewayAdmin.updateApp", summary = "编辑应用/启停/续期")
@PutMapping("/apps/{id}")
public Map<String, Object> updateApp(@PathVariable("id") Long id,
@RequestBody Map<String, Object> body) {
if (body.containsKey("appKey")) {
throw new IllegalArgumentException("appKey 不可改(行身份):如需更换请禁用本行后新增");
}
OpenApiApp app = appRepo.findById(id).orElse(null);
if (app == null) {
throw new IllegalArgumentException("应用不存在:" + id);
}
boolean modeTouched = body.containsKey("authMode");
if (modeTouched) {
app.setAuthMode(requireAuthMode(body.get("authMode")));
}
if (body.containsKey("appName")) {
app.setAppName(text(body.get("appName")));
}
if (body.containsKey("remark")) {
app.setRemark(text(body.get("remark")));
}
if (body.containsKey("enabled")) {
app.setEnabled(Boolean.TRUE.equals(body.get("enabled")));
}
if (APP_MODE_ANON.equals(app.getAuthMode())) {
if (body.containsKey("expireAt")) {
app.setExpireAt(requireAnonExpire(body.get("expireAt"))); // 续期:每次保存重新起算
} else if (modeTouched) {
// 切(或重申)ANON 必须同时给 expireAt——未传按越界拒绝,防止"开关一拨白送 31 天"
throw new IllegalArgumentException("authMode=ANON 时 expireAt 必填(免签最长 " + MAX_ANON_TTL_DAYS
+ " 天,到期惰性恢复验签)");
} // else:既有 ANON 行普通编辑(备注/启停),保留原 expireAt
} else {
app.setExpireAt(null); // SIGNED 行不设到期
}
app.setGmtModified(LocalDateTime.now());
try {
appRepo.save(app);
} catch (DataIntegrityViolationException e) {
throw new IllegalArgumentException("保存冲突:应用行已被并发修改,请刷新后重试");
}
return savedAppResult();
}
private String requireAuthMode(Object v) {
String mode = text(v);
if (!AUTH_MODE.contains(mode)) {
throw new IllegalArgumentException("authMode 非法:" + mode + "(合法 " + AUTH_MODE + ")");
}
return mode;
}
/**
* ANON 行的 expireAt(O1 时间锁):必填、可解析、&gt; now、≤ now+31 天——
* 惰性到期靠它实时比对,续期 = 每次保存重新起算。
*/
private static LocalDateTime requireAnonExpire(Object v) {
LocalDateTime expireAt = parseDateTime(v);
if (expireAt == null) {
throw new IllegalArgumentException("authMode=ANON 时 expireAt 必填(免签最长 " + MAX_ANON_TTL_DAYS
+ " 天,到期惰性恢复验签)");
}
LocalDateTime now = LocalDateTime.now();
if (!expireAt.isAfter(now)) {
throw new IllegalArgumentException("expireAt 必须晚于当前时刻:" + TS_FORMATTER.format(expireAt));
}
if (expireAt.isAfter(now.plusDays(MAX_ANON_TTL_DAYS))) {
throw new IllegalArgumentException("expireAt 不可晚于 " + MAX_ANON_TTL_DAYS + " 天(免签长期化=后门,"
+ "续期请到期前再来保存,每次保存重新起算)");
}
return expireAt;
}
/** 入参兼容 "yyyy-MM-dd HH:mm:ss" 与 ISO;null/空白返回 null(由调用方判必填) */
private static LocalDateTime parseDateTime(Object v) {
if (v == null) {
return null;
}
String s = String.valueOf(v).trim();
if (s.isEmpty()) {
return null;
}
try {
return s.contains("T") ? LocalDateTime.parse(s) : LocalDateTime.parse(s, TS_FORMATTER);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("expireAt 格式非法:" + s + "(应为 yyyy-MM-dd HH:mm:ss)");
}
}
/** 应用写操作收尾:就地 reload + 广播(其余实例靠广播同步),返回快照装载行数 */
private Map<String, Object> savedAppResult() {
Map<String, Object> out = new LinkedHashMap<>();
out.put("apps", appAuthRegistry.reload());
broadcast();
return out;
}
/** 变更广播:本实例已就地重载,其余实例靠广播同步(发布失败只记 WARN,不影响本次写操作) */
private void broadcast() {
if (broadcaster != null) {
broadcaster.publish();
}
}
/**
* 台账倒序分页查询。{@code pageSize} 优先、上限 500;兼容旧 {@code limit} 参数
* (未传 pageSize 时以 limit 作 pageSize)。targetCode/callerAppKey 可空=不过滤。
*/
@Operation(operationId = "GatewayAdmin.logs", summary = "访问台账分页")
@GetMapping("/logs")
public BasePageResult<OpenApiLog> logs(
@RequestParam(value = "pageIndex", required = false, defaultValue = "1") Integer pageIndex,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
@RequestParam(value = "limit", required = false) Integer limit,
@RequestParam(value = "targetCode", required = false) String targetCode,
@RequestParam(value = "callerAppKey", required = false) String callerAppKey) {
int size = resolvePageSize(pageSize, limit);
int page = normalizePageIndex(pageIndex);
Page<OpenApiLog> result = logRepo.pageLatest(emptyToNull(targetCode), emptyToNull(callerAppKey),
PageRequest.of(page - 1, size));
return toResult(result);
}
/**
* 值域/参数错误 → HTTP 200 + {@code {code:2, msg}}(平台规范:业务错误不改 HTTP 状态码,
* 与 notice/userbase 的 GlobalExceptionHandler 行为一致)。本类抛出的 IllegalArgumentException
* 全是"调用方传值非法",缺省会被 Spring 映射成 500,误导排障方向——故在类内显式收敛。
*/
@ExceptionHandler(IllegalArgumentException.class)
public ApiResponse<Object> badRequest(IllegalArgumentException e) {
return ApiResponse.fail(ErrorCode.PARAM_ERROR, e.getMessage());
}
private static <T> BasePageResult<T> toResult(Page<T> page) {
return new BasePageResult<T>(
page.getContent(),
(int) page.getTotalElements(),
page.getNumber() + 1,
page.getSize(),
page.getTotalPages(),
page.hasNext());
}
private static PageRequest pageRequest(Integer pageIndex, Integer pageSize, Sort sort) {
return PageRequest.of(normalizePageIndex(pageIndex) - 1, normalizePageSize(pageSize), sort);
}
private static int normalizePageIndex(Integer pageIndex) {
return pageIndex == null || pageIndex < 1 ? 1 : pageIndex;
}
private static int normalizePageSize(Integer pageSize) {
return pageSize == null || pageSize < 1 ? 10 : pageSize;
}
/** pageSize 优先;兼容旧 limit 参数,上限 500 */
private static int resolvePageSize(Integer pageSize, Integer limit) {
Integer v = pageSize != null ? pageSize : limit;
return v == null || v < 1 ? 50 : Math.min(v, 500);
}
private static String text(Object v) {
return v == null ? null : String.valueOf(v).trim();
}
private static int intVal(Object v, String field) {
if (!(v instanceof Number)) {
throw new IllegalArgumentException(field + " 须为数字:" + v);
}
return ((Number) v).intValue();
}
private static String emptyToNull(String s) {
return s == null || s.trim().isEmpty() ? null : s.trim();
}
}
@@ -0,0 +1,370 @@
package com.yunhai.platform.gateway.admin;
import com.yunhai.platform.apistd.ApiResponse;
import com.yunhai.platform.gateway.auth.GatewayKeyService;
import com.yunhai.platform.gateway.filter.SignVerifyFilter;
import com.yunhai.platform.utils.crypto.SignUtil;
import com.yunhai.platform.utils.exception.ErrorCode;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.apache.http.HttpEntity;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.RequestBuilder;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.SocketTimeoutException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
/**
* 治理面「代理测试」端点(N 系治理小页的调障配套):输入 appKey/appSecret + 完整 oapi 地址,
* 网关在服务端代为签名并<b>回环打自身的真实 oapi 链</b>(Sanitize→路由判定→验签→转发),
* 把最终 status/耗时/响应体原样带回页面——测的是生产同一条链,不是旁路模拟。
* <p>
* <b>为什么回环自身而不是直连提供方</b>:排障要复现的正是调用方将经历的完整判定
* (路由是否命中、验签是否通过、observe 是否放行、转发是否 502)。回环地址取
* {@code request.getLocalAddr():getLocalPort()}(本机直打,不经 Caddy),天然是
* <b>内部入口</b>——与 starter/SDK 的容器网内调用同一口径(一律验签)。
* <p>
* <b>安全边界</b>:入参 URL 的 scheme/host 一律忽略,只取 path+query 且形状必须为
* {@code /{target}/oapi/**}——不存在 SSRF 面(只会打到网关自身)。appSecret 支持两种来源:
* 页面明传(INPUT)或留空由 {@link GatewayKeyService} 从密钥链解析(REDIS,fail-closed)。
* 签名串 canonical 复用 {@link SignVerifyFilter#buildCanonicalTarget}——与验签侧同一实现,
* 杜绝双写漂移;时间戳毫秒(F1 契约)。
* <p>
* <b>响应契约</b>:与 InternalAdminController 同——成功 {@code {code:0,data}}(advice 统一包),
* 入参非法 HTTP 200 + {@code {code:2,msg}}。observe=true 时验签失败也会 200(FAIL_OBSERVED
* 放行透传),判定结论以访问台账为准——页面有文案提示。
*/
@Tag(name = "GatewayAdmin", description = "网关治理:路由旋钮 / 目标地址 / 访问台账")
@RestController
@RequestMapping("/api/internal/admin")
public class ProxyTestController {
private static final Set<String> ALLOWED_METHODS = new HashSet<>(
Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"));
/** 入参 URL 的 path 长度上限(防长串绕形) */
private static final int MAX_PATH_LEN = 512;
/** 入参 URL 的 query 长度上限 */
private static final int MAX_QUERY_LEN = 2048;
/** 请求体入参上限 64KB(治理面手工测试足够) */
private static final int MAX_REQ_BODY_BYTES = 64 * 1024;
/** 响应体带回上限 256KB:超出截断并置 bodyTruncated=true */
private static final int MAX_RES_BODY_BYTES = 256 * 1024;
/** 连接网关自身 5s(本机回环,防御挂死) */
private static final int CONNECT_TIMEOUT_MS = 5_000;
/** 读超时 60s:须覆盖 route.timeout_ms 上限(当前最大配 60s) */
private static final int READ_TIMEOUT_MS = 60_000;
@Autowired
private GatewayKeyService keyService;
/** 测试/显式装配入口(同 InternalAdminController.setRepos 惯例) */
void setKeyService(GatewayKeyService keyService) {
this.keyService = keyService;
}
/** 回环基址覆盖(单测注入本地 HttpServer 用);null = 按请求 localAddr:localPort 推导 */
private String loopbackBaseOverride;
void setLoopbackBase(String base) {
this.loopbackBaseOverride = base;
}
/**
* 代理测试:服务端签名后回环打真实 oapi 链,返回 status/耗时/响应体与签名调试信息。
*
* @param body {url, method?, appKey, appSecret?, contentType?, body?}
*/
@Operation(operationId = "GatewayAdmin.proxyTest", summary = "代理测试:票据签名后回环打真实 oapi 链")
@PostMapping("/proxy-test")
public Map<String, Object> proxyTest(@RequestBody Map<String, Object> body, HttpServletRequest req)
throws IOException {
String method = upperMethod(body.get("method"));
String url = text(body.get("url"));
String appKey = text(body.get("appKey"));
String appSecret = text(body.get("appSecret"));
String contentType = text(body.get("contentType"));
String reqBody = text(body.get("body"));
if (url == null) {
throw new IllegalArgumentException("url 不可为空(形如 http://网关域名/{target}/oapi/**)");
}
if (appKey == null) {
throw new IllegalArgumentException("appKey 不可为空");
}
if (reqBody != null && reqBody.getBytes(StandardCharsets.UTF_8).length > MAX_REQ_BODY_BYTES) {
throw new IllegalArgumentException("body 超过 " + MAX_REQ_BODY_BYTES / 1024 + "KB 上限");
}
Parsed p = parse(url);
String target = p.target;
String pathAndQuery = lenientUriEncode(p.rawPath, p.rawQuery); // 传输形(含容错编码)
int q = pathAndQuery.indexOf('?');
String finalPath = q < 0 ? pathAndQuery : pathAndQuery.substring(0, q);
String finalQuery = q < 0 ? null : pathAndQuery.substring(q + 1);
// canonical 坐标系 = 剥 target 首段后的路径;query 取传输形(网关侧同样以原始 query 解码排序)
String strippedPath = finalPath.substring(finalPath.indexOf('/', 1));
String secret;
String secretSource;
if (appSecret != null) {
secret = appSecret;
secretSource = "INPUT";
} else {
secret = keyService.getSecret(appKey); // fail-closed:未知/禁用/Redis 异常一律 null
secretSource = "REDIS";
if (secret == null) {
throw new IllegalArgumentException(
"appSecret 未填且网关解析不到该 appKey 的密钥(未注册/已禁用/密钥链不可用)");
}
}
String timestamp = String.valueOf(System.currentTimeMillis());
String canonical = SignVerifyFilter.buildCanonicalTarget(strippedPath, finalQuery);
String signature;
try {
signature = SignUtil.signRequest(method, canonical, timestamp, secret);
} catch (RuntimeException e) {
// SignUtil.checkSecret:密钥短于 16 位等——验签侧同因判 FAIL,这里给明确报错
throw new IllegalArgumentException("签名失败:" + e.getMessage());
}
String loopbackBase = loopbackBase(req);
String requestUrl = loopbackBase + pathAndQuery;
long startNanos = System.nanoTime();
int status = -1;
String resContentType = null;
String resBody = "";
boolean truncated = false;
IOException failure = null;
// 治理面低频手工操作:每次请求用独立 client,用完即关,不占全局连接池
try (CloseableHttpClient client = HttpClients.createDefault()) {
RequestBuilder rb = RequestBuilder.create(method)
.setUri(URI.create(requestUrl))
.setConfig(RequestConfig.custom()
.setConnectTimeout(CONNECT_TIMEOUT_MS)
.setSocketTimeout(READ_TIMEOUT_MS)
.setConnectionRequestTimeout(CONNECT_TIMEOUT_MS)
.setRedirectsEnabled(false)
.build())
.addHeader("X-App-Key", appKey)
.addHeader("X-Timestamp", timestamp)
.addHeader("X-Signature", signature);
if (reqBody != null && !reqBody.isEmpty() && hasRequestBody(method)) {
// 注意 StringEntity(String,String) 的第二参是 charset 不是 content-type——必须走 ContentType 形态
org.apache.http.entity.ContentType ct = contentType == null
? org.apache.http.entity.ContentType.APPLICATION_JSON
: org.apache.http.entity.ContentType.parse(contentType);
rb.setEntity(new StringEntity(reqBody, ct));
}
try (CloseableHttpResponse up = client.execute(rb.build())) {
status = up.getStatusLine().getStatusCode();
org.apache.http.Header ct = up.getFirstHeader("Content-Type");
if (ct != null) {
resContentType = ct.getValue();
}
HttpEntity entity = up.getEntity();
if (entity != null) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (InputStream in = entity.getContent()) {
byte[] buf = new byte[8192];
int n;
while (out.size() <= MAX_RES_BODY_BYTES && (n = in.read(buf)) >= 0) {
out.write(buf, 0, n);
}
}
byte[] bytes = out.toByteArray();
truncated = bytes.length > MAX_RES_BODY_BYTES;
resBody = new String(bytes, 0, Math.min(bytes.length, MAX_RES_BODY_BYTES),
StandardCharsets.UTF_8);
}
} catch (SocketTimeoutException e) {
// 网关自身 60s 未回包(后端 route.timeout 超时应已由网关回裸 504,此处为兜底口径)
failure = e;
status = 504;
}
} catch (IOException e) {
failure = e; // 连不上网关自身(端口收网/绑定异常等罕见场景;status 保持 -1)
}
int latencyMs = (int) ((System.nanoTime() - startNanos) / 1_000_000L);
Map<String, Object> sign = new LinkedHashMap<>();
sign.put("appKey", appKey);
sign.put("timestamp", timestamp);
sign.put("canonical", canonical);
sign.put("signature", signature);
sign.put("secretSource", secretSource);
Map<String, Object> out = new LinkedHashMap<>();
out.put("target", target);
out.put("method", method);
out.put("requestUrl", requestUrl);
out.put("status", status);
out.put("latencyMs", latencyMs);
out.put("contentType", resContentType);
out.put("bodyTruncated", truncated);
out.put("body", resBody);
out.put("signature", sign);
out.put("error", failure == null ? null : "回环调用网关自身失败:" + failure.getMessage());
return out;
}
/** 值域/参数错误 → HTTP 200 + {code:2, msg}(与 InternalAdminController.badRequest 同口径) */
@ExceptionHandler(IllegalArgumentException.class)
public ApiResponse<Object> badRequest(IllegalArgumentException e) {
return ApiResponse.fail(ErrorCode.PARAM_ERROR, e.getMessage());
}
/* ============================ 解析与编码 ============================ */
/** 解析产物:target 首段 + 原始 path/query(未编码容错,交给 lenientUriEncode 收口) */
static class Parsed {
final String target;
final String rawPath;
final String rawQuery;
Parsed(String target, String rawPath, String rawQuery) {
this.target = target;
this.rawPath = rawPath;
this.rawQuery = rawQuery;
}
}
/**
* 容忍式解析:只取 path+query(scheme/host 忽略——回环只打网关自身,无 SSRF 面)。
* 允许无 scheme 直贴 {@code /{target}/oapi/x} 或 {@code {target}/oapi/x}。
* 形状校验与 {@code TargetRouter.route} 同规则:第二段必须恰为 oapi。
*/
static Parsed parse(String raw) {
String s = raw.trim();
int schemeEnd = s.indexOf("://");
String pathAndQuery;
if (schemeEnd >= 0) {
int pathStart = s.indexOf('/', schemeEnd + 3);
if (pathStart < 0) {
throw new IllegalArgumentException("URL 缺少路径(形如 http://host/{target}/oapi/**):" + raw);
}
pathAndQuery = s.substring(pathStart); // 只信任 scheme 形态的 host 剥离
} else if (s.startsWith("/")) {
pathAndQuery = s;
} else {
pathAndQuery = "/" + s; // 无 scheme 直贴 {target}/oapi/x——视为路径,不当 host 剥
}
int q = pathAndQuery.indexOf('?');
String path = q < 0 ? pathAndQuery : pathAndQuery.substring(0, q);
String query = q < 0 ? null : pathAndQuery.substring(q + 1);
if (path.isEmpty() || path.equals("/")) {
throw new IllegalArgumentException("路径须形如 /{target}/oapi/**(如 /userbase/oapi/user/byId)");
}
if (path.length() > MAX_PATH_LEN) {
throw new IllegalArgumentException("path 超过 " + MAX_PATH_LEN + " 字符上限");
}
if (query != null && query.length() > MAX_QUERY_LEN) {
throw new IllegalArgumentException("query 超过 " + MAX_QUERY_LEN + " 字符上限");
}
int second = path.indexOf('/', 1);
if (second < 0) {
throw new IllegalArgumentException("路径第二段必须为 oapi(如 /userbase/oapi/user/byId),当前:" + path);
}
String target = path.substring(1, second);
String rest = path.substring(second);
if (target.isEmpty() || target.contains("..")) {
throw new IllegalArgumentException("target 非法:" + target);
}
if (!rest.equals("/oapi") && !rest.startsWith("/oapi/")) {
throw new IllegalArgumentException("路径第二段必须为 oapi(如 /userbase/oapi/user/byId),当前:" + path);
}
return new Parsed(target, path, query);
}
/**
* 容错编码:保留 RFC3986 合法字符与既有的 %xx 转义,其余(空格/中文等)按 UTF-8 百分号编码,
* 保证 {@code URI.create} 不炸。签名与传输共用编码后的最终形——网关 canonical 会对 query
* 解码排序,编码选择不影响验签一致性。
*/
static String lenientUriEncode(String path, String query) {
StringBuilder sb = new StringBuilder(path.length() + 16);
appendLenient(sb, path);
if (query != null) {
sb.append('?');
appendLenient(sb, query);
}
return sb.toString();
}
private static void appendLenient(StringBuilder sb, String s) {
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (isKept(c)) {
sb.append(c);
} else {
byte[] bytes = String.valueOf(c).getBytes(StandardCharsets.UTF_8);
for (byte b : bytes) {
sb.append('%').append(String.format("%02X", b & 0xFF));
}
}
}
}
/** unreserved + 保留字 + %(用户既有转义原样保留) */
private static boolean isKept(char c) {
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) {
return true;
}
return "-._~:/?#[]@!$&'()*+,;=%".indexOf(c) >= 0;
}
/* ============================ 小工具 ============================ */
private static String upperMethod(Object v) {
String m = v == null ? "GET" : String.valueOf(v).trim().toUpperCase();
if (!ALLOWED_METHODS.contains(m)) {
throw new IllegalArgumentException("method 非法:" + m + "(合法 " + ALLOWED_METHODS + ")");
}
return m;
}
private static boolean hasRequestBody(String method) {
return !"GET".equals(method) && !"HEAD".equals(method) && !"OPTIONS".equals(method);
}
private static String text(Object v) {
if (v == null) {
return null;
}
String s = String.valueOf(v).trim();
return s.isEmpty() ? null : s;
}
/** 回环基址:本机直打网关自身端口(内部入口口径);IPv6 地址加方括号 */
private String loopbackBase(HttpServletRequest req) {
if (loopbackBaseOverride != null) {
return loopbackBaseOverride;
}
String host = req.getLocalAddr();
if (host != null && host.contains(":") && !host.startsWith("[")) {
host = "[" + host + "]";
}
return "http://" + host + ":" + req.getLocalPort();
}
}
@@ -0,0 +1,32 @@
package com.yunhai.platform.gateway.auth;
/**
* ANON 路由的 per-IP 限流判定钩子(T3.5 约束:「ANON 的风险面就是匿名,必须限」)。
* <p>
* 契约:
* <ul>
* <li>{@link Decision#ALLOW} —— 限流内,免签放行(台账 verdict=ANON、caller=anonymous);</li>
* <li>{@link Decision#EXCEEDED} —— 超限,裸 <b>429</b>;</li>
* <li>{@link Decision#UNAVAILABLE} —— 限流能力未就绪/不可用,<b>fail-closed 拒绝</b>(裸 403)。</li>
* </ul>
* 本期(T3.1 Redis per-IP 计数未落地)唯一实现是 {@link UnavailableAnonRateLimiter}:
* 因此即便在治理页把路由配成 {@code EXTERNAL × ANON},也不会免签放行——这是有意为之
* (落地总纲 D4:未接限流的匿名口子等于无限流暴露面)。T3.1 落地时以新实现替换该 bean 即可。
*/
public interface AnonRateLimiter {
enum Decision {
/** 限流内:放行 */
ALLOW,
/** 超出限额:裸 429 */
EXCEEDED,
/** 限流不可用:fail-closed,裸 403 */
UNAVAILABLE
}
/**
* @param clientIp 客户端来源地址({@code remoteAddr})
* @param qps 限额;调用方已保证 &gt; 0(route.rate_limit_qps 为 0 时取全局缺省)
*/
Decision tryAcquire(String clientIp, int qps);
}
@@ -0,0 +1,91 @@
package com.yunhai.platform.gateway.auth;
import com.yunhai.platform.gateway.entity.OpenApiApp;
import com.yunhai.platform.gateway.filter.SignVerifyFilter;
import com.yunhai.platform.gateway.repository.OpenApiAppRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
/**
* 应用免签注册表(应用级免签 C 方向 G4):内部入口按 appKey 维度判定"无票据直接访问",
* 判定 O(1) 不查库,变更后重载即生效(与 {@code RouteRegistry} 同款 volatile 快照模式)。
* <p>
* <b>快照两件</b>:具体 ANON 应用(appKey → expireAt)+ {@code '*'} 匿名窗口行的到期时刻
* (哨兵行只折算成窗口时刻,不进 appKey 映射——请求头字面 {@code X-App-Key: *} 按无 key 处理)。
* <p>
* <b>fail-closed 缺省</b>(D4):只收 enabled=true 且 auth_mode=ANON 且 expire_at 非空的行;
* 表空 / 无行 / 禁用 / SIGNED / 已过期 一律返回 null 落回验签——存量 appKey 零行为变化。
* 异常口径同广播链语义:reload 失败沿用上一次快照(stale 但准确)。
* <p>
* <b>到期判定是惰性的</b>(D2a,零定时任务):admit 实时比对 now,过期行即刻不再命中
* (等价恢复验签),行数据不清除,续期走治理页保存。
*/
@Component
public class AppAuthRegistry {
/** 匿名窗口行哨兵 appKey(uk_app 保证全局至多一行) */
public static final String WINDOW_APP_KEY = "*";
private static final String AUTH_MODE_ANON = "ANON";
@Autowired
private OpenApiAppRepository appRepo;
/** key = appKey;只装 enabled=true 且 authMode=ANON 的具体 key 行(整体替换,读侧无锁) */
private volatile Map<String, LocalDateTime> anonApps = Collections.emptyMap();
/** '*' 匿名窗口行未过期时的 expireAt;无行/禁用/异常为 null = 无窗口 */
private volatile LocalDateTime anonWindowUntil;
@PostConstruct
public void init() {
reload();
}
/** 全量重 load(@PostConstruct / refresh 端点 / 广播 / 治理写操作共用);返回装载的具体 key 行数 */
public int reload() {
Map<String, LocalDateTime> fresh = new HashMap<>();
LocalDateTime window = null;
for (OpenApiApp app : appRepo.findByEnabledTrue()) {
if (!app.isEnabled() || !AUTH_MODE_ANON.equals(app.getAuthMode())) {
continue; // 双保险滤 enabled(同 RouteRegistry 惯例);SIGNED 行不进快照
}
if (app.getExpireAt() == null) {
continue; // 无到期时刻的 ANON 行 fail-closed 不装载(API 不允许产生;SQL 手插的坏行也放不出)
}
if (WINDOW_APP_KEY.equals(app.getAppKey())) {
window = app.getExpireAt();
} else {
fresh.put(app.getAppKey(), app.getExpireAt());
}
}
anonApps = fresh;
anonWindowUntil = window;
return fresh.size();
}
/**
* 内部入口免签判定(D2/D2a)。命中返回放行 verdict:{@code APP_ANON}=具体应用免签
* (调用方自报 appKey,台账照常归因)/ {@code ANON}=匿名窗口(无票据放行,归因 anonymous);
* 返回 null = 落回验签分支。
*/
public String admit(String appKey) {
LocalDateTime now = LocalDateTime.now();
if (appKey != null && !appKey.trim().isEmpty() && !WINDOW_APP_KEY.equals(appKey.trim())) {
LocalDateTime expireAt = anonApps.get(appKey.trim());
if (expireAt != null && expireAt.isAfter(now)) {
return SignVerifyFilter.VERDICT_APP_ANON; // 具体 key 免签
}
}
if (anonWindowUntil != null && anonWindowUntil.isAfter(now)) {
return SignVerifyFilter.VERDICT_ANON; // 匿名窗口
}
return null; // 落回验签
}
}
@@ -0,0 +1,138 @@
package com.yunhai.platform.gateway.auth;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
/**
* 入口来源判定(T4.3):判定一次请求是否走「外部入口」。
* <p>
* 外部入口 = <b>来源可信</b>({@code remoteAddr} 落在 {@code yh.gateway.trusted-proxies}
* 的某个 CIDR 内)<b>且</b> 带 {@code X-Entry: external}(大小写与首尾空白不敏感)。
* 两条件缺一即按<b>内部入口</b>处理——来源不可信时客户端伪造的 {@code X-Entry} 天然无效
* (A6),该头本身也不会转发给提供方({@code StreamingForwarder} 剥除),
* 故提供方永远看不到标记头。
* <p>
* 信任列表为空(默认)⇒ 不存在外部入口,全站按内部口径(一律验签)。
* 仅识别 IPv4(含 {@code ::ffff:a.b.c.d} 映射形态);IPv6 与非法 CIDR 一律判不可信
* (fail-closed,绝不因为"解析不了"而放行)。
*/
public final class EntryDetector {
/** Caddy 在 open 域 server 块标记的来源头(T4.3) */
public static final String HEADER_ENTRY = "X-Entry";
/** 标记值(比较时大小写不敏感) */
public static final String ENTRY_EXTERNAL = "external";
private EntryDetector() {
}
/**
* @param trustedProxies 可信来源 CIDR 列表({@code yh.gateway.trusted-proxies})
* @return true = 外部入口(可见性/免签判定仅对这个入口生效)
*/
public static boolean isExternal(HttpServletRequest req, List<String> trustedProxies) {
String marker = req.getHeader(HEADER_ENTRY);
if (marker == null || !ENTRY_EXTERNAL.equalsIgnoreCase(marker.trim())) {
return false;
}
return inAnyCidr(req.getRemoteAddr(), trustedProxies);
}
/** 来源 IP 是否落在任一 CIDR 内;空列表 / 解析失败 = false */
static boolean inAnyCidr(String ip, List<String> cidrs) {
byte[] addr = parseIpv4(ip);
if (addr == null || cidrs == null) {
return false;
}
for (String cidr : cidrs) {
if (matches(addr, cidr)) {
return true;
}
}
return false;
}
/** 单条 CIDR;无 {@code /} 时按 /32 精确地址比较 */
private static boolean matches(byte[] addr, String cidr) {
if (cidr == null) {
return false;
}
String spec = cidr.trim();
if (spec.isEmpty()) {
return false;
}
int slash = spec.indexOf('/');
String network = slash < 0 ? spec : spec.substring(0, slash);
int prefix = 32;
if (slash >= 0) {
try {
prefix = Integer.parseInt(spec.substring(slash + 1).trim());
} catch (NumberFormatException e) {
return false;
}
if (prefix < 0 || prefix > 32) {
return false;
}
}
byte[] net = parseIpv4(network);
if (net == null) {
return false;
}
int fullBytes = prefix / 8;
for (int i = 0; i < fullBytes; i++) {
if (addr[i] != net[i]) {
return false;
}
}
int restBits = prefix % 8;
if (restBits == 0) {
return true;
}
int mask = (0xFF << (8 - restBits)) & 0xFF;
return (addr[fullBytes] & mask) == (net[fullBytes] & mask);
}
/** 只认 IPv4 点分十进制;{@code ::ffff:a.b.c.d} 剥前缀后按 IPv4 处理,其余(纯 IPv6 等)返回 null */
private static byte[] parseIpv4(String raw) {
if (raw == null) {
return null;
}
String ip = raw.trim();
if (ip.isEmpty()) {
return null;
}
int lastColon = ip.lastIndexOf(':');
if (lastColon >= 0) {
String tail = ip.substring(lastColon + 1);
if (!ip.toLowerCase().startsWith("::ffff:") || tail.indexOf('.') < 0) {
return null;
}
ip = tail;
}
String[] parts = ip.split("\\.", -1);
if (parts.length != 4) {
return null;
}
byte[] out = new byte[4];
for (int i = 0; i < 4; i++) {
String p = parts[i];
if (p.isEmpty() || p.length() > 3) {
return null;
}
int v = 0;
for (int j = 0; j < p.length(); j++) {
char c = p.charAt(j);
if (c < '0' || c > '9') {
return null;
}
v = v * 10 + (c - '0');
}
if (v > 255) {
return null;
}
out[i] = (byte) v;
}
return out;
}
}
@@ -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;
}
}
@@ -0,0 +1,76 @@
package com.yunhai.platform.gateway.auth;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* SIGNED 流量的本地令牌桶限流器(能力补全 2):按 <b>appKey × 路由行</b> 判定,
* 与路由级 ANON 的 {@link AnonRateLimiter}(三态、fail-closed)刻意不同口径——
* <ul>
* <li><b>fail-open</b>:SIGNED 流量已过验签,限流是保护性旁路而非准入前提——限流器自身
* 故障放行 + WARN("限流器短暂失效"远小于"网关全量 503");本组件无外部依赖
* (纯内存),故障面只剩实现 bug;</li>
* <li><b>桶内不存 qps</b>:每次 {@link #tryAcquire} 按当次 qps 计算容量与补充速率,
* 容量对存量令牌取 min 截断——治理页热调 qps 后(走既有 reload 链)下一次请求即按
* 新值,旧桶靠 Caffeine expire 自然淘汰;</li>
* <li><b>容量 = qps</b>(允许 1 秒突发);补充按惰性计算,acquire = 补充后 tokens&ge;1 扣减。</li>
* </ul>
* 多实例部署下每实例各自限额(总额 = 行 qps × 实例数),当前单实例语义无损;
* 桶 key 已含全部维度,将来 Redis 化可平移。
*/
@Component
public class SignedRateLimiter {
private static final Logger log = LoggerFactory.getLogger(SignedRateLimiter.class);
/** 长尾 key(调用方×行)自动回收;桶数 = appKey × 启用行,量级几百,50_000 是防御上限 */
private final Cache<String, Bucket> buckets = Caffeine.newBuilder()
.expireAfterAccess(10, TimeUnit.MINUTES)
.maximumSize(50_000)
.build();
/**
* @param key 桶键(调用方维度由调用方拼入,如 {@code appKey@routeKey})
* @param qps 限额;&le;0 一律放行(0=不限是现契约)
* @return true=限内放行;内部异常 fail-open 放行 + WARN
*/
public boolean tryAcquire(String key, int qps) {
if (qps <= 0) {
return true;
}
try {
return buckets.get(key, k -> new Bucket()).tryAcquire(qps);
} catch (RuntimeException e) {
log.warn("[GatewayRateLimit] limiter failure, fail-open key={}", key, e);
return true;
}
}
/** synchronized 小对象:tokens + lastRefillNanos,容量随当次 qps(D7 桶内不存 qps) */
private static final class Bucket {
private double tokens;
private long lastRefillNanos;
synchronized boolean tryAcquire(int qps) {
long now = System.nanoTime();
if (lastRefillNanos == 0L) {
tokens = qps; // 新桶满容:允许 1 秒突发
} else {
double refill = (now - lastRefillNanos) / 1_000_000_000.0 * qps;
tokens = Math.min(qps, tokens + refill); // 存量截断到当前容量(qps 调小即时生效)
}
lastRefillNanos = now;
if (tokens >= 1d) {
tokens -= 1d;
return true;
}
return false;
}
}
}
@@ -0,0 +1,34 @@
package com.yunhai.platform.gateway.auth;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
/**
* ANON 限流的占位实现(T3.1 未落地期间的 fail-closed 形态):一律返回
* {@link Decision#UNAVAILABLE},故 {@code EXTERNAL × ANON} 路由不会被免签放行。
* <p>
* 为什么不是"先放行、后补限流":匿名入口一旦无限制开放,暴露面在限流落地前就是全开的,
* 且期间无法回收(第三方可能已经接入)。宁可先不给,也不留无限流的口子。
* <p>
* T3.1 落地方式:新增 Redis per-IP 计数的 {@link AnonRateLimiter} 实现并让它优先装配
* (替换本 bean 或加 {@code @Primary}),本类即自动退场;{@code SignVerifyFilter} 无需改动。
*/
@Component
public class UnavailableAnonRateLimiter implements AnonRateLimiter {
private static final Logger log = LoggerFactory.getLogger(UnavailableAnonRateLimiter.class);
@PostConstruct
void announce() {
log.warn("[GatewayAnon] ANON 限流实现未就绪(T3.1 per-IP 计数待落地):"
+ "EXTERNAL×ANON 路由一律 fail-closed 拒绝(403),不会免签放行");
}
@Override
public Decision tryAcquire(String clientIp, int qps) {
return Decision.UNAVAILABLE;
}
}
@@ -0,0 +1,78 @@
package com.yunhai.platform.gateway.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import java.time.LocalDateTime;
/**
* 应用治理表(应用级免签 C 方向 G2;DDL 见 docs/sql/04_app.sql,字段与列逐一对齐)。
* <p>
* 只管治理属性(免签与否),不管密钥——密钥链(appauth starter Redis)职责不变:
* SIGNED 应用照旧从密钥链取 secret,ANON 应用不要求密钥存在。
* 表空 / 无行 / 禁用 / expire_at 过期 = 维持验签(fail-closed 缺省)。
* 运行时快照由 {@code AppAuthRegistry} 装载;改行走治理 API,保存后热重载即生效。
*/
@Entity
@Table(name = "open_api_app", uniqueConstraints = @UniqueConstraint(
name = "uk_app", columnNames = {"app_key"}))
@Getter
@Setter
public class OpenApiApp {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** 调用方 appKey(与密钥链 sign:app:{appKey} 同键;* = 匿名窗口行哨兵,至多一行) */
@Column(name = "app_key", nullable = false, length = 64)
private String appKey;
@Column(name = "app_name", length = 64)
private String appName;
/** SIGNED=验签(缺省)/ ANON=内部入口免签 */
@Column(name = "auth_mode", nullable = false, length = 16)
private String authMode = "SIGNED";
/** 免签生效到期(ANON 行必填 ≤31 天,过期=惰性恢复验签;SIGNED 行为 NULL) */
@Column(name = "expire_at")
private LocalDateTime expireAt;
@Column(name = "enabled", nullable = false)
private boolean enabled = true;
@Column(name = "remark", length = 256)
private String remark;
@Column(name = "gmt_create", nullable = false)
private LocalDateTime gmtCreate;
@Column(name = "gmt_modified", nullable = false)
private LocalDateTime gmtModified;
@PrePersist
void preCreate() {
LocalDateTime now = LocalDateTime.now();
if (gmtCreate == null) {
gmtCreate = now;
}
if (gmtModified == null) {
gmtModified = now;
}
}
@PreUpdate
void preUpdate() {
gmtModified = LocalDateTime.now();
}
}
@@ -50,4 +50,23 @@ public final class EnvelopeWriter {
node.putNull("data");
response.getWriter().write(MAPPER.writeValueAsString(node));
}
/**
* 管理面鉴权失败:HTTP 401 + 治理面信封 {@code {code:401, msg, data:null}}。
* <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();
}
}
@@ -2,6 +2,7 @@ package com.yunhai.platform.gateway.filter;
import com.yunhai.platform.gateway.envelope.EnvelopeWriter;
import com.yunhai.platform.gateway.forward.StreamingForwarder;
import com.yunhai.platform.gateway.route.RouteSpec;
import com.yunhai.platform.gateway.route.TargetRouter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.Order;
@@ -20,9 +21,10 @@ import java.io.IOException;
* 链尾转发(T1.6 链序第 5 环):oapi 请求到此一律交给 {@link StreamingForwarder}
* 流式送提供方并原样回传,链在此终结(不再回到网关的 DispatcherServlet)。
* <p>
* 身份注入(链序第 4 环)在转发器内完成(T1.4 {@code CallerHeader}),所以本环只需带上
* 请求属性。缺路由 attribute(未经 RoutingFilter 的异常路径)按 404 收口,绝不裸转。
* 超时/体积暂无 per-route 覆盖(RouteSpec 不含该列),用转发器默认值。
* 身份注入在转发器内完成(T1.4 {@code CallerHeader}),所以本环只需带上请求属性。
* 缺路由 attribute(未经 RoutingFilter 的异常路径)按 404 收口,绝不裸转。
* 超时/体积取命中路由行({@code RouteSpec.timeoutMs/requestBodyLimitMb},
* 能力补全 3),缺 spec 或值 &le;0(防御)回落转发器默认 30s/10MB。
*/
@Component
@Order(5)
@@ -55,7 +57,14 @@ public class DispatchFilter implements Filter {
EnvelopeWriter.writeBare(res, 404);
return;
}
forwarder.forward(req, res, ((TargetRouter.Routing) routing).forwardUrl,
StreamingForwarder.DEFAULT_READ_TIMEOUT_MS, StreamingForwarder.DEFAULT_BODY_LIMIT_BYTES);
RouteSpec spec = (RouteSpec) req.getAttribute(RoutingFilter.ATTR_ROUTE_SPEC);
if (spec == null) {
spec = RouteSpec.DEFAULT; // 未经 RoutingFilter 的异常路径:转发器默认
}
int timeoutMs = spec.timeoutMs > 0 ? spec.timeoutMs : StreamingForwarder.DEFAULT_READ_TIMEOUT_MS;
long bodyLimitBytes = spec.requestBodyLimitMb > 0
? spec.requestBodyLimitMb * 1024L * 1024L
: StreamingForwarder.DEFAULT_BODY_LIMIT_BYTES;
forwarder.forward(req, res, ((TargetRouter.Routing) routing).forwardUrl, timeoutMs, bodyLimitBytes);
}
}
@@ -0,0 +1,75 @@
package com.yunhai.platform.gateway.filter;
import com.yunhai.platform.gateway.auth.SignedRateLimiter;
import com.yunhai.platform.gateway.envelope.EnvelopeWriter;
import com.yunhai.platform.gateway.route.RouteSpec;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
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;
/**
* SIGNED 流量限流(能力补全 2),链序第 4 环:验签(3)之后、转发(5)之前——
* 身份注入仍在转发器内完成,本环只判定不注头。
* <p>
* 判定对象仅 {@code PASS}/{@code APP_ANON}(有身份的调用方同口径限);
* {@code FAIL_OBSERVED} 不判——observe 是过渡态,其流量多为密钥配错的合法调用方,
* observe 期限流会放大误伤(observe 切 false 后该态自然消失);路由级 ANON 维持
* 自己的占位限流路径({@code SignVerifyFilter#anonBranch})不经本环。
* <p>
* 桶粒度 = appKey × 路由行({@code spec.routeKey});qps 缺省 0=不限(存量零变化)。
* 超限 <b>裸 429</b>(F5 契约,与 401/403/404 同形态)——策略层拒绝与 observe 正交
* (同 403 既有口径),verdict/caller 保持第 3 环产出,台账现有列即可表达"验过但被限"。
*/
@Component
@Order(4)
public class RateLimitFilter implements Filter {
@Autowired
private SignedRateLimiter rateLimiter;
/** 测试/显式装配入口 */
void setRateLimiter(SignedRateLimiter rateLimiter) {
this.rateLimiter = rateLimiter;
}
@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 (RoutingFilter.isGatewayInternal(RoutingFilter.sanitizedUri(req))) {
chain.doFilter(request, response); // 自身端点:不经 oapi 判定(同 DispatchFilter 旁路口径)
return;
}
String verdict = (String) req.getAttribute(SignVerifyFilter.ATTR_VERDICT);
if (!SignVerifyFilter.VERDICT_PASS.equals(verdict)
&& !SignVerifyFilter.VERDICT_APP_ANON.equals(verdict)) {
chain.doFilter(request, response); // FAIL/FAIL_OBSERVED/ANON 不判
return;
}
String appKey = (String) req.getAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY);
RouteSpec spec = (RouteSpec) req.getAttribute(RoutingFilter.ATTR_ROUTE_SPEC);
if (appKey == null || appKey.trim().isEmpty() || spec == null || spec.rateLimitQps <= 0) {
chain.doFilter(request, response); // 无身份或缺省 0=不限
return;
}
if (!rateLimiter.tryAcquire(appKey + "@" + spec.routeKey, spec.rateLimitQps)) {
EnvelopeWriter.writeBare(res, 429); // 策略层拒绝,不 observe(与 403 正交口径一致)
return;
}
chain.doFilter(request, response);
}
}
@@ -37,6 +37,23 @@ public class RoutingFilter implements Filter {
*/
public static final String ACTUATOR_PREFIX = "/actuator";
/**
* OpenAPI 文档端点(B2):springdoc 的 {@code /v3/api-docs}、{@code /swagger-ui/**} 属网关自身路径,
* 须与 {@link #INTERNAL_PREFIX} 同级旁路——否则首段({@code v3} / {@code swagger-ui.html})会被
* {@link TargetRouter} 当作 target 解析 → 未知 target 裸 404,文档端点不可达。
*/
public static final String API_DOCS_PREFIX = "/v3/api-docs";
public static final String SWAGGER_UI_PREFIX = "/swagger-ui";
/** springdoc UI 传统入口;{@link #isSelfPath} 的 equals+前缀双条件均不覆盖此路径,须单独 equals 判定 */
public static final String SWAGGER_UI_HTML = "/swagger-ui.html";
/**
* 外观偏好同源读端点(对接规范 §3;{@code AppearanceProxyController}):YschTheme 通道 init
* 固定打「serviceHost + /api/appearance」裸路径,不在 {@code /api/internal} 圈内——
* 须与 {@link #INTERNAL_PREFIX} 同级旁路,否则首段 api 会被当 target 解析 → 裸 404。
*/
public static final String APPEARANCE_PATH = "/api/appearance";
/** 路由产物 attribute({@link TargetRouter.Routing}) */
public static final String ATTR_ROUTING = "yh.gateway.routing";
/** 注册表策略 attribute({@link RouteSpec}) */
@@ -89,7 +106,12 @@ public class RoutingFilter implements Filter {
}
static boolean isGatewayInternal(String uri) {
return isSelfPath(uri, INTERNAL_PREFIX) || isSelfPath(uri, ACTUATOR_PREFIX);
return isSelfPath(uri, INTERNAL_PREFIX)
|| isSelfPath(uri, ACTUATOR_PREFIX)
|| isSelfPath(uri, API_DOCS_PREFIX)
|| isSelfPath(uri, SWAGGER_UI_PREFIX)
|| (uri != null && uri.equals(SWAGGER_UI_HTML))
|| (uri != null && uri.equals(APPEARANCE_PATH));
}
private static boolean isSelfPath(String uri, String prefix) {
@@ -22,7 +22,8 @@ import java.io.IOException;
* 含 {@code ..}(含编码形态 {@code %2e%2e})、路径含双斜杠 {@code //}、
* 含控制字符(0x00-0x1f,含空字节)。
* <p>
* 通过后把<b>原始 URI</b>(逐字节不改写——T0.12 forwardUrl 与 T1.2 canonical 都消费原始形态)
* 通过后把<b>业务 URI</b>(ctx 非空时已剥 context-path;ctx 为空时即原始 URI,逐字节不改写——
* T0.12 forwardUrl 与 T1.2 canonical 都消费此形态)
* 与剥掉 target 首段后的路径存 request attribute,后续过滤器与转发器一律取此值。
*/
@Component
@@ -41,7 +42,7 @@ public class SanitizeFilter implements Filter {
}
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
String uri = req.getRequestURI();
String uri = stripContextPath(req.getRequestURI(), req.getContextPath());
String decoded = percentDecode(uri);
if (isRejected(decoded)) {
EnvelopeWriter.writeBare(res, 400);
@@ -101,4 +102,18 @@ public class SanitizeFilter implements Filter {
int second = uri.indexOf('/', 1);
return second < 0 ? uri : uri.substring(second);
}
/**
* 剥 context-path:Servlet 规范下 getRequestURI() = ctx + servletPath + pathInfo,
* 而网关的业务路径空间以 target 为首段——ctx 非空时须先剥离,下游(白名单/target 判定/
* 台账/验签路径)看到的才是与 ctx 解耦的业务 URI。ctx 为空的应用 getContextPath() 返回 "",
* 此处零行为变化(向后兼容)。
*/
static String stripContextPath(String uri, String contextPath) {
if (uri == null || contextPath == null || contextPath.isEmpty()) {
return uri;
}
String prefix = contextPath + "/";
return uri.startsWith(prefix) ? uri.substring(contextPath.length()) : uri;
}
}
@@ -1,8 +1,12 @@
package com.yunhai.platform.gateway.filter;
import com.yunhai.platform.gateway.auth.AnonRateLimiter;
import com.yunhai.platform.gateway.auth.AppAuthRegistry;
import com.yunhai.platform.gateway.auth.EntryDetector;
import com.yunhai.platform.gateway.auth.GatewayKeyService;
import com.yunhai.platform.gateway.envelope.EnvelopeWriter;
import com.yunhai.platform.gateway.route.GatewayProperties;
import com.yunhai.platform.gateway.route.RouteSpec;
import com.yunhai.platform.utils.crypto.SignUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.Order;
@@ -24,18 +28,31 @@ import java.util.Collections;
import java.util.List;
/**
* v1 验签过滤器(T1.2,共享向量):canonical 与 starter
* 入口分流 + v1 验签过滤器(T1.2 / T4.3),链序第 3 环(T1.6 定型):
* Sanitize(1) → 路由判定(2) → 本过滤器(3) → 身份注入(4) → 转发(5)。
* <p>
* <b>先分入口,再分策略</b>(落地总纲 §0.1 判定矩阵):
* <ul>
* <li><b>内部入口</b>(容器网直连,无标记)—— 先查应用免签注册表 {@link AppAuthRegistry}:
* {@code auth_mode=ANON} 的应用凭 {@code X-App-Key} 免签放行(verdict={@link #VERDICT_APP_ANON},
* 台账 caller=appKey),{@code '*'} 匿名窗口开启时无票据请求也放行(verdict={@link #VERDICT_ANON},
* caller=anonymous)——<b>仅内部入口生效,永不外溢到外部</b>(应用级免签 C 方向 D2/D3);
* 未命中(表空/无行/禁用/过期)一律验签,<b>不看</b> {@code visibility}/{@code authMode}。
* 验签成本≈0,而 appKey 是限流/缓存隔离/台账归因的唯一键;</li>
* <li><b>外部入口</b>({@link EntryDetector}:来源可信 且 {@code X-Entry: external})——
* {@code INTERNAL} 路由裸 <b>403</b>;{@code EXTERNAL×SIGNED} 验签;{@code EXTERNAL×ANON}
* 限流内免签放行({@link #VERDICT_ANON},台账 caller=anonymous),限流未就绪则 fail-closed 拒绝。</li>
* </ul>
* 外部入口的 403/429 属策略层拒绝,与 {@code observe} <b>正交</b>(同 400/404:策略与协议错误永不放行)。
* <p>
* 验签口径(T1.2,共享向量):canonical 与 starter
* {@code AppAuthFilter.buildSignedTarget + SignCanonical} 逐字节一致——
* {@code METHOD\n剥掉 target 首段后的 path[?解码排序query]\ntimestamp},HmacSHA256-Base64(F1),
* 与客户端"剥 basePath 后签名"严格对齐;比对复用 starter 侧同一 {@link SignUtil}(不重写算法),
* 时间窗 ±5min(F2,不得更宽)。
* <p>
* 头缺失 / appKey 未知或禁用({@link GatewayKeyService} null = fail-closed)/ 验签失败 /
* 时间窗超出 → 判定 FAIL:observe=true 只记台账(verdict=FAIL_OBSERVED)放行(R4),
* observe=false 裸 <b>401</b>(F5 契约)。判定结果经 request attribute 传递
* 时间窗 ±5min(F2,不得更宽)。头缺失 / appKey 未知或禁用({@link GatewayKeyService} null =
* fail-closed)/ 验签失败 / 时间窗超出 → 判定 FAIL:observe=true 只记台账(verdict=FAIL_OBSERVED)
* 放行(R4),observe=false 裸 <b>401</b>(F5 契约)。判定结果经 request attribute 传递
* ({@link #ATTR_VERDICT} / {@link #ATTR_AUTH_APPKEY}),供 T1.4 身份注入与 T1.5 台账消费。
* <p>
* 链序第 3 环(T1.6 定型):Sanitize(1) → 路由判定(2) → 本过滤器(3) → 身份注入(4) → 转发(5)。
*/
@Component
@Order(3)
@@ -43,13 +60,20 @@ public class SignVerifyFilter implements Filter {
/** 判定结果 attribute(T1.5/T1.6 台账 verdict) */
public static final String ATTR_VERDICT = "yh.gateway.verdict";
/** 验签通过的 appKey(T1.4 身份注入唯一合法来源) */
/** 免签/验签通过的 appKey(T1.4 身份注入唯一合法来源;来源 = PASS 验签 或 APP_ANON 自报免签) */
public static final String ATTR_AUTH_APPKEY = "yh.gateway.auth.appKey";
public static final String VERDICT_PASS = "PASS";
public static final String VERDICT_FAIL = "FAIL";
public static final String VERDICT_FAIL_OBSERVED = "FAIL_OBSERVED";
public static final String VERDICT_ANON = "ANON";
/** 应用级免签放行(C 方向):ANON 应用凭 X-App-Key 自报身份,仅内部入口,台账 caller=appKey */
public static final String VERDICT_APP_ANON = "APP_ANON";
/** 路由可见性:仅外部入口可达 */
private static final String VISIBILITY_EXTERNAL = "EXTERNAL";
/** 路由鉴权模式:免签(必须配限流;本期限流未就绪 = fail-closed 拒绝) */
private static final String AUTH_MODE_ANON = "ANON";
@Autowired
private GatewayKeyService keyService;
@@ -57,6 +81,14 @@ public class SignVerifyFilter implements Filter {
@Autowired
private GatewayProperties properties;
/** ANON 限流判定;缺省装配的是 {@link com.yunhai.platform.gateway.auth.UnavailableAnonRateLimiter}(fail-closed) */
@Autowired(required = false)
private AnonRateLimiter anonRateLimiter;
/** 应用免签注册表(内部入口免签分支;@Autowired 必装配——缺省快照为空 = 全量验签) */
@Autowired
private AppAuthRegistry appAuthRegistry;
/** 测试/显式装配入口(同 TargetRouter.setProperties 惯例) */
void setKeyService(GatewayKeyService keyService) {
this.keyService = keyService;
@@ -66,6 +98,14 @@ public class SignVerifyFilter implements Filter {
this.properties = properties;
}
void setAnonRateLimiter(AnonRateLimiter anonRateLimiter) {
this.anonRateLimiter = anonRateLimiter;
}
void setAppAuthRegistry(AppAuthRegistry appAuthRegistry) {
this.appAuthRegistry = appAuthRegistry;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
@@ -79,7 +119,63 @@ public class SignVerifyFilter implements Filter {
chain.doFilter(request, response); // 网关自身端点不经 oapi 验签
return;
}
if (EntryDetector.isExternal(req, properties.getTrustedProxies())) {
externalBranch(req, res, chain);
return;
}
// 内部入口:ANON 应用/匿名窗口免签放行(应用级免签 C 方向 D2/D2a/D3,仅内部入口生效);
// 注册表未装配(单测)/表空/无行/禁用/过期 → admit 返回 null,一律落回原验签(fail-closed)
AppAuthRegistry registry = appAuthRegistry;
String verdict = registry == null ? null : registry.admit(req.getHeader("X-App-Key"));
if (verdict != null) {
req.setAttribute(ATTR_VERDICT, verdict); // APP_ANON 或 ANON(复用现有常量)
if (VERDICT_APP_ANON.equals(verdict)) {
req.setAttribute(ATTR_AUTH_APPKEY, req.getHeader("X-App-Key").trim());
} // ANON(匿名窗口)不注入身份——匿名就是匿名
chain.doFilter(req, res);
return;
}
verifyBranch(req, res, chain); // 内部入口:一律验签(不看 visibility / authMode)
}
/**
* 外部入口:按注册表策略分流。{@code spec} 缺省(未经 RoutingFilter 的异常路径)按最严处理,
* 且不可见 = 拒绝——外部入口永远不"默认放行"。
*/
private void externalBranch(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws IOException, ServletException {
RouteSpec spec = (RouteSpec) req.getAttribute(RoutingFilter.ATTR_ROUTE_SPEC);
if (spec == null || !VISIBILITY_EXTERNAL.equals(spec.visibility)) {
req.setAttribute(ATTR_VERDICT, VERDICT_FAIL);
EnvelopeWriter.writeBare(res, 403);
return;
}
if (AUTH_MODE_ANON.equals(spec.authMode)) {
anonBranch(req, res, chain, spec);
return;
}
verifyBranch(req, res, chain); // EXTERNAL×SIGNED(OPTIONAL 本期未开放,落到此处亦按 SIGNED)
}
/** EXTERNAL×ANON:限流内免签放行(不注入身份头 = 匿名就是匿名);限流不可用同样拒绝(D4 fail-closed) */
private void anonBranch(HttpServletRequest req, HttpServletResponse res, FilterChain chain, RouteSpec spec)
throws IOException, ServletException {
int qps = spec.rateLimitQps > 0 ? spec.rateLimitQps : properties.getAnonPerIpQps();
AnonRateLimiter.Decision decision = anonRateLimiter == null
? AnonRateLimiter.Decision.UNAVAILABLE
: anonRateLimiter.tryAcquire(req.getRemoteAddr(), qps);
if (decision == AnonRateLimiter.Decision.ALLOW) {
req.setAttribute(ATTR_VERDICT, VERDICT_ANON);
chain.doFilter(req, res);
return;
}
req.setAttribute(ATTR_VERDICT, VERDICT_FAIL);
EnvelopeWriter.writeBare(res, decision == AnonRateLimiter.Decision.EXCEEDED ? 429 : 403);
}
/** 验签分支:内部入口恒走此路,外部入口的 EXTERNAL×SIGNED 亦走此路 */
private void verifyBranch(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws IOException, ServletException {
boolean pass = false;
String appKey = req.getHeader("X-App-Key");
String timestamp = req.getHeader("X-Timestamp");
@@ -101,13 +197,13 @@ public class SignVerifyFilter implements Filter {
if (pass) {
req.setAttribute(ATTR_VERDICT, VERDICT_PASS);
req.setAttribute(ATTR_AUTH_APPKEY, appKey);
chain.doFilter(request, response);
chain.doFilter(req, res);
return;
}
if (properties.isObserve()) {
// R4:P1 只观察——记台账不拦(台账写入由 T1.5/T1.6 消费本 attribute)
req.setAttribute(ATTR_VERDICT, VERDICT_FAIL_OBSERVED);
chain.doFilter(request, response);
chain.doFilter(req, res);
} else {
req.setAttribute(ATTR_VERDICT, VERDICT_FAIL);
EnvelopeWriter.writeBare(res, 401);
@@ -117,8 +213,9 @@ public class SignVerifyFilter implements Filter {
/**
* canonical 的 target 部分 = path + (有 query 时 {@code ?解码排序query})——
* 与 starter {@code SignCanonical.buildTarget(sortRawQuery(...))} 同语义(共享向量钉死)。
* public:治理面「代理测试」端点(ProxyTestController)签名须与验签同一实现,杜绝双写漂移。
*/
static String buildCanonicalTarget(String pathAfterStrip, String rawQuery) {
public static String buildCanonicalTarget(String pathAfterStrip, String rawQuery) {
String sorted = sortRawQuery(rawQuery);
return sorted.isEmpty() ? pathAfterStrip : pathAfterStrip + "?" + sorted;
}
@@ -5,10 +5,11 @@ import com.yunhai.platform.gateway.filter.SignVerifyFilter;
import javax.servlet.http.HttpServletRequest;
/**
* 身份注入判定(T1.4):仅当验签链判定 PASS 时,转发请求注入
* {@code X-Caller-App-Key: {验签 appKey}};FAIL/FAIL_OBSERVED/ANON(含 OPTIONAL 未签名路径)
* 一律不注入——匿名就是匿名。注入动作发生在 {@link StreamingForwarder} 剥除客户端伪造头
* <b>之后</b>(顺序保证身份头唯一合法来源是网关);不注入任何其他身份字段
* 身份注入判定(T1.4):判定为 PASS(验签)或 APP_ANON(应用级免签,C 方向 G9——ANON 应用为
* 自报未验身份,仅内部入口)时,转发请求注入 {@code X-Caller-App-Key: {appKey}};
* FAIL/FAIL_OBSERVED/ANON(路由级匿名与匿名窗口)一律不注入——匿名就是匿名。
* 注入动作发生在 {@link StreamingForwarder} 剥除客户端伪造头<b>之后</b>
* (顺序保证身份头唯一合法来源是网关);不注入任何其他身份字段
* (oapi 是应用级调用,不做用户级透传)。
*/
final class CallerHeader {
@@ -17,10 +18,12 @@ final class CallerHeader {
}
/**
* @return 应注入的 appKey;非 PASS 或无验签 appKey → null = 不注入
* @return 应注入的 appKey;非 PASS/APP_ANON 或无 appKey → null = 不注入
*/
static String valueFor(HttpServletRequest req) {
if (!SignVerifyFilter.VERDICT_PASS.equals(req.getAttribute(SignVerifyFilter.ATTR_VERDICT))) {
Object verdict = req.getAttribute(SignVerifyFilter.ATTR_VERDICT);
if (!SignVerifyFilter.VERDICT_PASS.equals(verdict)
&& !SignVerifyFilter.VERDICT_APP_ANON.equals(verdict)) {
return null;
}
Object appKey = req.getAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY);
@@ -1,5 +1,6 @@
package com.yunhai.platform.gateway.forward;
import com.yunhai.platform.gateway.auth.EntryDetector;
import com.yunhai.platform.gateway.envelope.EnvelopeWriter;
import org.apache.http.HttpEntity;
import org.apache.http.Header;
@@ -38,10 +39,12 @@ import java.util.concurrent.TimeUnit;
* 请求线程不阻塞等待后端。
* <ul>
* <li>转发 URL 取 T0.12 {@code TargetRouter.Routing.forwardUrl}(换主机不换路径);</li>
* <li>读超时默认 30s({@link #DEFAULT_READ_TIMEOUT_MS};route.timeout_ms 覆盖,本期注册表不配 = 统一默认);</li>
* <li>读超时默认 30s({@link #DEFAULT_READ_TIMEOUT_MS};route.timeout_ms 经 {@code RouteSpec}
* per-route 覆盖,未配/≤0 = 统一默认);</li>
* <li>请求头:保留 {@code X-App-Key / X-Timestamp / X-Signature / X-Nonce} 原样透传
* (UserBase 自验签依赖,R3);剥除客户端伪造的 {@code X-Caller-App-Key}(身份头唯一合法来源是
* 网关,T1.4 注入);剥除 hop-by-hop 头(Connection/Keep-Alive/Transfer-Encoding/Content-Length
* 网关,T1.4 注入)与入口标记头 {@code X-Entry}(T4.3,网关与 Caddy 之间的约定);
* 剥除 hop-by-hop 头(Connection/Keep-Alive/Transfer-Encoding/Content-Length
* 由客户端库重算);</li>
* <li>body 上限 route.request_body_limit_mb(默认 10MB),超限<b>裸 413</b>;</li>
* <li>响应 status/headers/body 原样透传(不经 EnvelopeWriter);连接失败 → <b>裸 502</b>,
@@ -70,7 +73,10 @@ public class StreamingForwarder {
Set<String> s = new HashSet<>();
Collections.addAll(s, "connection", "keep-alive", "proxy-connection", "proxy-authenticate",
"proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade",
"content-length", "host", HEADER_CALLER_APP_KEY.toLowerCase());
"content-length", "host", HEADER_CALLER_APP_KEY.toLowerCase(),
// T4.3:入口标记头是网关与 Caddy 之间的约定,提供方不需要也不该看到——
// 一律剥除(伪造值自然也到不了提供方),网关内部靠 request attribute 传递判定结果
EntryDetector.HEADER_ENTRY.toLowerCase());
REQ_SKIP = s;
Set<String> r = new HashSet<>();
Collections.addAll(r, "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
@@ -145,8 +145,9 @@ public class AccessLogService {
/**
* 按卡口径从请求组装并记录一条台账(T1.6 链上过滤器消费):
* path = 剥段后路径;caller = ANON 记 anonymous、PASS 记验签 appKey、
* 否则尽力取 {@code X-App-Key} 头(401 无 appKey 记 NULL);status = 网关出口状态。
* path = 剥段后路径;caller = ANON 记 anonymous、PASS/APP_ANON 记 appKey
* (APP_ANON=应用级免签自报身份,C 方向 G10)、否则尽力取 {@code X-App-Key} 头
* (401 无 appKey 记 NULL);status = 网关出口状态。
*
* @param backendCode 提供方业务 code(响应为 JSON 信封时尽力解析,否则 null)
*/
@@ -248,7 +249,9 @@ public class AccessLogService {
if (SignVerifyFilter.VERDICT_ANON.equals(verdict)) {
return CALLER_ANONYMOUS;
}
if (SignVerifyFilter.VERDICT_PASS.equals(verdict)) {
if (SignVerifyFilter.VERDICT_PASS.equals(verdict)
|| SignVerifyFilter.VERDICT_APP_ANON.equals(verdict)) {
// APP_ANON(应用级免签):自报身份照常归因到 appKey,非 anonymous
Object appKey = req.getAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY);
if (appKey instanceof String && !((String) appKey).trim().isEmpty()) {
return (String) appKey;
@@ -0,0 +1,13 @@
package com.yunhai.platform.gateway.repository;
import com.yunhai.platform.gateway.entity.OpenApiApp;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface OpenApiAppRepository extends JpaRepository<OpenApiApp, Long> {
List<OpenApiApp> findByEnabledTrue();
boolean existsByAppKey(String appKey);
}
@@ -1,7 +1,22 @@
package com.yunhai.platform.gateway.repository;
import com.yunhai.platform.gateway.entity.OpenApiLog;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface OpenApiLogRepository extends JpaRepository<OpenApiLog, Long> {
/**
* 台账倒序分页(治理小页查询;targetCode/callerAppKey 可空=不过滤)。
* 可空参数走 (:x is null or ...) 形态与既有分页查询一致。
*/
@Query("select l from OpenApiLog l where (:targetCode is null or l.targetCode = :targetCode) "
+ "and (:callerAppKey is null or l.callerAppKey = :callerAppKey) "
+ "order by l.id desc")
Page<OpenApiLog> pageLatest(@Param("targetCode") String targetCode,
@Param("callerAppKey") String callerAppKey,
Pageable pageable);
}
@@ -3,7 +3,9 @@ package com.yunhai.platform.gateway.route;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@@ -11,7 +13,11 @@ import java.util.Map;
* <ul>
* <li>routes:target → base-url(容器网内服务直连 URL,如 http://userbase-service:8080;
* target = F11 七个生产 ctx);</li>
* <li>observe:观察模式(T1.6 消费;默认 true = 只记不拦,R4)。</li>
* <li>observe:观察模式(T1.6 消费;默认 true = 只记不拦,R4);</li>
* <li>trustedProxies:外部入口判定的可信来源(T4.3,CIDR 列表)——仅这些来源的
* {@code X-Entry: external} 生效;留空 = 不存在外部入口;</li>
* <li>anonPerIpQps:ANON 路由未配 {@code rate_limit_qps} 时的 per-IP 缺省限额(T3.1 消费);</li>
* <li>internalToken:机器面({@code /api/internal/**} 除浏览器面)的共享内部令牌。</li>
* </ul>
*/
@Component
@@ -24,6 +30,24 @@ public class GatewayProperties {
/** 观察模式:true = 全判定只记不拦(P1 只观察,R4) */
private boolean observe = true;
/**
* 可信来源列表(CIDR,如 {@code 172.16.0.0/12})。只有来自这些地址且带
* {@code X-Entry: external} 的请求才算外部入口——否则一律按内部口径验签。
* 默认空 = 外部入口不成立(最严,客户端伪造 X-Entry 无效)。
*/
private List<String> trustedProxies = new ArrayList<>();
/** ANON 路由未配 route.rate_limit_qps 时的 per-IP 缺省限额(&gt;0) */
private int anonPerIpQps = 10;
/**
* 机器面({@code /api/internal/**} 除浏览器面)的共享内部令牌,走平台应用 env
* {@code OPENGW_INTERNAL_TOKEN}。空 = 不校验(灰度窗口态,见
* {@link com.yunhai.platform.gateway.auth.InternalTokenFilter});值只在两侧应用 env,
* 不进代码、不进库。
*/
private String internalToken = "";
public Map<String, String> getRoutes() {
return routes;
}
@@ -39,4 +63,28 @@ public class GatewayProperties {
public void setObserve(boolean observe) {
this.observe = observe;
}
public List<String> getTrustedProxies() {
return trustedProxies;
}
public void setTrustedProxies(List<String> trustedProxies) {
this.trustedProxies = trustedProxies == null ? new ArrayList<>() : trustedProxies;
}
public int getAnonPerIpQps() {
return anonPerIpQps;
}
public void setAnonPerIpQps(int 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;
}
}
@@ -0,0 +1,109 @@
package com.yunhai.platform.gateway.route;
import com.yunhai.platform.gateway.auth.AppAuthRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
/**
* 注册表变更广播(D1 选型 C 的后半):治理页/刷新端点写库后就地 reload 之外,
* 再向 {@link #CHANNEL} 发一条消息,各实例收到即各自重载路由/目标地址/应用免签快照,
* 免重启、免逐实例点「重载注册表」。
* <p>
* <b>Redis 在这里的角色是「变更通知」,不是判定主存</b>:路由判定始终读本地 Caffeine(O(1) 不查库),
* 故 Redis 异常时各实例停留在本地快照(stale 但准确),绝不会出现「Redis 挂了就放行」——
* 广播通道断了最多是"改动晚一点生效"。
* <p>
* 失败口径:发布失败只记 WARN(本实例已重载成功,其余实例可由治理页「重载注册表」或重启兜底);
* 订阅装配失败退化为单实例语义,判定能力不受影响。开关 {@code yh.gateway.redis-broadcast-enabled}
* 默认开,测试上下文(无 Redis)显式关掉。
*/
@Component
@ConditionalOnProperty(prefix = "yh.gateway", name = "redis-broadcast-enabled",
havingValue = "true", matchIfMissing = true)
public class RouteChangeBroadcaster implements MessageListener, DisposableBean {
/** 变更频道名;消息体 = epoch 毫秒,仅作触发信号(内容不参与任何判定) */
public static final String CHANNEL = "gw:route:version";
private static final Logger log = LoggerFactory.getLogger(RouteChangeBroadcaster.class);
@Autowired
private RouteRegistry routeRegistry;
@Autowired
private AppAuthRegistry appAuthRegistry;
/**
* 广播通道走<b>应用主库</b>模板(bean 名 {@code stringRedisTemplate}),而非 appauth starter 的
* 密钥专用 {@code signKeyStringRedisTemplate}(那个直连 db0 专供 {@code sign:app:*})。
* 两者同类型,不显式 {@code @Qualifier} 会因注入歧义启动失败;选错则发布与订阅落在不同 db,
* 广播静默失效(比启动失败更糟——看不出问题)。
*/
@Autowired
@Qualifier("stringRedisTemplate")
private StringRedisTemplate redisTemplate;
private RedisMessageListenerContainer container;
@PostConstruct
void subscribe() {
try {
RedisMessageListenerContainer c = new RedisMessageListenerContainer();
// 订阅连接直接取用上面 template 的连接工厂,保证「发布」与「订阅」必然同库:
// 另注一个 RedisConnectionFactory 会引入同类型多 bean 歧义,且可能被解析到
// appauth 的 db0 工厂上与主库模板不匹配。
c.setConnectionFactory(redisTemplate.getConnectionFactory());
c.addMessageListener(this, new ChannelTopic(CHANNEL));
c.afterPropertiesSet(); // 连接不可用时异步重连,不阻塞启动
container = c;
log.info("[GatewayRoute] 变更广播已订阅 channel={}", CHANNEL);
} catch (RuntimeException e) {
log.warn("[GatewayRoute] 变更广播订阅装配失败,退化为单实例语义(判定不受影响):{}", e.getMessage());
}
}
/** 写库调用方在本地 reload 之后调用;失败不抛出(写操作本身已生效) */
public void publish() {
try {
redisTemplate.convertAndSend(CHANNEL, String.valueOf(System.currentTimeMillis()));
} catch (RuntimeException e) {
log.warn("[GatewayRoute] 变更广播发送失败(本实例已重载,其余实例未同步):{}", e.getMessage());
}
}
@Override
public void onMessage(Message message, byte[] pattern) {
try {
routeRegistry.reload();
routeRegistry.reloadTargets();
appAuthRegistry.reload(); // 应用免签快照与路由/目标同链同步(失败沿用快照,语义同下)
log.info("[GatewayRoute] 收到变更广播,本实例注册表已重载");
} catch (RuntimeException e) {
// 重载失败 = 沿用上一次快照(仍是准确的最严策略,不会放行)
log.warn("[GatewayRoute] 广播触发的重载失败,沿用上一次快照:{}", e.getMessage());
}
}
@Override
public void destroy() {
if (container != null) {
try {
container.destroy(); // DisposableBean 契约:关闭期异常无处上报,吞掉即可
} catch (Exception ignored) {
// 关闭期异常无需处理
}
}
}
}
@@ -1,30 +1,41 @@
package com.yunhai.platform.gateway.route;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 网关内部管理端点(T0.10):/api/internal/** 属网关自身路径,收网后仅容器网内可达,
* 不注册进 open_api_route(注册表只治理 /{target}/oapi/**)。
* <p>
* <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 = "网关治理:路由旋钮 / 目标地址 / 访问台账")
@RestController
@RequestMapping("/api/internal")
public class RouteRefreshController {
@Autowired
private RouteRegistry routeRegistry;
private RegistryReloader reloader;
/** 清缓存重 load,返回加载数(routes = 路由条目,targets = DB 地址行,E11 同端点重载) */
/** 清缓存重 load,返回加载数(routes = 路由条目,targets = DB 地址行,apps = ANON 应用行) */
@Operation(operationId = "GatewayAdmin.refresh", summary = "热重载路由注册表与目标地址")
@PostMapping("/routes/refresh")
public Map<String, Object> refresh() {
Map<String, Object> body = new LinkedHashMap<>();
body.put("loaded", routeRegistry.reload());
body.put("targets", routeRegistry.reloadTargets());
return body;
return reloader.reloadAll();
}
/** 测试/显式装配入口 */
void setReloader(RegistryReloader reloader) {
this.reloader = reloader;
}
}
@@ -33,11 +33,12 @@ public class RouteRegistry {
final String httpMethod;
final RouteSpec spec;
Entry(OpenApiRoute r) {
Entry(String targetCode, OpenApiRoute r) {
this.pattern = r.getPathPattern();
this.httpMethod = r.getHttpMethod();
this.spec = new RouteSpec(r.getVisibility(), r.getAuthMode(), r.getCacheTtlSeconds(),
r.getCacheScope(), r.getRateLimitQps());
this.spec = new RouteSpec(targetCode + " " + r.getPathPattern() + " " + r.getHttpMethod(),
r.getVisibility(), r.getAuthMode(), r.getCacheTtlSeconds(),
r.getCacheScope(), r.getRateLimitQps(), r.getTimeoutMs(), r.getRequestBodyLimitMb());
}
}
@@ -70,7 +71,7 @@ public class RouteRegistry {
if (!r.isEnabled()) {
continue; // 防御双保险:repo 已按 enabled=true 过滤,注册表自身再滤一层
}
fresh.computeIfAbsent(r.getTargetCode(), k -> new ArrayList<>()).add(new Entry(r));
fresh.computeIfAbsent(r.getTargetCode(), k -> new ArrayList<>()).add(new Entry(r.getTargetCode(), r));
count++;
}
cache.invalidateAll();
@@ -1,26 +1,36 @@
package com.yunhai.platform.gateway.route;
/**
* 路由判定结果(T0.10):注册表条目中参与网关判定的五字段提炼。
* 路由判定结果(T0.10):注册表条目中参与网关判定的字段提炼。
* 未命中时返回 {@link #DEFAULT}——默认值安全:忘配 = 最严判定(INTERNAL+SIGNED+零缓存+不限流),
* 不是裸奔。
* <p>
* {@code routeKey}=行身份(限流桶维度,{@code com.yunhai.platform.gateway.filter.RateLimitFilter} 消费);
* {@code timeoutMs}/{@code requestBodyLimitMb}=转发参数(DispatchFilter 消费,&le;0 回落转发器默认)。
*/
public final class RouteSpec {
public static final RouteSpec DEFAULT = new RouteSpec("INTERNAL", "SIGNED", 0, "CALLER", 0);
public static final RouteSpec DEFAULT = new RouteSpec("?", "INTERNAL", "SIGNED", 0, "CALLER", 0, 30000, 10);
/** 命中行的身份键(targetCode + " " + pathPattern + " " + httpMethod);DEFAULT 为 "?"(qps=0 不会进限流桶) */
public final String routeKey;
public final String visibility;
public final String authMode;
public final int cacheTtlSeconds;
public final String cacheScope;
public final int rateLimitQps;
public final int timeoutMs;
public final int requestBodyLimitMb;
public RouteSpec(String visibility, String authMode, int cacheTtlSeconds,
String cacheScope, int rateLimitQps) {
public RouteSpec(String routeKey, String visibility, String authMode, int cacheTtlSeconds,
String cacheScope, int rateLimitQps, int timeoutMs, int requestBodyLimitMb) {
this.routeKey = routeKey;
this.visibility = visibility;
this.authMode = authMode;
this.cacheTtlSeconds = cacheTtlSeconds;
this.cacheScope = cacheScope;
this.rateLimitQps = rateLimitQps;
this.timeoutMs = timeoutMs;
this.requestBodyLimitMb = requestBodyLimitMb;
}
}
@@ -0,0 +1,242 @@
package com.yunhai.platform.gateway.sso;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.gateway.auth.GatewayKeyService;
import com.yunhai.platform.gateway.filter.SignVerifyFilter;
import com.yunhai.platform.utils.crypto.SignUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.RequestBuilder;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.jasig.cas.client.util.AbstractCasFilter;
import org.jasig.cas.client.validation.Assertion;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
/**
* 外观偏好同源读端点 {@code GET /api/appearance}(对接规范 §3 外观契约,网关端实现)。
* <p>
* <b>为什么要有</b>:YschTheme 通道 init 的异步补齐段按「相对本应用 serviceHost 的
* /api/appearance」取服务端真源(manifest.appearance),收敛 yh-theme / yh-avatar /
* yh-logo-url 三键。Notice/svc-hall/flow-center 由各自后端的 AppearanceProxyController 提供,
* 网关按同构形态自建(平台 starter {@code yh-appearance-spring-boot-starter} 尚在
* feat/appearance-sdk 分支未发布,且网关无 YschOpenCall/BoaUser 六件套——规范文档 §7
* 允许"自建同构哑管道,503 铁律不可省")。
* <p>
* <b>链路</b>:浏览器(通道 init,同源带 OPENGW_SESSION)→ 本端点从 CAS 会话取登录名
* (不收前端参数,同 Notice"服务端取登录人"口径)→ 经自身密钥链 appKey 签名后
* <b>回环打网关真实 oapi 链</b>(同 ProxyTestController 形态:Sanitize→路由→验签→转发):
* ①{@code /userbase/oapi/user/byLoginName} 换 userId;②{@code /userhome/oapi/appearance}
* 取生效外观。两腿响应按平台统一信封 {@code {code,msg,data}} 解析(镜像
* {@code OpenApiEnvelope.unwrap} 语义:code≠0=上游业务失败→503;code==0 取 data;
* 无 code 视为已裸数据),②取出的 data【裸 payload 整体透传】,字段演进只在
* UserHome+通道两端,本端零改动。信封坑实录(2026-10-12):裸 HttpClient 不走
* YschOpenCall 的自动拆信封,顶层取 id 恒失败=登录态恒 503(单测裸 JSON stub 未对齐)。
* <p>
* <b>三态失败语义</b>:无 CAS 会话/会话断言缺失 → 401;未配
* {@code yh.gateway.appearance.app-key}(灰度关)或密钥链解析不到、userbase/userhome
* 不可达、user 解析不到、上游非 200、响应非 JSON —— 一律 sendError(503) 直写绕过
* advice/全局异常处理器。init 对任何非 2xx【静默保键】:网关/userhome 故障不会
* 清空浏览器本地观感(503 铁律,Notice 线上事故 0721cf3 教训)。绝不 200+全 null。
* <p>
* <b>信封豁免</b>:返回类型 String + produces=application/json 走 StringHttpMessageConverter,
* {@code ApiResponseAdvice.supports()} 对该 converter 直接跳过——裸 payload 透传不被
* {code:0} 信封二次包装(init 见 code 字段会当异常形态跳过收敛)。
* <p>
* 本路径不在 CAS 过滤器圈(urlPattern=/api/internal/admin/*)内,鉴权由本类自查会话断言
* 完成——CAS 面未装配(开关关)时恒 401,行为与"未登录"一致,无需额外防线。
*/
@Tag(name = "GatewayAppearance", description = "网关治理面外观偏好同源读(通道 init 消费)")
@RestController
public class AppearanceProxyController {
private static final Logger LOG = LoggerFactory.getLogger(AppearanceProxyController.class);
/** open_api_target 注册表中的目标码(与路由表同源,勿写死 host——寻址归注册表热重载) */
static final String TARGET_USERBASE = "userbase";
static final String TARGET_USERHOME = "userhome";
/** 回环两段各自超时:连接 2s / 读 3s——init 总超时 3s,本端点必须更快失败 */
private static final int CONNECT_TIMEOUT_MS = 2_000;
private static final int READ_TIMEOUT_MS = 3_000;
private final ObjectMapper mapper = new ObjectMapper();
@Autowired
private GatewayKeyService keyService;
@Value("${yh.gateway.appearance.app-key:}")
private String appKey;
/** 回环基址覆盖(单测注入本地 HttpServer 用);null = 按请求 localAddr:localPort 推导 */
private String loopbackBaseOverride;
void setLoopbackBase(String base) {
this.loopbackBaseOverride = base;
}
void setKeyService(GatewayKeyService keyService) {
this.keyService = keyService;
}
void setAppKey(String appKey) {
this.appKey = appKey;
}
@Operation(operationId = "GatewayAppearance.appearance", summary = "当前登录人生效外观(裸 payload 透传)")
@GetMapping(value = "/api/appearance", produces = "application/json")
public String appearance(HttpServletRequest req, HttpServletResponse res) throws IOException {
String loginName = principalOf(req);
if (loginName == null || loginName.isEmpty()) {
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "login required");
return null;
}
if (appKey == null || appKey.trim().isEmpty()) {
// 灰度关:不配 app-key 即整组静默(init 401/503 均保键),与 CAS 开关同哲学
res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance proxy disabled");
return null;
}
String secret = keyService.getSecret(appKey.trim()); // fail-closed:未知/禁用/Redis 异常一律 null
if (secret == null) {
LOG.warn("[appearance] 密钥链解析不到 appKey={}(503,init 侧静默不动本地键)", appKey);
res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable");
return null;
}
try {
String base = loopbackBase(req);
JsonNode user = unwrapEnvelope(signedGetJson(base, TARGET_USERBASE, "/oapi/user/byLoginName",
"loginName=" + urlEncode(loginName), appKey.trim(), secret));
if (user == null || user.isNull() || !user.path("id").canConvertToLong()) {
LOG.warn("[appearance] userbase 未解析到登录人 {} 的 userId(503)", loginName);
res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable");
return null;
}
JsonNode payload = unwrapEnvelope(signedGetJson(base, TARGET_USERHOME, "/oapi/appearance",
"userId=" + user.path("id").asLong(), appKey.trim(), secret));
if (payload == null || payload.isNull()) {
LOG.warn("[appearance] userhome 外观 data 为空/业务失败(503,init 侧静默不动本地键)");
res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable");
return null;
}
return mapper.writeValueAsString(payload);
} catch (Exception e) {
// 降级铁律:sendError 直写 503 绕过全局异常处理器(否则被兜成 200 信封,
// init 会把意外形态当脏值跳过——语义上浏览器必须拿到非 2xx)
LOG.warn("[appearance] 上游外观链路不可达(503,init 侧静默不动本地键)login={}:{}",
loginName, e.toString());
res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "appearance unavailable");
return null;
}
}
/** 会话断言里的登录名;无会话/未校验返回 null(同 SsoSessionController.principalOf) */
private static String principalOf(HttpServletRequest req) {
HttpSession session = req.getSession(false);
if (session == null) {
return null;
}
Object attribute = session.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION);
if (!(attribute instanceof Assertion)) {
return null;
}
return ((Assertion) attribute).getPrincipal() == null
? null : ((Assertion) attribute).getPrincipal().getName();
}
/** 回环 GET 并解析为 JSON(非 200/非 JSON 返回 null,fail-closed) */
private JsonNode signedGetJson(String base, String target, String strippedPath, String query,
String key, String secret) throws IOException {
String body = signedGetBody(base, target, strippedPath, query, key, secret);
if (body == null) {
return null;
}
try {
return mapper.readTree(body);
} catch (Exception e) {
return null;
}
}
/**
* 签名回环 GET:canonical 复用 {@link SignVerifyFilter#buildCanonicalTarget}
* (与验签侧同一实现,杜绝双写漂移),时间戳毫秒(F1 契约)。200 返体,其余返 null。
*/
private String signedGetBody(String base, String target, String strippedPath, String query,
String key, String secret) throws IOException {
String pathAndQuery = "/" + target + strippedPath + (query == null || query.isEmpty() ? "" : "?" + query);
String timestamp = String.valueOf(System.currentTimeMillis());
String canonical = SignVerifyFilter.buildCanonicalTarget(strippedPath,
query == null || query.isEmpty() ? null : query);
String signature = SignUtil.signRequest("GET", canonical, timestamp, secret);
try (CloseableHttpClient client = HttpClients.createDefault()) {
RequestBuilder rb = RequestBuilder.get()
.setUri(URI.create(base + pathAndQuery))
.setConfig(RequestConfig.custom()
.setConnectTimeout(CONNECT_TIMEOUT_MS)
.setSocketTimeout(READ_TIMEOUT_MS)
.setConnectionRequestTimeout(CONNECT_TIMEOUT_MS)
.setRedirectsEnabled(false)
.build())
.addHeader("X-App-Key", key)
.addHeader("X-Timestamp", timestamp)
.addHeader("X-Signature", signature);
try (CloseableHttpResponse up = client.execute(rb.build())) {
int status = up.getStatusLine().getStatusCode();
String body = up.getEntity() == null ? "" : EntityUtils.toString(up.getEntity(), StandardCharsets.UTF_8);
return status == 200 ? body : null;
}
}
}
private static String urlEncode(String v) {
try {
return URLEncoder.encode(v, "UTF-8");
} catch (Exception e) {
return v;
}
}
/**
* 信封感知解析,语义镜像平台 SDK {@code OpenApiEnvelope.unwrap}(UserBase/UserHome oapi
* 统一信封:成功={code:0,msg,data};业务失败={code≠0,...} 仍 HTTP 200):code≠0 返 null
* (本端点无业务错误通道,按 503 语义);code==0 取 data(可为 null);无 code 视为
* 已裸数据原样返回。
*/
private static JsonNode unwrapEnvelope(JsonNode root) {
if (root != null && root.isObject() && root.has("code")) {
return root.path("code").asInt(-1) == 0 ? root.get("data") : null;
}
return root;
}
/** 同 ProxyTestController:取本机直打地址(不经 Caddy),天然内部入口口径(一律验签) */
private String loopbackBase(HttpServletRequest req) {
if (loopbackBaseOverride != null) {
return loopbackBaseOverride;
}
String host = req.getLocalAddr();
if (host != null && host.contains(":") && !host.startsWith("[")) {
host = "[" + host + "]";
}
return "http://" + host + ":" + req.getLocalPort();
}
}
@@ -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();
}
}
+45 -2
View File
@@ -1,6 +1,15 @@
server:
# 网关默认端口(projman 注册档案:port=10010;compose 模板端口注入以盘点结论为准)
port: 10010
# 网关端口(注册档案 port=10013;10010 已被 dormnotice 占用。实际端口由平台配置层 env 注入,本值仅本地兜底)
port: 10013
servlet:
# 注意:这里【不】配 context-path——网关对外前缀 /open-gateway 由 Caddy `uri strip_prefix` 剥离,
# 容器侧只见 /api/**、/oapi/**(ctx 必须保持空,见「控制台命名空间 方案 A」)。
session:
cookie:
# 治理面 CAS 会话 cookie 显式改名:默认 JSESSIONID 的 Path=/(ctx 为空)会散到 base 域
# 全部应用,与 Notice 等自带 ctx 应用的 JSESSIONID(Path=/notice) 同名叠放在一个 Cookie 头里。
# Notice 靠 context-path 天然隔离,网关没有 ctx,必须靠名字隔离。
name: OPENGW_SESSION
spring:
application:
@@ -59,3 +68,37 @@ yh:
# notice: http://notice-service:8080
# 观察模式(T1.6):true = 全判定只记不拦(R4:P1 只观察);生产初期保持 true
observe: true
# 外部入口判定的可信来源(T4.3):只有来自这些地址(CIDR,逗号分隔)且带
# X-Entry: external 的请求才按外部入口处理——其余一律按内部口径(一律验签)。
# 留空/不配 = 不存在外部入口(最严;客户端伪造 X-Entry 天然无效)。
# 上线时由平台配置层注入 Caddy 所在网段(Caddy 在本网段向上游打 X-Entry)。
# trusted-proxies:
# - 172.16.0.0/12
# ANON 路由未配 route.rate_limit_qps 时的 per-IP 缺省限额(T3.1 消费)
# anon-per-ip-qps: 10
# 注册表变更广播(多实例同步治理页改动):关掉退化为单实例语义,判定不受影响
# redis-broadcast-enabled: true
# 机器面(/api/internal/** 除浏览器面)的共享内部令牌;值只在应用 env,不进代码不进库。
# 空 = 不校验(灰度窗口态:网关先上、env 后配,期间不打断 projman 的路由同步)。
internal-token: ${OPENGW_INTERNAL_TOKEN:}
appearance:
# 外观偏好同源读端点 /api/appearance(对接规范 §3;AppearanceProxyController):
# 用网关注册在自身密钥链里的 appKey 签名回环 oapi 链(userbase byLoginName → userhome appearance)。
# 空 = 端点恒 503(灰度关,通道 init 静默保键);上线值由平台应用 env 注入。
app-key: ${OPENGW_APPEARANCE_APP_KEY:}
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"
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- 对齐 Notice/UserBase 的平台日志策略:容器内 /app/logs 由 projman 挂到宿主 /data/logs/<app>,
归档日志页直接读宿主目录。不引用 ${LOG_FILE}(projman 注入的 logging.file.path 对本文件无效)。 -->
<configuration>
<!-- 引入 Spring Boot 默认彩色日志变量 -->
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<!-- 控制台:所有环境都有(docker json-file 采集 stdout) -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>${CONSOLE_LOG_CHARSET}</charset>
</encoder>
</appender>
<!-- 文件日志:非 local 环境启用,按天滚动 -->
<springProfile name="!local">
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/app/logs/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>/app/logs/app.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
</encoder>
</appender>
</springProfile>
<root level="INFO">
<appender-ref ref="CONSOLE" />
<springProfile name="!local">
<appender-ref ref="FILE" />
</springProfile>
</root>
</configuration>
@@ -0,0 +1,502 @@
package com.yunhai.platform.gateway.admin;
import com.yunhai.platform.gateway.auth.AppAuthRegistry;
import com.yunhai.platform.gateway.entity.OpenApiApp;
import com.yunhai.platform.gateway.entity.OpenApiLog;
import com.yunhai.platform.gateway.entity.OpenApiRoute;
import com.yunhai.platform.gateway.entity.OpenApiTarget;
import com.yunhai.platform.gateway.repository.OpenApiAppRepository;
import com.yunhai.platform.gateway.repository.OpenApiLogRepository;
import com.yunhai.platform.gateway.repository.OpenApiRouteRepository;
import com.yunhai.platform.gateway.repository.OpenApiTargetRepository;
import com.yunhai.platform.gateway.route.RegistryReloader;
import com.yunhai.platform.gateway.route.RouteRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.time.LocalDateTime;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 治理面后端(N02 TDD):routes/targets/logs 查询形态 + 路由/目标行内编辑的
* 值域闸(非法值不落库)+ 写后热重载调用。MockMvc standalone + mock 仓储/注册表。
* <p>
* <b>契约说明(B3/B4 后)</b>:查询接口返回 {@code BasePageResult}({@code {total, content, ...}}),
* 非法入参返回 <b>HTTP 200</b> + {@code {code:2, msg}}(平台规范)。standalone 模式不装配
* {@code yh-api-std} 的 {@code ApiResponseAdvice},故成功响应在本测试中保持裸结构;
* 线上由 advice 统一再包一层 {@code {code,msg,data}}(分页体落在 {@code data})。
* <p>
* v1 内置 HTML({@code /api/internal/admin/page} + static/admin.html)已退役:
* 治理 UI 独立在 {@code open-gateway/front},故此处不再有用例。
*/
class InternalAdminControllerTest {
private OpenApiRouteRepository routeRepo;
private OpenApiTargetRepository targetRepo;
private OpenApiLogRepository logRepo;
private OpenApiAppRepository appRepo;
private RouteRegistry registry;
private AppAuthRegistry appRegistry;
private MockMvc mvc;
@BeforeEach
void setUp() {
routeRepo = Mockito.mock(OpenApiRouteRepository.class);
targetRepo = Mockito.mock(OpenApiTargetRepository.class);
logRepo = Mockito.mock(OpenApiLogRepository.class);
appRepo = Mockito.mock(OpenApiAppRepository.class);
registry = Mockito.mock(RouteRegistry.class);
appRegistry = Mockito.mock(AppAuthRegistry.class);
InternalAdminController controller = new InternalAdminController();
controller.setRepos(routeRepo, targetRepo, logRepo, registry, appRepo, appRegistry);
// 浏览器面的「重载注册表」入口与机器面共用 RegistryReloader(同一实现的两个口)
controller.setReloader(new RegistryReloader(registry, appRegistry));
mvc = MockMvcBuilders.standaloneSetup(controller).build();
when(routeRepo.findAll(any(Pageable.class)))
.thenReturn(new PageImpl<>(Arrays.asList(route(1L))));
when(targetRepo.findAll(any(Pageable.class)))
.thenReturn(new PageImpl<>(Arrays.asList(target(5L))));
when(appRepo.findAll(any(Pageable.class)))
.thenReturn(new PageImpl<>(Arrays.asList(appRow(7L))));
when(registry.reload()).thenReturn(22);
when(registry.reloadTargets()).thenReturn(13);
when(appRegistry.reload()).thenReturn(2);
when(logRepo.pageLatest(any(), any(), any(Pageable.class)))
.thenReturn(new PageImpl<>(Arrays.asList(logRow())));
}
@Test
void routes_listSorted() throws Exception {
mvc.perform(get("/api/internal/admin/routes"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.total").value(1))
.andExpect(jsonPath("$.pageIndex").value(1))
.andExpect(jsonPath("$.content[0].targetCode").value("userbase"))
.andExpect(jsonPath("$.content[0].pathPattern").value("/oapi/user/**"));
}
@Test
void targets_paged() throws Exception {
mvc.perform(get("/api/internal/admin/targets?pageIndex=1&pageSize=20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.total").value(1))
.andExpect(jsonPath("$.content[0].targetCode").value("notice"));
verify(targetRepo).findAll(Mockito.<Pageable>argThat(p -> p.getPageSize() == 20 && p.getPageNumber() == 0));
}
@Test
void routeUpdate_legalValue_savesAndReloads() throws Exception {
when(routeRepo.findById(1L)).thenReturn(java.util.Optional.of(route(1L)));
mvc.perform(put("/api/internal/admin/routes/1")
.contentType("application/json")
.content("{\"authMode\":\"ANON\",\"cacheTtlSeconds\":30,\"enabled\":true}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.loaded").value(22));
verify(routeRepo).save(any(OpenApiRoute.class));
verify(registry).reload();
}
@Test
void routeUpdate_illegalAuthMode_bizErrorAndNoSave() throws Exception {
when(routeRepo.findById(1L)).thenReturn(java.util.Optional.of(route(1L)));
mvc.perform(put("/api/internal/admin/routes/1")
.contentType("application/json")
.content("{\"authMode\":\"OPEN\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2))
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("authMode")));
verify(routeRepo, never()).save(any());
verify(registry, never()).reload();
}
@Test
void routeUpdate_negativeTtl_bizError() throws Exception {
when(routeRepo.findById(1L)).thenReturn(java.util.Optional.of(route(1L)));
mvc.perform(put("/api/internal/admin/routes/1")
.contentType("application/json")
.content("{\"cacheTtlSeconds\":-1}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2));
verify(routeRepo, never()).save(any());
}
/** 能力补全 3:timeout/bodyLimit 越界不落库(D6:timeout ∈ [1000,120000],bodyLimit ∈ [1,100]) */
@Test
void routeUpdate_timeoutOrBodyLimitOutOfRange_bizError() throws Exception {
when(routeRepo.findById(1L)).thenReturn(java.util.Optional.of(route(1L)));
String[] illegal = new String[]{
"{\"timeoutMs\":999}", "{\"timeoutMs\":120001}",
"{\"requestBodyLimitMb\":0}", "{\"requestBodyLimitMb\":101}"};
for (String body : illegal) {
mvc.perform(put("/api/internal/admin/routes/1")
.contentType("application/json")
.content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2));
}
verify(routeRepo, never()).save(any());
verify(registry, never()).reload();
}
@Test
void routeUpdate_legalTimeoutAndBodyLimit_savesAndReloads() throws Exception {
when(routeRepo.findById(1L)).thenReturn(java.util.Optional.of(route(1L)));
mvc.perform(put("/api/internal/admin/routes/1")
.contentType("application/json")
.content("{\"timeoutMs\":1000,\"requestBodyLimitMb\":100}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.loaded").value(22));
ArgumentCaptor<OpenApiRoute> saved = ArgumentCaptor.forClass(OpenApiRoute.class);
verify(routeRepo).save(saved.capture());
assertThat(saved.getValue().getTimeoutMs()).isEqualTo(1000);
assertThat(saved.getValue().getRequestBodyLimitMb()).isEqualTo(100);
verify(registry).reload();
}
@Test
void routeUpdate_missingRow_bizError() throws Exception {
when(routeRepo.findById(99L)).thenReturn(java.util.Optional.empty());
mvc.perform(put("/api/internal/admin/routes/99")
.contentType("application/json")
.content("{\"enabled\":false}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2));
}
@Test
void targetUpdate_savesAndReloadsTargets() throws Exception {
when(targetRepo.findById(5L)).thenReturn(java.util.Optional.of(target(5L)));
mvc.perform(put("/api/internal/admin/targets/5")
.contentType("application/json")
.content("{\"baseUrl\":\"http://notice-service:10002\",\"enabled\":true}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.targets").value(13));
verify(targetRepo).save(any(OpenApiTarget.class));
verify(registry).reloadTargets();
}
/**
* 控制台「重载注册表」按钮走浏览器面入口,且与机器面 {@code /api/internal/routes/refresh}
* 共用同一实现(返回体三个键形态一致)。此前该按钮直接打机器面路径,令牌收口后会被 401 拦死。
*/
@Test
void consoleReload_onBrowserSurface_usesSharedReloader() throws Exception {
when(registry.reloadTargets()).thenReturn(4);
when(appRegistry.reload()).thenReturn(1);
mvc.perform(post("/api/internal/admin/routes/reload"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.loaded").value(22))
.andExpect(jsonPath("$.targets").value(4))
.andExpect(jsonPath("$.apps").value(1));
verify(registry).reload();
verify(registry).reloadTargets();
verify(appRegistry).reload();
}
@Test
void targetUpdate_blankBaseUrl_bizErrorNoSave() throws Exception {
when(targetRepo.findById(5L)).thenReturn(java.util.Optional.of(target(5L)));
mvc.perform(put("/api/internal/admin/targets/5")
.contentType("application/json")
.content("{\"baseUrl\":\" \"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2));
verify(targetRepo, never()).save(any());
}
@Test
void logs_defaultLimitAndFilters() throws Exception {
mvc.perform(get("/api/internal/admin/logs?targetCode=notice&callerAppKey=100001&limit=10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.total").value(1))
.andExpect(jsonPath("$.content[0].targetCode").value("notice"));
verify(logRepo).pageLatest(eq("notice"), eq("100001"), any(Pageable.class));
}
@Test
void logs_pageIndexAndSize() throws Exception {
mvc.perform(get("/api/internal/admin/logs?pageIndex=2&pageSize=20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.total").value(1));
verify(logRepo).pageLatest(any(), any(), Mockito.argThat(p -> p.getPageSize() == 20 && p.getPageNumber() == 1));
}
@Test
void logs_overLimitCappedTo500() throws Exception {
mvc.perform(get("/api/internal/admin/logs?limit=9999"))
.andExpect(status().isOk());
verify(logRepo).pageLatest(any(), any(), Mockito.argThat(p -> p.getPageSize() == 500));
}
private static OpenApiRoute route(Long id) {
OpenApiRoute r = new OpenApiRoute();
r.setId(id);
r.setTargetCode("userbase");
r.setPathPattern("/oapi/user/**");
r.setHttpMethod("*");
r.setVisibility("INTERNAL");
r.setAuthMode("SIGNED");
r.setCacheTtlSeconds(0);
r.setCacheScope("CALLER");
r.setRateLimitQps(0);
r.setEnabled(true);
return r;
}
private static OpenApiTarget target(Long id) {
OpenApiTarget t = new OpenApiTarget();
t.setId(id);
t.setTargetCode("notice");
t.setBaseUrl("http://notice-service:10002");
t.setEnabled(true);
return t;
}
private static OpenApiLog logRow() {
OpenApiLog l = new OpenApiLog();
l.setId(100L);
l.setTargetCode("notice");
l.setMethod("GET");
l.setPath("/oapi/notice/getDict");
l.setStatus(200);
l.setCallerAppKey("100001");
l.setCostMs(5);
return l;
}
private static OpenApiApp appRow(Long id) {
OpenApiApp a = new OpenApiApp();
a.setId(id);
a.setAppKey("100009");
a.setAppName("冒烟-免签");
a.setAuthMode("ANON");
a.setExpireAt(LocalDateTime.now().plusDays(7));
a.setEnabled(true);
return a;
}
/* ============ 应用治理接口(C 方向 G12-9)============ */
@Test
void apps_listSorted() throws Exception {
mvc.perform(get("/api/internal/admin/apps"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.total").value(1))
.andExpect(jsonPath("$.content[0].appKey").value("100009"))
.andExpect(jsonPath("$.content[0].authMode").value("ANON"));
}
@Test
void appCreate_anonWithLegalExpire_savesAndReloads() throws Exception {
when(appRepo.existsByAppKey("100018")).thenReturn(false);
String expire = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.format(LocalDateTime.now().plusDays(7));
mvc.perform(post("/api/internal/admin/apps")
.contentType("application/json")
.content("{\"appKey\":\"100018\",\"appName\":\"新应用\",\"authMode\":\"ANON\",\"expireAt\":\"" + expire + "\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.apps").value(2));
ArgumentCaptor<OpenApiApp> cap = ArgumentCaptor.forClass(OpenApiApp.class);
verify(appRepo).save(cap.capture());
assertThat(cap.getValue().getExpireAt()).isNotNull();
assertThat(cap.getValue().getGmtCreate()).as("gmt 手填").isNotNull();
verify(appRegistry).reload();
}
@Test
void appCreate_signedMode_expireCleared() throws Exception {
when(appRepo.existsByAppKey("100018")).thenReturn(false);
mvc.perform(post("/api/internal/admin/apps")
.contentType("application/json")
.content("{\"appKey\":\"100018\",\"authMode\":\"SIGNED\"}"))
.andExpect(status().isOk());
ArgumentCaptor<OpenApiApp> cap = ArgumentCaptor.forClass(OpenApiApp.class);
verify(appRepo).save(cap.capture());
assertThat(cap.getValue().getExpireAt()).as("SIGNED 行不设到期").isNull();
}
@Test
void appCreate_duplicateAppKey_bizErrorNoSave() throws Exception {
when(appRepo.existsByAppKey("100009")).thenReturn(true);
mvc.perform(post("/api/internal/admin/apps")
.contentType("application/json")
.content("{\"appKey\":\"100009\",\"authMode\":\"SIGNED\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2))
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("已存在")));
verify(appRepo, never()).save(any());
verify(appRegistry, never()).reload();
}
@Test
void appCreate_duplicateWindowRow_bizError() throws Exception {
// '*' 匿名窗口行全局至多一行
when(appRepo.existsByAppKey("*")).thenReturn(true);
mvc.perform(post("/api/internal/admin/apps")
.contentType("application/json")
.content("{\"appKey\":\"*\",\"authMode\":\"ANON\",\"expireAt\":\"2099-01-01 00:00:00\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2))
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("匿名窗口")));
verify(appRepo, never()).save(any());
}
@Test
void appCreate_blankOrOverlongAppKey_bizError() throws Exception {
mvc.perform(post("/api/internal/admin/apps")
.contentType("application/json")
.content("{\"appKey\":\" \",\"authMode\":\"SIGNED\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2));
mvc.perform(post("/api/internal/admin/apps")
.contentType("application/json")
.content("{\"appKey\":\"" + new String(new char[65]).replace('\0', 'k') + "\",\"authMode\":\"SIGNED\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2));
verify(appRepo, never()).save(any());
}
@Test
void appCreate_illegalAuthMode_bizError() throws Exception {
mvc.perform(post("/api/internal/admin/apps")
.contentType("application/json")
.content("{\"appKey\":\"100018\",\"authMode\":\"OPEN\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2))
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("authMode")));
verify(appRepo, never()).save(any());
}
@Test
void appCreate_anonMissingExpire_bizError() throws Exception {
when(appRepo.existsByAppKey("100018")).thenReturn(false);
mvc.perform(post("/api/internal/admin/apps")
.contentType("application/json")
.content("{\"appKey\":\"100018\",\"authMode\":\"ANON\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2))
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("expireAt")));
verify(appRepo, never()).save(any());
}
@Test
void appCreate_expireOver31Days_bizError() throws Exception {
when(appRepo.existsByAppKey("100018")).thenReturn(false);
String expire = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.format(LocalDateTime.now().plusDays(32));
mvc.perform(post("/api/internal/admin/apps")
.contentType("application/json")
.content("{\"appKey\":\"100018\",\"authMode\":\"ANON\",\"expireAt\":\"" + expire + "\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2))
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("31")));
verify(appRepo, never()).save(any());
}
@Test
void appCreate_expireInPast_bizError() throws Exception {
when(appRepo.existsByAppKey("100018")).thenReturn(false);
String expire = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.format(LocalDateTime.now().minusMinutes(1));
mvc.perform(post("/api/internal/admin/apps")
.contentType("application/json")
.content("{\"appKey\":\"100018\",\"authMode\":\"ANON\",\"expireAt\":\"" + expire + "\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2))
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("晚于")));
verify(appRepo, never()).save(any());
}
@Test
void appUpdate_appKeyInBody_rejected() throws Exception {
when(appRepo.findById(7L)).thenReturn(java.util.Optional.of(appRow(7L)));
mvc.perform(put("/api/internal/admin/apps/7")
.contentType("application/json")
.content("{\"appKey\":\"100999\",\"remark\":\"x\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2))
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("appKey 不可改")));
verify(appRepo, never()).save(any());
}
@Test
void appUpdate_switchToAnonWithoutExpire_bizError() throws Exception {
OpenApiApp signed = appRow(7L);
signed.setAuthMode("SIGNED");
signed.setExpireAt(null);
when(appRepo.findById(7L)).thenReturn(java.util.Optional.of(signed));
mvc.perform(put("/api/internal/admin/apps/7")
.contentType("application/json")
.content("{\"authMode\":\"ANON\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2))
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("expireAt")));
verify(appRepo, never()).save(any());
}
@Test
void appUpdate_switchBackToSigned_expireCleared() throws Exception {
OpenApiApp anon = appRow(7L);
when(appRepo.findById(7L)).thenReturn(java.util.Optional.of(anon));
mvc.perform(put("/api/internal/admin/apps/7")
.contentType("application/json")
.content("{\"authMode\":\"SIGNED\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.apps").value(2));
verify(appRepo).save(anon);
assertThat(anon.getExpireAt()).as("切回 SIGNED 清 expire_at").isNull();
verify(appRegistry).reload();
}
@Test
void appUpdate_anonRowRenewalWithExpire_restartsWindow() throws Exception {
OpenApiApp anon = appRow(7L);
when(appRepo.findById(7L)).thenReturn(java.util.Optional.of(anon));
String expire = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.format(LocalDateTime.now().plusDays(30));
mvc.perform(put("/api/internal/admin/apps/7")
.contentType("application/json")
.content("{\"expireAt\":\"" + expire + "\"}"))
.andExpect(status().isOk());
ArgumentCaptor<OpenApiApp> cap = ArgumentCaptor.forClass(OpenApiApp.class);
verify(appRepo).save(cap.capture());
assertThat(cap.getValue().getExpireAt()).as("续期=重新起算").isAfter(LocalDateTime.now().plusDays(29));
}
@Test
void appUpdate_anonRowPlainEdit_keepsExpire() throws Exception {
// 既有 ANON 行只改备注/启停:不强制重传 expireAt,保留原到期
OpenApiApp anon = appRow(7L);
when(appRepo.findById(7L)).thenReturn(java.util.Optional.of(anon));
mvc.perform(put("/api/internal/admin/apps/7")
.contentType("application/json")
.content("{\"remark\":\"改备注\",\"enabled\":false}"))
.andExpect(status().isOk());
verify(appRepo).save(anon);
assertThat(anon.getRemark()).isEqualTo("改备注");
assertThat(anon.isEnabled()).isFalse();
assertThat(anon.getExpireAt()).as("原到期保留").isNotNull();
}
}
@@ -0,0 +1,208 @@
package com.yunhai.platform.gateway.admin;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import com.yunhai.platform.gateway.auth.GatewayKeyService;
import com.yunhai.platform.utils.crypto.SignUtil;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 「代理测试」端点(N 系治理小页):回环打真实链的核心契约——
* <ul>
* <li>URL 容忍式解析与形状闸(/{target}/oapi/**,第二段必须恰为 oapi);</li>
* <li>容错编码:空格/中文百分号编码、既有 %xx 原样保留;</li>
* <li>签名可被平台 {@link SignUtil#verifyRequestWithExpire} 验回(探针与验签同一 canonical,
* query 排序口径共享);</li>
* <li>密钥两来源(INPUT 页面明传 / REDIS 密钥链解析 fail-closed);</li>
* <li>POST body 透传与非法入参 HTTP 200 + {code:2}。</li>
* </ul>
* 回环目标用 JDK 内置 HttpServer 冒充(单测无真实网关端口);standalone MockMvc 不装 advice,
* 成功响应为裸 Map(线上由 ApiResponseAdvice 包 {code,msg,data})。
*/
class ProxyTestControllerTest {
/** 与 SignUtil.checkSecret 对齐:≥16 位 */
private static final String SECRET = "probe-secret-0123456789";
private HttpServer server;
private String loopbackBase;
private final AtomicReference<String> capturedMethod = new AtomicReference<>();
private final AtomicReference<String> capturedUri = new AtomicReference<>();
private final AtomicReference<byte[]> capturedBody = new AtomicReference<>();
private final Map<String, String> capturedHeaders = new HashMap<>();
private GatewayKeyService keyService;
private ProxyTestController controller;
private MockMvc mvc;
@BeforeEach
void setUp() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", new CapturingHandler());
server.start();
loopbackBase = "http://127.0.0.1:" + server.getAddress().getPort();
keyService = Mockito.mock(GatewayKeyService.class);
controller = new ProxyTestController();
controller.setKeyService(keyService);
controller.setLoopbackBase(loopbackBase);
mvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@AfterEach
void tearDown() {
server.stop(0);
}
private class CapturingHandler implements HttpHandler {
@Override
public void handle(HttpExchange ex) throws IOException {
capturedMethod.set(ex.getRequestMethod());
capturedUri.set(ex.getRequestURI().toString());
// JDK Headers 会按自身规则归一化键大小写,这里统一小写存取避免猜测归一形
ex.getRequestHeaders().forEach((k, vs) -> capturedHeaders.put(k.toLowerCase(), String.join(",", vs)));
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (InputStream in = ex.getRequestBody()) {
byte[] buf = new byte[1024];
int n;
while ((n = in.read(buf)) >= 0) {
out.write(buf, 0, n);
}
}
capturedBody.set(out.toByteArray());
byte[] resp = "{\"code\":0,\"msg\":\"\",\"data\":{\"ok\":true}}".getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "application/json");
ex.sendResponseHeaders(200, resp.length);
try (OutputStream os = ex.getResponseBody()) {
os.write(resp);
}
}
}
@Test
void parse_shapeGate() {
// 合法:带 scheme + 带无 scheme 两种贴法
ProxyTestController.Parsed p = ProxyTestController.parse("http://gw.example.com/userbase/oapi/user/byId?id=1");
assertEquals("userbase", p.target);
assertEquals("/userbase/oapi/user/byId", p.rawPath);
assertEquals("id=1", p.rawQuery);
p = ProxyTestController.parse("notice/oapi/notice/getDict");
assertEquals("notice", p.target);
// 非法:无路径 / 单段 / 第二段非 oapi
assertThrows(IllegalArgumentException.class, () -> ProxyTestController.parse("http://gw.example.com"));
assertThrows(IllegalArgumentException.class, () -> ProxyTestController.parse("/userbase"));
assertThrows(IllegalArgumentException.class, () -> ProxyTestController.parse("/userbase/api/user/list"));
}
@Test
void lenientEncode_keepsLegalAndEncodesRaw() {
String enc = ProxyTestController.lenientUriEncode("/userbase/oapi/user/byId", "id=1&kw=中 文&keep=%2F");
assertEquals("/userbase/oapi/user/byId?id=1&kw=%E4%B8%AD%20%E6%96%87&keep=%2F", enc);
}
@Test
void proxyTest_signatureVerifiesAndLoopbackReceivesIt() throws Exception {
mvc.perform(post("/api/internal/admin/proxy-test")
.contentType("application/json")
.content("{\"url\":\"http://gw.example.com/userbase/oapi/user/byId?id=1&kw=中文\","
+ "\"appKey\":\"100001\",\"appSecret\":\"" + SECRET + "\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(200))
.andExpect(jsonPath("$.target").value("userbase"))
.andExpect(jsonPath("$.signature.appKey").value("100001"))
.andExpect(jsonPath("$.signature.secretSource").value("INPUT"))
.andExpect(jsonPath("$.signature.canonical").value("/oapi/user/byId?id=1&kw=中文"))
.andExpect(jsonPath("$.body").value("{\"code\":0,\"msg\":\"\",\"data\":{\"ok\":true}}"));
// 回环侧收到:编码后的 query、三个签名头;body 为空(GET)
assertEquals("/userbase/oapi/user/byId?id=1&kw=%E4%B8%AD%E6%96%87", capturedUri.get());
assertEquals("GET", capturedMethod.get());
assertEquals("100001", capturedHeaders.get("x-app-key"));
String ts = capturedHeaders.get("x-timestamp");
String sign = capturedHeaders.get("x-signature");
assertTrue(ts != null && ts.length() >= 13, "时间戳须为毫秒级");
// 核心断言:探针签名能被平台验签实现验回(canonical 含 query 解码排序)
assertTrue(SignUtil.verifyRequestWithExpire("GET",
"/oapi/user/byId?id=1&kw=中文", ts, SECRET, sign));
}
@Test
void proxyTest_secretFromRedisResolver() throws Exception {
when(keyService.getSecret(eq("100007"))).thenReturn(SECRET);
mvc.perform(post("/api/internal/admin/proxy-test")
.contentType("application/json")
.content("{\"url\":\"/notice/oapi/notice/getDict\",\"appKey\":\"100007\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(200))
.andExpect(jsonPath("$.signature.secretSource").value("REDIS"));
}
@Test
void proxyTest_secretUnresolvableFailsClosed() throws Exception {
when(keyService.getSecret(eq("999999"))).thenReturn(null);
mvc.perform(post("/api/internal/admin/proxy-test")
.contentType("application/json")
.content("{\"url\":\"/notice/oapi/notice/getDict\",\"appKey\":\"999999\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2));
}
@Test
void proxyTest_postBodyForwarded() throws Exception {
mvc.perform(post("/api/internal/admin/proxy-test")
.contentType("application/json")
.content("{\"url\":\"/flow-center/oapi/flow/apply\",\"method\":\"POST\","
+ "\"appKey\":\"100001\",\"appSecret\":\"" + SECRET + "\","
+ "\"body\":\"{\\\"flowId\\\":9}\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(200));
assertEquals("POST", capturedMethod.get());
assertEquals("{\"flowId\":9}", new String(capturedBody.get(), StandardCharsets.UTF_8));
assertTrue(capturedHeaders.get("content-type").startsWith("application/json"),
"实际 content-type=" + capturedHeaders.get("content-type"));
}
@Test
void proxyTest_badPathRejected() throws Exception {
mvc.perform(post("/api/internal/admin/proxy-test")
.contentType("application/json")
.content("{\"url\":\"/userbase/api/user/list\",\"appKey\":\"100001\",\"appSecret\":\"" + SECRET + "\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2));
}
@Test
void proxyTest_shortSecretClearError() throws Exception {
mvc.perform(post("/api/internal/admin/proxy-test")
.contentType("application/json")
.content("{\"url\":\"/notice/oapi/notice/getDict\",\"appKey\":\"100001\",\"appSecret\":\"short\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(2));
}
}
@@ -0,0 +1,164 @@
package com.yunhai.platform.gateway.auth;
import com.yunhai.platform.gateway.entity.OpenApiApp;
import com.yunhai.platform.gateway.filter.SignVerifyFilter;
import com.yunhai.platform.gateway.repository.OpenApiAppRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 应用免签注册表(C 方向 G12-8):快照只收 enabled=true 且 authMode=ANON 的行;
* {@code '*'} 哨兵行只折算成匿名窗口时刻、不进 appKey 映射(请求头字面 {@code X-App-Key: *}
* 不会命中具体应用);到期判定惰性(实时比对 now)——过期行即刻失效、null 到期 fail-closed。
*/
class AppAuthRegistryTest {
private OpenApiAppRepository repo;
private AppAuthRegistry registry;
@BeforeEach
void setUp() {
repo = mock(OpenApiAppRepository.class);
registry = new AppAuthRegistry();
org.springframework.test.util.ReflectionTestUtils.setField(registry, "appRepo", repo);
}
private static OpenApiApp app(String appKey, String authMode, boolean enabled, LocalDateTime expireAt) {
OpenApiApp a = new OpenApiApp();
a.setAppKey(appKey);
a.setAuthMode(authMode);
a.setEnabled(enabled);
a.setExpireAt(expireAt);
return a;
}
@Test
void 未过期ANON行命中返回APP_ANON() {
when(repo.findByEnabledTrue()).thenReturn(Arrays.asList(
app("100009", "ANON", true, LocalDateTime.now().plusDays(7))));
registry.reload();
assertThat(registry.admit("100009")).isEqualTo(SignVerifyFilter.VERDICT_APP_ANON);
assertThat(registry.admit(" 100009 ")).as("头值两侧空白容忍").isEqualTo(SignVerifyFilter.VERDICT_APP_ANON);
}
@Test
void 禁用行与SIGNED行不入快照() {
when(repo.findByEnabledTrue()).thenReturn(Arrays.asList(
app("100009", "ANON", false, LocalDateTime.now().plusDays(7)), // 禁用(repo 已滤,双保险再滤)
app("100018", "SIGNED", true, LocalDateTime.now().plusDays(7)))); // SIGNED 永不免签
int loaded = registry.reload();
assertThat(loaded).as("只装具体 ANON key 行").isZero();
assertThat(registry.admit("100009")).as("禁用 ANON 行不命中").isNull();
assertThat(registry.admit("100018")).as("SIGNED 行不命中").isNull();
}
@Test
void 已过期行即刻失效_惰性不查库() {
// 行数据仍在快照里,但 expireAt 已过——admit 实时比对 now,即刻不再命中
when(repo.findByEnabledTrue()).thenReturn(Arrays.asList(
app("100009", "ANON", true, LocalDateTime.now().minusSeconds(1))));
registry.reload();
assertThat(registry.admit("100009")).as("过期 = 等价无行,落回验签").isNull();
}
@Test
void 无到期时刻的ANON行failClosed不装载() {
// API 不允许产生,SQL 手插的坏行也放不出
when(repo.findByEnabledTrue()).thenReturn(Collections.singletonList(
app("100009", "ANON", true, null)));
registry.reload();
assertThat(registry.admit("100009")).isNull();
}
@Test
void 匿名窗口行放行无票据请求_且不进appKey映射() {
when(repo.findByEnabledTrue()).thenReturn(Arrays.asList(
app(AppAuthRegistry.WINDOW_APP_KEY, "ANON", true, LocalDateTime.now().plusDays(1))));
int loaded = registry.reload();
assertThat(loaded).as("'*' 行不计入具体 key 行数").isZero();
assertThat(registry.admit(null)).as("无 X-App-Key → 窗口放行").isEqualTo(SignVerifyFilter.VERDICT_ANON);
assertThat(registry.admit("ghost")).as("带未登记 key 亦落窗口(窗口=内部入口全部无票据请求)")
.isEqualTo(SignVerifyFilter.VERDICT_ANON);
assertThat(registry.admit(AppAuthRegistry.WINDOW_APP_KEY))
.as("字面 X-App-Key: * 不是具体应用,按无 key 走窗口").isEqualTo(SignVerifyFilter.VERDICT_ANON);
}
@Test
void 窗口未开时字面星号头不命中任何应用() {
when(repo.findByEnabledTrue()).thenReturn(Collections.emptyList());
registry.reload();
assertThat(registry.admit(AppAuthRegistry.WINDOW_APP_KEY)).as("无窗口无应用 → 验签").isNull();
}
@Test
void 窗口过期后无票据请求落回验签() {
when(repo.findByEnabledTrue()).thenReturn(Collections.singletonList(
app(AppAuthRegistry.WINDOW_APP_KEY, "ANON", true, LocalDateTime.now().minusMinutes(1))));
registry.reload();
assertThat(registry.admit(null)).as("窗口过期 = 惰性关闭").isNull();
}
@Test
void 具体key命中优先于窗口_但未登记key只落窗口() {
LocalDateTime future = LocalDateTime.now().plusDays(5);
when(repo.findByEnabledTrue()).thenReturn(Arrays.asList(
app("100009", "ANON", true, future),
app(AppAuthRegistry.WINDOW_APP_KEY, "ANON", true, LocalDateTime.now().plusDays(1))));
registry.reload();
assertThat(registry.admit("100009")).as("具体 key 免签归因 appKey")
.isEqualTo(SignVerifyFilter.VERDICT_APP_ANON);
}
@Test
void 空白头按无key处理只看窗口() {
when(repo.findByEnabledTrue()).thenReturn(Collections.emptyList());
registry.reload();
assertThat(registry.admit("")).isNull();
assertThat(registry.admit(" ")).isNull();
}
@Test
void reload失败沿用上一次快照() {
when(repo.findByEnabledTrue()).thenReturn(Collections.singletonList(
app("100009", "ANON", true, LocalDateTime.now().plusDays(7))));
registry.reload();
assertThat(registry.admit("100009")).isNotNull();
when(repo.findByEnabledTrue()).thenThrow(new RuntimeException("db down"));
try {
registry.reload();
} catch (RuntimeException expected) {
// 广播链由调用方 catch;本用例只验证异常后的快照仍可用
}
assertThat(registry.admit("100009")).as("异常未替换快照(stale 但准确)")
.isEqualTo(SignVerifyFilter.VERDICT_APP_ANON);
}
@Test
void 空表快照为全量验签() {
when(repo.findByEnabledTrue()).thenReturn(new ArrayList<>());
registry.reload();
assertThat(registry.admit("100009")).isNull();
assertThat(registry.admit(null)).isNull();
}
}
@@ -0,0 +1,94 @@
package com.yunhai.platform.gateway.auth;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 入口判定用例(T4.3):外部入口 = 来源可信(CIDR 命中)<b>且</b> 带 X-Entry: external。
* 覆盖 CIDR 边界、映射地址形态、非法/空缺配置的 fail-closed 行为——伪造 X-Entry(来源不可信)
* 必须无效,这是"客户端不能自封为外部入口"的唯一保证(A6)。
*/
class EntryDetectorTest {
private static final List<String> DOCKER_NET = Arrays.asList("172.16.0.0/12");
private static MockHttpServletRequest request(String remoteAddr, String entryHeader) {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/userbase/oapi/x");
req.setRemoteAddr(remoteAddr);
if (entryHeader != null) {
req.addHeader(EntryDetector.HEADER_ENTRY, entryHeader);
}
return req;
}
@Test
void 可信来源带标记_判定外部入口() {
assertThat(EntryDetector.isExternal(request("172.18.0.2", "external"), DOCKER_NET)).isTrue();
}
@Test
void 标记大小写与首尾空白不敏感() {
assertThat(EntryDetector.isExternal(request("172.18.0.2", " EXTERNAL "), DOCKER_NET)).isTrue();
assertThat(EntryDetector.isExternal(request("172.18.0.2", "External"), DOCKER_NET)).isTrue();
}
@Test
void 来源不可信_伪造标记无效() {
// 公网来源自带 X-Entry: external —— 必须仍按内部入口处理
assertThat(EntryDetector.isExternal(request("203.0.113.9", "external"), DOCKER_NET)).isFalse();
}
@Test
void 无标记或有其他标记_仍为内部入口() {
assertThat(EntryDetector.isExternal(request("172.18.0.2", null), DOCKER_NET)).isFalse();
assertThat(EntryDetector.isExternal(request("172.18.0.2", "internal"), DOCKER_NET)).isFalse();
}
@Test
void 信任列表为空_不存在外部入口() {
assertThat(EntryDetector.isExternal(request("172.18.0.2", "external"), new ArrayList<>())).isFalse();
assertThat(EntryDetector.isExternal(request("172.18.0.2", "external"), null)).isFalse();
}
@Test
void cidr边界_网段内命中网段外不命中() {
assertThat(EntryDetector.inAnyCidr("172.16.0.0", DOCKER_NET)).as("起始地址").isTrue();
assertThat(EntryDetector.inAnyCidr("172.31.255.255", DOCKER_NET)).as("末地址").isTrue();
assertThat(EntryDetector.inAnyCidr("172.15.255.255", DOCKER_NET)).as("前一位").isFalse();
assertThat(EntryDetector.inAnyCidr("172.32.0.1", DOCKER_NET)).as("后一位").isFalse();
}
@Test
void 精确地址与全匹配与斜零前缀() {
assertThat(EntryDetector.inAnyCidr("10.0.0.7", Arrays.asList("10.0.0.7"))).as("无 / 按精确地址").isTrue();
assertThat(EntryDetector.inAnyCidr("10.0.0.8", Arrays.asList("10.0.0.7"))).isFalse();
assertThat(EntryDetector.inAnyCidr("8.8.8.8", Arrays.asList("0.0.0.0/0"))).as("/0 全通").isTrue();
assertThat(EntryDetector.inAnyCidr("10.1.2.3", Arrays.asList("10.0.0.0/8", "10.1.0.0/16"))).as("多值命中一项即可").isTrue();
}
@Test
void 列表中非法项不影响合法项命中() {
assertThat(EntryDetector.inAnyCidr("172.18.0.2", Arrays.asList("bogus", "172.16.0.0/12"))).isTrue();
}
@Test
void 映射地址形态按IPv4匹配() {
assertThat(EntryDetector.inAnyCidr("::ffff:172.18.0.2", DOCKER_NET)).isTrue();
}
@Test
void 非法配置与IPv6一律不可信() {
assertThat(EntryDetector.inAnyCidr("172.18.0.2", Arrays.asList("172.16.0.0/33"))).as("前缀越界").isFalse();
assertThat(EntryDetector.inAnyCidr("172.18.0.2", Arrays.asList("172.16.0.0/abc"))).as("前缀非数字").isFalse();
assertThat(EntryDetector.inAnyCidr("172.18.0.2", Arrays.asList("172.16.0"))).as("非法网络段").isFalse();
assertThat(EntryDetector.inAnyCidr("172.18.0.2", Arrays.asList(" "))).as("空白项").isFalse();
assertThat(EntryDetector.inAnyCidr("fe80::1", Arrays.asList("::/0"))).as("纯 IPv6 不支持").isFalse();
assertThat(EntryDetector.inAnyCidr(null, DOCKER_NET)).isFalse();
}
}
@@ -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,65 @@
package com.yunhai.platform.gateway.auth;
import com.github.benmanes.caffeine.cache.Cache;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* SignedRateLimiter 单测(能力补全 2 TDD):qps 语义(0=不限/超限/惰性补充/热调截断)、
* 桶按 key 隔离、fail-open(D1)。纯单测,无 Spring 上下文。
*/
class SignedRateLimiterTest {
private final SignedRateLimiter limiter = new SignedRateLimiter();
@Test
void qps为0或负_恒放行() {
assertThat(limiter.tryAcquire("k1", 0)).isTrue();
assertThat(limiter.tryAcquire("k1", 0)).isTrue();
assertThat(limiter.tryAcquire("k1", -5)).isTrue();
}
@Test
void qps为2_连续第3次拒绝() {
assertThat(limiter.tryAcquire("k2", 2)).isTrue();
assertThat(limiter.tryAcquire("k2", 2)).isTrue();
assertThat(limiter.tryAcquire("k2", 2)).isFalse();
}
@Test
void 拒绝后按qps惰性补充_可恢复() throws InterruptedException {
assertThat(limiter.tryAcquire("k3", 1)).isTrue();
assertThat(limiter.tryAcquire("k3", 1)).isFalse();
Thread.sleep(1100); // qps=1 → 1 秒补 1 个令牌
assertThat(limiter.tryAcquire("k3", 1)).as("惰性补充后恢复").isTrue();
}
@Test
void qps调小_存量令牌截断到新容量() {
assertThat(limiter.tryAcquire("k4", 100)).isTrue(); // 新桶满容 = qps(1 秒突发)
// 同一桶改按 qps=1 判定(治理页热调,D7):存量令牌须截断,不得沿用旧容量连放
assertThat(limiter.tryAcquire("k4", 1)).isTrue();
assertThat(limiter.tryAcquire("k4", 1)).isFalse();
}
@Test
void 不同key桶各自独立() {
assertThat(limiter.tryAcquire("appA@demo /oapi/x GET", 1)).isTrue();
assertThat(limiter.tryAcquire("appB@demo /oapi/x GET", 1)).as("不同调用方互不挤占").isTrue();
assertThat(limiter.tryAcquire("appA@demo /oapi/x GET", 1)).isFalse();
}
@Test
void 限流器内部异常_failOpen放行() {
@SuppressWarnings({"unchecked", "rawtypes"})
Cache broken = mock(Cache.class);
when(broken.get(any(), any())).thenThrow(new IllegalStateException("boom"));
ReflectionTestUtils.setField(limiter, "buckets", broken);
assertThat(limiter.tryAcquire("k5", 10)).as("fail-open(D1):限流器故障不断主链路").isTrue();
}
}
@@ -0,0 +1,103 @@
package com.yunhai.platform.gateway.filter;
import com.yunhai.platform.gateway.forward.StreamingForwarder;
import com.yunhai.platform.gateway.route.RouteSpec;
import com.yunhai.platform.gateway.route.TargetRouter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import javax.servlet.FilterChain;
import java.lang.reflect.Constructor;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* DispatchFilter 单测(能力补全 3 TDD):per-route 超时/体积取 spec 透传转发器、
* 缺 spec 走默认、值 &le;0 防御回落(D6)。{@code TargetRouter.Routing} 构造包级,
* 反射构建测试夹具。纯单测,mock 转发器只验参不真转。
*/
class DispatchFilterTest {
private DispatchFilter filter;
private StreamingForwarder forwarder;
@BeforeEach
void setUp() throws Exception {
forwarder = mock(StreamingForwarder.class);
filter = new DispatchFilter();
filter.setForwarder(forwarder);
}
/** Routing 构造包级(filter 包不可见),反射构建 */
private static TargetRouter.Routing routing(String forwardUrl) throws Exception {
Constructor<TargetRouter.Routing> c = TargetRouter.Routing.class
.getDeclaredConstructor(String.class, String.class, String.class);
c.setAccessible(true);
return c.newInstance("demo", "/oapi/hello", forwardUrl);
}
private MockHttpServletRequest request(TargetRouter.Routing routing, RouteSpec spec) {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/demo/oapi/hello");
req.setAttribute(RoutingFilter.ATTR_ROUTING, routing);
req.setAttribute(RoutingFilter.ATTR_ROUTE_SPEC, spec);
return req;
}
private ArgumentCaptor<Long> forwardAndCaptureBodyLimit(MockHttpServletRequest req) throws Exception {
filter.doFilter(req, new MockHttpServletResponse(), mock(FilterChain.class));
ArgumentCaptor<Long> limit = ArgumentCaptor.forClass(Long.class);
verify(forwarder).forward(any(), any(), anyString(), anyInt(), limit.capture());
return limit;
}
@Test
void spec超时与体积_透传转发器() throws Exception {
RouteSpec spec = new RouteSpec("demo /oapi/hello GET", "INTERNAL", "SIGNED",
0, "CALLER", 0, 5000, 2);
MockHttpServletRequest req = request(routing("http://backend/demo/oapi/hello"), spec);
ArgumentCaptor<Long> limit = forwardAndCaptureBodyLimit(req);
verify(forwarder).forward(any(), any(), eq("http://backend/demo/oapi/hello"), eq(5000), limit.capture());
assertThat(limit.getValue()).as("bodyLimit(mb) 换算字节透传").isEqualTo(2L * 1024 * 1024);
}
@Test
void 缺spec_用转发器默认() throws Exception {
MockHttpServletRequest req = request(routing("http://backend/demo/oapi/hello"), null);
ArgumentCaptor<Long> limit = forwardAndCaptureBodyLimit(req);
verify(forwarder).forward(any(), any(), anyString(),
eq(StreamingForwarder.DEFAULT_READ_TIMEOUT_MS), limit.capture());
assertThat(limit.getValue()).isEqualTo(StreamingForwarder.DEFAULT_BODY_LIMIT_BYTES);
}
@Test
void spec值非正_防御回落默认() throws Exception {
RouteSpec spec = new RouteSpec("demo /oapi/hello GET", "INTERNAL", "SIGNED",
0, "CALLER", 0, 0, 0);
MockHttpServletRequest req = request(routing("http://backend/demo/oapi/hello"), spec);
ArgumentCaptor<Long> limit = forwardAndCaptureBodyLimit(req);
verify(forwarder).forward(any(), any(), anyString(),
eq(StreamingForwarder.DEFAULT_READ_TIMEOUT_MS), limit.capture());
assertThat(limit.getValue()).as("D6:&le;0 回落转发器默认").isEqualTo(StreamingForwarder.DEFAULT_BODY_LIMIT_BYTES);
}
@Test
void 缺routing属性_裸404不转() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/demo/oapi/hello");
MockHttpServletResponse res = new MockHttpServletResponse();
filter.doFilter(req, res, mock(FilterChain.class));
assertThat(res.getStatus()).as("异常路径按 404 收口").isEqualTo(404);
org.mockito.Mockito.verifyNoInteractions(forwarder);
}
}
@@ -0,0 +1,125 @@
package com.yunhai.platform.gateway.filter;
import com.yunhai.platform.gateway.auth.SignedRateLimiter;
import com.yunhai.platform.gateway.route.RouteSpec;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import javax.servlet.ServletException;
import java.io.IOException;
import static org.assertj.core.api.Assertions.assertThat;
/**
* RateLimitFilter 单测(能力补全 2 TDD):判定对象(PASS/APP_ANON 判、FAIL_OBSERVED/ANON 不判)、
* 裸 429 与 observe 正交(本环不读 observe,超限即拒)、qps=0 不限、自身端点旁路。纯单测。
*/
class RateLimitFilterTest {
private RateLimitFilter filter;
@BeforeEach
void setUp() {
filter = new RateLimitFilter();
filter.setRateLimiter(new SignedRateLimiter());
}
private MockHttpServletRequest request(String verdict, String appKey, RouteSpec spec) {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/demo/oapi/hello");
req.setRequestURI("/demo/oapi/hello");
if (verdict != null) {
req.setAttribute(SignVerifyFilter.ATTR_VERDICT, verdict);
}
if (appKey != null) {
req.setAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY, appKey);
}
if (spec != null) {
req.setAttribute(RoutingFilter.ATTR_ROUTE_SPEC, spec);
}
return req;
}
private static RouteSpec spec(int qps) {
return new RouteSpec("demo /oapi/hello GET", "INTERNAL", "SIGNED", 0, "CALLER", qps, 30000, 10);
}
private MockHttpServletResponse doFilter(MockHttpServletRequest req, MockFilterChain chain)
throws ServletException, IOException {
MockHttpServletResponse res = new MockHttpServletResponse();
filter.doFilter(req, res, chain);
return res;
}
@Test
void pass超限_裸429无响应体_observe无关() throws Exception {
// 本环不读 observe:验签已过的 PASS 流量超限即拒(与 403 同口径正交),verdict/caller 保持第 3 环产出
MockFilterChain first = new MockFilterChain();
assertThat(doFilter(request(SignVerifyFilter.VERDICT_PASS, "100617", spec(1)), first).getStatus())
.as("第 1 次限内放行").isEqualTo(200);
assertThat(first.getRequest()).isNotNull();
MockFilterChain second = new MockFilterChain();
MockHttpServletResponse res = doFilter(request(SignVerifyFilter.VERDICT_PASS, "100617", spec(1)), second);
assertThat(res.getStatus()).as("第 2 次超限裸 429").isEqualTo(429);
assertThat(res.getContentAsByteArray()).as("F5 契约:裸码无体").isEmpty();
assertThat(second.getRequest()).as("被限请求不进转发链").isNull();
}
@Test
void pass但qps为0_不限() throws Exception {
for (int i = 0; i < 5; i++) {
MockFilterChain chain = new MockFilterChain();
doFilter(request(SignVerifyFilter.VERDICT_PASS, "100617", spec(0)), chain);
assertThat(chain.getRequest()).as("存量契约 0=不限,第 " + (i + 1) + " 次仍放行").isNotNull();
}
}
@Test
void failObserved不判_超限也放行() throws Exception {
for (int i = 0; i < 5; i++) {
MockFilterChain chain = new MockFilterChain();
doFilter(request(SignVerifyFilter.VERDICT_FAIL_OBSERVED, "100617", spec(1)), chain);
assertThat(chain.getRequest()).as("observe 过渡态不判(D3),第 " + (i + 1) + " 次仍放行").isNotNull();
}
}
@Test
void appAnon与pass同口径_超限429() throws Exception {
MockFilterChain first = new MockFilterChain();
assertThat(doFilter(request(SignVerifyFilter.VERDICT_APP_ANON, "100617", spec(1)), first).getStatus())
.isEqualTo(200);
MockFilterChain second = new MockFilterChain();
assertThat(doFilter(request(SignVerifyFilter.VERDICT_APP_ANON, "100617", spec(1)), second).getStatus())
.as("应用级免签同样限").isEqualTo(429);
}
@Test
void anon不判_路由级匿名走自己的限流路径() throws Exception {
MockFilterChain chain = new MockFilterChain();
doFilter(request(SignVerifyFilter.VERDICT_ANON, null, spec(1)), chain);
assertThat(chain.getRequest()).isNotNull();
}
@Test
void 身份缺失或spec缺失_放行不判() throws Exception {
MockFilterChain noKey = new MockFilterChain();
doFilter(request(SignVerifyFilter.VERDICT_PASS, null, spec(1)), noKey);
assertThat(noKey.getRequest()).as("appKey 缺失(异常路径)不判").isNotNull();
MockFilterChain noSpec = new MockFilterChain();
doFilter(request(SignVerifyFilter.VERDICT_PASS, "100617", null), noSpec);
assertThat(noSpec.getRequest()).as("spec 缺失(异常路径)不判").isNotNull();
}
@Test
void 网关自身端点旁路() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/internal/routes/refresh");
req.setRequestURI("/api/internal/routes/refresh");
MockFilterChain chain = new MockFilterChain();
doFilter(req, chain);
assertThat(chain.getRequest()).as("自身端点不经 oapi 判定(同 Dispatch 旁路口径)").isNotNull();
}
}
@@ -9,8 +9,9 @@ import org.springframework.mock.web.MockHttpServletResponse;
import static org.assertj.core.api.Assertions.assertThat;
/**
* SanitizeFilter 五用例(T1.1 TDD):路径穿越(明文/编码)、双斜杠、控制字符均裸 400;
* 正常路径放行且两个 attribute 契约已设。
* SanitizeFilter 九用例(T1.1 TDD):路径穿越(明文/编码)、双斜杠、控制字符均裸 400;
* 正常路径放行且两个 attribute 契约已设。ctx 非空时先剥 context-path 再判定,
* 前缀相似不误剥,ctx 为空零行为变化。
*/
class SanitizeFilterTest {
@@ -58,4 +59,51 @@ class SanitizeFilterTest {
assertThat(req.getAttribute(SanitizeFilter.ATTR_STRIPPED_PATH))
.as("stripped.path = 剥 target 首段").isEqualTo("/oapi/x");
}
@Test
void ctx非空_剥除后存业务URI() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/open-gateway/notice/oapi/x");
req.setContextPath("/open-gateway");
MockHttpServletResponse res = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
new SanitizeFilter().doFilter(req, res, chain);
assertThat(res.getStatus()).as("ctx 内正常路径放行").isEqualTo(200);
assertThat(req.getAttribute(SanitizeFilter.ATTR_SANITIZED_URI))
.as("sanitized.uri = 业务 URI(已去 ctx)").isEqualTo("/notice/oapi/x");
assertThat(req.getAttribute(SanitizeFilter.ATTR_STRIPPED_PATH))
.as("stripped.path 基于业务 URI 剥 target").isEqualTo("/oapi/x");
}
@Test
void ctx非空_前缀相似不误剥() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/open-gateway-other/oapi/x");
req.setContextPath("/open-gateway");
MockHttpServletResponse res = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
new SanitizeFilter().doFilter(req, res, chain);
assertThat(req.getAttribute(SanitizeFilter.ATTR_SANITIZED_URI))
.as("前缀相似不剥,交由 target 判定 404").isEqualTo("/open-gateway-other/oapi/x");
}
@Test
void ctx非空_剥后双斜杠仍拒() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/open-gateway//x");
req.setContextPath("/open-gateway");
MockHttpServletResponse res = new MockHttpServletResponse();
MockFilterChain chain = new MockFilterChain();
new SanitizeFilter().doFilter(req, res, chain);
assertThat(res.getStatus()).as("剥 ctx 后 // 仍裸 400").isEqualTo(400);
}
@Test
void stripContextPath_边界() {
assertThat(SanitizeFilter.stripContextPath("/userbase/oapi/x", "")).isEqualTo("/userbase/oapi/x");
assertThat(SanitizeFilter.stripContextPath("/userbase/oapi/x", null)).isEqualTo("/userbase/oapi/x");
assertThat(SanitizeFilter.stripContextPath(null, "/open-gateway")).isNull();
assertThat(SanitizeFilter.stripContextPath("/open-gateway", "/open-gateway"))
.as("裸 ctx 不剥(无尾斜杠),原样走 404 语义").isEqualTo("/open-gateway");
assertThat(SanitizeFilter.stripContextPath("/open-gateway/x", "/open-gateway")).isEqualTo("/x");
assertThat(SanitizeFilter.stripContextPath("/open-gateway-other/x", "/open-gateway"))
.isEqualTo("/open-gateway-other/x");
}
}
@@ -0,0 +1,230 @@
package com.yunhai.platform.gateway.filter;
import com.yunhai.platform.gateway.auth.AnonRateLimiter;
import com.yunhai.platform.gateway.auth.AppAuthRegistry;
import com.yunhai.platform.gateway.auth.EntryDetector;
import com.yunhai.platform.gateway.auth.GatewayKeyService;
import com.yunhai.platform.gateway.route.GatewayProperties;
import com.yunhai.platform.gateway.route.RouteSpec;
import com.yunhai.platform.utils.crypto.SignUtil;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import java.util.ArrayList;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 入口×策略判定矩阵用例(T4.3 / 落地总纲 §0.1,验收 A1-A6):
* <ul>
* <li>A1 内部入口(无标记)—— 任意路由一律验签,不看 {@code visibility}/{@code authMode};</li>
* <li>A2 外部入口打 {@code INTERNAL} 路由 —— 裸 403(且不因 observe=true 而放行);</li>
* <li>A3 外部入口打 {@code EXTERNAL×SIGNED} 无签名 —— 裸 401;</li>
* <li>A4 外部入口打 {@code EXTERNAL×ANON} 限流内 —— 免签放行,verdict=ANON、不注入身份头;</li>
* <li>A5 同上超限 —— 裸 429;限流未就绪 —— 裸 403(fail-closed,D4);</li>
* <li>A6 来源不可信时伪造 {@code X-Entry: external} —— 无效,仍按内部入口处理。</li>
* </ul>
*/
class SignVerifyFilterEntryTest {
private static final String SECRET = "sec-entry-0123456789abcdef";
private static final RouteSpec INTERNAL_SIGNED = new RouteSpec("?", "INTERNAL", "SIGNED", 0, "CALLER", 0, 30000, 10);
private static final RouteSpec EXTERNAL_SIGNED = new RouteSpec("?", "EXTERNAL", "SIGNED", 0, "CALLER", 0, 30000, 10);
private static final RouteSpec EXTERNAL_ANON = new RouteSpec("?", "EXTERNAL", "ANON", 0, "CALLER", 0, 30000, 10);
private static final AnonRateLimiter ALLOW_LIMITER = (ip, qps) -> AnonRateLimiter.Decision.ALLOW;
private static final AnonRateLimiter EXCEED_LIMITER = (ip, qps) -> AnonRateLimiter.Decision.EXCEEDED;
private SignVerifyFilter filter(boolean observe, boolean sourceTrusted, AnonRateLimiter limiter) {
SignVerifyFilter f = new SignVerifyFilter();
GatewayKeyService keyService = mock(GatewayKeyService.class);
when(keyService.getSecret("app1")).thenReturn(SECRET);
GatewayProperties props = new GatewayProperties();
props.setObserve(observe);
props.setTrustedProxies(sourceTrusted ? Arrays.asList("0.0.0.0/0") : new ArrayList<>());
f.setKeyService(keyService);
f.setProperties(props);
f.setAnonRateLimiter(limiter);
return f;
}
/** 已过 RoutingFilter 的请求:routeSpec + 剥段 path;remoteAddr 默认 127.0.0.1(落在 0.0.0.0/0 内) */
private MockHttpServletRequest request(RouteSpec spec, String xEntry) {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/userbase/oapi/message/list");
req.setAttribute(SanitizeFilter.ATTR_STRIPPED_PATH, "/oapi/message/list");
req.setAttribute(RoutingFilter.ATTR_ROUTE_SPEC, spec);
if (xEntry != null) {
req.addHeader(EntryDetector.HEADER_ENTRY, xEntry);
}
return req;
}
/** 带合法三件套、且来自可信来源(标记为外部入口) */
private MockHttpServletRequest signedExternalRequest(RouteSpec spec) {
MockHttpServletRequest req = request(spec, EntryDetector.ENTRY_EXTERNAL);
String ts = String.valueOf(System.currentTimeMillis());
req.addHeader("X-App-Key", "app1");
req.addHeader("X-Timestamp", ts);
req.addHeader("X-Signature", SignUtil.signRequest("GET", "/oapi/message/list", ts, SECRET));
return req;
}
private MockHttpServletResponse run(SignVerifyFilter f, MockHttpServletRequest req, MockFilterChain chain)
throws Exception {
MockHttpServletResponse res = new MockHttpServletResponse();
f.doFilter(req, res, chain);
return res;
}
@Test
void A1_内部入口_ANON路由仍走验签不因策略放行() throws Exception {
MockFilterChain chain = new MockFilterChain();
// 无 X-Entry ⇒ 内部入口;即便路由是 EXTERNAL×ANON、限流就绪也不该免签放行
MockHttpServletRequest req = request(EXTERNAL_ANON, null);
MockHttpServletResponse res = run(filter(false, true, ALLOW_LIMITER), req, chain);
assertThat(res.getStatus()).as("内部入口不看 authMode:无签名即裸 401").isEqualTo(401);
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL);
assertThat(chain.getRequest()).as("链条中断").isNull();
}
@Test
void A1_内部入口_坏签名observe为true仍只记不拦() throws Exception {
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = request(EXTERNAL_ANON, null);
MockHttpServletResponse res = run(filter(true, true, ALLOW_LIMITER), req, chain);
assertThat(res.getStatus()).as("内部入口的 observe 语义不变").isEqualTo(200);
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL_OBSERVED);
}
@Test
void A2_外部入口打INTERNAL路由_裸403且不受observe影响() throws Exception {
MockFilterChain chain = new MockFilterChain();
// observe=true 也不放行:策略层拒绝与 observe 正交(同 400/404)
MockHttpServletRequest req = request(INTERNAL_SIGNED, EntryDetector.ENTRY_EXTERNAL);
MockHttpServletResponse res = run(filter(true, true, ALLOW_LIMITER), req, chain);
assertThat(res.getStatus()).isEqualTo(403);
assertThat(res.getContentAsByteArray()).as("裸码无信封").isEmpty();
assertThat(chain.getRequest()).isNull();
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL);
}
@Test
void 外部入口_未经路由判定_同样裸403() throws Exception {
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = request(null, EntryDetector.ENTRY_EXTERNAL);
MockHttpServletResponse res = run(filter(true, true, ALLOW_LIMITER), req, chain);
assertThat(res.getStatus()).as("spec 缺失按最严:外部入口永不默认放行").isEqualTo(403);
assertThat(chain.getRequest()).isNull();
}
@Test
void A3_外部入口EXTERNAL_SIGNED_无签名裸401() throws Exception {
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = request(EXTERNAL_SIGNED, EntryDetector.ENTRY_EXTERNAL);
MockHttpServletResponse res = run(filter(false, true, ALLOW_LIMITER), req, chain);
assertThat(res.getStatus()).isEqualTo(401);
assertThat(chain.getRequest()).isNull();
}
@Test
void 外部入口EXTERNAL_SIGNED_合法签名放行() throws Exception {
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = signedExternalRequest(EXTERNAL_SIGNED);
MockHttpServletResponse res = run(filter(false, true, ALLOW_LIMITER), req, chain);
assertThat(res.getStatus()).isEqualTo(200);
assertThat(chain.getRequest()).isNotNull();
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_PASS);
assertThat(req.getAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY)).isEqualTo("app1");
}
@Test
void A4_外部入口EXTERNAL_ANON_限流内免签放行且不注入身份头() throws Exception {
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = request(EXTERNAL_ANON, EntryDetector.ENTRY_EXTERNAL);
MockHttpServletResponse res = run(filter(false, true, ALLOW_LIMITER), req, chain);
assertThat(res.getStatus()).isEqualTo(200);
assertThat(chain.getRequest()).isNotNull();
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).as("台账 caller=anonymous 的依据")
.isEqualTo(SignVerifyFilter.VERDICT_ANON);
assertThat(req.getAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY)).as("匿名就是匿名:不注入 appKey").isNull();
}
@Test
void A5_外部入口EXTERNAL_ANON_超限裸429() throws Exception {
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = request(EXTERNAL_ANON, EntryDetector.ENTRY_EXTERNAL);
MockHttpServletResponse res = run(filter(false, true, EXCEED_LIMITER), req, chain);
assertThat(res.getStatus()).isEqualTo(429);
assertThat(chain.getRequest()).isNull();
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL);
}
@Test
void A5_外部入口EXTERNAL_ANON_限流未就绪failClosd裸403() throws Exception {
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = request(EXTERNAL_ANON, EntryDetector.ENTRY_EXTERNAL);
// 限流 bean 缺失(本期 UnavailableAnonRateLimiter 的等价形态)⇒ 不放行
MockHttpServletResponse res = run(filter(false, true, null), req, chain);
assertThat(res.getStatus()).as("未接限流的匿名口子必须拒绝,不是放行").isEqualTo(403);
assertThat(chain.getRequest()).isNull();
}
@Test
void A6_来源不可信时伪造XEntry无效_按内部入口处理() throws Exception {
MockFilterChain chain = new MockFilterChain();
// trusted-proxies 为空(来源不可信)——客户端自带 X-Entry: external 也不能自封外部入口
MockHttpServletRequest req = request(EXTERNAL_ANON, EntryDetector.ENTRY_EXTERNAL);
MockHttpServletResponse res = run(filter(false, false, ALLOW_LIMITER), req, chain);
assertThat(res.getStatus()).as("伪造标记无效:走内部口径验签 → 401").isEqualTo(401);
assertThat(chain.getRequest()).isNull();
}
@Test
void 应用免签不外溢到外部入口_窗口开着仍须验签() throws Exception {
// C 方向 D3:应用免签永不外溢——即使注册表对任意头都放行(窗口开/ANON 行在),
// 外部入口 EXTERNAL×SIGNED 仍走验签分支,无签名裸 401
SignVerifyFilter f = filter(false, true, ALLOW_LIMITER);
AppAuthRegistry registry = mock(AppAuthRegistry.class);
when(registry.admit(org.mockito.ArgumentMatchers.any())).thenReturn(SignVerifyFilter.VERDICT_APP_ANON);
f.setAppAuthRegistry(registry);
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = request(EXTERNAL_SIGNED, EntryDetector.ENTRY_EXTERNAL);
MockHttpServletResponse res = run(f, req, chain);
assertThat(res.getStatus()).as("外部入口不查应用免签").isEqualTo(401);
assertThat(chain.getRequest()).as("链条中断").isNull();
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL);
}
@Test
void 外部入口EXTERNAL_SIGNED_合法签名照旧PASS_与免签注册表无关() throws Exception {
SignVerifyFilter f = filter(false, true, ALLOW_LIMITER);
AppAuthRegistry registry = mock(AppAuthRegistry.class);
when(registry.admit(org.mockito.ArgumentMatchers.any())).thenReturn(SignVerifyFilter.VERDICT_APP_ANON);
f.setAppAuthRegistry(registry);
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = signedExternalRequest(EXTERNAL_SIGNED);
MockHttpServletResponse res = run(f, req, chain);
assertThat(res.getStatus()).isEqualTo(200);
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).as("外部只认验签 PASS").isEqualTo(SignVerifyFilter.VERDICT_PASS);
}
}
@@ -2,6 +2,7 @@ package com.yunhai.platform.gateway.filter;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.gateway.auth.AppAuthRegistry;
import com.yunhai.platform.gateway.auth.GatewayKeyService;
import com.yunhai.platform.gateway.route.GatewayProperties;
import com.yunhai.platform.utils.crypto.SignUtil;
@@ -11,6 +12,7 @@ import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import java.io.InputStream;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
@@ -169,4 +171,90 @@ class SignVerifyFilterTest {
.isEqualTo(expectedSign);
}
}
/* ============ 应用级免签(C 方向 G12-1~4):内部入口 admit 命中即放行,未命中落回验签 ============ */
/** 挂应用免签注册表的过滤器;admit 返回值由用例控制 */
private SignVerifyFilter filterWithRegistry(String admitVerdict) {
SignVerifyFilter f = filter(false);
AppAuthRegistry registry = mock(AppAuthRegistry.class);
when(registry.admit(org.mockito.ArgumentMatchers.any())).thenReturn(admitVerdict);
f.setAppAuthRegistry(registry);
return f;
}
@Test
void 内部ANON应用凭key免签放行且注入身份() throws Exception {
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "100009", null, null);
MockHttpServletResponse res = run(filterWithRegistry(SignVerifyFilter.VERDICT_APP_ANON), req, chain);
assertThat(res.getStatus()).as("免 X-Timestamp/X-Signature 两头直接放行").isEqualTo(200);
assertThat(chain.getRequest()).isNotNull();
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_APP_ANON);
assertThat(req.getAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY)).as("台账归因与身份注入依据").isEqualTo("100009");
}
@Test
void 内部匿名窗口无票据放行且不注入身份() throws Exception {
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, null, null, null);
MockHttpServletResponse res = run(filterWithRegistry(SignVerifyFilter.VERDICT_ANON), req, chain);
assertThat(res.getStatus()).isEqualTo(200);
assertThat(chain.getRequest()).isNotNull();
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_ANON);
assertThat(req.getAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY)).as("匿名就是匿名:不注入").isNull();
}
@Test
void 内部ANON行过期落回验签observe下FAIL_OBSERVED() throws Exception {
// admit 返回 null(惰性到期,等同无行)→ 原验签行为一字不变
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "100009", null, null);
SignVerifyFilter f = filter(true);
AppAuthRegistry registry = mock(AppAuthRegistry.class);
when(registry.admit(org.mockito.ArgumentMatchers.any())).thenReturn(null);
f.setAppAuthRegistry(registry);
MockHttpServletResponse res = run(f, req, chain);
assertThat(res.getStatus()).as("observe=true:验签失败仍只记不拦").isEqualTo(200);
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL_OBSERVED);
}
@Test
void 内部ANON行过期非observe下裸401() throws Exception {
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "100009", null, null);
MockHttpServletResponse res = run(filterWithRegistry(null), req, chain);
assertThat(res.getStatus()).isEqualTo(401);
assertThat(chain.getRequest()).as("链条中断").isNull();
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL);
}
@Test
void 内部无行SIGNED禁用_原验签行为不变() throws Exception {
// 回归:注册表恒 null(表空/无行/SIGNED/禁用同口径)→ 与改造前判定链完全一致
String ts = String.valueOf(System.currentTimeMillis());
String sign = SignUtil.signRequest("GET", "/oapi/message/list", ts, SECRET);
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "app1", ts, sign);
MockHttpServletResponse res = run(filterWithRegistry(null), req, chain);
assertThat(res.getStatus()).as("合法签名照旧 PASS").isEqualTo(200);
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_PASS);
assertThat(req.getAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY)).isEqualTo("app1");
}
@Test
void 注册表未装配时落回验签() throws Exception {
// 单测/异常装配形态:registry 为 null 绝不能 NPE,fail-closed 走原验签
MockFilterChain chain = new MockFilterChain();
MockHttpServletRequest req = request("GET", "/oapi/message/list", null, null, null, null);
MockHttpServletResponse res = run(filter(false), req, chain);
assertThat(res.getStatus()).isEqualTo(401);
assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL);
}
}
@@ -23,9 +23,10 @@ import java.util.concurrent.Executors;
import static org.assertj.core.api.Assertions.assertThat;
/**
* 身份头注入 X-Caller-App-Key 四用例(T1.4 TDD):PASS 注入且值 = 验签 appKey;
* FAIL(observe)=FAIL_OBSERVED 不注入;ANON 路由不注入(匿名就是匿名);
* 客户端伪造头先剥后注入——假后端收到的终值 = 网关注入值(唯一来源,顺序契约)。
* 身份头注入 X-Caller-App-Key 用例(T1.4 TDD + C 方向 G12-6):PASS 注入且值 = 验签 appKey;
* APP_ANON(应用级免签)注入且值 = 自报 appKey;FAIL(observe)=FAIL_OBSERVED 不注入;
* ANON 路由/匿名窗口不注入(匿名就是匿名);客户端伪造头先剥后注入——假后端收到的终值 =
* 网关注入值(唯一来源,顺序契约)。
*/
class CallerHeaderTest {
@@ -93,6 +94,19 @@ class CallerHeaderTest {
assertThat(headers.get("x-caller-app-key")).isEqualTo("100617");
}
@Test
void APP_ANON判定注入且值等于自报appKey() throws Exception {
// 应用级免签(C 方向):自报未验身份也注入——下游凭头归因,背书弱于 PASS(O2:暂不区分)
Map<String, String> headers = forwardWithVerdict(SignVerifyFilter.VERDICT_APP_ANON, "100009", null);
assertThat(headers.get("x-caller-app-key")).isEqualTo("100009");
}
@Test
void APP_ANON_伪造头先剥后注入() throws Exception {
Map<String, String> headers = forwardWithVerdict(SignVerifyFilter.VERDICT_APP_ANON, "100009", "hack");
assertThat(headers.get("x-caller-app-key")).as("唯一合法来源是网关").isEqualTo("100009");
}
@Test
void FAIL_OBSERVED不注入() throws Exception {
Map<String, String> headers = forwardWithVerdict(SignVerifyFilter.VERDICT_FAIL_OBSERVED, null, null);
@@ -25,9 +25,9 @@ import java.util.concurrent.Executors;
import static org.assertj.core.api.Assertions.assertThat;
/**
* StreamingForwarder 五用例(T1.3 TDD,假后端 = JDK 自带 com.sun.net.httpserver.HttpServer,
* StreamingForwarder 用例(T1.3 TDD,假后端 = JDK 自带 com.sun.net.httpserver.HttpServer,
* 零新增测试依赖):GET 转发 forwardUrl 逐字符 + 响应透传;POST body 流式透传;
* 客户端伪造 X-Caller-App-Key 不出现在转发请求;签名头四件原样到达;
* 客户端伪造 X-Caller-App-Key / X-Entry 不出现在转发请求;签名头四件原样到达;
* 超时裸 504 / 连接拒绝裸 502(另附 body 超限裸 413——实现要点契约)。
*/
class StreamingForwarderTest {
@@ -169,6 +169,19 @@ class StreamingForwarderTest {
assertThat(lastHeaders).as("身份头唯一合法来源是网关(T1.4)").doesNotContainKey("x-caller-app-key");
}
@Test
void 入口标记头XEntry不透传提供方() throws Exception {
MockHttpServletRequest req = request("GET", "/userbase/oapi/x");
req.addHeader("X-Entry", "external");
MockHttpServletResponse res = new MockHttpServletResponse();
forwarder().forward(req, res, "http://127.0.0.1:" + port + "/userbase/oapi/x", 5000, 1024 * 1024);
assertThat(res.getStatus()).isEqualTo(201);
assertThat(lastHeaders).as("T4.3:入口标记是网关与 Caddy 之间的约定,提供方不该看到(伪造值也到不了)")
.doesNotContainKey("x-entry");
}
@Test
void 签名头四件原样到达假后端() throws Exception {
MockHttpServletRequest req = request("GET", "/userbase/oapi/x");
@@ -2,10 +2,12 @@ package com.yunhai.platform.gateway.log;
import com.yunhai.platform.gateway.entity.OpenApiLog;
import com.yunhai.platform.gateway.entity.OpenApiStatDaily;
import com.yunhai.platform.gateway.filter.SignVerifyFilter;
import com.yunhai.platform.gateway.repository.OpenApiLogRepository;
import com.yunhai.platform.gateway.repository.OpenApiStatDailyRepository;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
@@ -104,4 +106,48 @@ class AccessLogServiceTest {
assertThat(svc.queueSize()).isEqualTo(2);
verify(logRepo, never()).saveAll(any());
}
/* ============ caller 归因口径(C 方向 G12-7):APP_ANON → appKey;ANON → anonymous ============ */
private MockHttpServletRequest reqWithVerdict(String verdict, String authAppKey, String headerKey) {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/userbase/oapi/x");
req.setAttribute(SignVerifyFilter.ATTR_VERDICT, verdict);
if (authAppKey != null) {
req.setAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY, authAppKey);
}
if (headerKey != null) {
req.addHeader("X-App-Key", headerKey);
}
return req;
}
@Test
void APP_ANON归因到自报appKey而非anonymous() {
String caller = ReflectionTestUtils.invokeMethod(AccessLogService.class, "callerOf",
reqWithVerdict(SignVerifyFilter.VERDICT_APP_ANON, "100009", "100009"));
assertThat(caller).as("应用级免签照常归因").isEqualTo("100009");
}
@Test
void ANON判定归因anonymous_即使带了未登记key() {
// 匿名窗口放行的请求可能带未登记 key,但 verdict=ANON 就是匿名口径
String caller = ReflectionTestUtils.invokeMethod(AccessLogService.class, "callerOf",
reqWithVerdict(SignVerifyFilter.VERDICT_ANON, null, "ghost"));
assertThat(caller).isEqualTo(AccessLogService.CALLER_ANONYMOUS);
}
@Test
void PASS归因验签appKey_FAIL回退取头() {
String pass = ReflectionTestUtils.invokeMethod(AccessLogService.class, "callerOf",
reqWithVerdict(SignVerifyFilter.VERDICT_PASS, "100617", "100617"));
assertThat(pass).isEqualTo("100617");
String fail = ReflectionTestUtils.invokeMethod(AccessLogService.class, "callerOf",
reqWithVerdict(SignVerifyFilter.VERDICT_FAIL, null, "100617"));
assertThat(fail).as("401 有头尽力记").isEqualTo("100617");
String none = ReflectionTestUtils.invokeMethod(AccessLogService.class, "callerOf",
reqWithVerdict(SignVerifyFilter.VERDICT_FAIL, null, null));
assertThat(none).as("401 无 appKey = NULL").isNull();
}
}
@@ -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,103 @@
package com.yunhai.platform.gateway.route;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor;
import org.springframework.beans.factory.annotation.QualifierAnnotationAutowireCandidateResolver;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.beans.factory.support.RootBeanDefinition;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
/**
* 回归测试(2026-09-24 线上事故):{@link RouteChangeBroadcaster} 的 Redis 注入必须显式选主库模板,
* 且必须能容忍「两个同类型 StringRedisTemplate 共存」的上下文。
* <p>
* <b>事故</b>:{@code yh-appauth} 的 {@code SignKeyRedisAutoConfiguration} 注册了同名类型的
* {@code signKeyStringRedisTemplate}(直连 db0,专供 {@code sign:app:*})。本类原先裸
* {@code @Autowired StringRedisTemplate} → {@code NoUniqueBeanDefinitionException} →
* 网关启动即失败、容器无限重启(RestartCount=11)。
* <p>
* <b>既有测试为何漏掉</b>:测试 profile 一律 {@code yh.gateway.redis-broadcast-enabled=false}
* 把本类整个关掉,装配分支从未被触碰。
* <p>
* <b>为什么不用 @SpringBootTest</b>:需要一个<i>恰好只有这两个模板</i>的最小 bean 集合来复现歧义,
* 全量上下文会被组件扫描、数据源等噪声干扰。这里手工驱动
* {@link AutowiredAnnotationBeanPostProcessor}——它与生产环境用的是同一套注入逻辑,
* 因此能真实复现「裸 @Autowired 会炸」这一行为。
*/
class RouteChangeBroadcasterWiringTest {
/** appauth starter 密钥模板的 bean 名(与 SignKeyRedisAutoConfiguration.SIGN_TEMPLATE_BEAN 一致) */
private static final String SIGN_TEMPLATE_BEAN = "signKeyStringRedisTemplate";
private static final String MAIN_TEMPLATE_BEAN = "stringRedisTemplate";
/** 复现环境:注册两个同类型模板 + 一个 RouteRegistry + 一个 AppAuthRegistry(广播器 onMessage 重载对象) */
private DefaultListableBeanFactory twoTemplatesFactory() {
DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
factory.setAutowireCandidateResolver(new QualifierAnnotationAutowireCandidateResolver());
factory.registerSingleton(MAIN_TEMPLATE_BEAN,
new StringRedisTemplate(mock(RedisConnectionFactory.class)));
factory.registerSingleton(SIGN_TEMPLATE_BEAN,
new StringRedisTemplate(mock(RedisConnectionFactory.class)));
factory.registerSingleton("routeRegistry", mock(RouteRegistry.class));
factory.registerSingleton("appAuthRegistry", mock(com.yunhai.platform.gateway.auth.AppAuthRegistry.class));
return factory;
}
/** 用生产同款注入逻辑装配一个 RouteChangeBroadcaster 实例 */
private RouteChangeBroadcaster build(DefaultListableBeanFactory factory) {
RootBeanDefinition def = new RootBeanDefinition(RouteChangeBroadcaster.class);
factory.registerBeanDefinition("routeChangeBroadcaster", def);
AutowiredAnnotationBeanPostProcessor processor = new AutowiredAnnotationBeanPostProcessor();
processor.setBeanFactory(factory);
RouteChangeBroadcaster bean = new RouteChangeBroadcaster();
processor.postProcessProperties(null, bean, "routeChangeBroadcaster");
return bean;
}
@Test
void 两个同类型模板共存时_广播器仍可装配() {
RouteChangeBroadcaster bean = build(twoTemplatesFactory());
assertThat(bean)
.as("stringRedisTemplate 与 signKeyStringRedisTemplate 共存时广播器必须装配成功")
.isNotNull();
}
@Test
void 广播器绑定主库模板而非密钥专用模板() {
DefaultListableBeanFactory factory = twoTemplatesFactory();
RouteChangeBroadcaster bean = build(factory);
StringRedisTemplate bound = (StringRedisTemplate) org.springframework.test.util.ReflectionTestUtils
.getField(bean, "redisTemplate");
assertThat(bound)
.as("必须绑定应用主库的 stringRedisTemplate(选错 = 发布订阅异库、广播静默失效)")
.isSameAs(factory.getBean(MAIN_TEMPLATE_BEAN, StringRedisTemplate.class));
}
/**
* 反向保护:钉死「裸 @Autowired 确实会失败」这一事实,防止有人误以为本测试是多余的。
* 若哪天歧义被 Spring 默认策略自动消解(例如某方加了 @Primary),本用例会失败并提醒复核。
*/
@Test
void 无Qualifier时同类型双Bean必然注入歧义() {
DefaultListableBeanFactory factory = twoTemplatesFactory();
factory.registerBeanDefinition("bareBroadcaster", new RootBeanDefinition(BareInjectProbe.class));
AutowiredAnnotationBeanPostProcessor processor = new AutowiredAnnotationBeanPostProcessor();
processor.setBeanFactory(factory);
BareInjectProbe probe = new BareInjectProbe();
assertThatThrownBy(() -> processor.postProcessProperties(null, probe, "bareBroadcaster"))
.as("两个同类型模板下裸 @Autowired 必须失败——这正是事故成因")
.isInstanceOf(org.springframework.beans.factory.UnsatisfiedDependencyException.class);
}
/** 与 RouteChangeBroadcaster 改前同形的裸注入探针,仅用于反向断言 */
static class BareInjectProbe {
@org.springframework.beans.factory.annotation.Autowired
private StringRedisTemplate redisTemplate;
}
}
@@ -62,6 +62,23 @@ class RouteRegistryTest {
assertThat(spec.rateLimitQps).isZero();
}
@Test
void 行身份与转发参数_按行提炼() {
// 能力补全 2+3:routeKey=行身份(限流桶维度),timeout/bodyLimit=转发参数
OpenApiRoute row = route("usermsg", "/oapi/message/list", "*", "ANON", 30);
row.setRateLimitQps(7);
row.setTimeoutMs(5000);
row.setRequestBodyLimitMb(2);
when(routeRepo.findByEnabledTrue()).thenReturn(new ArrayList<>(Arrays.asList(row)));
registry.reload();
RouteSpec spec = registry.resolve("usermsg", "/oapi/message/list", "GET");
assertThat(spec.routeKey).isEqualTo("usermsg /oapi/message/list *");
assertThat(spec.rateLimitQps).isEqualTo(7);
assertThat(spec.timeoutMs).isEqualTo(5000);
assertThat(spec.requestBodyLimitMb).isEqualTo(2);
}
@Test
void 前缀通配命中() {
RouteSpec spec = registry.resolve("usermsg", "/oapi/message/detail", "POST");
@@ -70,13 +87,16 @@ class RouteRegistryTest {
}
@Test
void 未注册路径取最严默认_五字段全断言() {
void 未注册路径取最严默认_八字段全断言() {
RouteSpec spec = registry.resolve("usermsg", "/oapi/unknown/path", "GET");
assertThat(spec.routeKey).as("routeKey(DEFAULT 为 \"?\",qps=0 不会进限流桶)").isEqualTo("?");
assertThat(spec.visibility).as("visibility").isEqualTo("INTERNAL");
assertThat(spec.authMode).as("authMode").isEqualTo("SIGNED");
assertThat(spec.cacheTtlSeconds).as("cacheTtl").isZero();
assertThat(spec.cacheScope).as("cacheScope").isEqualTo("CALLER");
assertThat(spec.rateLimitQps).as("qps").isZero();
assertThat(spec.timeoutMs).as("timeoutMs").isEqualTo(30000);
assertThat(spec.requestBodyLimitMb).as("bodyLimit").isEqualTo(10);
}
@Test
@@ -0,0 +1,190 @@
package com.yunhai.platform.gateway.sso;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import com.yunhai.platform.gateway.auth.GatewayKeyService;
import org.jasig.cas.client.authentication.AttributePrincipal;
import org.jasig.cas.client.util.AbstractCasFilter;
import org.jasig.cas.client.validation.Assertion;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 外观偏好同源读端点契约(对接规范 §3,网关自建同构哑管道):
* <ul>
* <li>无会话断言 → 401;未配 app-key / 密钥链解析不到 → 503(绝不 200+兜底值);</li>
* <li>两腿响应按平台统一信封 {code,msg,data} 解析(镜像 OpenApiEnvelope.unwrap:
* code==0 取 data,code≠0 或 data:null → 503;无 code 视为已裸);</li>
* <li>happy path:byLoginName 换 userId → appearance 的 data 裸 payload 整体透传;</li>
* <li>上游任一环节非 200 / 解析不到 userId → 503(init 侧静默保键语义的前提)。</li>
* </ul>
* 回环用 JDK HttpServer 按路径分桩(standalone MockMvc 不装 advice,String 返回即裸体)。
* 信封坑(2026-10-12 线上实录):真实 UserBase/UserHome oapi 均包 {code,msg,data},
* 裸 JSON stub 曾造成顶层取 id 恒失败=登录态恒 503——stub 一律按真实信封形态。
*/
class AppearanceProxyControllerTest {
private static final String SECRET = "probe-secret-0123456789";
private static final String PAYLOAD =
"{\"theme\":{\"theme\":\"blue\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":true},"
+ "\"avatar\":\"text\",\"logoUrl\":\"http://cdn/logo.png\"}";
private HttpServer server;
private final Map<String, String> stubBodies = new HashMap<>();
private final Map<String, Integer> stubStatus = new HashMap<>();
private AppearanceProxyController controller;
private MockMvc mvc;
@BeforeEach
void setUp() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", new StubHandler());
server.start();
GatewayKeyService keyService = Mockito.mock(GatewayKeyService.class);
Mockito.when(keyService.getSecret("gw-appearance")).thenReturn(SECRET);
Mockito.when(keyService.getSecret("gw-unknown")).thenReturn(null);
controller = new AppearanceProxyController();
controller.setKeyService(keyService);
controller.setLoopbackBase("http://127.0.0.1:" + server.getAddress().getPort());
mvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@AfterEach
void tearDown() {
server.stop(0);
}
private class StubHandler implements HttpHandler {
@Override
public void handle(HttpExchange ex) throws IOException {
String path = ex.getRequestURI().getPath();
int status = stubStatus.getOrDefault(path, 200);
String body = stubBodies.getOrDefault(path, "{}");
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "application/json");
ex.sendResponseHeaders(status, status == 204 ? -1 : bytes.length);
if (status != 204) {
try (OutputStream os = ex.getResponseBody()) {
os.write(bytes);
}
}
}
}
private static MockHttpSession sessionWithPrincipal(String name) {
MockHttpSession session = new MockHttpSession();
AttributePrincipal principal = Mockito.mock(AttributePrincipal.class);
Mockito.when(principal.getName()).thenReturn(name);
Assertion assertion = Mockito.mock(Assertion.class);
Mockito.when(assertion.getPrincipal()).thenReturn(principal);
session.setAttribute(AbstractCasFilter.CONST_CAS_ASSERTION, assertion);
return session;
}
@Test
void noSession_is401() throws Exception {
controller.setAppKey("gw-appearance");
mvc.perform(get("/api/appearance")).andExpect(status().isUnauthorized());
}
@Test
void disabledAppKey_is503() throws Exception {
controller.setAppKey("");
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin")))
.andExpect(status().isServiceUnavailable());
}
@Test
void keyNotInChain_is503() throws Exception {
controller.setAppKey("gw-unknown");
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin")))
.andExpect(status().isServiceUnavailable());
}
@Test
void happyPath_unwrapsEnvelopeAndPassesBarePayload() throws Exception {
controller.setAppKey("gw-appearance");
stubBodies.put("/userbase/oapi/user/byLoginName",
"{\"code\":0,\"msg\":\"\",\"data\":{\"id\":353,\"loginName\":\"admin\"}}");
stubBodies.put("/userhome/oapi/appearance", "{\"code\":0,\"msg\":\"\",\"data\":" + PAYLOAD + "}");
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin")))
.andExpect(status().isOk())
.andExpect(content().json(PAYLOAD));
}
@Test
void bareFallback_stillResolvesAndPassesThrough() throws Exception {
// 防御兼容:上游若返回无 code 的裸数据形态,unwrap 原样放行(同 OpenApiEnvelope 语义)
controller.setAppKey("gw-appearance");
stubBodies.put("/userbase/oapi/user/byLoginName", "{\"id\":353,\"loginName\":\"admin\"}");
stubBodies.put("/userhome/oapi/appearance", PAYLOAD);
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin")))
.andExpect(status().isOk())
.andExpect(content().json(PAYLOAD));
}
@Test
void userbaseBusinessError_is503() throws Exception {
controller.setAppKey("gw-appearance");
stubBodies.put("/userbase/oapi/user/byLoginName", "{\"code\":4,\"msg\":\"无此用户\",\"data\":null}");
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("ghost")))
.andExpect(status().isServiceUnavailable());
}
@Test
void userhomeBusinessError_is503() throws Exception {
controller.setAppKey("gw-appearance");
stubBodies.put("/userbase/oapi/user/byLoginName", "{\"code\":0,\"msg\":\"\",\"data\":{\"id\":353}}");
stubBodies.put("/userhome/oapi/appearance", "{\"code\":5,\"msg\":\"外观服务异常\",\"data\":null}");
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin")))
.andExpect(status().isServiceUnavailable());
}
@Test
void userhomeNullData_is503() throws Exception {
controller.setAppKey("gw-appearance");
stubBodies.put("/userbase/oapi/user/byLoginName", "{\"code\":0,\"msg\":\"\",\"data\":{\"id\":353}}");
stubBodies.put("/userhome/oapi/appearance", "{\"code\":0,\"msg\":\"\",\"data\":null}");
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin")))
.andExpect(status().isServiceUnavailable());
}
@Test
void userNotFound_is503() throws Exception {
controller.setAppKey("gw-appearance");
stubBodies.put("/userbase/oapi/user/byLoginName", "{\"code\":0,\"msg\":\"\",\"data\":null}");
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("ghost")))
.andExpect(status().isServiceUnavailable());
}
@Test
void upstreamError_is503() throws Exception {
controller.setAppKey("gw-appearance");
stubBodies.put("/userbase/oapi/user/byLoginName", "{\"code\":0,\"msg\":\"\",\"data\":{\"id\":353}}");
stubStatus.put("/userhome/oapi/appearance", 404);
mvc.perform(get("/api/appearance").session(sessionWithPrincipal("admin")))
.andExpect(status().isServiceUnavailable());
}
}
@@ -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,205 @@
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"));
/**
* 自守卫浏览器端点:不在 CAS url-pattern 圈内(通道 init 固定打「serviceHost+/api/appearance」
* 裸路径,无法挪进 /api/internal/admin/*),由控制器自查会话断言(无会话 401、
* 上游不可达 503,见 AppearanceProxyController 三态语义)。
*/
private static final Set<String> SELF_GUARDED_BROWSER_PATHS =
new TreeSet<>(Collections.singletonList("/api/appearance"));
/** 必须留在鉴权面之外的路径(本次线上探针实测过的形态) */
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)
|| SELF_GUARDED_BROWSER_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");
}
}
+7 -1
View File
@@ -6,11 +6,12 @@ spring:
password:
sql:
init:
# 直接跑交付 DDL(docs/sql/01_schema.sql + 03_target.sql)——round-trip 钉死「DDL ↔ 实体」一致
# 直接跑交付 DDL(docs/sql/01_schema.sql + 03_target.sql + 04_app.sql)——round-trip 钉死「DDL ↔ 实体」一致
mode: always
schema-locations:
- file:docs/sql/01_schema.sql
- file:docs/sql/03_target.sql
- file:docs/sql/04_app.sql
jpa:
hibernate:
ddl-auto: none
@@ -21,3 +22,8 @@ management:
# 测试上下文没有 redis(默认 localhost:6379 连不上会让聚合探针 503);
# E04a 用例要钉的是「探针不经 oapi 链」= 平台探活口径 HTTP 200
enabled: false
yh:
gateway:
# 变更广播需 Redis:测试上下文关掉(路由判定读本地 Caffeine,与广播无关)
redis-broadcast-enabled: false