尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Sa-Token JSON Body验签实战:从原始报文到国密SM2防重放

发布时间:2026/9/26 2:33:31

资讯中心
01
ARTICLE

Sa-Token JSON Body验签实战:从原始报文到国密SM2防重放

Sa-Token JSON Body验签实战:从原始报文到国密SM2防重放
做服务端开发的人应该都有过这种经历对接第三方开放平台、支付回调或者公司内部服务联调的时候对方丢过来一段 JSON 报文要求“先验签再处理”。我前阵子就遇到一个需求系统权限体系用的是 Sa-Token本来登录、踢人、注解鉴权都挺顺手但一走到“异步通知验签”这里问题就来了Sa-Token 自带的签名模块默认是按普通请求参数query string 或表单键值对来组织验签内容的照文档写了半天JSON body 的验签根本走不通。后来我把整个方案重构了一遍在 Sa-Token 的过滤链上补齐了“JSON body 原始报文验签”的能力顺便还把 SM2 国密签名验签、防重放、时间窗口这几个老生常谈的坑都填了一遍。这篇文章不打算讲太多理论更多是把我在项目里真正落地的一套做法讲清楚为什么 JSON body 不能照搬参数验签的写法如何在不动 Sa-Token 原有 API 权限体系的前提下补上验签层以及遇到“签名不匹配”“回调重复通知”这类问题时的排查思路。不管你是刚接触sa-token 快速入门还是已经在生产环境用了很久只要涉及到 API 验签应该都能从这里找到可以抄作业的内容。1. 项目背景Sa-Token 验签体系与 JSON body 的冲突点1.1 Sa-Token 默认签名模块擅长什么Sa-Token 的签名模块sa-token-sign设计初衷很明确给请求参数加一层数字签名防止参数被中途篡改。它默认的做法大致是把请求里除sign之外的参数按字典序排序、拼接成字符串再加上时间戳和随机数一起做 HMAC 或者 RSA 签名最后塞进sign参数里。服务端拿到请求后用同样的规则重新拼一次字符串再和客户端传上来的sign比对。这种做法的优点是简单、对网关友好几乎所有语言都能很方便地实现。你在很多老项目里都能看到这种签名方式比如微信以前的 JSAPI 支付签名、各类开放平台的通用签名。它也能很好地配合 Sa-Token 的其它能力验签通过之后接着走StpUtil.login()或者SaRouter的权限校验链路是通的。但它有一个隐含前提参与签名计算的原始数据是“键值对”。如果请求体是一整段 JSON 字符串问题就来了。Sa-Token 签名插件本身针对的是Map或者带参请求它没法知道你业务里这段 JSON 的哪一层该参与签名、字段顺序怎么处理、嵌套对象要不要展开。你当然可以把 JSON 反序列化成Map再传给签名组件但这样拼出来的字符串和别人后端真正签名的那个字符串往往对不上。1.2 必须直接对 JSON body 验签的场景我这次踩坑的项目涉及三种典型场景它们无一例外都是“对 JSON 原文字节串签名”而不是对键值对签名第一种是开放平台的异步回调。比如钉钉、飞书、企业微信这类平台的事件订阅回调消息就是一段 JSON签名值放在请求头里服务端必须拿到原始 request body 做验签。你如果自己写一套Map排序拼接的逻辑去验签几乎必挂因为平台方签名用的字符串就是“你裸眼看到的那个 JSON 原文”里面包含什么参数顺序、什么缩进格式就是什么。第二种是支付/电商平台的异步通知。很多支付回调的通知报文是 JSON签名算法可以是 RSA2也可以是国密 SM2。这种通知还带一个非常恶心的特性平台会重试而且重试频率很快。验签一旦失败平台误以为你没收到会不断重推最终把接口打到爆。第三种是服务间内部调用。公司内部微服务之间为了避免调用方身份被伪造也会在 header 里放签名参与计算的仍是整个 body。这种场景下body 验签是服务调用链上的第一道门过了这道门才谈得上后续的业务鉴权。这类需求用普通参数签名方案根本没法覆盖所以你必须自己实现一套“JSON body 验签”逻辑。2. 方案设计先验签后鉴权并且保留原始报文2.1 为什么选 Filter而不是 Sa-Token 的 HandlerInterceptor把需求拆开以后第一件事是确定验签逻辑放在哪一层。Sa-Token 提供SaInterceptor和SaRouter可以很自然地做登录校验、权限校验。你很容易想到在拦截器里先验签、后做登录校验不就行了实际上这里有个很关键的坑拦截器执行时虽然能通过request.getInputStream()读到 body但读完一遍之后流就被消费掉了后面的 SpringRequestBody再想读就会直接报错。你需要在过滤器中先把 body 缓存下来并用包装后的 Request 对象继续放行。所以我的选择是用 Spring 的OncePerRequestFilter在 Servlet 容器阶段先做一次验签。原因有三Filter 的执行时机早于 DispatcherServlet也早于 Sa-Token 的拦截器可以实现“先验签、后鉴权”的完整链路。过滤器里可以自由包装HttpServletRequest把请求体读出来之后重新塞回去Controller 才能继续用RequestBody正常接收。验签逻辑和 Sa-Token 的登录逻辑解耦。验签不过直接返回 401验签通过后该走登录走登录该走权限走权限互不干扰。这样的结构既保留了 Sa-Token 原有的能力又扩展出了对 JSON body 验签的支持。要注意如果你用的不是 Spring Boot 单体应用而是 Spring Cloud Gateway 这类网关思路类似只是要把DataBuffer先读出来再重放回去。2.2 验签字符串必须用“原始报文字节串”这个原则在整个方案里优先级最高我想单独拎出来说。很多朋友验签失败不是算法用错了而是验签字符串和签名端对不上。JSON 这个东西同一个对象在不同语言、不同库、不同配置下序列化结果完全不同字段顺序可能不同比如 Java 的HashMap不保证顺序但LinkedHashMap保证空格和换行可能被剔除或保留中文可能被转成\uXXXX也可能原样输出特殊字符如/可能被转义成\/数字的精度和格式也可能被改变。你在代码里反序列化成对象再重新toJSONString()拼签名串十有八九会跟前端或平台方签名的字符串长得不一样。而且签名算法本来就是作用在“字节”上的平台方发出请求时的实体就是一段字节流它签名的是那一段字节流。正确的做法一定是在原始 HTTP body 首次被读取时以String或byte[]形式缓存下来验签时直接对缓存内容做签名校验业务侧如果需要再用RequestBody反序列化那也是从缓存内容里重新读一遍。我在项目里就是这么做的后面会给你看代码。3. 基于 Sa-Token 的 JSON Body 验签实操3.1 快速接入 Sa-Token 签名扩展与基础配置既然标题是“Sa-Token 支持使用 JSON body 验签”先说说怎么快速把 Sa-Token 的签名模块带进来。假设你用的是 Spring BootMaven 里至少要有这几项dependency groupIdcn.dev33/groupId artifactIdsa-token-spring-boot-starter/artifactId version1.38.0/version /dependency dependency groupIdcn.dev33/groupId artifactIdsa-token-sign/artifactId version1.38.0/version /dependencysa-token-sign在官网文档里是作为独立插件存在的。它自带SaSignTemplate能完成常规的签名生成与校验也支持配置是否开启时间戳校验、随机数校验。在application.yml里可以这样配sa-token: sign: secret-key: your-sign-secret # 用于 HMAC 系列的密钥 is-check-sign: true # 是否校验签名 is-check-timestamp: true # 是否校验时间戳 timestamp-disallow: 300000 # 允许的最大时间偏差5 分钟 is-check-nonce: true # 是否校验随机数需要说明的是这次我们的重点是 JSON body 验签SaSignTemplate原生方法是处理参数的但它的设计思想和SaTokenDao的存储机制值得复用。比如nonce的存储、时间戳的判断我会在下面讲到。你可以把这部分原则用来规范自己的验签实现不必非要强行调用它不被设计的场景。3.2 实现 Body 缓存过滤器与验签主流程直接上代码。我的实现分了两个类一个负责缓存 Body 并放行一个负责真正的签名校验。先是 Body 包装类目的是让请求在被过滤器读完后依然能供后续链路读取public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { private final byte[] cachedBody; public CachedBodyHttpServletRequest(HttpServletRequest request, byte[] body) { super(request); this.cachedBody body; } Override public ServletInputStream getInputStream() { ByteArrayInputStream byteArrayInputStream new ByteArrayInputStream(cachedBody); return new ServletInputStream() { Override public int read() { return byteArrayInputStream.read(); } Override public boolean isFinished() { return byteArrayInputStream.available() 0; } Override public boolean isReady() { return true; } Override public void setReadListener(ReadListener readListener) { // 不需要异步监听 } }; } Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } }然后是过滤器核心逻辑是判断是否需要验签 → 读取原始 Body → 从 Header 里取时间戳、随机数、签名 → 校验 → 通过后把包装后的请求放行。Component public class JsonSignFilter extends OncePerRequestFilter { private static final String SIGN_HEADER X-Sign; private static final String TIMESTAMP_HEADER X-Timestamp; private static final String NONCE_HEADER X-Nonce; Autowired private JsonSignService jsonSignService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // 只处理 POST/PUT 且 Content-Type 为 JSON 的请求 if (!isJsonBodyRequest(request)) { chain.doFilter(request, response); return; } // 1. 读取原始 body String body StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); // 2. 读取相关验签头 String sign request.getHeader(SIGN_HEADER); String timestamp request.getHeader(TIMESTAMP_HEADER); String nonce request.getHeader(NONCE_HEADER); // 3. 执行验签 boolean valid jsonSignService.verify(body, timestamp, nonce, sign); if (!valid) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\sign invalid\}); return; } // 4. 把原始 body 缓存进包装类再放行给后续 Sa-Token 过滤器、Interceptor、Controller CachedBodyHttpServletRequest cachedRequest new CachedBodyHttpServletRequest( request, body.getBytes(StandardCharsets.UTF_8)); chain.doFilter(cachedRequest, response); } private boolean isJsonBodyRequest(HttpServletRequest request) { String method request.getMethod(); String contentType request.getContentType(); return (POST.equalsIgnoreCase(method) || PUT.equalsIgnoreCase(method)) contentType ! null contentType.toLowerCase().contains(application/json); } }签名的拼接规则我单独做成一个 Service方便对接不同平台的协议Service public class JsonSignService { /** * 拼接待签名字符串 * 这里以最常见的“body timestamp nonce”为例实际以对接平台约定为准 */ private String buildSignContent(String body, String timestamp, String nonce) { // 使用显式分隔符避免拼接歧义 return timestamp timestamp nonce nonce body body; } /** * HMAC-SHA256 验签适用于双方约定共享密钥的场景 */ public boolean verify(String body, String timestamp, String nonce, String sign) { if (StringUtils.hasText(body) || timestamp null || nonce null || sign null) { return false; } // 1. 校验时间窗口 long current System.currentTimeMillis(); long requestTime; try { requestTime Long.parseLong(timestamp); } catch (NumberFormatException e) { return false; } if (Math.abs(current - requestTime) 300_000) { return false; } // 2. 校验 nonce 是否已使用防重放 if (!nonceService.tryConsume(nonce)) { return false; } // 3. 计算期望签名 String signContent buildSignContent(body, timestamp, nonce); String expectedSign hmacSha256(signContent, secretKey); // 4. 使用常量时间比较避免时序攻击 return MessageDigest.isEqual( expectedSign.getBytes(StandardCharsets.UTF_8), sign.getBytes(StandardCharsets.UTF_8)); } }有几个点值得展开说。第一timestamp窗口不一定要做得特别短。异步通知存在网络延迟平台可能重试窗口太短会把正常请求误杀太长又容易放大重放攻击。我的经验是 5 分钟300000 毫秒是一个比较平衡的值如果是内网服务可以缩到 1 分钟。第二nonce的去重存储。很多项目用 Redis直接SETNX指定过期时间。如果没引入 Redis用本地Caffeine缓存也行但要注意多实例部署时本地缓存不共享。第三验签一旦失败不要在日志里打印完整报文和签名只打印摘要即可例如SecureUtil.sha256(body)。因为这类接口很可能涉及真实业务数据完整报文打出来容易把敏感信息泄露到日志平台。3.3 验签后的数据如何与 Sa-Token 权限体系衔接验签通过只是第一步业务接口往往还需要确认调用方是谁、有没有权限。这里有一个容易想歪的地方是不是验签通过就直接StpUtil.login()创建会话我的建议是尽量保持无状态。对于回调通知、开放平台 API 这类场景验签本身就完成了身份认证没必要再去创建一个“登录会话”。如果每次验签都创建会话反而会产生一堆无效会话给后续会话管理带来麻烦。更合理的模式是这样验签通过后把调用方身份写入request的 Attribute比如appId后续的 Sa-Token 路由只负责放行或拦截 URL不参与身份判断Controller 里直接通过SaHolder.getRequest().getRequest().getAttribute(appId)或者自定义上下文获取调用方标识。如果你非要让验签结果参与 Sa-Token 的StpUtilAPI有一种做法是把调用方标识当作临时loginId做一次 5 分钟的有效登录但你要注意这会带来会话并发、踢人、续期等连锁问题。除非是内部系统且明确需要“应用身份在线”的概念否则我建议不要这么干。另外在链路顺序上要让 Sa-Token 的拦截器在验签过滤器之后执行。Spring Boot 的OncePerRequestFilter默认走的是 Servlet 过滤器链而 Sa-Token 的SaInterceptor走的是 Spring MVC 拦截器链过滤器的执行天然先于拦截器所以这一点其实不用额外配置。只要你的 URL 规划得当就能保证恶意请求还没到业务路由就被挡在 401 上。4. 异步通知验签实战RSA2 与 SM2 国密签名适配4.1 异步通知验签的通用约定在实际项目里JSON body 验签出场率最高的场景就是“异步通知”。而异步通知比普通 API 调用的难点在于平台不信任你的返回值它会重试。我遇到的支付类回调逻辑通常是这样的平台把通知报文 POST 到你的接口body 是 JSONheader 里带着sign、timestamp、nonce。你的接口收到之后必须读取 body → 验签 → 校验订单状态 → 落库 → 返回固定结构。一旦返回结构不符合平台约定平台会按 30 秒、1 分钟、5 分钟……的间隔持续重推。平台可能不保证只推一次所以接口必须实现“幂等处理”。这几个环节里验签是地基。验签不过后面的事都不能做。具体的响应体格式要以平台文档为准但很多平台都接受{code:SUCCESS}作为成功标记其它字符串一律视为失败并触发重试。所以有一个常见的坑是业务逻辑抛异常后直接把异常堆栈返回给平台。平台看不懂你的异常结构会认为回调失败继续重推。正确的做法是业务失败时也要返回平台规定的失败格式然后在你的系统内部记录失败原因。4.2 对接 SM2 国密签名验签的要点这个项目里还涉及到了国密改造所以需要重点讲一下 SM2 验签。SM2 是中国密码标准和 RSA 不一样它是基于椭圆曲线的非对称算法。如果你对接的是“签名验签服务器”这类密码设备私钥通常不会出设备你的应用只负责把待验签的原文摘要提交给它拿回true“或”false。而在应用层自研验签时通常会借助工具库来完成。我这里用的是 Hutool 的hutool-crypto它对 SM2 做了封装省了不少事。import cn.hutool.crypto.SmUtil; import cn.hutool.crypto.asymmetric.SM2; public boolean verifySm2(String body, String timestamp, String nonce, String sign, String publicKey) { String content timestamp timestamp nonce nonce body body; // 公钥初始化Hutool 支持 Hex 和 Base64 形式的公钥 SM2 sm2 SmUtil.sm2(publicKey, null); // sign 通常是 Base64 编码 byte[] signBytes Base64.getDecoder().decode(sign); return sm2.verify(content.getBytes(StandardCharsets.UTF_8), signBytes); }这里有几个坑要提醒你公钥格式不统一。有的平台给的是压缩公钥有的给你的是04开头的非压缩公钥还有 Base64 和 Hex 混着给的。写代码时一定要确认你的工具库支持哪种格式或者加一段格式自动判断的逻辑。签名内容到底包含哪些字段。有些平台会把appId也拼进签名内容有些只拼 body 本身有些是先对 body 做 SHA256 摘要再对摘要签名。不要想当然一定要用平台提供的测试报文逐字符核对。SM2 的verify参数顺序。Hutool 的SM2.verify第一个参数是待验签数据第二个是签名值顺序错了会一直返回 false。Base64 解码异常。如果签名值里出现了换行符、URL 编码残留解码会直接报错需要先清理非法字符。当年我卡在 SM2 验签上整整一个下午最后发现是平台把签名字段做了 URL 编码传到我这边时末尾多了个%3D。这种问题靠眼睛很难看出来一定要把收到的原始请求头打印出来做对比。4.3 如何设计一个可切换的验签服务刚才那段代码只写了 SM2。但生产环境里很可能存在不同平台用不同算法的情况所以我建议把验签服务设计成策略模式。定义一个统一接口public interface SignVerifyStrategy { boolean verify(JsonSignRequest req, String publicKeyOrSecret); }然后分别实现 HMAC、RSA2、SM2 等策略通过一个SignVerifyContext根据algorithm字段路由。这样对接新平台时你只需要新增一个策略实现不需要动过滤器的代码。这也是我在项目里反复重构得到的教训一开始把所有验签逻辑都写在一个 Service 里结果每接一个平台就要改一次主流程后来拆成策略模式才清净下来。5. 常见问题与排查技巧实录5.1 问题速查表我把自己在这个项目里踩过的问题整理成一张表你可以先收藏遇到相同情况直接对号入座。现象最常见原因排查方向验签始终返回 false拼接的待签名字符串与平台不一致让平台提供签名前的原文串逐字符比对回调重复触发且数量巨大业务接口返回值不符合平台约定检查成功响应体是否和平台文档一致Spring 报getReader() already called过滤器中读了 body 没有重放使用HttpServletRequestWrapper缓存原始 body验签偶发失败时好时坏JSON 字段顺序不稳定确认平台是否对原文直接签名避免重新序列化时间戳校验一直超时服务器与平台时间不同步配置 NTP 时间同步重复通知能绕过验签随机数 nonce 没有去重用 RedisSETNX或数据库唯一索引拦截SM2 验签失败公钥格式、Base64 解码、签名内容缺少字段打印平台收到的 content对比调试Controller 能取到 body但验签没生效过滤器没匹配到接口路径检查shouldNotFilter或 Filter 注册顺序5.2 几个容易被忽视的细节第一签名串里绝不能包含sign字段自身。有一次内部服务联调对方把sign也放进了签名参数里两边怎么都对不上因为一方签名时把sign排除另一方没排除。这个细节在写签名规则时就要明确。第二验签失败时不要立刻断言是平台的问题。你先给自己的日志里加上“原始 body、header、签名值、待签名串”这四个要素然后再去找平台对。很多时候所谓“平台验签 bug”其实是因为你没打印全眼睛瞎猜。第三幂等处理一定要放在验签通过之后。如果幂等判断在验签之前攻击者可以故意带一个重复的requestId来探测你的业务逻辑还可能因为提前响应而跳过验签。第四不要随意把 JSON 做格式化后签名。很多人在本地调试时用 IDEA 的格式化功能把报文折行了然后拿去与平台对比发现永远不一致。平台签名的是网络传输时的原始字节串本地格式化后的内容已经变了。6. 传输层加密 TLS 1.3 与应用层验签的边界6.1 有了 HTTPS 为什么还是要验签近几年 TLS 1.3 已经非常普及绝大多数 API 都跑在 HTTPS 上。但“传输层加密”和“业务层验签”是两个层面的问题它们解决的问题不同不能相互替代。TLS 1.3 保证的是传输过程中第三方看不到、改不了。但在开放平台回调和 webhook 场景里你面对的真正威胁不是网络上的窃听者而是“伪造的平台请求”。一个攻击者不需要解密你的流量只要他把请求头和 body 按平台公开的格式重新构造一遍就能骗过没有验签的接口。TLS 只能证明“这段数据在传输过程中没有被改”无法证明“这段数据的发送方声称自己是支付宝/微信/钉钉”。所以验签的目的不是代替 TLS而是建立应用层的发送方身份认证和内容完整性校验。哪怕你的服务直接裸奔在内网只要有人能构造请求验签就有意义。此外TLS 1.3 的另一个特点也值得注意它默认启用前向保密TLS 握手用的会话密钥通过临时密钥协商生成即使长期私钥泄漏也无法解密历史会话。这当然很好但如果你把业务验签也设计得很严密两层叠加安全性会明显强于只依赖任一层。6.2 分层加固的实践清单我在项目里通常会把安全设计分成三层避免“只搞验签不管传输”或“只上 HTTPS 不验签”的极端情况传输层全站 HTTPSTLS 最低版本设到 1.2如果有条件直接用 TLS 1.3证书要接入自动化续期避免半夜证书过期导致回调大面积失败。业务入口层JSON body 验签必须放在 Controller 之前对高风险接口增加 IP 白名单或双向 TLS验签通过后设置请求级别的 traceId方便排查回调链路。业务处理层所有回调处理逻辑要做到幂等落库和响应要原子化先更新状态成功再返回 SUCCESS重复通知必须被幂等表挡住失败原因要有独立的记录表而不是只靠日志。这几层各司其职才能避免“一个环节出问题整个服务挂掉”的情况。实际运营中我见过不少项目因为只做了 HTTPS、没做验签被恶意调用刷爆了业务接口也见过项目验签做得特别严却因为 TLS 证书过期导致第三方回调进不来。安全设计没有银弹但分层补齐之后至少不会因为某一块的缺失而翻车。个人建议在你接下一个需要 JSON body 验签的第三方平台时先别急着写业务逻辑花半小时做三件事确认签名覆盖的原始报文范围、确认验签算法对应的密钥格式、确认回调成功响应的结构。这三件事确认清楚了后面的开发会顺很多。我在这个项目里把上面这套方案落地之后再把同样的模式复制到其它几个平台对接上基本没有再为验签问题连夜查日志。希望这篇文章能帮你少走几趟弯路。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。