Vue.js前端AES加密实战:基于Axios拦截器的数据传输安全方案
1. 项目概述与核心需求
在前后端分离的架构下,数据通过HTTP/HTTPS协议在网络中明文传输,这构成了一个潜在的安全风险。即便使用了HTTPS,数据在到达服务端或客户端后,如果日志记录不当或中间代理处理有误,敏感信息仍有泄露的可能。因此,对传输内容本身进行二次加密,成为许多对数据安全有更高要求项目的标配。RuoYi作为一个成熟的企业级快速开发框架,其前后端分离版本广泛应用于各类管理系统中,处理着大量的用户信息、业务数据,实现前后端数据传输加密是提升其安全水位的关键一步。
上一篇文章我们详细探讨了后端(Spring Boot)如何集成加密组件、设计加解密过滤器以及处理全局异常。本篇我们将聚焦于前端(Vue.js)部分,这是加密链路中与用户直接交互、负责发起加密请求和解析加密响应的关键环节。前端加密的核心目标很明确:在数据离开浏览器前将其加密,并在收到服务端响应后,准确解密出原始数据,同时保证整个过程的性能开销可控、对开发者透明、且不影响正常的业务逻辑开发。
简单来说,我们需要在前端构建一个“加密层”,它能够自动拦截所有的axios请求和响应。对于发出的请求,它需要将data中的指定字段或整个请求体进行加密;对于收到的响应,它需要判断其是否为加密数据,并进行解密,然后将解密后的原始数据交还给业务代码。整个过程,对于编写页面组件的开发者而言,应该是无感的,他们依然像往常一样调用接口、处理数据。
2. 前端加密方案设计与核心思路
2.1 技术选型与考量
在前端实现加密,我们面临几个关键选择:加密算法、密钥管理以及集成方式。
加密算法选择:AES对称加密与后端保持一致,我们选择AES(Advanced Encryption Standard)对称加密算法。原因如下:
- 性能优异:AES加解密速度很快,对于前端频繁的网络请求而言,性能损耗在可接受范围内。
- 安全性足够:在正确使用模式(如CBC、GCM)和密钥长度(如256位)的情况下,AES目前被认为是安全的。
- 生态支持好:JavaScript有成熟的库支持,如
crypto-js或Web Crypto API。 - 与后端协同简单:前后端使用相同的算法和模式,只需安全地共享同一个密钥或密钥派生机制。
密钥管理:前端的安全困境这是前端加密中最棘手的一环。前端代码是公开的,任何密钥或硬编码在JS文件中的秘密都等同于明文。因此,绝对不能将固定的加密密钥直接写在前端代码里。我们采用的策略是“动态密钥”或“密钥协商”:
- 方案一(推荐):后端下发。用户登录成功后,后端生成一个随机的
sessionKey(仅用于本次会话的数据加密),通过HTTPS通道返回给前端。前端将此sessionKey保存在内存(如Vuex/Pinia store)或sessionStorage中(注意:localStorage持久化存储风险更高)。这个sessionKey可以有一定有效期,或每次重要操作前刷新。 - 方案二:基于登录凭证派生。使用用户登录后的Token(如JWT)或密码的派生值,通过一个固定的盐(Salt)和算法(如PBKDF2)在前端生成加密密钥。这样即使Token被截获,攻击者也需要知道派生算法和盐才能还原出加密密钥,增加了一层难度。盐可以硬编码,但最好也能由后端在初始化时提供。
集成方式:Axios拦截器RuoYi-Vue前端基于Vue和Axios。Axios提供的请求/响应拦截器(Interceptors)是实现加密层最优雅的方式。我们可以在拦截器中完成以下工作:
- 请求拦截器:判断当前请求是否需要加密(可通过自定义请求头
X-Encrypt标记),如果需要,则对config.data进行加密处理。 - 响应拦截器:判断响应数据是否为加密格式(可通过响应头或数据结构判断),如果是,则进行解密,并将解密后的数据替换
response.data。
2.2 核心设计思路
我们的设计目标是实现一个可配置、低侵入、高可用的加密方案。具体思路如下:
- 白名单/黑名单机制:不是所有接口都需要加密。例如,登录接口本身可能用于传输密码(已通过HTTPS和可能的后端加密),或者获取验证码的接口,加密反而增加复杂度。我们可以通过维护一个接口URL的白名单(仅加密列表内的)或黑名单(不加密列表外的)来控制加密范围。在RuoYi中,通常将需要加密的敏感业务接口(如用户信息修改、财务数据操作)加入白名单。
- 加密数据格式约定:前后端需要约定加密后的数据格式。一个常见的格式是,将加密后的二进制数据进行Base64编码,得到一个字符串。请求时,可以将这个字符串作为请求体的一个字段(如
{“encryptedData”: “Base64String”}),或者直接替换整个请求体。为了通用性,我们采用一个包装对象:
响应体也采用同样的格式。这样,拦截器可以通过判断{ "encrypted": true, "data": "加密后并Base64编码的字符串" }encrypted字段来快速决定是否需要解密。 - 错误处理与降级:加密解密过程可能失败(如密钥过期、数据被篡改)。需要有完善的错误处理机制,将错误信息友好地反馈给用户或触发重新获取密钥的流程。同时,考虑在开发环境或特定情况下,可以配置关闭加密功能,便于调试。
3. 核心模块实现与代码解析
接下来,我们将在RuoYi-Vue项目中实现上述思路。假设项目已集成Axios,并且请求实例统一在@/utils/request.js中管理。
3.1 安装加密库
我们选择crypto-js,它功能全面且易于使用。
npm install crypto-js # 或 yarn add crypto-js3.2 封装加密解密工具类
在@/utils/目录下创建crypto.js文件。这里我们实现一个通用的AES加密解密类,并采用CBC模式(需要初始向量IV)。
import CryptoJS from 'crypto-js'; /** * AES加密解密工具类 * 采用CBC模式,PKCS7填充(CryptoJS默认自动处理) */ class AesCrypto { /** * 构造函数 * @param {string} key - 加密密钥(Base64字符串或WordArray) * @param {string} iv - 初始向量(Base64字符串或WordArray),长度需为16字节 */ constructor(key, iv) { // 确保key和iv是CryptoJS理解的格式 this.key = CryptoJS.enc.Utf8.parse(key); this.iv = CryptoJS.enc.Utf8.parse(iv); } /** * AES加密 * @param {string|Object} data - 待加密的数据,如果是对象会转为JSON字符串 * @returns {string} Base64编码的加密字符串 */ encrypt(data) { let dataStr = typeof data === 'object' ? JSON.stringify(data) : String(data); const encrypted = CryptoJS.AES.encrypt(dataStr, this.key, { iv: this.iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 // 明确指定,虽然默认是它 }); // 返回Base64格式的密文 return encrypted.toString(); } /** * AES解密 * @param {string} encryptedBase64 - Base64编码的加密字符串 * @returns {Object|string} 解密后的数据,如果是JSON字符串会尝试解析为对象 */ decrypt(encryptedBase64) { try { const decrypted = CryptoJS.AES.decrypt(encryptedBase64, this.key, { iv: this.iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); const decryptedStr = decrypted.toString(CryptoJS.enc.Utf8); // 尝试解析为JSON,如果不是JSON则返回字符串 try { return JSON.parse(decryptedStr); } catch (e) { return decryptedStr; } } catch (error) { console.error('解密失败:', error); throw new Error('数据解密失败,可能密钥不正确或数据已损坏'); } } } // 示例:如何初始化和使用(实际密钥应从后端获取,此处仅为演示) // const crypto = new AesCrypto('1234567890123456', '1234567890123456'); // 16字节密钥和IV // const encrypted = crypto.encrypt({ name: 'test', id: 1 }); // const decrypted = crypto.decrypt(encrypted); export default AesCrypto;注意:上面的示例将密钥和IV硬编码了,这只是为了演示。在生产环境中,绝对不可以这样做!密钥
key和初始向量iv必须通过安全的方式(如登录后从后端接口动态获取)传递到前端,并存储在内存中。
3.3 实现动态密钥管理
我们需要一个安全的地方来存储从后端获取的会话密钥。这里使用Vuex(假设项目已使用),也可以使用Pinia或简单的响应式对象。
- 在Vuex store中增加加密模块(
@/store/modules/encrypt.js):
const state = { // 存储当前的加密密钥和IV,初始为空 cryptoKey: null, cryptoIv: null, // 密钥获取时间,用于判断是否过期 keyTimestamp: null }; const mutations = { SET_CRYPTO_KEY(state, { key, iv }) { state.cryptoKey = key; state.cryptoIv = iv; state.keyTimestamp = Date.now(); }, CLEAR_CRYPTO_KEY(state) { state.cryptoKey = null; state.cryptoIv = null; state.keyTimestamp = null; } }; const actions = { // 从后端获取加密密钥 async fetchCryptoKey({ commit }) { try { // 调用后端接口,这里假设接口为 /api/system/encrypt/key const response = await axios.get('/api/system/encrypt/key'); const { key, iv } = response.data; // 假设后端返回 { key: '...', iv: '...' } commit('SET_CRYPTO_KEY', { key, iv }); return { key, iv }; } catch (error) { console.error('获取加密密钥失败:', error); throw error; } }, // 检查并刷新密钥(例如,密钥有效期30分钟) async checkAndRefreshKey({ state, dispatch }) { const KEY_TTL = 30 * 60 * 1000; // 30分钟 if (!state.cryptoKey || !state.keyTimestamp || (Date.now() - state.keyTimestamp > KEY_TTL)) { console.log('加密密钥已过期或不存在,正在重新获取...'); await dispatch('fetchCryptoKey'); } } }; export default { namespaced: true, state, mutations, actions };- 在登录成功后或应用初始化时获取密钥。可以在用户登录成功的回调中,或者在全局路由守卫、App.vue的
created钩子中调用fetchCryptoKey。
3.4 改造Axios请求实例集成加密拦截器
这是最核心的一步,我们将修改@/utils/request.js。
import axios from 'axios'; import { Message, MessageBox } from 'element-ui'; import store from '@/store'; import AesCrypto from '@/utils/crypto'; // 创建axios实例 const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, // 从环境变量读取 timeout: 5000 }); // 定义需要加密的接口URL白名单(支持正则表达式或字符串匹配) const ENCRYPT_WHITELIST = [ /^\/api\/system\/user\/update/, // 更新用户信息 /^\/api\/system\/role\/auth/, // 角色授权 /^\/api\/monitor\/operlog/, // 操作日志(可能包含敏感参数) /^\/api\/finance\/.*/, // 所有财务相关接口 // 可以添加更多... ]; // 判断一个URL是否需要加密 function needEncrypt(url) { return ENCRYPT_WHITELIST.some(pattern => { if (pattern instanceof RegExp) { return pattern.test(url); } else { return url.startsWith(pattern); } }); } // 请求拦截器 service.interceptors.request.use( async config => { // 1. 检查是否需要加密 if (needEncrypt(config.url)) { // 2. 确保我们有有效的加密密钥 await store.dispatch('encrypt/checkAndRefreshKey'); const { cryptoKey, cryptoIv } = store.state.encrypt; if (!cryptoKey || !cryptoIv) { console.warn(`接口 ${config.url} 需要加密,但未获取到有效密钥,请求将不会被加密。`); return config; } // 3. 初始化加密工具 const crypto = new AesCrypto(cryptoKey, cryptoIv); // 4. 加密请求数据 if (config.data) { try { const encryptedData = crypto.encrypt(config.data); // 使用约定的加密格式包装数据 config.data = { encrypted: true, data: encryptedData }; // 可以添加一个自定义请求头,告知后端此请求体已加密(非必须,后端可通过解析data判断) config.headers['X-Request-Encrypted'] = 'AES'; } catch (error) { console.error(`请求数据加密失败 (${config.url}):`, error); // 加密失败,可以选择抛出错误或降级为不加密(不推荐) throw new Error(`请求加密失败: ${error.message}`); } } // 注意:对于GET请求,通常参数在params中,对params加密比较复杂且非常规,一般敏感操作不用GET。 // 如果需要对GET的params加密,可以将其放入data中(GET请求带body有些服务器不支持),或使用POST。 } return config; }, error => { console.error('请求拦截器错误:', error); return Promise.reject(error); } ); // 响应拦截器 service.interceptors.response.use( response => { const res = response.data; const config = response.config; // 1. 判断响应数据是否为加密格式 if (res && res.encrypted === true && res.data) { // 2. 获取密钥进行解密 const { cryptoKey, cryptoIv } = store.state.encrypt; if (!cryptoKey || !cryptoIv) { console.error('收到加密响应,但前端无解密密钥。'); // 可以触发重新登录或获取密钥 MessageBox.confirm('会话已过期或安全配置异常,请重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' }).then(() => { store.dispatch('user/logout').then(() => { location.reload(); // 重新登录 }); }); return Promise.reject(new Error('解密密钥缺失')); } const crypto = new AesCrypto(cryptoKey, cryptoIv); try { // 3. 解密数据 const decryptedData = crypto.decrypt(res.data); // 4. 用解密后的数据替换原始响应数据 response.data = decryptedData; // 注意:这里替换后,后续的拦截器或then回调收到的就是解密后的data了 } catch (error) { console.error(`响应数据解密失败 (${config.url}):`, error); Message({ message: '数据解析失败,请稍后重试', type: 'error', duration: 5 * 1000 }); return Promise.reject(error); } } // 这里是RuoYi原有的业务状态码判断逻辑(示例) const code = response.data.code || res.code; // 注意:解密后res可能已经是对象,这里需要从response.data拿 if (code === 401) { // 未登录或token过期 MessageBox.confirm('登录状态已过期,请重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' }).then(() => { store.dispatch('user/logout').then(() => { location.reload(); }); }); return Promise.reject(new Error('未授权')); } else if (code !== 200 && code !== undefined) { // 其他业务错误 Message({ message: response.data.msg || '请求错误', type: 'error', duration: 5 * 1000 }); return Promise.reject(new Error(response.data.msg || 'Error')); } else { // 请求成功,返回解密后的数据(或原始未加密数据) return response; } }, error => { console.error('响应拦截器错误:', error); Message({ message: error.message || '网络请求失败', type: 'error', duration: 5 * 1000 }); return Promise.reject(error); } ); export default service;3.5 后端配合改造要点回顾
为了让前端加密正常工作,后端(上篇内容)必须做好对应配合:
- 提供一个获取动态密钥的接口(
/api/system/encrypt/key),该接口本身不应加密,且需做访问频率限制和身份验证(如校验登录Token)。 - 实现一个请求解密过滤器:拦截请求,判断请求体格式(如检查
encrypted字段或X-Request-Encrypted头),对加密数据进行解密,并将解密后的数据重新放入请求流中,供后续的Controller使用。 - 实现一个响应加密过滤器:在Controller处理完业务后,判断是否需要加密(可通过注解、URL匹配等方式),对返回的数据进行加密,并包装成
{“encrypted”: true, “data”: “...”}的格式。 - 统一的异常处理:加解密过程可能抛出异常(如密钥错误、数据篡改),需要被全局异常处理器捕获,并返回给前端统一的错误信息格式,前端响应拦截器需要能处理这种错误。
4. 实战配置、调试与注意事项
4.1 配置白名单与加密策略
白名单ENCRYPT_WHITELIST的配置是平衡安全与性能的关键。不建议全局加密,因为:
- 性能开销:每个请求响应都加解密会增加CPU负担和延迟。
- 调试困难:所有数据都加密后,在浏览器开发者工具中查看网络请求会非常不便。
- 必要性:很多公开接口(如获取菜单、字典数据)无需加密。
建议的策略是:按需加密,聚焦敏感数据。仔细梳理业务,将涉及个人隐私(手机号、身份证)、财务数据、核心业务操作(删除、授权、审批)的接口加入白名单。
4.2 开发环境调试技巧
在开发阶段,加解密可能会掩盖真实的请求和响应,给调试带来困难。我们可以通过环境变量来控制加密功能的开关。
- 在
.env.development文件中添加:
VUE_APP_ENCRYPTION_ENABLED=false- 在
.env.production文件中添加:
VUE_APP_ENCRYPTION_ENABLED=true- 修改
request.js中的needEncrypt函数和响应拦截器:
const ENCRYPTION_ENABLED = process.env.VUE_APP_ENCRYPTION_ENABLED !== 'false'; // 默认开启 function needEncrypt(url) { if (!ENCRYPTION_ENABLED) return false; // 全局开关 return ENCRYPT_WHITELIST.some(pattern => { // ... 原有匹配逻辑 }); } // 在响应拦截器中,同样先判断开关 if (ENCRYPTION_ENABLED && res && res.encrypted === true && res.data) { // ... 解密逻辑 }这样,在开发时关闭加密,可以清晰地看到原始请求和响应数据;在生产环境开启,保障安全。
4.3 密钥安全与更新机制
这是安全的核心,再强调一次:
- 切勿硬编码:密钥不能出现在前端源代码中。
- 会话级密钥:每次登录生成新的密钥,退出登录或会话过期时清除。
- 定期刷新:像上面Vuex action中实现的,可以设置一个较短的TTL(如30分钟),在发起加密请求前检查并刷新密钥。后端接口应能处理旧的密钥解密请求(例如,允许一个用户同时存在最多2个有效会话密钥),或者在返回新密钥时使旧密钥立即失效(后者更安全,但需要处理请求中途密钥失效的情况)。
- 存储位置:优先存储在内存(Vuex/Pinia)中,其次是
sessionStorage。避免使用localStorage。
4.4 处理文件上传等特殊场景
对于文件上传(multipart/form-data),通常不直接对文件二进制流进行加密,因为效率极低且可能破坏文件格式。常见的做法是:
- 如果上传请求中除了文件还有其他敏感文本字段,可以仅加密这些文本字段。
- 或者,采用另一种方案:先通过普通接口获取一个一次性的、针对本次上传的加密令牌(Token)和上传地址,前端使用该令牌上传文件,后端验证令牌的有效性。文件本身通过HTTPS传输已足够安全。
在我们的拦截器实现中,需要排除对FormData类型data的加密:
// 在请求拦截器的加密判断里 if (config.data && needEncrypt(config.url)) { // 如果是FormData,跳过加密(或者只加密其中的特定字段,这很复杂) if (config.data instanceof FormData) { console.warn(`接口 ${config.url} 使用FormData,跳过自动加密。`); return config; } // ... 原有的加密逻辑 }5. 常见问题排查与性能优化
5.1 常见问题速查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 请求发送后,后端报“无法解析JSON”或“解密失败” | 1. 前端加密了,但后端未开启解密过滤器。 2. 前后端加密算法、模式、填充方式不一致。 3. 密钥(Key/IV)前后端不匹配。 4. 加密后的数据在传输中被意外修改。 | 1. 检查后端解密过滤器是否配置正确,并已应用到对应接口。 2. 对比前后端的AES配置(CBC模式,PKCS7/PKCS5填充,密钥长度)。 3. 确保前端用于加密的密钥是从后端接口正确获取的。可以在浏览器控制台打印和后台日志对比Base64编码的密钥。 4. 开启开发工具,查看网络请求的 Request Payload,确认encrypted和data字段结构正确。对比前端加密后的字符串和后端收到的字符串是否完全一致。 |
| 前端收到响应后,解密失败,控制台报错 | 1. 后端返回的数据不是约定的加密格式。 2. 前端解密密钥已过期或错误。 3. 后端加密时使用了不同的密钥。 4. 响应数据被篡改或网络错误。 | 1. 查看网络响应体,确认是否是{encrypted: true, data: “...”}格式。2. 检查Vuex中存储的 cryptoKey和cryptoIv是否存在且为最新。触发一次checkAndRefreshKey。3. 确认后端加密过滤器使用的密钥与下发前端的密钥一致。 4. 检查响应状态码是否为200,响应数据是否完整。 |
| 特定接口(如登录)加密后出现循环重定向或错误 | 登录接口本身可能用于获取加密密钥,如果它也在加密白名单内,会导致“先有鸡还是先有蛋”的死循环。 | 将登录接口、获取加密密钥的接口等排除在加密白名单之外。确保这些核心认证接口的通信不依赖我们实现的这层应用层加密。 |
| 加密后,某些POST请求变成OPTIONS请求失败 | 可能是在加密过程中,请求头或请求体被修改,触发了复杂的CORS预检请求。 | 检查加密拦截器是否错误地修改了Content-Type等关键头。确保加密后的Content-Type仍然是application/json。 |
| 页面性能感觉变慢 | 大量接口被加密,加解密计算消耗资源。 | 1. 收紧白名单,只加密必要接口。 2. 使用Web Crypto API(原生API)替代 crypto-js,性能更好。3. 检查密钥是否每次请求都重新实例化 AesCrypto对象,可考虑缓存该实例。 |
5.2 性能优化建议
- 缓存加密实例:在请求拦截器中,我们每次都需要
new AesCrypto(key, iv)。如果密钥在短时间内不变,可以在Vuex中或一个模块变量里缓存这个实例,避免重复的密钥解析开销。 - 使用Web Crypto API(进阶):现代浏览器支持原生的
window.crypto.subtleAPI,其加解密性能远高于crypto-js这类纯JavaScript实现的库。但API更底层,使用稍复杂。如果项目对性能有极致要求且不需要兼容老浏览器,可以考虑迁移。// 使用Web Crypto API进行AES-CBC加密的示例(概念性代码) async function encryptWithWebCrypto(data, key, iv) { const encoder = new TextEncoder(); const dataBuffer = encoder.encode(JSON.stringify(data)); const cryptoKey = await window.crypto.subtle.importKey( 'raw', encoder.encode(key), { name: 'AES-CBC' }, false, ['encrypt'] ); const encryptedBuffer = await window.crypto.subtle.encrypt( { name: 'AES-CBC', iv: encoder.encode(iv) }, cryptoKey, dataBuffer ); return btoa(String.fromCharCode(...new Uint8Array(encryptedBuffer))); // 转Base64 } - 减少不必要的加密:这是最有效的优化。持续审查白名单。
5.3 一个完整的加密流程示例
假设我们有一个修改用户手机号的接口POST /api/system/user/profile/updatePhone
- 用户登录:成功登录后,前端调用
/api/system/encrypt/key获取本次会话的sessionKey和iv,存入Vuex。 - 用户修改手机号:前端表单填写新手机号
{“newPhone”: “13800138000”}。 - 请求发出:Axios请求拦截器发现URL匹配白名单,从Vuex取出
key和iv,使用AES加密表单数据,得到密文“xyz...abc”,包装成{“encrypted”: true, “data”: “xyz...abc”}作为新的请求体发出。 - 后端处理:请求解密过滤器识别出
encrypted: true,使用相同的sessionKey和iv解密data字段,还原出{“newPhone”: “13800138000”},并交给UserController处理。 - 返回响应:业务处理成功,响应加密过滤器将结果
{“code”: 200, “msg”: “成功”}加密并包装,返回{“encrypted”: true, “data”: “加密后的字符串”}。 - 前端处理响应:响应拦截器识别加密格式,用Vuex中的密钥解密,得到原始结果
{“code”: 200, “msg”: “成功”},然后交由页面逻辑进行成功提示。
至此,一个完整的前后端分离数据传输加密方案的前端部分就实现完毕了。这套方案无缝集成到RuoYi框架中,通过配置化的方式管理加密范围,通过动态密钥保障安全基础,通过拦截器实现开发透明化,在提升系统安全性的同时,尽可能降低了对业务代码的侵入性和开发复杂度。在实际项目中,可以根据具体的安全等级要求,在此基础上进一步强化,例如引入非对称加密协商对称密钥、增加请求签名防重放等机制。
