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:
@@ -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 取端到端耗时。
|
||||
* <p>
|
||||
* 转发是异步的(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) {
|
||||
// 台账故障绝不影响请求收尾
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
* <p>
|
||||
* 身份注入(链序第 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);
|
||||
}
|
||||
}
|
||||
@@ -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 第二段 = <b>裸 404</b>,协议层错误永不放行),
|
||||
* 并取注册表最严策略({@link RouteRegistry})存入 attribute 供后续环节消费。
|
||||
* <p>
|
||||
* {@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 + "/"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user