T1.6: 观察模式开关 + Filter 链组装(链序定型 台账0→Sanitize1→Routing2→SignVerify3→身份注入4(转发器内)→Dispatch5;/api/internal/** 三环旁路直达 controller;observe=true 坏签名放行、false 裸401 集成对照;400/404 协议层错误不受 observe 影响;关掉 starter AppAuthFilter(正确键 yh.app-auth.enabled=false)避免抢链首误 401)

This commit is contained in:
chenxing
2026-09-21 09:45:52 +08:00
parent 28b9bfca3c
commit 10374abd6d
7 changed files with 602 additions and 16 deletions
@@ -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<String> CALLER_SEEN = new AtomicReference<>();
static final AtomicReference<String> 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<OpenApiLog> awaitLedger() throws InterruptedException {
for (int i = 0; i < 100; i++) {
List<OpenApiLog> all = logRepository.findAll();
if (!all.isEmpty()) {
return all;
}
Thread.sleep(50);
}
return logRepository.findAll();
}
@Test
void 全链走通_身份注入_响应透传_台账入库() throws Exception {
ResponseEntity<String> 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<OpenApiLog> 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<String> 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<String> 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<OpenApiLog> 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<String> resp = rest.postForEntity("/api/internal/routes/refresh", null, String.class);
assertThat(resp.getStatusCodeValue()).as("自身端点走 controller,不进 oapi 链").isEqualTo(200);
}
}
@@ -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(收口形态)对照用例:坏签名不再只记,而是<b>裸 401</b> 拦下、
* 请求不到提供方;台账仍记录该次判定(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<String> 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<OpenApiLog> awaitLedger() throws InterruptedException {
for (int i = 0; i < 100; i++) {
List<OpenApiLog> 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<String> 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<OpenApiLog> 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<String> resp = rest.exchange("/demo/oapi/hello", HttpMethod.GET,
new HttpEntity<>(h), String.class);
assertThat(resp.getStatusCodeValue()).isEqualTo(200);
assertThat(CALLER_SEEN.get()).isEqualTo("100617");
}
}