Spring Boot接口国密SM4/SM3加密签名实战:5分钟集成指南
2026/8/4 19:37:34 网站建设 项目流程

1. 项目概述:为什么你的Spring Boot接口需要国密加密与签名?

最近在做一个对数据安全要求比较高的项目,甲方明确要求使用国密算法来保障接口数据传输的安全。这让我不得不把尘封已久的国密标准文档又翻了出来。说实话,一开始我也觉得头大,国密SM2、SM3、SM4这些名词听起来就有点“硬核”,网上资料虽然多,但要么是纯理论,要么是零散的代码片段,想快速集成到Spring Boot项目里,还真得花点功夫踩踩坑。

所以,我花了点时间,把SM4对称加密和SM3摘要签名这两个最常用的功能,封装成了一个开箱即用的Spring Boot Starter。目标很简单:让你在5分钟内,通过几行配置和注解,就能为你的RESTful接口穿上国密算法的“防弹衣”。无论是敏感的用户信息、支付数据,还是普通的业务参数,都能轻松实现“传输即加密,收包即验签”,从源头杜绝数据在传输过程中被窃取或篡改的风险。

这篇文章,我就来手把手带你走一遍这个“5分钟实战”的全过程。我会重点讲清楚三个事:一是国密算法(SM4/SM3)在接口安全里到底扮演什么角色;二是如何用最少的代码把它们集成到你的Spring Boot项目里;三是我在实际开发中踩过的那些坑和总结的最佳实践。如果你正在为项目的数据安全合规性发愁,或者单纯想给接口加一层更可靠的保护,那这篇内容应该能帮到你。

2. 核心思路与架构设计:如何优雅地为接口穿上“防护服”?

在动手写代码之前,我们得先想明白整个方案要怎么设计。我的核心思路是:“非侵入式”和“声明式”。简单说就是,业务开发人员最好完全感知不到加密解密、签名验签的存在,他们只需要关注自己的业务逻辑。加解密这些“脏活累活”,应该由框架在请求进来和响应出去的时候自动完成。

2.1 技术选型与依赖梳理

首先,我们得选好“武器库”。国密算法的Java实现,主流的有两个选择:Bouncy Castle和国内的GMSSL/Tongsuo。考虑到易用性和与Spring Boot的集成度,我选择了Bouncy Castle这个老牌且强大的加密库,它提供了完整的国密算法支持。同时,为了简化操作,我引入了Hutool这个国产工具包,它对Bouncy Castle的国密算法进行了友好封装,API非常简洁。

<!-- 核心依赖 --> <dependency> <groupId>org.bouncycastle</groupId> <artifactId>bcprov-jdk15to18</artifactId> <version>1.78</version> <!-- 使用较新稳定版本 --> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.26</version> </dependency> <!-- Spring Boot Web基础 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>

注意:Bouncy Castle的版本需要与你项目的JDK版本匹配。bcprov-jdk15to18适用于JDK 1.5到1.8。如果你使用的是JDK 9及以上,通常也兼容,但为了最佳稳定性,建议查阅官方文档选择对应版本。

2.2 整体架构设计

整个方案的架构围绕Spring MVC的拦截器(HandlerInterceptor)和响应处理器展开,分为上行(请求)和下行(响应)两条线:

  1. 上行(解密与验签)

    • 请求到达Controller之前,被一个自定义的拦截器DecryptAndVerifyInterceptor拦截。
    • 拦截器读取请求头中的加密/签名标识(例如X-Data-Encrypted: SM4X-Signature: xxx)。
    • 如果标识存在,则从请求体(Body)中获取被SM4加密的密文,用预共享的密钥进行解密,得到原始的明文参数。
    • 同时,利用SM3算法,根据解密后的明文和预共享的签名密钥,重新计算签名,并与请求头中的签名值比对,完成验签。
    • 验签通过后,将解密得到的明文参数(通常是JSON字符串)通过HttpServletRequestWrapper替换掉原始的请求流,供后续的@RequestBody等注解正常绑定。
  2. 下行(加密与签名)

    • Controller方法执行完毕,返回对象给前端之前,被一个自定义的ResponseBodyAdvice处理。
    • 这个处理器检查响应体是否需要加密(可通过注解标记,如@EncryptResponse)。
    • 如果需要,则将返回的Java对象序列化为JSON字符串,用SM4进行加密。
    • 同时,对加密前的原始明文(或密文本身,根据约定)用SM3计算签名。
    • 最后,将密文和签名分别放入响应体和响应头中,返回给客户端。

这样,对于业务代码来说,Controller里拿到和返回的,始终是明文的POJO对象,加解密和签名验签对开发者透明。

// 理想中的业务代码,完全无感知 @PostMapping("/user") @EncryptResponse // 一个声明式注解,标记此接口响应需要加密 public ApiResult<UserVO> createUser(@RequestBody @DecryptedParam UserCreateDTO dto) { // dto 已经是解密后的明文对象 UserVO user = userService.create(dto); return ApiResult.success(user); // 返回的user会被自动加密 }

3. 核心工具类封装:打造你的国密算法“瑞士军刀”

有了架构,我们需要先造好“轮子”——一个健壮、易用的加解密和签名工具类。这里我基于Hutool进行封装,处理一些底层细节和异常。

3.1 SM4对称加密工具类

SM4是一种分组密码算法,密钥长度固定为128位(16字节)。我们通常使用CBC(密码分组链接)模式,因为它比ECB(电子密码本)模式更安全。CBC模式需要一个初始化向量(IV),长度也是16字节。

import cn.hutool.core.util.CharsetUtil; import cn.hutool.core.util.HexUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.Mode; import cn.hutool.crypto.Padding; import cn.hutool.crypto.symmetric.SM4; import lombok.extern.slf4j.Slf4j; import org.springframework.util.Base64Utils; import java.nio.charset.StandardCharsets; /** * SM4 加解密工具类 (CBC模式,PKCS7Padding填充) */ @Slf4j public class Sm4Util { /** * 默认密钥 (16字节)。【重要】生产环境必须从安全配置中心获取,严禁硬编码! */ private static final String DEFAULT_KEY = "1234567890abcdef"; // 示例,128位 /** * 默认初始化向量IV (16字节)。【重要】生产环境必须妥善管理,可与密钥一起配置。 */ private static final String DEFAULT_IV = "fedcba0987654321"; // 示例,128位 private static final SM4 sm4; static { // 初始化SM4实例,指定CBC模式和PKCS7Padding填充 sm4 = new SM4(Mode.CBC, Padding.PKCS7Padding, DEFAULT_KEY.getBytes(StandardCharsets.UTF_8), DEFAULT_IV.getBytes(StandardCharsets.UTF_8)); } /** * SM4 加密 (输出Base64字符串) * @param plaintext 明文 * @return Base64编码的密文 */ public static String encryptToBase64(String plaintext) { if (StrUtil.isBlank(plaintext)) { return plaintext; } try { return sm4.encryptBase64(plaintext, CharsetUtil.CHARSET_UTF_8); } catch (Exception e) { log.error("SM4加密失败,明文: {}", plaintext, e); throw new RuntimeException("SM4加密失败", e); } } /** * SM4 解密 (输入Base64字符串) * @param base64Ciphertext Base64编码的密文 * @return 明文 */ public static String decryptFromBase64(String base64Ciphertext) { if (StrUtil.isBlank(base64Ciphertext)) { return base64Ciphertext; } try { return sm4.decryptStr(base64Ciphertext, CharsetUtil.CHARSET_UTF_8); } catch (Exception e) { log.error("SM4解密失败,密文: {}", base64Ciphertext, e); // 这里可以根据业务需求决定是抛出异常还是返回空,通常解密失败意味着数据被篡改,应抛出异常 throw new RuntimeException("SM4解密失败或数据异常", e); } } // 可选:提供Hex格式的加解密方法,适用于某些特定场景 public static String encryptToHex(String plaintext) { byte[] encryptBytes = sm4.encrypt(plaintext); return HexUtil.encodeHexStr(encryptBytes); } public static String decryptFromHex(String hexCiphertext) { byte[] decryptBytes = sm4.decrypt(HexUtil.decodeHex(hexCiphertext)); return StrUtil.str(decryptBytes, CharsetUtil.CHARSET_UTF_8); } }

关键点与避坑指南:

  1. 密钥与IV管理:示例中的DEFAULT_KEYDEFAULT_IV致命错误示范!在生产环境中,必须将这些敏感信息存储在环境变量、配置中心或专业的密钥管理服务(KMS)中。绝对不要写在代码里或提交到版本库。
  2. 模式与填充:务必使用CBC模式,ECB模式不安全。填充选择PKCS7Padding(PKCS5Padding的扩展,在块大小为16字节时等价)。
  3. 异常处理:加解密过程可能因数据被篡改、密钥错误等原因失败。必须做好异常捕获和日志记录,并根据业务场景决定是抛出业务异常还是返回特定错误码。解密失败通常意味着请求不合法。
  4. 编码格式:确保加解密过程中字符串与字节数组的转换使用统一的字符集(如UTF-8),否则会出现乱码。

3.2 SM3摘要签名工具类

SM3是一种密码杂凑算法(哈希算法),类似于SHA-256。它生成一个固定长度(256位,32字节)的摘要,具有不可逆和抗碰撞性。我们用它来保证数据的完整性(是否被篡改)。

import cn.hutool.crypto.SmUtil; import cn.hutool.crypto.digest.DigestUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.util.Base64Utils; import java.nio.charset.StandardCharsets; /** * SM3 摘要签名工具类 */ @Slf4j public class Sm3Util { /** * 生成SM3摘要 (输出Hex字符串) * @param data 原始数据字符串 * @return 64位Hex字符串的摘要 */ public static String digestToHex(String data) { if (data == null) { data = ""; } return SmUtil.sm3(data); } /** * 生成SM3摘要 (输出Base64字符串) * @param data 原始数据字符串 * @return Base64编码的摘要 */ public static String digestToBase64(String data) { byte[] digestBytes = DigestUtil.digester("SM3").digest(data.getBytes(StandardCharsets.UTF_8)); return Base64Utils.encodeToString(digestBytes); } /** * 生成带密钥的HMAC-SM3签名 (更安全,推荐) * @param data 原始数据 * @param key 签名密钥 * @return Hex字符串的HMAC-SM3签名 */ public static String hmacSm3ToHex(String data, String key) { return SmUtil.hmacSm3(key.getBytes(StandardCharsets.UTF_8)).digestHex(data); } /** * 验证签名 * @param data 原始数据 * @param key 签名密钥 (用于HMAC-SM3) * @param signature 待验证的签名 * @return 是否验证通过 */ public static boolean verifyHmacSm3(String data, String key, String signature) { if (data == null || signature == null) { return false; } String calculatedSign = hmacSm3ToHex(data, key); // 使用恒定时间比较,防止时序攻击 return calculatedSign.equalsIgnoreCase(signature); } }

关键点与避坑指南:

  1. 普通摘要 vs HMACdigestToHex生成的是普通SM3摘要,任何拥有相同数据的人都能计算出相同的值。而hmacSm3ToHex带密钥的哈希消息认证码,只有拥有密钥的一方才能生成和验证正确的签名,安全性更高,强烈推荐在接口签名中使用HMAC-SM3
  2. 签名密钥:HMAC-SM3的密钥同样需要严格管理,且应该与SM4的加密密钥不同。
  3. 签名内容:签什么很重要!通常是对原始明文参数(按特定规则排序后)的字符串进行签名。也可以约定对“密文+时间戳+随机数”的组合进行签名,防止重放攻击。这部分需要前后端协商一致。
  4. 验证时机:验签必须在解密之后进行。因为你需要用解密后的明文(或约定的其他数据)重新计算签名,与客户端传来的签名做对比。

4. Spring Boot集成实战:实现自动化的拦截与处理

工具准备好了,现在开始集成到Spring Boot。我们将创建拦截器、响应处理器和相关的配置。

4.1 自定义HttpServletRequestWrapper

为了能在拦截器中修改请求体(InputStream只能读一次),我们需要包装原始的HttpServletRequest

import javax.servlet.ReadListener; import javax.servlet.ServletInputStream; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequestWrapper; import java.io.*; /** * 可重复读取Body的Request包装器 */ public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { private byte[] cachedBody; public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException { super(request); InputStream requestInputStream = request.getInputStream(); this.cachedBody = requestInputStream.readAllBytes(); // JDK9+ // 如果是JDK8,可以使用ByteArrayOutputStream来读取 // ByteArrayOutputStream baos = new ByteArrayOutputStream(); // byte[] buffer = new byte[1024]; // int len; // while ((len = requestInputStream.read(buffer)) > -1) { // baos.write(buffer, 0, len); // } // baos.flush(); // this.cachedBody = baos.toByteArray(); } @Override public ServletInputStream getInputStream() { return new CachedBodyServletInputStream(this.cachedBody); } @Override public BufferedReader getReader() { ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.cachedBody); return new BufferedReader(new InputStreamReader(byteArrayInputStream)); } // 内部类,提供基于字节数组的InputStream static class CachedBodyServletInputStream extends ServletInputStream { private final InputStream cachedBodyInputStream; public CachedBodyServletInputStream(byte[] cachedBody) { this.cachedBodyInputStream = new ByteArrayInputStream(cachedBody); } @Override public boolean isFinished() { try { return cachedBodyInputStream.available() == 0; } catch (IOException e) { return true; } } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener listener) { throw new UnsupportedOperationException(); } @Override public int read() throws IOException { return cachedBodyInputStream.read(); } } }

4.2 核心:解密与验签拦截器

这是处理上行请求的核心。

import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.util.Map; import java.util.Objects; /** * 请求解密与签名验证拦截器 */ @Slf4j @Component @RequiredArgsConstructor public class DecryptAndVerifyInterceptor implements HandlerInterceptor { // 从配置中心或环境变量读取,此处用常量示例 private static final String ENCRYPT_KEY = System.getenv("SM4_ENCRYPT_KEY"); private static final String SIGN_KEY = System.getenv("SM3_SIGN_KEY"); private static final String HEADER_ENCRYPT_FLAG = "X-Data-Encrypted"; private static final String HEADER_SIGNATURE = "X-Signature"; private static final String HEADER_TIMESTAMP = "X-Timestamp"; private static final String ENCRYPT_FLAG_VALUE = "SM4"; private final ObjectMapper objectMapper; // Spring Boot默认注入的Jackson @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 检查是否需要解密 String encryptFlag = request.getHeader(HEADER_ENCRYPT_FLAG); if (!ENCRYPT_FLAG_VALUE.equalsIgnoreCase(encryptFlag)) { // 未加密标识,直接放行 return true; } // 2. 读取并缓存请求体 CachedBodyHttpServletRequest wrappedRequest = new CachedBodyHttpServletRequest(request); String requestBody = new String(wrappedRequest.getCachedBody(), StandardCharsets.UTF_8); log.debug("收到加密请求,原始密文: {}", requestBody); // 3. SM4解密 String decryptedBody; try { decryptedBody = Sm4Util.decryptFromBase64(requestBody); log.debug("解密后明文: {}", decryptedBody); } catch (Exception e) { log.warn("请求数据解密失败,可能密钥错误或数据被篡改", e); response.setStatus(HttpServletResponse.SC_BAD_REQUEST); response.getWriter().write("{\"code\":400,\"msg\":\"数据解密失败\"}"); return false; } // 4. SM3验签 (验证数据完整性) String clientSignature = request.getHeader(HEADER_SIGNATURE); String timestamp = request.getHeader(HEADER_TIMESTAMP); // 用于防重放 if (StrUtil.isNotBlank(clientSignature)) { // 构造待签名字符串:约定为 `明文数据 + "|" + 时间戳` String dataToSign = decryptedBody + "|" + (Objects.requireNonNullElse(timestamp, "")); boolean signValid = Sm3Util.verifyHmacSm3(dataToSign, SIGN_KEY, clientSignature); if (!signValid) { log.warn("请求签名验证失败,数据可能被篡改或重放。clientSign:{}, dataToSign:{}", clientSignature, dataToSign); response.setStatus(HttpServletResponse.SC_BAD_REQUEST); response.getWriter().write("{\"code\":400,\"msg\":\"签名验证失败\"}"); return false; } // 可选:添加时间戳防重放检查(例如,请求时间与服务器时间差超过5分钟则拒绝) if (StrUtil.isNotBlank(timestamp)) { long serverTime = System.currentTimeMillis(); long clientTime = Long.parseLong(timestamp); if (Math.abs(serverTime - clientTime) > 5 * 60 * 1000) { // 5分钟容忍 log.warn("请求时间戳过期,疑似重放攻击。server:{}, client:{}", serverTime, clientTime); response.setStatus(HttpServletResponse.SC_BAD_REQUEST); response.getWriter().write("{\"code\":400,\"msg\":\"请求已过期\"}"); return false; } } } // 5. 将解密后的明文替换回Request中,供后续@RequestBody解析 // 这里需要将修改后的Request对象放回参数中,但Interceptor的preHandle无法直接替换。 // 更优雅的做法是使用Filter,或者使用ControllerAdvice配合@ModelAttribute。 // 此处提供一个在Interceptor中实现的思路:将解密后的body设置到request attribute中,在Controller中用自定义注解解析。 // 为了简化,本例采用一个全局的ThreadLocal(仅适用于当前线程同步场景,异步需小心)。 RequestDataHolder.setDecryptedBody(decryptedBody); // 实际更推荐使用OncePerRequestFilter,在Filter中完成解密并包装Request,后面会给出优化方案。 return true; } // 线程局部变量,用于存储解密后的body private static class RequestDataHolder { private static final ThreadLocal<String> DECRYPTED_BODY_HOLDER = new ThreadLocal<>(); static void setDecryptedBody(String body) { DECRYPTED_BODY_HOLDER.set(body); } static String getDecryptedBody() { return DECRYPTED_BODY_HOLDER.get(); } static void clear() { DECRYPTED_BODY_HOLDER.remove(); } } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 清除ThreadLocal,防止内存泄漏 RequestDataHolder.clear(); } }

关键点与避坑指南:

  1. Filter vs Interceptor:对于修改请求体这种操作,使用OncePerRequestFilterHandlerInterceptor更合适,因为Filter在更早的链路中,可以包装HttpServletRequest。上面的ThreadLocal方案是一种妥协,在简单同步场景下可用,但在异步编程(如@Async)中会失效。最佳实践是使用Filter
  2. 验签数据构造:签名的内容(dataToSign)必须前后端严格约定一致。通常包括解密后的明文、时间戳、随机数(Nonce)等,并按特定顺序和分隔符拼接。时间戳和Nonce是防御重放攻击的关键。
  3. 密钥获取ENCRYPT_KEYSIGN_KEY必须从外部配置(如Apollo、Nacos)或环境变量读取,绝对不要硬编码。
  4. 错误处理:解密或验签失败时,应立即中断请求并返回明确的错误信息(但不要泄露密钥等敏感细节),HTTP状态码建议使用400(Bad Request)。

4.3 优化方案:使用Filter进行请求体解密

为了解决Interceptor的局限性,我们实现一个更健壮的Filter。

import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import java.io.IOException; /** * 请求解密过滤器 (高优先级) */ @Component @Order(Ordered.HIGHEST_PRECEDENCE) // 设置最高优先级,确保最先执行 public class DecryptRequestFilter implements Filter { // 密钥、常量定义同上... @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) servletRequest; String encryptFlag = httpRequest.getHeader(HEADER_ENCRYPT_FLAG); if (!ENCRYPT_FLAG_VALUE.equalsIgnoreCase(encryptFlag)) { chain.doFilter(servletRequest, servletResponse); return; } // 包装请求,实现Body可重复读取和修改 CachedBodyHttpServletRequest wrappedRequest = new CachedBodyHttpServletRequest(httpRequest); String encryptedBody = new String(wrappedRequest.getCachedBody(), StandardCharsets.UTF_8); // 解密过程... String decryptedBody = Sm4Util.decryptFromBase64(encryptedBody); // 验签过程... // 创建新的RequestWrapper,其getInputStream返回解密后的数据流 HttpServletRequest decryptedRequestWrapper = new DecryptedBodyHttpServletRequestWrapper(wrappedRequest, decryptedBody); // 继续执行过滤器链,后续的Interceptor和Controller拿到的就是解密后的Request chain.doFilter(decryptedRequestWrapper, servletResponse); } // 一个简单的Wrapper,覆盖getInputStream和getReader方法 static class DecryptedBodyHttpServletRequestWrapper extends HttpServletRequestWrapper { private final String decryptedBody; public DecryptedBodyHttpServletRequestWrapper(HttpServletRequest request, String decryptedBody) { super(request); this.decryptedBody = decryptedBody; } @Override public ServletInputStream getInputStream() { ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(decryptedBody.getBytes(StandardCharsets.UTF_8)); return new ServletInputStream() { @Override public boolean isFinished() { return byteArrayInputStream.available() == 0; } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener listener) { } @Override public int read() { return byteArrayInputStream.read(); } }; } @Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(this.getInputStream(), StandardCharsets.UTF_8)); } } }

使用Filter后,Controller就可以像处理普通请求一样使用@RequestBody了,框架会自动将解密后的JSON绑定到你的DTO对象上。

4.4 响应加密与签名处理器

处理下行响应,我们使用ResponseBodyAdvice

import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.core.MethodParameter; import org.springframework.http.MediaType; import org.springframework.http.server.ServerHttpRequest; import org.springframework.http.server.ServerHttpResponse; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; import java.util.Objects; /** * 响应加密处理器 */ @ControllerAdvice public class EncryptResponseAdvice implements ResponseBodyAdvice<Object> { // 密钥、常量定义同上... private final ObjectMapper objectMapper; public EncryptResponseAdvice(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } /** * 判断哪些接口需要加密 * 这里通过检查Controller方法上是否有 @EncryptResponse 注解来决定 */ @Override public boolean supports(MethodParameter returnType, Class converterType) { // 也可以检查类上是否有注解,或者通过配置白名单/黑名单 return returnType.hasMethodAnnotation(EncryptResponse.class) || Objects.requireNonNull(returnType.getMethod()).getDeclaringClass().isAnnotationPresent(EncryptResponse.class); } /** * 在响应体写入之前处理 */ @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { try { // 1. 将响应对象序列化为JSON字符串 String originalJson = objectMapper.writeValueAsString(body); // 2. 使用SM4加密 String encryptedData = Sm4Util.encryptToBase64(originalJson); // 3. 生成签名 (对原始明文签名,确保完整性) long timestamp = System.currentTimeMillis(); String dataToSign = originalJson + "|" + timestamp; String signature = Sm3Util.hmacSm3ToHex(dataToSign, SIGN_KEY); // 4. 设置响应头 response.getHeaders().set(HEADER_ENCRYPT_FLAG, ENCRYPT_FLAG_VALUE); response.getHeaders().set(HEADER_SIGNATURE, signature); response.getHeaders().set(HEADER_TIMESTAMP, String.valueOf(timestamp)); // 5. 返回一个只包含加密数据的简单对象,或者直接返回密文字符串 // 这里返回一个Map,前端需要先解析这个Map,再根据header判断解密 // 更常见的做法是:响应体就是密文,头信息包含签名和标识。 // 为了清晰,我们返回一个结构化的对象 EncryptedResponse encryptedResponse = new EncryptedResponse(); encryptedResponse.setEncryptedData(encryptedData); encryptedResponse.setTimestamp(timestamp); return encryptedResponse; } catch (Exception e) { log.error("响应数据加密失败", e); // 加密失败,返回错误信息(不要暴露密钥等细节) return ApiResult.fail(500, "系统处理异常"); } } // 声明式注解,标记需要加密响应的接口 @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface EncryptResponse { } // 加密响应包装对象 @Data public static class EncryptedResponse { private String encryptedData; private Long timestamp; } }

4.5 注册拦截器与配置

最后,将我们写的组件注册到Spring容器。

import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private DecryptAndVerifyInterceptor decryptAndVerifyInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { // 如果使用Interceptor方案,在这里添加 registry.addInterceptor(decryptAndVerifyInterceptor) .addPathPatterns("/api/**") // 拦截/api开头的所有接口 .excludePathPatterns("/api/public/**"); // 排除公开接口 } // 如果使用Filter方案,Filter通过@Component注解已被自动注册,无需在此配置 }

5. 完整流程测试与常见问题排查

现在,我们来模拟一个完整的请求-响应流程,并看看可能遇到的问题。

5.1 测试用例:一个用户注册接口

1. 定义DTO和Controller

@Data public class UserRegisterDTO { @NotBlank private String username; @NotBlank private String mobile; @NotBlank private String password; // 注意:密码传输也应加密,这里演示业务数据加密 } @Data public class UserVO { private Long id; private String username; } @RestController @RequestMapping("/api/user") public class UserController { @EncryptResponse // 标记此接口响应需要加密 @PostMapping("/register") public ApiResult<UserVO> register(@RequestBody UserRegisterDTO dto) { // 此时dto已经是解密后的对象 log.info("收到注册请求,用户名: {}, 手机号: {}", dto.getUsername(), dto.getMobile()); // 模拟业务处理... UserVO userVO = new UserVO(); userVO.setId(1001L); userVO.setUsername(dto.getUsername()); return ApiResult.success(userVO); } }

2. 客户端(如Postman)构造加密请求

假设我们的密钥是:

  • SM4密钥:1234567890abcdef
  • SM3签名密钥:my-secret-sign-key-123
// 客户端模拟代码 (Java) public class ClientSimulator { public static void main(String[] args) throws JsonProcessingException { ObjectMapper mapper = new ObjectMapper(); UserRegisterDTO dto = new UserRegisterDTO(); dto.setUsername("张三"); dto.setMobile("13800138000"); dto.setPassword("aStrongPassword123"); // 1. 生成明文JSON String plainJson = mapper.writeValueAsString(dto); System.out.println("明文JSON: " + plainJson); // 2. SM4加密 String encryptedData = Sm4Util.encryptToBase64(plainJson); System.out.println("SM4加密后(Base64): " + encryptedData); // 3. 生成签名 (明文+时间戳) long timestamp = System.currentTimeMillis(); String dataToSign = plainJson + "|" + timestamp; String signature = Sm3Util.hmacSm3ToHex(dataToSign, "my-secret-sign-key-123"); System.out.println("时间戳: " + timestamp); System.out.println("签名: " + signature); // 4. 构造HTTP请求 (伪代码) // POST /api/user/register // Headers: // Content-Type: application/json // X-Data-Encrypted: SM4 // X-Signature: [上面计算的signature] // X-Timestamp: [上面生成的timestamp] // Body: [encryptedData] } }

3. 服务端处理流程

  1. DecryptRequestFilter拦截请求,发现X-Data-Encrypted: SM4头。
  2. 读取请求体密文,用SM4密钥解密,得到明文JSON。
  3. 用明文JSON和时间戳,结合SM3签名密钥重新计算签名,与X-Signature头对比,验证通过。
  4. 将解密后的JSON流替换原始请求流,传递给Controller。
  5. Controller方法正常执行,返回UserVO对象。
  6. EncryptResponseAdvice拦截返回,发现@EncryptResponse注解。
  7. UserVO序列化为JSON,用SM4加密。
  8. 对原始明文JSON和时间戳生成新的签名。
  9. 设置响应头X-Data-Encrypted,X-Signature,X-Timestamp
  10. 将加密后的数据包装成EncryptedResponse对象返回。

4. 客户端收到响应后

  1. 检查响应头X-Data-Encrypted是否为SM4
  2. 从响应体EncryptedResponse.encryptedData中获取密文。
  3. 用SM4密钥解密,得到服务端返回的真实数据明文JSON。
  4. (可选)根据响应头中的时间戳和签名,验证响应数据的完整性。

5.2 常见问题与排查技巧实录

在实际集成中,你几乎一定会遇到下面这些问题。我把它们和解决方案整理成了表格,方便你快速排查。

问题现象可能原因排查步骤与解决方案
启动报错:java.security.InvalidKeyException: SM4 requires a 128 bit keySM4密钥长度不是16字节(128位)。1. 检查你的密钥字符串,用key.getBytes(StandardCharsets.UTF_8).length打印长度。
2. 确保密钥是恰好16个字符(如果直接用字符串),或者字节数组长度是16。
3. 如果密钥来自配置,检查是否有空格或不可见字符。
加解密结果不一致,或解密失败1. 加解密使用的密钥不同。
2. 模式或填充方式不一致。
3. 字符编码不一致。
4. IV(初始化向量)不一致或错误。
1.首要检查:确保服务端和客户端使用的密钥、IV、模式(CBC)、填充(PKCS7)完全一致
2. 检查加解密前后的字符串编码,统一使用UTF-8。
3. 使用Base64编解码时,确保没有引入换行符等问题。可以打印出加密后的Base64字符串和解密前的Base64字符串进行比对。
4. 对于CBC模式,每次加密最好使用随机生成的IV,并将IV和密文一起传输。但我们的示例使用了固定IV,所以必须保证一致。
验签总是失败1. 签名密钥不一致。
2. 待签名字符串的构造规则前后端不一致。
3. 数据在传输过程中被修改(哪怕一个空格)。
4. 时间戳格式或单位不一致(秒 vs 毫秒)。
1. 在服务端和客户端分别打印出用于计算签名的原始字符串(dataToSign),进行逐字符比对。
2. 检查拼接规则:字段顺序、分隔符(如|)、是否去除了JSON字符串末尾的换行符。
3. 确保时间戳是字符串拼接的一部分,且单位一致(通常用毫秒)。
4. 使用Postman等工具手动构造请求时,特别注意JSON的格式(紧凑型 vs 美化型),建议使用紧凑型。
拦截器/Filter获取不到请求体(为空)HttpServletRequestInputStream只能读取一次,在Filter或Interceptor中读取后,后续的Controller就无法读取了。1.必须使用HttpServletRequestWrapper包装请求,并缓存Body数据,如文中的CachedBodyHttpServletRequest
2. 确保你的Filter或Interceptor顺序正确,并且包装后的Request对象被传递下去了(chain.doFilter(wrappedRequest, response))。
@RequestBody绑定参数失败,报400错误解密后,传递给Controller的请求体格式错误,不是合法的JSON,或者包装的Request未生效。1. 在解密后,打印出decryptedBody,确认它是一个格式正确的JSON字符串。
2. 如果使用ThreadLocal方案,确保你的自定义参数解析器能正确地从ThreadLocal中读取数据并绑定。
3.强烈推荐使用Filter包装方案,它更符合Servlet规范,能让Spring MVC的@RequestBody正常解析。
性能问题,感觉接口变慢SM4/SM3加解密是CPU密集型操作,对每个请求/响应都进行加解密会带来开销。1. 对于性能要求极高的内部接口,可以考虑不走加解密流程(通过注解或路径排除)。
2. 确保加解密工具类中的SM4/HMAC-SM3实例是单例复用的,避免每次请求都重新初始化,如我们工具类中的static final实例。
3. 对于大量数据的加密,考虑是否真的需要全量加密,或许可以只加密关键字段。
如何管理多环境的不同密钥?开发、测试、生产环境必须使用不同的密钥。1. 将密钥存储在环境变量中(如SM4_KEY,SM3_SIGN_KEY)。
2. 或者使用配置中心(Apollo, Nacos),为不同环境配置不同的值。
3.绝对不要将生产环境的密钥提交到代码仓库。

我个人在实际操作中的几点深刻体会:

  1. 约定大于配置:和前端(或客户端)同学一定要事先把加密、签名、防重放的规则白纸黑字定下来。包括用哪个头标识加密、签名怎么拼串、时间戳的容忍范围是多少。最好能写一个双方都认可的接口安全规范文档,并各自实现一个SDK,这样可以避免很多联调时的扯皮。
  2. 日志要打,但别乱打:加解密和验签的关键步骤一定要打DEBUG或INFO日志,比如“收到加密请求”、“解密成功”、“验签通过”。但是,绝对不要在日志里打印密钥、完整的明文或密文。可以打印长度、前几位后几位做跟踪,或者对数据进行哈希后再打印。
  3. 做好降级和兼容:在初期接入或者灰度发布时,可以考虑支持“明文/密文”双模式。通过请求头中的一个字段(如X-Data-Encrypted)来动态切换。这样万一加密层出问题,可以快速切回明文模式保障业务,给排查问题留出时间。
  4. 不要自己造轮子(在密码学上):除非你是密码学专家,否则请务必使用像Bouncy Castle、Hutool这样经过广泛验证的库。自己实现加密算法或者哈希函数,极易引入难以察觉的安全漏洞。
  5. 关注依赖安全:定期检查bcprovhutool等依赖库是否有安全漏洞更新。国密算法本身也在演进,保持依赖库的更新是安全的基本要求。

最后,这个方案提供了一个坚实的起点。你可以根据实际需求,把它封装成一个独立的Spring Boot Starter,方便在多个项目中复用。还可以考虑集成配置中心来实现密钥的动态轮转,或者加入非对称加密(SM2)来协商对称密钥,进一步提升安全性。安全之路,永无止境,但每一步扎实的实践,都能让你的系统更稳固一分。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询