From 1ae94d7100d4f9a17d4e24d1d9c2949cc7fed37d Mon Sep 17 00:00:00 2001 From: chenxing Date: Fri, 9 Oct 2026 10:37:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20=E5=BA=94=E7=94=A8=E7=BA=A7?= =?UTF-8?q?=E5=85=8D=E7=AD=BE(ANON)=20+=20=E7=AD=BE=E5=90=8D=E9=99=90?= =?UTF-8?q?=E6=B5=81/per-route=20=E8=B6=85=E6=97=B6=20+=20=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E6=B5=8B=E8=AF=95=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .gitignore | 2 + docs/sql/04_app.sql | 29 ++ .../admin/InternalAdminController.java | 200 +++++++++- .../gateway/admin/ProxyTestController.java | 370 ++++++++++++++++++ .../gateway/auth/AppAuthRegistry.java | 91 +++++ .../gateway/auth/SignedRateLimiter.java | 76 ++++ .../platform/gateway/entity/OpenApiApp.java | 78 ++++ .../gateway/filter/DispatchFilter.java | 19 +- .../gateway/filter/RateLimitFilter.java | 75 ++++ .../gateway/filter/SignVerifyFilter.java | 34 +- .../gateway/forward/CallerHeader.java | 15 +- .../gateway/forward/StreamingForwarder.java | 3 +- .../gateway/log/AccessLogService.java | 9 +- .../repository/OpenApiAppRepository.java | 13 + .../gateway/route/RouteChangeBroadcaster.java | 9 +- .../gateway/route/RouteRefreshController.java | 7 +- .../platform/gateway/route/RouteRegistry.java | 9 +- .../platform/gateway/route/RouteSpec.java | 18 +- .../admin/InternalAdminControllerTest.java | 263 ++++++++++++- .../admin/ProxyTestControllerTest.java | 208 ++++++++++ .../gateway/auth/AppAuthRegistryTest.java | 164 ++++++++ .../gateway/auth/SignedRateLimiterTest.java | 65 +++ .../gateway/filter/DispatchFilterTest.java | 103 +++++ .../gateway/filter/RateLimitFilterTest.java | 125 ++++++ .../filter/SignVerifyFilterEntryTest.java | 40 +- .../gateway/filter/SignVerifyFilterTest.java | 88 +++++ .../gateway/forward/CallerHeaderTest.java | 20 +- .../gateway/log/AccessLogServiceTest.java | 46 +++ .../RouteChangeBroadcasterWiringTest.java | 3 +- .../gateway/route/RouteRegistryTest.java | 22 +- src/test/resources/application-test.yml | 3 +- 31 files changed, 2164 insertions(+), 43 deletions(-) create mode 100644 docs/sql/04_app.sql create mode 100644 src/main/java/com/yunhai/platform/gateway/admin/ProxyTestController.java create mode 100644 src/main/java/com/yunhai/platform/gateway/auth/AppAuthRegistry.java create mode 100644 src/main/java/com/yunhai/platform/gateway/auth/SignedRateLimiter.java create mode 100644 src/main/java/com/yunhai/platform/gateway/entity/OpenApiApp.java create mode 100644 src/main/java/com/yunhai/platform/gateway/filter/RateLimitFilter.java create mode 100644 src/main/java/com/yunhai/platform/gateway/repository/OpenApiAppRepository.java create mode 100644 src/test/java/com/yunhai/platform/gateway/admin/ProxyTestControllerTest.java create mode 100644 src/test/java/com/yunhai/platform/gateway/auth/AppAuthRegistryTest.java create mode 100644 src/test/java/com/yunhai/platform/gateway/auth/SignedRateLimiterTest.java create mode 100644 src/test/java/com/yunhai/platform/gateway/filter/DispatchFilterTest.java create mode 100644 src/test/java/com/yunhai/platform/gateway/filter/RateLimitFilterTest.java diff --git a/.gitignore b/.gitignore index 02ac4c4..554584d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ target/ .idea/ *.iml logs/ +gateway-run.log +tmp_*.sql diff --git a/docs/sql/04_app.sql b/docs/sql/04_app.sql new file mode 100644 index 0000000..2a05c32 --- /dev/null +++ b/docs/sql/04_app.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) +); diff --git a/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java b/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java index 154eb91..6bfdaab 100644 --- a/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java +++ b/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java @@ -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 链)。 *

* 前端:治理面 UI 独立在 {@code open-gateway/front}(Vue3),本类不再下发 HTML @@ -61,6 +69,13 @@ public class InternalAdminController { private static final Set AUTH_MODE = new HashSet<>(Arrays.asList("SIGNED", "ANON")); private static final Set 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 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 createApp(@RequestBody Map 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}; + * appKey 不可改(行身份,同路由"不可改 target/pattern"惯例,要换=禁用旧行+新增新行); + * 切 ANON(或对 ANON 行续期)时 expireAt 必填并同 POST 校验——每次保存重新起算 ≤31 天; + * 切回 SIGNED 时 expireAt 置 NULL(注意:须先确认密钥链有该 appKey,否则即刻 401)。 + */ + @Operation(operationId = "GatewayAdmin.updateApp", summary = "编辑应用/启停/续期") + @PutMapping("/apps/{id}") + public Map updateApp(@PathVariable("id") Long id, + @RequestBody Map 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 savedAppResult() { + Map out = new LinkedHashMap<>(); + out.put("apps", appAuthRegistry.reload()); + broadcast(); + return out; + } + /** 变更广播:本实例已就地重载,其余实例靠广播同步(发布失败只记 WARN,不影响本次写操作) */ private void broadcast() { if (broadcaster != null) { diff --git a/src/main/java/com/yunhai/platform/gateway/admin/ProxyTestController.java b/src/main/java/com/yunhai/platform/gateway/admin/ProxyTestController.java new file mode 100644 index 0000000..792affd --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/admin/ProxyTestController.java @@ -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 地址, + * 网关在服务端代为签名并回环打自身的真实 oapi 链(Sanitize→路由判定→验签→转发), + * 把最终 status/耗时/响应体原样带回页面——测的是生产同一条链,不是旁路模拟。 + *

+ * 为什么回环自身而不是直连提供方:排障要复现的正是调用方将经历的完整判定 + * (路由是否命中、验签是否通过、observe 是否放行、转发是否 502)。回环地址取 + * {@code request.getLocalAddr():getLocalPort()}(本机直打,不经 Caddy),天然是 + * 内部入口——与 starter/SDK 的容器网内调用同一口径(一律验签)。 + *

+ * 安全边界:入参 URL 的 scheme/host 一律忽略,只取 path+query 且形状必须为 + * {@code /{target}/oapi/**}——不存在 SSRF 面(只会打到网关自身)。appSecret 支持两种来源: + * 页面明传(INPUT)或留空由 {@link GatewayKeyService} 从密钥链解析(REDIS,fail-closed)。 + * 签名串 canonical 复用 {@link SignVerifyFilter#buildCanonicalTarget}——与验签侧同一实现, + * 杜绝双写漂移;时间戳毫秒(F1 契约)。 + *

+ * 响应契约:与 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 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 proxyTest(@RequestBody Map 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 sign = new LinkedHashMap<>(); + sign.put("appKey", appKey); + sign.put("timestamp", timestamp); + sign.put("canonical", canonical); + sign.put("signature", signature); + sign.put("secretSource", secretSource); + + Map 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 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(); + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/auth/AppAuthRegistry.java b/src/main/java/com/yunhai/platform/gateway/auth/AppAuthRegistry.java new file mode 100644 index 0000000..68e67b0 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/auth/AppAuthRegistry.java @@ -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 快照模式)。 + *

+ * 快照两件:具体 ANON 应用(appKey → expireAt)+ {@code '*'} 匿名窗口行的到期时刻 + * (哨兵行只折算成窗口时刻,不进 appKey 映射——请求头字面 {@code X-App-Key: *} 按无 key 处理)。 + *

+ * fail-closed 缺省(D4):只收 enabled=true 且 auth_mode=ANON 且 expire_at 非空的行; + * 表空 / 无行 / 禁用 / SIGNED / 已过期 一律返回 null 落回验签——存量 appKey 零行为变化。 + * 异常口径同广播链语义:reload 失败沿用上一次快照(stale 但准确)。 + *

+ * 到期判定是惰性的(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 anonApps = Collections.emptyMap(); + + /** '*' 匿名窗口行未过期时的 expireAt;无行/禁用/异常为 null = 无窗口 */ + private volatile LocalDateTime anonWindowUntil; + + @PostConstruct + public void init() { + reload(); + } + + /** 全量重 load(@PostConstruct / refresh 端点 / 广播 / 治理写操作共用);返回装载的具体 key 行数 */ + public int reload() { + Map 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; // 落回验签 + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/auth/SignedRateLimiter.java b/src/main/java/com/yunhai/platform/gateway/auth/SignedRateLimiter.java new file mode 100644 index 0000000..73e725d --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/auth/SignedRateLimiter.java @@ -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):按 appKey × 路由行 判定, + * 与路由级 ANON 的 {@link AnonRateLimiter}(三态、fail-closed)刻意不同口径—— + *

    + *
  • fail-open:SIGNED 流量已过验签,限流是保护性旁路而非准入前提——限流器自身 + * 故障放行 + WARN("限流器短暂失效"远小于"网关全量 503");本组件无外部依赖 + * (纯内存),故障面只剩实现 bug;
  • + *
  • 桶内不存 qps:每次 {@link #tryAcquire} 按当次 qps 计算容量与补充速率, + * 容量对存量令牌取 min 截断——治理页热调 qps 后(走既有 reload 链)下一次请求即按 + * 新值,旧桶靠 Caffeine expire 自然淘汰;
  • + *
  • 容量 = qps(允许 1 秒突发);补充按惰性计算,acquire = 补充后 tokens≥1 扣减。
  • + *
+ * 多实例部署下每实例各自限额(总额 = 行 qps × 实例数),当前单实例语义无损; + * 桶 key 已含全部维度,将来 Redis 化可平移。 + */ +@Component +public class SignedRateLimiter { + + private static final Logger log = LoggerFactory.getLogger(SignedRateLimiter.class); + + /** 长尾 key(调用方×行)自动回收;桶数 = appKey × 启用行,量级几百,50_000 是防御上限 */ + private final Cache 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; + } + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/entity/OpenApiApp.java b/src/main/java/com/yunhai/platform/gateway/entity/OpenApiApp.java new file mode 100644 index 0000000..ec59a2c --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/entity/OpenApiApp.java @@ -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,字段与列逐一对齐)。 + *

+ * 只管治理属性(免签与否),不管密钥——密钥链(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(); + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/filter/DispatchFilter.java b/src/main/java/com/yunhai/platform/gateway/filter/DispatchFilter.java index eb4a2eb..5d994c6 100644 --- a/src/main/java/com/yunhai/platform/gateway/filter/DispatchFilter.java +++ b/src/main/java/com/yunhai/platform/gateway/filter/DispatchFilter.java @@ -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)。 *

- * 身份注入(链序第 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); } } diff --git a/src/main/java/com/yunhai/platform/gateway/filter/RateLimitFilter.java b/src/main/java/com/yunhai/platform/gateway/filter/RateLimitFilter.java new file mode 100644 index 0000000..2729b3e --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/filter/RateLimitFilter.java @@ -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)之前—— + * 身份注入仍在转发器内完成,本环只判定不注头。 + *

+ * 判定对象仅 {@code PASS}/{@code APP_ANON}(有身份的调用方同口径限); + * {@code FAIL_OBSERVED} 不判——observe 是过渡态,其流量多为密钥配错的合法调用方, + * observe 期限流会放大误伤(observe 切 false 后该态自然消失);路由级 ANON 维持 + * 自己的占位限流路径({@code SignVerifyFilter#anonBranch})不经本环。 + *

+ * 桶粒度 = appKey × 路由行({@code spec.routeKey});qps 缺省 0=不限(存量零变化)。 + * 超限 裸 429(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); + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/filter/SignVerifyFilter.java b/src/main/java/com/yunhai/platform/gateway/filter/SignVerifyFilter.java index d2f4c04..a9b8074 100644 --- a/src/main/java/com/yunhai/platform/gateway/filter/SignVerifyFilter.java +++ b/src/main/java/com/yunhai/platform/gateway/filter/SignVerifyFilter.java @@ -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; *

* 先分入口,再分策略(落地总纲 §0.1 判定矩阵): *

    - *
  • 内部入口(容器网直连,无标记)—— 一律验签,不看 {@code visibility}/{@code authMode}。 + *
  • 内部入口(容器网直连,无标记)—— 先查应用免签注册表 {@link AppAuthRegistry}: + * {@code auth_mode=ANON} 的应用凭 {@code X-App-Key} 免签放行(verdict={@link #VERDICT_APP_ANON}, + * 台账 caller=appKey),{@code '*'} 匿名窗口开启时无票据请求也放行(verdict={@link #VERDICT_ANON}, + * caller=anonymous)——仅内部入口生效,永不外溢到外部(应用级免签 C 方向 D2/D3); + * 未命中(表空/无行/禁用/过期)一律验签,不看 {@code visibility}/{@code authMode}。 * 验签成本≈0,而 appKey 是限流/缓存隔离/台账归因的唯一键;
  • *
  • 外部入口({@link EntryDetector}:来源可信 且 {@code X-Entry: external})—— * {@code INTERNAL} 路由裸 403;{@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; } diff --git a/src/main/java/com/yunhai/platform/gateway/forward/CallerHeader.java b/src/main/java/com/yunhai/platform/gateway/forward/CallerHeader.java index cd83fc6..b850690 100644 --- a/src/main/java/com/yunhai/platform/gateway/forward/CallerHeader.java +++ b/src/main/java/com/yunhai/platform/gateway/forward/CallerHeader.java @@ -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} 剥除客户端伪造头 - * 之后(顺序保证身份头唯一合法来源是网关);不注入任何其他身份字段 + * 身份注入判定(T1.4):判定为 PASS(验签)或 APP_ANON(应用级免签,C 方向 G9——ANON 应用为 + * 自报未验身份,仅内部入口)时,转发请求注入 {@code X-Caller-App-Key: {appKey}}; + * FAIL/FAIL_OBSERVED/ANON(路由级匿名与匿名窗口)一律不注入——匿名就是匿名。 + * 注入动作发生在 {@link StreamingForwarder} 剥除客户端伪造头之后 + * (顺序保证身份头唯一合法来源是网关);不注入任何其他身份字段 * (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); diff --git a/src/main/java/com/yunhai/platform/gateway/forward/StreamingForwarder.java b/src/main/java/com/yunhai/platform/gateway/forward/StreamingForwarder.java index 09c6ac4..b5f7af9 100644 --- a/src/main/java/com/yunhai/platform/gateway/forward/StreamingForwarder.java +++ b/src/main/java/com/yunhai/platform/gateway/forward/StreamingForwarder.java @@ -39,7 +39,8 @@ import java.util.concurrent.TimeUnit; * 请求线程不阻塞等待后端。 *
      *
    • 转发 URL 取 T0.12 {@code TargetRouter.Routing.forwardUrl}(换主机不换路径);
    • - *
    • 读超时默认 30s({@link #DEFAULT_READ_TIMEOUT_MS};route.timeout_ms 覆盖,本期注册表不配 = 统一默认);
    • + *
    • 读超时默认 30s({@link #DEFAULT_READ_TIMEOUT_MS};route.timeout_ms 经 {@code RouteSpec} + * per-route 覆盖,未配/≤0 = 统一默认);
    • *
    • 请求头:保留 {@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 之间的约定); diff --git a/src/main/java/com/yunhai/platform/gateway/log/AccessLogService.java b/src/main/java/com/yunhai/platform/gateway/log/AccessLogService.java index d8233de..01575cd 100644 --- a/src/main/java/com/yunhai/platform/gateway/log/AccessLogService.java +++ b/src/main/java/com/yunhai/platform/gateway/log/AccessLogService.java @@ -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; diff --git a/src/main/java/com/yunhai/platform/gateway/repository/OpenApiAppRepository.java b/src/main/java/com/yunhai/platform/gateway/repository/OpenApiAppRepository.java new file mode 100644 index 0000000..bf9b642 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/repository/OpenApiAppRepository.java @@ -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 { + + List findByEnabledTrue(); + + boolean existsByAppKey(String appKey); +} diff --git a/src/main/java/com/yunhai/platform/gateway/route/RouteChangeBroadcaster.java b/src/main/java/com/yunhai/platform/gateway/route/RouteChangeBroadcaster.java index b5a0374..c451b6a 100644 --- a/src/main/java/com/yunhai/platform/gateway/route/RouteChangeBroadcaster.java +++ b/src/main/java/com/yunhai/platform/gateway/route/RouteChangeBroadcaster.java @@ -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} 发一条消息,各实例收到即各自重载路由/目标地址/应用免签快照, + * 免重启、免逐实例点「重载注册表」。 *

      * Redis 在这里的角色是「变更通知」,不是判定主存:路由判定始终读本地 Caffeine(O(1) 不查库), * 故 Redis 异常时各实例停留在本地快照(stale 但准确),绝不会出现「Redis 挂了就放行」—— @@ -41,6 +42,9 @@ public class RouteChangeBroadcaster implements MessageListener, DisposableBean { @Autowired private RouteRegistry routeRegistry; + @Autowired + private AppAuthRegistry appAuthRegistry; + /** * 广播通道走应用主库模板(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) { // 重载失败 = 沿用上一次快照(仍是准确的最严策略,不会放行) diff --git a/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java b/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java index ac2850a..840c29a 100644 --- a/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java +++ b/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java @@ -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 refresh() { Map body = new LinkedHashMap<>(); body.put("loaded", routeRegistry.reload()); body.put("targets", routeRegistry.reloadTargets()); + body.put("apps", appAuthRegistry.reload()); if (broadcaster != null) { broadcaster.publish(); } diff --git a/src/main/java/com/yunhai/platform/gateway/route/RouteRegistry.java b/src/main/java/com/yunhai/platform/gateway/route/RouteRegistry.java index 78fcc19..83bacb0 100644 --- a/src/main/java/com/yunhai/platform/gateway/route/RouteRegistry.java +++ b/src/main/java/com/yunhai/platform/gateway/route/RouteRegistry.java @@ -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(); diff --git a/src/main/java/com/yunhai/platform/gateway/route/RouteSpec.java b/src/main/java/com/yunhai/platform/gateway/route/RouteSpec.java index 9063e29..6f5d974 100644 --- a/src/main/java/com/yunhai/platform/gateway/route/RouteSpec.java +++ b/src/main/java/com/yunhai/platform/gateway/route/RouteSpec.java @@ -1,26 +1,36 @@ package com.yunhai.platform.gateway.route; /** - * 路由判定结果(T0.10):注册表条目中参与网关判定的五字段提炼。 + * 路由判定结果(T0.10):注册表条目中参与网关判定的字段提炼。 * 未命中时返回 {@link #DEFAULT}——默认值安全:忘配 = 最严判定(INTERNAL+SIGNED+零缓存+不限流), * 不是裸奔。 + *

      + * {@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; } } diff --git a/src/test/java/com/yunhai/platform/gateway/admin/InternalAdminControllerTest.java b/src/test/java/com/yunhai/platform/gateway/admin/InternalAdminControllerTest.java index 490ca38..50e8d0f 100644 --- a/src/test/java/com/yunhai/platform/gateway/admin/InternalAdminControllerTest.java +++ b/src/test/java/com/yunhai/platform/gateway/admin/InternalAdminControllerTest.java @@ -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 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 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 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 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(); + } } diff --git a/src/test/java/com/yunhai/platform/gateway/admin/ProxyTestControllerTest.java b/src/test/java/com/yunhai/platform/gateway/admin/ProxyTestControllerTest.java new file mode 100644 index 0000000..98295c6 --- /dev/null +++ b/src/test/java/com/yunhai/platform/gateway/admin/ProxyTestControllerTest.java @@ -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 系治理小页):回环打真实链的核心契约—— + *

        + *
      • URL 容忍式解析与形状闸(/{target}/oapi/**,第二段必须恰为 oapi);
      • + *
      • 容错编码:空格/中文百分号编码、既有 %xx 原样保留;
      • + *
      • 签名可被平台 {@link SignUtil#verifyRequestWithExpire} 验回(探针与验签同一 canonical, + * query 排序口径共享);
      • + *
      • 密钥两来源(INPUT 页面明传 / REDIS 密钥链解析 fail-closed);
      • + *
      • POST body 透传与非法入参 HTTP 200 + {code:2}。
      • + *
      + * 回环目标用 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 capturedMethod = new AtomicReference<>(); + private final AtomicReference capturedUri = new AtomicReference<>(); + private final AtomicReference capturedBody = new AtomicReference<>(); + private final Map 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)); + } +} diff --git a/src/test/java/com/yunhai/platform/gateway/auth/AppAuthRegistryTest.java b/src/test/java/com/yunhai/platform/gateway/auth/AppAuthRegistryTest.java new file mode 100644 index 0000000..796fb13 --- /dev/null +++ b/src/test/java/com/yunhai/platform/gateway/auth/AppAuthRegistryTest.java @@ -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(); + } +} diff --git a/src/test/java/com/yunhai/platform/gateway/auth/SignedRateLimiterTest.java b/src/test/java/com/yunhai/platform/gateway/auth/SignedRateLimiterTest.java new file mode 100644 index 0000000..019327b --- /dev/null +++ b/src/test/java/com/yunhai/platform/gateway/auth/SignedRateLimiterTest.java @@ -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(); + } +} diff --git a/src/test/java/com/yunhai/platform/gateway/filter/DispatchFilterTest.java b/src/test/java/com/yunhai/platform/gateway/filter/DispatchFilterTest.java new file mode 100644 index 0000000..c9de7ad --- /dev/null +++ b/src/test/java/com/yunhai/platform/gateway/filter/DispatchFilterTest.java @@ -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 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 forwardAndCaptureBodyLimit(MockHttpServletRequest req) throws Exception { + filter.doFilter(req, new MockHttpServletResponse(), mock(FilterChain.class)); + ArgumentCaptor 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 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 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 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); + } +} diff --git a/src/test/java/com/yunhai/platform/gateway/filter/RateLimitFilterTest.java b/src/test/java/com/yunhai/platform/gateway/filter/RateLimitFilterTest.java new file mode 100644 index 0000000..2cacb62 --- /dev/null +++ b/src/test/java/com/yunhai/platform/gateway/filter/RateLimitFilterTest.java @@ -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(); + } +} diff --git a/src/test/java/com/yunhai/platform/gateway/filter/SignVerifyFilterEntryTest.java b/src/test/java/com/yunhai/platform/gateway/filter/SignVerifyFilterEntryTest.java index 9b09f4f..d493aff 100644 --- a/src/test/java/com/yunhai/platform/gateway/filter/SignVerifyFilterEntryTest.java +++ b/src/test/java/com/yunhai/platform/gateway/filter/SignVerifyFilterEntryTest.java @@ -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); + } } diff --git a/src/test/java/com/yunhai/platform/gateway/filter/SignVerifyFilterTest.java b/src/test/java/com/yunhai/platform/gateway/filter/SignVerifyFilterTest.java index 7ae15c4..3350023 100644 --- a/src/test/java/com/yunhai/platform/gateway/filter/SignVerifyFilterTest.java +++ b/src/test/java/com/yunhai/platform/gateway/filter/SignVerifyFilterTest.java @@ -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); + } } diff --git a/src/test/java/com/yunhai/platform/gateway/forward/CallerHeaderTest.java b/src/test/java/com/yunhai/platform/gateway/forward/CallerHeaderTest.java index 01cba3d..0c6e60d 100644 --- a/src/test/java/com/yunhai/platform/gateway/forward/CallerHeaderTest.java +++ b/src/test/java/com/yunhai/platform/gateway/forward/CallerHeaderTest.java @@ -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 headers = forwardWithVerdict(SignVerifyFilter.VERDICT_APP_ANON, "100009", null); + assertThat(headers.get("x-caller-app-key")).isEqualTo("100009"); + } + + @Test + void APP_ANON_伪造头先剥后注入() throws Exception { + Map 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 headers = forwardWithVerdict(SignVerifyFilter.VERDICT_FAIL_OBSERVED, null, null); diff --git a/src/test/java/com/yunhai/platform/gateway/log/AccessLogServiceTest.java b/src/test/java/com/yunhai/platform/gateway/log/AccessLogServiceTest.java index ee2b6d3..3f04502 100644 --- a/src/test/java/com/yunhai/platform/gateway/log/AccessLogServiceTest.java +++ b/src/test/java/com/yunhai/platform/gateway/log/AccessLogServiceTest.java @@ -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(); + } } diff --git a/src/test/java/com/yunhai/platform/gateway/route/RouteChangeBroadcasterWiringTest.java b/src/test/java/com/yunhai/platform/gateway/route/RouteChangeBroadcasterWiringTest.java index 2b37519..b34b8f8 100644 --- a/src/test/java/com/yunhai/platform/gateway/route/RouteChangeBroadcasterWiringTest.java +++ b/src/test/java/com/yunhai/platform/gateway/route/RouteChangeBroadcasterWiringTest.java @@ -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; } diff --git a/src/test/java/com/yunhai/platform/gateway/route/RouteRegistryTest.java b/src/test/java/com/yunhai/platform/gateway/route/RouteRegistryTest.java index 8fdd957..e9ba07c 100644 --- a/src/test/java/com/yunhai/platform/gateway/route/RouteRegistryTest.java +++ b/src/test/java/com/yunhai/platform/gateway/route/RouteRegistryTest.java @@ -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 diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index a25ea09..bb02cfd 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -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