1. 项目概述与核心价值最近在重构一个前端项目涉及到大量敏感数据的传输比如用户身份信息、交易记录等。直接明文传输显然是不安全的虽然HTTPS能解决传输层的安全问题但考虑到业务合规和防止内部人员窥探我们决定在应用层再做一层数据加密。项目选型是Vue 3 TypeScript网络请求库自然是Axios。我的目标很明确封装一个既安全又易用的Axios实例让它能自动对请求体和响应体进行AES加解密同时对业务开发者透明他们只需要像平时一样调用post或get方法加解密过程在底层自动完成。这不仅仅是调用一个加密库那么简单。它涉及到Axios拦截器的灵活运用、加密密钥的安全管理、不同数据格式如FormData、URLSearchParams的处理、以及如何优雅地处理加密失败或解密异常等边界情况。一个健壮的封装能让团队在后续开发中完全不用关心加密细节大幅提升开发效率和代码安全性。如果你也在为Vue项目中的数据安全传输头疼或者想学习如何深度定制Axios那么这次封装过程的经验分享应该能给你不少启发。2. 整体架构设计与核心思路2.1 技术选型与方案对比为什么是AES而不是RSA或者简单的Base64这是首先要厘清的问题。Base64只是编码谈不上加密。RSA是非对称加密安全性高但加解密速度慢不适合对可能较大的请求/响应体进行全程加密。AES作为对称加密算法在安全性和性能之间取得了很好的平衡加解密速度快适合对数据体本身进行加密。在Vue项目中集成主要有两种思路一是在每个API调用处手动加密解密二是在Axios拦截器中统一处理。前者侵入性强容易遗漏且代码冗余。后者无疑是最佳实践通过请求拦截器对data进行加密通过响应拦截器对response.data进行解密对业务代码零侵入。关于AES的模式和填充我选择了CBC模式和PKCS7填充在JavaScript中通常实现为PKCS5。CBC模式相比ECB更安全因为它引入了初始化向量IV来确保相同的明文加密后产生不同的密文。密钥Key和IV的管理是关键绝不能硬编码在前端代码里。我们的方案是在用户登录成功后由后端动态生成一个随机的AES密钥和IV通过HTTPS通道下发给前端前端将其存储在Vuex/Pinia或本地存储中并设置合理的过期时间。这样即使一次会话的密钥泄露影响范围也有限。2.2 封装的核心目标与原则这次封装我定了几个必须遵守的原则对业务透明业务开发人员调用封装后的Axios实例时传入和接收的都应该是明文对象无需感知加密过程。类型安全使用TypeScript为封装后的实例提供完整的类型提示包括请求配置、响应数据格式等。灵活可配置不是所有接口都需要加密。例如获取公开城市列表的接口就不需要。因此需要设计一个白名单或标记机制让某些请求“绕过”加解密。异常处理健壮加密或解密过程可能失败如密钥错误、数据格式异常必须有清晰的错误抛出和降级处理机制不能导致整个请求流程静默失败。避免循环依赖加解密逻辑可能会用到存储在Vuex中的密钥而Axios封装模块应该独立于具体的状态管理库通过依赖注入或函数参数来解耦。基于这些原则我设计的核心流程是创建一个工厂函数用于生成配置好的Axios实例。这个函数接收一个“密钥获取器”函数作为参数在拦截器内部调用这个获取器来拿到当前有效的Key和IV。同时通过自定义配置项比如_needEncrypt来控制单个请求是否启用加密。3. 核心工具函数AES加解密实现在编写拦截器之前我们需要先实现可靠且一致的AES加解密函数。这里我选择了crypto-js这个成熟的库。3.1 安装依赖与基础函数首先安装必要的库npm install axios crypto-js # 或 yarn add axios crypto-js接下来在src/utils/crypto.ts中实现我们的加解密核心函数import CryptoJS from crypto-js; /** * AES加密函数 (CBC模式, PKCS7填充) * param data 待加密的明文字符串或可序列化为字符串的对象 * param key 加密密钥字符串 * param iv 初始化向量字符串 * returns 加密后的Base64格式密文字符串 */ export function encryptAES(data: any, key: string, iv: string): string { // 统一将数据转换为字符串。如果是对象则序列化为JSON字符串。 const dataStr typeof data string ? data : JSON.stringify(data); // 使用CryptoJS进行加密 const encrypted CryptoJS.AES.encrypt(dataStr, CryptoJS.enc.Utf8.parse(key), { iv: CryptoJS.enc.Utf8.parse(iv), mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); // 将加密结果转换为Base64字符串返回 return encrypted.toString(); } /** * AES解密函数 * param cipherText Base64格式的密文字符串 * param key 解密密钥需与加密密钥一致 * param iv 初始化向量需与加密时一致 * returns 解密后的原始字符串。注意可能需要尝试解析为JSON对象。 */ export function decryptAES(cipherText: string, key: string, iv: string): string { const decryptBytes CryptoJS.AES.decrypt(cipherText, CryptoJS.enc.Utf8.parse(key), { iv: CryptoJS.enc.Utf8.parse(iv), mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); // 将解密后的WordArray对象转换为UTF-8字符串 const decryptedStr decryptBytes.toString(CryptoJS.enc.Utf8); return decryptedStr; } /** * 尝试将解密后的字符串解析为JSON对象如果失败则返回原字符串。 * 这是一个便捷工具函数。 */ export function tryParseDecryptedData(decryptedStr: string): any { try { return JSON.parse(decryptedStr); } catch (e) { // 如果解析失败说明原始数据可能不是JSON字符串比如只是一个普通字符串 return decryptedStr; } }注意密钥和IV的长度。AES-128、AES-192、AES-256分别对应16、24、32字节的密钥。IV固定为16字节。CryptoJS.enc.Utf8.parse会将字符串转换为WordArray确保长度正确。如果后端使用的是Base64编码的密钥前端可能需要先用CryptoJS.enc.Base64.parse来解析。3.2 密钥管理策略这是安全的核心。绝对不要在前端代码中写死密钥。我们的策略是用户登录接口/api/auth/login在验证成功后在响应体中返回本次会话使用的aesKey和aesIv。这两个值应由后端随机生成。前端收到后将其存储在内存如Vuex/Pinia中。不建议长期存储在localStorage因为容易被XSS攻击窃取。存储在内存中页面刷新后失效需要重新登录获取这反而是一种安全特性。后续所有需要加密的请求拦截器都从这个内存存储中读取密钥和IV。为了解耦我们定义一个密钥获取器的类型让Axios封装层不关心具体存储在哪里// src/types/encryption.ts export interface EncryptionConfig { key: string; iv: string; } export type KeyGetter () EncryptionConfig | null;4. 封装Axios实例与拦截器实现现在进入重头戏创建我们的安全Axios实例。4.1 创建Axios实例与自定义配置在src/utils/request.ts中import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from axios; import { encryptAES, decryptAES, tryParseDecryptedData } from ./crypto; import type { KeyGetter } from /types/encryption; // 扩展AxiosRequestConfig添加我们自定义的配置项 declare module axios { interface AxiosRequestConfig { _needEncrypt?: boolean; // 标记该请求是否需要加密 } } /** * 创建带AES加解密功能的Axios实例 * param keyGetter 获取当前加密密钥和IV的函数 * param baseConfig Axios基础配置 * returns 配置好的AxiosInstance */ export function createEncryptedAxiosInstance( keyGetter: KeyGetter, baseConfig: AxiosRequestConfig {} ): AxiosInstance { const instance axios.create({ timeout: 15000, headers: { Content-Type: application/json;charsetUTF-8, }, ...baseConfig, // 允许外部覆盖配置 }); // 请求拦截器 instance.interceptors.request.use( (config: InternalAxiosRequestConfig) { // 1. 判断该请求是否需要加密默认需要 const needEncrypt config._needEncrypt ! false; // 默认true除非显式设为false if (needEncrypt config.data) { const encryptionConfig keyGetter(); if (!encryptionConfig) { throw new Error(加密密钥未就绪请先登录或检查密钥状态。); } // 2. 处理特殊数据格式FormData 和 URLSearchParams 通常不加密 if (config.data instanceof FormData || config.data instanceof URLSearchParams) { console.warn(请求 ${config.url} 携带 FormData/URLSearchParams跳过加密。); } else { // 3. 执行加密 try { const encryptedData encryptAES(config.data, encryptionConfig.key, encryptionConfig.iv); config.data { cipherText: encryptedData }; // 将密文包装成一个对象方便后端统一解析 // 可以添加一个自定义请求头告知后端这是加密数据非必须 config.headers[X-Data-Encrypted] AES; } catch (error) { console.error(请求数据加密失败:, error); throw new Error(请求数据加密处理异常); } } } return config; }, (error) { return Promise.reject(error); } ); // 响应拦截器 instance.interceptors.response.use( (response: AxiosResponse) { const config response.config; const needDecrypt config._needEncrypt ! false; // 判断响应数据是否需要解密通常根据响应头或约定的数据格式 const isEncryptedResponse response.headers[x-data-encrypted] AES || (response.data typeof response.data object response.data.cipherText); if (needDecrypt isEncryptedResponse) { const encryptionConfig keyGetter(); if (!encryptionConfig) { throw new Error(解密密钥未就绪无法解密响应数据。); } // 提取密文。后端可能直接返回密文字符串也可能包装在{cipherText: ...}对象中。 const cipherText typeof response.data string ? response.data : response.data.cipherText; if (!cipherText || typeof cipherText ! string) { console.warn(响应数据格式异常无法找到密文:, response.data); return response; // 返回原始响应由业务层处理 } try { const decryptedStr decryptAES(cipherText, encryptionConfig.key, encryptionConfig.iv); const parsedData tryParseDecryptedData(decryptedStr); // 用解密后的数据替换原始的response.data response.data parsedData; } catch (error) { console.error(响应数据解密失败:, error, 密文:, cipherText.substring(0, 50) ...); // 解密失败可以抛出一个特定错误或者返回原始数据并标记错误状态 // 这里选择抛出错误让业务层捕获处理 throw new Error(响应数据解密异常可能密钥已过期或数据被篡改。); } } // 这里还可以统一处理业务错误码例如 if (response.data.code ! 0) { throw error } return response; }, (error) { // 统一处理网络错误、超时等 console.error(请求失败:, error.message); return Promise.reject(error); } ); return instance; }4.2 在Vue应用中集成与使用接下来我们需要在应用初始化时创建这个Axios实例并管理密钥的生命周期。假设我们使用Pinia进行状态管理。首先创建一个Pinia Store来管理密钥// src/stores/encryption.ts import { defineStore } from pinia; import { ref } from vue; import type { EncryptionConfig } from /types/encryption; export const useEncryptionStore defineStore(encryption, () { const config refEncryptionConfig | null(null); const setEncryptionConfig (newConfig: EncryptionConfig) { config.value newConfig; }; const clearEncryptionConfig () { config.value null; }; const getEncryptionConfig (): EncryptionConfig | null { return config.value; }; return { config, setEncryptionConfig, clearEncryptionConfig, getEncryptionConfig, }; });然后在应用入口或请求工具模块中创建并导出全局的Axios实例// src/utils/request.ts (续) import { useEncryptionStore } from /stores/encryption; // 创建一个适配Pinia的密钥获取器 const createKeyGetterFromPinia (): KeyGetter { // 注意这个函数可能在非Vue上下文中调用需要处理Store未初始化的情况 try { const store useEncryptionStore(); return () store.getEncryptionConfig(); } catch (e) { console.warn(无法获取加密Store返回null。); return () null; } }; // 创建全局请求实例 export const encryptedRequest createEncryptedAxiosInstance(createKeyGetterFromPinia(), { baseURL: import.meta.env.VITE_API_BASE_URL, // 从环境变量读取 }); // 你也可以导出一个不加密的实例用于公开接口 export const plainRequest axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, });最后在登录逻辑中设置密钥// 在登录API调用成功后 import { encryptedRequest } from /utils/request; import { useEncryptionStore } from /stores/encryption; const login async (username: string, password: string) { try { // 使用不加密的实例调用登录接口 const response await plainRequest.post(/auth/login, { username, password }); if (response.data.code 0) { const { token, aesKey, aesIv } response.data.data; // 1. 存储token用于身份认证 localStorage.setItem(token, token); // 2. 将AES密钥存入Pinia Store内存中 const encryptionStore useEncryptionStore(); encryptionStore.setEncryptionConfig({ key: aesKey, iv: aesIv }); // 3. 后续所有请求默认使用 encryptedRequest会自动加解密 } } catch (error) { console.error(登录失败, error); } };现在在业务组件中你可以像下面这样使用script setup langts import { encryptedRequest } from /utils/request; const fetchSensitiveData async () { try { // 这个请求会自动加密请求体并解密响应体 const response await encryptedRequest.post(/api/user/profile, { userId: 123 }); console.log(获取到的明文数据:, response.data); } catch (error) { console.error(请求失败, error); } }; const fetchPublicData async () { try { // 通过 _needEncrypt: false 标记此请求不需要加密 const response await encryptedRequest.get(/api/public/cities, { _needEncrypt: false }); console.log(公开数据:, response.data); } catch (error) { console.error(请求失败, error); } }; /script5. 高级配置、边界处理与优化5.1 处理文件上传等特殊场景当请求数据是FormData时我们通常不进行整体加密因为文件本身是二进制数据且可能很大。但有时我们希望在FormData中添加一些需要加密的文本字段。这时我们的拦截器需要更精细的控制。我们可以修改请求拦截器逻辑允许对FormData中的特定字段进行加密// 在请求拦截器中对FormData的特殊处理 if (config.data instanceof FormData) { // 检查是否有需要加密的字段标记例如一个字符串数组 const fieldsToEncrypt config._encryptFields as string[] | undefined; if (fieldsToEncrypt fieldsToEncrypt.length 0) { const encryptionConfig keyGetter(); if (!encryptionConfig) { throw new Error(加密密钥未就绪); } fieldsToEncrypt.forEach(fieldName { const value config.data.get(fieldName); if (value) { try { const encryptedValue encryptAES(value.toString(), encryptionConfig.key, encryptionConfig.iv); config.data.set(fieldName, encryptedValue); } catch (error) { console.error(加密FormData字段 ${fieldName} 失败:, error); } } }); } // 注意设置正确的Content-Typeaxios会自动处理为multipart/form-data // config.headers[Content-Type] multipart/form-data; // 通常不需要手动设置 }使用时const formData new FormData(); formData.append(file, fileObject); formData.append(encryptedRemark, 这是一条需要加密的备注信息); encryptedRequest.post(/api/upload, formData, { _encryptFields: [encryptedRemark] // 指定加密这个字段 });5.2 密钥过期与刷新机制会话密钥应该有生命周期。我们可以在响应拦截器中检查特定的错误码例如后端返回的ERR_KEY_EXPIRED触发密钥刷新流程。// 在响应拦截器的错误处理部分或成功响应但业务码表示密钥过期 if (error.response error.response.data.code ERR_KEY_EXPIRED) { // 1. 尝试调用刷新密钥的接口 const refreshSuccess await refreshEncryptionKey(); if (refreshSuccess) { // 2. 重试失败的请求 const config error.config; config._retryCount (config._retryCount || 0) 1; if (config._retryCount 3) { // 限制重试次数 return instance(config); } } // 3. 刷新失败跳转到登录页 router.push(/login); } // 其他错误照常抛出 return Promise.reject(error);refreshEncryptionKey函数可以调用一个特定的安全接口使用旧的密钥加密一个挑战码后端验证后返回新的密钥。5.3 性能与调试考量性能AES加解密是CPU密集型操作。对于非常频繁或数据量极大的请求如实时日志流需要评估性能影响。可以通过_needEncrypt标记关闭非敏感请求的加密。另外确保密钥获取函数keyGetter是高效的避免复杂的计算或同步操作。调试在开发环境下我们可能希望看到明文数据。可以创建一个环境变量来控制// .env.development VITE_ENABLE_API_ENCRYPTIONfalse然后在创建实例时判断const enableEncryption import.meta.env.VITE_ENABLE_API_ENCRYPTION ! false; export const encryptedRequest enableEncryption ? createEncryptedAxiosInstance(createKeyGetterFromPinia(), { baseURL }) : axios.create({ baseURL }); // 开发环境直接返回普通实例同时在拦截器中添加调试日志if (process.env.NODE_ENV development needEncrypt) { console.log([Encrypt Request] ${config.url}:, config.data); } // 解密后 if (process.env.NODE_ENV development needDecrypt isEncryptedResponse) { console.log([Decrypt Response] ${response.config.url}:, response.data); }6. 常见问题排查与实战心得在实际封装和使用过程中我遇到了不少坑这里总结一下问题1后端解密失败提示“Padding is invalid and cannot be removed.”原因这是最常见的AES CBC模式解密错误。根本原因是前后端的Key、IV、模式、填充方式不一致。排查步骤确认Key和IV确保前端keyGetter返回的Key和IV与后端接收到的完全一致注意空格、换行符。后端收到请求后应先打印出收到的Key和IV进行比对。一个常见错误是后端对Key/IV做了额外的解码如URLDecode而前端没有。确认编码前端使用CryptoJS.enc.Utf8.parse后端如果用Java通常对应key.getBytes(StandardCharsets.UTF_8)。如果后端提供的是Base64格式的Key前端要用CryptoJS.enc.Base64.parse。确认模式与填充双方必须都是CBC模式和PKCS7/PKCS5填充。在Java中AES/CBC/PKCS5Padding。确认数据格式前端发送的密文是Base64字符串。检查请求拦截器是否将加密后的数据正确放在了请求体中例如我们包装成了{cipherText: ...}。后端需要从这个字段取出密文。问题2加密后请求体变大导致后端解析异常原因AES加密后数据会膨胀且我们包装成了JSON对象。如果后端框架对请求体大小有限制或者期望的是application/x-www-form-urlencoded格式就会出错。解决与后端协商好加密数据的传输格式。我们采用的{cipherText: ...}是一种清晰的方式。确保后端能正确解析这个JSON对象并提取cipherText字段。同时检查后端服务器的client_max_body_sizeNginx或类似配置。问题3某些请求莫名其妙被加密了或者该加密的没加密原因_needEncrypt配置未正确传递或理解有误。注意_needEncrypt默认是true因为我们在拦截器里判断的是config._needEncrypt ! false。这意味着如果你不显式设置_needEncrypt: false所有携带data的POST/PUT/PATCH请求都会被尝试加密。对于GET请求通常没有data所以不受影响。建议为公开的、不需要加密的接口显式设置_needEncrypt: false避免遗忘。对于需要加密的接口可以不设置采用默认或者显式设置_needEncrypt: true以增加可读性。问题4在Vue3的setup外如路由守卫、纯JS模块中使用encryptedRequest报错提示Pinia store未初始化原因我们在createKeyGetterFromPinia中直接调用了useEncryptionStore()这个hook必须在Vue应用上下文和Pinia安装后才能调用。解决这是一个设计缺陷。更健壮的做法是将keyGetter函数作为参数在调用createEncryptedAxiosInstance时从已经存在的Vue组件或Store中传入。或者在应用初始化完成后再创建这个Axios实例。对于在路由守卫中使用的情况可以尝试在守卫内部动态获取Store// 在路由守卫中 import { useEncryptionStore } from /stores/encryption; import { encryptedRequest } from /utils/request; // 这个实例可能keyGetter是无效的 // 更好的做法在需要发送请求时从当前活跃的Store获取密钥 const sendRequestInGuard async () { const store useEncryptionStore(); // 现在在Vue上下文中了 const dynamicKeyGetter () store.getEncryptionConfig(); // 临时创建一个使用当前store的请求实例 const dynamicRequest createEncryptedAxiosInstance(dynamicKeyGetter); await dynamicRequest.post(...); };个人心得安全是相对的前端加密无法绝对防止恶意用户因为密钥和代码对用户是可见的虽然可混淆。其主要目的是增加攻击门槛防止流量窃听和内部数据泄露满足合规要求。核心安全依然依赖HTTPS和后端校验。类型定义很重要为扩展的_needEncrypt等配置项提供TypeScript声明能让团队成员在使用时有完善的代码提示减少错误。错误处理要友好加解密失败的错误应该与网络错误、业务错误区分开给出明确的提示方便快速定位是密钥问题还是数据问题。与后端紧密协作前后端加密方案必须对齐每一个细节算法、模式、填充、密钥长度、IV生成方式、数据包装格式、错误码约定。最好能共同维护一份接口文档或共享的加解密SDK。做好降级预案考虑在加密功能完全失效时如密钥服务宕机是否有不加密的备用通道或友好的用户提示而不是让整个应用卡死。封装完成后团队的新同学在写业务代码时完全不需要关心数据是如何安全传输的他们只需要调用encryptedRequest就像调用普通的Axios一样。这种“基础设施对业务透明”的设计极大地提升了团队的开发体验和项目的安全水位。