行业资讯
若依框架接口加密实战:RSA+AES混合加密方案详解
1. 项目概述为什么要在若依框架里折腾接口加密最近在重构一个基于若依框架RuoYi-Vue的后台管理系统客户对数据安全提出了明确要求所有敏感数据的传输必须加密。这让我不得不重新审视前后端分离架构下的接口安全问题。虽然HTTPS已经普及但它解决的是传输链路的安全数据到了服务器端依然是明文。对于一些核心业务数据比如用户身份证号、银行卡信息、交易金额等我们希望在应用层再加一道锁确保即便在复杂的网络环境中数据包被截获也无法被直接解读。这就是接口加密的价值所在。若依框架本身功能强大开箱即用但它并没有内置一套完整的、面向业务数据的接口加解密方案。市面上常见的做法是使用AES高级加密标准进行对称加密因为它加解密速度快适合对大量业务数据进行处理。但单纯使用AES密钥如何安全地传递给前端又是一个问题。因此一个更健壮的方案是结合RSA非对称加密来传递AES的密钥。这个项目就是要基于若依框架前后端分离版从零开始搭建这样一套“RSA传钥AES加密数据”的完整机制并解决Spring Boot拦截器、跨域配置等实际开发中必然会遇到的“坑”。2. 整体方案设计与核心思路拆解2.1 为什么选择 RSA AES 混合加密模式在决定技术方案前我们先理清几个核心问题。如果只用AES那么加解密双方必须持有同一个密钥。这个密钥如果硬编码在前端无异于“把钥匙挂在门上”毫无安全性可言。如果每次请求由后端动态生成一个AES密钥那这个新密钥又该如何安全地告诉前端呢这时非对称加密RSA就派上用场了。RSA的特点是有一对密钥公钥和私钥。公钥可以公开用来加密数据私钥必须严格保密用来解密数据。基于这个特性我们的混合加密流程就清晰了初始化阶段后端生成一对RSA密钥对将公钥RSAPublicKey通过一个安全的接口例如登录前的一个初始化接口暴露给前端。私钥RSAPrivateKey则牢牢保存在后端服务器绝不外传。前端加密流程前端随机生成一个AES密钥例如一个16或32位的随机字符串。用这个AES密钥对需要发送的请求体JSON数据进行加密得到密文cipherData。用从后端获取的RSA公钥对刚才生成的AES密钥本身进行加密得到加密后的密钥encryptedKey。将encryptedKey和cipherData一同发送给后端。有的方案还会加上时间戳、随机数等防止重放攻击。后端解密流程后端接收到请求后先用自己持有的RSA私钥解密encryptedKey得到原始的AES密钥。再用这个AES密钥去解密cipherData得到明文的请求数据然后进行正常的业务逻辑处理。响应时如果需要加密则可以用同一个AES密钥或新生成一个加密响应体前端用对应的AES密钥解密。这样每次会话或每次请求的AES密钥都是动态的并且通过RSA安全传输兼顾了安全性与性能。AES负责“干活”加密大量数据RSA负责“送钥匙”安全传递AES密钥分工明确。2.2 在若依框架中的技术落地点若依框架前后端分离版后端是标准的Spring Boot项目前端是Vue。我们的改造主要集中在后端。需要解决以下几个关键点加解密服务创建统一的工具类用于执行RSA密钥对生成、加解密以及AES的加解密。密钥管理RSA密钥对如何生成、存储不建议每次请求都重新生成通常应用启动时生成或使用固定密钥对。AES密钥是临时的存在于单次请求的上下文中。请求解密拦截器我们需要一个Servlet过滤器Filter或Spring MVC拦截器Interceptor在请求进入Controller之前对标记了需要加密的接口进行拦截完成解密操作并将解密后的数据重新放入请求流中。这里要特别注意拦截器与Spring Boot跨域配置addCorsMappings的优先级问题否则会导致跨域请求失败。响应加密处理器在Controller返回后对需要加密的响应数据进行加密处理。这可以通过实现ResponseBodyAdvice接口来优雅地完成。注解驱动为了灵活控制哪些接口需要加解密我们定义一个自定义注解例如Encrypt标注在Controller类或方法上。拦截器和处理器根据该注解的存在与否来决定是否执行加解密逻辑。前端适配需要编写对应的前端工具函数用于生成AES密钥、执行RSA加密AES密钥、以及用AES加密请求数据。这个方案的核心在于后端的拦截器和响应处理器它们构成了加解密的“网关”对业务代码几乎零侵入。3. 核心细节解析与实操要点3.1 RSA与AES工具类的关键实现首先我们创建加解密的核心工具类。这里以Java为例使用javax.crypto包下的类库。RSA工具类要点密钥生成与存储通常我们在应用启动时通过PostConstruct或CommandLineRunner生成一对RSA密钥并存入应用内存如静态变量或配置中心。绝对不要将私钥写在配置文件里提交到代码仓库。密钥格式生成的密钥是KeyPair对象但前端通常需要PEM格式的字符串。我们需要将PublicKey转换为Base64编码的字符串如PKCS#8格式提供给前端。可以使用Base64.getEncoder().encodeToString(key.getEncoded())。分段加密RSA有加密长度限制例如1024位密钥最多加密117字节。加密AES密钥通常16-32字节绰绰有余但如果你要加密更长的数据必须实现分段加密。在我们的场景里RSA只用于加密短的AES密钥所以不需要分段。AES工具类要点算法模式与填充推荐使用AES/CBC/PKCS5Padding。CBC模式需要初始化向量IVIV不需要保密但需要唯一性。通常可以将IV和密文一起传输。为了简化也可以使用固定的IV但安全性略低或者使用AES/GCM/NoPadding模式它同时提供了加密和认证。密钥长度支持128、192、256位。确保你的JRE支持无限制强度加密策略JCE否则256位可能报错。字符编码在将字符串转换为字节数组进行加密以及将加密后的字节数组转换为Base64字符串时务必统一使用UTF-8编码否则前后端会出现乱码导致解密失败。实操心得在AES工具类中务必处理好SecretKeySpec和IvParameterSpec的创建。一个常见的坑是从字符串生成密钥时直接使用getBytes()的长度可能不符合AES要求必须是16、24、32字节。安全的做法是使用MessageDigest如SHA-256对原始密钥字符串进行哈希然后取前N位作为真正的AES密钥。3.2 自定义注解与加解密元数据设计为了灵活控制我们定义两个注解// 用于标记需要加密响应的接口 Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface EncryptResponse { } // 用于标记需要解密请求体的接口通常加密响应的接口也需要解密请求 Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface DecryptRequest { }你可以将它们合并为一个Encrypt。注解可以加在Controller类上表示该控制器下所有方法生效或单个方法上。在拦截器和处理器中通过HandlerMethod和AnnotationUtils来检查当前请求的处理方法是否带有这些注解。3.3 请求解密拦截器Interceptor的深度剖析这是整个流程中最关键、也是最容易出错的一环。我们需要实现HandlerInterceptor接口并重写preHandle方法。核心步骤判断是否需要解密通过请求URI或注解判断当前请求是否需要进行解密。通常我们只对POST、PUT等带有请求体的方法进行解密GET、DELETE可以忽略。读取请求体HttpServletRequest的输入流getInputStream()只能读取一次。我们需要通过包装请求使用ContentCachingRequestWrapper或自定义HttpServletRequestWrapper来缓存请求体数据以便多次读取。解析参数从请求中获取加密后的AES密钥通常放在请求头X-Encrypt-Key中和加密的请求体请求Body。执行解密用RSA私钥解密encryptedKey得到AES密钥再用此AES密钥解密请求体得到原始JSON字符串。替换请求体将解密后的JSON字符串重新设置到被包装的Request对象的输入流中。这样后续的Spring MVC参数解析器如RequestBody读取到的就是明文数据了。一个巨大的“坑”拦截器与跨域配置的优先级若依框架或Spring Boot中我们常用WebMvcConfigurer的addCorsMappings方法来配置跨域。这个配置会生成一个CorsFilter。Filter的执行顺序在Interceptor之前。问题来了当浏览器发送一个复杂请求如Content-Type为application/json的POST请求时会先发一个OPTIONS预检请求。这个请求没有我们自定义的加密头如X-Encrypt-Key。如果我们的解密拦截器在preHandle中判断所有带特定路径的请求都需要解密那么对于OPTIONS请求它因找不到加密头而可能直接返回错误导致预检失败后续真正的POST请求也无法发出。解决方案有两种在拦截器中放行OPTIONS请求在preHandle方法最开始判断如果请求方法是OPTIONS直接返回true。if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; }使用Filter并注意顺序将解密逻辑放在Filter中实现并通过Order注解或FilterRegistrationBean明确指定Filter的执行顺序确保它在CorsFilter之后执行。但Filter中获取Spring Bean如注解元数据比拦截器麻烦。推荐使用第一种方案在拦截器中处理简单有效。4. 实操过程与核心环节实现4.1 后端核心代码实现步骤步骤一创建加解密工具类创建RSAUtil和AESUtil类包含密钥生成、加密、解密等静态方法。这里给出AESUtil的核心片段public class AESUtil { private static final String ALGORITHM AES; private static final String TRANSFORMATION AES/CBC/PKCS5Padding; // 使用CBC模式 private static final String CHARSET UTF-8; /** * AES加密 * param data 明文 * param key 密钥 (16, 24, 32字节) * param iv 初始化向量 (16字节) * return Base64编码的密文 */ public static String encrypt(String data, String key, String iv) throws Exception { Cipher cipher Cipher.getInstance(TRANSFORMATION); SecretKeySpec keySpec new SecretKeySpec(key.getBytes(CHARSET), ALGORITHM); IvParameterSpec ivSpec new IvParameterSpec(iv.getBytes(CHARSET)); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encryptedBytes cipher.doFinal(data.getBytes(CHARSET)); return Base64.getEncoder().encodeToString(encryptedBytes); } // 省略decrypt方法... }步骤二实现请求解密拦截器创建DecryptInterceptor实现HandlerInterceptor。Component public class DecryptInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 放行OPTIONS预检请求 if (HttpMethod.OPTIONS.matches(request.getMethod())) { return true; } // 2. 判断是否为HandlerMethod且是否需要解密 if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod (HandlerMethod) handler; boolean needDecrypt checkIfNeedDecrypt(handlerMethod); // 检查DecryptRequest注解 if (!needDecrypt) { return true; } // 3. 包装Request缓存请求体 if (!(request instanceof ContentCachingRequestWrapper)) { request new ContentCachingRequestWrapper(request); } // 4. 获取加密密钥和密文 String encryptedKey request.getHeader(X-Encrypt-Key); String encryptedData getRequestBody(request); // 从包装的Request中读取Body if (StringUtils.isBlank(encryptedKey) || StringUtils.isBlank(encryptedData)) { throw new RuntimeException(加密参数缺失); } // 5. RSA解密得到AES密钥 String aesKey RSAUtil.decryptByPrivateKey(encryptedKey, RSA_PRIVATE_KEY); // 假设IV通过Header传递字段为X-IV String iv request.getHeader(X-IV); // 6. AES解密得到明文 String decryptedData AESUtil.decrypt(encryptedData, aesKey, iv); // 7. 关键将解密后的数据重新设置回Request // 这里需要自定义一个Wrapper重写getInputStream和getReader方法 request new DecryptHttpServletRequestWrapper(request, decryptedData.getBytes()); // 8. 将包装后的request对象设置到属性中以便后续获取需要强制转换 // 注意这里无法直接替换Request对象通常的做法是使用Filter或者在参数解析器层面处理。 // 更常见的做法是在拦截器中解密后将明文数据存入Request Attribute然后自定义一个ArgumentResolver来读取Attribute并注入到Controller参数中。 // 这是一个简化示例实际生产环境更复杂。 request.setAttribute(DECRYPTED_BODY, decryptedData); return true; } // 其他方法... }重要提示上面的第7、8步是简化概念。在实际中直接在拦截器里替换HttpServletRequest对象是非常棘手且不推荐的因为Spring的请求处理链已经对原始Request进行了包装。更优雅和通用的做法是使用Filter在过滤器链的最前端用自定义Wrapper缓存请求体在后续的Filter或Interceptor中进行解密并修改Wrapper中的内容。这能确保Controller拿到的是解密后的流。自定义RequestBodyAdvice实现RequestBodyAdvice接口在RequestBody参数被解析之前介入进行解密操作。这种方式与Spring MVC的集成度更高但需要更深入的理解。步骤三实现响应加密处理器创建EncryptResponseAdvice实现ResponseBodyAdvice。RestControllerAdvice public class EncryptResponseAdvice implements ResponseBodyAdviceObject { Override public boolean supports(MethodParameter returnType, Class converterType) { // 判断该方法或类是否有EncryptResponse注解 return returnType.hasMethodAnnotation(EncryptResponse.class) || returnType.getContainingClass().hasAnnotation(EncryptResponse.class); } Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { // 1. 从Request Header中获取本次请求使用的AES密钥和IV应由前端在请求时传来后端缓存于本次请求上下文如ThreadLocal String aesKey RequestContextHolder.getCurrentRequestAttributes().getAttribute(CURRENT_AES_KEY, RequestAttributes.SCOPE_REQUEST); String iv ...; // 类似方式获取 // 2. 将响应体对象序列化为JSON字符串 ObjectMapper mapper new ObjectMapper(); String originalData mapper.writeValueAsString(body); // 3. 使用AES加密 String encryptedData AESUtil.encrypt(originalData, aesKey, iv); // 4. 返回一个统一的加密响应包装对象 MapString, String result new HashMap(); result.put(encryptedData, encryptedData); // 如果需要可以返回新的IV如果每次响应使用新的IV // result.put(iv, newIv); return result; } }步骤四注册拦截器与提供密钥接口在若依框架的配置类或新建一个WebMvcConfig中注册拦截器并提供一个接口用于获取RSA公钥。Configuration public class WebConfig implements WebMvcConfigurer { Autowired private DecryptInterceptor decryptInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(decryptInterceptor) .addPathPatterns(/**) // 拦截所有路径 .excludePathPatterns(/getPublicKey); // 排除获取公钥的接口 } RestController public class KeyController { GetMapping(/getPublicKey) public Result getPublicKey() { // 返回Base64编码的RSA公钥字符串 String publicKeyStr RSAUtil.getPublicKeyStr(); return Result.success(publicKeyStr); } } }4.2 前端Vue适配实现前端需要在Axios拦截器中实现自动加解密。初始化获取RSA公钥在应用启动或用户登录前调用/getPublicKey接口获取公钥并存储。请求拦截器判断当前请求配置中是否需要加密可以自定义一个needEncrypt参数。如果需要加密则随机生成AES密钥和IV。用AES加密请求数据JSON.stringify(data)。用RSA公钥加密AES密钥。将加密后的AES密钥放入请求头X-Encrypt-KeyIV放入X-IV将加密后的数据作为请求体发送。同时将本次生成的AES密钥和IV临时存储起来例如放在一个Map里以请求的某种唯一标识为Key以备解密响应时使用。响应拦截器判断响应头或响应数据结构是否表明数据被加密例如返回的数据是一个包含encryptedData字段的对象。如果是加密响应则根据当前请求的唯一标识取出之前存储的AES密钥和IV。用AES解密encryptedData字段得到明文的JSON字符串再解析为JavaScript对象替换原始的响应数据这样业务代码收到的就是解密后的数据了。5. 常见问题与排查技巧实录在实际整合过程中我遇到了不少问题这里记录下最典型的几个及其解决方案。5.1 跨域CORS请求失败尤其是OPTIONS预检请求现象前端控制台报CORS错误网络请求中能看到OPTIONS请求返回了非2xx状态码如400或500。排查首先确认Spring Boot的跨域配置已正确设置addCorsMappings允许了对应的来源、头和方法。检查解密拦截器的preHandle方法。如果它拦截了OPTIONS请求并试图从中读取加密头会因为找不到而抛出异常导致请求被拦截并返回错误。解决如3.3节所述在拦截器preHandle方法的最开始添加对OPTIONS方法的判断并直接放行。if (HttpMethod.OPTIONS.matches(request.getMethod())) { return true; }5.2 解密失败InvalidKeyException 或 BadPaddingException现象后端日志抛出加密相关的异常如InvalidKeyException: Illegal key size或BadPaddingException: Given final block not properly padded。排查与解决Illegal key size这是经典的JCE限制问题。你需要为你的JRE安装“Java Cryptography Extension (JCE) Unlimited Strength Jurisdiction Policy Files”。去Oracle官网下载对应你JDK版本的包替换$JAVA_HOME/jre/lib/security/下的两个jar文件local_policy.jar和US_export_policy.jar。或者如果你使用的是较新的JDK如8u161及以上可以在启动参数中添加-Djava.security.egdfile:/dev/./urandom并确保使用的是UnlimitedStrength策略默认已开启但最好确认。BadPaddingException这是最常见的解密错误原因多样。密钥不一致前端用于加密的AES密钥和后端用于解密的AES密钥不同。检查RSA解密encryptedKey的过程是否正确公私钥是否配对。确保前端使用的是从/getPublicKey接口拿到的、最新的公钥。IV不一致CBC模式必须使用相同的IV。确保前端生成的IV通过Header如X-IV完整地传给了后端并且后端解密时使用的是同一个IV。IV必须是16字节128位。数据被篡改或编码问题在传输过程中Base64字符串中的、/、等特殊字符可能被URL编码或修改。确保前后端对加密后的字符串进行安全的传输例如使用URL安全的Base64编码或将整个密文放在请求Body中而非URL参数。同时确保前后端字符编码统一为UTF-8。填充模式不匹配前端使用的填充模式必须和后端一致。都使用PKCS5Padding在AES中PKCS5Padding和PKCS7Padding是等价的。5.3 拦截器获取不到请求体InputStream只能读一次现象在拦截器中调用request.getInputStream()读取数据后后续的Controller中RequestBody接收到的是空值。原因HttpServletRequest的输入流默认只能被读取一次。解决使用包装类。Spring提供了ContentCachingRequestWrapper但它通常需要提前触发读取才能缓存。更可靠的做法是自定义一个HttpServletRequestWrapper在构造函数中读取并缓存请求体的字节数组然后重写getInputStream()和getReader()方法返回基于缓存数据的新流。public class DecryptHttpServletRequestWrapper extends HttpServletRequestWrapper { private final byte[] body; public DecryptHttpServletRequestWrapper(HttpServletRequest request, byte[] body) { super(request); this.body body; } Override public ServletInputStream getInputStream() throws IOException { ByteArrayInputStream byteArrayInputStream new ByteArrayInputStream(body); return new ServletInputStream() { // 实现所有抽象方法基于byteArrayInputStream Override public int read() throws IOException { return byteArrayInputStream.read(); } Override public boolean isFinished() { ... } Override public boolean isReady() { ... } Override public void setReadListener(ReadListener listener) { ... } }; } // 同样需要重写getReader... }然后在拦截器中用这个Wrapper替换原来的Request。但如前所述在Interceptor中直接替换Request对象很难影响到后续流程。因此更生产级的做法是将解密逻辑放在一个Filter中并在Filter的最开始就用这个Wrapper包装原始Request。5.4 响应加密后前端收到的不是JSON对象现象前端收到响应但response.data是一个包含encryptedData字符串的对象而不是业务数据导致前端业务逻辑出错。排查这是因为响应加密处理器EncryptResponseAdvice将整个响应体替换成了{“encryptedData”: “xxx”}的结构。前端需要先解密这个字符串才能得到真正的业务数据JSON。解决前端必须在响应拦截器中做统一处理。判断如果响应数据包含encryptedData字段则先进行AES解密再将解密后的JSON字符串解析为对象最后将解析后的对象赋值给response.data这样后续的.then(res {...})中收到的res.data就是明文的业务数据了。5.5 性能考量与优化建议RSA加解密开销RSA运算比AES慢得多。避免在每次请求中都进行RSA加解密。我们只在每次会话初始化或登录时用RSA传递一次AES密钥。之后的一段会话期内例如通过Token关联都使用这个AES密钥进行通信。可以在后端将sessionId或token与对应的AES密钥关联缓存起来如放在Redis中设置合理的过期时间。密钥管理RSA密钥对可以定期更换如每天但不宜过于频繁。AES会话密钥应在用户登出或过期时清除。非加密接口对于登录、获取公钥等接口本身以及一些不敏感的数据查询接口如公开信息不要使用加密以节省资源。通过注解灵活控制。监控与日志加解密过程可能失败需要记录详细的日志但切勿日志记录密钥或明文数据并做好异常处理给前端返回明确的错误码和信息方便排查。同时监控加解密接口的响应时间确保不影响用户体验。这套方案实施下来虽然前期需要一定的开发工作量但一旦整合完毕对业务代码的侵入性极小通过注解即可控制为若依框架构建的系统提供了强有力的数据传输安全加固。它不仅仅是功能的实现更是一种安全设计思维的落地。在实际项目中还需要结合具体的业务安全等级要求考虑加入签名防篡改、时间戳防重放等更多安全机制。
郑州网站建设
网页设计
企业官网