稷然如此

  • 首页
  • 文章分类
    • AI
    • Android
    • Java
    • Shell
    • Vue
    • C#
    • Python
    • 数据库
    • 组件
    • 其他
    • Game
  • 常用命令
    • Docker
    • Git
    • Linux
  • 操作系统
    • CentOS
    • Ubuntu
    • Windows
    • Kylin
  • 工具
    • IntelliJ IDEA
    • Visual Studio Code
稷然如此
不积跬步,无以至千里
  1. 首页
  2. 文章分类
  3. Java
  4. 正文

Spring Boot SM2 加解密

2024年11月7日 731点热度 1人点赞

姊妹篇《Spring Cloud Gateway 网关 SM2 加解密》

相较于 Spring Cloud Gateway 网关加密,Spring Boot 就比较简单了。为什么 GetMapping、DeleteMapping 要用 OncePerRequestFilter,而PostMapping、PutMapping 用的 RequestBodyAdvice 可以参考《Spring MVC 常见拦截器的区别》或自行查阅相关资料,当然 PostMapping、PutMapping也可以写到 OncePerRequestFilter。来吧,直接进入正题:

1、引用工具包

引用 Hutool 和加密算法库 Bouncy Castle Crypto
<dependency>
	<groupId>cn.hutool</groupId>
	<artifactId>hutool-all</artifactId>
	<version>5.8.22</version>
</dependency>

<dependency>
	<groupId>org.bouncycastle</groupId>
	<artifactId>bcprov-jdk15to18</artifactId>
	<version>1.79</version>
</dependency>

2.SM2 密钥对

package com.akim.boot.starter.common.util.crypto;

import lombok.Data;

/**
 * @author akim
 * @date 2023-03-29 14:10
 * @desc sm2密钥对
 */
@Data
public class SM2Key {
    /**
     * 服务端加密公钥,对应私钥由客户端持有(clientPrivateKey)
     */
    private String serverPublicKey;

    /**
     * 服务端解密私钥,对应公钥由客户端持有(clientPublicKey)
     */
    private String serverPrivateKey;

    /**
     * 客户端加密公钥,对应私钥由服务端持有(serverPrivateKey)
     */
    private String clientPublicKey;

    /**
     * 客户端解密私钥,对应公钥由服务端持有(serverPublicKey)
     */
    private String clientPrivateKey;
}

3.SM2 工具类

package com.akim.boot.starter.common.util.crypto;

import cn.hutool.core.util.HexUtil;
import cn.hutool.crypto.BCUtil;
import cn.hutool.crypto.SmUtil;
import cn.hutool.crypto.asymmetric.KeyType;
import cn.hutool.crypto.asymmetric.SM2;

import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * @author akim
 * @date 2023-03-29 14:10
 * @desc sm2工具类
 */
public class SM2Util {
    /**
     * 按密钥 Hex 缓存 SM2 实例
     */
    private static final Map<String, SM2> PRIVATE_KEY_CACHE = new ConcurrentHashMap<>(64);
    private static final Map<String, SM2> PUBLIC_KEY_CACHE = new ConcurrentHashMap<>(64);

    /**
     * 生成两对密钥(server/client),与原逻辑等价
     */
    public static SM2Key generate() {
        SM2Key sm2Key = new SM2Key();
        SM2 sm2 = SmUtil.sm2();
        sm2Key.setServerPublicKey(HexUtil.encodeHexStr(BCUtil.encodeECPublicKey(sm2.getPublicKey())));
        sm2Key.setClientPrivateKey(HexUtil.encodeHexStr(BCUtil.encodeECPrivateKey(sm2.getPrivateKey())));
        sm2 = SmUtil.sm2();
        sm2Key.setClientPublicKey(HexUtil.encodeHexStr(BCUtil.encodeECPublicKey(sm2.getPublicKey())));
        sm2Key.setServerPrivateKey(HexUtil.encodeHexStr(BCUtil.encodeECPrivateKey(sm2.getPrivateKey())));
        return sm2Key;
    }

    /**
     * 私钥签名(入参与出参均为 Hex 签名字符串)
     */
    public static String sign(String privateKeyHex, String data) {
        byte[] bytes = data.getBytes(StandardCharsets.UTF_8);
        String hex = HexUtil.encodeHexStr(bytes);
        return getSM2ByPrivate(privateKeyHex).signHexFromHex(hex);
    }

    /**
     * 公钥验签(签名为 Hex 字符串)
     */
    public static boolean verify(String publicKeyHex, String signHex, String data) {
        String hex = HexUtil.encodeHexStr(data.getBytes(StandardCharsets.UTF_8));
        return getSM2ByPublic(publicKeyHex).verifyHex(hex, signHex);
    }

    /**
     * 公钥加密 -> 返回 Hex(去掉 04 前缀)
     */
    public static String encrypt(String publicKeyHex, String data) {
        String hex = getSM2ByPublic(publicKeyHex)
                .encryptHex(data.getBytes(StandardCharsets.UTF_8), KeyType.PublicKey);
        return (hex.length() > 2 && hex.startsWith("04")) ? hex.substring(2) : hex;
    }

    /**
     * 私钥解密(自动补 04 前缀)
     */
    public static String decrypt(String privateKeyHex, String dataHex) {
        String hex = dataHex.startsWith("04") ? dataHex : "04" + dataHex;
        return getSM2ByPrivate(privateKeyHex).decryptStr(hex, KeyType.PrivateKey);
    }

    /**
     * 清理缓存(热切换密钥或压测用)
     */
    public static void clearCaches() {
        PRIVATE_KEY_CACHE.clear();
        PUBLIC_KEY_CACHE.clear();
    }

    /**
     * 获取私钥 SM2 对象
     *
     * @param privateKeyHex 私钥 hex
     * @return 私钥 SM2 对象
     */
    private static SM2 getSM2ByPrivate(String privateKeyHex) {
        return PRIVATE_KEY_CACHE.computeIfAbsent(privateKeyHex, k -> SmUtil.sm2(k, null));
    }

    /**
     * 获取公钥 SM2 对象
     *
     * @param publicKeyHex 公钥 hex
     * @return 公钥 SM2 对象
     */
    private static SM2 getSM2ByPublic(String publicKeyHex) {
        return PUBLIC_KEY_CACHE.computeIfAbsent(publicKeyHex, k -> SmUtil.sm2(null, k));
    }

//    public static void main(String[] args) {
//        try {
//            SM2Key sm2Key = SM2Util.generate();
//            String testStr = "我是测试内容";
//            // 服务端加密
//            String enStr = SM2Util.encrypt(sm2Key.getServerPublicKey(), testStr);
//            System.out.println("服务端加密结果:" + enStr);
//            // 加签
//            String sign = SM2Util.sign(sm2Key.getClientPrivateKey(), enStr);
//            // 验签
//            boolean verify = SM2Util.verify(sm2Key.getServerPublicKey(), sign, enStr);
//            System.out.println(verify);
//            // 客户端解密
//            String deStr = SM2Util.decrypt(sm2Key.getClientPrivateKey(), enStr);
//            System.out.println("客户端解密结果:" + deStr);
//
//
//            // 客户端加密
//            String enStr2 = SM2Util.encrypt(sm2Key.getClientPublicKey(), testStr);
//            System.out.println("客户端加密结果:" + enStr2);
//            String enStr3 = SM2Util.encrypt(sm2Key.getClientPublicKey(), testStr);
//            System.out.println("客户端加密结果:" + enStr3);
//            String sing2 = SM2Util.sign(sm2Key.getServerPrivateKey(), enStr2);
//            boolean verify2 = SM2Util.verify(sm2Key.getClientPublicKey(), sing2, enStr2);
//            System.out.println(verify2);
//            // 服务端解密
//            String deStr2 = SM2Util.decrypt(sm2Key.getServerPrivateKey(), enStr2);
//            System.out.println("服务端解密结果:" + deStr2);
//        } catch (Exception e) {
//            System.out.println(e.getMessage());
//        }
//
//        // 生成密钥对
//        SM2 sm2 = SmUtil.sm2();
//
//        // 私钥
//        String privateKey = Base64.getEncoder().encodeToString(BCUtil.encodeECPrivateKey(sm2.getPrivateKey()));
//        System.out.println(StrUtil.format("===============privateKey key:{}==================", privateKey));
//        String privateKey2 = HexUtil.encodeHexStr(BCUtil.encodeECPrivateKey(sm2.getPrivateKey()));
//        System.out.println(StrUtil.format("===============privateKey2 key:{}==================", privateKey2));
//        String privateKey3 = Base64.getEncoder().encodeToString(sm2.getD());
//        System.out.println(StrUtil.format("===============privateKey3 key:{}==================", privateKey3));
//        String privateKey4 = HexUtil.encodeHexStr(sm2.getD());
//        System.out.println(StrUtil.format("===============privateKey4 key:{}==================", privateKey4));
//        String privateKey5 = sm2.getPrivateKeyBase64();
//        System.out.println(StrUtil.format("===============privateKey5 key:{}==================", privateKey5));
//        String privateKey6 = HexUtil.encodeHexStr(sm2.getPrivateKeyBase64());
//        System.out.println(StrUtil.format("===============privateKey6 key:{}==================", privateKey6));
//
//        // 公钥
//        String publicKey = Base64.getEncoder().encodeToString(BCUtil.encodeECPublicKey(sm2.getPublicKey()));
//        System.out.println(StrUtil.format("===============publicKey key:{}==================", publicKey));
//        String publicKey2 = Base64.getEncoder().encodeToString(BCUtil.encodeECPublicKey(sm2.getPublicKey(), false));
//        System.out.println(StrUtil.format("===============publicKey2 key:{}==================", publicKey2));
//        String publicKey3 = HexUtil.encodeHexStr(BCUtil.encodeECPublicKey(sm2.getPublicKey()));
//        System.out.println(StrUtil.format("===============publicKey3 key:{}==================", publicKey3));
//        String publicKey4 = HexUtil.encodeHexStr(BCUtil.encodeECPublicKey(sm2.getPublicKey(), false));
//        System.out.println(StrUtil.format("===============publicKey4 key:{}==================", publicKey4));
//        String publicKey5 = Base64.getEncoder().encodeToString(sm2.getQ(true));
//        System.out.println(StrUtil.format("===============publicKey5 key:{}==================", publicKey5));
//        String publicKey6 = Base64.getEncoder().encodeToString(sm2.getQ(false));
//        System.out.println(StrUtil.format("===============publicKey6 key:{}==================", publicKey6));
//        String publicKey7 = HexUtil.encodeHexStr(sm2.getQ(true));
//        System.out.println(StrUtil.format("===============publicKey7 key:{}==================", publicKey7));
//        String publicKey8 = HexUtil.encodeHexStr(sm2.getQ(false));
//        System.out.println(StrUtil.format("===============publicKey8 key:{}==================", publicKey8));
//        String publicKey9 = sm2.getPublicKeyBase64();
//        System.out.println(StrUtil.format("===============publicKey9 key:{}==================", publicKey9));
//
//        String content = "测试加解密";
//        // 私钥签名
//        String sign = SM2Util.sign(privateKey, content);
//        System.out.println(StrUtil.format("===============sign:{}==================", sign));
//
//        // 公钥验签
//        boolean verify = SM2Util.verify(publicKey, sign, content);
//        System.out.println(StrUtil.format("===============verify:{}==================", verify));
//
//        // 服务端加密
//        String enStr = SM2Util.encrypt(publicKey, content);
//        System.out.println("服务端加密结果:" + enStr);
//
//        // 客户端解密
//        String deStr = SM2Util.decrypt(privateKey, enStr);
//        System.out.println("客户端解密结果:" + deStr);
//    }
}

4.加密配置属性

package com.akim.boot.starter.web.common.crypto.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.http.server.PathContainer;
import org.springframework.util.CollectionUtils;
import org.springframework.web.util.pattern.PathPattern;
import org.springframework.web.util.pattern.PathPatternParser;

import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * @author akim
 * @date 2024-10-28 11:58
 * @desc 加密配置属性
 * 后续可以通过配置文件增加加解密类型进行拓展
 */
@ConfigurationProperties(prefix = "akim.crypto")
@Data
public class CryptoProperties {
    /**
     * 是否启用全报文加密
     */
    private Boolean enable = false;

    /**
     * 白名单列表
     */
    private Set<String> ignoreUrls = Collections.emptySet();

    /**
     * 预编译的忽略路径,提高匹配性能
     */
    private volatile List<PathPattern> compiledIgnorePatterns = List.of();

    /**
     * 设置忽略请求地址
     *
     * @param ignoreUrls 忽略请求地址集合
     */
    public void setIgnoreUrls(Set<String> ignoreUrls) {
        this.ignoreUrls = (ignoreUrls == null) ? Collections.emptySet() : ignoreUrls;
        if (CollectionUtils.isEmpty(this.ignoreUrls)) {
            this.compiledIgnorePatterns = List.of();
        } else {
            PathPatternParser parser = PathPatternParser.defaultInstance;
            this.compiledIgnorePatterns = this.ignoreUrls.stream()
                    .map(parser::parse)
                    .collect(Collectors.toList());
        }
    }

    /**
     * 匹配路径是否白名单
     *
     * @param uri 请求 url
     * @return 是否白名单
     */
    public boolean matchIgnored(String uri) {
        if (compiledIgnorePatterns.isEmpty()) return false;
        PathContainer pc = PathContainer.parsePath(uri);
        for (PathPattern p : compiledIgnorePatterns) {
            if (p.matches(pc)) return true;
        }
        return false;
    }
}

5.加密配置

package com.akim.boot.starter.web.common.crypto.config;

import com.akim.boot.starter.common.enums.WebFilterOrderEnum;
import com.akim.boot.starter.web.common.crypto.filter.DecryptRequestParamsFilter;
import com.akim.boot.starter.web.common.crypto.handler.ResponseBodyAdviceHandler;
import com.akim.boot.starter.web.config.AkimWebAutoConfiguration;
import com.akim.boot.starter.web.config.WebProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * @author akim
 * @date 2024-10-28 11:58
 * @desc 加密配置
 * 后续可以通过配置文件增加加解密类型进行拓展
 */
@AutoConfiguration
@ConditionalOnProperty(prefix = "akim.crypto", value = "enable", havingValue = "true")
@EnableConfigurationProperties(CryptoProperties.class)
@Slf4j
public class CryptoConfiguration implements WebMvcConfigurer {
    /**
     * 注入 GET、DELETE 请求过滤器
     *
     * @param cryptoProperties 加解密配置
     * @param webProperties    web 配置
     * @return GET、DELETE 请求过滤器
     */
    @Bean
    public FilterRegistrationBean<DecryptRequestParamsFilter> requestParamsFilter(CryptoProperties cryptoProperties, WebProperties webProperties) {
        // 只依赖 HTTP 方法快速判定,避免每次扫描 HandlerMapping 的高成本
        DecryptRequestParamsFilter requestParamsFilter = new DecryptRequestParamsFilter(cryptoProperties, webProperties);
        return AkimWebAutoConfiguration.createFilterBean(requestParamsFilter, WebFilterOrderEnum.REQUEST_PARAMS_FILTER);
    }

    /**
     * 注入请求输出 Response 拦截器
     *
     * @param properties 加解密配置
     * @return 请求输出 Response 拦截器
     */
    @Bean
    public ResponseBodyAdviceHandler responseBodyAdviceHandler(CryptoProperties properties) {
        return new ResponseBodyAdviceHandler(properties);
    }
}

6.加密参数对象

package com.akim.boot.starter.web.common.crypto.model;

import lombok.Builder;
import lombok.Data;

/**
 * @author akim
 * @date 2025-08-18 10:19
 * @desc 加密参数对象
 */
@Data
@Builder
public class CryptoParamsVO {
    /**
     * 请求参数
     */
    private String payload;

    /**
     * 签名
     */
    private String sign;
}

7.请求工具

package com.akim.boot.starter.web.common.crypto.util;

import com.akim.boot.starter.web.common.crypto.config.CryptoProperties;
import jakarta.servlet.http.HttpServletRequest;
import lombok.experimental.UtilityClass;

/**
 * @author akim
 * @date 2025-08-18 10:30
 * @desc 请求工具
 */
@UtilityClass
public class CryptoUtils {
    /**
     * 校验是否白名单 url
     *
     * @param properties 加解密配置
     * @param request    请求对象
     * @return 是否白名单 url
     */
    public static boolean isIgnoredUrl(CryptoProperties properties, HttpServletRequest request) {
        return properties.matchIgnored(request.getRequestURI());
    }
}

8.请求拦截器

package com.akim.boot.starter.web.common.crypto.filter;

import cn.hutool.core.util.URLUtil;
import cn.hutool.json.JSONUtil;
import com.akim.boot.starter.common.exception.enums.GlobalErrorCodeConstants;
import com.akim.boot.starter.common.exception.util.ServiceExceptionUtil;
import com.akim.boot.starter.common.util.crypto.SM2Key;
import com.akim.boot.starter.common.util.crypto.SM2Util;
import com.akim.boot.starter.web.common.crypto.config.CryptoProperties;
import com.akim.boot.starter.web.common.crypto.model.CryptoParamsVO;
import com.akim.boot.starter.web.common.crypto.util.CryptoUtils;
import com.akim.boot.starter.web.common.crypto.wrapper.RequestParamsWrapper;
import com.akim.boot.starter.web.common.crypto.wrapper.RequestBodyWrapper;
import com.akim.boot.starter.web.config.WebProperties;
import com.akim.boot.starter.web.core.filter.ApiRequestFilter;
import com.akim.boot.starter.web.core.util.WebUtils;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpMethod;
import org.springframework.util.StringUtils;

import java.io.IOException;
import java.net.URLDecoder;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * @author akim
 * @date 2024-11-07 9:10
 * @desc 请求拦截器
 */
@Slf4j
public class DecryptRequestParamsFilter extends ApiRequestFilter {
    /**
     * 加解密配置
     */
    private final CryptoProperties cryptoProperties;

    public DecryptRequestParamsFilter(CryptoProperties cryptoProperties, WebProperties webProperties) {
        super(webProperties);
        this.cryptoProperties = cryptoProperties;
    }

    @SuppressWarnings("NullableProblems")
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        if (CryptoUtils.isIgnoredUrl(cryptoProperties, request)) {
            chain.doFilter(request, response);
            return;
        }

        String p = null;
        String method = request.getMethod();

        if (HttpMethod.GET.name().equals(method) || HttpMethod.DELETE.name().equals(method)) {
            p = request.getParameter("p");
        } else if (HttpMethod.POST.name().equals(method) || HttpMethod.PUT.name().equals(method)) {
            chain.doFilter(new RequestBodyWrapper(request), response);
            return;
        }

        if (!StringUtils.hasText(p)) {
            chain.doFilter(request, response);
            return;
        }

        try {
            SM2Key key = WebUtils.getLoginUserCryptoKey();
            String json = SM2Util.decrypt(key.getServerPrivateKey(), p);
            CryptoParamsVO paramsVO = JSONUtil.toBean(json, CryptoParamsVO.class);

            if (!SM2Util.verify(key.getServerPublicKey(), paramsVO.getSign(), paramsVO.getPayload())) {
                throw new RuntimeException("验签失败");
            }

            Map<String, String> newParams = parseQuery(paramsVO.getPayload());
            chain.doFilter(new RequestParamsWrapper(request, newParams), response);
        } catch (Exception e) {
            log.error("解密或验签失败 uri={} err={}", request.getRequestURI(), e.getMessage(), e);
            throw ServiceExceptionUtil.exception(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR);
        }
    }

    private Map<String, String> parseQuery(String query) {
        Map<String, String> map = new LinkedHashMap<>();
        if (!StringUtils.hasText(query)) return map;
        int len = query.length(), i = 0;
        while (i < len) {
            int eq = query.indexOf('=', i);
            int amp = query.indexOf('&', i);
            if (eq < 0) eq = amp < 0 ? len : amp;
            String key = query.substring(i, eq);
            String value = (eq < len && amp != eq) ? URLUtil.decode(query.substring(eq + 1, amp < 0 ? len : amp)) : "";
            map.put(key, value);
            i = (amp < 0) ? len : amp + 1;
        }
        return map;
    }
}

9.响应拦截器

package com.akim.boot.starter.web.common.crypto.handler;

import com.akim.boot.starter.common.exception.enums.GlobalErrorCodeConstants;
import com.akim.boot.starter.common.exception.util.ServiceExceptionUtil;
import com.akim.boot.starter.common.pojo.CommonResult;
import com.akim.boot.starter.common.util.crypto.SM2Key;
import com.akim.boot.starter.common.util.crypto.SM2Util;
import com.akim.boot.starter.common.util.json.JsonUtils;
import com.akim.boot.starter.web.common.crypto.config.CryptoProperties;
import com.akim.boot.starter.web.common.crypto.model.CryptoParamsVO;
import com.akim.boot.starter.web.common.crypto.util.CryptoUtils;
import com.akim.boot.starter.web.core.util.WebUtils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.AllArgsConstructor;
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.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;

/**
 * @author akim
 * @date 2024-10-31 14:51
 * @desc 响应拦截器
 */
@SuppressWarnings("NullableProblems")
@RestControllerAdvice
@AllArgsConstructor
public class ResponseBodyAdviceHandler implements ResponseBodyAdvice<Object> {
    /**
     * 加解密配置
     */
    private final CryptoProperties cryptoProperties;

    @Override
    public boolean supports(MethodParameter methodParameter, Class converterType) {
        HttpServletRequest request = WebUtils.getRequest();
        return request != null && !CryptoUtils.isIgnoredUrl(cryptoProperties, request);
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter methodParameter, MediaType selectedContentType,
                                  Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        if (!(body instanceof CommonResult<?> result)) return body;
        try {
            String payload = JsonUtils.toJsonString(result);
            SM2Key key = WebUtils.getLoginUserCryptoKey();
            String sign = SM2Util.sign(key.getServerPrivateKey(), payload);
            CryptoParamsVO vo = CryptoParamsVO.builder().payload(payload).sign(sign).build();
            String json = JsonUtils.toJsonString(vo);
            return SM2Util.encrypt(key.getServerPublicKey(), json);
        } catch (Exception e) {
            throw ServiceExceptionUtil.exception(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR);
        }
    }
}

10.GET/DELETE 请求参数包装器

package com.akim.boot.starter.web.common.crypto.wrapper;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;

import java.util.*;

/**
 * @author akim
 * @date 2025-08-19 14:21
 * @desc GET/DELETE 请求参数包装器
 * 主要解决解密后请求参数还会携带 p 参数
 * 构造函数增加 this.params.putAll(request.getParameterMap()); // 这个可以保留原始参数
 */
public class RequestParamsWrapper extends HttpServletRequestWrapper {
    /**
     * GET/DELETE 请求参数
     */
    private final Map<String, String> params = new LinkedHashMap<>();

    public RequestParamsWrapper(HttpServletRequest request, Map<String, String> newParams) {
        super(request);
        this.params.putAll(newParams);
    }

    @Override
    public String getParameter(String name) {
        return params.getOrDefault(name, super.getParameter(name));
    }

    @Override
    public Enumeration<String> getParameterNames() {
        Set<String> names = new LinkedHashSet<>(params.keySet());
        return Collections.enumeration(names);
    }

    @Override
    public String[] getParameterValues(String name) {
        if (params.containsKey(name)) return new String[]{params.get(name)};
        return super.getParameterValues(name);
    }

    @Override
    public Map<String, String[]> getParameterMap() {
        Map<String, String[]> map = new LinkedHashMap<>();
        params.forEach((k, v) -> map.put(k, new String[]{v}));
        return map;
    }
}

11.POST/PUT 请求体包装器

package com.akim.boot.starter.web.common.crypto.wrapper;

import cn.hutool.core.io.IoUtil;
import cn.hutool.json.JSONUtil;
import com.akim.boot.starter.common.exception.enums.GlobalErrorCodeConstants;
import com.akim.boot.starter.common.exception.util.ServiceExceptionUtil;
import com.akim.boot.starter.common.util.crypto.SM2Key;
import com.akim.boot.starter.common.util.crypto.SM2Util;
import com.akim.boot.starter.web.common.crypto.model.CryptoParamsVO;
import com.akim.boot.starter.web.core.util.WebUtils;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import lombok.extern.slf4j.Slf4j;

import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;

/**
 * @author akim
 * @date 2025-08-19 11:33
 * @desc POST/PUT 请求体包装器
 */
@Slf4j
public class RequestBodyWrapper extends HttpServletRequestWrapper {
    private final byte[] body;

    /**
     * json 工厂
     */
    private final JsonFactory JSON_FACTORY = new JsonFactory();

    public RequestBodyWrapper(HttpServletRequest request) throws IOException {
        super(request);
        byte[] raw = request.getInputStream().readAllBytes();
        String p = extractP(raw);

        SM2Key key = WebUtils.getLoginUserCryptoKey();
        String decrypted = SM2Util.decrypt(key.getServerPrivateKey(), p);
        CryptoParamsVO paramsVO = JSONUtil.toBean(decrypted, CryptoParamsVO.class);

        if (!SM2Util.verify(key.getServerPublicKey(), paramsVO.getSign(), paramsVO.getPayload())) {
            throw ServiceExceptionUtil.exception(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR);
        }

        this.body = paramsVO.getPayload().getBytes(StandardCharsets.UTF_8);
    }

    private String extractP(byte[] raw) throws IOException {
        try (JsonParser parser = JSON_FACTORY.createParser(raw)) {
            if (parser.nextToken() != JsonToken.START_OBJECT) {
                throw ServiceExceptionUtil.exception(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR);
            }
            while (parser.nextToken() != JsonToken.END_OBJECT) {
                String field = parser.currentName();
                parser.nextToken();
                if ("p".equals(field)) {
                    String val = parser.getValueAsString();
                    if (val == null) throw ServiceExceptionUtil.exception(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR);
                    return val;
                } else {
                    parser.skipChildren();
                }
            }
        }
        throw ServiceExceptionUtil.exception(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR);
    }

    @Override
    public ServletInputStream getInputStream() {
        ByteArrayInputStream stream = new ByteArrayInputStream(body);
        return new ServletInputStream() {
            @Override public int read() { return stream.read(); }
            @Override public boolean isFinished() { return stream.available() == 0; }
            @Override public boolean isReady() { return true; }
            @Override public void setReadListener(ReadListener listener) { }
        };
    }

    @Override
    public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream())); }

    @Override
    public int getContentLength() { return body.length; }

    @Override
    public long getContentLengthLong() { return body.length; }
}

12.application.yml 配置

  # SM2全报文加密
  crypto:
    # 是否启用SM2国密全报文加密
    enable: true
    # 免加密接口配置
    ignore-urls:
      - /admin-api/system/captcha/get
      - /admin-api/system/auth/login
      - /admin-api/system/auth/refresh-token
      - /admin-api/system/auth/logout

13.服务端其他

1、登录生成密钥对
2、过滤器 token 认证成功后设置当前登录用户请求 Headers 属性 SM2Key 密钥对
2、登录成功后将客户端密钥对返回

14.前端Vue2处理参考

1、请求工具类:service.ts
import axios, { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios'

import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
import qs from 'qs'
import { config } from '@/config/axios/config'
import {
  getAccessToken,
  getRefreshToken,
  getTenantId,
  getVisitTenantId,
  removeToken,
  setToken,
  setCryptKey,
  removeCryptKey
} from '@/utils/auth'
import errorCode from './error-code'

import { resetRouter } from '@/router'
import { deleteUserCache } from '@/hooks/web/use-cache'
import {
  buildQueryPayload,
  encryptRequest,
  decryptResponse,
  resetKeyCache
} from '../crypto/sm2-client'

const tenantEnable = import.meta.env.VITE_APP_TENANT_ENABLE
const { result_code, base_url, request_timeout } = config

// 需要忽略的提示。忽略后,自动 Promise.reject('error')
const ignoreMsgs = [
  '无效的刷新令牌', // 刷新令牌被删除时,不用提示
  '刷新令牌已过期' // 使用刷新令牌,刷新获取新的访问令牌时,结果因为过期失败,此时需要忽略。否则,会导致继续 401,无法跳转到登出界面
]
// 是否显示重新登录
export const isRelogin = { show: false }
// Axios 无感知刷新令牌,参考 https://www.dashingdog.cn/article/11 与 https://segmentfault.com/a/1190000020210980 实现
// 请求队列
let requestList: any[] = []
// 是否正在刷新中
let isRefreshToken = false
// 请求白名单,无须 token 的接口
const whiteList: string[] = ['/login', '/refresh-token']
// 是否启用全报文加密
const cryptEnable = import.meta.env.VITE_APP_CRYPTO_ENABLE
// 刷新 token 后重发请求需要使用原始请求数据加密后再重发
const retransConfig: {
  url?: any
  params?: any
  data?: any
} = {}
// hex string 正则表达式
const hexReg = /^[a-fA-F0-9]+$/

// 创建axios实例
const service: AxiosInstance = axios.create({
  baseURL: base_url, // api 的 base_url
  timeout: request_timeout, // 请求超时时间
  withCredentials: false, // 禁用 Cookie 等信息
  // 自定义参数序列化函数
  paramsSerializer: (params) => {
    return qs.stringify(params, { allowDots: true })
  }
})

// request拦截器
service.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    // 是否需要设置 token
    let isToken = (config!.headers || {}).isToken === false
    whiteList.some((v) => {
      if (config.url && config.url.indexOf(v) > -1) {
        return (isToken = false)
      }
    })
    if (getAccessToken() && !isToken) {
      config.headers.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token
    }
    // 设置租户
    if (tenantEnable && tenantEnable === 'true') {
      const tenantId = getTenantId()
      if (tenantId) config.headers['tenant-id'] = tenantId
      // 只有登录时,才设置 visit-tenant-id 访问租户
      const visitTenantId = getVisitTenantId()
      if (config.headers.Authorization && visitTenantId) {
        config.headers['visit-tenant-id'] = visitTenantId
      }
    }
    const method = config.method?.toUpperCase()
    // 防止 GET 请求缓存
    if (method === 'GET') {
      config.headers['Cache-Control'] = 'no-cache'
      config.headers['Pragma'] = 'no-cache'
    }
    // 自定义参数序列化函数
    else if (method === 'POST') {
      const contentType = config.headers['Content-Type'] || config.headers['content-type']
      if (contentType === 'application/x-www-form-urlencoded') {
        if (config.data && typeof config.data !== 'string') {
          config.data = qs.stringify(config.data)
        }
      }
    }
    if (cryptEnable && cryptEnable === 'true') {
      // 非刷新 token 请求的,记录请求参数信息。刷新token后重发请求,需要新的sm2密钥对加密参数
      if (isRefreshToken) {
        retransConfig.url = config.url
        retransConfig.params = config.params
        retransConfig.data = config.data
      }
      if (getAccessToken()) {
        encryptRequestParams(config)
      }
    }
    return config
  },
  (error: AxiosError) => {
    // Do something with request error
    console.log(error) // for debug
    return Promise.reject(error)
  }
)

// response 拦截器
service.interceptors.response.use(
  async (response: AxiosResponse<any>) => {
    let { data } = response
    const config = response.config
    if (!data) {
      // 返回“[HTTP]请求没有返回值”;
      throw new Error()
    }
    const { t } = useI18n()
    // 未设置状态码则默认成功状态
    // 二进制数据则直接返回,例如说 Excel 导出
    if (
      response.request.responseType === 'blob' ||
      response.request.responseType === 'arraybuffer'
    ) {
      // 注意:如果导出的响应为 json,说明可能失败了,不直接返回进行下载
      if (response.data.type !== 'application/json') {
        return response.data
      }
      data = await new Response(response.data).json()
    }
    // 校验是否hex字符串(sm2全报文加密)
    if (cryptEnable && cryptEnable === 'true' && hexReg.test(data)) {
      // 解密出参json报文
      const decryptJson = decryptResponse(data)
      // 转换为对象
      data = JSON.parse(decryptJson)
    }
    const code = data.code || result_code
    // 获取错误信息
    const msg = data.msg || errorCode[code] || errorCode['default']
    if (ignoreMsgs.indexOf(msg) !== -1) {
      // 如果是忽略的错误码,直接返回 msg 异常
      return Promise.reject(msg)
    } else if (code === 401) {
      // 如果未认证,并且未进行刷新令牌,说明可能是访问令牌过期了
      if (!isRefreshToken) {
        isRefreshToken = true
        // 1. 如果获取不到刷新令牌,则只能执行登出操作
        if (!getRefreshToken()) {
          return handleAuthorized()
        }
        // 2. 进行刷新访问令牌
        try {
          // 刷新前先移除sm2密钥对
          removeCryptKey()
          resetKeyCache()
          const refreshTokenRes = await refreshToken()
          const data = (await refreshTokenRes).data.data
          // 2.1 刷新成功,则回放队列的请求 + 当前请求
          setToken(data)
          // 刷新sm2密钥对
          setCryptKey(data)
          config.headers!.Authorization = 'Bearer ' + getAccessToken()
          requestList.forEach((cb: any) => {
            cb()
          })
          if (cryptEnable && cryptEnable === 'true') {
            // 重发交易,使用新的sm2密钥对重新处理请求参数
            config.url = retransConfig.url
            config.params = retransConfig.params
            config.data = retransConfig.data
            encryptRequestParams(config)
          }
          requestList = []
          return service(config)
        } catch (e) {
          // 为什么需要 catch 异常呢?刷新失败时,请求因为 Promise.reject 触发异常。
          // 2.2 刷新失败,只回放队列的请求
          requestList.forEach((cb: any) => {
            cb()
          })
          // 提示是否要登出。即不回放当前请求!不然会形成递归
          return handleAuthorized()
        } finally {
          requestList = []
          isRefreshToken = false
        }
      } else {
        // 添加到队列,等待刷新获取到新的令牌
        return new Promise((resolve) => {
          requestList.push(() => {
            config.headers!.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token 请根据实际情况自行修改
            if (cryptEnable && cryptEnable === 'true') {
              // 重发交易,使用新的sm2密钥对重新处理请求参数
              config.url = retransConfig.url
              config.params = retransConfig.params
              config.data = retransConfig.data
              encryptRequestParams(config)
            }
            resolve(service(config))
          })
        })
      }
    } else if (code === 500) {
      ElMessage.error(t('sys.api.errMsg500'))
      return Promise.reject(new Error(msg))
    } else if (code === 901) {
      ElMessage.error({
        offset: 300,
        dangerouslyUseHTMLString: true,
        message: '<div>' + t('sys.api.errMsg901') + '</div>'
      })
      return Promise.reject(new Error(msg))
    } else if (code !== 200) {
      if (msg === '无效的刷新令牌') {
        // hard coding:忽略这个提示,直接登出
        console.log(msg)
        return handleAuthorized()
      } else {
        ElNotification.error({ title: msg })
      }
      return Promise.reject('error')
    } else {
      return data
    }
  },
  (error: AxiosError) => {
    console.log('err' + error) // for debug
    let { message } = error
    const { t } = useI18n()
    if (message === 'Network Error') {
      message = t('sys.api.errorMessage')
    } else if (message.includes('timeout')) {
      message = t('sys.api.apiTimeoutMessage')
    } else if (message.includes('Request failed with status code')) {
      message = t('sys.api.apiRequestFailed') + message.substr(message.length - 3)
    }
    ElMessage.error(message)
    return Promise.reject(error)
  }
)

const refreshToken = async () => {
  axios.defaults.headers.common['tenant-id'] = getTenantId()
  return await axios.post(base_url + '/system/auth/refresh-token?refreshToken=' + getRefreshToken())
}
const handleAuthorized = () => {
  const { t } = useI18n()
  if (!isRelogin.show) {
    // 如果已经到登录页面则不进行弹窗提示
    if (window.location.href.includes('login')) {
      return
    }
    isRelogin.show = true
    ElMessageBox.confirm(t('sys.api.timeoutMessage'), t('common.confirmTitle'), {
      showCancelButton: false,
      closeOnClickModal: false,
      showClose: false,
      closeOnPressEscape: false,
      confirmButtonText: t('login.relogin'),
      type: 'warning'
    }).then(() => {
      resetRouter() // 重置静态路由表
      deleteUserCache() // 删除用户缓存
      removeToken()
      isRelogin.show = false
      // 干掉token后再走一次路由让它过router.beforeEach的校验
      window.location.href = window.location.href
    })
  }
  return Promise.reject(t('sys.api.timeoutMessage'))
}
/**
 * 加密请求参数
 * @param config 请求配置
 */
const encryptRequestParams = (config) => {
  const method = config.method?.toUpperCase()
  if ((method === 'GET' || method === 'DELETE') && config.params) {
    const payload = buildQueryPayload(config.params as Record<string, any>)
    const cipher = encryptRequest(payload)
    config.params = { p: cipher }
  } else if ((method === 'POST' || method === 'PUT') && config.data) {
    const payload = JSON.stringify(config.data)
    const cipher = encryptRequest(payload)
    config.data = { p: cipher }
  }
}
export { service }
2、加密工具类:sm2-client.ts
import { sm2EncryptHex, sm2DecryptToString, sm2SignHex, sm2VerifyHex } from './sm2'
import { getPrivateKey, getPublicKey } from '@/utils/auth'

export interface RequestWrap {
  payload: string
  sign: string
}

// 本地快速缓存,减少多次读取 localStorage/sessionStorage
let _cachedPub = ''
let _cachedPri = ''
export function readKeys() {
  // 若缓存为空或被外部清掉(切用户/刷新密钥),下次自动回填
  if (!_cachedPri || !_cachedPub) {
    _cachedPri = getPrivateKey()
    _cachedPub = getPublicKey()
  }
  return { pri: _cachedPri, pub: _cachedPub }
}
// 当 setCryptKey() 被调用时,请顺手调用 resetKeyCache()(见 service.ts 已处理)
export function resetKeyCache() {
  _cachedPri = ''
  _cachedPub = ''
}

// 仅用于 GET/DELETE 拼接(服务器 parse 不做 decode,所以保持 encodeURIComponent)
export function buildQueryPayload(params: Record<string, any>): string {
  // 原地线性拼接,避免 Object.keys + map + join 产生多数组
  const entries = Object.entries(params)
  if (!entries.length) return ''
  let out = ''
  for (let i = 0; i < entries.length; i++) {
    const [k, v] = entries[i]
    if (i > 0) out += '&'
    // 保持 encode,与后端保持一致(后端不 decode,仅原样接收)
    out += encodeURIComponent(k) + '=' + encodeURIComponent(v ?? '')
  }
  return out
}

export function encryptRequest(payload: string): string {
  const { pri, pub } = readKeys()
  const sign = sm2SignHex(pri, payload)
  // 直接一次 stringify,避免额外对象拷贝
  const json = `{"payload":${JSON.stringify(payload)},"sign":"${sign}"}`
  return sm2EncryptHex(pub, json)
}

export function decryptResponse(cipherHex: string): string {
  const { pri, pub } = readKeys()
  const json = sm2DecryptToString(pri, cipherHex)
  // 轻量解析,仅取两个字段
  // 注:这里仍用 JSON.parse,因返回体结构固定且很小
  const o = JSON.parse(json) as RequestWrap
  if (!sm2VerifyHex(pub, o.payload, o.sign)) {
    throw new Error('Invalid server signature')
  }
  return o.payload
}
3、加密类:sm2.ts
import { sm2 } from 'sm-crypto'

export function sm2EncryptHex(pub: string, data: string): string {
  return sm2.doEncrypt(data, pub) as string
}

export function sm2DecryptToString(pri: string, data: string): string {
  return sm2.doDecrypt(data, pri) as string
}

export function sm2SignHex(pri: string, msg: string): string {
  return sm2.doSignature(msg, pri, { hash: true, der: true }) as string
}

export function sm2VerifyHex(pub: string, msg: string, sign: string): boolean {
  return sm2.doVerifySignature(msg, sign, pub, {
    hash: true,
    der: true
  }) as boolean
}
工具类 auth.ts 增加以下配置
// ========== 全报文加密相关 ==========

const PirvateKey = 'PRIVATE_KEY'
const PublicKey = 'PUBLIC_KEY'

export const setCryptKey = (cryptKey: CryptKeyVO) => {
  wsCache.set(PirvateKey, cryptKey.privateKey)
  wsCache.set(PublicKey, cryptKey.publicKey)
}

export const getPrivateKey = () => {
  return wsCache.get(PirvateKey)
}

export const getPublicKey = () => {
  return wsCache.get(PublicKey)
}

export const removeCryptKey = () => {
  wsCache.delete(PirvateKey)
  wsCache.delete(PublicKey)
}
登录成功设置密钥对,login-form.vue,方法:handleLogin
// 登录
const handleLogin = async (params: any) => {
  loginLoading.value = true
  try {
    await getTenantId()
    const data = await validForm()
    if (!data) {
      return
    }
    const loginDataLoginForm = { ...loginData.loginForm }
    loginDataLoginForm.captchaVerification = params.captchaVerification
    const res = await LoginApi.login(loginDataLoginForm)
    if (!res) {
      return
    }
    loading.value = ElLoading.service({
      lock: true,
      text: '正在加载系统中...',
      background: 'rgba(0, 0, 0, 0.7)'
    })
    if (loginDataLoginForm.rememberMe) {
      authUtil.setLoginForm(loginDataLoginForm)
    } else {
      authUtil.removeLoginForm()
    }
    authUtil.setToken(res)
    // 是否开启全报文加密
    if (loginData.cryptEnable === 'true') {
      // 设置密钥对
      authUtil.setCryptKey(res)
    }
    if (!redirect.value) {
      redirect.value = '/'
    }
    // 判断是否为SSO登录
    if (redirect.value.indexOf('sso') !== -1) {
      window.location.href = window.location.href.replace('/login?redirect=', '')
    } else {
      await push({ path: redirect.value || permissionStore.addRouters[0].path })
    }
  } finally {
    loginLoading.value = false
    loading.value.close()
  }
}
标签: sm2 Spring Spring Boot 加解密
最后更新:2025年8月25日

Akim

犇 骉 Java、C#、Python、Go、Android、MiniProgram、Bootstrap、Vue2

点赞
< 上一篇
下一篇 >
文章目录
  • 1、引用工具包
  • 2.SM2 密钥对
  • 3.SM2 工具类
  • 4.加密配置属性
  • 5.加密配置
  • 6.加密参数对象
  • 7.请求工具
  • 8.请求拦截器
  • 9.响应拦截器
  • 10.GET/DELETE 请求参数包装器
  • 11.POST/PUT 请求体包装器
  • 12.application.yml 配置
  • 13.服务端其他
  • 14.前端Vue2处理参考

Copyright © 2025 aianran.com All Rights Reserved.

免责申明 | 隐私政策 | 服务条款 | 关于我们

黔ICP备2023008200号-1

贵公网安备 52010202003594号