开放平台接口对接多了,你会发现一个很普遍的规律:签名校验基本都要求在网关层做。业务系统各管各的,安全策略没法统一,安全团队提需求也是盯着入口处那条链路。Spring Cloud Gateway 作为整个微服务架构的流量入口,承担签名校验再合适不过。但真上手写 GlobalFilter 的时候,很多人第一个坑就摔在同一个地方——拿不到 POST 请求体。
这个坑不是网关有什么隐藏 Bug,而是它的设计本身决定的。网关底层基于 Netty 和 Reactor,请求体是异步流式数据,从设计上就不允许你在一个地方读完再原样交给下游。想象一下,一根水管里的水你喝了一口,后面的人拿到水管就只剩空管子了。请求体就是这根水管,DataBuffer 就是水管里的水,读一次就没了。
那签名校验又必须在网关做,怎么办?只能想办法在过滤器里先把请求体“截流”下来,缓存到某个地方,校验时读缓存里的内容,校验完再重新构造一个带完整请求体的请求转发给下游。
这篇文章我会把完整的实现思路、核心代码、以及我实际跑通后踩过的几个坑都写出来。核心就两件事:第一,如何用缓存加装饰器模式让请求体变成可以重复读的;第二,如何在签名过滤器里安全地拿到 POST 请求体,算完签名再转发。适合正在做网关中间层、或者被请求体读取问题折磨的开发者参考。
1. 请求体为什么只能读一次:先从流式数据的设计说起
1.1 请求体和响应体都是异步流,不是一次性读入内存的数组
很多初学者想当然地认为,HTTP 请求到了网关,整个 body 已经完整躺在内存里,只要随便调用一个方法就能拿到全部内容。实际上 Spring Cloud Gateway 的底层不是这种模型。它基于 Spring WebFlux,请求体被抽象为一个Flux<DataBuffer>,也就是一个异步的数据流。数据可能分多个数据块到达,也可能延迟到达,也可能是很大体积的文件流,如果框架在入口处就把所有字节一次性吞进内存,那网关内存早就被撑爆了。
这也是为什么ServerHttpRequest的getBody()方法返回的是Flux<DataBuffer>而不是byte[]。它是一个流,意味着消费它是单向的。读完了,管道里就空了,数据不会自动回来。就像你从一卷卫生纸上撕下一截,剩下的卷还是那个卷,但你撕过的那截已经在你手里了,不可能原路塞回去。
1.2 为什么在 Gateway 过滤器里直接读 body,下游就会收不到数据
这里有个绕不开的问题:如果我在 GlobalFilter 里直接调用request.getBody()去读,即使我把读到的内容用完了,下游转发的时候,框架再调用一次getBody(),得到的往往是一个空流或者已经释放的流。
原因在于Flux是冷流还是热流这个细节。对于请求体而言,框架在接收数据时,内部的数据缓冲已经在往某些订阅者手里派发数据了。当过滤器订阅并消费了这个流,数据被接收、被释放,而下游NettyRoutingFilter再去订阅同一个流时,数据源头已经不存在了。数据传输链路说白了是一条单行道,前一个人把路走完了,后一个人无路可走。
规避方法有两个层面:一是让请求体在进入业务过滤器之前就被缓存进一个可重复访问的容器;二是把缓存好的请求体通过装饰器重新塞回请求对象里,让下游所有过滤器访问getBody()时拿到的都是缓存里的那份数据。这两件事是配套的,少一个都会出问题。
1.3 先搞清楚过滤器执行顺序,再动手写代码
Spring Cloud Gateway 的过滤器分为全局过滤器和路由过滤器两类。GlobalFilter接口本身没有顺序的概念,但所有全局过滤器在实际运行时会被包装成GatewayFilterAdapter,然后统一合并成过滤器链。顺序控制靠的是实现Ordered接口,返回值越小优先级越高。
这里的关键点在于:缓存请求体的过滤器的执行优先级,必须高于所有需要读取请求体的过滤器。签名校验过滤器当然是要读请求体的,所以缓存过滤器必须排在它前面。更严格地说,缓存过滤器应该是整条链上最先执行的那个,否则任何前置过滤器只要读了一次流,缓存过滤器接手时已经拿不到完整数据了。这个坑我见过不止一次,有人把缓存过滤器优先级排在签名校验后面,结果签名过滤器拿到的 body 永远是空的,排查半天找不到原因。
缓存请求体的过滤器,Order 直接设成最小整数值Integer.MIN_VALUE就行,确保它绝对优先执行。下面的签名过滤器 Order 设成0或者负数但大于最小值,保证执行顺序在缓存之后。
2. 先缓存请求体:装饰器模式让一次性流变成可重复读的数据
2.1 核心思路:读取原始流,把字节数组存到 Exchange 属性里
要让请求体能被多处读取,本质思路是化流为数组。在缓存过滤器里,订阅原始请求体的Flux<DataBuffer>,把所有数据块拼接成一个完整字节数组,存放到ServerWebExchange的属性中。这个属性是请求生命周期内的共享容器,后面所有过滤器都能通过exchange.getAttribute(key)拿到。
代码结构一般分两个类。第一个是缓存过滤器本身:
@Component public class CacheBodyGlobalFilter implements GlobalFilter, Ordered { public static final String CACHE_REQUEST_BODY_ATTR = "cachedRequestBody"; @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); if (Objects.equals(request.getMethod(), HttpMethod.GET)) { return chain.filter(exchange); } return DataBufferUtils.join(request.getBody()) .flatMap(dataBuffer -> { byte[] bytes = new byte[dataBuffer.readableByteCount()]; dataBuffer.read(bytes); DataBufferUtils.release(dataBuffer); exchange.getAttributes().put(CACHE_REQUEST_BODY_ATTR, bytes); return chain.filter(exchange); }); } @Override public int getOrder() { return Integer.MIN_VALUE; } }这里用了DataBufferUtils.join(),可以将请求体流中的所有数据块聚合成一个DataBuffer。拿到之后把字节读进一个byte[],然后立刻释放这个合并后的缓冲区。千万别忘了release(),忘了它等于内存泄漏,因为 Netty 的PooledDataBuffer是引用计数的,不释放会让直接内存一直被占用。
2.2 装饰器重写 getBody():让下游读到的不再是原始空流
光把字节数组存到属性里还不够。下游过滤器,尤其是路由转发过滤器NettyRoutingFilter,转发时还是会调用request.getBody()。它拿到的依旧是原始请求对象,而原始对象的流已经被我们消费过了。要让它拿到的内容是缓存里的字节,就必须用装饰器包一层。
public class CachedBodyRequestDecorator extends ServerHttpRequestDecorator { private final byte[] body; public CachedBodyRequestDecorator(ServerHttpRequest delegate, byte[] body) { super(delegate); this.body = body; } @Override public Flux<DataBuffer> getBody() { return Flux.defer(() -> { DataBuffer buffer = new DefaultDataBufferFactory().wrap(body); return Flux.just(buffer); }); } @Override public HttpHeaders getHeaders() { HttpHeaders headers = new HttpHeaders(); headers.putAll(super.getHeaders()); if (body.length > 0 && headers.getContentLength() < 0) { headers.setContentLength(body.length); } return headers; } }核心就两个方法。getBody()不再返回原始流,而是每次被调用时重新生成一个包含缓存字节的新的Flux。注意这里用了Flux.defer,保证每次订阅都重新生成 DataBuffer,而不是复用同一个缓冲区实例,避免多个订阅者消费同一个缓冲导致数据错乱。
2.3 缓存过滤器的完整逻辑:装饰器替换、属性传递、GET 请求跳过
第二个坑是:为什么 GET 请求要跳过缓存?因为 GET 没有请求体,缓存了也是空数组,还白白增加逻辑分支。不过签名校验过滤器仍然可以对 GET 请求做参数签名校验,这是后话。
缓存过滤器完整逻辑应该是这样的:拿到请求后,判断方法,如果不是需要处理请求体的方法,直接放行;如果是,先读 body 存入属性,然后构造装饰器,用装饰器替换当前请求,再放行到过滤器链。
@Component public class CacheBodyGlobalFilter implements GlobalFilter, Ordered { public static final String CACHE_REQUEST_BODY_ATTR = "cachedRequestBody"; @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); if (HttpMethod.GET.equals(request.getMethod()) || HttpMethod.DELETE.equals(request.getMethod())) { return chain.filter(exchange); } return DataBufferUtils.join(request.getBody()) .flatMap(dataBuffer -> { byte[] bytes = new byte[dataBuffer.readableByteCount()]; dataBuffer.read(bytes); DataBufferUtils.release(dataBuffer); exchange.getAttributes().put(CACHE_REQUEST_BODY_ATTR, bytes); ServerHttpRequest decorator = new CachedBodyRequestDecorator(request, bytes); return chain.filter(exchange.mutate().request(decorator).build()); }); } @Override public int getOrder() { return Integer.MIN_VALUE; } }这样整条链路往后的所有过滤器,包括路由转发过滤器,拿到的都是装饰器请求,而装饰器的getBody()每次都会从缓存字节数组里生成数据。相当于这条水管被我们改装过,水喝完了还能自动续上。
注意:
exchange.mutate().request(decorator).build()不能省略。只构造装饰器而不替换到 exchange 里,下游访问的依然是原始请求,白干。
2.4 为什么必须把缓存放最前面:重试链路的隐性问题
这里有个容易被忽略的场景——网关路由的重试机制。如果某个过滤器链下游因为网络超时触发了重试,路由过滤器会尝试重新转发。如果请求体没有被缓存,重试时再次getBody()拿到的就是空流,下游接口收到的就是一个 body 为空的 POST 请求,等于是把脏数据发出去了。
把缓存放在最前面,还有一个附带好处:重试时装饰器的getBody()永远能根据缓存数组重新生成流,请求体重试安全。这个问题在联调时可能完全暴露不出来,只有在生产环境出现瞬时故障触发重试的时候才会突然冒出来,等线上出了事故再排查就很被动了。
3. 签名校验 GlobalFilter:从缓存拿 body、计算签名、放行或拦截
3.1 签名校验的目标和常见方案
在写代码之前,先明确签名校验的常规玩法。开放平台最常见的签名方案是:客户端持有 appKey 和 appSecret,请求时把业务参数、时间戳、随机数等按规则拼接,用摘要算法生成一个签名串放在请求头里。服务端拿到请求参数后,用相同的规则自己算一遍签名,然后比对。
我用到的是比较通用的一套规则:
| 请求头字段 | 含义 |
|---|---|
X-App-Key | 客户端标识,用来查对应的 secret |
X-Timestamp | 请求发起时间,用于防重放 |
X-Nonce | 随机字符串,配合时间戳使用 |
X-Signature | 最终签名值 |
签名规则可以有很多变种,比如把请求体内容加入签名串、把请求参数排序后拼接等。我在网关侧为了健壮性和简单性,主要是把 timestamp、nonce、请求体摘要拼接后做 HMAC-SHA256。网关侧其实不关心业务参数具体内容,只关心参数有没有被篡改。
3.2 从缓存中获取 POST 请求体的正确写法
签名过滤器里最核心的一步,就是从 exchange 属性中把之前缓存好的字节数组取出来。这个操作不能放在filter()方法直接同步执行,因为整个过程是响应式的,取到Mono/Flux后要通过 flatMap 之类的操作拼接逻辑。
@Component public class SignatureGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String path = request.getURI().getPath(); // 白名单直接放行,例如某些对外开放的接口、健康检查等 if (isWhitelist(path)) { return chain.filter(exchange); } // 1. 先校验公共请求头是否存在 String appKey = request.getHeaders().getFirst("X-App-Key"); String timestamp = request.getHeaders().getFirst("X-Timestamp"); String nonce = request.getHeaders().getFirst("X-Nonce"); String signature = request.getHeaders().getFirst("X-Signature"); if (StringUtils.isAnyBlank(appKey, timestamp, nonce, signature)) { return unauthorizedResponse(exchange, "missing required headers"); } // 2. 时间戳防重放:允许正负5分钟误差 long ts = Long.parseLong(timestamp); if (Math.abs(System.currentTimeMillis() - ts) > 5 * 60 * 1000) { return unauthorizedResponse(exchange, "timestamp expired"); } // 3. 根据 body 计算签名 byte[] bodyBytes = exchange.getAttribute(CacheBodyGlobalFilter.CACHE_REQUEST_BODY_ATTR); if (bodyBytes == null) { bodyBytes = new byte[0]; } String bodyMd5 = DigestUtils.md5DigestAsHex(bodyBytes); String serverSign = hmacSha256(appKey + nonce + timestamp + bodyMd5, findSecretByAppKey(appKey)); // 4. 对比签名 if (!MessageDigest.isEqual(serverSign.getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8))) { return unauthorizedResponse(exchange, "invalid signature"); } return chain.filter(exchange); } }有个小细节需要说明:MessageDigest.isEqual是恒定时间比较,能防止时序攻击。千万别用equals直接比较两个字符串,虽然网关内部风险有限,但一个好的签名校验逻辑就该有安全意识地写。
3.3 校验失败时的统一错误响应与状态码
校验失败的返回也要走响应式写法。直接 new 一个ServerHttpResponse然后 writeWith 是不推荐的,正确姿势是设置响应头、状态码、然后通过response.writeWith()写入字节流,最后返回Mono<Void>。
private Mono<Void> unauthorizedResponse(ServerWebExchange exchange, String message) { ServerHttpResponse response = exchange.getResponse(); response.setStatusCode(HttpStatus.UNAUTHORIZED); response.getHeaders().add("Content-Type", "application/json;charset=UTF-8"); String result = "{\"code\":401,\"msg\":\"" + message + "\"}"; DataBuffer buffer = response.bufferFactory().wrap(result.getBytes(StandardCharsets.UTF_8)); return response.writeWith(Mono.just(buffer)); }这里有个容易踩的坑:如果不设置Content-Type中的字符集,前端可能会把中文提示解析成乱码。不要问我怎么知道的,排查这种问题真的很浪费时间。
3.4 GET 请求参数签名校验:和 POST 分开处理
GET 请求没有 body,签名校验通常针对查询参数。常规做法是把查询参数按 key 排序后拼接成key1=value1&key2=value2的形式,再参与签名计算。实现时可以用LinkedHashMap排序,避免因为参数顺序不一致导致上下游签名结果不一致。
String query = request.getURI().getQuery(); if (StringUtils.hasText(query)) { Map<String, String> paramMap = new LinkedHashMap<>(); for (String pair : query.split("&")) { int idx = pair.indexOf("="); if (idx > 0) { paramMap.put(pair.substring(0, idx), pair.substring(idx + 1)); } } String sortedQuery = paramMap.entrySet().stream() .map(e -> e.getKey() + "=" + e.getValue()) .collect(Collectors.joining("&")); String serverSign = hmacSha256(appKey + nonce + timestamp + sortedQuery, secret); // 比对逻辑与 POST 一致 }POST 请求如果也携带查询参数,健壮性比较好的做法是把 query 签名和 body 签名都算进去,防止有人篡改 URL 参数。
3.5 用一个专用类管理 Filter Order 常量
看到这里你可能会想,Order 值东一个西一个,维护起来麻烦。我建议定义专门的常量类或者放在各自 Filter 里用静态常量声明,至少做到一处定义、全局可见:
public class FilterOrder { public static final int CACHE_BODY = Integer.MIN_VALUE; public static final int SIGNATURE = -100; public static final int AUTH = -99; }顺序值之间留一点余量,后面插入新的过滤器时不用大改。签名校验这类安全过滤器要尽量靠前,但必须排在缓存过滤器之后。认证和鉴权逻辑如果后续要加,顺序排在签名后、路由前即可。
4. 网关层签名校验的进阶与易错点:我踩过的坑都在这
4.1 Content-Length 不一致导致的下游接口数据读不满
这个问题特别隐蔽。原始请求头里的Content-Length是客户端发送时的长度。如果我们缓存的 body 字节数组长度与原始长度一致,通常没问题;但如果在签名过滤器里改写了 body(比如替换了某些字段),而装饰器又没有更新Content-Length,下游服务读取请求体时可能按旧的 Content-Length 读取,导致数据截断或读取超时。
在装饰器的getHeaders()方法里,保险起见一定要基于当前字节数组长度重新计算 Content-Length。上面代码已经处理了这一点,但很多人会把这一步忽略掉。还有一种更隐蔽的情况:Content-Length 为 0 的 POST 请求,比如Content-Type: application/x-www-form-urlencoded的某些空表单提交,bodyBytes.length 也为 0。此时不要强行为长度 0 的 body 设置 Content-Length 为 0,这会破坏某些客户端的 Keep-Alive 机制。我一般判断是body.length > 0才设置。
4.2 请求体过大导致的内存撑爆问题
缓存整个请求体到内存的代价是:如果某个接口上传几十 MB 的文件,网关就要在内存里硬生生挂着这么多字节。并发一高,GC 压力陡增,极端情况下直接 OOM。网关层的通用签名校验一般只针对普通业务接口,文件上传接口建议从签名校验范围里排除,或者走专门的旁路校验通道。
如果你确实需要缓存大请求体,可以考虑加一个体积上限:
DataBufferUtils.join(request.getBody()) .map(dataBuffer -> { if (dataBuffer.readableByteCount() > MAX_BODY_SIZE) { throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE, "body too large"); } return dataBuffer; }) .flatMap(...);或者直接根据Content-Length头预先判断,超过阈值的不进缓存过滤器,返回 413。这个策略在真实项目中很有效,也简单直接。
4.3 白名单路径配置的最佳位置
不是所有接口都需要签名校验。健康检查、登录接口、支付回调这类接口往往要放行。白名单判断我建议放在签名过滤器的第一行,不要放缓存过滤器里。因为缓存过滤器不关心业务权限,它只负责把 body 缓存好。但白名单接口如果也有 body,缓存本身没坏处,因此白名单只在签名过滤器判断即可。
private boolean isWhitelist(String path) { return path.startsWith("/health") || path.startsWith("/login") || path.startsWith("/callback"); }真实项目里白名单一般来自配置中心,这里只是给出思路。路径匹配时注意前缀匹配的粒度,别把/login/和/login两种情况遗漏了。
4.4 签名过滤器里读取 body 的时机问题
还有一个需要特别留意的问题:如果某个前置过滤器把CACHE_REQUEST_BODY_ATTR属性删掉了,或者某个自定义过滤器重新构造了请求对象但没有带上属性,后面的签名过滤器再查这个属性时就会拿到null。防御性写法是在获取属性时判空,拿到 null 就按空 body 处理。
更常见的问题反而是时序。网上很多教程里DataBufferUtils.join(request.getBody())这段逻辑写在签名过滤器里,这和我的方案最大的不同在于:一旦写在签名过滤器里,签名过滤器消费完请求体后,后续路由过滤器再转发就会拿到空 body。除非你在这个过滤器内部也做装饰器替换,否则缩写为:缓存放前置过滤器,签名过滤器只做读取和校验,两者各司其职。
4.5 CORS 跨域处理与签名校验的先后顺序
如果网关配置了全局 CORS 处理器,预检请求OPTIONS会直接通过,不会进入后续的过滤器链。但是一旦 CORS 的配置顺序和全局过滤器冲突,可能导致带自定义头(如X-Signature)的跨域请求被浏览器拦截,因为预检没通过或没返回正确的Access-Control-Allow-Headers。
遇到这种情况,检查一下是否在 CORS 配置里显式声明了允许携带的请求头:
@Bean public CorsWebFilter corsWebFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedHeader("X-App-Key"); config.addAllowedHeader("X-Timestamp"); config.addAllowedHeader("X-Nonce"); config.addAllowedHeader("X-Signature"); // ... return new CorsWebFilter(source -> config); }别小看这个配置,前端报“CORS 错误”的时候,排查方向经常不在签名逻辑,而在跨域头部声明上。我把这个放在这一节,是因为它表面看起来像签名问题,实际上和签名头是否被允许携带直接相关。
4.6 网上流传的 ServerRequest 缓存写法不可直接照搬
如果你在网上搜索 Spring Cloud Gateway 缓存请求体,可能会找到一种基于ServerRequest的写法:用ServerRequest.create(exchange)包装请求,然后调用bodyToMono(String.class)拿 body。这种写法的问题在于:ServerRequest本质上还是从原始请求里读取流,如果没做装饰器替换,你在这个过滤器里消费完,后面的过滤器同样拿不到数据。因此一切没做装饰器替换的“缓存”,都是假缓存。
有些人也遇到过一种情况:在某个过滤器里读到了 body,但后续过滤器再读的时候读到的是null。仔细检查一下,十有八九就是直读之后没有用装饰器包装。技术方案没有捷径,关键点就是缓存加装饰器替换,两个动作缺一不可。
4.7 日志输出时的脱敏处理
网关作为统一入口,调试时打印 body 和签名信息确实方便,但一定要防止敏感信息落在日志里。手机号、身份证号、登录密码这些字段如果明文打印,后面做安全审计就是给自己挖坑。我的做法是打印请求体前做一次简单的脱敏处理,把常见的敏感字段值替换成***。
private String maskSensitiveFields(String body) { return body.replaceAll("(\"password\"\\s*:\\s*\")[^\"]*(\")", "$1***$2") .replaceAll("(\"idCard\"\\s*:\\s*\")[^\"]*(\")", "$1***$2"); }日志格式统一带上 appKey、请求路径、校验结果、耗时,这样排查问题非常高效。
5. 最后聊聊我实际运行这个方案的体会
这套方案在我负责的模拟项目 X 的网关层已经跑了很长时间,整体稳定。回顾一下,最核心的设计思路就一句话:请求体缓存必须在最前排,装饰器是配套动作,签名过滤器只负责算和比。很多人以为是签名算法本身难,其实难点全在那份“一次性流”怎么变成“可重复读”上。
如果你准备直接上手,我的建议是先把缓存过滤器单独跑通,在过滤器里打日志,确认下游确实能拿到 body 再写签名逻辑。不要一口气写两个过滤器然后调半天不知道是哪一步出的问题。调试时可以临时加一个 Echo 接口,把收到的 body 原样返回,网关转发后看回显是否一致,一步到位验证缓存有没有生效。
另外一个经验是把时间戳校验的容忍窗口放宽一些。生产环境常有客户端和服务器时间不同步的情况,5 分钟的误差窗口是实践经验里比较合理的值。太严格,线上频繁报签名过期;太宽松,防重放的效果又变差。
签名方案里 nonce 一定要参与计算,否则同一时间戳下相同请求体的签名是固定的,攻击者完全可以重放旧请求。生产场景如果不引入 Redis 存储已使用 nonce,至少也要把时间戳窗口收窄,并在业务侧增加幂等键。签名校验防的是参数篡改和过期重放,不是所有安全问题的银弹。
提示:如果后续要在同一个过滤器里同时做认证、鉴权、限流,注意保持过滤器职责单一。把签名校验、登录态校验、IP 黑白名单全部挤在一个 GlobalFilter 里,代码会越来越难维护。每个过滤器只做一件事,通过 Order 控制链条顺序,出了问题一眼就能定位。
如果你是在做开放平台网关,这个方案可以继续扩展的方向有:签名算法插拔化(支持多套摘要算法)、请求体落盘审计、灰度环境跳过校验等。但不管怎么扩展,请求体缓存和装饰器替换这套底子不会变,先把它吃透,后面的路会好走很多。