Vue.js前端AES加密实战:基于Axios拦截器的数据传输安全方案

📅 2026/7/6 19:52:33
Vue.js前端AES加密实战:基于Axios拦截器的数据传输安全方案
1. 项目概述与核心需求在前后端分离的架构下数据通过HTTP/HTTPS协议在网络中明文传输这构成了一个潜在的安全风险。即便使用了HTTPS数据在到达服务端或客户端后如果日志记录不当或中间代理处理有误敏感信息仍有泄露的可能。因此对传输内容本身进行二次加密成为许多对数据安全有更高要求项目的标配。RuoYi作为一个成熟的企业级快速开发框架其前后端分离版本广泛应用于各类管理系统中处理着大量的用户信息、业务数据实现前后端数据传输加密是提升其安全水位的关键一步。上一篇文章我们详细探讨了后端Spring Boot如何集成加密组件、设计加解密过滤器以及处理全局异常。本篇我们将聚焦于前端Vue.js部分这是加密链路中与用户直接交互、负责发起加密请求和解析加密响应的关键环节。前端加密的核心目标很明确在数据离开浏览器前将其加密并在收到服务端响应后准确解密出原始数据同时保证整个过程的性能开销可控、对开发者透明、且不影响正常的业务逻辑开发。简单来说我们需要在前端构建一个“加密层”它能够自动拦截所有的axios请求和响应。对于发出的请求它需要将data中的指定字段或整个请求体进行加密对于收到的响应它需要判断其是否为加密数据并进行解密然后将解密后的原始数据交还给业务代码。整个过程对于编写页面组件的开发者而言应该是无感的他们依然像往常一样调用接口、处理数据。2. 前端加密方案设计与核心思路2.1 技术选型与考量在前端实现加密我们面临几个关键选择加密算法、密钥管理以及集成方式。加密算法选择AES对称加密与后端保持一致我们选择AESAdvanced 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_ENABLEDfalse在.env.production文件中添加VUE_APP_ENCRYPTION_ENABLEDtrue修改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框架中通过配置化的方式管理加密范围通过动态密钥保障安全基础通过拦截器实现开发透明化在提升系统安全性的同时尽可能降低了对业务代码的侵入性和开发复杂度。在实际项目中可以根据具体的安全等级要求在此基础上进一步强化例如引入非对称加密协商对称密钥、增加请求签名防重放等机制。