ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Sa-Token框架下JSON Body验签实战:接口签名与防重放完整方案

Sa-Token框架下JSON Body验签实战:接口签名与防重放完整方案 最近在鼓捣开放接口时遇到了一个很实际的问题项目里已经上了Sa-Token做登录认证接口只要StpUtil.checkLogin()一下就能挡住未登录请求但开通第三方通道后回调接口收到的参数却没法确认是不是对方原始发出的数据。说白了一套接口光证明你是谁还不够还得证明请求没被改过这时候就要在SaToken框架基础上补一层JSON body验签。这篇文章就把我在项目里从零实现SaToken JSON body验签的方案、踩过的坑和可直接抄的代码完整记录一下给同样在SaToken项目里需要做接口签名校验的朋友做个参考。我选定的技术路线是继续沿用Sa-Token的拦截器机制不额外引入Spring Security这类重量级框架只在请求进入业务Controller之前用SaInterceptor挂一个自定义的验签逻辑。核心工作其实只有三块一是设计一套合理的签名规范二是解决HTTP请求体body只能读取一次的经典问题三是在Sa-Token的拦截器里把验签不通过和登录态失效两种情况区分处理。下面按我的实际开发顺序逐步讲清楚。1. 登录态与接口签名为什么SaToken框架下还要单独做body验签1.1 两类校验解决的是两个不同维度的问题很多刚接触服务端认证的同事会把登录认证和接口验签混为一谈但实际上它们负责的问题维度完全不同。Sa-Token的StpUtil.checkLogin()验证的是这个请求是由一个已登录用户发出的靠的是token的合法性和会话状态它解决的是身份认证问题——你是谁。而body验签解决的是数据完整性和请求来源可信度问题——你发过来的这串JSON到底有没有在中途被人动过手脚。举个最直白的例子你给第三方开放一个下单回调接口对方调用时带着登录态的概率很低就算带着你也无法确认请求里的金额字段是对方原始填写的还是被黑客抓包篡改过的。这时候你需要的不是要求对方登录而是要求对方用约定好的密钥把整个JSON body算一个签名出来服务端验签通过才放行。签名一旦对不上直接判定请求不合法业务完全不需要关心数据是否真实。1.2 哪些接口需要这种验签结合我自己的项目经验需要做JSON body验签的接口通常逃不出这几类开放平台API对外提供数据查询、提交订单的能力调用方是第三方开发者无法要求对方登录你的业务系统。服务端到服务端回调比如支付回调、物流状态回调、内容审核结果回调这些接口往往涉及资金或状态变更参数一旦被篡改后果很严重。App端的敏感操作虽然App用户已经通过Sa-Token做了登录认证但某些关键操作比如修改手机号、提现、绑定银行卡在登录态基础上再叠加一层body签名校验可以防止中间人篡改请求体。服务间内部调用微服务架构下A服务调B服务如果整个内网环境不是绝对可信签名校验能有效防止测试环境参数被恶意构造。也就是说登录态决定要不要拦这个人验签决定拦下来的请求数据能不能信两者层层叠加而不是互相替代。在Sa-Token的项目里我遇到最多的情况是部分接口只校验登录态部分接口在登录态之上再验签还有少数纯开放接口只验签不要求登录。这种灵活的接口权限模型恰好是Sa-Token这种轻量级框架最擅长承载的。2. 验签规则设计先把签名规范和防重放机制定清楚2.1 签名算法选择HMAC-SHA256与MD5方案的取舍在我接触过的项目里接口签名算法用的最多的就是MD5和HMAC-SHA256。简单场景下MD5盐的方式足够用把业务参数拼接成字符串加上约定好的密钥salt后取MD5优点是计算极快、代码简单、任何语言都有现成工具库。但MD5方案有个明显缺陷如果不做拼接顺序混淆对简单数据结构很容易被碰撞或枚举而且在性能过剩的现代服务端MD5的安全性只能算够用但不推荐。所以我最终选的是HMAC-SHA256。这个算法的好处是它本身就需要一个密钥参与运算天然适合双方共享同一个secret的验签模型。相比直接SHA256(body secret)这种拼接式写法HMAC算法在实现上对密钥和消息做了分组填充处理理论上更能抵抗长度扩展攻击。实测下来一次HmacSHA256计算的耗时在微秒级别对接口性能影响完全可以忽略。如果你所在的团队比较保守老项目里都是MD5也完全可以在不改变算法框架的前提下兼容只要把签名规则统一到一个工具类里MD5和HMAC-SHA256无非是SignatureUtil中一个方法实现不同不影响拦截器的主体逻辑。2.2 待签字符串结构method、path、timestamp、nonce、body的拼接顺序签名规则里面最容易被忽略却又最关键的是到底把哪些东西拿来算签名。只签body是不够的因为攻击者可以把整个请求复制下来换个时间重放。我的规范设计如下参与签名的元素HTTP方法大写、请求路径、时间戳timestamp秒级、随机字符串nonce、原始请求体字符串。拼接顺序固定为POST\n/api/open/order\n1710000000\n6a2f8c9e-1b3d-4e5f-9a7b-0c1d2e3f4a5b\n{amount:100}。每项之间用换行符\n分隔。用共享密钥对这个字符串做HmacSHA256计算结果转为十六进制小写字符串。这样设计有几个目的把HTTP方法和路径放进签名串可以防止攻击者把同一个body复制到另一个接口上重放放时间戳和nonce是为了防重放放body是为了校验内容完整性。如果双方约定了用POST提交且路径是固定的理论上method和path可以不放但我建议保留因为实际项目里一个开放接口往往会有多个路径共用同一套签名逻辑把路径放进去能避免很多串接口的麻烦。2.3 防重放机制时间戳窗口加nonce存Redis防重放是验签体系里绝对不能省的一环。即便有签名攻击者把截获的原始请求原封不动重新发送一次服务端是没法通过签名识别出这是重放的——因为签名是正确的。所以必须对时间戳和nonce做联合校验时间戳窗口服务端收到请求后检查timestamp与当前时间的差值超过5分钟直接拒绝。这个窗口不能太短否则调用方服务器时钟稍有偏差就会被误杀也不能太长给攻击者留下宽裕的重放时间。我实测下来5分钟是开发和联调阶段最舒服的窗口。nonce防重放同一timestamp内nonce只允许使用一次。服务端把timestamp-nonce作为key写入Redis并设置与窗口时间一致的过期时间如果写入时发现key已存在说明这个请求被重放过了直接拒绝。把nonce的key设计成sign:nonce:{timestamp}:{nonce}而不是只存nonce是为了避免不同时间窗口内同一nonce导致误杀。实际联调中很多问题不是因为算法错而是因为不同服务之间系统时间差太大我建议在运维层面统一用NTP对时否则客户端签名的时间戳和服务端校验的时间戳一旦差出几分钟排查起来非常痛苦。3. 上手实操把SaInterceptor当成验签入口路由式挂载3.1 引入依赖和配置Sa-Token拦截器项目基于Spring BootSa-Token的引入非常简单。我这边用的是当前稳定版本只需要在pom.xml中加入依赖dependency groupIdcn.dev33/groupId artifactIdsa-token-spring-boot-starter/artifactId version1.37.0/version /dependency登录认证部分相关的配置token名称、超时时间、token风格我不过多展开网上资料很多。重点是拦截器如何写。Sa-Token官方推荐的方式是注册一个SaInterceptor通过SaRouter.match()做路由匹配再调用check()或checkLogin()完成校验。我的做法是把验签逻辑封装成一个独立方法在拦截器里对不同路径区分调用Configuration public class SaTokenConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new SaInterceptor(handler - { // 开放验签接口只验签不要求登录 SaRouter.match(/open/**).check(r - SignInterceptor.checkSign()); // 业务接口先验登录再验签商品下单等敏感操作 SaRouter.match(/api/**) .check(r - StpUtil.checkLogin()) .check(r - SignInterceptor.checkSign()); // 其他接口只验登录 SaRouter.match(/**) .notMatch(/open/**, /api/**, /auth/**) .check(r - StpUtil.checkLogin()); })).excludePathPatterns(/auth/**, /error); } }这里有个设计细节要提醒SaRouter.match的匹配顺序是自上而下的但不是匹配到就停止而是每个match都会对符合路径的请求执行自己的check如果某个请求同时符合多个match多个check都会执行。所以我在/api/**里同时挂登录和验签而/open/**只挂验签。对于纯公开接口比如获取公钥、下载服务端公钥直接放进excludePathPatterns即可。3.2 自定义请求包装器解决body只能读一次的问题凡是做过接口验签的人十有八九都栽过同一个跟头HttpServletRequest的getInputStream()只能读取一次。问题在于业务层Controller里的RequestBody也要读body如果拦截器先读了一次body来做验签到Controller那边再读就拿到空串或直接报Stream closed。解决办法是写一个RequestWrapper在请求进入拦截器之前把body字节缓存下来后续任何一次getInputStream()和getReader()都返回缓存的内容。我是在过滤器Filter里做包装的这样能确保在Spring的DispatcherServlet和Sa-Token拦截器之前就把body读进缓存。唯一需要注意的是用了包装器后getParameter()、getParameterMap()这些方法也要一起重写因为表单参数可能也走了body流。public class BodyCachingRequestWrapper extends HttpServletRequestWrapper { private final byte[] body; public BodyCachingRequestWrapper(HttpServletRequest request) throws IOException { super(request); // 读取原始body this.body request.getInputStream().readAllBytes(); } Override public ServletInputStream getInputStream() { ByteArrayInputStream byteArrayInputStream new ByteArrayInputStream(body); 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 listener) { // 不需要实现 } }; } Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } Override public String getParameter(String name) { // 如果body是JSON这里也可以从解析结果中取值 return super.getParameter(name); } }注册Filter时注意WebFilter配合ServletComponentScan或者直接用FilterRegistrationBean。一定要把Filter的优先级放到最高Ordered.HIGHEST_PRECEDENCE否则框架层的Filter可能在某些场景抢先把body读走。同时要过滤掉GET请求和Content-Type不是application/json的请求避免白读了非同类型接口的body。3.3 验签拦截器核心代码SaRouter.match加check回调完成包装器之后验签拦截器本身就可以专心做业务了。我把它设计成一个静态方法checkSign()在Sa-Token的check()回调里调用。之所以用静态方法而不是单独实现一个HandlerInterceptor是因为Sa-Token官方拦截器模式下check()回调本身就是拦截逻辑的落点再用一个HandlerInterceptor会多一层复杂度。public class SignInterceptor { public static void checkSign() { SaRequest saRequest SaHolder.getRequest(); HttpServletRequest request saRequest.getRequest(); String appId request.getHeader(X-AppId); String timestamp request.getHeader(X-Timestamp); String nonce request.getHeader(X-Nonce); String sign request.getHeader(X-Sign); // 基础参数校验 if (StringUtils.isAnyBlank(appId, timestamp, nonce, sign)) { throw new SaTokenException(签名参数缺失); } // 1. 根据appId获取密钥 String appSecret SecretManager.getSecretByAppId(appId); if (appSecret null) { throw new SaTokenException(未知的AppId); } // 2. 时间戳窗口校验 long ts Long.parseLong(timestamp); if (Math.abs(System.currentTimeMillis() / 1000 - ts) 300) { throw new SaTokenException(请求已过期); } // 3. nonce防重放 String nonceKey sign:nonce: timestamp : nonce; Boolean success RedisTemplate.opsForValue().setIfAbsent(nonceKey, 1, Duration.ofSeconds(600)); if (Boolean.FALSE.equals(success)) { throw new SaTokenException(重复请求); } // 4. 读取body并验签 String body getRawBody(request); String serverSign SignatureUtil.hmacSha256(buildSignContent(request, timestamp, nonce, body), appSecret); if (!serverSign.equalsIgnoreCase(sign)) { throw new SaTokenException(签名不匹配); } } private static String getRawBody(HttpServletRequest request) { try { return IOUtils.toString(request.getInputStream(), StandardCharsets.UTF_8); } catch (IOException e) { throw new SaTokenException(读取body失败); } } private static String buildSignContent(HttpServletRequest request, String timestamp, String nonce, String body) { return request.getMethod() \n request.getRequestURI() \n timestamp \n nonce \n body; } }这段代码里有个细节值得说验签时的request.getRequestURI()要能拿到原始路径不要用带context-path或拼了query的完整URL。如果网关做了路径重写客户端签名用的path和服务端实际收到的path不一致签名就会对不上。这种情况我一般建议客户端直接用请求发给网关时的相对路径参与签名服务端拿request.getRequestURI()时注意和网关侧的约定保持一致。4. 完整验签代码实现4.1 签名生成工具类服务端验签和客户端验签共用一个算法工具类非常简单。我习惯把签名相关的方法收拢到一个SignatureUtil里方便测试和复用。HmacSHA256的Java实现不依赖第三方库直接用JDK自带的javax.crypto.Mac即可。public class SignatureUtil { public static String hmacSha256(String data, String secret) { try { Mac mac Mac.getInstance(HmacSHA256); SecretKeySpec keySpec new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HmacSHA256); mac.init(keySpec); byte[] bytes mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); return toHex(bytes); } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new RuntimeException(签名计算失败, e); } } private static String toHex(byte[] bytes) { StringBuilder sb new StringBuilder(); for (byte b : bytes) { String hex Integer.toHexString(b 0xFF); if (hex.length() 1) { sb.append(0); } sb.append(hex); } return sb.toString(); } }这里有一个非常容易被忽视的小坑secret和data在用getBytes()时一定要显式指定StandardCharsets.UTF_8不同环境默认字符集不同一旦服务器或客户端的默认编码不是UTF-8两边算出来的签名就会不一致。你可能会想我本地明明好的怎么上了服务器就对不上多半就是编码问题。4.2 验签逻辑拆解从拿到rawBody到比对结果把验签逻辑整体拆开来看流程是从Header中取出X-AppId、X-Timestamp、X-Nonce、X-Sign四个字段。用X-AppId查密钥。密钥管理我建议做成独立的SecretManager不要硬编码在代码里。我最初图省事直接写在配置文件里后来新增合作方时每次都要重新构建发布。改成数据库表或独立的配置中心之后增加一个合作方只需要插一条记录效率提升非常明显。时间戳窗口校验。这里有个细节客户端生成时间戳时用的是秒级也就是System.currentTimeMillis() / 1000服务端校验时也要用秒级否则放大1000倍再相减任何正常请求都会过期。nonce防重放。我把setIfAbsent当分布式锁用setIfAbsent成功说明这个nonce第一次出现失败则说明已经处理过同nonce请求。这一步要放在时间戳校验之后、签名比对之前避免攻击者用过期时间戳疯狂刷新nonce。读取body、拼接签名内容、计算服务端签名、和请求头里的sign做比对。注意比对时用equalsIgnoreCase兼容客户端大小写差异。这里插一句验签失败的异常处理。因为Sa-Token的check()回调抛出的SaTokenException会被全局异常处理器捕获所以需要在RestControllerAdvice里给SaTokenException写一个统一的异常处理返回约定的响应体比如code401或code40002。Sa-Token的NotLoginException已经有一套默认返回了但它默认返回的是登录失效语义验签失败如果也走这层客户端会分不清到底是你没登录还是签名不对。建议分两个业务异常SignException和SaTokenException前者返回签名错误码后者返回登录失效码。RestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(NotLoginException.class) public Result? handleNotLogin(NotLoginException e) { return Result.error(401, 未登录或登录已过期); } ExceptionHandler(SignException.class) public Result? handleSign(SignException e) { return Result.error(40002, e.getMessage()); } ExceptionHandler(SaTokenException.class) public Result? handleSaToken(SaTokenException e) { return Result.error(500, e.getMessage()); } }4.3 错误响应与状态码设计验签相关状态码设计得越清晰联调阶段越省心。我这边用的编码如下供参考状态码含义场景40001签名参数缺失Header缺少AppId/Timestamp/Nonce/Sign任一字段40002未知AppId服务端没有该调用方的密钥40003请求已过期时间戳超出5分钟窗口40004重复请求nonce在Redis中已存在40005签名不匹配服务端重算签名与请求头不一致40006请求体为空约定传body但实际为空响应体我统一返回JSON{code: 40005, msg: 签名不匹配, data: null}。不要返回堆栈信息内部异常细节只在服务端日志里记录对调用方只暴露业务可读的错误信息。5. 客户端如何生成签名给调接口的人一份可直接抄的模板5.1 Java客户端签名示例服务端验签做好之后还要给客户端一个能跑通的签名模板。我这边接触的调用方大部分也是Java技术栈给了一份最简单的示例。注意客户端这里要能拿到原始请求body字符串如果框架层已经帮你做了对象序列化要保证序列化出来的内容和实际发送的body完全一致。public class ClientSignDemo { public static void main(String[] args) { String appId your-app-id; String appSecret your-app-secret; String url https://api.example.com/open/order; String method POST; // 请求体必须是和实际发送完全一致的字符串 String body {\userId\:1001,\amount\:99.9}; String timestamp String.valueOf(System.currentTimeMillis() / 1000); String nonce UUID.randomUUID().toString().replace(-, ); String content method \n /open/order \n timestamp \n nonce \n body; String sign SignatureUtil.hmacSha256(content, appSecret); System.out.println(X-AppId: appId); System.out.println(X-Timestamp: timestamp); System.out.println(X-Nonce: nonce); System.out.println(X-Sign: sign); } }客户端拼content时有一个隐含要求body字符串从左到右的字节排列必须和服务端收到的body字节完全一致。很多语言里JSON序列化会自动把中文转成\uXXXX这种转义会导致字符串内容和原始发送的body不一致。比如Java里用new JSONObject(map).toString()输出的中文会被转义成\uXXXX但HTTP传输时实际body可能是UTF-8明文中文两边算出来的签名就永远对不上。5.2 常见语言与环境差异JSON字符串化时ensure_ascii、空格、换行这是签名联调里最折磨人的地方没有之一。Python、Java、JavaScript、Go各自对JSON序列化的默认行为差异极大。Python的json.dumps()默认ensure_asciiTrue会把中文转成ascll码形式JavaScript的JSON.stringify()默认不转义中文Go的json.Marshal也不转义中文。这三方生成同样的JSON结构body字节流很可能不同签名自然不同。我的建议是约定一条硬性规则客户端参与签名和实际传输的body必须是同一份字符串也就是先把body字符串计算好传输时就发这串字符串签名也直接对这串字符串计算千万不要序列化成对象→发HTTP→再对对象做签名这样分开操作。只要保证签名用的字符串 HTTP请求体字节经过UTF-8解码后的字符串跨语言问题基本都能绕过去。如果为了做演示讲清楚也可以约定对body做一次规范化后再签名但规范化规则要详细到冒号后是否有空格、数组元素之间是否有换行、中文字段是否转义这个复杂度太高我建议能用直接对原始body签名就坚决不做规范化。6. 实测踩坑body重新格式化、环境差异和拦截器生效顺序6.1 最大的坑JSON格式化导致签名对不上我在联调阶段踩过最狠的一次坑就是客户端那边用了Jackson的writerWithDefaultPrettyPrinter()把body格式化成带缩进的美化格式去发送签名也是按美化后的内容算的。按理说两边应该一致结果服务端用RequestBody接收到的对象再序列化后格式完全不一样了。问题表面上看是服务端验签失败实际上是因为服务端验签时根本不关心body长什么样子它只认request.getInputStream()读到的原始字节。只要客户端发送的是美化格式服务端在拦截器里拿到的就是美化格式签名理论上应该能过。真正的问题出在另一种情况客户端签名时用对象序列化后的内容比如{amount:99.9,userId:1001}发送时却走了框架框架自动把body重新序列化成了另一种顺序比如{userId:1001,amount:99.9}两边字节流不一致。解决这个问题的唯一可靠办法就是在客户端创建HTTP请求时手动物化body字符串签名和请求体共用同一个字符串变量。6.2 编码与大小写Hex还是Base64HmacSHA256计算出来的结果是字节数组转成字符串有两种常见方式十六进制Hex和Base64。我见过同一个平台里服务端用Hex、客户端用Base64两边怎么验都不通过。这个纯属约定问题但很容易埋雷。方案对比编码方式长度特点适用场景Hex小写64字符可读性好url-safe无特殊字符默认推荐最简单Hex大写64字符和Hex小写只差大小写兼容历史项目Base6444字符更短但含///Header中可能出现转义问题偶尔在移动端见到我建议统一用Hex小写并且在Header传输时使用X-Sign这种自定义头不要往Authorization里塞避免和Sa-Token的token头冲突。6.3 拦截器与全局过滤器的执行顺序Sa-Token的SaInterceptor本身是一个HandlerInterceptor它执行的时机是在Spring MVC的DispatcherServlet之后、Controller之前。而我的BodyCachingRequestWrapper是在自定义Filter中做的。过滤器的执行顺序天然早于拦截器这个顺序正好满足需求先包装请求把body缓存下来后续拦截器和Controller都可以放心读取body。但有一个很隐蔽的顺序问题如果你在项目中同时用了其它过滤器比如CharacterEncodingFilter、HiddenHttpMethodFilter一定要用Order把包装请求的Filter放到最前面。否则Spring的CharacterEncodingFilter一旦先设置了编码理论上没问题但某些框架版本中如果其它Filter先调用了getParameter()body里的内容可能被消耗掉包装器再读就拿到空串了。我在一个老项目里还遇到过这样的情况Sa-Token的拦截器依赖SaHolder获取上下文而SaHolder的上下文初始化是在SaTokenContext过滤器中完成的如果我自己注册的Filter在Sa-Token的上下文过滤器之前就尝试访问SaHolder会抛空指针。解决方法是验签逻辑不要写在Filter里而是像前文那样写在Sa-Token的SaInterceptor.check()回调中确保Sa-Token上下文已经初始化完成。这个细节是我反复调整代码后总结出来的最早我图省事把验签写进全局Filter结果被SaHolder.getRequest()的空指针折腾了一下午。6.4 日志与调试技巧验签联调时最怕的是两边都觉得自己算得对。我的做法是在验签失败时把服务端参与签名的原始串打出来但把密钥打码客户端也同样打印自己的签名串。两边把签名串对齐只要字符串一致签名一定一致。我在SignException里加了一个字段存原始签名内容全局异常处理里做了脱敏后打到日志。ExceptionHandler(SignException.class) public Result? handleSign(SignException e) { log.warn(验签失败, reason: {}, signContent: {}, e.getMessage(), e.getSignContent()); return Result.error(40005, e.getMessage()); }实际排查时还有一个捷径先用Postman直接发一个最简单的请求body固定为{}把timestamp和nonce设为固定值服务端和客户端分别计算这样能把变量压缩到最小快速判断是算法问题、编码问题还是路径问题。定位清楚后再逐步替换成真实数据。最后分享一个我个人的体会body验签这套机制看起来是给接口加了一道门槛实际上它更大的价值是逼着你把接口调用边界理清楚。过去反正有登录态参数不用太担心的粗放思维在开放第三方接口和服务间调用时一定会出事。Sa-Token把登录认证这块做得很轻我们程序员要做的只是在它留出的扩展点上把数据可信这条线补上。上面这套实现我在项目里已经稳定跑了大半年新增一个合作方平台只需要在密钥表里加一行记录后面再遇到同类需求你完全可以照着这个思路快速落地。
返回列表