【Java】使用国密2,3,4.仿照https 统一请求响应加解密

后端请求响应加解密

  • 前言
  • 实现步骤
    • [1. Filter将ServletRequest改造成可重复读](#1. Filter将ServletRequest改造成可重复读)
    • [2. 使用拦截器对请求体进行解密](#2. 使用拦截器对请求体进行解密)
    • [3. 使用ResponseBodyAdvice 进行响应加密](#3. 使用ResponseBodyAdvice 进行响应加密)
  • 测试效果
  • 附录
  • 扩展知识

前言

由于安全要求,需要对请求响应做加解密。这边设计思路如下:

在前端存储一个默认的国密2密钥对,在用户请求未登录的接口时使用。登录之后重新生成一个密钥对,前端存储起来。前端请求的时候,使用公钥加密,后端使用私钥解密。后端响应的时候,生成国密4秘钥,使用国密4秘钥将响应内容加密,使用国密2公钥,将国密4秘钥加密,响应内容+时间戳,使用国密3做签名。形成如下数据结构

json 复制代码
{
    "data": "国密4加密的响应数据",
    "t": 1763103461390,
    "encryptedKey": "国密2公钥加密的sm4Key",
    "sign": "国密3加密响应数据原文+时间搓形成的签名"
}

基本逻辑如上,但是有时候加解密会导致测试时十分不方便,或者一些特殊接口不要加解密。因为我定义了两个注解@NoReqDecrypt表示绕过请求数据解密,@NoRespEncrypt表示绕过响应数据加密。

这边主要关注后端加解密的逻辑,因此暂时抛开用户登录,生成秘钥,获取当前用户密钥对等的逻辑。

OK,那么如上所属,后端的基本逻辑已经确定,开始写吧。

实现步骤

1. Filter将ServletRequest改造成可重复读

默认情况,servletRequest请求中的getInputStream()被读取之后就无法再次读取了,因此需要使用装饰器模式进行包装以下,将流中的请求体缓存下来。

java 复制代码
// 
public class RepeatableRequestWrapper extends HttpServletRequestWrapper {

    private byte[] cachedBody;


    public RepeatableRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        this.cachedBody = StreamUtils.copyToByteArray(request.getInputStream());
    }

    @Override
    public ServletInputStream getInputStream() {
        return new CachedServletInputStream(this.cachedBody);
    }

    @Override
    public 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);
        }

        @Override
        public boolean isFinished() {
            return input.available() == 0;
        }

        @Override
        public boolean isReady() {
            return true;
        }

        @Override
        public void setReadListener(ReadListener listener) {
        }

        @Override
        public int read() {
            return input.read();
        }
    }
}
java 复制代码
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RepeatableFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
    }

    @Override
    public 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);
    }

    @Override
    public void destroy() {
        Filter.super.destroy();
    }
}

2. 使用拦截器对请求体进行解密

这里需要说明一下,为什么不直接在Filter中直接解密,而要在拦截器中进行解密。

这是因为前面提的注解的小需求:@NoReqDecrypt,我需要判断处理的方法上是否有这个注解,默认解密,有则不解密。

但是Filter的执行顺序是优于DispatcherServlet,也就是说在Filter中chain.doFilter()之前,还不知道Spring将这个请求分发给哪个方法进行处理,因此无法判断。

java 复制代码
// 请求解密拦截器
@Component
public class DecryptInterceptor implements HandlerInterceptor {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public 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);
    }
}

配置开启拦截器

java 复制代码
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Resource
    private Sm2EncryptInterceptor sm2EncryptInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(sm2EncryptInterceptor)
                // 拦截所有请求
                .addPathPatterns("/**")
                // 排除静态资源
                .excludePathPatterns("/static/**");
    }
}

3. 使用ResponseBodyAdvice 进行响应加密

对于响应加密其实有几个选择

  1. 在Filter的 chain.doFilter()之后进行加密
  2. ResponseBodyAdvice进行加密
    当然ResponseBodyAdvice是一个最合适也最优雅的方式。
java 复制代码
@Slf4j
@ControllerAdvice
public class EncryptResponseBodyAdvice implements ResponseBodyAdvice<Object> {

    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        return needEncrypt(returnType);
    }

    @Override
    public 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 复制代码
<?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>

注解类

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface NoReqDecrypt {
}
java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface NoRespEncrypt {
}

加解密工具类

java 复制代码
public class Sm2Constant {
    public static final String DEFAULT_PUBLIC_KEY = "044a034dd21fab64a667696e164e15c6fa501f07c071b02822d9c8b7c6077c710d28950be906fc344f50a07d340b7b9b29652ace5a5bdb8afdd28f3a8ed952007e";
    public static final String DEFAULT_PRIVATE_KEY = "74b9376b9e0c72ce4de159193b4d9161486c1b009c589d23f13432a7a606582e";
}
java 复制代码
@Data
public class Sm2Key {

    private String publicKeyHex;

    private String privateKeyHex;

    public Sm2Key(String publicKeyHex, String privateKeyHex) {
        this.publicKeyHex = publicKeyHex;
        this.privateKeyHex = privateKeyHex;
    }
}
java 复制代码
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);
    }
}

扩展知识

完整执行流程

  1. Filter预处理阶段 ‌:HTTP请求首先经过所有配置的Filter,执行doFilter方法中filterChain.doFilter()之前的逻辑
  2. DispatcherServlet接收请求‌:请求到达DispatcherServlet,作为前端控制器统一捕获
  3. HandlerInterceptor.preHandle():在DispatcherServlet调用HandlerMapping解析到对应处理器后,执行拦截器链中所有拦截器的preHandle方法
  4. RequestBodyAdvice.beforeBodyRead() :在执行Handler方法前,对请求体进行处理。并且只有方法参数上添加这个注释@RequestBody的才会执行
  5. Controller Handler执行‌:实际执行业务逻辑的处理器方法
  6. ResponseBodyAdvice.beforeBodyWrite():在Handler方法返回后、写入响应体前执行。
  7. HandlerInterceptor.postHandle():执行拦截器链中所有拦截器的postHandle方法
  8. Filter后处理阶段‌:执行doFilter方法中filterChain.doFilter()之后的逻辑
  9. HandlerInterceptor.afterCompletion():在DispatcherServlet请求处理的最后执行,无论是否抛出异常

踩坑

  1. HandlerInterceptor.postHandle() 无法进行响应的修改,可能导致ouputStream冲突导致响应为空
  2. HandlerInterceptor.preHandle() 是无法使用装饰器模式的,所以对请求的包装,要在Filter中完成。
相关推荐
N 年 后2 小时前
单独Docker部署和Docker Compose部署
java·docker·容器
lkbhua莱克瓦242 小时前
Java练习——数组练习
java·开发语言·笔记·github·学习方法
趙卋傑2 小时前
常见排序算法
java·算法·排序算法
Slow菜鸟2 小时前
Java后端常用技术选型 |(四)微服务篇
java·分布式
武子康2 小时前
Java-168 Neo4j CQL 实战:WHERE、DELETE/DETACH、SET、排序与分页
java·开发语言·数据库·python·sql·nosql·neo4j
Filotimo_2 小时前
SpringBoot3入门
java·spring boot·后端
通往曙光的路上3 小时前
SpringIOC-注解
java·开发语言
一 乐3 小时前
校园墙|校园社区|基于Java+vue的校园墙小程序系统(源码+数据库+文档)
java·前端·数据库·vue.js·spring boot·后端·小程序
TT哇3 小时前
【面经 每日一题】面试题16.25.LRU缓存(medium)
java·算法·缓存·面试