diff --git a/src/main/java/com/yunhai/platform/gateway/filter/AccessLogFilter.java b/src/main/java/com/yunhai/platform/gateway/filter/AccessLogFilter.java new file mode 100644 index 0000000..94270b3 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/filter/AccessLogFilter.java @@ -0,0 +1,116 @@ +package com.yunhai.platform.gateway.filter; + +import com.yunhai.platform.gateway.log.AccessLogService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import javax.servlet.AsyncEvent; +import javax.servlet.AsyncListener; +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 台账采集(T1.6 链序第 0 环,最外层):只记 oapi 请求(网关自身 {@code /api/internal/**} 不记), + * 出口状态取网关真实响应码,costMs 取端到端耗时。 + *

+ * 转发是异步的(T1.3 {@code AsyncContext})——请求线程提前返回时响应尚未完成, + * 此时挂 {@link AsyncListener} 在 complete 时记录,保证台账 status 不是"半途值"; + * 记录动作本身在 {@link AccessLogService} 内只入队,绝不拖慢请求。 + */ +@Component +@Order(0) +public class AccessLogFilter implements Filter { + + private static final String ATTR_START_NANOS = "yh.gateway.start.nanos"; + + @Autowired + private AccessLogService accessLogService; + + /** 测试/显式装配入口 */ + void setAccessLogService(AccessLogService accessLogService) { + this.accessLogService = accessLogService; + } + + @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); // 网关自身端点不进 open_api 台账 + return; + } + req.setAttribute(ATTR_START_NANOS, System.nanoTime()); + try { + chain.doFilter(request, response); + } finally { + if (req.isAsyncStarted()) { + req.getAsyncContext().addListener(new LedgerListener(req, res)); + } else { + record(req, res); + } + } + } + + private void record(HttpServletRequest req, HttpServletResponse res) { + Object start = req.getAttribute(ATTR_START_NANOS); + int costMs = start instanceof Long ? (int) ((System.nanoTime() - (Long) start) / 1_000_000L) : 0; + // backendCode:流式转发零缓冲(T1.3),本期不解析提供方信封 code → null + accessLogService.recordRequest(req, res.getStatus(), null, costMs); + } + + /** 异步收尾(complete/timeout/error)时记一次,重复触发由 claimed 挡住 */ + private class LedgerListener implements AsyncListener { + private final HttpServletRequest req; + private final HttpServletResponse res; + private final java.util.concurrent.atomic.AtomicBoolean claimed = + new java.util.concurrent.atomic.AtomicBoolean(); + + LedgerListener(HttpServletRequest req, HttpServletResponse res) { + this.req = req; + this.res = res; + } + + @Override + public void onComplete(AsyncEvent event) { + recordOnce(); + } + + @Override + public void onTimeout(AsyncEvent event) { + recordOnce(); + } + + @Override + public void onError(AsyncEvent event) { + recordOnce(); + } + + @Override + public void onStartAsync(AsyncEvent event) { + // 网关不嵌套异步:忽略 + } + + private void recordOnce() { + if (claimed.compareAndSet(false, true)) { + try { + record(req, res); + } catch (RuntimeException e) { + // 台账故障绝不影响请求收尾 + } + } + } + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/filter/DispatchFilter.java b/src/main/java/com/yunhai/platform/gateway/filter/DispatchFilter.java new file mode 100644 index 0000000..eb4a2eb --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/filter/DispatchFilter.java @@ -0,0 +1,61 @@ +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.TargetRouter; +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; + +/** + * 链尾转发(T1.6 链序第 5 环):oapi 请求到此一律交给 {@link StreamingForwarder} + * 流式送提供方并原样回传,链在此终结(不再回到网关的 DispatcherServlet)。 + *

+ * 身份注入(链序第 4 环)在转发器内完成(T1.4 {@code CallerHeader}),所以本环只需带上 + * 请求属性。缺路由 attribute(未经 RoutingFilter 的异常路径)按 404 收口,绝不裸转。 + * 超时/体积暂无 per-route 覆盖(RouteSpec 不含该列),用转发器默认值。 + */ +@Component +@Order(5) +public class DispatchFilter implements Filter { + + @Autowired + private StreamingForwarder forwarder; + + /** 测试/显式装配入口 */ + void setForwarder(StreamingForwarder forwarder) { + this.forwarder = forwarder; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) { + chain.doFilter(request, response); + return; + } + HttpServletRequest req = (HttpServletRequest) request; + HttpServletResponse res = (HttpServletResponse) response; + String uri = RoutingFilter.sanitizedUri(req); + if (RoutingFilter.isGatewayInternal(uri)) { + chain.doFilter(request, response); // 自身端点:交给 controller,不经提供方 + return; + } + Object routing = req.getAttribute(RoutingFilter.ATTR_ROUTING); + if (!(routing instanceof TargetRouter.Routing)) { + EnvelopeWriter.writeBare(res, 404); + return; + } + forwarder.forward(req, res, ((TargetRouter.Routing) routing).forwardUrl, + StreamingForwarder.DEFAULT_READ_TIMEOUT_MS, StreamingForwarder.DEFAULT_BODY_LIMIT_BYTES); + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/filter/RoutingFilter.java b/src/main/java/com/yunhai/platform/gateway/filter/RoutingFilter.java new file mode 100644 index 0000000..7049b00 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/filter/RoutingFilter.java @@ -0,0 +1,89 @@ +package com.yunhai.platform.gateway.filter; + +import com.yunhai.platform.gateway.envelope.EnvelopeWriter; +import com.yunhai.platform.gateway.route.RouteRegistry; +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; +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; + +/** + * 路由判定(T1.6 链序第 2 环):Sanitize 之后、验签之前,把 URI 定到 target 与提供方 + * ({@link TargetRouter},未知 target / 非 oapi 第二段 = 裸 404,协议层错误永不放行), + * 并取注册表最严策略({@link RouteRegistry})存入 attribute 供后续环节消费。 + *

+ * {@code /api/internal/**} 属网关自身路径(如 T0.10 刷新端点),不进 oapi 链、不验签、不转发。 + */ +@Component +@Order(2) +public class RoutingFilter implements Filter { + + /** 网关自身管理端点前缀:绕开 oapi 判定链(T0.10 /api/internal/routes/refresh) */ + public static final String INTERNAL_PREFIX = "/api/internal"; + + /** 路由产物 attribute({@link TargetRouter.Routing}) */ + public static final String ATTR_ROUTING = "yh.gateway.routing"; + /** 注册表策略 attribute({@link RouteSpec}) */ + public static final String ATTR_ROUTE_SPEC = "yh.gateway.route.spec"; + + @Autowired + private TargetRouter targetRouter; + + @Autowired + private RouteRegistry routeRegistry; + + /** 测试/显式装配入口 */ + void setTargetRouter(TargetRouter targetRouter) { + this.targetRouter = targetRouter; + } + + void setRouteRegistry(RouteRegistry routeRegistry) { + this.routeRegistry = routeRegistry; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) { + chain.doFilter(request, response); + return; + } + HttpServletRequest req = (HttpServletRequest) request; + HttpServletResponse res = (HttpServletResponse) response; + String uri = sanitizedUri(req); + if (isGatewayInternal(uri)) { + chain.doFilter(request, response); // 自身端点:直达 controller + return; + } + TargetRouter.Routing routing = targetRouter.route(uri, req.getQueryString()); + if (routing == null) { + EnvelopeWriter.writeBare(res, 404); // 404 属协议层,与 observe 无关 + return; + } + req.setAttribute(ATTR_ROUTING, routing); + req.setAttribute(ATTR_ROUTE_SPEC, + routeRegistry.resolve(routing.target, routing.pathAfterStrip, req.getMethod())); + chain.doFilter(request, response); + } + + /** 一律消费 Sanitize 归一化后的原始 URI;缺 attribute(未经 Sanitize)时退回 getRequestURI */ + static String sanitizedUri(HttpServletRequest req) { + Object attr = req.getAttribute(SanitizeFilter.ATTR_SANITIZED_URI); + return attr instanceof String ? (String) attr : req.getRequestURI(); + } + + static boolean isGatewayInternal(String uri) { + return uri != null + && (uri.equals(INTERNAL_PREFIX) || uri.startsWith(INTERNAL_PREFIX + "/")); + } +} 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 d6c8360..dbd5aa9 100644 --- a/src/main/java/com/yunhai/platform/gateway/filter/SignVerifyFilter.java +++ b/src/main/java/com/yunhai/platform/gateway/filter/SignVerifyFilter.java @@ -75,6 +75,10 @@ public class SignVerifyFilter implements Filter { } HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse res = (HttpServletResponse) response; + if (RoutingFilter.isGatewayInternal(RoutingFilter.sanitizedUri(req))) { + chain.doFilter(request, response); // 网关自身端点不经 oapi 验签 + return; + } boolean pass = false; String appKey = req.getHeader("X-App-Key"); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 94d30d4..40f53cf 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -22,9 +22,10 @@ spring: batch_size: 200 order_inserts: true redis: - # 密钥链(sign:app:{appKey})走共享 Redis db0——由平台配置层注入 - host: # - port: # + # 密钥链(sign:app:{appKey})走共享 Redis db0——host/port 由平台配置层注入。 + # 注意:jar 内只留注释,不留 `host:` 空键(空值绑不到 int 会让 context 起不来) + # host: <平台配置层> + # port: <平台配置层> main: allow-bean-definition-overriding: true @@ -39,20 +40,22 @@ management: enabled: true yh: - app: - auth: - # starter 验签配置(T0.11 起 GatewayKeyService 复用 SignKeyResolver) - # include/exclude 路径策略由平台配置层下发;本文件不配任何真实值 - enabled: # 默认随 starter + # starter 的服务端验签过滤器(AppAuthFilter,注册在 HIGHEST_PRECEDENCE+10)必须关掉: + # 网关自身的 SignVerifyFilter(T1.2) 是唯一验签面,否则它会抢在链首把合法流量按 + # "完整 URI 含 target 段"验签 → 全部 401。注意前缀是 yh.app-auth(不是 yh.app.auth)。 + # 密钥链 SignKeyRedisAutoConfiguration 独立装配,不受此开关影响(GatewayKeyService 仍可用)。 + app-auth: + enabled: false gateway: # 路由表(T0.12):target -> base-url,值 = 容器网内服务直连 URL,走平台配置层 - routes: # - # userbase: http://userbase-service:8080 - # userclass: http://userclass-service:8080 - # cms: http://cms-service:8080 - # svc-hall: http://svc-hall-service:8080 - # flow-center: http://flow-center-service:8080 - # usermsg: http://usermsg-service:8080 - # notice: http://notice-service:8080 + # (只留注释不留空键:空值会让 Binder 把 Map 绑成 null) + # routes: + # userbase: http://userbase-service:8080 + # userclass: http://userclass-service:8080 + # cms: http://cms-service:8080 + # svc-hall: http://svc-hall-service:8080 + # flow-center: http://flow-center-service:8080 + # usermsg: http://usermsg-service:8080 + # notice: http://notice-service:8080 # 观察模式(T1.6):true = 全判定只记不拦(R4:P1 只观察);生产初期保持 true observe: true diff --git a/src/test/java/com/yunhai/platform/gateway/chain/GatewayChainTest.java b/src/test/java/com/yunhai/platform/gateway/chain/GatewayChainTest.java new file mode 100644 index 0000000..d67c230 --- /dev/null +++ b/src/test/java/com/yunhai/platform/gateway/chain/GatewayChainTest.java @@ -0,0 +1,182 @@ +package com.yunhai.platform.gateway.chain; + +import com.sun.net.httpserver.HttpServer; +import com.yunhai.platform.gateway.auth.GatewayKeyService; +import com.yunhai.platform.gateway.entity.OpenApiLog; +import com.yunhai.platform.gateway.repository.OpenApiLogRepository; +import com.yunhai.platform.utils.crypto.SignUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * T1.6 链序集成测试(observe=true 默认):Sanitize(0/1) → Routing(2) → SignVerify(3) → + * 身份注入(4,转发器内) → Dispatch(5) 全链走通——假后端收到注入的 X-Caller-App-Key、 + * 伪造值被剥、响应原样透传、台账异步入库;observe 语义边界:坏签名放行、 + * 但协议层错误(400/404)不受 observe 影响永远拦;/api/internal/** 自身端点不被链拦截。 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class GatewayChainTest { + + private static final String SECRET = "sec-chain-0123456789abcdef"; + private static final HttpServer BACKEND; + private static final int BACKEND_PORT; + static final AtomicReference CALLER_SEEN = new AtomicReference<>(); + static final AtomicReference URI_SEEN = new AtomicReference<>(); + + static { + try { + BACKEND = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + BACKEND.setExecutor(Executors.newFixedThreadPool(4)); + BACKEND.createContext("/", exchange -> { + CALLER_SEEN.set(exchange.getRequestHeaders().getFirst("X-Caller-App-Key")); + URI_SEEN.set(exchange.getRequestURI().toString()); + byte[] body = "pong".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("X-Upstream", "v1"); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + exchange.close(); + }); + BACKEND.start(); + BACKEND_PORT = BACKEND.getAddress().getPort(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + @DynamicPropertySource + static void routes(DynamicPropertyRegistry registry) { + registry.add("yh.gateway.routes.demo", () -> "http://127.0.0.1:" + BACKEND_PORT); + } + + @Autowired + TestRestTemplate rest; + @Value("${local.server.port}") + int port; + @Autowired + OpenApiLogRepository logRepository; + @MockBean + GatewayKeyService keyService; + + @BeforeEach + void setUp() { + logRepository.deleteAll(); + CALLER_SEEN.set(null); + URI_SEEN.set(null); + when(keyService.getSecret("100617")).thenReturn(SECRET); + } + + private HttpHeaders signedHeaders() { + return signedHeaders("/oapi/hello"); + } + + /** 客户端签名口径 = 剥掉 target 段后的 path[?query](F1),故 query 必须进 canonical */ + private HttpHeaders signedHeaders(String pathToSign) { + String ts = String.valueOf(System.currentTimeMillis()); + HttpHeaders h = new HttpHeaders(); + h.add("X-App-Key", "100617"); + h.add("X-Timestamp", ts); + h.add("X-Signature", SignUtil.signRequest("GET", pathToSign, ts, SECRET)); + return h; + } + + /** 台账异步落库(500ms 周期 + 异步 complete),轮询等待 */ + private List awaitLedger() throws InterruptedException { + for (int i = 0; i < 100; i++) { + List all = logRepository.findAll(); + if (!all.isEmpty()) { + return all; + } + Thread.sleep(50); + } + return logRepository.findAll(); + } + + @Test + void 全链走通_身份注入_响应透传_台账入库() throws Exception { + ResponseEntity resp = rest.exchange("/demo/oapi/hello", HttpMethod.GET, + new HttpEntity<>(signedHeaders()), String.class); + + assertThat(resp.getStatusCodeValue()).isEqualTo(200); + assertThat(resp.getBody()).as("提供方响应体原样透传").isEqualTo("pong"); + assertThat(resp.getHeaders().getFirst("X-Upstream")).as("提供方响应头原样透传").isEqualTo("v1"); + assertThat(URI_SEEN.get()).as("换主机不换路径").isEqualTo("/demo/oapi/hello"); + assertThat(CALLER_SEEN.get()).as("链序终值 = 网关注入的验签 appKey").isEqualTo("100617"); + + List ledger = awaitLedger(); + assertThat(ledger).as("台账入库").isNotEmpty(); + OpenApiLog row = ledger.get(0); + assertThat(row.getTargetCode()).isEqualTo("demo"); + assertThat(row.getPath()).isEqualTo("/oapi/hello"); + assertThat(row.getCallerAppKey()).isEqualTo("100617"); + assertThat(row.getStatus()).isEqualTo(200); + } + + @Test + void 伪造身份头被剥_终值为网关注入() throws Exception { + HttpHeaders h = signedHeaders(); + h.add("X-Caller-App-Key", "hack"); + ResponseEntity resp = rest.exchange("/demo/oapi/hello", HttpMethod.GET, + new HttpEntity<>(h), String.class); + assertThat(resp.getStatusCodeValue()).isEqualTo(200); + assertThat(CALLER_SEEN.get()).as("先剥后注入:链上终值唯一").isEqualTo("100617"); + } + + @Test + void observe为true坏签名放行且台账有记录() throws Exception { + String ts = String.valueOf(System.currentTimeMillis()); + HttpHeaders h = new HttpHeaders(); + h.add("X-App-Key", "100617"); + h.add("X-Timestamp", ts); + h.add("X-Signature", "deadbeef"); + ResponseEntity resp = rest.exchange("/demo/oapi/hello", HttpMethod.GET, + new HttpEntity<>(h), String.class); + + assertThat(resp.getStatusCodeValue()).as("R4:P1 只观察不拦").isEqualTo(200); + assertThat(CALLER_SEEN.get()).as("FAIL_OBSERVED 不注入身份").isNull(); + List ledger = awaitLedger(); + assertThat(ledger).as("坏签名也进台账(caller 尽力记)").isNotEmpty(); + assertThat(ledger.get(0).getCallerAppKey()).isEqualTo("100617"); + } + + @Test + void observe不影响协议层错误_400与404永远拦() throws Exception { + // 原始 URI 直发(不经客户端归一化):编码形态 %2e%2e 解码后是 ..,Sanitize 必裸 400 + URI traversal = new URI("http://127.0.0.1:" + port + "/demo/oapi/a/%2e%2e/b"); + assertThat(rest.getForEntity(traversal, String.class).getStatusCodeValue()) + .as("Sanitize 400 不受 observe 影响").isEqualTo(400); + assertThat(rest.getForEntity("/noway/oapi/x", String.class).getStatusCodeValue()) + .as("未知 target 404 不受 observe 影响").isEqualTo(404); + } + + @Test + void 网关自身internal端点不被链拦_refresh可用() { + ResponseEntity resp = rest.postForEntity("/api/internal/routes/refresh", null, String.class); + assertThat(resp.getStatusCodeValue()).as("自身端点走 controller,不进 oapi 链").isEqualTo(200); + } +} diff --git a/src/test/java/com/yunhai/platform/gateway/chain/GatewayEnforceChainTest.java b/src/test/java/com/yunhai/platform/gateway/chain/GatewayEnforceChainTest.java new file mode 100644 index 0000000..9eef8cb --- /dev/null +++ b/src/test/java/com/yunhai/platform/gateway/chain/GatewayEnforceChainTest.java @@ -0,0 +1,131 @@ +package com.yunhai.platform.gateway.chain; + +import com.sun.net.httpserver.HttpServer; +import com.yunhai.platform.gateway.auth.GatewayKeyService; +import com.yunhai.platform.gateway.entity.OpenApiLog; +import com.yunhai.platform.gateway.repository.OpenApiLogRepository; +import com.yunhai.platform.utils.crypto.SignUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * T1.6 observe=false(收口形态)对照用例:坏签名不再只记,而是裸 401 拦下、 + * 请求不到提供方;台账仍记录该次判定(status=401,caller 尽力记 appKey)。 + * 与 {@link GatewayChainTest} 分属两个 Spring context(observe 属性不同)。 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "yh.gateway.observe=false") +@ActiveProfiles("test") +class GatewayEnforceChainTest { + + private static final String SECRET = "sec-enforce-0123456789abc"; + private static final HttpServer BACKEND; + private static final int BACKEND_PORT; + static final AtomicReference CALLER_SEEN = new AtomicReference<>(); + + static { + try { + BACKEND = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + BACKEND.setExecutor(Executors.newFixedThreadPool(2)); + BACKEND.createContext("/", exchange -> { + CALLER_SEEN.set(exchange.getRequestHeaders().getFirst("X-Caller-App-Key")); + byte[] body = "pong".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + exchange.close(); + }); + BACKEND.start(); + BACKEND_PORT = BACKEND.getAddress().getPort(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + @DynamicPropertySource + static void routes(DynamicPropertyRegistry registry) { + registry.add("yh.gateway.routes.demo", () -> "http://127.0.0.1:" + BACKEND_PORT); + } + + @Autowired + TestRestTemplate rest; + @Autowired + OpenApiLogRepository logRepository; + @MockBean + GatewayKeyService keyService; + + @BeforeEach + void setUp() { + logRepository.deleteAll(); + CALLER_SEEN.set(null); + when(keyService.getSecret("100617")).thenReturn(SECRET); + } + + private List awaitLedger() throws InterruptedException { + for (int i = 0; i < 100; i++) { + List all = logRepository.findAll(); + if (!all.isEmpty()) { + return all; + } + Thread.sleep(50); + } + return logRepository.findAll(); + } + + @Test + void 坏签名observe为false裸401且不到提供方() throws Exception { + HttpHeaders h = new HttpHeaders(); + h.add("X-App-Key", "100617"); + h.add("X-Timestamp", String.valueOf(System.currentTimeMillis())); + h.add("X-Signature", "deadbeef"); + + ResponseEntity resp = rest.exchange("/demo/oapi/hello", HttpMethod.GET, + new HttpEntity<>(h), String.class); + + assertThat(resp.getStatusCodeValue()).as("收口形态:裸 401").isEqualTo(401); + assertThat(resp.getBody()).as("裸码无信封").isNullOrEmpty(); + assertThat(CALLER_SEEN.get()).as("请求不得送达提供方").isNull(); + + List ledger = awaitLedger(); + assertThat(ledger).as("拦截同样进台账").isNotEmpty(); + assertThat(ledger.get(0).getStatus()).isEqualTo(401); + assertThat(ledger.get(0).getCallerAppKey()).isEqualTo("100617"); + } + + @Test + void 好签名observe为false正常放行() throws Exception { + HttpHeaders h = new HttpHeaders(); + h.add("X-App-Key", "100617"); + String ts = String.valueOf(System.currentTimeMillis()); + h.add("X-Timestamp", ts); + h.add("X-Signature", SignUtil.signRequest("GET", "/oapi/hello", ts, SECRET)); + + ResponseEntity resp = rest.exchange("/demo/oapi/hello", HttpMethod.GET, + new HttpEntity<>(h), String.class); + + assertThat(resp.getStatusCodeValue()).isEqualTo(200); + assertThat(CALLER_SEEN.get()).isEqualTo("100617"); + } +}