姊妹篇《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、登录成功后将客户端密钥对返回
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()
}
}