SpringBoot HTTP接口AES加密传输:从原理到跨平台工程实践
1. 项目概述:为什么我们需要在HTTP接口上做AES加密?
最近在做一个跨平台的数据同步项目,前端有Web、移动端App,后端还有几个独立的微服务,它们之间需要通过HTTP接口频繁交换数据。项目刚上线没多久,安全团队就提了个醒:虽然我们用了HTTPS,但那只是通道加密,数据在到达对方服务器内存之前是安全的。一旦数据到了对方手里,就是明文。如果日志系统不小心把请求体打印出来了,或者中间某个环节被恶意拦截,敏感信息就暴露了。这让我意识到,光有传输层安全(TLS)还不够,我们还需要应用层的数据加密。
这就是“SpringBoot项目不同平台通过HTTP接口AES加密传输”这个需求的由来。它要解决的核心问题是:在HTTPS保障的传输通道之上,再为业务数据本身加一把锁,确保数据在发送方和接收方的内存之外,始终保持密文状态,实现端到端的业务数据安全。AES(高级加密标准)因其速度快、安全性高、标准化程度好,成为实现这种“内容加密”的首选对称加密算法。
简单来说,这个方案就是在你的业务逻辑和HTTP客户端/服务器之间,插入一个透明的加密/解密层。你的业务代码像往常一样处理明文对象,而底层框架负责在发送前加密、在接收后解密。对于调用方(无论是另一个SpringBoot服务、一个Vue前端,还是一个移动端应用),它们需要遵循同样的加密规则来组装和解析数据。
2. 整体方案设计与核心思路拆解
2.1 为什么是AES,而不是RSA或直接HTTPS?
首先得理清几个概念。HTTPS(TLS/SSL)解决的是传输过程的加密和身份认证,防止数据在网络上被窃听和篡改。但它不关心数据到达服务器后是什么样子。而我们的需求是内容加密,即数据本身在离开我方应用内存时就是加密的,只有拥有密钥的合法接收方才能解密。
那么,为什么选择AES而不是其他算法呢?
- 对称加密 vs. 非对称加密:RSA是非对称加密,公钥加密,私钥解密。它的优点是密钥分发方便,但致命缺点是加解密速度慢,不适合加密大量数据。AES是对称加密,加解密使用同一把密钥,速度极快,适合对业务数据体进行加密。
- 混合加密模式:在实际方案中,我们通常会采用“RSA + AES”的混合模式。这正是网络资料中提到的思路。用RSA来加密传输AES的密钥,用AES来加密实际的业务数据。这样既利用了RSA便于密钥分发的优点,又发挥了AES高效加密大数据量的长处。在我们的SpringBoot跨平台场景中,可以在首次握手时,由服务端生成一个随机的AES密钥(即“会话密钥”),用客户端的RSA公钥加密后传给客户端。之后本次会话的所有数据都用这把AES密钥加密。
- AES的模式和填充:AES有不同的工作模式(如ECB, CBC, GCM)和填充方案(如PKCS5Padding)。ECB模式不安全,不推荐。CBC模式是最常用的,但它需要一个初始化向量(IV)来增加随机性,且需要填充。GCM模式则更现代,它同时提供了加密和认证功能,不需要单独填充,且能防止密文被篡改,是当前的首选。在我们的实现中,我会重点讲解CBC和GCM两种模式。
2.2 方案架构:如何无缝集成到SpringBoot项目中?
我们的目标是对开发者透明或侵入性最小。理想状态下,业务开发人员只需要关注@RequestBody和@ResponseBody,加解密由框架自动完成。
基于这个思路,核心架构围绕Spring MVC的拦截器(Interceptor)和消息转换器(HttpMessageConverter)展开:
- 统一入口与出口:在HTTP请求进入Controller之前,在响应返回给客户端之后,是处理加解密的黄金点位。我们可以通过实现
HandlerInterceptor或使用@ControllerAdvice配合@ResponseBodyAdvice来实现。 - 消息转换器的定制:更优雅的方式是自定义一个
HttpMessageConverter。当Spring MVC处理@RequestBody时,会使用配置的HttpMessageConverter将HTTP请求体转换成Java对象。我们可以定制一个,在转换前先解密请求体,在转换后(写入响应前)先加密响应体。 - 密钥管理:这是安全的核心。密钥不能硬编码在代码中。推荐的方式是:
- 环境变量/配置中心:将AES密钥的Base64编码字符串放在应用启动参数或配置中心(如Nacos, Apollo)。
- KMS服务:在云环境下,可以使用阿里云KMS、AWS KMS等服务来生成和管理密钥,应用在运行时动态获取。
- 首次握手协商:如上文所述,通过RSA非对称加密来安全地交换每次会话的AES密钥。
考虑到实现的普适性和清晰度,下文我将以基于HandlerInterceptor和自定义注解的方案作为主线进行详解,同时会剖析消息转换器方案的优缺点。我们会实现一个@Encrypt注解,标记在Controller方法或类上,框架就会自动对该方法的响应进行加密,对请求进行解密。
3. 核心细节解析与实操要点
3.1 AES加解密的核心参数与Java实现
在动手写框架代码前,必须把AES加解密的单点功能搞扎实。这里以最常用的AES/CBC/PKCS5Padding模式为例,给出一个完整的工具类。
import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.util.Base64; public class AesUtils { // 算法/模式/填充 private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding"; private static final String ALGORITHM = "AES"; // 密钥,必须是16、24或32字节(对应AES-128, AES-192, AES-256) private static final String SECRET_KEY = "Your16ByteKey123"; // 示例,实际应从配置读取 // 初始化向量,必须是16字节,且需要与加密方保持一致 private static final String IV_STRING = "Your16ByteIV4567"; // 示例 /** * AES加密 * @param content 明文 * @return Base64编码的密文 */ public static String encrypt(String content) throws Exception { Cipher cipher = Cipher.getInstance(TRANSFORMATION); SecretKeySpec keySpec = new SecretKeySpec(SECRET_KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM); IvParameterSpec ivSpec = new IvParameterSpec(IV_STRING.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encryptedBytes = cipher.doFinal(content.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encryptedBytes); } /** * AES解密 * @param encryptedBase64 Base64编码的密文 * @return 明文 */ public static String decrypt(String encryptedBase64) throws Exception { Cipher cipher = Cipher.getInstance(TRANSFORMATION); SecretKeySpec keySpec = new SecretKeySpec(SECRET_KEY.getBytes(StandardCharsets.UTF_8), ALGORITHM); IvParameterSpec ivSpec = new IvParameterSpec(IV_STRING.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); byte[] encryptedBytes = Base64.getDecoder().decode(encryptedBase64); byte[] decryptedBytes = cipher.doFinal(encryptedBytes); return new String(decryptedBytes, StandardCharsets.UTF_8); } }注意:这里有一个巨大的坑!上面的代码为了演示,将密钥(SECRET_KEY)和向量(IV_STRING)硬编码了。在生产环境中,这是绝对禁止的!你必须通过外部配置注入。而且,IV(初始化向量)在CBC模式下,为了安全,每次加密都应该使用随机生成的IV,并将IV和密文一起传输给接收方。固定IV会大大降低安全性。下面会给出改进方案。
3.2 更安全的AES/CBC实现:动态IV
安全的CBC模式,需要每次加密随机生成IV,并将IV拼接到密文前面(或通过其他方式传递)。解密时,先从密文中分离出IV。
public class SecureAesCbcUtils { private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding"; private static final String ALGORITHM = "AES"; private static final int IV_LENGTH = 16; // AES块大小是16字节 private final SecretKeySpec secretKeySpec; // 通过构造器传入密钥,密钥应从配置文件读取 public SecureAesCbcUtils(String base64Key) { byte[] keyBytes = Base64.getDecoder().decode(base64Key); this.secretKeySpec = new SecretKeySpec(keyBytes, ALGORITHM); } public String encrypt(String plainText) throws Exception { // 1. 生成随机IV byte[] iv = new byte[IV_LENGTH]; SecureRandom secureRandom = new SecureRandom(); secureRandom.nextBytes(iv); IvParameterSpec ivSpec = new IvParameterSpec(iv); // 2. 加密 Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivSpec); byte[] cipherTextBytes = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); // 3. 将IV和密文拼接,然后整体Base64编码 byte[] combined = new byte[iv.length + cipherTextBytes.length]; System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(cipherTextBytes, 0, combined, iv.length, cipherTextBytes.length); return Base64.getEncoder().encodeToString(combined); } public String decrypt(String combinedBase64) throws Exception { // 1. Base64解码 byte[] combined = Base64.getDecoder().decode(combinedBase64); // 2. 分离IV和密文 byte[] iv = new byte[IV_LENGTH]; byte[] cipherTextBytes = new byte[combined.length - IV_LENGTH]; System.arraycopy(combined, 0, iv, 0, IV_LENGTH); System.arraycopy(combined, IV_LENGTH, cipherTextBytes, 0, cipherTextBytes.length); IvParameterSpec ivSpec = new IvParameterSpec(iv); // 3. 解密 Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivSpec); byte[] plainTextBytes = cipher.doFinal(cipherTextBytes); return new String(plainTextBytes, StandardCharsets.UTF_8); } }这样,每次加密的结果都不同,安全性得到保障。调用方在解密时,也需要使用同样的逻辑来分离IV和密文。
3.3 进阶之选:AES/GCM模式实现
GCM模式更推荐,因为它自带完整性校验。在Java中实现同样需要注意,GCM需要一个随机生成的Nonce(类似IV),并且会产生一个认证标签(Authentication Tag)。
public class AesGcmUtils { private static final String TRANSFORMATION = "AES/GCM/NoPadding"; private static final int TAG_LENGTH_BIT = 128; // 认证标签长度,通常为128位 private static final int NONCE_LENGTH = 12; // 推荐Nonce长度为12字节 private final SecretKey secretKey; public AesGcmUtils(String base64Key) throws Exception { byte[] keyBytes = Base64.getDecoder().decode(base64Key); this.secretKey = new SecretKeySpec(keyBytes, "AES"); } public String encrypt(String plainText) throws Exception { byte[] nonce = new byte[NONCE_LENGTH]; SecureRandom secureRandom = new SecureRandom(); secureRandom.nextBytes(nonce); GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(TAG_LENGTH_BIT, nonce); Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmParameterSpec); byte[] cipherTextBytes = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); // 拼接Nonce和密文 byte[] combined = new byte[nonce.length + cipherTextBytes.length]; System.arraycopy(nonce, 0, combined, 0, nonce.length); System.arraycopy(cipherTextBytes, 0, combined, nonce.length, cipherTextBytes.length); return Base64.getEncoder().encodeToString(combined); } public String decrypt(String combinedBase64) throws Exception { byte[] combined = Base64.getDecoder().decode(combinedBase64); byte[] nonce = new byte[NONCE_LENGTH]; byte[] cipherTextBytes = new byte[combined.length - NONCE_LENGTH]; System.arraycopy(combined, 0, nonce, 0, NONCE_LENGTH); System.arraycopy(combined, NONCE_LENGTH, cipherTextBytes, 0, cipherTextBytes.length); GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(TAG_LENGTH_BIT, nonce); Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmParameterSpec); byte[] plainTextBytes = cipher.doFinal(cipherTextBytes); return new String(plainTextBytes, StandardCharsets.UTF_8); } }实操心得:选择CBC还是GCM?如果你的JDK版本较低(早于1.7),或者需要与一些老系统交互,CBC兼容性更好。如果是全新的系统,强烈建议使用GCM。GCM的
NoPadding意味着它不需要填充,且密文长度固定为明文长度 + TAG长度(16字节),计算更精确。同时,它能有效防止“填充预言攻击”(Padding Oracle Attack),这是CBC模式的一个潜在风险。
4. 在SpringBoot中实现全局HTTP接口加解密
有了核心的加解密工具,我们现在来构建SpringBoot的集成层。我们将采用“自定义注解 + 拦截器”的方案,因为它理解起来直观,且能灵活控制哪些接口需要加解密。
4.1 定义加解密注解与响应体包装类
首先,定义一个注解,用来标记需要加密响应或解密请求的接口。
/** * 加解密注解 * 标记在Controller类或方法上。 * 如果标记在类上,则该类下所有方法的响应都需要加密,请求体都需要解密。 * 如果标记在方法上,则只对该方法生效。 */ @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Encrypt { /** * 是否对响应进行加密,默认true */ boolean responseEncrypt() default true; /** * 是否对请求进行解密,默认true */ boolean requestDecrypt() default true; }然后,定义一个统一的加密响应体。因为加密后,原来的JSON对象会变成一个字符串,我们需要一个固定的结构来包装它。
@Data @AllArgsConstructor @NoArgsConstructor public class EncryptedResponse<T> { /** * 状态码 */ private Integer code; /** * 提示信息 */ private String message; /** * 加密后的数据字符串。 * 当成功时,这里是密文;当失败时,这里可以是null或错误详情明文。 */ private T encryptedData; /** * 时间戳 */ private Long timestamp; public static <T> EncryptedResponse<T> success(T encryptedData) { return new EncryptedResponse<>(200, "success", encryptedData, System.currentTimeMillis()); } public static EncryptedResponse<String> error(String message) { // 错误信息一般不加密,直接返回明文 return new EncryptedResponse<>(500, message, null, System.currentTimeMillis()); } }4.2 实现加解密拦截器(HandlerInterceptor)
这是核心组件,它将在请求到达Controller之前和之后执行。
@Component public class EncryptInterceptor implements HandlerInterceptor { @Autowired private AesCbcUtils aesUtils; // 注入我们之前写的加解密工具,这里以CBC为例 @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 判断是否需要解密请求 if (!needDecrypt(handler)) { return true; } // 2. 获取加密的请求体 String encryptedBody = getRequestBody(request); if (StringUtils.isEmpty(encryptedBody)) { // 没有请求体,可能是GET请求,直接放行 return true; } // 3. 解密请求体 String decryptedBody; try { decryptedBody = aesUtils.decrypt(encryptedBody); } catch (Exception e) { // 解密失败,可能是非法请求或数据被篡改 response.setStatus(HttpStatus.BAD_REQUEST.value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.getWriter().write(JSON.toJSONString(EncryptedResponse.error("请求数据解密失败"))); return false; // 中断请求 } // 4. 将解密后的请求体重新放入Request中,供后续的@RequestBody读取 // 这里需要自定义一个HttpServletRequestWrapper来覆盖getInputStream和getReader方法 request = new DecryptHttpServletRequestWrapper(request, decryptedBody); // 注意:这里需要将包装后的request对象设置回参数,但Interceptor的request参数是final的。 // 更常见的做法是使用Filter,或者在Controller方法参数中直接读取解密后的字符串。 // 为了简化,我们换一种思路:在preHandle中解密并验证,将明文存入Request属性,在Controller中用@RequestParam接收。 request.setAttribute("DECRYPTED_BODY", decryptedBody); return true; } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { // 这个方法在Controller方法执行后,视图渲染前调用,不太适合处理@ResponseBody的响应。 // 我们需要用@ControllerAdvice + ResponseBodyAdvice接口,或者使用OncePerRequestFilter。 } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 请求完成后清理 } private boolean needDecrypt(Object handler) { if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod = (HandlerMethod) handler; // 检查方法上的注解 Encrypt methodEncrypt = handlerMethod.getMethodAnnotation(Encrypt.class); // 检查类上的注解 Encrypt classEncrypt = handlerMethod.getBeanType().getAnnotation(Encrypt.class); // 优先级:方法注解 > 类注解 Encrypt encrypt = methodEncrypt != null ? methodEncrypt : classEncrypt; return encrypt != null && encrypt.requestDecrypt(); } return false; } private String getRequestBody(HttpServletRequest request) throws IOException { // 读取请求体,注意:request.getInputStream()只能读一次,需要包装 return StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); } }上面的preHandle方法展示了思路,但直接修改请求体在Interceptor中比较麻烦。更通用的方案是使用Filter或**@ControllerAdvice配合RequestBodyAdvice/ResponseBodyAdvice**。
4.3 更优雅的方案:使用ResponseBodyAdvice和RequestBodyAdvice
Spring提供了这两个接口,可以让我们在消息转换器工作前后进行干预,完美契合我们的需求。
第一步:实现请求解密 Advice
@ControllerAdvice public class DecryptRequestBodyAdvice implements RequestBodyAdvice { @Autowired private AesCbcUtils aesUtils; @Override public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) { // 判断该请求是否需要解密:检查方法或类上是否有@Encrypt注解,且requestDecrypt为true Encrypt methodEncrypt = methodParameter.getMethodAnnotation(Encrypt.class); Encrypt classEncrypt = methodParameter.getContainingClass().getAnnotation(Encrypt.class); Encrypt encrypt = methodEncrypt != null ? methodEncrypt : classEncrypt; return encrypt != null && encrypt.requestDecrypt(); } @Override public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException { // 在消息转换器读取body之前,我们拿到加密的输入流,解密后返回一个新的输入流 String encryptedBody = StreamUtils.copyToString(inputMessage.getBody(), StandardCharsets.UTF_8); String decryptedBody; try { decryptedBody = aesUtils.decrypt(encryptedBody); } catch (Exception e) { throw new RuntimeException("请求数据解密失败", e); } // 将解密后的字符串重新封装为输入流 byte[] decryptedBytes = decryptedBody.getBytes(StandardCharsets.UTF_8); ByteArrayInputStream decryptedStream = new ByteArrayInputStream(decryptedBytes); return new HttpInputMessage() { @Override public InputStream getBody() throws IOException { return decryptedStream; } @Override public HttpHeaders getHeaders() { return inputMessage.getHeaders(); } }; } @Override public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) { // body已经被转换器转换成对象了,这里直接返回 return body; } @Override public Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) { // 请求体为空时的处理 return body; } }第二步:实现响应加密 Advice
@ControllerAdvice public class EncryptResponseBodyAdvice implements ResponseBodyAdvice<Object> { @Autowired private AesCbcUtils aesUtils; @Override public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) { // 判断该响应是否需要加密 Encrypt methodEncrypt = returnType.getMethodAnnotation(Encrypt.class); Encrypt classEncrypt = returnType.getContainingClass().getAnnotation(Encrypt.class); Encrypt encrypt = methodEncrypt != null ? methodEncrypt : classEncrypt; return encrypt != null && encrypt.responseEncrypt(); } @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { // 在消息转换器写body之前,对body进行加密包装 // 注意:body可能是各种类型,比如String,或者我们自己的Result对象 // 我们需要统一处理,返回一个EncryptedResponse对象 if (body instanceof EncryptedResponse) { // 如果已经是EncryptedResponse,说明可能已经处理过或者是错误响应,直接返回 return body; } // 将业务返回的对象先序列化成JSON字符串 String originalData; try { originalData = JSON.toJSONString(body); } catch (Exception e) { // 序列化失败,可能是无法序列化的对象,返回错误 return EncryptedResponse.error("响应数据序列化失败"); } // 对JSON字符串进行加密 String encryptedData; try { encryptedData = aesUtils.encrypt(originalData); } catch (Exception e) { return EncryptedResponse.error("响应数据加密失败"); } // 返回统一的加密响应结构 return EncryptedResponse.success(encryptedData); } }第三步:在Controller中使用
@RestController @RequestMapping("/api/user") @Encrypt // 该类下所有接口的请求和响应都启用加解密 public class UserController { @PostMapping("/info") // 这里不需要再写@Encrypt,类上已经有了 public UserInfo getUserInfo(@RequestBody QueryRequest request) { // 这里的request对象已经是解密后的JSON自动反序列化生成的 // 业务逻辑处理... UserInfo userInfo = userService.getInfo(request.getId()); // 直接返回业务对象,EncryptResponseBodyAdvice会将其加密包装 return userInfo; } @GetMapping("/public") @Encrypt(responseEncrypt = false, requestDecrypt = false) // 这个接口明确不加密 public String publicInfo() { return "这是一个公开信息,不需要加密"; } }这个方案非常优雅,对业务代码的侵入性极小,只需要一个注解即可。RequestBodyAdvice和ResponseBodyAdvice是Spring MVC处理@RequestBody和@ResponseBody的利器,用在这里正合适。
4.4 密钥配置与管理
绝对不能把密钥写在代码里!我们使用Spring Boot的@ConfigurationProperties来管理。
# application.yml security: aes: key: dGhpc2lzYTE2Ynl0ZWtleSE= # 这是一个Base64编码的32字节密钥(AES-256) # iv: xxxxxx # 如果使用固定IV的CBC模式,可以在这里配置。但更推荐使用动态IV。@Configuration @ConfigurationProperties(prefix = "security.aes") @Data public class AesProperties { private String key; private String iv; // 可选 } @Configuration public class AesConfig { @Bean @ConditionalOnMissingBean public AesCbcUtils aesCbcUtils(AesProperties properties) throws Exception { // 从配置中读取Base64编码的密钥 String base64Key = properties.getKey(); if (StringUtils.isEmpty(base64Key)) { throw new IllegalArgumentException("AES密钥未配置"); } return new AesCbcUtils(base64Key); } // 也可以同时配置GCM的工具类 @Bean public AesGcmUtils aesGcmUtils(AesProperties properties) throws Exception { String base64Key = properties.getKey(); if (StringUtils.isEmpty(base64Key)) { throw new IllegalArgumentException("AES密钥未配置"); } return new AesGcmUtils(base64Key); } }5. 多平台客户端如何调用加密接口?
服务端准备好了,客户端(其他SpringBoot服务、Vue前端、安卓/iOS App、Python脚本等)需要按照同样的规则来加密请求、解密响应。
5.1 其他SpringBoot服务作为客户端
在另一个SpringBoot服务中,你可以使用RestTemplate或WebClient,并为其配置一个自定义的拦截器,在发送请求前加密请求体,在收到响应后解密响应体。思路和服务端的RequestBodyAdvice/ResponseBodyAdvice类似。
这里给出一个RestTemplate的配置示例:
@Configuration public class RestTemplateConfig { @Autowired private AesCbcUtils aesUtils; @Bean public RestTemplate secureRestTemplate() { RestTemplate restTemplate = new RestTemplate(); // 获取原有的拦截器列表 List<ClientHttpRequestInterceptor> interceptors = restTemplate.getInterceptors(); if (interceptors == null) { interceptors = new ArrayList<>(); } // 添加自定义的加解密拦截器 interceptors.add(new EncryptClientHttpRequestInterceptor(aesUtils)); restTemplate.setInterceptors(interceptors); return restTemplate; } } public class EncryptClientHttpRequestInterceptor implements ClientHttpRequestInterceptor { private final AesCbcUtils aesUtils; public EncryptClientHttpRequestInterceptor(AesCbcUtils aesUtils) { this.aesUtils = aesUtils; } @Override public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { // 1. 加密请求体 if (body != null && body.length > 0) { String originalBody = new String(body, StandardCharsets.UTF_8); try { String encryptedBody = aesUtils.encrypt(originalBody); body = encryptedBody.getBytes(StandardCharsets.UTF_8); } catch (Exception e) { throw new RuntimeException("请求加密失败", e); } // 更新Content-Length头(重要!) request.getHeaders().setContentLength(body.length); } // 2. 执行请求 ClientHttpResponse response = execution.execute(request, body); // 3. 解密响应体(这里简化处理,假设响应体就是加密的字符串) // 在实际中,响应体应该是EncryptedResponse结构,需要先解析JSON,再解密encryptedData字段 // 这里提供一个思路: // 将response包装一层,在它的getBody()方法里进行解密操作。 return new DecryptClientHttpResponse(response, aesUtils); } }DecryptClientHttpResponse是一个包装类,它会在读取响应流时进行解密,代码略长,核心思想是继承ClientHttpResponseWrapper,并重写getBody()方法。
5.2 前端(如Vue/React)调用
前端需要有一个对应的AES加密库,如crypto-js。调用流程如下:
- 将业务参数组装成JSON对象。
- 使用与后端约定的密钥和模式(如AES/CBC/PKCS7Padding,注意前端可能是PKCS7,但和Java的PKCS5是兼容的)加密这个JSON字符串。
- 将加密后的密文字符串作为请求体(
data)发送。 - 收到响应后,先解析JSON,得到
encryptedData字段。 - 对
encryptedData字段进行解密,得到真正的业务数据JSON字符串,再解析成对象。
import CryptoJS from 'crypto-js'; const key = CryptoJS.enc.Utf8.parse('Your16ByteKey123'); // 密钥,需要和后端一致 const iv = CryptoJS.enc.Utf8.parse('Your16ByteIV4567'); // IV,如果是动态IV模式,需要从首次响应或其他方式获取 // 加密函数 function encrypt(data) { const dataStr = JSON.stringify(data); const encrypted = CryptoJS.AES.encrypt(dataStr, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return encrypted.toString(); // 返回Base64格式的密文 } // 解密函数 function decrypt(encryptedBase64) { const decrypt = CryptoJS.AES.decrypt(encryptedBase64, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); const decryptedStr = decrypt.toString(CryptoJS.enc.Utf8); return JSON.parse(decryptedStr); } // 调用示例 async function callEncryptedApi() { const requestData = { userId: 123 }; const encryptedRequest = encrypt(requestData); const response = await axios.post('/api/user/info', encryptedRequest, { headers: { 'Content-Type': 'text/plain' } // 请求体是纯文本密文 }); const serverResponse = response.data; // 假设返回 {code:200, message:'success', encryptedData:'...', timestamp:...} if (serverResponse.code === 200) { const realData = decrypt(serverResponse.encryptedData); console.log('真实数据:', realData); } else { console.error('请求失败:', serverResponse.message); } }5.3 其他语言客户端(Python示例)
Python可以使用pycryptodome库。
from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad import base64 import json class AesClient: def __init__(self, key: bytes, iv: bytes): self.key = key # 16, 24, or 32 bytes self.iv = iv # 16 bytes for CBC def encrypt(self, data: dict) -> str: """加密字典数据,返回Base64字符串""" json_str = json.dumps(data, ensure_ascii=False) cipher = AES.new(self.key, AES.MODE_CBC, self.iv) ct_bytes = cipher.encrypt(pad(json_str.encode('utf-8'), AES.block_size)) return base64.b64encode(ct_bytes).decode('utf-8') def decrypt(self, encrypted_b64: str) -> dict: """解密Base64密文,返回字典""" ct_bytes = base64.b64decode(encrypted_b64) cipher = AES.new(self.key, AES.MODE_CBC, self.iv) pt_bytes = unpad(cipher.decrypt(ct_bytes), AES.block_size) json_str = pt_bytes.decode('utf-8') return json.loads(json_str) # 使用示例 if __name__ == '__main__': # 密钥和IV必须与Java后端完全一致(字节对字节) key = b'Your16ByteKey123' # 16字节 iv = b'Your16ByteIV4567' # 16字节 client = AesClient(key, iv) request_data = {"userId": 123} encrypted = client.encrypt(request_data) print(f"加密后: {encrypted}") # 模拟收到加密响应 encrypted_response_from_server = "..." # 这里填从服务器收到的encryptedData字段值 decrypted = client.decrypt(encrypted_response_from_server) print(f"解密后: {decrypted}")6. 常见问题、排查技巧与进阶优化
6.1 常见问题速查表
| 问题现象 | 可能原因 | 排查步骤 |
|---|---|---|
服务端解密失败,报javax.crypto.BadPaddingException: Given final block not properly padded | 1. 客户端与服务端密钥不一致。 2. 客户端与服务端IV不一致(CBC模式)。 3. 加密模式或填充方式不一致。 4. 密文在传输中被修改或编码错误(如Base64)。 | 1. 核对双方密钥的原始字节或Base64字符串是否完全相同。 2. 核对IV。如果是动态IV,检查拼接和分离逻辑。 3. 确认双方的 TRANSFORMATION字符串完全一致(如AES/CBC/PKCS5Padding)。4. 打印出客户端发送的密文和服务端收到的密文,进行比对。检查是否有URL编码解码问题。 |
| 服务端能解密,但反序列化JSON失败 | 1. 客户端加密的不是合法的JSON字符串。 2. 加解密过程中字符编码不一致(如UTF-8 vs GBK)。 3. 解密后的字符串包含不可见字符或多余内容。 | 1. 在客户端加密前,打印即将加密的字符串,确认是标准JSON。 2. 确保加解密全过程使用统一的字符集(强烈推荐UTF-8)。 3. 将解密后的字符串用 System.out.println或日志打印出来,肉眼观察是否有异常。 |
| 前端(JS)加密,Java解密失败 | 1. JS库(如crypto-js)的默认输出可能是WordArray或OpenSSL格式,而非纯Base64。 2. PKCS5Padding和PKCS7Padding的兼容性问题(实际上在AES中它们等价)。 3. 密钥和IV的字符串到字节的转换方式不同。 | 1. 在JS端,使用CryptoJS.enc.Base64.stringify(ciphertext)确保输出纯Base64。2. 确认填充方案,在JS中通常写 padding: CryptoJS.pad.Pkcs7。3. 确保双方密钥和IV的字符串用同样的编码(如UTF-8)转换成字节数组。在JS中 CryptoJS.enc.Utf8.parse('key'),在Java中key.getBytes(StandardCharsets.UTF_8)。 |
| 性能问题,接口响应变慢 | 1. AES加解密本身是计算密集型操作,特别是对大报文。 2. 每次请求都进行Base64编解码。 3. 日志打印了完整的加解密过程。 | 1. 对于非常大的数据,考虑是否所有字段都需要加密,或采用分段加密。 2. 确保加解密工具类被Spring管理为单例,避免重复创建。 3. 在生产环境关闭调试日志,避免打印完整的密文/明文。 |
| 动态IV模式下,客户端不知道IV如何解密 | 服务端没有将IV传递给客户端。 | 服务端在加密后,必须将IV和密文一起返回给客户端。通常有两种方式: 1.拼接法:如本文所示,将IV拼在密文前,整体Base64。 2.分字段法:在 EncryptedResponse中增加一个iv字段,单独存放Base64编码的IV。客户端先取IV,再解密数据。 |
6.2 进阶优化与安全建议
- 密钥轮转:长期使用同一个AES密钥存在风险。应设计密钥轮转机制。例如,可以使用一个主密钥(Master Key)来加密实际的数据加密密钥(Data Key)。每次会话或定期生成新的Data Key,用Master Key加密后存储或传输。这样即使某个Data Key泄露,影响范围也有限。
- 增加签名防篡改:AES加密保证了机密性,但为了确保数据的完整性和来源可信,可以引入HMAC(哈希消息认证码)。在发送加密数据的同时,用另一个密钥对“密文+时间戳”生成HMAC签名一并发送。接收方先验证HMAC签名,通过后再解密。这能有效防止重放攻击和密文被篡改。
- 非对称加密交换密钥:对于全新的客户端,最安全的方式是使用RSA非对称加密来交换AES会话密钥。流程如下:
- 客户端持有服务端的RSA公钥。
- 客户端生成一个随机的AES会话密钥。
- 客户端用RSA公钥加密这个AES密钥,发送给服务端。
- 服务端用RSA私钥解密,得到AES会话密钥。
- 后续通信全部使用这个AES会话密钥进行对称加密。
- 会话结束后,密钥销毁。
- 选择性加密:并非所有接口、所有字段都需要加密。像一些公开的、非敏感的数据,加密只会增加开销。可以通过更细粒度的
@Encrypt注解来控制,或者设计一个@EncryptField注解,结合Jackson的序列化器,只加密实体类中的特定字段。 - 监控与审计:记录加解密失败的操作(如BadPaddingException),这可能是攻击尝试。监控接口的响应时间,如果因加解密导致性能瓶颈,需要考虑升级硬件或优化算法(如使用AES-NI硬件加速)。
6.3 与现有框架的兼容性
- Spring Security:本文的加解密层可以很好地与Spring Security共存。Spring Security处理认证授权,加解密层处理报文安全。执行顺序上,认证过滤器通常在最前面,然后是解密逻辑,最后才是业务Controller。
- Spring Cloud / OpenFeign:在微服务内部调用时,如果使用Feign,可以编写一个自定义的
FeignClient配置,为Feign客户端注入加解密的编解码器,原理与RestTemplate拦截器类似。 - Swagger/OpenAPI:接口文档需要特殊处理。因为文档工具无法感知你的加解密逻辑,它展示的仍然是明文DTO。你需要在文档中明确说明该接口需要加密传输,并可能提供一个“加密测试”功能,或者编写插件来模拟加解密过程。
整个方案实施下来,虽然前期有一定的工作量,但它为系统间的数据流动增加了一层坚实的安全屏障。尤其是在跨团队、跨公司的平台对接中,明确的数据加密规范能极大降低安全风险和数据泄露的担忧。最后一点个人体会是,加解密方案一旦上线,后期修改成本极高,因为涉及所有客户端同步更新。因此,在方案设计初期,务必充分评审,在密钥管理、算法选型、异常处理等方面考虑周全,并编写详尽的客户端对接文档。