【Vue3 + Pinia 踩坑】刷新页面直接登录成功?initFromStorage 未校验 Token 导致的权限漏洞
一、问题背景
在基于 Vue3 + Pinia + 动态路由的后台系统中,我遇到了两个隐蔽但关键的权限问题,既涉及 Token 校验漏洞,也牵扯路由守卫的安全作用,具体如下:
1. 浏览器刷新页面后,即使 Token 已过期、无效,用户依然能直接进入首页,系统判定为已登录状态;
2. 未登录用户打开首页,未自动跳转登录页,反而进入主页后点击菜单(无角色),导航到不存在的 403 页面并报错,与预期的“未登录→跳转登录页”流程不符。
经过排查,第一个问题根源出在 Pinia 的用户状态初始化方法 `initFromStorage` 没有校验 Token;第二个问题则是路由守卫逻辑不完善,混淆了“未登录”与“已登录无权限”的跳转逻辑。
二、核心问题现象
现象1:Token 未校验导致的“伪登录”
-
登录成功后,退出登录;
-
手动修改 localStorage 里的 token(随便填写无效值);
-
刷新页面;
-
系统直接认为已登录,成功进入首页,权限校验形同虚设。
现象2:未登录用户跳转逻辑错误
当前错误流程:
用户打开首页 → 未登录状态 → 进入主页 → 点击菜单 → 无角色 → 导航到403(路由不存在)→ 报错
正确流程应该是:
用户打开首页 → 未登录状态 → 自动跳转到登录页 → 登录成功 → 进入主页 → 点击菜单 → 正常显示
三、问题根源深度分析
根源1:initFromStorage 未校验 Token 有效性
原来的 `initFromStorage` 代码如下,核心问题是“只读取本地存储,不校验 Token 真伪”:
function initFromStorage() {
const savedToken = localStorage.getItem("token");
const savedUserInfo = localStorage.getItem("userInfo");
if (savedToken && savedUserInfo) {
// ⚠ 致命问题:只要本地有 token,就直接认为登录成功
token.value = savedToken;
userInfo.value = JSON.parse(savedUserInfo);
isLoggedIn.value = true;
}
}
核心问题总结:
-
没有向后端发送 Token 校验请求,直接信任本地存储的 Token;
-
不管 Token 是否过期、是否伪造、是否无效,只要 localStorage 中有 token(即使是无效值)就判定为已登录;
-
刷新页面 = 自动登录,形成高危安全漏洞。
-
犯错原因(贴合实际开发):
简单说:开发时优先追求 “能登录、刷新后能保留状态” 的基础功能,却没考虑安全问题,具体拆解 3 点:
-
初期目标:快速实现「登录成功后保存 Token、刷新页面不退出」—— 只要 localStorage 存了 Token,就默认用户是 “已登录” 状态,能正常访问系统;
-
认知误区:误以为 “localStorage 里有 Token,就等于用户是合法登录”,忽略了两个关键问题:
- 本地存储可篡改:用户能手动修改 localStorage 里的 Token(随便填无效值),系统也会认;
- Token 会过期:即使是合法 Token,过了有效期就失效了,系统却没校验,依然允许访问;
-
对应你的项目:就是你初期写的 initFromStorage 方法,只读取 localStorage 的 Token 就设为已登录,没向后端校验 Token 是否有效,导致出现 “伪登录” 漏洞。
结合你路由守卫的调试场景:如果用户角色是 [](未正确登录),但 localStorage 有无效 Token,初期未校验的逻辑会误判为 “已登录”,进而导致路由跳转异常(比如未登录却能进入首页)。
根源2:混淆“未登录”与“已登录无权限”的跳转逻辑
很多开发者会有疑问:“我已经实现了菜单过滤,普通用户根本看不到用户管理这类权限页面,为什么还要纠结跳转逻辑?” 其实关键在于:403 页面的适用场景的是“已登录但无权限”,而非“未登录”。
| 用户状态 | 应该导航到的页面 | 核心原因 |
|---|---|---|
| 未登录 | /login(登录页) | 用户还没有身份,无法判断权限,需先登录获取身份 |
| 已登录但无权限 | /403(禁止访问页) | 用户有合法身份,但该身份没有对应页面的访问权限 |
补充说明:403 的含义是“禁止访问”,本质是“身份有效但权限不足”;未登录用户连身份都没有,直接跳 403 既不符合 HTTP 语义,也会导致用户体验混乱(不知道该登录还是该退出)。
根源3:忽视路由守卫的“第二层安全保障”
很多开发者会疑惑:“我已经通过后端过滤菜单,普通用户看不到管理员菜单,为什么还需要路由守卫?” 答案很简单:菜单过滤只是“隐藏入口”,无法防止用户直接访问 URL。
即使普通用户看不到菜单,他们仍然可以通过以下方式访问权限页面:
-
在浏览器地址栏直接输入:http://localhost:5173/\#/system/user;
-
通过浏览器书签、历史记录访问权限页面 URL;
-
通过他人分享的链接、截图中的 URL 访问;
-
手动修改 localStorage 中的路由记录,强行跳转。
这里就需要明确“菜单过滤”与“路由守卫”的分工,二者是“纵深防御”的核心:
| 安全层级 | 具体作用 | 说明 |
|---|---|---|
| 第一层:菜单过滤 | 前端隐藏访问入口 | 普通用户看不到权限菜单,从视觉和操作上屏蔽访问路径 |
| 第二层:路由守卫 | 后端权限校验+状态校验 | 即使知道 URL 也无法访问,拦截未登录/无权限访问,是最后一道安全防线 |
举个直观例子
场景:张三是普通用户,没有管理员权限
-
张三登录系统后,看不到“用户管理”菜单(菜单过滤生效);
-
但张三从同事口中得知了管理员页面的 URL:/system/user;
-
张三在浏览器地址栏输入这个 URL;
-
如果没有路由守卫:张三可以直接访问管理员页面,造成权限泄露 ❌;
-
有路由守卫:张三被拦截,跳转到 403 页面,禁止访问 ✅。
这就是路由守卫的核心价值——即使第一层安全(菜单过滤)被绕过,第二层安全依然能保护系统不被非法访问。
四、为什么必须校验 Token?
结合上述问题,Token 校验不仅是解决“伪登录”的关键,也是路由守卫生效的基础,不校验 Token 会出现以下严重问题:
-
Token 过期 → 依然保持登录状态,无法强制退出;
-
Token 被篡改 → 非法用户可通过伪造 Token 绕过登录,进入系统;
-
用户手动伪造 Token → 直接跳过登录页,访问任意页面;
-
他人拷贝本地 localStorage → 直接登录你的账号,获取你的权限;
-
刷新页面后,权限永久有效,不会随 Token 失效而退出。
五、完整修复方案(Token 校验 + 路由守卫优化)
修复1:优化 initFromStorage,异步校验 Token 有效性
修改 Pinia Store 中的 `initFromStorage` 方法,增加后端 Token 校验,确保只有有效 Token 才能恢复登录状态:
import { defineStore } from "pinia";
import { ref } from "vue";
import axios from "axios";
export const useUserStore = defineStore("user", () => {
// 用户基本信息
const userInfo = ref({
id: "",
username: "",
nickname: "",
avatar: "",
department: "",
position: "",
});
// 认证信息
const token = ref("");
const refreshToken = ref("");
// 权限信息
const roles = ref([]);
const permissions = ref([]);
// 登录状态
const isLoggedIn = ref(false);
// 登录方法
function login(loginData) {
// 保存完整的用户信息
userInfo.value = loginData.user;
token.value = loginData.token;
roles.value = loginData.roles;
permissions.value = loginData.permissions;
isLoggedIn.value = true;
// 保存到localStorage
localStorage.setItem("token", loginData.token);
localStorage.setItem("userInfo", JSON.stringify(loginData.user));
localStorage.setItem("roles", JSON.stringify(loginData.roles));
localStorage.setItem("permissions", JSON.stringify(loginData.permissions));
// 设置axios默认header
axios.defaults.headers.common["Authorization"] = `Bearer ${loginData.token}`;
}
// 登出方法
function logout() {
userInfo.value = {};
token.value = "";
roles.value = [];
permissions.value = [];
isLoggedIn.value = false;
localStorage.removeItem("token");
localStorage.removeItem("userInfo");
localStorage.removeItem("roles");
localStorage.removeItem("permissions");
// 清除axios默认header
delete axios.defaults.headers.common["Authorization"];
}
// ✅ 修复:异步校验 Token 有效性,避免伪登录
async function initFromStorage() {
const savedToken = localStorage.getItem("token");
const savedUserInfo = localStorage.getItem("userInfo");
const savedRoles = localStorage.getItem("roles");
const savedPermissions = localStorage.getItem("permissions");
// 没有Token或用户信息,直接返回(未登录状态)
if (!savedToken || !savedUserInfo) {
return;
}
try {
// 向后端发送请求,校验Token是否有效
const res = await axios.get("http://localhost:3000/api/auth/validate");
if (res.data.code === 200) {
// Token 有效 → 恢复登录状态
token.value = savedToken;
userInfo.value = JSON.parse(savedUserInfo);
roles.value = savedRoles ? JSON.parse(savedRoles) : [];
permissions.value = savedPermissions ? JSON.parse(savedPermissions) : [];
isLoggedIn.value = true;
axios.defaults.headers.common["Authorization"] = `Bearer ${savedToken}`;
} else {
// Token 无效 → 清空状态,强制登出
logout();
}
} catch (err) {
// 请求失败(网络错误、Token过期返回401等)→ 强制登出
logout();
}
}
return {
userInfo,
token,
refreshToken,
roles,
permissions,
isLoggedIn,
login,
logout,
initFromStorage,
};
});
修复2:优化路由守卫,区分“未登录”与“已登录无权限”
修改 src/router/index.js 中的路由守卫,确保未登录用户跳转登录页,已登录无权限用户跳转 403 页,贴合正确流程。这里补充一个常见的路由守卫调试场景,方便大家排查问题:
路由守卫调试要点:
要去的位置: /(首页,对应路由配置中的 Home 页面)
用户角色: [] // 如果是空数组,说明未正确登录
路由元信息: { requiresAuth: true }
补充:本地存储(localStorage)手动篡改方法(实操演示) ,对应前文“本地存储可篡改”的问题,具体操作步骤(浏览器端,所有主流浏览器通用):
- 打开项目页面(如你的后台系统首页),按 F12 打开浏览器开发者工具;
- 在开发者工具中,切换到「Application」(应用)选项卡;
- 左侧导航栏找到「Local Storage」,点击展开,选择当前项目的域名(如 localhost:5173);
- 此时会看到项目存在的 localStorage 键值对(如 token、userInfo、roles 等),对应你 Pinia 中保存的信息;
- 篡改操作: 双击「Value」列的内容,即可直接修改(比如把 token 的值随便改成一串乱码,或把 roles 的值从 ['user'] 改成 []);
- 也可以右键点击对应键值对,选择「Edit」修改,或「Add new」新增无效的 token 键值对;
- 修改完成后,刷新页面,就能复现“伪登录”问题——即使 token 是无效值,未做校验的 initFromStorage 方法依然会读取篡改后的 token,判定用户已登录。
这也是为什么必须做 Token 后端校验的核心原因:localStorage 完全由用户掌控,可随意修改,不能作为判断登录状态的唯一依据。
// src/router/index.js (主路由配置)
import { createRouter, createWebHistory } from 'vue-router'
import Layout from '@/layout/index.vue'
import { useUserStore } from '@/store/index.js'
import { initDynamicRoutes } from './dynamicRoutes.js'
// 基础路由
const baseRoutes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue')
},
{
path: '/403',
name: 'Forbidden',
component: () => import('@/views/403.vue') // 需自行创建403页面
},
{
path: '/',
component: Layout,
name: 'HomeLayout',
redirect: '/home',
children: [
{
path: 'home',
name: 'Home',
component: () => import('@/views/Home.vue'),
meta: { requiresAuth: true }
}
]
}
]
(注:文档部分内容可能由 AI 生成)
// src/router/index.js (主路由配置)
import { createRouter, createWebHistory } from 'vue-router'
import Layout from '@/layout/index.vue'
import { useUserStore } from '@/store/index.js'
import { initDynamicRoutes } from './dynamicRoutes.js'
// 基础路由
const baseRoutes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue')
},
{
path: '/403',
name: 'Forbidden',
component: () => import('@/views/403.vue') // 需自行创建403页面
},
{
path: '/',
component: Layout,
name: 'HomeLayout',
redirect: '/home',
children: [
{
path: 'home',
name: 'Home',
component: () => import('@/views/Home.vue'),
meta: { requiresAuth: true }
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes: baseRoutes
})
// ✅ 优化路由守卫,区分未登录和已登录无权限
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
// 初始化用户状态(校验Token)
await userStore.initFromStorage()
// 1. 访问登录页 → 直接放行
if (to.path === '/login') {
// 如果已登录,访问登录页自动跳首页
if (userStore.isLoggedIn) {
next('/')
} else {
next()
}
return
}
// 2. 未登录 → 强制跳登录页(解决“未登录进入首页”问题)
if (!userStore.isLoggedIn) {
next('/login')
return
}
// 3. 已登录 → 校验权限
if (to.meta.requiresAuth) {
// 有角色限制,校验用户是否有对应角色
if (to.meta.roles && to.meta.roles.length > 0) {
const hasRole = to.meta.roles.some(role => userStore.roles.includes(role))
if (!hasRole) {
// 已登录但无权限 → 跳403
next('/403')
return
}
}
}
// 4. 已登录且有权限 → 放行(若未加载动态路由,先加载)
const hasDynamicRoutes = router.getRoutes().some(route => route.path.includes('/system') || route.path.includes('/space'))
if (!hasDynamicRoutes) {
await initDynamicRoutes(router)
// 重新跳转,确保动态路由生效
next({ ...to, replace: true })
return
}
next()
})
export default router
修复3:后端增加 Token 校验接口
以 Node.js / Koa 为例,在 server/routes/auth.js 中添加 Token 校验接口,用于前端 initFromStorage 方法调用:
// server/routes/auth.js
const Router = require('koa-router')
const router = new Router()
// Token 校验接口(需配合 jwt 中间件使用)
router.get("/api/auth/validate", async (ctx) => {
// 假设 jwt 中间件已验证 Token,并将用户信息存入 ctx.state.user
if (ctx.state.user) {
ctx.body = {
code: 200,
message: "Token 有效",
data: ctx.state.user
};
} else {
ctx.status = 401;
ctx.body = {
code: 401,
message: "Token 无效或已过期"
};
}
})
// 登录接口(原有)
router.post('/api/auth/login', async (ctx) => {
// 原有登录逻辑...
})
module.exports = { router }
六、修复后效果对比
效果1:Token 校验效果
| 场景 | 修复前 | 修复后 |
|---|---|---|
| Token 过期 | 保持登录,可正常访问 | 自动登出,跳转到登录页 |
| 伪造 Token | 可进入系统,权限泄露 | 强制登出,拒绝访问 |
| 刷新页面 | 直接登录,不校验 Token | 先校验 Token,有效则保持登录,无效则登出 |
| 安全性 | 低(高危漏洞) | 高(企业级纵深防御) |
效果2:跳转逻辑效果
| 用户状态 | 修复前跳转 | 修复后跳转 |
|---|---|---|
| 未登录,访问首页 | 进入首页,点击菜单跳无效403 | 自动跳转到登录页 |
| 已登录,无权限访问 URL | 跳无效403或报错 | 跳合法403页面,提示无权限 |
| 已登录,有权限访问 URL | 正常访问(无异常) | 正常访问,动态路由正常加载 |
七、关键总结(必看)
-
Token 校验是底线:`initFromStorage` 绝对不能只读取本地存储就信任,必须向后端校验 Token 有效性,否则会出现“伪登录”漏洞;
-
路由守卫是最后一道防线:菜单过滤只是“隐藏入口”,路由守卫才是防止用户直接访问 URL 的核心,二者结合形成纵深防御;
-
区分 403 与登录页的适用场景:未登录 → 跳登录页,已登录无权限 → 跳 403 页,符合 HTTP 语义和用户体验;
-
前端权限只是辅助:无论前端如何过滤菜单、拦截路由,最终的权限校验必须由后端完成,前端只做“体验优化”和“二次拦截”;
-
刷新页面必做 Token 校验:每次刷新页面,都要通过 initFromStorage 校验 Token,确保登录状态的真实性。
如果你正在开发 Vue3 + Pinia 后台系统,这三个问题(Token 未校验、路由守卫不完善、跳转逻辑混乱)是 90% 新手都会踩的坑。只要按照上述方案修复,你的系统权限安全会提升一个档次,彻底解决“伪登录”“未登录跳转异常”“权限泄露”等问题。
(注:妍妍要加油哦~)