feat(gateway): 应用级免签(ANON) + 签名限流/per-route 超时 + 代理测试页
build / build-push (push) Successful in 1m47s
build / build-push (push) Successful in 1m47s
- open_api_app 表(docs/sql/04_app.sql) + OpenApiApp/OpenApiAppRepository/AppAuthRegistry: authMode=ANON 应用级免签,支持 '*' 路径通配窗口,有效期 ≤31 天惰性到期; PUT 带 authMode=ANON 时 expireAt 必传 - SignedRateLimiter + RateLimitFilter:按签名键限流(fail-closed); RouteSpec 增加 per-route 超时与请求体积上限,StreamingForwarder 执行超时 - ProxyTestController:网关代理测试页后端接口 - SignVerifyFilter/DispatchFilter/InternalAdminController 及对应测试接线 - .gitignore 补 gateway-run.log / tmp_*.sql(本地运行产物不入库) 验证:全量 171 用例绿 + 双轮冒烟(2026-10-03)
This commit is contained in:
@@ -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)
|
||||
);
|
||||
@@ -1,9 +1,12 @@
|
||||
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;
|
||||
@@ -14,12 +17,14 @@ 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;
|
||||
@@ -27,6 +32,8 @@ 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;
|
||||
@@ -34,9 +41,10 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 网关治理接口(/api/internal/admin/**,D5 治理面后端):路由/目标地址的查询与行内编辑、
|
||||
* 台账分页查询;写操作落库后立即热重载注册表(routes→reload / targets→reloadTargets)
|
||||
* 并向 {@link RouteChangeBroadcaster} 发一条变更广播(其余实例收到即各自重载),无须重启网关。
|
||||
* 网关治理接口(/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
|
||||
@@ -61,6 +69,13 @@ public class InternalAdminController {
|
||||
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;
|
||||
|
||||
@@ -70,20 +85,29 @@ public class InternalAdminController {
|
||||
@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;
|
||||
|
||||
/** 测试/显式装配入口 */
|
||||
void setRepos(OpenApiRouteRepository routeRepo, OpenApiTargetRepository targetRepo,
|
||||
OpenApiLogRepository logRepo, RouteRegistry routeRegistry) {
|
||||
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;
|
||||
}
|
||||
|
||||
/** 路由表分页(治理对象全量含禁用行;targetCode+pathPattern 稳定排序) */
|
||||
@@ -143,6 +167,20 @@ public class InternalAdminController {
|
||||
}
|
||||
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")));
|
||||
}
|
||||
@@ -193,6 +231,160 @@ public class InternalAdminController {
|
||||
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) {
|
||||
|
||||
@@ -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,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,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,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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -32,7 +33,11 @@ import java.util.List;
|
||||
* <p>
|
||||
* <b>先分入口,再分策略</b>(落地总纲 §0.1 判定矩阵):
|
||||
* <ul>
|
||||
* <li><b>内部入口</b>(容器网直连,无标记)—— 一律验签,<b>不看</b> {@code visibility}/{@code authMode}。
|
||||
* <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}
|
||||
@@ -55,13 +60,15 @@ 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";
|
||||
@@ -78,6 +85,10 @@ public class SignVerifyFilter implements Filter {
|
||||
@Autowired(required = false)
|
||||
private AnonRateLimiter anonRateLimiter;
|
||||
|
||||
/** 应用免签注册表(内部入口免签分支;@Autowired 必装配——缺省快照为空 = 全量验签) */
|
||||
@Autowired
|
||||
private AppAuthRegistry appAuthRegistry;
|
||||
|
||||
/** 测试/显式装配入口(同 TargetRouter.setProperties 惯例) */
|
||||
void setKeyService(GatewayKeyService keyService) {
|
||||
this.keyService = keyService;
|
||||
@@ -91,6 +102,10 @@ public class SignVerifyFilter implements Filter {
|
||||
this.anonRateLimiter = anonRateLimiter;
|
||||
}
|
||||
|
||||
void setAppAuthRegistry(AppAuthRegistry appAuthRegistry) {
|
||||
this.appAuthRegistry = appAuthRegistry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
@@ -108,6 +123,18 @@ public class SignVerifyFilter implements Filter {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -186,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);
|
||||
|
||||
@@ -39,7 +39,8 @@ 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 注入)与入口标记头 {@code X-Entry}(T4.3,网关与 Caddy 之间的约定);
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
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;
|
||||
@@ -17,8 +18,8 @@ import javax.annotation.PostConstruct;
|
||||
|
||||
/**
|
||||
* 注册表变更广播(D1 选型 C 的后半):治理页/刷新端点写库后就地 reload 之外,
|
||||
* 再向 {@link #CHANNEL} 发一条消息,各实例收到即各自 {@link RouteRegistry#reload()} /
|
||||
* {@link RouteRegistry#reloadTargets()},免重启、免逐实例点「重载注册表」。
|
||||
* 再向 {@link #CHANNEL} 发一条消息,各实例收到即各自重载路由/目标地址/应用免签快照,
|
||||
* 免重启、免逐实例点「重载注册表」。
|
||||
* <p>
|
||||
* <b>Redis 在这里的角色是「变更通知」,不是判定主存</b>:路由判定始终读本地 Caffeine(O(1) 不查库),
|
||||
* 故 Redis 异常时各实例停留在本地快照(stale 但准确),绝不会出现「Redis 挂了就放行」——
|
||||
@@ -41,6 +42,9 @@ public class RouteChangeBroadcaster implements MessageListener, DisposableBean {
|
||||
@Autowired
|
||||
private RouteRegistry routeRegistry;
|
||||
|
||||
@Autowired
|
||||
private AppAuthRegistry appAuthRegistry;
|
||||
|
||||
/**
|
||||
* 广播通道走<b>应用主库</b>模板(bean 名 {@code stringRedisTemplate}),而非 appauth starter 的
|
||||
* 密钥专用 {@code signKeyStringRedisTemplate}(那个直连 db0 专供 {@code sign:app:*})。
|
||||
@@ -84,6 +88,7 @@ public class RouteChangeBroadcaster implements MessageListener, DisposableBean {
|
||||
try {
|
||||
routeRegistry.reload();
|
||||
routeRegistry.reloadTargets();
|
||||
appAuthRegistry.reload(); // 应用免签快照与路由/目标同链同步(失败沿用快照,语义同下)
|
||||
log.info("[GatewayRoute] 收到变更广播,本实例注册表已重载");
|
||||
} catch (RuntimeException e) {
|
||||
// 重载失败 = 沿用上一次快照(仍是准确的最严策略,不会放行)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.yunhai.platform.gateway.route;
|
||||
|
||||
import com.yunhai.platform.gateway.auth.AppAuthRegistry;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -25,17 +26,21 @@ public class RouteRefreshController {
|
||||
@Autowired
|
||||
private RouteRegistry routeRegistry;
|
||||
|
||||
@Autowired
|
||||
private AppAuthRegistry appAuthRegistry;
|
||||
|
||||
/** 变更广播(多实例同步);广播开关关闭时该 bean 不存在,故 required=false */
|
||||
@Autowired(required = false)
|
||||
private RouteChangeBroadcaster broadcaster;
|
||||
|
||||
/** 清缓存重 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());
|
||||
body.put("apps", appAuthRegistry.reload());
|
||||
if (broadcaster != null) {
|
||||
broadcaster.publish();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,35 @@
|
||||
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.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;
|
||||
@@ -44,7 +51,9 @@ class InternalAdminControllerTest {
|
||||
private OpenApiRouteRepository routeRepo;
|
||||
private OpenApiTargetRepository targetRepo;
|
||||
private OpenApiLogRepository logRepo;
|
||||
private OpenApiAppRepository appRepo;
|
||||
private RouteRegistry registry;
|
||||
private AppAuthRegistry appRegistry;
|
||||
private MockMvc mvc;
|
||||
|
||||
@BeforeEach
|
||||
@@ -52,18 +61,23 @@ class InternalAdminControllerTest {
|
||||
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);
|
||||
controller.setRepos(routeRepo, targetRepo, logRepo, registry, appRepo, 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())));
|
||||
}
|
||||
@@ -123,6 +137,39 @@ class InternalAdminControllerTest {
|
||||
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());
|
||||
@@ -215,4 +262,218 @@ class InternalAdminControllerTest {
|
||||
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,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();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -33,9 +34,9 @@ class SignVerifyFilterEntryTest {
|
||||
|
||||
private static final String SECRET = "sec-entry-0123456789abcdef";
|
||||
|
||||
private static final RouteSpec INTERNAL_SIGNED = new RouteSpec("INTERNAL", "SIGNED", 0, "CALLER", 0);
|
||||
private static final RouteSpec EXTERNAL_SIGNED = new RouteSpec("EXTERNAL", "SIGNED", 0, "CALLER", 0);
|
||||
private static final RouteSpec EXTERNAL_ANON = new RouteSpec("EXTERNAL", "ANON", 0, "CALLER", 0);
|
||||
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;
|
||||
@@ -193,4 +194,37 @@ class SignVerifyFilterEntryTest {
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -36,7 +36,7 @@ class RouteChangeBroadcasterWiringTest {
|
||||
|
||||
private static final String MAIN_TEMPLATE_BEAN = "stringRedisTemplate";
|
||||
|
||||
/** 复现环境:注册两个同类型模板 + 一个 RouteRegistry */
|
||||
/** 复现环境:注册两个同类型模板 + 一个 RouteRegistry + 一个 AppAuthRegistry(广播器 onMessage 重载对象) */
|
||||
private DefaultListableBeanFactory twoTemplatesFactory() {
|
||||
DefaultListableBeanFactory factory = new DefaultListableBeanFactory();
|
||||
factory.setAutowireCandidateResolver(new QualifierAnnotationAutowireCandidateResolver());
|
||||
@@ -45,6 +45,7 @@ class RouteChangeBroadcasterWiringTest {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user