摘要 :
登录认证是后台管理系统的"第一道门"。在前后端分离架构中,JWT (JSON Web Token) 是目前最主流的无状态认证方案。本文将手把手带你实现一套完整的闭环流程:后端生成 Token、前端 Pinia 管理状态、Axios 拦截器处理令牌、以及通过 Vue Router 路由守卫实现"未登录拦截"与"用户信息自动拉取"。
前置文章:
从零开始:在阿里云 Ubuntu 服务器部署 Node+Express 接口(一)
阿里云域名解析 + Nginx 反向代理 + HTTPS 全流程:从 IP 访问到加密域名的完整配置(二)
后端代码部署到服务器,服务器配置数据库,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);
一、 认证流程设计
在开始写代码前,我们需要理清"身份验证"的完整链路。这就好比去坐高铁:
- 购票(登录) :用户提交账号密码,服务器核验通过后,发一张"身份证"(Token)。
- 安检(拦截器) :前端每次发请求,都要把"身份证"挂在请求头里带给服务器。
- 验票(后端中间件) :服务器检查"身份证"是否伪造、是否过期。
- 进站(路由守卫) :前端页面跳转时,检查有没有身份证。如果有,再检查是否拉取了用户信息(权限)。
二、 后端实现: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)
- 请求自动携带 Token。
- 响应自动处理 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)
}
}
})
}
六、 总结
通过本篇实战,我们完成了系统的"安保"工作:
- 后端:实现了基于 JWT 的无状态认证。
- 前端:通过 Axios 拦截器实现了 Token 的自动携带与过期处理。
- 路由:通过 isInfoLoaded 标志位完美解决了路由守卫的逻辑死循环问题。