news 2026/10/21 5:00:31

Spring Boot前后端接口传参基于国密SM4加密方案实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot前后端接口传参基于国密SM4加密方案实战

前后端联调的那段日子,前端同事把接口报文往我一摆,我盯着 HTTP 抓包工具里的明文请求,愣了半秒。里面不光有业务参数,还把账号、手机号、订单号全裸奔在传输链路里。客户方要求核心接口的数据不能在传输过程中露出明文,等保评审也明确提到敏感字段要加密。项目用的是 Spring Boot,后端 Java、前端 Vue,大家第一反应都是“上 HTTPS”。但 HTTPS 只保护传输通道,不能解决入口被中间人代理或者前端被篡改后抓包的问题,而且合规那边点名要国密算法。

于是我基于国密 SM4 做了一套“前后端传参加密解密”的完整方案。核心思路很简单:请求发出的那一刻,前端把参数加密后再发给后端;后端拿到密文解密后走正常业务逻辑;返回结果时后端把响应体加密,前端收到密文再解出来渲染。这套方案适合所有用 Spring Boot 开发接口、前端用 axios 这类 HTTP 库的项目,不管你的系统是 B 端内部的管理平台,还是 C 端面向用户的业务系统,只要想给报文加一层“业务层加密”,都可以直接参考这套落地做法。

1. 整体方案设计:为什么选择 Filter 拦截器 + 注解标记这套组合

1.1 需求场景拆解:HTTPS 都布置了,为什么还要加一层

先说一个我反复跟团队确认过的结论:HTTPS 解决的是“链路窃听”问题,但它解决不了“客户端到服务端之间的全链路可信”问题。你在浏览器里打开开发者工具,网络面板里照样能看到请求参数,加载完的 JS 也照样能被下下来跑一遍。对于合规要求严格的场景,明文出现在客户端环境里这件事本身就是风险。

我遇到的实际需求比这个更进一步:系统要接受第三方审计,审计方明确把“核心业务接口的请求参数和响应数据不得明文传输”写进了整改项。这就不是 HTTPS 能一句带过的,必须靠业务层加解密来兜底。所谓业务层加解密,就是在应用协议内部再做一层加密,让 HTTP 层面的抓包工具只能看到一坨 Base64 字符串,真正的业务参数藏在这层壳里。

基于这个需求,我设定了几个技术约束:第一,不能影响现有 Controller 的编码方式,最好做到接口代码零侵入;第二,GET 和 POST 两种传参方式都要覆盖;第三,前后端各自有一套独立的工具类,加解密规则必须完全对齐;第四,密钥至少要能周期性轮换,不能写死在代码里一用就是三年。这些都指向了“拦截器 + 注解 + 请求包装”的实现模式。

1.2 方案选型对比:SM4 加密、拦截器还是请求体通知

密码算法的选型几乎没有悬念。SM4 是国密标准中的对称分组密码算法,分组长度 128 位,密钥长度也是 128 位,加解密速度非常快,特别适合接口传参这种“每次请求小数据量、高频调用”的场景。业界和它对比的无非是 AES、DES、3DES。DES 早就被证明不安全,3DES 性能太差,AES 虽然通用性极好,但审计和合规不认,属于“没得选也得换”。SM4 的轮函数结构和 AES 有相似之处,但它是完全自主设计的标准,配套的 GM/T 系列标准在等保和密评里是加分项,这也是我这次坚持用 SM4 的根本原因。

实现方式的选型要更纠结一些。Spring Boot 里做请求解密有两条路:一条是实现 RequestBodyAdvice,它能在请求体被框架读取之前做拦截处理,但对 GET 的 query string 参数无能为力;另一条是写一个 Filter,Filter 是所有请求的必经之路,配合 HttpServletRequestWrapper 重写参数读取方法,既能覆盖 POST 的 body,也能覆盖 GET 的 query string。我最后选了 Filter 方案,因为这套系统是前后端分离项目,GET 接口的数量占到一半以上,如果只处理 POST,前端同事会先动手打人。

不过我不建议每个请求都尝试解密,那样静态资源和本来就该公开的接口也要走一遍耗时的解密逻辑。我定义了一个 @Crypto 注解,放在 Controller 类或方法上,Filter 里判断当前请求的 Controller 方法是否有这个注解,有才进入解密流程。这样就把“哪些接口要加密”的开关完全交到了开发手里,一个注解搞定。

1.3 动态 IV 与密钥分发策略:同一个 Key 不能一直用

SM4 是对称加密,前后端必须持有同一个密钥。密钥怎么给到前端,是这套方案里最容易翻车的地方。我用的是“管理端静态配置 + 定时轮换”的方式来分发:普通用户访问的系统里,接口地址由 Nginx 代理,SM4 密钥由运维通过配置中心分发到前端构建环境,换密钥的时候只需要改配置中心并触发前端重新发布。虽然不算是学术意义上的绝对安全,但在内网落地是完全够用的,因为它把密钥从代码仓库里挪出去了。

除此之外,我还做了一层“动态 IV”。IV 是初始向量,在很多实现里被忽略掉了,直接用固定 IV,结果就是“同一个明文在同一把密钥下永远产生同一个密文”。这是很危险的信号,抓包抓多了,别人就能通过密文重复率反推出业务行为规律。我的做法是每次请求由前端生成一个随机 16 字节 IV,加密时带上它,然后把 IV 的 Base64 值放到自定义请求头里一起传给后端。这样同一参数被加密两次,出来的密文完全不一样,后端拿到之后用同一个 IV 才能正确解密。

整体请求链路是这样的:前端生成随机 IV,用 SM4/CBC + PKCS7Padding 加密参数,把密文放进 body 或 query,把 IV 放在请求头;Spring Boot 的 Filter 拦截请求,从请求头取 IV,对请求体或参数做解密后重新包装 Request 对象,Controller 里拿到的就是明文参数;响应时 ResponseBodyAdvice 拦截返回结果,同样生成随机 IV 加密 data 字段,把 IV 放在响应头,前端 axios 响应拦截器里再做一次解密。整个过程对业务代码完全透明。

2. SM4 核心点拆解:分组、模式、填充、IV 之间的匹配关系

2.1 搞懂 SM4 的关键属性和加解密对称性

SM4 是一个分组密码,这里的“分组”是硬性规定的:不管你的参数是 1 个字节还是 1 万个字节,它加密的时候都是按 16 字节一块一块处理的。如果明文长度不是 16 的整数倍,就需要填充算法把它补齐。SM4 还有一个特点,它的解密过程用的是和加密相同的轮结构,只是轮密钥顺序反过来。这个特性在做硬件实现和嵌入式开发时很有意义,但在 Spring Boot 里,我们直接调用 Bouncy Castle 提供的 JCE 接口就够了,不需要自己去实现轮函数。

在 Java 里使用 SM4,有两个库可选:Bouncy Castle 和 Hutool。Hutool 封装的 SmUtil 用起来确实很爽,代码就一行。但 Hutool 的默认实现是基于 ECB 模式,而且它自己实现的 SM4 类对 CBC 模式的支持并不是开箱即用的,你得自己拼参数。我更推荐直接用 Bouncy Castle,它是 Java 密码学领域的老牌兼容库,SM4 的相关实现已经非常成熟,Maven 里引入 bcprov 之后,直接用标准 JCE API 就能写,后续切换到 SM2、SM3 也顺理成章。

Bouncy Castle 的引入方式是这样的,在 pom.xml 里加一条依赖:

<dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk18on</artifactId> <version>1.78</version> </dependency>

依赖加好之后,我写了一个工具类 Sm4CbcUtil,把密钥、IV、加密、解密都封装起来。下面是核心代码:

package com.yourproject.crypto; import org.bouncycastle.jce.provider.BouncyCastleProvider; import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.security.SecureRandom; import java.security.Security; import java.util.Base64; public class Sm4CbcUtil { private static final String ALGORITHM = "SM4"; private static final String TRANSFORMATION = "SM4/CBC/PKCS7Padding"; static { if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { Security.addProvider(new BouncyCastleProvider()); } } private final byte[] keyBytes; public Sm4CbcUtil(byte[] keyBytes) { this.keyBytes = keyBytes; } public String encryptToBase64(String plainText, byte[] ivBytes) throws Exception { Cipher cipher = Cipher.getInstance(TRANSFORMATION, BouncyCastleProvider.PROVIDER_NAME); SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM); IvParameterSpec ivSpec = new IvParameterSpec(ivBytes); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encrypted = cipher.doFinal(plainText.getBytes(java.nio.charset.StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encrypted); } public String decryptFromBase64(String cipherText, byte[] ivBytes) throws Exception { Cipher cipher = Cipher.getInstance(TRANSFORMATION, BouncyCastleProvider.PROVIDER_NAME); SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM); IvParameterSpec ivSpec = new IvParameterSpec(ivBytes); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(cipherText)); return new String(decrypted, java.nio.charset.StandardCharsets.UTF_8); } public static byte[] generateRandomIv() { byte[] iv = new byte[16]; new SecureRandom().nextBytes(iv); return iv; } }

注意到我加密出来的密文做了 Base64 编码。原因很朴素:加密出来的是一堆无法打印的字节,直接塞进 JSON、请求头、URL 参数里会被编码器破坏。Base64 把二进制数组变成了 ASCII 字符串,方便在各个上下文里传递和解码。后面前端对齐的时候,后端也要用相同的方式处理 IV。

2.2 工作模式对比:为什么放弃 ECB 和 CTR,选择 CBC

SM4 本身只是分组加密算法,怎么把一个个 16 字节分组串起来,由工作模式决定。常见的模式有 ECB、CBC、CTR、GCM 四种。ECB 最简单,每一块明文独立加密成一块密文,块与块之间没有任何关联。它的致命问题是:相同明文块一定得到相同密文块,如果用户提交的参数里有很多结构雷同的字段,密文会长得一模一样,这等于把数据结构泄露给了抓包者。CTR 模式是按计数器位置做异或,可以并行处理、性能极高,但它不提供完整性保护,密文被篡改之后你很难察觉到。

CBC 模式引入了 IV。每个明文分组先和前一个密文分组做异或,再执行加密。这样一来,相同的明文在不同的 IV 下会产生完全不同的密文,而且当前分组之前的任意一个密文改动都会影响后面所有分组的解密结果,对篡改有一定的扩散效果。当然,CBC 不防主动攻击者改动明文,所以正规方案里一般会再加 SM3 或 HMAC 做完整性校验。我这套方案的定位是业务层防泄露,防篡改就交给时间戳和签名头去兜底,CBC 已经足够满足原始需求。

在有 IV 需求的模式里,IV 必须是 16 字节的随机数,而且每次请求都要变。实现上我用 SecureRandom 生成一个 16 字节数组作为 IV。它不需要加密保护,因为它本身不包含业务信息,就算抓包人员拿到 IV,没有密钥也解不开密文,这是 IV 公开是安全的设计。前端生成的 IV 会放在请求头里,后端解密时读取这个头,再拼接解密参数,顺序必须严格一致。

2.3 填充方式 PKCS7Padding 与字符串编码细节

SM4 的分组是 16 字节,如果明文最后一块不足 16 字节,就得填充。PKCS7Padding 的规则是“缺多少字节就填充多少个值为这个数字的字节”。比如最后一块差 4 字节,就补 4 个 0x04;如果明文恰好是 16 的整数倍呢?PKCS7Padding 也会补一个完整的 16 字节填充块,这是为了解密时能明确区分“填充的数据”和“真实数据”。很多刚接触的人会在这里栽跟头,他们看到最后多了 16 个 0x10 字节,以为是自己加密错了,其实是算法设计如此。

我处理字符串的时候会统一使用 UTF-8 编码,Java 端的字符串默认是 UTF-16 内存模型,存取时必须显式指定 Charset,否则密文对不上。曾经调试过一个中文乱码问题,后端解密出来全是“???”,前端那边用 utf8 字节数组加密,到 Java 这边却用平台默认字符集编码,两边不匹配。这个细节会在后面的排查章节细说。

对齐前后端时,大家要共同确认几个点:工作模式是不是 CBC、填充方式是不是 PKCS7、密钥是不是 16 字节 UTF-8 字符串、IV 是不是 16 字节。只要有一个点不一致,解密就一定报错。

3. 后端实现细节:注解定义、请求包装解密、响应加密

3.1 自定义 @Crypto 注解控制加解密范围

我不想让所有接口都被动套上解密逻辑。有的接口本来就要暴露大数据量列表,有的接口要对接第三方回调,它们根本不适合做后端解密。所以我搞了一个极简的注解:

package com.yourproject.crypto.annotation; import java.lang.annotation.*; @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Crypto { boolean decrypt() default true; boolean encrypt() default true; }

定义在类上表示这个 Controller 里所有接口默认都走加解密,定义在方法上可以单独精细化控制。比如你有个查询下拉数据的接口,数据本身不敏感,你就可以在方法上写@Crypto(decrypt = true, encrypt = false),只解密请求参数,不加密响应,也能少一层序列化开销。这个注解在后面的自定义 Filter 和被 ResponseBodyAdvice 的 supports 方法里都要读取。

使用注解的时候提醒一句:注解要在org.springframework.web.method.HandlerMethod上判断。HandlerMethod 是 Spring MVC 把 URL 映射到具体 Controller 方法后的封装对象,里面包含了方法上的注解。Filter 拿到的只是 HttpServletRequest,想读出 HandlerMethod 必须把 request 的 attribute 用HandlerMapping.class.getName() + ".handler"这个 key 去取,或者干脆把解密逻辑放在一个自定义拦截器 HandlerInterceptor 里做,那样 preHandle 方法天然能拿到 HandlerMethod。这个权衡大家根据自己的工程习惯选,我写在 Filter 里是为了后续给非 Spring Boot 容器也留一条路。

3.2 解密请求参数:重写 HttpServletRequestWrapper 的读取逻辑

Filter 解密请求的核心道具是 HttpServletRequestWrapper。这个包装器可以让我们接管原始 Request 的流读取方法,在流被 Controller 读取之前把密文替换成明文。我拆成两步:第一步是解密请求体,第二步是重写 getParameter 相关方法。

处理 POST 请求时,请求体里是一段 Base64 密文字符串。Filter 需要先拿到请求头的 IV,然后从输入流里读出这段密文,解密成明文 JSON,再把明文字节缓存到包装类里。之后 Controller 里凡是调用request.getInputStream()或request.getReader(),拿到的都是我缓存进去的明文,而不是原始密文。这里有一个坑:Servlet 的输入流只能被读取一次,RequestBodyAdvice 没包装的话,在 Filter 里读完了,后面 Controller 就再也读不到了,所以包装器的getInputStream()返回的流要基于缓存好的明文数组重新构建。

处理 GET 请求时,密文在 query string 里,比如?data=abc123xxx。包装器需要重写getParameter(String name)、getParameterValues(String name)和getParameterMap()。因为一个 GET 请求可能同时存在加密参数和普通参数,比如?data=密文&pageNo=1&pageSize=10,所以我在解密的时候只尝试解密名为 data 的参数,解密失败则保持原样,剩下的普通透传参数原样返回。核心代码是这样:

public class DecryptRequestWrapper extends HttpServletRequestWrapper { private final byte[] decryptedBody; private final Sm4CbcUtil sm4CbcUtil; private final Map<String, String[]> params = new HashMap<>(); public DecryptRequestWrapper(HttpServletRequest request, byte[] decryptedBody, byte[] ivBytes, Sm4CbcUtil sm4CbcUtil) { super(request); this.decryptedBody = decryptedBody; this.sm4CbcUtil = sm4CbcUtil; if (request.getParameterMap() != null) { this.params.putAll(request.getParameterMap()); } // 尝试解密 query string 里名为 data 的参数 String encryptedParam = request.getParameter("data"); if (encryptedParam != null) { try { String decrypted = sm4CbcUtil.decryptFromBase64(encryptedParam, ivBytes); String[] values = decrypted.split("&"); Map<String, String[]> decryptedParams = new HashMap<>(); for (String value : values) { String[] kv = value.split("=", 2); if (kv.length == 2) { decryptedParams.put(kv[0], new String[]{kv[1]}); } } params.putAll(decryptedParams); } catch (Exception e) { throw new BusinessException(1001, "参数解密失败"); } } } @Override public String getParameter(String name) { String[] values = params.get(name); return values != null && values.length > 0 ? values[0] : null; } @Override public String[] getParameterValues(String name) { return params.get(name); } @Override public Map<String, String[]> getParameterMap() { return java.util.Collections.unmodifiableMap(params); } @Override public ServletInputStream getInputStream() { ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(decryptedBody); 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)); } }

设置了纯业务代码不需要感知加解密,Controller 里正常用一个 DTO 接参,Spring MVC 在绑定参数时调用的是getParameter系列方法,拿到手的自然就是解密后的明文。这里有个值得注意的边界:如果 DTO 接收的是@RequestBodyJSON 对象,Spring 读取的输入流是我们重写的 getInputStream,所以同样能正确反序列化。

注意:Filter 里面要区分请求路径,如果把这个包装器用在了没有任何注解的接口上,逻辑上也会执行解密。所以潜规则是“被包装的接口,请求参数 data 一定是密文;没被包装的接口,data 就是普通参数”,两边不能混用。

3.3 响应加密:用 ResponseBodyAdvice 拦截返回结果

响应加密我用的是ResponseBodyAdvice,这是 Spring MVC 提供的一个钩子,Controller 方法执行完,在消息转换器把返回值写入响应流之前,我们可以修改返回值。这样服务端的业务代码完全不用改,返回什么对象都行,最后统一在外面套一层加密壳。

先看一下这个接口的关键方法:

@RestControllerAdvice public class CryptoResponseAdvice implements ResponseBodyAdvice<Object> { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @Override public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) { return returnType.hasMethodAnnotation(Crypto.class) || returnType.getContainingClass().getAnnotation(Crypto.class) != null; } @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { // 生成随机 IV byte[] ivBytes = Sm4CbcUtil.generateRandomIv(); String ivBase64 = Base64.getEncoder().encodeToString(ivBytes); // 把返回对象序列化为 JSON 字符串再加密 Sm4CbcUtil sm4CbcUtil = new Sm4CbcUtil(Sm4KeyHolder.get()); try { String plainText; if (body instanceof String) { plainText = (String) body; } else { plainText = OBJECT_MAPPER.writeValueAsString(body); } String cipherText = sm4CbcUtil.encryptToBase64(plainText, ivBytes); response.getHeaders().add("X-Response-Iv", ivBase64); return Map.of("code", 200, "message", "ok", "data", cipherText); } catch (Exception e) { throw new BusinessException(1002, "响应加密失败"); } } }

返回给前端的数据格式我统一成{code, message, data},data 是 Base64 密文,响应头X-Response-Iv放 IV。前端要把密文和 IV 配对拿出来再做解密,如果 header 缺失或者密文格式不对,就能第一时间判定为“链路出了问题”,而不是糊里糊涂地把加密串渲染到页面上。

特别留意处理 String 返回值。如果 Controller 本身就是返回 String,Spring MVC 的默认消息转换器是 StringHttpMessageConverter,它返回的 body 就是一个字符串对象,不需要 OBJECT_MAPPER 再序列化一次。如果你对所有 Object 一视同仁地writeValueAsString,字符串内容会被加上一层 JSON 引号转义,前端解密后拿到的是一堆带转义符号的怪字符。我这里用 instanceof 分流,是比较稳妥的做法。

3.4 iv 传递与全局异常处理器的配合

解密请求需要 IV,加密响应也需要 IV。我把请求的 IV 放在X-Request-Iv这个自定义请求头里,后端从 Header 读取;响应 IV 放在X-Response-Iv响应头里,前端从 Header 读取。这样做能避免把 IV 拼在密文串里再解析一遍,逻辑更清晰,而且 Header 在很多网关日志里会自动记录,方便排查。

这里有个很容易忽略的问题:全局异常处理器抛出的异常,会不会也走到 ResponseBodyAdvice?会。因为你抛出的 BusinessException 最终交给 @RestControllerAdvice 里的 @ExceptionHandler 处理,返回值同样会进到 beforeBodyWrite。处理这个问题,我的经验是自定义一个 Result 结构,全局异常处理器返回一个明确的Result.fail(code, message),但这个结果不再走加密。怎么区分?我在 supports 方法里直接排除掉那些“异常处理专用”的方法,或者根据 body 的类型识别。用body instanceof Result && ((Result) body).isError()判断需要加密的方法足够实用。

另外,如果接口返回的是一个文件流或者已经写好的二进制数据,ResponseBodyAdvice 根本不会生效,所以文件下载接口不需要担心加密干扰响应。实际遇到的情况是上传和下载走的是 MultipartFile 和 StreamingResponseBody,这些消息转换器类型不同,supports 里如果只针对 MappingJackson2HttpMessageConverter 会返回 false,也就不会重复加密。

4. 前端实现:axios 拦截器里的 SM4-CBC 加解密

4.1 前端如何保证和后端 SM4 算法对齐

前端可选的 SM4 实现有好几个,我自己用得最顺的是 sm-crypto 这个库。它基于 JavaScript 实现,支持 ECB、CBC 模式,对 PKCS7Padding 的处理也在封装里做掉了。npm 安装一行命令不够形象,实际是:

npm install sm-crypto

引入使用再补一条:

import { sm4 } from 'sm-crypto'

注意 sm-crypto 的加密返回值默认是 hex 字符串,如果直接把它放进 JSON 里,后端的 Base64 解码会失败。所以前端在拿到 sm4.encrypt 的返回结果之后,必须手动把 hex 字符串转成字节数组,再走一次 Base64 编码。反过来,后端返回的密文是 Base64,前端 sm4.decrypt 要求输入 hex 或字节数组,那就得先把 Base64 解码成字节数组。这一来一回的编码转换,最容易把两端搞乱。

我封装了两个工具函数:

function hexToBytes(hex) { const bytes = [] for (let i = 0; i < hex.length; i += 2) { bytes.push(parseInt(hex.substr(i, 2), 16)) } return bytes } function base64ToBytes(base64) { const binary = atob(base64) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i) } return Array.from(bytes) } function bytesToBase64(bytes) { let binary = '' bytes.forEach(b => { binary += String.fromCharCode(b) }) return btoa(binary) }

统一以后,我前端封装的加密函数长这样:

function encryptParams(plainText, keyBytes, ivBytes) { const hexResult = sm4.encrypt(plainText, keyBytes, { mode: 'cbc', iv: ivBytes, padding: 'pkcs7' }) const cipherBytes = hexToBytes(hexResult) return bytesToBase64(cipherBytes) }

keyBytes 和 ivBytes 在这里都是字节数组。如果你们的配置中心只能下发字符串,只要把字符串先按 UTF-8 转成字节数组即可。Java 端用key.getBytes(StandardCharsets.UTF_8)生成 SecretKeySpec,前端用 TextEncoder 或手动编码的 Uint8Array 保持一致。

4.2 请求拦截器:把 body 和 query string 都加密

axios 的请求拦截器在业务代码发出请求后、离开浏览器之前执行,非常适合统一注入加密逻辑。我的做法是在请求拦截器里判断配置项crypto.enable,只有当请求配置开启加密时才走这一段:

service.interceptors.request.use(config => { if (!config.crypto) { return config } const keyBytes = getSm4Key() const ivBytes = generateIv() config.headers['X-Request-Iv'] = bytesToBase64(ivBytes) if (config.method === 'get') { const params = config.params || {} const plainText = JSON.stringify(params) config.params = { data: encryptParams(plainText, keyBytes, ivBytes) } } if (config.method === 'post') { const plainText = JSON.stringify(config.data) config.data = { data: encryptParams(plainText, keyBytes, ivBytes) } } return config })

POST 的处理比较直白,把原来的请求体整个序列化成 JSON 字符串,加密后放进一个data字段里。后端的 DTO 结构就变成只有一个 data 属性的对象,或者你用Map<String, String>接收也行,我在后端 Filter 里直接读取原始输入流解密,因此 DTO 怎么定义都不重要。

GET 的处理我是把所有的 params 对象序列化之后,全部塞进一个data参数里。后端包装器的 params map 逻辑支持把解密出的key=value键值对重新放回参数容器里。假如一个 GET 请求原本是?userId=100&name=zhang,前端序列化后整个变成?data=密文串,后端解密后把userId和name再填回 params,Controller 里拿到的 request 参数依然完好。整个链路的数据一致性是闭环的。

注意:GET 请求的加密字符串里可能包含+、/、=这些 Base64 特殊字符,放进 URL 里会被服务器解析成空格或分隔符,必须预先做一次encodeURIComponent编码。我在上面代码里生成 config.params 之后,axios 会自己拼 query,但为了保险,你可以在手动拼 URL 的场景里额外调用encodeURIComponent。

4.3 响应拦截器:解析统一壳结构并解密 data

响应侧的逻辑要反过来。axios 的 response 拦截器拿到的是后端返回的统一结构{code, message, data},data 是加密后的密文,IV 在响应头里。为了把这个壳拆开,我把后端所有 Controller 的返回值都包成统一的 Result 体,前端再把这个 body 透传出去。解密代码:

service.interceptors.response.use(response => { if (!response.config.crypto) { return response.data } const res = response.data if (res && res.code === 200 && res.data) { const ivBase64 = response.headers['x-response-iv'] const ivBytes = base64ToBytes(ivBase64) const cipherBytes = base64ToBytes(res.data) const decryptedHex = sm4.decrypt(cipherBytes, getSm4Key(), { mode: 'cbc', iv: ivBytes, padding: 'pkcs7' }) const decryptedText = utf8ArrayToString(hexToBytes(decryptedHex)) res.data = JSON.parse(decryptedText) } if (res && res.code !== 200) { // 业务异常统一提示 } return res }, error => { ... })

这里需要注意sm4.decrypt的返回值也是 hex 字符串,必须转成字节数组再按 UTF-8 解码成字符串,最后 JSON.parse。如果你不转,直接把 hex 字符串塞给业务代码,页面会渲染出一连串68656c6c6f之类的 ASCII 码。我在真实项目中第一次联调就栽在这,后来封装工具函数之后才爽利起来。

还有一个小点:响应头的大小写。axios 读取自定义响应头的 key 方式通常是大写转小写,也就是x-response-iv。如果后端网关或 Nginx 配置了“删除未暴露的响应头”,需要改一下跨域配置,用Access-Control-Expose-Headers把X-Response-Iv暴露出来,否则前端在跨域环境下读不到这个 header。这个坑藏得比较深,排查过一晚上。

5. 密钥管理与防重放设计:加密不是终点,抗篡改才是

5.1 密钥轮换与多环境隔离

如果 SM4 的密钥在前端代码里被明文硬编码,那这套加密的意义就要打折扣。我要求项目里所有前端密钥都来自构建时的环境变量,不同环境(开发、测试、生产)使用不同的密钥。开发环境可以用.env.development配置,生产环境必须在构建服务器上通过密钥管理服务注入,禁止提交到 Git。密钥的轮换周期建议是 30 天到 90 天,轮换时前后端同时发布,或者做成双密钥兼容的模式,给旧密钥留一个 24 小时过渡窗口。

后端维护密钥的地方是密钥持有类 Sm4KeyHolder,通过配置中心动态更新。上线轮换密钥只需要更新配置并调用一次刷新接口,不需要重启服务。如果密钥变了,前端还在用旧密钥请求,解密一定会失败,这时候要设计一个明确的提示,让前端能跳转重新登录或者强制刷新页面,而不是浏览器白屏。

5.2 时间戳、随机数和签名配合防重放

SM4 加密能防泄露,但它不防重放。攻击者把之前的密文重新发一遍,后端一样能解密成功。对于交易类接口,这很危险。我的配套方案是:请求头里带时间戳X-Timestamp和随机数X-Nonce,后端在 Filter 解密之前先校验时间戳和当前时间是否超过 5 分钟,再把 nonce 存到 Redis 做一次性消费。这样同一段密文即使被抓包,也过期作废,无法二次使用。

如果有更高的篡改防护要求,可以在前端对“明文参数 + 时间戳 + nonce”做一次 SM3 哈希,把哈希值放进请求头,后端解密后重新计算并比对。SM3 是国密里的哈希算法,和 SM4 配合起来非常顺畅。我目前用的这套系统核心交易接口就加了这层验签,非核心查询接口只做解密不验签,性能开销小很多。整体流程是自洽的。

5.3 白名单机制与静态资源放行

很多接口并不需要加解密,比如用户登录前获取图形验证码、后端心跳检查、静态资源加载。如果在 Filter 里对所有请求都尝试解密,会带来两个问题:一个是静态资源请求根本没有业务参数,读不到密文会继续走原逻辑,逻辑上不报错但白白浪费加解密开销;另一个是某些第三方回调接口的报文格式是别人定的,你没法要求对方也走 SM4。我维护了一个 whitelist 列表,作用是在注解判断之前先跳过不兼容的路径:

if (isWhiteListed(request.getRequestURI())) { chain.doFilter(request, response); return; }

这个白名单放在配置中心里维护,可以随时增删,发布不需要重启后端。同时我还把 Swagger 等文档路径加了进去,因为团队里有人习惯直接在文档页面调试接口。如果不对文档路径放行,开发环境一打开接口文档就会看到请求参数是密文,根本无法手动测试。这个细节非常影响开发体验,建议大家一开始就设计进去。

6. 实际踩坑与排查实录:密文对不上的那些现场

6.1 中文乱码、Base64 换行符、URL 特殊字符

最早一次联调,后端解出来的中文全是问号。排查到最后,问题出在前端在sm4.encrypt之前把 JSON.stringify 的字符串直接放进函数,sm-crypto 内部对中文字符的处理方式可能和 Java 端 UTF-8 字节不一致。解决办法是在前端把明文先转成 UTF-8 字节数组,再转成 hex 字符串,喂给 sm4.encrypt。加解密必须保证“两端处理的明文是同一个字节序列”。

Base64 还有一个隐蔽问题:默认的 Base64 编码器在密文过长时可能会插入换行符。Java 标准库java.util.Base64.getEncoder()并不会插入换行,但很多第三方库会。如果响应体里的 Base64 密文中间出现了换行,前端atob解码就会报错。我的建议是前端在处理密文前先去掉所有空白字符,正则表达式/[\s\r\n]/g替换为空串,避免不同 Base64 实现的差异影响联调。

6.2 GET 请求密文传输被截断

明文?userId=100&name=zhang加密后的 Base64 可能带着+号,浏览器在解析 URL 时会把+当作空格。我第一次调试 GET 请求时,后端解出来的字符串中间多了一个空格,一直以为是加密逻辑错乱,后来才发现是 URL 编码的问题。解决方案就是前端对config.params.data这个值做encodeURIComponent,把+转成%2B,后端再通过 URLDecoder 解码还原。这个我也是踩了好几轮才总结出来的规则。

还有一类 GET 场景是网关或负载均衡层开启了对 query string 的日志脱敏,网关把密文里的某些字符给替换成了星号。这种问题在测试环境几乎不会出现,一到生产环境就有人反馈解密失败,最后查日志才看到密文被网关截断了一截。如果你遇到生产环境偶发解密失败,优先怀疑是不是中间设备动了参数,最好统一在 POST body 里传密文,规避 query string 被改写。

6.3 String 返回值被二次序列化

这个问题的表现是前端解密后得到的数据是"hello"带引号的字符串,或者是像"\"hello\""这样被转义的内容。根因就是我前面提到过的:ResponseBodyAdvice 在处理 String 类型的返回值时,不能再用 Jackson 去序列化。我最初没做类型判断,所有返回值一把梭用 OBJECT_MAPPER 序列化,前端拿到字符串类型就变成了 JSON 字符串的形式。后来改成 instanceof String 分支,问题就消失了。

另外当你统一返回体后,前端的JSON.parse(decryptedText)立刻发现 parse 出来的是一个字符串而不是对象,就会报错。这个时候先确认后端是否对 String 做了特判,以及全局异常返回的数据结构是不是也满足统一的 Result 格式。

6.4 排查工具与校验方法:三步定位加解密问题

我平时调试这类问题有一套固定的三步法。第一步,在后端写一个 main 方法,直接用 SMCBCUtil 加密一串已知文本,再用同一个工具类解密,确认 Java 侧加解密本身是自洽的。第二步,把前端加密结果拿到这个 main 方法里解,或者把后端加密结果的 Base64 拿到前端解,能定位问题出在哪一侧。第三步,把 IV 和密文都用常量输出对比,确认前端的 IV 和后端读取到的 IV 是同一个字节序列。这个三步法能帮我快速区分是算法参数问题、编码问题还是 IV 传递问题。

排查问题速查表如下:

现象可能原因解决方案
解密后中文乱码前后端字符串字节编码不一致统一使用 UTF-8 编码并显式指定 Charset
Base64 中包含换行或空格Base64 实现不同导致前端解码前去除空白字符
GET 请求解密后多出空格Base64 的+被解析为空格对密文参数做 encodeURIComponent
响应解密结果带引号String 返回值被二次序列化ResponseBodyAdvice 中特判 String 类型
跨域环境读不到响应 IV响应头未暴露给前端配置 Access-Control-Expose-Headers
生产偶发解密失败网关修改了 query string改用 POST body 传密文或排查网关策略
重复请求可被重放缺少时间戳与 nonce 校验请求头增加时间戳与 nonce 并做 Redis 校验

前端调试的时候,我还会在浏览器控制台打印加密前的明文、密文、IV、解密后的数据,四段对照。只要中间某一段和 Java 侧的日志对不上,问题范围立刻缩小。最后再分享一个小技巧:我把整个加解密过程封装成一个独立的 NPM 包和 Java 工具类,统一发给前后端同事使用,版本号保持一致。这样不管谁改了算法细节,只要版本不变,两边一定是对齐的,省掉了大量“我这里明明没问题”的扯皮成本。加密这东西,最怕的就是两端各自实现一套,能共用一套封装,阻力会小得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 5:00:13

SEATA AT模式实战:分布式事务原理、配置与避坑指南

在微服务架构遍地走的今天&#xff0c;分布式事务一直是个绕不开的坎。数据库本地事务好办&#xff0c;ACID 四个特性背得滚瓜烂熟就能搞定&#xff1b;一旦拆了服务、拆了库&#xff0c;数据一致性就成了悬在头上的达摩克利斯之剑。我接触 SEATA 的 AT 模式也算比较早&#xf…

作者头像 李华
网站建设 2026/10/21 4:59:21

地图API接入实战:从密钥管理到性能优化的全流程避坑指南

1. 为什么“接入地图API”远不止复制粘贴一段代码很多刚接触地图功能开发的朋友&#xff0c;第一次听到“接入地图API”这个任务时&#xff0c;脑子里浮现的画面往往很简单&#xff1a;去官网注册个账号&#xff0c;拿到一串密钥&#xff0c;然后把官方示例里的那几十行代码复制…

作者头像 李华
网站建设 2026/10/21 4:58:20

Electron UDP调试工具:3分钟搭建跨平台网络调试器

简介&#xff1a;这是一款基于 Electron 框架开发的轻量级 UDP 网络调试工具&#xff0c;面向前端开发者、网络协议学习者及嵌入式/物联网测试人员&#xff0c;用于快速搭建本地 UDP 客户端与服务器环境&#xff0c;验证数据报收发逻辑、端口连通性及通信稳定性。资源包共30个文…

作者头像 李华
网站建设 2026/10/21 4:58:19

技能不是名词而是动词:从保鲜到复用的职业能力管理指南

很多人会把“skills”理解成简历上那一行技能列表&#xff0c;或者招聘网站上的标签云。但真正在项目里滚过几年、带过团队、招过人之后&#xff0c;你会发现&#xff0c;skills不是名词&#xff0c;是动词——它是你持续解决问题的能力的总和&#xff0c;是“知道什么、做到什…

作者头像 李华
网站建设 2026/10/21 4:58:04

把技能练成肌肉记忆:硬技能、软技能与元技能的修炼之路

一个人从二十多岁开始工作&#xff0c;到三十多岁甚至更长的时间跨度里&#xff0c;真正决定职业天花板和生活质量的&#xff0c;往往不是某个具体的“岗位title”&#xff0c;而是你手里那几张拿得出手的“skills”。我见过太多人把“skills”这个词挂在嘴边&#xff0c;简历上…

作者头像 李华
网站建设 2026/10/21 4:57:35

个人技能管理系统:从元技能到组合复利的实践指南

1. 先别急着学新技能&#xff1a;多数人搞错了技能成长的第一步我见过太多人把“技能提升”理解成两件事&#xff1a;报课和收藏。某朋友去年年初兴致勃勃买了一套视频课程&#xff0c;三个月后问起来&#xff0c;他说看完了前两章&#xff0c;剩下的躺在网盘里吃灰&#xff1b…

作者头像 李华