从Cookie到LocalStorage:用户数据存储的安全迁移实践
🎯 为什么要迁移?
在维护一个生产环境的Vue应用时,我们发现了一个潜在的安全和性能问题:所有用户数据都存储在Cookie中。
Cookie存储的问题
// 原来的实现
function setToken(token) {
Cookies.set('Admin-Token', token)
}
function saveUserInfo(username, password) {
Cookies.set('username', username)
Cookies.set('password', password) // 😱 密码存储在Cookie中!
}这种做法存在以下问题:
1. 安全性问题
❌ Cookie随每个HTTP请求发送到服务器
❌ 增加了CSRF攻击的风险
❌ 敏感信息(密码、Token)在网络传输
❌ 可能被网络中间人截获2. 性能问题
❌ 每个请求都携带Cookie数据,增加请求大小
❌ Cookie大小限制(4KB),无法存储大量数据
❌ 影响页面加载速度3. 开发问题
❌ Cookie的API相对复杂
❌ 需要处理过期时间、路径、域名等
❌ 调试不够直观🎨 迁移方案设计
核心原则
- 向后兼容:支持从Cookie自动迁移到LocalStorage
- 无感知升级:用户不会感知到任何变化
- 渐进式清理:迁移后自动清除Cookie
- 容错机制:兼容浏览器不支持LocalStorage的情况
架构对比
迁移前:
┌─────────────┐
│ Cookie │ ◄── 所有数据
│ (4KB限制) │ ◄── 随HTTP请求发送
└─────────────┘迁移后:
┌──────────────┐
│ LocalStorage │ ◄── 用户数据
│ (5-10MB) │ ◄── 仅客户端访问
└──────────────┘
↑
│ 自动迁移
│
┌──────────────┐
│ Cookie │ ◄── 逐步清理
└──────────────┘💻 实现步骤
第一步:封装统一的存储工具
// src/utils/storage.js
/**
* 统一的存储工具类
* 支持LocalStorage和Cookie的自动迁移
*/
class StorageManager {
/**
* 获取数据(支持自动从Cookie迁移)
*/
getItem(key) {
try {
// 1. 首先尝试从LocalStorage读取
let value = localStorage.getItem(key)
// 2. 如果LocalStorage中没有,尝试从Cookie读取
if (!value) {
value = this.getCookie(key)
// 3. 如果从Cookie读取到数据,自动迁移到LocalStorage
if (value) {
console.log(`${key} migrated from Cookie to LocalStorage`)
localStorage.setItem(key, value)
// 迁移后删除Cookie
this.removeCookie(key)
}
}
return value
} catch (error) {
console.error(`Error reading ${key} from storage:`, error)
return null
}
}
/**
* 保存数据到LocalStorage
*/
setItem(key, value) {
try {
localStorage.setItem(key, value)
// 确保Cookie中没有遗留数据
this.removeCookie(key)
} catch (error) {
console.error(`Error saving ${key} to LocalStorage:`, error)
// 如果LocalStorage失败,降级到Cookie
this.setCookie(key, value)
}
}
/**
* 删除数据(同时清理LocalStorage和Cookie)
*/
removeItem(key) {
try {
localStorage.removeItem(key)
this.removeCookie(key)
} catch (error) {
console.error(`Error removing ${key}:`, error)
}
}
/**
* 从Cookie读取
*/
getCookie(name) {
const value = `; ${document.cookie}`
const parts = value.split(`; ${name}=`)
if (parts.length === 2) {
return parts.pop().split(';').shift()
}
return null
}
/**
* 写入Cookie(仅作为降级方案)
*/
setCookie(name, value, days = 7) {
const expires = new Date()
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000)
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`
}
/**
* 删除Cookie
*/
removeCookie(name) {
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`
}
/**
* 检查LocalStorage是否可用
*/
isLocalStorageAvailable() {
try {
const test = '__storage_test__'
localStorage.setItem(test, test)
localStorage.removeItem(test)
return true
} catch (error) {
return false
}
}
}
export const storageManager = new StorageManager()
export default storageManager第二步:重构Token管理
// src/utils/auth.js
import storageManager from './storage'
const TokenKey = 'Admin-Token'
/**
* 获取Token(支持自动从Cookie迁移)
*/
export function getToken() {
return storageManager.getItem(TokenKey)
}
/**
* 保存Token到LocalStorage
*/
export function setToken(token) {
return storageManager.setItem(TokenKey, token)
}
/**
* 删除Token(同时清理LocalStorage和Cookie)
*/
export function removeToken() {
return storageManager.removeItem(TokenKey)
}第三步:迁移登录逻辑
<!-- src/views/Login.vue -->
<template>
<div class="login">
<el-form ref="loginFormRef" :model="loginForm" class="login-form">
<h3 class="title">用户登录</h3>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="用户名"
auto-complete="off"
>
<template #prefix>
<svg-icon icon-class="user" />
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="密码"
auto-complete="off"
@keyup.enter="handleLogin"
>
<template #prefix>
<svg-icon icon-class="password" />
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-checkbox v-model="loginForm.rememberMe">
记住密码
</el-checkbox>
</el-form-item>
<el-form-item>
<el-button
:loading="loading"
type="primary"
style="width: 100%"
@click.prevent="handleLogin"
>
登 录
</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import storageManager from '@/utils/storage'
const router = useRouter()
const store = useStore()
const loginForm = ref({
username: '',
password: '',
rememberMe: false
})
const loading = ref(false)
/**
* 从存储中读取保存的登录信息
*/
function loadSavedCredentials() {
// 自动从Cookie迁移到LocalStorage
const savedUsername = storageManager.getItem('username')
const savedPassword = storageManager.getItem('password')
const savedRememberMe = storageManager.getItem('rememberMe')
if (savedUsername) {
loginForm.value.username = savedUsername
console.log('Username loaded from storage')
}
if (savedPassword) {
loginForm.value.password = savedPassword
console.log('Password loaded from storage')
}
if (savedRememberMe === 'true') {
loginForm.value.rememberMe = true
console.log('RememberMe loaded from storage')
}
}
/**
* 保存登录信息到LocalStorage
*/
function saveCredentials() {
if (loginForm.value.rememberMe) {
// 保存到LocalStorage
storageManager.setItem('username', loginForm.value.username)
storageManager.setItem('password', loginForm.value.password)
storageManager.setItem('rememberMe', 'true')
console.log('Credentials saved to LocalStorage')
} else {
// 不记住密码时清除
storageManager.removeItem('username')
storageManager.removeItem('password')
storageManager.removeItem('rememberMe')
console.log('Credentials cleared from storage')
}
}
/**
* 处理登录
*/
function handleLogin() {
loading.value = true
store.dispatch('Login', loginForm.value)
.then(() => {
// 登录成功后保存凭据
saveCredentials()
// 跳转到首页
router.push({ path: '/' })
})
.catch(() => {
loading.value = false
})
}
// 组件挂载时加载保存的凭据
onMounted(() => {
loadSavedCredentials()
})
</script>第四步:重构Vuex Store
// src/store/modules/app.js
import storageManager from '@/utils/storage'
/**
* 统一的存储读取函数(支持Cookie自动迁移)
*/
function getStorageItem(key, defaultValue) {
const value = storageManager.getItem(key)
return value !== null ? value : defaultValue
}
const state = {
sidebar: {
opened: getStorageItem('sidebarStatus', '1') !== '0',
withoutAnimation: false
},
device: 'desktop',
size: getStorageItem('size', 'default')
}
const mutations = {
TOGGLE_SIDEBAR: state => {
state.sidebar.opened = !state.sidebar.opened
state.sidebar.withoutAnimation = false
// 保存到LocalStorage
storageManager.setItem('sidebarStatus', state.sidebar.opened ? '1' : '0')
},
SET_SIZE: (state, size) => {
state.size = size
// 保存到LocalStorage
storageManager.setItem('size', size)
}
}
export default {
namespaced: true,
state,
mutations
}// src/store/modules/user.js
import storageManager from '@/utils/storage'
/**
* 获取保存的公司信息(支持Cookie自动迁移)
*/
function getSavedCompany() {
return storageManager.getItem('defaultCompany') || 'KWRTools'
}
const state = {
token: getToken(),
name: '',
company: getSavedCompany()
}
const mutations = {
SET_COMPANY: (state, company) => {
state.company = company
// 保存到LocalStorage
storageManager.setItem('defaultCompany', company)
}
}
export default {
namespaced: true,
state,
mutations,
actions
}第五步:实现重新登录对话框
<!-- src/components/ReLoginDialog.vue -->
<template>
<el-dialog
v-model="dialogVisible"
title="会话已过期"
width="400px"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
>
<el-form ref="formRef" :model="loginForm">
<el-form-item label="用户名" prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入用户名"
auto-complete="off"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
auto-complete="off"
@keyup.enter="handleReLogin"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleLogout">退出登录</el-button>
<el-button type="primary" :loading="loading" @click="handleReLogin">
重新登录
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useStore } from 'vuex'
import { useRouter } from 'vue-router'
import storageManager from '@/utils/storage'
const store = useStore()
const router = useRouter()
const dialogVisible = ref(false)
const loading = ref(false)
const loginForm = ref({
username: '',
password: ''
})
/**
* 从LocalStorage加载保存的凭据
*/
function loadCredentials() {
loginForm.value.username = storageManager.getItem('username') || ''
loginForm.value.password = storageManager.getItem('password') || ''
}
/**
* 显示对话框
*/
function show() {
dialogVisible.value = true
loadCredentials()
}
/**
* 重新登录
*/
function handleReLogin() {
loading.value = true
store.dispatch('Login', loginForm.value)
.then(() => {
dialogVisible.value = false
// 刷新当前页面数据
window.location.reload()
})
.catch(() => {
loading.value = false
})
}
/**
* 退出登录
*/
function handleLogout() {
store.dispatch('LogOut').then(() => {
dialogVisible.value = false
router.push('/login')
})
}
// 暴露show方法供外部调用
defineExpose({
show
})
</script><!-- src/App.vue -->
<template>
<div id="app">
<router-view />
<!-- 全局重新登录对话框 -->
<re-login-dialog ref="reLoginDialogRef" />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import ReLoginDialog from '@/components/ReLoginDialog.vue'
const reLoginDialogRef = ref(null)
onMounted(() => {
// 将重新登录对话框注册到全局
window.$reLoginDialog = reLoginDialogRef.value
})
</script>第六步:更新HTTP拦截器
// src/utils/request.js
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { getToken } from '@/utils/auth'
const service = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_API,
timeout: 30000
})
// 请求拦截器
service.interceptors.request.use(
config => {
// 从LocalStorage读取Token
const token = getToken()
if (token) {
config.headers['Authorization'] = 'Bearer ' + token
}
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
response => {
return response.data
},
error => {
if (error.response) {
switch (error.response.status) {
case 401:
// Token过期,显示重新登录对话框
ElMessage.error('登录状态已过期,请重新登录')
// 打开重新登录对话框(从LocalStorage自动填充)
if (window.$reLoginDialog) {
window.$reLoginDialog.show()
}
break
case 403:
ElMessage.error('没有权限访问此资源')
break
case 500:
ElMessage.error('服务器错误,请稍后再试')
break
default:
ElMessage.error(error.response.data.message || '请求失败')
}
}
return Promise.reject(error)
}
)
export default service🔄 迁移流程
对于现有用户
1. 用户访问应用
↓
2. StorageManager尝试从LocalStorage读取数据
↓
3. LocalStorage中没有数据
↓
4. 自动从Cookie读取数据
↓
5. 数据迁移到LocalStorage
↓
6. 删除Cookie中的数据
↓
7. 输出迁移日志到控制台
↓
8. 迁移完成,用户无感知对于新用户
1. 用户首次登录
↓
2. 数据直接保存到LocalStorage
↓
3. 不使用Cookie📊 迁移效果对比
安全性提升
| 指标 | Cookie | LocalStorage | 提升 |
|---|---|---|---|
| 随HTTP发送 | ✅ 是 | ❌ 否 | 🟢 高 |
| CSRF风险 | 🔴 高 | 🟢 低 | 🟢 显著 |
| 网络传输敏感信息 | ✅ 是 | ❌ 否 | 🟢 高 |
| XSS风险 | 🟡 中 | 🟡 中 | - |
性能提升
| 指标 | Cookie | LocalStorage | 提升 |
|---|---|---|---|
| 存储容量 | 4KB | 5-10MB | 📈 1000倍+ |
| HTTP请求大小 | +Cookie大小 | 无影响 | 📉 减少 |
| 读写速度 | 🟡 中 | 🟢 快 | 📈 更快 |
开发体验
| 指标 | Cookie | LocalStorage | 改进 |
|---|---|---|---|
| API简洁性 | 🟡 复杂 | 🟢 简单 | ✅ |
| 调试便利性 | 🟡 一般 | 🟢 直观 | ✅ |
| 配置复杂度 | 🔴 高 | 🟢 低 | ✅ |
⚠️ 注意事项
1. 浏览器兼容性
// 检查LocalStorage可用性
if (!storageManager.isLocalStorageAvailable()) {
console.warn('LocalStorage not available, falling back to Cookie')
// 降级到Cookie
}2. 隐私模式处理
// Safari隐私模式下LocalStorage会抛出异常
try {
localStorage.setItem('test', 'test')
localStorage.removeItem('test')
} catch (error) {
console.warn('Storage access denied, using Cookie fallback')
// 使用Cookie作为降级方案
}3. 存储大小限制
// 监控LocalStorage使用量
function getStorageSize() {
let total = 0
for (let key in localStorage) {
if (localStorage.hasOwnProperty(key)) {
total += localStorage[key].length + key.length
}
}
return (total / 1024).toFixed(2) + ' KB'
}
console.log('LocalStorage usage:', getStorageSize())4. 数据清理策略
// 定期清理过期数据
function cleanupExpiredData() {
const keys = Object.keys(localStorage)
const now = Date.now()
keys.forEach(key => {
if (key.startsWith('temp_')) {
try {
const data = JSON.parse(localStorage.getItem(key))
if (data.expireAt && data.expireAt < now) {
localStorage.removeItem(key)
console.log(`Removed expired data: ${key}`)
}
} catch (error) {
console.error(`Error cleaning up ${key}:`, error)
}
}
})
}
// 每天清理一次
setInterval(cleanupExpiredData, 24 * 60 * 60 * 1000)🧪 测试验证
1. 单元测试
// tests/utils/storage.spec.js
import { describe, it, expect, beforeEach } from 'vitest'
import storageManager from '@/utils/storage'
describe('StorageManager', () => {
beforeEach(() => {
localStorage.clear()
document.cookie = ''
})
it('should save and retrieve data from LocalStorage', () => {
storageManager.setItem('test', 'value')
expect(storageManager.getItem('test')).toBe('value')
})
it('should migrate data from Cookie to LocalStorage', () => {
// 模拟Cookie中有数据
document.cookie = 'username=testuser'
// 第一次读取应该从Cookie迁移
const username = storageManager.getItem('username')
expect(username).toBe('testuser')
// 数据应该已经在LocalStorage中
expect(localStorage.getItem('username')).toBe('testuser')
// Cookie应该被清除
expect(document.cookie).not.toContain('username=testuser')
})
it('should handle storage errors gracefully', () => {
// 模拟LocalStorage不可用
const originalSetItem = Storage.prototype.setItem
Storage.prototype.setItem = () => {
throw new Error('QuotaExceededError')
}
// 应该降级到Cookie
expect(() => {
storageManager.setItem('test', 'value')
}).not.toThrow()
// 恢复原始方法
Storage.prototype.setItem = originalSetItem
})
})2. 集成测试
// tests/integration/auth.spec.js
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Login from '@/views/Login.vue'
describe('Login with storage migration', () => {
it('should load credentials from Cookie on first visit', async () => {
// 模拟Cookie中有保存的凭据
document.cookie = 'username=admin;path=/'
document.cookie = 'password=123456;path=/'
const wrapper = mount(Login)
await wrapper.vm.$nextTick()
// 应该从Cookie自动加载
expect(wrapper.vm.loginForm.username).toBe('admin')
expect(wrapper.vm.loginForm.password).toBe('123456')
// 数据应该已迁移到LocalStorage
expect(localStorage.getItem('username')).toBe('admin')
expect(localStorage.getItem('password')).toBe('123456')
})
})3. E2E测试
// e2e/auth.spec.js
describe('Authentication flow', () => {
it('should preserve login state after page refresh', () => {
cy.visit('/login')
// 登录
cy.get('[data-test="username"]').type('admin')
cy.get('[data-test="password"]').type('123456')
cy.get('[data-test="remember"]').check()
cy.get('[data-test="login-btn"]').click()
// 等待跳转
cy.url().should('include', '/dashboard')
// 刷新页面
cy.reload()
// 应该仍然保持登录状态
cy.url().should('include', '/dashboard')
cy.get('[data-test="user-menu"]').should('contain', 'admin')
})
it('should handle session expiration gracefully', () => {
// 模拟Token过期
cy.window().then(win => {
win.localStorage.setItem('Admin-Token', 'expired-token')
})
cy.visit('/dashboard')
// 触发需要认证的API调用
cy.get('[data-test="user-list"]').click()
// 应该显示重新登录对话框
cy.get('[data-test="relogin-dialog"]').should('be.visible')
// 应该自动填充用户名和密码
cy.get('[data-test="relogin-username"]').should('have.value', 'admin')
})
})💡 最佳实践总结
1. 向后兼容是关键
// ✅ 好的做法:支持平滑迁移
function getData(key) {
return localStorage.getItem(key) || getCookieValue(key)
}
// ❌ 不好的做法:直接切换,丢失旧数据
function getData(key) {
return localStorage.getItem(key) // 忽略Cookie中的数据
}2. 安全第一
// ✅ 敏感数据不存储在客户端
function setPassword(password) {
// 不存储明文密码,存储加密后的或者不存储
const encrypted = encrypt(password)
storageManager.setItem('pwd_hash', encrypted)
}
// ❌ 明文存储密码
function setPassword(password) {
storageManager.setItem('password', password) // 危险!
}3. 容错机制
// ✅ 处理存储失败
try {
localStorage.setItem(key, value)
} catch (error) {
console.error('Storage failed, using fallback')
sessionStorage.setItem(key, value)
}4. 及时清理
// ✅ 退出登录时清理所有数据
function logout() {
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
// 但保留"记住我"的数据
if (!rememberMe) {
localStorage.removeItem('username')
localStorage.removeItem('password')
}
}🔚 结论
从Cookie到LocalStorage的迁移不仅仅是简单地更换存储API,而是一个涉及安全性、性能、用户体验和向后兼容的系统性工程。
关键成功因素:
- ✅ 向后兼容的迁移策略
- ✅ 统一的存储管理工具
- ✅ 完善的错误处理
- ✅ 充分的测试覆盖
- ✅ 详细的迁移日志
这次迁移让我们的应用:
- 🔒 更加安全
- ⚡ 更加快速
- 💾 更大的存储容量
- 🛠️ 更好的开发体验
最重要的是,所有这些改进对用户来说是完全透明的,没有造成任何使用上的困扰。
本文记录了一次完整的用户数据存储迁移实践,希望能为有类似需求的团队提供参考。
评论 (0)