【Java】使用国密2,3,4.仿照https 统一请求响应加解密
后端请求响应加解密
- 前言
- 实现步骤
- 1. Filter将ServletRequest改造成可重复读
- 2. 使用拦截器对请求体进行解密
- 3. 使用ResponseBodyAdvice 进行响应加密
- 测试效果
- 附录
- 扩展知识
- 完整执行流程
- 踩坑
前言
由于安全要求,需要对请求响应做加解密。这边设计思路如下:
在前端存储一个默认的国密2密钥对,在用户请求未登录的接口时使用。登录之后重新生成一个密钥对,前端存储起来。前端请求的时候,使用公钥加密,后端使用私钥解密。后端响应的时候,生成国密4秘钥,使用国密4秘钥将响应内容加密,使用国密2公钥,将国密4秘钥加密,响应内容+时间戳,使用国密3做签名。形成如下数据结构
{"data": "国密4加密的响应数据","t": 1763103461390,"encryptedKey": "国密2公钥加密的sm4Key","sign": "国密3加密响应数据原文+时间搓形成的签名"
}
基本逻辑如上,但是有时候加解密会导致测试时十分不方便,或者一些特殊接口不要加解密。因为我定义了两个注解@NoReqDecrypt表示绕过请求数据解密,@NoRespEncrypt表示绕过响应数据加密。
这边主要关注后端加解密的逻辑,因此暂时抛开用户登录,生成秘钥,获取当前用户密钥对等的逻辑。
OK,那么如上所属,后端的基本逻辑已经确定,开始写吧。
实现步骤
1. Filter将ServletRequest改造成可重复读
默认情况,servletRequest请求中的getInputStream()被读取之后就无法再次读取了,因此需要使用装饰器模式进行包装以下,将流中的请求体缓存下来。
//
public class RepeatableRequestWrapper extends HttpServletRequestWrapper {private byte[] cachedBody;public RepeatableRequestWrapper(HttpServletRequest request) throws IOException {super(request);this.cachedBody = StreamUtils.copyToByteArray(request.getInputStream());}@Overridepublic ServletInputStream getInputStream() {return new CachedServletInputStream(this.cachedBody);}@Overridepublic BufferedReader getReader() {return new BufferedReader(new InputStreamReader(getInputStream()));}public byte[] getCachedBody() {return cachedBody;}public void updateBody(String body){cachedBody = body.getBytes(StandardCharsets.UTF_8);}public void updateBody(byte[] body){cachedBody = body;}private static class CachedServletInputStream extends ServletInputStream {private final ByteArrayInputStream input;public CachedServletInputStream(byte[] buf) {this.input = new ByteArrayInputStream(buf);}@Overridepublic boolean isFinished() {return input.available() == 0;}@Overridepublic boolean isReady() {return true;}@Overridepublic void setReadListener(ReadListener listener) {}@Overridepublic int read() {return input.read();}}
}
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RepeatableFilter implements Filter {@Overridepublic void init(FilterConfig filterConfig) throws ServletException {Filter.super.init(filterConfig);}@Overridepublic void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;// 对JSON请求进行包装ServletRequest requestWrapper = new RepeatableRequestWrapper(httpRequest);chain.doFilter(requestWrapper, servletResponse);}@Overridepublic void destroy() {Filter.super.destroy();}
}
2. 使用拦截器对请求体进行解密
这里需要说明一下,为什么不直接在Filter中直接解密,而要在拦截器中进行解密。
这是因为前面提的注解的小需求:@NoReqDecrypt,我需要判断处理的方法上是否有这个注解,默认解密,有则不解密。
但是Filter的执行顺序是优于DispatcherServlet,也就是说在Filter中chain.doFilter()之前,还不知道Spring将这个请求分发给哪个方法进行处理,因此无法判断。
// 请求解密拦截器
@Component
public class DecryptInterceptor implements HandlerInterceptor {private final ObjectMapper objectMapper = new ObjectMapper();@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException {RepeatableRequestWrapper requestWrapper = null;if (request instanceof RepeatableRequestWrapper){requestWrapper = (RepeatableRequestWrapper) request;}boolean needDecrypt = true;if (handler instanceof HandlerMethod handlerMethod) {System.out.println("请求--拦截器开始判断===================");// 检查是否需要解密NoReqDecrypt noReqDecrypt = handlerMethod.getMethodAnnotation(NoReqDecrypt.class);if (noReqDecrypt != null) {needDecrypt = false;}}if (needDecrypt && isJsonRequest(request) && requestWrapper != null) {try {String requestBody = new String(requestWrapper.getCachedBody());String decryptedData = decryptedData(requestBody);requestWrapper.updateBody(decryptedData);} catch(Exception e){throw new ServletException("SM2解密失败", e);}}return true;}private String decryptedData(String requestBody) throws JsonProcessingException {if (StringUtils.hasText(requestBody)) {ObjectNode jsonNodes = objectMapper.readValue(requestBody, ObjectNode.class);String encryptedData = jsonNodes.get("data").asText();if (StringUtils.hasText(encryptedData)) {return Sm2Util.decryptSm2(encryptedData, Sm2Constant.DEFAULT_PRIVATE_KEY);}else {return "";}}return "";}private boolean isJsonRequest(HttpServletRequest request) {String contentType = request.getContentType();return contentType != null && contentType.contains(MediaType.APPLICATION_JSON_VALUE);}
}
配置开启拦截器
@Configuration
public class WebConfig implements WebMvcConfigurer {@Resourceprivate Sm2EncryptInterceptor sm2EncryptInterceptor;@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(sm2EncryptInterceptor)// 拦截所有请求.addPathPatterns("/**")// 排除静态资源.excludePathPatterns("/static/**");}
}
3. 使用ResponseBodyAdvice 进行响应加密
对于响应加密其实有几个选择
- 在Filter的
chain.doFilter()之后进行加密 - 在
ResponseBodyAdvice进行加密
当然ResponseBodyAdvice是一个最合适也最优雅的方式。
@Slf4j
@ControllerAdvice
public class EncryptResponseBodyAdvice implements ResponseBodyAdvice<Object> {private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();@Overridepublic boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {return needEncrypt(returnType);}@Overridepublic Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {Map<String, Object> resMap = new HashMap<>(16);try {{System.out.println("ResponseBodyAdvice 加密开始============");String json = OBJECT_MAPPER.writeValueAsString(body);resMap = createEncryptMap(json);}} catch (JsonProcessingException e) {e.printStackTrace();}if (!ObjectUtils.isEmpty(resMap)){return resMap;}return body;}// 判断是否需要加密private boolean needEncrypt(MethodParameter returnType){return !returnType.hasMethodAnnotation(NoRespEncrypt.class);}// 加密响应体// 1.生成sm4Key// 2.使用sm4Key对json进行国密4加密// 3.使用公钥对sm4Key进行国密2加密// 4.生成时间戳// 5.json+时间戳进行国密3签名private Map<String,Object> createEncryptMap(String json){Map<String, Object> resMap = new HashMap<>(16);if (StringUtils.hasText(json)){String sm4Key = Sm2Util.generateSm4Key();String encryptedSm4Key = Sm2Util.encryptSm2(sm4Key, Sm2Constant.DEFAULT_PUBLIC_KEY);String encryptedResponse = Sm2Util.encryptSm4(json, sm4Key);long timestamp = System.currentTimeMillis();String sign = Sm2Util.signSm3(json + timestamp);resMap.put("data", encryptedResponse);resMap.put("encryptedKey",encryptedSm4Key);resMap.put("t",timestamp);resMap.put("sign",sign);}return resMap;}
}
至此整体加解密逻辑完成。后面附上使用到的工具类,注解,依赖等。
测试效果

附录
这边加解密工具,使用的是hutool的。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><groupId>com.zxh.test</groupId><artifactId>03_httpSm234</artifactId><version>1.0-SNAPSHOT</version><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.3.8</version></parent><properties><maven.compiler.source>17</maven.compiler.source><maven.compiler.target>17</maven.compiler.target></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></dependency><!--国密--><dependency><groupId>org.bouncycastle</groupId><artifactId>bcprov-jdk15to18</artifactId><version>1.69</version></dependency><dependency><groupId>cn.hutool</groupId><artifactId>hutool-all</artifactId><version>5.8.39</version></dependency><dependency><groupId>cn.dev33</groupId><artifactId>sa-token-spring-boot3-starter</artifactId><version>1.44.0</version></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId><executions><execution><goals><goal>repackage</goal></goals></execution></executions></plugin></plugins></build>
</project>
注解类
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface NoReqDecrypt {
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface NoRespEncrypt {
}
加解密工具类
public class Sm2Constant {public static final String DEFAULT_PUBLIC_KEY = "044a034dd21fab64a667696e164e15c6fa501f07c071b02822d9c8b7c6077c710d28950be906fc344f50a07d340b7b9b29652ace5a5bdb8afdd28f3a8ed952007e";public static final String DEFAULT_PRIVATE_KEY = "74b9376b9e0c72ce4de159193b4d9161486c1b009c589d23f13432a7a606582e";
}
@Data
public class Sm2Key {private String publicKeyHex;private String privateKeyHex;public Sm2Key(String publicKeyHex, String privateKeyHex) {this.publicKeyHex = publicKeyHex;this.privateKeyHex = privateKeyHex;}
}
public class Sm2Util {/*** 生成一对 C1C2C3 格式的SM2密钥** @return 处理结果*/public static Sm2Key generateSm2Key() {//创建sm2 对象SM2 sm2 = SmUtil.sm2();byte[] privateKeyByte = BCUtil.encodeECPrivateKey(sm2.getPrivateKey());//这里公钥不压缩 公钥的第一个字节用于表示是否压缩 可以不要byte[] publicKeyByte = ((BCECPublicKey) sm2.getPublicKey()).getQ().getEncoded(false);String privateKey = HexUtil.encodeHexStr(privateKeyByte);String publicKey = HexUtil.encodeHexStr(publicKeyByte);return new Sm2Key(publicKey, privateKey);}/*** 获取SM2加密工具对象** @param privateKey 加密私钥* @param publicKey 加密公钥* @return 处理结果*/private static SM2 getSm2(String privateKey, String publicKey) {ECPrivateKeyParameters ecPrivateKeyParameters = null;ECPublicKeyParameters ecPublicKeyParameters = null;if (StrUtil.isNotBlank(privateKey)) {ecPrivateKeyParameters = BCUtil.toSm2Params(privateKey);}if (StrUtil.isNotBlank(publicKey)) {if (publicKey.length() == 130) {//这里需要去掉开始第一个字节 第一个字节表示标记publicKey = publicKey.substring(2);}String xhex = publicKey.substring(0, 64);String yhex = publicKey.substring(64, 128);ecPublicKeyParameters = BCUtil.toSm2Params(xhex, yhex);}//创建sm2 对象SM2 sm2 = new SM2(ecPrivateKeyParameters, ecPublicKeyParameters);sm2.usePlainEncoding();sm2.setMode(SM2Engine.Mode.C1C2C3);return sm2;}/*** SM2加密** @param data : 需要加密的数据* @param publicKey : 公钥* @return 加密结果*/public static String encryptSm2(String data, String publicKey) {//创建sm2 对象SM2 sm2 = getSm2(null, publicKey);return sm2.encryptBcd(data, KeyType.PublicKey);}/*** SM2解密** @param dataHex : 需要加密的数据* @param privateKey : 私钥* @return 解密结果*/public static String decryptSm2(String dataHex, String privateKey) {//创建sm2 对象SM2 sm2 = getSm2(privateKey, null);return StrUtil.utf8Str(sm2.decryptFromBcd(dataHex, KeyType.PrivateKey));}/*** 摘要加密算法SM3* @param aaa aaa* @return sign*/public static String signSm3(String aaa){return SmUtil.sm3(aaa);}/*** 生成国密4秘钥* @return key*/public static String generateSm4Key(){SM4 sm4 = SmUtil.sm4();return HexUtil.encodeHexStr(sm4.getSecretKey().getEncoded());}/*** SM4加密* @param content 明文内容* @param sm4Key 16进制格式的密钥* @return 加密后的16进制字符串*/public static String encryptSm4(String content, String sm4Key) {if (StrUtil.isBlank(content)) {return "";}SM4 sm4 = new SM4(HexUtil.decodeHex(sm4Key));return sm4.encryptHex(content);}/*** SM4解密* @param content 密文内容* @param sm4Key 16进制格式的密钥* @return 解密后的明文字符串*/public static String decryptSm4(String content, String sm4Key) {if (StrUtil.isBlank(content)) {return "";}SM4 sm4 = new SM4(HexUtil.decodeHex(sm4Key));return sm4.decryptStr(content);}
}
扩展知识
完整执行流程
- Filter预处理阶段:HTTP请求首先经过所有配置的Filter,执行doFilter方法中
filterChain.doFilter()之前的逻辑 - DispatcherServlet接收请求:请求到达DispatcherServlet,作为前端控制器统一捕获
- HandlerInterceptor.preHandle():在DispatcherServlet调用HandlerMapping解析到对应处理器后,执行拦截器链中所有拦截器的preHandle方法
- RequestBodyAdvice.beforeBodyRead():在执行Handler方法前,对请求体进行处理。并且只有方法参数上添加这个注释
@RequestBody的才会执行 - Controller Handler执行:实际执行业务逻辑的处理器方法
- ResponseBodyAdvice.beforeBodyWrite():在Handler方法返回后、写入响应体前执行。
- HandlerInterceptor.postHandle():执行拦截器链中所有拦截器的postHandle方法
- Filter后处理阶段:执行doFilter方法中filterChain.doFilter()之后的逻辑
- HandlerInterceptor.afterCompletion():在DispatcherServlet请求处理的最后执行,无论是否抛出异常
踩坑
- HandlerInterceptor.postHandle() 无法进行响应的修改,可能导致ouputStream冲突导致响应为空
- HandlerInterceptor.preHandle() 是无法使用装饰器模式的,所以对请求的包装,要在Filter中完成。
