Compare commits
14
Commits
50f9948b49
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
487a2e11d7 | ||
|
|
235a44ff88 | ||
|
|
5804ed3118 | ||
|
|
a2c8905c7a | ||
|
|
7e3690e523 | ||
|
|
1ae94d7100 | ||
|
|
48625d0f11 | ||
|
|
f456c85702 | ||
|
|
a19ded48ef | ||
|
|
2389d2fa9a | ||
|
|
6e64acc0c7 | ||
|
|
3887892d4d | ||
|
|
ce4c3cc5a6 | ||
|
|
06b0f4ed23 |
@@ -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,3 +2,5 @@ target/
|
||||
.idea/
|
||||
*.iml
|
||||
logs/
|
||||
gateway-run.log
|
||||
tmp_*.sql
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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 必填、> 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 时间锁):必填、可解析、> 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 限额;调用方已保证 > 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≥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 限额;≤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 或值 ≤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 缺省限额(>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 消费,≤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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 走默认、值 ≤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:≤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();
|
||||
}
|
||||
}
|
||||
+103
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user