
1. 项目概述为什么我们需要在HTTP接口上做AES加密最近在做一个跨平台的数据同步项目前端有Web、移动端App后端还有几个独立的微服务它们之间需要通过HTTP接口频繁交换数据。项目刚上线没多久安全团队就提了个醒虽然我们用了HTTPS但那只是通道加密数据在到达对方服务器内存之前是安全的。一旦数据到了对方手里就是明文。如果日志系统不小心把请求体打印出来了或者中间某个环节被恶意拦截敏感信息就暴露了。这让我意识到光有传输层安全TLS还不够我们还需要应用层的数据加密。这就是“SpringBoot项目不同平台通过HTTP接口AES加密传输”这个需求的由来。它要解决的核心问题是在HTTPS保障的传输通道之上再为业务数据本身加一把锁确保数据在发送方和接收方的内存之外始终保持密文状态实现端到端的业务数据安全。AES高级加密标准因其速度快、安全性高、标准化程度好成为实现这种“内容加密”的首选对称加密算法。简单来说这个方案就是在你的业务逻辑和HTTP客户端/服务器之间插入一个透明的加密/解密层。你的业务代码像往常一样处理明文对象而底层框架负责在发送前加密、在接收后解密。对于调用方无论是另一个SpringBoot服务、一个Vue前端还是一个移动端应用它们需要遵循同样的加密规则来组装和解析数据。2. 整体方案设计与核心思路拆解2.1 为什么是AES而不是RSA或直接HTTPS首先得理清几个概念。HTTPSTLS/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 EncryptedResponseT { /** * 状态码 */ private Integer code; /** * 提示信息 */ private String message; /** * 加密后的数据字符串。 * 当成功时这里是密文当失败时这里可以是null或错误详情明文。 */ private T encryptedData; /** * 时间戳 */ private Long timestamp; public static T EncryptedResponseT success(T encryptedData) { return new EncryptedResponse(200, success, encryptedData, System.currentTimeMillis()); } public static EncryptedResponseString 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和RequestBodyAdviceSpring提供了这两个接口可以让我们在消息转换器工作前后进行干预完美契合我们的需求。第一步实现请求解密 AdviceControllerAdvice 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; } }第二步实现响应加密 AdviceControllerAdvice public class EncryptResponseBodyAdvice implements ResponseBodyAdviceObject { 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(); // 获取原有的拦截器列表 ListClientHttpRequestInterceptor 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_asciiFalse) 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 bYour16ByteKey123 # 16字节 iv bYour16ByteIV4567 # 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 padded1. 客户端与服务端密钥不一致。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。你需要在文档中明确说明该接口需要加密传输并可能提供一个“加密测试”功能或者编写插件来模拟加解密过程。整个方案实施下来虽然前期有一定的工作量但它为系统间的数据流动增加了一层坚实的安全屏障。尤其是在跨团队、跨公司的平台对接中明确的数据加密规范能极大降低安全风险和数据泄露的担忧。最后一点个人体会是加解密方案一旦上线后期修改成本极高因为涉及所有客户端同步更新。因此在方案设计初期务必充分评审在密钥管理、算法选型、异常处理等方面考虑周全并编写详尽的客户端对接文档。