Pinia 状态持久化与 CryptoJS.AES 加密实战指南

规范前端项目中敏感数据的本地存储行为,结合 pinia-plugin-persistedstatecrypto-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,每次输出也不同 — 这是正常且安全的

七、参考资料

posted @ 2026-06-18 13:50  HuangBingQuan  阅读(25)  评论(0)    收藏  举报