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;
|
HttpServletRequest req = (HttpServletRequest) request;
|
||||||
HttpServletResponse res = (HttpServletResponse) response;
|
HttpServletResponse res = (HttpServletResponse) response;
|
||||||
|
if (RoutingFilter.isGatewayInternal(RoutingFilter.sanitizedUri(req))) {
|
||||||
|
chain.doFilter(request, response); // 网关自身端点不经 oapi 验签
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
boolean pass = false;
|
boolean pass = false;
|
||||||
String appKey = req.getHeader("X-App-Key");
|
String appKey = req.getHeader("X-App-Key");
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ spring:
|
|||||||
batch_size: 200
|
batch_size: 200
|
||||||
order_inserts: true
|
order_inserts: true
|
||||||
redis:
|
redis:
|
||||||
# 密钥链(sign:app:{appKey})走共享 Redis db0——由平台配置层注入
|
# 密钥链(sign:app:{appKey})走共享 Redis db0——host/port 由平台配置层注入。
|
||||||
host: #
|
# 注意:jar 内只留注释,不留 `host:` 空键(空值绑不到 int 会让 context 起不来)
|
||||||
port: #
|
# host: <平台配置层>
|
||||||
|
# port: <平台配置层>
|
||||||
main:
|
main:
|
||||||
allow-bean-definition-overriding: true
|
allow-bean-definition-overriding: true
|
||||||
|
|
||||||
@@ -39,14 +40,16 @@ management:
|
|||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
yh:
|
yh:
|
||||||
app:
|
# starter 的服务端验签过滤器(AppAuthFilter,注册在 HIGHEST_PRECEDENCE+10)必须关掉:
|
||||||
auth:
|
# 网关自身的 SignVerifyFilter(T1.2) 是唯一验签面,否则它会抢在链首把合法流量按
|
||||||
# starter 验签配置(T0.11 起 GatewayKeyService 复用 SignKeyResolver)
|
# "完整 URI 含 target 段"验签 → 全部 401。注意前缀是 yh.app-auth(不是 yh.app.auth)。
|
||||||
# include/exclude 路径策略由平台配置层下发;本文件不配任何真实值
|
# 密钥链 SignKeyRedisAutoConfiguration 独立装配,不受此开关影响(GatewayKeyService 仍可用)。
|
||||||
enabled: # 默认随 starter
|
app-auth:
|
||||||
|
enabled: false
|
||||||
gateway:
|
gateway:
|
||||||
# 路由表(T0.12):target -> base-url,值 = 容器网内服务直连 URL,走平台配置层
|
# 路由表(T0.12):target -> base-url,值 = 容器网内服务直连 URL,走平台配置层
|
||||||
routes: #
|
# (只留注释不留空键:空值会让 Binder 把 Map 绑成 null)
|
||||||
|
# routes:
|
||||||
# userbase: http://userbase-service:8080
|
# userbase: http://userbase-service:8080
|
||||||
# userclass: http://userclass-service:8080
|
# userclass: http://userclass-service:8080
|
||||||
# cms: http://cms-service:8080
|
# cms: http://cms-service:8080
|
||||||
|
|||||||
@@ -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