为什么很多开发者会踩 “Token 未校验” 的坑

17 阅读12分钟

【Vue3 + Pinia 踩坑】刷新页面直接登录成功?initFromStorage 未校验 Token 导致的权限漏洞

一、问题背景

在基于 Vue3 + Pinia + 动态路由的后台系统中,我遇到了两个隐蔽但关键的权限问题,既涉及 Token 校验漏洞,也牵扯路由守卫的安全作用,具体如下:

1. 浏览器刷新页面后,即使 Token 已过期、无效,用户依然能直接进入首页,系统判定为已登录状态;

2. 未登录用户打开首页,未自动跳转登录页,反而进入主页后点击菜单(无角色),导航到不存在的 403 页面并报错,与预期的“未登录→跳转登录页”流程不符。

经过排查,第一个问题根源出在 Pinia 的用户状态初始化方法 `initFromStorage` 没有校验 Token;第二个问题则是路由守卫逻辑不完善,混淆了“未登录”与“已登录无权限”的跳转逻辑。

二、核心问题现象

现象1:Token 未校验导致的“伪登录”

  1. 登录成功后,退出登录;

  2. 手动修改 localStorage 里的 token(随便填写无效值);

  3. 刷新页面;

  4. 系统直接认为已登录,成功进入首页,权限校验形同虚设。

现象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 点:

  1. 初期目标:快速实现「登录成功后保存 Token、刷新页面不退出」—— 只要 localStorage 存了 Token,就默认用户是 “已登录” 状态,能正常访问系统;

  2. 认知误区:误以为 “localStorage 里有 Token,就等于用户是合法登录”,忽略了两个关键问题:

    • 本地存储可篡改:用户能手动修改 localStorage 里的 Token(随便填无效值),系统也会认;
    • Token 会过期:即使是合法 Token,过了有效期就失效了,系统却没校验,依然允许访问;
  3. 对应你的项目:就是你初期写的 initFromStorage 方法,只读取 localStorage 的 Token 就设为已登录,没向后端校验 Token 是否有效,导致出现 “伪登录” 漏洞。

结合你路由守卫的调试场景:如果用户角色是 [](未正确登录),但 localStorage 有无效 Token,初期未校验的逻辑会误判为 “已登录”,进而导致路由跳转异常(比如未登录却能进入首页)。

根源2:混淆“未登录”与“已登录无权限”的跳转逻辑

很多开发者会有疑问:“我已经实现了菜单过滤,普通用户根本看不到用户管理这类权限页面,为什么还要纠结跳转逻辑?” 其实关键在于:403 页面的适用场景的是“已登录但无权限”,而非“未登录”

用户状态应该导航到的页面核心原因
未登录/login(登录页)用户还没有身份,无法判断权限,需先登录获取身份
已登录但无权限/403(禁止访问页)用户有合法身份,但该身份没有对应页面的访问权限

补充说明:403 的含义是“禁止访问”,本质是“身份有效但权限不足”;未登录用户连身份都没有,直接跳 403 既不符合 HTTP 语义,也会导致用户体验混乱(不知道该登录还是该退出)。

根源3:忽视路由守卫的“第二层安全保障”

很多开发者会疑惑:“我已经通过后端过滤菜单,普通用户看不到管理员菜单,为什么还需要路由守卫?” 答案很简单:菜单过滤只是“隐藏入口”,无法防止用户直接访问 URL

即使普通用户看不到菜单,他们仍然可以通过以下方式访问权限页面:

  1. 在浏览器地址栏直接输入:http://localhost:5173/\#/system/user;

  2. 通过浏览器书签、历史记录访问权限页面 URL;

  3. 通过他人分享的链接、截图中的 URL 访问;

  4. 手动修改 localStorage 中的路由记录,强行跳转。

这里就需要明确“菜单过滤”与“路由守卫”的分工,二者是“纵深防御”的核心:

安全层级具体作用说明
第一层:菜单过滤前端隐藏访问入口普通用户看不到权限菜单,从视觉和操作上屏蔽访问路径
第二层:路由守卫后端权限校验+状态校验即使知道 URL 也无法访问,拦截未登录/无权限访问,是最后一道安全防线

举个直观例子

场景:张三是普通用户,没有管理员权限

  1. 张三登录系统后,看不到“用户管理”菜单(菜单过滤生效);

  2. 但张三从同事口中得知了管理员页面的 URL:/system/user;

  3. 张三在浏览器地址栏输入这个 URL;

  4. 如果没有路由守卫:张三可以直接访问管理员页面,造成权限泄露 ❌;

  5. 有路由守卫:张三被拦截,跳转到 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)手动篡改方法(实操演示) ,对应前文“本地存储可篡改”的问题,具体操作步骤(浏览器端,所有主流浏览器通用):

  1. 打开项目页面(如你的后台系统首页),按 F12 打开浏览器开发者工具;
  2. 在开发者工具中,切换到「Application」(应用)选项卡;
  3. 左侧导航栏找到「Local Storage」,点击展开,选择当前项目的域名(如 localhost:5173);
  4. 此时会看到项目存在的 localStorage 键值对(如 token、userInfo、roles 等),对应你 Pinia 中保存的信息;
  5. 篡改操作: 双击「Value」列的内容,即可直接修改(比如把 token 的值随便改成一串乱码,或把 roles 的值从 ['user'] 改成 []);
  6. 也可以右键点击对应键值对,选择「Edit」修改,或「Add new」新增无效的 token 键值对;
  7. 修改完成后,刷新页面,就能复现“伪登录”问题——即使 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正常访问(无异常)正常访问,动态路由正常加载

七、关键总结(必看)

  1. Token 校验是底线:`initFromStorage` 绝对不能只读取本地存储就信任,必须向后端校验 Token 有效性,否则会出现“伪登录”漏洞;

  2. 路由守卫是最后一道防线:菜单过滤只是“隐藏入口”,路由守卫才是防止用户直接访问 URL 的核心,二者结合形成纵深防御;

  3. 区分 403 与登录页的适用场景:未登录 → 跳登录页,已登录无权限 → 跳 403 页,符合 HTTP 语义和用户体验;

  4. 前端权限只是辅助:无论前端如何过滤菜单、拦截路由,最终的权限校验必须由后端完成,前端只做“体验优化”和“二次拦截”;

  5. 刷新页面必做 Token 校验:每次刷新页面,都要通过 initFromStorage 校验 Token,确保登录状态的真实性。

如果你正在开发 Vue3 + Pinia 后台系统,这三个问题(Token 未校验、路由守卫不完善、跳转逻辑混乱)是 90% 新手都会踩的坑。只要按照上述方案修复,你的系统权限安全会提升一个档次,彻底解决“伪登录”“未登录跳转异常”“权限泄露”等问题。

(注:妍妍要加油哦~)