Spring Boot接口国密SM4/SM3加密签名实战:5分钟集成指南 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 groupIdorg.bouncycastle/groupId artifactIdbcprov-jdk15to18/artifactId version1.78/version !-- 使用较新稳定版本 -- /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.26/version /dependency !-- Spring Boot Web基础 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency注意Bouncy Castle的版本需要与你项目的JDK版本匹配。bcprov-jdk15to18适用于JDK 1.5到1.8。如果你使用的是JDK 9及以上通常也兼容但为了最佳稳定性建议查阅官方文档选择对应版本。2.2 整体架构设计整个方案的架构围绕Spring MVC的拦截器HandlerInterceptor和响应处理器展开分为上行请求和下行响应两条线上行解密与验签请求到达Controller之前被一个自定义的拦截器DecryptAndVerifyInterceptor拦截。拦截器读取请求头中的加密/签名标识例如X-Data-Encrypted: SM4X-Signature: xxx。如果标识存在则从请求体Body中获取被SM4加密的密文用预共享的密钥进行解密得到原始的明文参数。同时利用SM3算法根据解密后的明文和预共享的签名密钥重新计算签名并与请求头中的签名值比对完成验签。验签通过后将解密得到的明文参数通常是JSON字符串通过HttpServletRequestWrapper替换掉原始的请求流供后续的RequestBody等注解正常绑定。下行加密与签名Controller方法执行完毕返回对象给前端之前被一个自定义的ResponseBodyAdvice处理。这个处理器检查响应体是否需要加密可通过注解标记如EncryptResponse。如果需要则将返回的Java对象序列化为JSON字符串用SM4进行加密。同时对加密前的原始明文或密文本身根据约定用SM3计算签名。最后将密文和签名分别放入响应体和响应头中返回给客户端。这样对于业务代码来说Controller里拿到和返回的始终是明文的POJO对象加解密和签名验签对开发者透明。// 理想中的业务代码完全无感知 PostMapping(/user) EncryptResponse // 一个声明式注解标记此接口响应需要加密 public ApiResultUserVO 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); } }关键点与避坑指南密钥与IV管理示例中的DEFAULT_KEY和DEFAULT_IV是致命错误示范在生产环境中必须将这些敏感信息存储在环境变量、配置中心或专业的密钥管理服务KMS中。绝对不要写在代码里或提交到版本库。模式与填充务必使用CBC模式ECB模式不安全。填充选择PKCS7PaddingPKCS5Padding的扩展在块大小为16字节时等价。异常处理加解密过程可能因数据被篡改、密钥错误等原因失败。必须做好异常捕获和日志记录并根据业务场景决定是抛出业务异常还是返回特定错误码。解密失败通常意味着请求不合法。编码格式确保加解密过程中字符串与字节数组的转换使用统一的字符集如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); } }关键点与避坑指南普通摘要 vs HMACdigestToHex生成的是普通SM3摘要任何拥有相同数据的人都能计算出相同的值。而hmacSm3ToHex是带密钥的哈希消息认证码只有拥有密钥的一方才能生成和验证正确的签名安全性更高强烈推荐在接口签名中使用HMAC-SM3。签名密钥HMAC-SM3的密钥同样需要严格管理且应该与SM4的加密密钥不同。签名内容签什么很重要通常是对原始明文参数按特定规则排序后的字符串进行签名。也可以约定对“密文时间戳随机数”的组合进行签名防止重放攻击。这部分需要前后端协商一致。验证时机验签必须在解密之后进行。因为你需要用解密后的明文或约定的其他数据重新计算签名与客户端传来的签名做对比。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 ThreadLocalString 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(); } }关键点与避坑指南Filter vs Interceptor对于修改请求体这种操作使用OncePerRequestFilter比HandlerInterceptor更合适因为Filter在更早的链路中可以包装HttpServletRequest。上面的ThreadLocal方案是一种妥协在简单同步场景下可用但在异步编程如Async中会失效。最佳实践是使用Filter。验签数据构造签名的内容dataToSign必须前后端严格约定一致。通常包括解密后的明文、时间戳、随机数Nonce等并按特定顺序和分隔符拼接。时间戳和Nonce是防御重放攻击的关键。密钥获取ENCRYPT_KEY和SIGN_KEY必须从外部配置如Apollo、Nacos或环境变量读取绝对不要硬编码。错误处理解密或验签失败时应立即中断请求并返回明确的错误信息但不要泄露密钥等敏感细节HTTP状态码建议使用400Bad 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 ResponseBodyAdviceObject { // 密钥、常量定义同上... 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和ControllerData 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 ApiResultUserVO 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密钥:1234567890abcdefSM3签名密钥: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. 服务端处理流程DecryptRequestFilter拦截请求发现X-Data-Encrypted: SM4头。读取请求体密文用SM4密钥解密得到明文JSON。用明文JSON和时间戳结合SM3签名密钥重新计算签名与X-Signature头对比验证通过。将解密后的JSON流替换原始请求流传递给Controller。Controller方法正常执行返回UserVO对象。EncryptResponseAdvice拦截返回发现EncryptResponse注解。将UserVO序列化为JSON用SM4加密。对原始明文JSON和时间戳生成新的签名。设置响应头X-Data-Encrypted,X-Signature,X-Timestamp。将加密后的数据包装成EncryptedResponse对象返回。4. 客户端收到响应后检查响应头X-Data-Encrypted是否为SM4。从响应体EncryptedResponse.encryptedData中获取密文。用SM4密钥解密得到服务端返回的真实数据明文JSON。可选根据响应头中的时间戳和签名验证响应数据的完整性。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获取不到请求体为空HttpServletRequest的InputStream只能读取一次在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.绝对不要将生产环境的密钥提交到代码仓库。我个人在实际操作中的几点深刻体会约定大于配置和前端或客户端同学一定要事先把加密、签名、防重放的规则白纸黑字定下来。包括用哪个头标识加密、签名怎么拼串、时间戳的容忍范围是多少。最好能写一个双方都认可的接口安全规范文档并各自实现一个SDK这样可以避免很多联调时的扯皮。日志要打但别乱打加解密和验签的关键步骤一定要打DEBUG或INFO日志比如“收到加密请求”、“解密成功”、“验签通过”。但是绝对不要在日志里打印密钥、完整的明文或密文。可以打印长度、前几位后几位做跟踪或者对数据进行哈希后再打印。做好降级和兼容在初期接入或者灰度发布时可以考虑支持“明文/密文”双模式。通过请求头中的一个字段如X-Data-Encrypted来动态切换。这样万一加密层出问题可以快速切回明文模式保障业务给排查问题留出时间。不要自己造轮子在密码学上除非你是密码学专家否则请务必使用像Bouncy Castle、Hutool这样经过广泛验证的库。自己实现加密算法或者哈希函数极易引入难以察觉的安全漏洞。关注依赖安全定期检查bcprov和hutool等依赖库是否有安全漏洞更新。国密算法本身也在演进保持依赖库的更新是安全的基本要求。最后这个方案提供了一个坚实的起点。你可以根据实际需求把它封装成一个独立的Spring Boot Starter方便在多个项目中复用。还可以考虑集成配置中心来实现密钥的动态轮转或者加入非对称加密SM2来协商对称密钥进一步提升安全性。安全之路永无止境但每一步扎实的实践都能让你的系统更稳固一分。