彻底搞懂 JWT 登录认证与路由守卫(五)

摘要

登录认证是后台管理系统的"第一道门"。在前后端分离架构中,JWT (JSON Web Token) 是目前最主流的无状态认证方案。本文将手把手带你实现一套完整的闭环流程:后端生成 Token、前端 Pinia 管理状态、Axios 拦截器处理令牌、以及通过 Vue Router 路由守卫实现"未登录拦截"与"用户信息自动拉取"。

前置文章:

从零开始:在阿里云 Ubuntu 服务器部署 Node+Express 接口(一)

阿里云域名解析 + Nginx 反向代理 + HTTPS 全流程:从 IP 访问到加密域名的完整配置(二)

Node+Express+MySQL 实现注册功能(三)

后端代码部署到服务器,服务器配置数据库,pm2进程管理发布(四)

准备工作:数据库初始化

在开始写后端接口之前,我们需要在 MySQL 中创建用户表,并插入一个管理员账号用于测试。

1. 创建用户表

请在数据库中执行以下 SQL。注意 password 字段我们要存加密后的哈希值,不能存明文。

sql 复制代码
CREATE TABLE `sys_users` (
  `id` int NOT NULL AUTO_INCREMENT COMMENT '用户ID',
  `username` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户账号(登录名)',
  `password` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '密码(加密存储)',
  `nickname` varchar(30) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '用户昵称',
  `role_id` int DEFAULT NULL COMMENT '关联的角色ID',
  `is_admin` tinyint(1) DEFAULT '0' COMMENT '是否后台管理员(1是 0否)',
  `phone` varchar(11) COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '手机号码',
  `status` tinyint(1) DEFAULT '1' COMMENT '帐号状态(1正常 0停用)',
  `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注',
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_username` (`username`) COMMENT '账号唯一',
  KEY `idx_phone` (`phone`) COMMENT '手机号索引'
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户信息表';

2. 插入测试数据 (关键!)

因为我们的登录代码使用了 bcrypt.compare,所以数据库里的密码必须是加密后的乱码。

我们这里直接预设一个密码:123456 。 它的 Bcrypt 哈希值是(你可以直接复制这个用):
<math xmlns="http://www.w3.org/1998/Math/MathML"> 2 a 2a </math>2a10$7JB720yubVSZv5W56jdb..6kcdWDR6rypQ.9lrH.jinlib.DfWd.

执行 SQL 插入管理员: ``

sql 复制代码
INSERT INTO `sys_users` (`username`, `password`, `nickname`, `status`) 
VALUES 
('admin', '$2a$10$7JB720yubVSZv5W56jdb..6kcdWDR6rypQ.9lrH.jinlib.DfWd.', '超级管理员', 1);

一、 认证流程设计

在开始写代码前,我们需要理清"身份验证"的完整链路。这就好比去坐高铁:

  1. 购票(登录) :用户提交账号密码,服务器核验通过后,发一张"身份证"(Token)。
  2. 安检(拦截器) :前端每次发请求,都要把"身份证"挂在请求头里带给服务器。
  3. 验票(后端中间件) :服务器检查"身份证"是否伪造、是否过期。
  4. 进站(路由守卫) :前端页面跳转时,检查有没有身份证。如果有,再检查是否拉取了用户信息(权限)。

二、 后端实现:JWT 颁发与验签

我们要使用 jsonwebtoken 库来生成和验证 Token,使用 bcryptjs 来比对密码。

安装

js 复制代码
npm i jsonwebtoken bcrypt

1. 配置密钥 (config.js)

在项目根目录新建配置,用于存放加密密钥。

js 复制代码
// config.js
export default {
  JWT_SECRET: 'my-super-secret-key-2025', // 生产环境请放入环境变量
  JWT_EXPIRES_IN: '24h' // Token 有效期
}

2. 登录接口 (routes/auth.js)

登录接口只做一件事:验密码,发 Token。用户信息我们稍后通过单独接口获取。

js 复制代码
import express from 'express'
import { body, validationResult } from 'express-validator'
import bcrypt from 'bcrypt'
import jwt from 'jsonwebtoken'
import { pool } from '../db/mysql.js'
import config from '../config.js'
import HttpError from '../utils/HttpError.js'
import { authMiddleware } from '../middleware/auth.js' // 引入保安

const router = express.Router()

// 1. 登录接口:POST /auth/login
router.post(
  '/login',
  [
    body('username').notEmpty().withMessage('账号不能为空'),
    body('password').notEmpty().withMessage('密码不能为空'),
  ],
  async (req, res, next) => {
    try {
      const errors = validationResult(req)
      if (!errors.isEmpty()) throw new HttpError(400, errors.array()[0].msg)

      const { username, password } = req.body

      const [users] = await pool.query(
        'SELECT * FROM sys_users WHERE username = ?',
        [username]
      )

      if (users.length === 0) throw new HttpError(400, '账号或密码错误')
      const user = users[0]

      if (user.status === 0) throw new HttpError(403, '账号已被停用')

      const isMatch = await bcrypt.compare(password, user.password)
      if (!isMatch) throw new HttpError(400, '账号或密码错误')

      // 生成 Token
      const payload = {
        username: user.username,
        userId: user.id,
        roleId: user.role_id,
        isAdmin: user.is_admin,
      }
      const token = jwt.sign(payload, config.JWT_SECRET, {
        expiresIn: config.JWT_EXPIRES_IN,
      })
      res.json({
        code: 200,
        message: '登录成功',
        data: { token },
      })
    } catch (err) {
      console.error('Info接口报错:', err)
      next(err)
    }
  }
)

export default router

3. 认证中间件 (middleware/auth.js)

这是后端的"保安",保护需要登录才能访问的接口(如用户列表、修改密码等)。

js 复制代码
import jwt from 'jsonwebtoken'
import config from '../config.js'

export const authMiddleware = (req, res, next) => {
  // 1. 获取 Header: "Authorization: Bearer <token>"
  const authHeader = req.headers.authorization
  if (!authHeader) return res.status(401).json({ code: 401, message: '未登录' })

  // 2. 提取 Token
  const token = authHeader.split(' ')[1]

  try {
    // 3. 验证并挂载用户信息
    const decoded = jwt.verify(token, config.JWT_SECRET)
    req.user = decoded // { userId: 1, iat: ..., exp: ... }
    next()
  } catch (err) {
    return res.status(401).json({ code: 401, message: 'Token 无效或已过期' })
  }
}

三、 前端实现:状态管理与拦截器

1. Pinia 状态管理 (store/modules/user.ts)

js 复制代码
import { defineStore } from 'pinia'
import { login, getUserInfo } from '@/api/auth'
import router from '@/router'

export const useUserStore = defineStore('user', {
  state: () => ({
    token: localStorage.getItem('token') || '',
    userInfo: {
      username: '',
      roles: [] as string[],
      avatar: ''
    },
    // 关键:标记是否已拉取用户信息
    isInfoLoaded: false 
  }),

  actions: {
    // 1. 登录:只负责存 Token
    async login(loginForm: any) {
      const res: any = await login(loginForm)
      const token = res.data.token
      this.token = token
      localStorage.setItem('token', token)
    },

    // 2. 获取信息:登录后拉取,或者刷新页面后拉取
    async getInfo() {
      const res: any = await getUserInfo()
      this.userInfo = res.data
      this.isInfoLoaded = true // 标记已加载
      return res
    },

    // 3. 退出:清空所有状态
     logout() {
      // 1. 清空 State
      this.token = ''
      this.userInfo = {
        username: '',
        nickname: '',
        roles: [],
        avatar: '',
        permissions: [],
      }
      // 2. 清空 LocalStorage
      localStorage.removeItem('token')
      localStorage.removeItem('userInfo')
      // --- 退出时重置为 false ---
      this.isInfoLoaded = false
      // 重定向到登录
      router.push('/login')
    },
  }
})

Axios 拦截器 (utils/request.ts)

  1. 请求自动携带 Token
  2. 响应自动处理 401 过期
js 复制代码
import axios from 'axios'
import { useUserStore } from '@/store/modules/user'
import { ElMessage } from 'element-plus'

const service = axios.create({ baseURL: '/', timeout: 5000 })

// 请求拦截
service.interceptors.request.use(config => {
  const userStore = useUserStore()
  if (userStore.token) {
    config.headers.Authorization = `Bearer ${userStore.token}`
  }
  return config
})

// 响应拦截
service.interceptors.response.use(
  res => res.data,
  error => {
    if (error.response && error.response.status === 401) {
      ElMessage.error('登录已过期,请重新登录')
      const userStore = useUserStore()
      userStore.logout() // 触发退出
    } else {
      ElMessage.error(error.message || '系统错误')
    }
    return Promise.reject(error)
  }
)

export default service

四、 核心难点:路由守卫 (permission.ts)

这是整个权限系统的控制中枢。我们需要处理好"首次登录"和"刷新页面"两种情况。

在src下新建permission.ts,

常见误区

很多人使用 if (roles.length === 0) 来判断是否需要拉取用户信息。这会导致如果一个新用户没有任何角色,代码会陷入死循环(不断请求,不断为空)。

正确解法

使用 Store 中的 isInfoLoaded 布尔值作为判断依据。

src/permission.ts

js 复制代码
import router from '@/router'
import { useUserStore } from '@/store/modules/user'
import { usePermissionStore } from '@/store/modules/permission'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

NProgress.configure({ showSpinner: false })
const whiteList = ['/login', '/404'] // 白名单

router.beforeEach(async (to, from, next) => {
  NProgress.start()
  const userStore = useUserStore()
  const permissionStore = usePermissionStore()

  // 1. 判断是否有 Token
  if (userStore.token) {
    if (to.path === '/login') {
      // 已登录则重定向到首页
      next({ path: '/' })
      NProgress.done()
    } else {
      // 2. 判断是否已拉取过用户信息 (关键!)
      // 只要 isInfoLoaded 为 true,说明已经请求过后端了,直接放行
      if (!userStore.isInfoLoaded) {
        try {
          // 2.1 拉取用户信息 (roles, permissions)
          const { roles } = await userStore.getInfo()
          
          // 2.2 根据角色生成动态侧边栏路由 (下一篇博客详细讲)
          const accessRoutes = await permissionStore.generateRoutes()
          
          // 2.3 动态添加路由
          accessRoutes.forEach(route => router.addRoute(route))

          // 2.4 确保路由添加完毕,使用 replace 模式重启守卫
          next({ ...to, replace: true })
        } catch (err) {
          // 拉取失败(如 Token 失效),重置状态并去登录页
          userStore.logout()
          next(`/login?redirect=${to.path}`)
          NProgress.done()
        }
      } else {
        // 已加载过信息,直接放行
        next()
      }
    }
  } else {
    // 无 Token,判断白名单
    if (whiteList.includes(to.path)) {
      next()
    } else {
      next(`/login?redirect=${to.path}`) // 记录原本想去的页面
      NProgress.done()
    }
  }
})

router.afterEach(() => {
  NProgress.done()
})

五、 登录页面逻辑

最后,在登录页面中,我们只需要调用 userStore.login,并在成功后处理 redirect 重定向即可。

js 复制代码
const handleLogin = () => {
  loginFormRef.value.validate(async (valid) => {
    if (valid) {
      try {
        await userStore.login(loginForm)
        
        // 登录成功后,跳转到 query.redirect 指向的页面,没有则跳首页
        const { redirect } = route.query
        router.push((redirect as string) || '/')
        
      } catch (error) {
        console.error(error)
      }
    }
  })
}

六、 总结

通过本篇实战,我们完成了系统的"安保"工作:

  1. 后端:实现了基于 JWT 的无状态认证。
  2. 前端:通过 Axios 拦截器实现了 Token 的自动携带与过期处理。
  3. 路由:通过 isInfoLoaded 标志位完美解决了路由守卫的逻辑死循环问题。
相关推荐
前端不太难10 小时前
从 Navigation State 反推架构腐化
前端·架构·react
前端程序猿之路11 小时前
Next.js 入门指南 - 从 Vue 角度的理解
前端·vue.js·语言模型·ai编程·入门·next.js·deepseek
大布布将军11 小时前
⚡️ 深入数据之海:SQL 基础与 ORM 的应用
前端·数据库·经验分享·sql·程序人生·面试·改行学it
川贝枇杷膏cbppg11 小时前
Redis 的 RDB 持久化
前端·redis·bootstrap
D_C_tyu11 小时前
Vue3 + Element Plus | el-table 表格获取排序后的数据
javascript·vue.js·elementui
JIngJaneIL11 小时前
基于java+ vue农产投入线上管理系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot
天外天-亮12 小时前
v-if、v-show、display: none、visibility: hidden区别
前端·javascript·html
jump_jump12 小时前
手写一个 Askama 模板压缩工具
前端·性能优化·rust
hellotutu12 小时前
vue2 从 sessionStorage 手动取 token 后,手动加入到 header
vue.js·token·session·header
be or not to be12 小时前
HTML入门系列:从图片到表单,再到音视频的完整实践
前端·html·音视频