Pinia 状态持久化与 CryptoJS.AES 加密实战指南
规范前端项目中敏感数据的本地存储行为,结合
pinia-plugin-persistedstate与crypto-js,实现数据防窥探、防篡改的「暗箱化」缓存方案。
一、CryptoJS 是什么
1.1 出现背景
CryptoJS(Crypto.js)是一个纯 JavaScript 实现的加密算法库,由 Evan You(Vue.js 作者)早期参与的 Google Code 项目衍生而来,后由社区维护。
它的诞生要解决一个核心矛盾:
浏览器中没有通用的加密 API(Web Crypto API 是 2017 年后才逐步普及的),但 Web 应用越来越需要在客户端处理敏感数据。
在 CryptoJS 出现之前,前端如果要加密数据,只能:
- 依赖服务端加密后下发(多一次网络请求)
- 自己手写简单的 Base64 / 字符替换(毫无安全性可言)
- 使用 Flash / ActiveX 插件(已淘汰)
CryptoJS 用纯 JS 实现了 AES、DES、SHA、HMAC、PBKDF2 等主流加密算法,不需要任何原生模块,在浏览器中直接运行,成为前端加密的事实标准库。
1.2 与其他方案的对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| CryptoJS | 兼容性好(IE10+)、API 简洁、文档丰富 | 密钥在前端代码中、性能不如原生 | localStorage 加密、一般业务场景 |
| Web Crypto API | 浏览器原生、密钥更安全(可非导出)、性能更好 | 异步 API 较复杂、老旧浏览器不支持 | 高安全场景、大文件加解密 |
| 服务端加密后下发 | 密钥不在客户端、最安全 | 多一次请求、离线不可用 | Token / 关键凭证 |
| IndexedDB 加密存储 | 存储容量大、可存二进制 | API 复杂、兼容性问题 | 离线应用 |
实际项目中通常是 CryptoJS + 服务端下发 混合使用:CryptoJS 做 localStorage 日常持久化加密,服务端下发 Token 本身走 HTTPS。
二、核心业务场景
2.1 「记住我」自动登录
用户勾选"记住我"后,登录凭证需要持久化到 localStorage。如果不加密:
// F12 → LocalStorage 中肉眼可见:
{ "token": "eyJhbGciOiJIUzI1NiIs...", "username": "zhangsan@company.com", "role": "financial_manager" }
风险:
- 旁人看一眼就知道你的角色和联系方式
- 随手把
role改成admin可能引发越权
加密后:
secure_user_session: "U2FsdGVkX19zSWhvNWhm..."
2.2 多账号切换
教育 SaaS 场景中,一个老师可能绑定多个学校(租户),需要在不同账号间切换。切换时涉及:
- 读取加密存储的账号列表
- 解密后展示账号信息
- 选择目标账号,更新 localStorage 中的凭证
2.3 权限信息持久化
用户的角色、权限、所属租户等敏感信息需要在页面刷新后保持。这些数据直接暴露在 localStorage 中意味着:
- 任意浏览器插件都可以读取
- 同域下的其他页面可以读取
- 截图分享时可能无意泄露
2.4 防篡改兜底
加密不是为了让数据"绝对安全",而是让篡改行为可被检测:
攻击者修改密文 → 解密失败 → 代码检测到异常 → 自动清除缓存 → 强制重新登录
三、技术方案全景
┌─────────────────────────────────────────────────────────────┐
│ 前端应用层 │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Pinia │ │ pinia-plugin │ │ CryptoJS │ │
│ │ Store │──▶│ -persisted │──▶│ AES 加密/解密 │ │
│ │ (user) │ │ state │ │ │ │
│ └──────────┘ └──────────────┘ └──────────────────────┘ │
│ │ │
│ ┌──────▼──────┐ │
│ │ localStorage │ │
│ │ Base64 密文 │ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘
3.1 数据流
登录成功 → Pinia Store 更新状态
↓
persist 插件触发 serializer.serialize()
↓
JSON.stringify(state)
↓
AES.encrypt() → Base64 密文
↓
localStorage.setItem()
[页面刷新]
localStorage.getItem()
↓
AES.decrypt() → 明文字符串
↓
JSON.parse() → 对象
↓
persist 插件触发 serializer.deserialize()
↓
Pinia Store 恢复状态
四、完整代码实现
4.1 环境准备
# 安装 Pinia 官方推荐的持久化插件
npm i pinia-plugin-persistedstate
# 安装加密算法库
npm i crypto-js
4.2 注册持久化插件(main.ts)
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
// 关键步骤:注册持久化插件
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
app.mount('#app')
4.3 加密/解密工具类(utils/crypto.ts)
使用 AES-128-CBC + Pkcs7 填充模式:
import CryptoJS from 'crypto-js'
// ⚠️ 生产环境建议通过环境变量注入,见下方的加固方案
const CRYPTO_KEY = CryptoJS.enc.Utf8.parse('1234567890123456') // 16字节 = AES-128
const CRYPTO_IV = CryptoJS.enc.Utf8.parse('6543210987654321') // 16字节,与 Key 不同
/**
* AES 加密
* @param text 待加密的明文字符串
*/
export function encrypt(text: string): string {
const encrypted = CryptoJS.AES.encrypt(text, CRYPTO_KEY, {
iv: CRYPTO_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
})
return encrypted.toString() // Base64 格式密文
}
/**
* AES 解密
* @param cipherText 待解密的 Base64 密文
*/
export function decrypt(cipherText: string): string {
try {
const decrypted = CryptoJS.AES.decrypt(cipherText, CRYPTO_KEY, {
iv: CRYPTO_IV,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
})
const result = decrypted.toString(CryptoJS.enc.Utf8)
if (!result) throw new Error('Decryption returned empty string')
return result
} catch (error) {
console.error('[Security] AES 解密失败,密文可能已被篡改', error)
return ''
}
}
4.4 配置加密持久化的 Pinia Store(stores/user.ts)
import { defineStore } from 'pinia'
import { encrypt, decrypt } from '@/utils/crypto'
interface UserState {
token: string
username: string
role: string
}
export const useUserStore = defineStore('user', {
state: (): UserState => ({
token: '',
username: '',
role: '',
}),
persist: {
key: 'secure_user_session', // localStorage 中的键名
storage: localStorage,
// 自定义加解密拦截器
serializer: {
// 存入前:JSON 序列化 → AES 加密
serialize: (state: UserState) => {
const jsonStr = JSON.stringify(state)
return encrypt(jsonStr)
},
// 读取时:AES 解密 → JSON 反序列化
deserialize: (ciphertext: string) => {
const jsonStr = decrypt(ciphertext)
if (!jsonStr) {
// 解密失败 → 清除缓存,强制重新登录
localStorage.removeItem('secure_user_session')
return {} as UserState
}
return JSON.parse(jsonStr)
},
},
},
})
4.5 使用示例
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 登录成功后写入
userStore.$patch({
token: 'eyJhbGciOiJIUzI1NiIs...',
username: 'zhangsan@company.com',
role: 'financial_manager',
})
// 读取(页面刷新后自动从 localStorage 恢复)
console.log(userStore.role) // 'financial_manager'
// 退出时清除
userStore.$reset()
localStorage.removeItem('secure_user_session')
五、密钥加固方案(必读)
「防君子不防小人」的破解办法是分层设防,把逆向成本从 5 分钟提升到 5 小时。
5.1 密钥分段 + 环境因子绑定(最低成本)
// utils/crypto.ts — 密钥不写成完整字符串
const _k1 = '1234'
const _k2 = '5678'
const _k3 = '9012'
const _k4 = '3456'
function _deriveKey() {
// 拼装后绑定域名,换了域名密钥就不同
const domain = location.hostname || 'localhost'
return CryptoJS.enc.Utf8.parse(_k1 + _k2 + _k3 + _k4 + domain.length)
}
5.2 构建时注入(配合 Vite)
# .env.production
VITE_CRYPTO_KEY="1234567890123456"
VITE_CRYPTO_IV="6543210987654321"
const CRYPTO_KEY = CryptoJS.enc.Utf8.parse(import.meta.env.VITE_CRYPTO_KEY)
const CRYPTO_IV = CryptoJS.enc.Utf8.parse(import.meta.env.VITE_CRYPTO_IV)
5.3 服务端动态下发(最安全)
登录时从服务端获取临时密钥,仅存在于运行时内存,前端代码中不携带任何密钥。
六、注意事项
6.1 不要用 AES 做什么
| 不要 | 原因 |
|---|---|
| ❌ 替代 HTTPS | AES 防的是本地存储泄露,HTTPS 防的是传输嗅探,两者不重叠 |
| ❌ 前端加密密码后传输 | 密码应该在服务端加盐哈希,前端加密无意义 |
| ❌ 替代后端鉴权 | 「永远不要信任前端」,后端必须对每个请求做权限校验 |
| ❌ 加密非敏感数据 | 主题偏好、语言设置等加密只会徒增性能开销 |
6.2 常见误区
误区一:「用了 AES 加密就可以防 XSS」
不能。XSS 攻击者可以读取解密后的数据(因为解密逻辑也在客户端),也可以直接调用 API。防 XSS 靠 CSP + 输入输出过滤,不是靠加密。
误区二:「密钥长一点就更安全」
AES 的密钥强度取决于字节长度是否符合标准(16/24/32 字节),而不是字符串的视觉长度。'a' 重复 16 次(16 字节)比 'hello'(5 字节,不符合任何标准密钥长度)更规范。
误区三:「加密了就可以放心存任何数据」
localStorage 本身有 5MB 容量限制,且同步 API 会阻塞主线程。加密后数据体积增加约 30%,大对象要考虑存储上限。
6.3 CryptoJS API 行为说明
// encrypt() 返回的是 CipherParams 对象,toString() = Base64 密文
const cipher = CryptoJS.AES.encrypt(plaintext, key, { iv, mode, padding })
cipher.toString() // "U2FsdGVkX19zSWhvNWhm..."
cipher.ciphertext.toString() // 纯密文(不含 salt)
cipher.salt.toString() // 随机 salt
cipher.iv.toString() // 使用的 IV
cipher.key.toString() // 派生后的密钥
// ⚠️ 注意:CryptoJS 每次加密会生成随机 salt
// 即使相同的明文+密钥+IV,每次输出也不同 — 这是正常且安全的

浙公网安备 33010602011771号