1. 项目概述为什么我们需要混合加密在前后端分离架构成为主流的今天API接口的安全性是一个绕不开的话题。我们每天都在处理登录、支付、敏感信息查询等操作这些请求和响应数据在网络上裸奔无异于将隐私暴露在光天化日之下。传统的HTTPSTLS/SSL提供了传输层的安全但它是一种通道加密数据到达服务器后是明文的。一旦服务器被入侵或者需要审计日志日志中若记录明文密码将是灾难问题就暴露了。这就是应用层加密的价值所在即使传输通道和服务器存储被窥探攻击者拿到的也是一堆无法直接理解的密文。而单一的加密算法往往难以兼顾所有场景。比如非对称加密如RSA安全但性能差不适合加密大量数据对称加密如AES速度快但密钥如何安全地传递给对方是个难题。RuoYi-Vue-Plus框架给出的答案是JSEncryptRSA AES混合加密。这个方案的精妙之处在于“各司其职”。简单来说就是用RSA来安全地传递AES的密钥再用AES来高效地加密实际的业务数据。这就像你要寄送一份绝密文件业务数据先把它装进一个坚固的保险箱AES加密但保险箱的钥匙AES密钥怎么寄给对方呢你再把钥匙放进一个只有对方才能打开的密码盒RSA加密里一起寄过去。对方收到后先用自己的私钥打开密码盒拿到钥匙再用钥匙打开保险箱拿到文件。这套方案在 RuoYi-Vue-Plus 中不是简单的功能堆砌而是深度集成到了其请求拦截器、响应处理器和后端加解密服务中形成了一套开箱即用、配置灵活的安全通信体系。接下来我将带你深入这套体系的设计思路、落地细节以及那些官方文档可能没写的“坑”。2. 核心设计思路与架构拆解2.1 混合加密流程全景图要理解这套机制必须先把整个数据流转的脉络理清楚。它不是一个点对点的加密而是一个包含密钥协商和数据加密的完整闭环。前端初始化获取RSA公钥前端应用Vue启动时首先调用一个无需加密的接口如/getPublicKey从后端获取一对RSA密钥的公钥部分。这个公钥是公开的用于加密前端将其保存下来通常放在内存或Vuex/Store中。前端生成会话密钥当需要发送敏感请求如登录前前端在内存中随机生成一个高质量的AES密钥例如一个32字节的随机字符串对应AES-256和一个随机初始向量IV用于CBC等模式。密钥的“安全投递”前端使用之前获取的RSA公钥对刚刚生成的AES密钥和IV进行加密。因为RSA加密有长度限制通常需要分段或采用混合加密模式但JSEncrypt库内部会处理这些细节。加密后得到一段密文我们称之为encryptedAesKey。业务数据的“装箱”前端使用生成的AES密钥和IV对实际的业务请求参数JSON对象进行AES加密。这里通常会将JSON字符串化后进行加密得到一个密文字符串encryptedData。组合请求体前端将encryptedAesKey和encryptedData组合成一个新的JSON对象作为本次请求的data发送给后端。为了区分通常还会增加一个标识字段如encrypted: true。后端解密“密码盒”后端控制器接收到请求后通过标识判断是否为加密请求。如果是则使用与之前下发公钥配对的RSA私钥这个私钥必须安全地存储在后端如配置文件或密钥管理服务中对encryptedAesKey进行解密还原出明文的AES密钥和IV。后端解密“保险箱”后端使用解密得到的AES密钥和IV对encryptedData进行AES解密得到明文的业务参数字符串再反序列化为JSON对象之后就可以正常进行业务逻辑处理了。响应加密可选处理完成后后端可以用同一个AES密钥对响应数据进行加密前端再用内存中的AES密钥解密。这样就实现了请求和响应的双向加密。注意这个AES会话密钥通常是“一次一密”的即每次重要请求如登录都生成新的用完即弃。对于后续的非敏感请求可以沿用这个密钥也可以不加密这取决于你的安全策略。2.2 RuoYi-Vue-Plus 的集成设计RuoYi-Vue-Plus 巧妙地将上述流程封装起来对开发者几乎透明。前端/utils/request.js核心在axios的请求拦截器 (request.interceptors.request.use)。拦截器会判断当前请求配置是否需要加密可通过自定义headers如isEncrypt: true或URL白名单判断。如果需要则自动执行上述步骤2-5生成混合加密的请求体。响应拦截器则负责判断响应是否加密并进行对应的AES解密。后端EncryptFilter或Decrypt注解采用过滤器Filter或AOP切面的方式对请求进行统一拦截。过滤器会检查请求头或参数中的加密标识然后调用RSAUtil和AESUtil工具类进行解密并将解密后的参数重新放入请求中后续的Controller接收到的就是明文数据了。对于响应同样可以通过过滤器或注解进行加密。配置化是否启用加密、RSA密钥对的位置、AES的模式CBC/ECB和填充方式PKCS5Padding/PKCS7Padding等都可以在application.yml中灵活配置实现了策略与代码的分离。这种设计的好处是业务开发者在写一个登录接口时只需要关注PostMapping(/login)和接收LoginBody对象完全不用在业务代码里写一行加解密逻辑安全与业务得到了解耦。3. 核心工具类深度解析与实操要点理解了流程我们来看看实现这些流程的核心工具。这里藏着许多性能和安全的“魔鬼细节”。3.1 前端JSEncrypt 与 CryptoJS 的协同作战前端加密通常依赖两个库jsencrypt用于RSA和crypto-js用于AES。安装与引入npm install jsencrypt crypto-js --save关键实现代码剖析import JSEncrypt from jsencrypt import CryptoJS from crypto-js class HybridEncrypt { constructor(publicKey) { this.rsaEncryptor new JSEncrypt() this.rsaEncryptor.setPublicKey(publicKey) // 设置后端下发的公钥 this.aesKey null // 临时会话AES密钥 this.aesIv null // 临时会话IV } // 生成随机的AES密钥和IV generateAesSessionKey() { // AES-256 需要32字节的密钥 const randomWords CryptoJS.lib.WordArray.random(32) // 32字节 256位 this.aesKey CryptoJS.enc.Hex.parse(randomWords.toString(CryptoJS.enc.Hex)) // CBC模式需要16字节的IV const randomWordsIv CryptoJS.lib.WordArray.random(16) this.aesIv CryptoJS.enc.Hex.parse(randomWordsIv.toString(CryptoJS.enc.Hex)) // 将密钥和IV转为Base64字符串方便用RSA加密 const keyStr CryptoJS.enc.Base64.stringify(this.aesKey) const ivStr CryptoJS.enc.Base64.stringify(this.aesIv) return { keyStr, ivStr } } // 用RSA公钥加密AES密钥和IV encryptAesKeyWithRSA(aesKeyInfo) { // 通常将密钥和IV拼接后一起加密如 key:iv const plainText ${aesKeyInfo.keyStr}:${aesKeyInfo.ivStr} // JSEncrypt 内部处理PKCS#1填充和长文本分段 const encrypted this.rsaEncryptor.encrypt(plainText) if (!encrypted) { throw new Error(RSA加密失败请检查公钥格式是否正确是否包含-----BEGIN PUBLIC KEY-----头尾) } return encrypted } // 用AES加密业务数据 encryptDataWithAES(data) { if (!this.aesKey || !this.aesIv) { throw new Error(请先生成AES会话密钥) } // 将业务对象转为JSON字符串 const dataStr typeof data string ? data : JSON.stringify(data) // 使用CBC模式PKCS7填充CryptoJS的PKCS7与PKCS5在AES上等效 const encrypted CryptoJS.AES.encrypt(dataStr, this.aesKey, { iv: this.aesIv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }) // 输出为Base64格式的密文 return encrypted.toString() } // 解密后端返回的AES加密数据 decryptDataWithAES(encryptedBase64Str) { const decrypt CryptoJS.AES.decrypt(encryptedBase64Str, this.aesKey, { iv: this.aesIv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }) const decryptedStr decrypt.toString(CryptoJS.enc.Utf8) try { return JSON.parse(decryptedStr) } catch (e) { // 如果不是JSON直接返回字符串 return decryptedStr } } }实操要点与避坑指南RSA公钥格式这是第一个大坑。后端生成的公钥通常是PEM格式包含-----BEGIN PUBLIC KEY-----头和-----END PUBLIC KEY-----尾。JSEncrypt需要完整的PEM格式字符串。确保前端获取后原样设置不要自行去除头尾或换行符。AES密钥长度与随机性AES-256的安全性建立在密钥的随机性和不可预测性上。务必使用CryptoJS.lib.WordArray.random()或window.crypto.getRandomValues()这类密码学安全的随机数生成器绝对不能用Math.random()或固定字符串。IV初始化向量的重要性在CBC、CFB等模式下IV必须随机且每次加密都不同。重复使用相同的密钥和IV会严重削弱安全性。这就是为什么我们在每次会话中都重新生成IV。数据编码CryptoJS内部操作的是WordArray对象与字符串之间的转换需要指定编码如Hex,Base64,Utf8。加解密过程中必须保持编码一致否则会得到乱码或解密失败。通常网络传输使用Base64。错误处理JSEncrypt.encrypt()在公钥格式错误或数据过长时可能返回false或null必须进行判断。3.2 后端Java 端的 RSA 与 AES 实现后端以Spring Boot为例通常使用javax.crypto包或BouncyCastle提供者。RSA 工具类关键代码import javax.crypto.Cipher; import java.security.*; import java.util.Base64; public class RSAUtil { private static final String TRANSFORMATION RSA/ECB/PKCS1Padding; private PrivateKey privateKey; private PublicKey publicKey; // 初始化从配置文件或keystore加载密钥对 public void initKeys(String privateKeyStr, String publicKeyStr) throws Exception { // 这里省略了从PEM字符串加载PrivateKey和PublicKey的详细代码 // 通常需要使用 KeyFactory 和 PKCS8EncodedKeySpec / X509EncodedKeySpec } // 用私钥解密用于解密前端传来的加密AES密钥 public String decryptWithPrivateKey(String encryptedBase64) throws Exception { Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, privateKey); byte[] encryptedBytes Base64.getDecoder().decode(encryptedBase64); byte[] decryptedBytes cipher.doFinal(encryptedBytes); return new String(decryptedBytes, StandardCharsets.UTF_8); } // 用公钥加密可用于生成前端所需的公钥或加密返回给前端的敏感信息 public String encryptWithPublicKey(String data) throws Exception { Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, publicKey); byte[] encryptedBytes cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encryptedBytes); } }AES 工具类关键代码import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.util.Base64; public class AESUtil { private static final String ALGORITHM AES; private static final String TRANSFORMATION AES/CBC/PKCS5Padding; // 与前端CryptoJS的PKCS7Padding兼容 // 解密业务数据 public static String decrypt(String data, String keyStr, String ivStr) throws Exception { byte[] keyBytes Base64.getDecoder().decode(keyStr); byte[] ivBytes Base64.getDecoder().decode(ivStr); byte[] dataBytes Base64.getDecoder().decode(data); SecretKeySpec secretKeySpec new SecretKeySpec(keyBytes, ALGORITHM); IvParameterSpec ivParameterSpec new IvParameterSpec(ivBytes); Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec); byte[] decryptedBytes cipher.doFinal(dataBytes); return new String(decryptedBytes, StandardCharsets.UTF_8); } // 加密响应数据 public static String encrypt(String data, String keyStr, String ivStr) throws Exception { // ... 加密过程与解密对称模式改为 Cipher.ENCRYPT_MODE } }后端实操要点密钥管理RSA私钥是安全的核心绝不能硬编码在代码中。应该放在服务器的环境变量、配置中心如Apollo、Nacos或专业的密钥管理服务KMS中。生产环境甚至可以考虑使用HSM硬件安全模块。算法与模式确保前后端的算法、模式、填充方式完全一致。AES前端CryptoJS.mode.CBCCryptoJS.pad.Pkcs7对应后端AES/CBC/PKCS5Padding在AES语境下PKCS5Padding和PKCS7Padding是等价的。RSA前端JSEncrypt默认使用RSAES-PKCS1-V1_5方案对应后端RSA/ECB/PKCS1Padding。注意这里的“ECB”是RSA的模式与AES的ECB不同RSA的ECB模式是标准叫法不存在AES-ECB的安全问题。Base64编码网络传输使用Base64编码但要注意Java中Base64.getEncoder()与前端btoa()或CryptoJS.enc.Base64的兼容性。通常使用java.util.Base64类即可。异常处理解密过程可能因密钥错误、数据被篡改、填充错误等原因抛出BadPaddingException、IllegalBlockSizeException等异常。必须妥善捕获并转化为统一的业务异常如“请求数据非法”避免将底层加密库的异常信息暴露给前端这本身也是一种信息泄露。4. 在 RuoYi-Vue-Plus 中的配置与实战落地RuoYi-Vue-Plus 将上述理论工程化了。我们来看看如何在一个新项目中启用和配置它。4.1 后端配置与启用检查依赖确保ruoyi-common模块中已经包含了加解密相关的工具类RSAUtil,AESUtil和过滤器EncryptFilter或注解Decrypt,Encrypt。配置密钥在application.yml中配置RSA密钥对。密钥对可以通过在线工具或KeyPairGenerator生成。# 安全配置 security: encrypt: enabled: true # 启用加解密过滤器 rsa: private-key: 你的RSA私钥PEM字符串注意换行符要用\n表示 public-key: 你的RSA公钥PEM字符串 aes: mode: CBC padding: PKCS5Padding启用过滤器/注解过滤器方式如果使用EncryptFilter确保它在SecurityConfig或FilterConfig中被注册并配置需要拦截的URL模式如/api/**。过滤器会自动处理请求和响应。注解方式更灵活。在Controller的方法参数上使用Decrypt注解在方法上或返回值上使用Encrypt注解。需要在配置类上启用AOP支持EnableAspectJAutoProxy并有一个对应的切面EncryptAspect来处理这些注解。编写一个提供公钥的接口RestController public class KeyController { Value(${security.encrypt.rsa.public-key}) private String publicKey; GetMapping(/getPublicKey) public RString getPublicKey() { // 注意这个接口本身不应该被加密过滤器拦截 return R.ok(publicKey); } }4.2 前端请求封装获取并存储公钥在应用入口如main.js或App.vue或请求工具初始化时调用/getPublicKey接口将公钥存储到全局状态如Vuex或一个单例的加密工具实例中。改造请求工具request.js这是核心步骤。在axios的请求拦截器中判断哪些请求需要加密。import axios from axios import { getPublicKeyFromStore } from /store // 假设公钥存在store里 import HybridEncrypt from /utils/hybrid-encrypt // 前面封装的类 const service axios.create({...}) let encryptor null // 初始化加密器 const initEncryptor async () { if (!encryptor) { const pubKey await fetchPublicKey() // 获取公钥 encryptor new HybridEncrypt(pubKey) } } service.interceptors.request.use(async config { await initEncryptor() // 判断是否需要加密可以通过config.headers设置标识或根据URL白名单判断 if (config.isEncrypt || needEncrypt(config.url)) { // 1. 生成本次会话的AES密钥和IV const aesSession encryptor.generateAesSessionKey() // 2. 用RSA公钥加密AES密钥信息 const encryptedKey encryptor.encryptAesKeyWithRSA(aesSession) // 3. 用AES加密业务数据 const originalData config.data const encryptedData encryptor.encryptDataWithAES(originalData) // 4. 重组请求数据 config.data { encryptedKey: encryptedKey, data: encryptedData, // 可以加一个时间戳或随机数防重放 timestamp: new Date().getTime() } // 5. 可选设置请求头标识告知后端这是一个加密请求 config.headers[X-Request-Encrypted] true // 6. 重要将本次生成的AES密钥和IV临时存储用于解密本次请求的响应 // 可以存在本次请求的config对象上或者一个以timestamp为key的Map里 config._aesSession aesSession } return config }, error {...}) service.interceptors.response.use(response { const config response.config // 判断响应是否加密可以通过响应头如 X-Response-Encrypted if (response.headers[x-response-encrypted] true) { const encryptedData response.data.data const aesSession config._aesSession // 取出请求时存储的密钥 if (aesSession encryptor) { // 临时设置解密密钥 encryptor.aesKey CryptoJS.enc.Base64.parse(aesSession.keyStr) encryptor.aesIv CryptoJS.enc.Base64.parse(aesSession.ivStr) const decryptedData encryptor.decryptDataWithAES(encryptedData) response.data decryptedData // 将解密后的数据替换原始响应数据 } } return response }, error {...})在业务中调用之后在Vue组件或Store中发起请求时只需要在需要加密的请求配置中加上标识即可。// 登录请求需要加密 export function login(data) { return request({ url: /auth/login, method: post, data: data, isEncrypt: true // 触发加密拦截器 }) } // 获取公开信息不需要加密 export function getPublicInfo() { return request({ url: /api/public/info, method: get }) }4.3 一个完整的登录接口示例前端Login.vuesubmitForm() { this.$refs.form.validate(valid { if (valid) { login(this.loginForm).then(res { // res.data 已经是响应拦截器解密后的明文数据了 console.log(登录成功, res.data) }) } }) }后端SysLoginController.javaRestController public class SysLoginController { PostMapping(/auth/login) // 使用注解方式参数自动解密返回值自动加密 // Decrypt 注解在参数上表示需要解密LoginBody // Encrypt 注解在方法上表示返回值需要加密 Encrypt public RLoginUser login(Decrypt RequestBody LoginBody loginBody) { String username loginBody.getUsername(); String password loginBody.getPassword(); // ... 验证用户名密码逻辑 LoginUser loginUser loginService.login(username, password); return R.ok(loginUser); } }整个过程中开发者接触到的loginForm、loginBody、返回的RLoginUser都是明文对象加解密过程由框架在“水下”完成极大地简化了开发。5. 常见问题、性能考量与安全进阶5.1 常见问题排查表问题现象可能原因排查步骤前端RSA加密失败encrypt返回false1. 公钥格式错误缺少头尾、换行符丢失。2. 公钥与加密算法不匹配如用了非RSA公钥。3. 待加密数据过长RSA有长度限制但JSEncrypt应自动处理。1. 控制台打印获取到的公钥字符串检查是否完整。2. 使用在线PEM解析工具验证公钥。3. 检查JSEncrypt版本确保其支持长文本分段加密。后端RSA解密失败报BadPaddingException1. 私钥与公钥不配对。2. 前端传过来的encryptedKeyBase64格式损坏或传输中被修改。3. 前后端RSA填充模式不一致。1. 核对配置文件中的私钥是否与生成公钥的私钥配对。2. 在后端记录接收到的encryptedKey与前端发送的进行比对。3. 确认后端Cipher.getInstance(“RSA/ECB/PKCS1Padding”)与前端JSEncrypt默认模式一致。AES解密失败报BadPaddingException或得到乱码1. 前后端AES密钥、IV不一致。2. 前后端AES模式/填充不一致。3. 密文在传输中被篡改或Base64解码错误。4. 前端加密后后端解密前数据被额外编码/解码如URL编码。1.关键在后端解密AES密钥后打印出解密得到的明文key和iv与前端生成的进行比对。2. 确认模式都是CBC填充都是PKCS7/PKCS5。3. 检查网络请求查看实际传输的data字段值是否一致。4. 检查是否有全局的HttpMessageConverter对请求体做了处理。响应解密失败1. 前端用于解密响应的AES密钥不是加密请求时用的那个会话丢失。2. 后端加密响应和前端解密响应的密钥不一致。3. 响应数据被网关、过滤器额外处理。1. 确保前端将请求时的AES会话密钥与请求绑定并在响应时能准确取出。2. 后端加密响应时必须使用解密请求时得到的那个AES密钥不能重新生成。3. 检查Nginx等网关是否对响应体做了压缩或修改。性能明显下降1. RSA加解密是CPU密集型操作频繁使用导致。2. 对所有请求都启用了加密包括静态资源、图片等。1. 采用“一次一密”的会话密钥一个会话内只用一次RSA后续用AES。2.最重要配置加密白名单。只对/auth/login,/user/updatePassword等真正敏感的API启用加密。公开数据、文件上传下载等接口不要加密。5.2 性能优化与安全进阶建议会话复用对于登录后的后续敏感请求如修改个人信息、交易不必每次都走完整的RSAAES流程。可以在登录成功后由服务端生成一个随机的sessionAesKey通过登录接口的加密响应返回给前端用登录时的会话AES密钥加密。之后前端用这个sessionAesKey来加密后续请求服务端从Session或缓存中取出对应的sessionAesKey解密。这样避免了频繁的RSA运算。防重放攻击在加密请求体中加入时间戳timestamp和随机数nonce。服务端收到请求后校验时间戳是否在合理窗口内如5分钟并检查该随机数在窗口内是否已被使用过可用缓存实现从而防止请求被拦截后重复发送。密钥轮转RSA密钥对不应永久使用。应制定策略定期如每季度更换密钥对。更换时需要有一个过渡期新老公钥同时有效前端逐步升级。非对称加密算法升级RSA 2048位目前仍安全但从长远看可考虑使用更现代的椭圆曲线加密ECC如ECDH密钥交换对称加密在相同安全强度下ECC的密钥更短计算更快。启用HTTPS这是大前提应用层加密RSAAES绝不能替代HTTPS。HTTPS提供了身份认证防中间人、通道加密和完整性校验。应用层加密是在HTTPS之上为业务数据提供的额外保护主要用于防范服务器侧的数据泄露和满足审计要求。这套JSEncrypt AES的混合加密方案在 RuoYi-Vue-Plus 的实践中被证明是兼顾安全性、性能和开发效率的优雅方案。它把复杂的安全逻辑封装在框架底层让业务开发者可以更专注于功能实现。然而安全是一个持续的过程理解和掌握其背后的原理、熟练进行问题排查、并根据实际业务场景进行优化和加固才是用好这套方案的关键。
RSA+AES混合加密在前后端API安全通信中的原理与实践
1. 项目概述为什么我们需要混合加密在前后端分离架构成为主流的今天API接口的安全性是一个绕不开的话题。我们每天都在处理登录、支付、敏感信息查询等操作这些请求和响应数据在网络上裸奔无异于将隐私暴露在光天化日之下。传统的HTTPSTLS/SSL提供了传输层的安全但它是一种通道加密数据到达服务器后是明文的。一旦服务器被入侵或者需要审计日志日志中若记录明文密码将是灾难问题就暴露了。这就是应用层加密的价值所在即使传输通道和服务器存储被窥探攻击者拿到的也是一堆无法直接理解的密文。而单一的加密算法往往难以兼顾所有场景。比如非对称加密如RSA安全但性能差不适合加密大量数据对称加密如AES速度快但密钥如何安全地传递给对方是个难题。RuoYi-Vue-Plus框架给出的答案是JSEncryptRSA AES混合加密。这个方案的精妙之处在于“各司其职”。简单来说就是用RSA来安全地传递AES的密钥再用AES来高效地加密实际的业务数据。这就像你要寄送一份绝密文件业务数据先把它装进一个坚固的保险箱AES加密但保险箱的钥匙AES密钥怎么寄给对方呢你再把钥匙放进一个只有对方才能打开的密码盒RSA加密里一起寄过去。对方收到后先用自己的私钥打开密码盒拿到钥匙再用钥匙打开保险箱拿到文件。这套方案在 RuoYi-Vue-Plus 中不是简单的功能堆砌而是深度集成到了其请求拦截器、响应处理器和后端加解密服务中形成了一套开箱即用、配置灵活的安全通信体系。接下来我将带你深入这套体系的设计思路、落地细节以及那些官方文档可能没写的“坑”。2. 核心设计思路与架构拆解2.1 混合加密流程全景图要理解这套机制必须先把整个数据流转的脉络理清楚。它不是一个点对点的加密而是一个包含密钥协商和数据加密的完整闭环。前端初始化获取RSA公钥前端应用Vue启动时首先调用一个无需加密的接口如/getPublicKey从后端获取一对RSA密钥的公钥部分。这个公钥是公开的用于加密前端将其保存下来通常放在内存或Vuex/Store中。前端生成会话密钥当需要发送敏感请求如登录前前端在内存中随机生成一个高质量的AES密钥例如一个32字节的随机字符串对应AES-256和一个随机初始向量IV用于CBC等模式。密钥的“安全投递”前端使用之前获取的RSA公钥对刚刚生成的AES密钥和IV进行加密。因为RSA加密有长度限制通常需要分段或采用混合加密模式但JSEncrypt库内部会处理这些细节。加密后得到一段密文我们称之为encryptedAesKey。业务数据的“装箱”前端使用生成的AES密钥和IV对实际的业务请求参数JSON对象进行AES加密。这里通常会将JSON字符串化后进行加密得到一个密文字符串encryptedData。组合请求体前端将encryptedAesKey和encryptedData组合成一个新的JSON对象作为本次请求的data发送给后端。为了区分通常还会增加一个标识字段如encrypted: true。后端解密“密码盒”后端控制器接收到请求后通过标识判断是否为加密请求。如果是则使用与之前下发公钥配对的RSA私钥这个私钥必须安全地存储在后端如配置文件或密钥管理服务中对encryptedAesKey进行解密还原出明文的AES密钥和IV。后端解密“保险箱”后端使用解密得到的AES密钥和IV对encryptedData进行AES解密得到明文的业务参数字符串再反序列化为JSON对象之后就可以正常进行业务逻辑处理了。响应加密可选处理完成后后端可以用同一个AES密钥对响应数据进行加密前端再用内存中的AES密钥解密。这样就实现了请求和响应的双向加密。注意这个AES会话密钥通常是“一次一密”的即每次重要请求如登录都生成新的用完即弃。对于后续的非敏感请求可以沿用这个密钥也可以不加密这取决于你的安全策略。2.2 RuoYi-Vue-Plus 的集成设计RuoYi-Vue-Plus 巧妙地将上述流程封装起来对开发者几乎透明。前端/utils/request.js核心在axios的请求拦截器 (request.interceptors.request.use)。拦截器会判断当前请求配置是否需要加密可通过自定义headers如isEncrypt: true或URL白名单判断。如果需要则自动执行上述步骤2-5生成混合加密的请求体。响应拦截器则负责判断响应是否加密并进行对应的AES解密。后端EncryptFilter或Decrypt注解采用过滤器Filter或AOP切面的方式对请求进行统一拦截。过滤器会检查请求头或参数中的加密标识然后调用RSAUtil和AESUtil工具类进行解密并将解密后的参数重新放入请求中后续的Controller接收到的就是明文数据了。对于响应同样可以通过过滤器或注解进行加密。配置化是否启用加密、RSA密钥对的位置、AES的模式CBC/ECB和填充方式PKCS5Padding/PKCS7Padding等都可以在application.yml中灵活配置实现了策略与代码的分离。这种设计的好处是业务开发者在写一个登录接口时只需要关注PostMapping(/login)和接收LoginBody对象完全不用在业务代码里写一行加解密逻辑安全与业务得到了解耦。3. 核心工具类深度解析与实操要点理解了流程我们来看看实现这些流程的核心工具。这里藏着许多性能和安全的“魔鬼细节”。3.1 前端JSEncrypt 与 CryptoJS 的协同作战前端加密通常依赖两个库jsencrypt用于RSA和crypto-js用于AES。安装与引入npm install jsencrypt crypto-js --save关键实现代码剖析import JSEncrypt from jsencrypt import CryptoJS from crypto-js class HybridEncrypt { constructor(publicKey) { this.rsaEncryptor new JSEncrypt() this.rsaEncryptor.setPublicKey(publicKey) // 设置后端下发的公钥 this.aesKey null // 临时会话AES密钥 this.aesIv null // 临时会话IV } // 生成随机的AES密钥和IV generateAesSessionKey() { // AES-256 需要32字节的密钥 const randomWords CryptoJS.lib.WordArray.random(32) // 32字节 256位 this.aesKey CryptoJS.enc.Hex.parse(randomWords.toString(CryptoJS.enc.Hex)) // CBC模式需要16字节的IV const randomWordsIv CryptoJS.lib.WordArray.random(16) this.aesIv CryptoJS.enc.Hex.parse(randomWordsIv.toString(CryptoJS.enc.Hex)) // 将密钥和IV转为Base64字符串方便用RSA加密 const keyStr CryptoJS.enc.Base64.stringify(this.aesKey) const ivStr CryptoJS.enc.Base64.stringify(this.aesIv) return { keyStr, ivStr } } // 用RSA公钥加密AES密钥和IV encryptAesKeyWithRSA(aesKeyInfo) { // 通常将密钥和IV拼接后一起加密如 key:iv const plainText ${aesKeyInfo.keyStr}:${aesKeyInfo.ivStr} // JSEncrypt 内部处理PKCS#1填充和长文本分段 const encrypted this.rsaEncryptor.encrypt(plainText) if (!encrypted) { throw new Error(RSA加密失败请检查公钥格式是否正确是否包含-----BEGIN PUBLIC KEY-----头尾) } return encrypted } // 用AES加密业务数据 encryptDataWithAES(data) { if (!this.aesKey || !this.aesIv) { throw new Error(请先生成AES会话密钥) } // 将业务对象转为JSON字符串 const dataStr typeof data string ? data : JSON.stringify(data) // 使用CBC模式PKCS7填充CryptoJS的PKCS7与PKCS5在AES上等效 const encrypted CryptoJS.AES.encrypt(dataStr, this.aesKey, { iv: this.aesIv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }) // 输出为Base64格式的密文 return encrypted.toString() } // 解密后端返回的AES加密数据 decryptDataWithAES(encryptedBase64Str) { const decrypt CryptoJS.AES.decrypt(encryptedBase64Str, this.aesKey, { iv: this.aesIv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }) const decryptedStr decrypt.toString(CryptoJS.enc.Utf8) try { return JSON.parse(decryptedStr) } catch (e) { // 如果不是JSON直接返回字符串 return decryptedStr } } }实操要点与避坑指南RSA公钥格式这是第一个大坑。后端生成的公钥通常是PEM格式包含-----BEGIN PUBLIC KEY-----头和-----END PUBLIC KEY-----尾。JSEncrypt需要完整的PEM格式字符串。确保前端获取后原样设置不要自行去除头尾或换行符。AES密钥长度与随机性AES-256的安全性建立在密钥的随机性和不可预测性上。务必使用CryptoJS.lib.WordArray.random()或window.crypto.getRandomValues()这类密码学安全的随机数生成器绝对不能用Math.random()或固定字符串。IV初始化向量的重要性在CBC、CFB等模式下IV必须随机且每次加密都不同。重复使用相同的密钥和IV会严重削弱安全性。这就是为什么我们在每次会话中都重新生成IV。数据编码CryptoJS内部操作的是WordArray对象与字符串之间的转换需要指定编码如Hex,Base64,Utf8。加解密过程中必须保持编码一致否则会得到乱码或解密失败。通常网络传输使用Base64。错误处理JSEncrypt.encrypt()在公钥格式错误或数据过长时可能返回false或null必须进行判断。3.2 后端Java 端的 RSA 与 AES 实现后端以Spring Boot为例通常使用javax.crypto包或BouncyCastle提供者。RSA 工具类关键代码import javax.crypto.Cipher; import java.security.*; import java.util.Base64; public class RSAUtil { private static final String TRANSFORMATION RSA/ECB/PKCS1Padding; private PrivateKey privateKey; private PublicKey publicKey; // 初始化从配置文件或keystore加载密钥对 public void initKeys(String privateKeyStr, String publicKeyStr) throws Exception { // 这里省略了从PEM字符串加载PrivateKey和PublicKey的详细代码 // 通常需要使用 KeyFactory 和 PKCS8EncodedKeySpec / X509EncodedKeySpec } // 用私钥解密用于解密前端传来的加密AES密钥 public String decryptWithPrivateKey(String encryptedBase64) throws Exception { Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, privateKey); byte[] encryptedBytes Base64.getDecoder().decode(encryptedBase64); byte[] decryptedBytes cipher.doFinal(encryptedBytes); return new String(decryptedBytes, StandardCharsets.UTF_8); } // 用公钥加密可用于生成前端所需的公钥或加密返回给前端的敏感信息 public String encryptWithPublicKey(String data) throws Exception { Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, publicKey); byte[] encryptedBytes cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encryptedBytes); } }AES 工具类关键代码import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.util.Base64; public class AESUtil { private static final String ALGORITHM AES; private static final String TRANSFORMATION AES/CBC/PKCS5Padding; // 与前端CryptoJS的PKCS7Padding兼容 // 解密业务数据 public static String decrypt(String data, String keyStr, String ivStr) throws Exception { byte[] keyBytes Base64.getDecoder().decode(keyStr); byte[] ivBytes Base64.getDecoder().decode(ivStr); byte[] dataBytes Base64.getDecoder().decode(data); SecretKeySpec secretKeySpec new SecretKeySpec(keyBytes, ALGORITHM); IvParameterSpec ivParameterSpec new IvParameterSpec(ivBytes); Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec); byte[] decryptedBytes cipher.doFinal(dataBytes); return new String(decryptedBytes, StandardCharsets.UTF_8); } // 加密响应数据 public static String encrypt(String data, String keyStr, String ivStr) throws Exception { // ... 加密过程与解密对称模式改为 Cipher.ENCRYPT_MODE } }后端实操要点密钥管理RSA私钥是安全的核心绝不能硬编码在代码中。应该放在服务器的环境变量、配置中心如Apollo、Nacos或专业的密钥管理服务KMS中。生产环境甚至可以考虑使用HSM硬件安全模块。算法与模式确保前后端的算法、模式、填充方式完全一致。AES前端CryptoJS.mode.CBCCryptoJS.pad.Pkcs7对应后端AES/CBC/PKCS5Padding在AES语境下PKCS5Padding和PKCS7Padding是等价的。RSA前端JSEncrypt默认使用RSAES-PKCS1-V1_5方案对应后端RSA/ECB/PKCS1Padding。注意这里的“ECB”是RSA的模式与AES的ECB不同RSA的ECB模式是标准叫法不存在AES-ECB的安全问题。Base64编码网络传输使用Base64编码但要注意Java中Base64.getEncoder()与前端btoa()或CryptoJS.enc.Base64的兼容性。通常使用java.util.Base64类即可。异常处理解密过程可能因密钥错误、数据被篡改、填充错误等原因抛出BadPaddingException、IllegalBlockSizeException等异常。必须妥善捕获并转化为统一的业务异常如“请求数据非法”避免将底层加密库的异常信息暴露给前端这本身也是一种信息泄露。4. 在 RuoYi-Vue-Plus 中的配置与实战落地RuoYi-Vue-Plus 将上述理论工程化了。我们来看看如何在一个新项目中启用和配置它。4.1 后端配置与启用检查依赖确保ruoyi-common模块中已经包含了加解密相关的工具类RSAUtil,AESUtil和过滤器EncryptFilter或注解Decrypt,Encrypt。配置密钥在application.yml中配置RSA密钥对。密钥对可以通过在线工具或KeyPairGenerator生成。# 安全配置 security: encrypt: enabled: true # 启用加解密过滤器 rsa: private-key: 你的RSA私钥PEM字符串注意换行符要用\n表示 public-key: 你的RSA公钥PEM字符串 aes: mode: CBC padding: PKCS5Padding启用过滤器/注解过滤器方式如果使用EncryptFilter确保它在SecurityConfig或FilterConfig中被注册并配置需要拦截的URL模式如/api/**。过滤器会自动处理请求和响应。注解方式更灵活。在Controller的方法参数上使用Decrypt注解在方法上或返回值上使用Encrypt注解。需要在配置类上启用AOP支持EnableAspectJAutoProxy并有一个对应的切面EncryptAspect来处理这些注解。编写一个提供公钥的接口RestController public class KeyController { Value(${security.encrypt.rsa.public-key}) private String publicKey; GetMapping(/getPublicKey) public RString getPublicKey() { // 注意这个接口本身不应该被加密过滤器拦截 return R.ok(publicKey); } }4.2 前端请求封装获取并存储公钥在应用入口如main.js或App.vue或请求工具初始化时调用/getPublicKey接口将公钥存储到全局状态如Vuex或一个单例的加密工具实例中。改造请求工具request.js这是核心步骤。在axios的请求拦截器中判断哪些请求需要加密。import axios from axios import { getPublicKeyFromStore } from /store // 假设公钥存在store里 import HybridEncrypt from /utils/hybrid-encrypt // 前面封装的类 const service axios.create({...}) let encryptor null // 初始化加密器 const initEncryptor async () { if (!encryptor) { const pubKey await fetchPublicKey() // 获取公钥 encryptor new HybridEncrypt(pubKey) } } service.interceptors.request.use(async config { await initEncryptor() // 判断是否需要加密可以通过config.headers设置标识或根据URL白名单判断 if (config.isEncrypt || needEncrypt(config.url)) { // 1. 生成本次会话的AES密钥和IV const aesSession encryptor.generateAesSessionKey() // 2. 用RSA公钥加密AES密钥信息 const encryptedKey encryptor.encryptAesKeyWithRSA(aesSession) // 3. 用AES加密业务数据 const originalData config.data const encryptedData encryptor.encryptDataWithAES(originalData) // 4. 重组请求数据 config.data { encryptedKey: encryptedKey, data: encryptedData, // 可以加一个时间戳或随机数防重放 timestamp: new Date().getTime() } // 5. 可选设置请求头标识告知后端这是一个加密请求 config.headers[X-Request-Encrypted] true // 6. 重要将本次生成的AES密钥和IV临时存储用于解密本次请求的响应 // 可以存在本次请求的config对象上或者一个以timestamp为key的Map里 config._aesSession aesSession } return config }, error {...}) service.interceptors.response.use(response { const config response.config // 判断响应是否加密可以通过响应头如 X-Response-Encrypted if (response.headers[x-response-encrypted] true) { const encryptedData response.data.data const aesSession config._aesSession // 取出请求时存储的密钥 if (aesSession encryptor) { // 临时设置解密密钥 encryptor.aesKey CryptoJS.enc.Base64.parse(aesSession.keyStr) encryptor.aesIv CryptoJS.enc.Base64.parse(aesSession.ivStr) const decryptedData encryptor.decryptDataWithAES(encryptedData) response.data decryptedData // 将解密后的数据替换原始响应数据 } } return response }, error {...})在业务中调用之后在Vue组件或Store中发起请求时只需要在需要加密的请求配置中加上标识即可。// 登录请求需要加密 export function login(data) { return request({ url: /auth/login, method: post, data: data, isEncrypt: true // 触发加密拦截器 }) } // 获取公开信息不需要加密 export function getPublicInfo() { return request({ url: /api/public/info, method: get }) }4.3 一个完整的登录接口示例前端Login.vuesubmitForm() { this.$refs.form.validate(valid { if (valid) { login(this.loginForm).then(res { // res.data 已经是响应拦截器解密后的明文数据了 console.log(登录成功, res.data) }) } }) }后端SysLoginController.javaRestController public class SysLoginController { PostMapping(/auth/login) // 使用注解方式参数自动解密返回值自动加密 // Decrypt 注解在参数上表示需要解密LoginBody // Encrypt 注解在方法上表示返回值需要加密 Encrypt public RLoginUser login(Decrypt RequestBody LoginBody loginBody) { String username loginBody.getUsername(); String password loginBody.getPassword(); // ... 验证用户名密码逻辑 LoginUser loginUser loginService.login(username, password); return R.ok(loginUser); } }整个过程中开发者接触到的loginForm、loginBody、返回的RLoginUser都是明文对象加解密过程由框架在“水下”完成极大地简化了开发。5. 常见问题、性能考量与安全进阶5.1 常见问题排查表问题现象可能原因排查步骤前端RSA加密失败encrypt返回false1. 公钥格式错误缺少头尾、换行符丢失。2. 公钥与加密算法不匹配如用了非RSA公钥。3. 待加密数据过长RSA有长度限制但JSEncrypt应自动处理。1. 控制台打印获取到的公钥字符串检查是否完整。2. 使用在线PEM解析工具验证公钥。3. 检查JSEncrypt版本确保其支持长文本分段加密。后端RSA解密失败报BadPaddingException1. 私钥与公钥不配对。2. 前端传过来的encryptedKeyBase64格式损坏或传输中被修改。3. 前后端RSA填充模式不一致。1. 核对配置文件中的私钥是否与生成公钥的私钥配对。2. 在后端记录接收到的encryptedKey与前端发送的进行比对。3. 确认后端Cipher.getInstance(“RSA/ECB/PKCS1Padding”)与前端JSEncrypt默认模式一致。AES解密失败报BadPaddingException或得到乱码1. 前后端AES密钥、IV不一致。2. 前后端AES模式/填充不一致。3. 密文在传输中被篡改或Base64解码错误。4. 前端加密后后端解密前数据被额外编码/解码如URL编码。1.关键在后端解密AES密钥后打印出解密得到的明文key和iv与前端生成的进行比对。2. 确认模式都是CBC填充都是PKCS7/PKCS5。3. 检查网络请求查看实际传输的data字段值是否一致。4. 检查是否有全局的HttpMessageConverter对请求体做了处理。响应解密失败1. 前端用于解密响应的AES密钥不是加密请求时用的那个会话丢失。2. 后端加密响应和前端解密响应的密钥不一致。3. 响应数据被网关、过滤器额外处理。1. 确保前端将请求时的AES会话密钥与请求绑定并在响应时能准确取出。2. 后端加密响应时必须使用解密请求时得到的那个AES密钥不能重新生成。3. 检查Nginx等网关是否对响应体做了压缩或修改。性能明显下降1. RSA加解密是CPU密集型操作频繁使用导致。2. 对所有请求都启用了加密包括静态资源、图片等。1. 采用“一次一密”的会话密钥一个会话内只用一次RSA后续用AES。2.最重要配置加密白名单。只对/auth/login,/user/updatePassword等真正敏感的API启用加密。公开数据、文件上传下载等接口不要加密。5.2 性能优化与安全进阶建议会话复用对于登录后的后续敏感请求如修改个人信息、交易不必每次都走完整的RSAAES流程。可以在登录成功后由服务端生成一个随机的sessionAesKey通过登录接口的加密响应返回给前端用登录时的会话AES密钥加密。之后前端用这个sessionAesKey来加密后续请求服务端从Session或缓存中取出对应的sessionAesKey解密。这样避免了频繁的RSA运算。防重放攻击在加密请求体中加入时间戳timestamp和随机数nonce。服务端收到请求后校验时间戳是否在合理窗口内如5分钟并检查该随机数在窗口内是否已被使用过可用缓存实现从而防止请求被拦截后重复发送。密钥轮转RSA密钥对不应永久使用。应制定策略定期如每季度更换密钥对。更换时需要有一个过渡期新老公钥同时有效前端逐步升级。非对称加密算法升级RSA 2048位目前仍安全但从长远看可考虑使用更现代的椭圆曲线加密ECC如ECDH密钥交换对称加密在相同安全强度下ECC的密钥更短计算更快。启用HTTPS这是大前提应用层加密RSAAES绝不能替代HTTPS。HTTPS提供了身份认证防中间人、通道加密和完整性校验。应用层加密是在HTTPS之上为业务数据提供的额外保护主要用于防范服务器侧的数据泄露和满足审计要求。这套JSEncrypt AES的混合加密方案在 RuoYi-Vue-Plus 的实践中被证明是兼顾安全性、性能和开发效率的优雅方案。它把复杂的安全逻辑封装在框架底层让业务开发者可以更专注于功能实现。然而安全是一个持续的过程理解和掌握其背后的原理、熟练进行问题排查、并根据实际业务场景进行优化和加固才是用好这套方案的关键。