Vue - 从Cookie到LocalStorage:用户数据存储的安全迁移实践
标签搜索
Vue

Vue - 从Cookie到LocalStorage:用户数据存储的安全迁移实践

HackTech.top
2025-12-24 / 0 评论 / 1 阅读 / 正在检测是否收录...

从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相对复杂
❌ 需要处理过期时间、路径、域名等
❌ 调试不够直观

🎨 迁移方案设计

核心原则

  1. 向后兼容:支持从Cookie自动迁移到LocalStorage
  2. 无感知升级:用户不会感知到任何变化
  3. 渐进式清理:迁移后自动清除Cookie
  4. 容错机制:兼容浏览器不支持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

📊 迁移效果对比

安全性提升

指标CookieLocalStorage提升
随HTTP发送✅ 是❌ 否🟢 高
CSRF风险🔴 高🟢 低🟢 显著
网络传输敏感信息✅ 是❌ 否🟢 高
XSS风险🟡 中🟡 中-

性能提升

指标CookieLocalStorage提升
存储容量4KB5-10MB📈 1000倍+
HTTP请求大小+Cookie大小无影响📉 减少
读写速度🟡 中🟢 快📈 更快

开发体验

指标CookieLocalStorage改进
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,而是一个涉及安全性、性能、用户体验和向后兼容的系统性工程。

关键成功因素:

  • ✅ 向后兼容的迁移策略
  • ✅ 统一的存储管理工具
  • ✅ 完善的错误处理
  • ✅ 充分的测试覆盖
  • ✅ 详细的迁移日志

这次迁移让我们的应用:

  • 🔒 更加安全
  • ⚡ 更加快速
  • 💾 更大的存储容量
  • 🛠️ 更好的开发体验

最重要的是,所有这些改进对用户来说是完全透明的,没有造成任何使用上的困扰。


本文记录了一次完整的用户数据存储迁移实践,希望能为有类似需求的团队提供参考。

1

评论 (0)

取消