Spring Cloud Gateway请求体缓存与签名校验完整实践
开放平台接口对接多了你会发现一个很普遍的规律签名校验基本都要求在网关层做。业务系统各管各的安全策略没法统一安全团队提需求也是盯着入口处那条链路。Spring Cloud Gateway 作为整个微服务架构的流量入口承担签名校验再合适不过。但真上手写 GlobalFilter 的时候很多人第一个坑就摔在同一个地方——拿不到 POST 请求体。这个坑不是网关有什么隐藏 Bug而是它的设计本身决定的。网关底层基于 Netty 和 Reactor请求体是异步流式数据从设计上就不允许你在一个地方读完再原样交给下游。想象一下一根水管里的水你喝了一口后面的人拿到水管就只剩空管子了。请求体就是这根水管DataBuffer 就是水管里的水读一次就没了。那签名校验又必须在网关做怎么办只能想办法在过滤器里先把请求体“截流”下来缓存到某个地方校验时读缓存里的内容校验完再重新构造一个带完整请求体的请求转发给下游。这篇文章我会把完整的实现思路、核心代码、以及我实际跑通后踩过的几个坑都写出来。核心就两件事第一如何用缓存加装饰器模式让请求体变成可以重复读的第二如何在签名过滤器里安全地拿到 POST 请求体算完签名再转发。适合正在做网关中间层、或者被请求体读取问题折磨的开发者参考。1. 请求体为什么只能读一次先从流式数据的设计说起1.1 请求体和响应体都是异步流不是一次性读入内存的数组很多初学者想当然地认为HTTP 请求到了网关整个 body 已经完整躺在内存里只要随便调用一个方法就能拿到全部内容。实际上 Spring Cloud Gateway 的底层不是这种模型。它基于 Spring WebFlux请求体被抽象为一个FluxDataBuffer也就是一个异步的数据流。数据可能分多个数据块到达也可能延迟到达也可能是很大体积的文件流如果框架在入口处就把所有字节一次性吞进内存那网关内存早就被撑爆了。这也是为什么ServerHttpRequest的getBody()方法返回的是FluxDataBuffer而不是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 属性里要让请求体能被多处读取本质思路是化流为数组。在缓存过滤器里订阅原始请求体的FluxDataBuffer把所有数据块拼接成一个完整字节数组存放到ServerWebExchange的属性中。这个属性是请求生命周期内的共享容器后面所有过滤器都能通过exchange.getAttribute(key)拿到。代码结构一般分两个类。第一个是缓存过滤器本身Component public class CacheBodyGlobalFilter implements GlobalFilter, Ordered { public static final String CACHE_REQUEST_BODY_ATTR cachedRequestBody; Override public MonoVoid 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 FluxDataBuffer 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 MonoVoid 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客户端标识用来查对应的 secretX-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 MonoVoid 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()写入字节流最后返回MonoVoid。private MonoVoid unauthorizedResponse(ServerWebExchange exchange, String message) { ServerHttpResponse response exchange.getResponse(); response.setStatusCode(HttpStatus.UNAUTHORIZED); response.getHeaders().add(Content-Type, application/json;charsetUTF-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 排序后拼接成key1value1key2value2的形式再参与签名计算。实现时可以用LinkedHashMap排序避免因为参数顺序不一致导致上下游签名结果不一致。String query request.getURI().getQuery(); if (StringUtils.hasText(query)) { MapString, 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 控制链条顺序出了问题一眼就能定位。如果你是在做开放平台网关这个方案可以继续扩展的方向有签名算法插拔化支持多套摘要算法、请求体落盘审计、灰度环境跳过校验等。但不管怎么扩展请求体缓存和装饰器替换这套底子不会变先把它吃透后面的路会好走很多。