Vue3 + Node.js 实现双 Token 验证与无感刷新完整指南
在前后端分离项目中,身份认证是保障系统安全的核心环节。传统单 Token 认证存在过期重新登录、Token 被盗风险高等问题,而双 Token 验证(Access Token + Refresh Token) 结合无感刷新机制,能有效解决这些痛点,提升用户体验与系统安全性。本文将基于 Vue3 前端和 Node.js 后端,从零实现这一完整方案。
一、双 Token 验证核心原理
在开始编码前,我们需要先明确双 Token 机制的工作流程,理解其设计逻辑:
|
Token 类型 |
作用 |
有效期 |
存储位置 |
安全性要求 |
|
Access Token |
接口访问凭证,请求需携带 |
短期(15-30 分钟) |
内存(如 Pinia) |
较低(过期快,降低泄露风险) |
|
Refresh Token |
刷新 Access Token 的凭证 |
长期(7-30 天) |
HttpOnly Cookie |
极高(禁止前端操作,防 XSS) |
核心流程
- 登录阶段:用户提交账号密码,后端验证通过后,生成一对 Token(Access Token + Refresh Token),并返回给前端;
- 接口请求阶段:前端每次请求需在 Header 中携带 Access Token,后端验证 Token 有效性,有效则返回数据,无效则返回 401;
- 无感刷新阶段:当 Access Token 过期(后端返回 401),前端自动携带 Refresh Token 请求 “刷新接口”,获取新的 Access Token;
- Refresh Token 过期:若 Refresh Token 过期,前端引导用户重新登录。
-
二、技术栈准备
在开始实现前,确保已搭建好以下环境与依赖:
端
核心技术 / 依赖
用途说明
前端(Vue3)
Vue3 + Pinia(状态管理) + Axios(请求库)
存储 Token、拦截请求 / 响应、实现无感刷新逻辑
后端(Node.js)
Express(Web 框架) + jsonwebtoken(JWT 库) + cors(跨域处理)
生成 Token、验证 Token、处理跨域 Cookie 传递
三、后端实现(Node.js)
后端核心任务:生成双 Token、验证 Access Token、通过 Refresh Token 刷新 Access Token、配置跨域支持 Cookie 传递。用到了
bcryptjs,body-parser,cookie-parser,cors,cross-env,dotenv,express, jsonwebtoken,multer,mysql, nodemon, supervisor,看个人需要用到哪些包。下面是文件夹参考

3.1入口文件
//app.js
const express = require('express')
const cors = require('cors')
const bodyParser = require('body-parser')
const cookieParser = require('cookie-parser');
// 引入dotenv加载环境变量
require('dotenv').config({
path: `.env.${process.env.NODE_ENV || 'development'}` // 根据NODE_ENV加载对应文件
})
const app = express()
// 示例:根据环境变量配置不同行为
if (process.env.NODE_ENV === 'development') {
// 开发环境:打印请求日志
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
next();
});
}
// 路由和中间件配置...
const login = require('./router/login')
const setting = require('./router/setting')
app.use(bodyParser.json({limit: '50mb'}))
app.use(bodyParser.urlencoded({ extended: false }))
app.use(cookieParser());
app.use(cors({
origin: (origin, callback) => {
// 允许的来源(开发环境+生产环境)
const allowedOrigins = [
'http://localhost:5174', // 开发环境
'https://11qh4075vd771.vicp.fun' // 实际生产环境域名
];
// 允许本地请求(origin为null的情况,如Postman)
if (!origin) return callback(null, true);
// 检查当前来源是否在允许列表中
if (allowedOrigins.includes(origin)) {
callback(null, true); // 允许跨域
} else {
callback(new Error('当前域名不允许跨域访问'), false); // 拒绝跨域
}
}, // 开发环境本地地址
credentials: true
}))
app.use(login)
app.use(setting)
// 错误处理:根据环境返回不同信息
app.use((err, req, res, next) => {
console.error(err.stack);
if (process.env.NODE_ENV === 'production') {
res.status(500).send('服务器错误'); // 生产环境隐藏详细错误
} else {
res.status(500).send(`错误:${err.message}`); // 开发环境显示详细错误
}
});
const PORT = process.env.PORT || 3001; // 从环境变量获取端口,默认3001
app.listen(PORT, () => {
console.log(`Server running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`);
});
3.2 token.js
//双token
const jwt = require('jsonwebtoken');
const JWT_SECRET = '6666QQ..!!'; // 生产环境请使用更安全的密钥
const REFRESH_SECRET = '666666^'; // 刷新令牌专用密钥
// 生成Access Token(短期有效)
const generateAccessToken = (user) => {
return jwt.sign(user, JWT_SECRET, { expiresIn: '1000s' }); // 1000秒过期
};
// 生成Refresh Token(长期有效)
const generateRefreshToken = (user) => {
return jwt.sign(user, REFRESH_SECRET, { expiresIn: '1d' }); // 1天过期
};
// 验证Access Token中间件
const authenticateJWT = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.sendStatus(401); // 未提供Token
}
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).send({ message: '令牌已过期' });
}
return res.status(403).send({ message: '令牌无效' });
}
req.user = user;
next();
});
};
// 验证Refresh Token
const verifyRefreshToken = (token) => {
try {
return jwt.verify(token, REFRESH_SECRET);
} catch (err) {
return null;
}
};
module.exports = {
JWT_SECRET,
REFRESH_SECRET,
authenticateJWT,
generateAccessToken,
generateRefreshToken,
verifyRefreshToken
};
3.3 接口示例(api>login.js)
let db = require("../db/index");
const { encrypt, decrypt } = require("../verify/AESHelper");
const {
generateAccessToken,
generateRefreshToken,
verifyRefreshToken
} = require('../verify/token');
exports.adminpswlogin = (req, res) => {
const { phone, password } = req.body;
// console.log(phone, password, decrypt(password), '传递的数据')
var sql = `select * from admin where phone='${phone}'`;
db.query(sql, [], (err, data) => {
if (err) {
console.log("出错了", err);
return res.send("错误" + err.message);
}
const user = data.find(u => u.phone === phone && decrypt(u.password) === decrypt(password));
console.log(user, '找到的数据')
if (!user) {
console.log(user);
res.send({ status: 400 }); // 账号或密码错误
} else {
// 用户信息 payload
const payload = {
phone: user.phone,
id: user.id,
level: user.level
};
// 生成双Token
const accessToken = generateAccessToken(payload);
const refreshToken = generateRefreshToken(payload);
// 存储refreshToken到数据库
const updateSql = `UPDATE admin SET refresh_token = ? WHERE id = ?`;
db.query(updateSql, [refreshToken, user.id], (err) => {
if (err) {
console.log("存储refreshToken失败", err);
return res.send({ status: 500, message: "登录失败" });
}
console.log('当前环境:', process.env.NODE_ENV);
// 设置HttpOnly Cookie存储refreshToken
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // 仅HTTP(S)协议访问,防止JS读取
secure: true,
sameSite: 'None', // 跨域开发时放宽 sameSite
maxAge: 1 * 24 * 60 * 60 * 1000 // 1天有效期
});
// 返回结果(仅返回accessToken)
res.send({
status: 200,
accessToken, // Access Token
phone: user.phone,
level: user.level,
username: user.username,
id: user.id,
accstatus: user.status
});
});
}
});
};
// 刷新AccessToken接口 - 从Cookie获取refreshToken
exports.refreshToken = (req, res) => {
// 从请求体中获取用户 ID(前端需要传递 userId)
if (process.env.NODE_ENV === 'production') {
// 1. 从Cookie中获取refreshToken
const refreshToken = req.cookies.refreshToken;
// 2. 检查refreshToken是否存在
if (!refreshToken) {
return res.send({
status: 401,
message: 'refreshToken不存在,请重新登录'
});
}
try {
// 3. 验证refreshToken的有效性
const decoded = verifyRefreshToken(refreshToken); // 假设verifyRefreshToken是验证refreshToken的函数
// 4. 从数据库查询该refreshToken是否存在(防止已注销的token被复用)
const sql = `SELECT * FROM admin WHERE id = ? AND refresh_token = ?`;
db.query(sql, [decoded.id, refreshToken], (err, data) => {
if (err) {
console.log('查询refreshToken失败', err);
return res.send({ status: 500, message: '服务器错误' });
}
// 5. 验证数据库中是否存在匹配的记录
if (data.length === 0) {
return res.send({
status: 401,
message: 'refreshToken无效或已过期,请重新登录'
});
}
// 6. 生成新的accessToken(使用相同的用户信息)
const newAccessToken = generateAccessToken({
phone: decoded.phone,
id: decoded.id,
level: decoded.level
});
// 7. 返回新的accessToken
res.send({
status: 200,
accessToken: newAccessToken,
message: 'accessToken刷新成功'
});
});
} catch (error) {
// 处理token验证失败(如过期、篡改等)
console.log('refreshToken验证失败', error);
return res.send({
status: 401,
message: 'refreshToken已过期,请重新登录'
});
}
} else {
const { userId } = req.body;
console.log(userId,'刷新token的参数');
if (!userId) {
return res.send({ status: 400, message: "缺少用户 ID" });
}
// 从数据库查询该用户的 refresh_token
const sql = `SELECT refresh_token FROM admin WHERE id = ?`;
db.query(sql, [userId], (err, data) => {
if (err) {
console.log("数据库查询错误", err);
return res.send({ status: 500, message: "服务器错误" });
}
if (data.length === 0) {
return res.send({ status: 404, message: "未找到该用户" });
}
const storedRefreshToken = data[0].refresh_token;
if (!storedRefreshToken) {
return res.send({ status: 401, message: "该用户无有效身份令牌" });
}
// 验证 refreshToken
const decoded = verifyRefreshToken(storedRefreshToken);
if (!decoded) {
return res.send({ status: 401, message: "身份令牌无效" });
}
// 生成新的 accessToken
const newAccessToken = generateAccessToken({
phone: decoded.phone, // 假设 payload 中有 phone
id: decoded.id,
level: decoded.level
});
res.send({ status: 200, accessToken: newAccessToken });
});
}
};
// 登出接口 - 清除Cookie
exports.logout = (req, res) => {
const { id } = req.body;
// 清除数据库中的refreshToken
const sql = `UPDATE admin SET refresh_token = NULL WHERE id = ?`;
db.query(sql, [id], (err) => {
if (err) {
return res.send({ status: 500, message: "登出失败" });
}
// 清除Cookie
res.clearCookie('refreshToken');
res.send({ status: 200, message: "登出成功" });
});
};
//查找admin表的所有数据
exports.getadmin = (req, res) => {
var sql = `select * from admin`;
db.query(sql, [], (err, data) => {
if (err) {
console.log("出错了", err);
return res.send("错误" + err.message);
}
res.send({ status: 200, data });
});
};
router>login.js
const express = require('express')
const router = express.Router()
const login = require('../api/login')
const {authenticateJWT}=require('../verify/token')
router.post('/adminpswlogin',login.adminpswlogin)
router.post('/refreshToken',login.refreshToken)
router.post('/getadmin',authenticateJWT,login.getadmin)
router.post('/logout', authenticateJWT, login.logout);
module.exports = router
四、前端实现
前端的核心工作是:管理 Access Token、拦截请求自动携带 Token、拦截 401 响应实现无感刷新、控制路由访问权限。
4.1axios封装
import axios from 'axios';
import { ElMessage, ElMessageBox } from 'element-plus';
import router from '@/router/index';
const service = axios.create({
baseURL: '地址',
timeout: 15000,
withCredentials: true
});
let isRefreshing = false;
let requestQueue = [];
service.interceptors.request.use(
(config) => {
const isAuth = config.headers.isAuth !== false;
if (isAuth) {
const accessToken = localStorage.getItem('access_token');
if (accessToken) {
config.headers['Authorization'] = `Bearer ${accessToken}`;
} else if (!config._isRefreshRequest) {
ElMessage.error('请先登录');
return Promise.reject(new Error('未授权'));
}
}
delete config.headers.isAuth;
return config;
},
(error) => Promise.reject(error)
);
service.interceptors.response.use(
(response) => {
console.log('响应数据:', response);
// 这里假设后端返回的有效数据在 response.data 中,且数据里有 status 字段
const res = response.data;
// 统一处理后端返回格式,判断 res 里的 status
if (res.status !== 200) {
ElMessage.error(res.message || '操作失败');
return Promise.reject(new Error(res.message || '操作失败'));
}
// 只返回数据部分
return res;
},
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
if (!originalRequest._isRefreshRequest && !isRefreshing) {
isRefreshing = true;
try {
const refreshResponse = await service.post(
'/refreshToken',
{userId:1},
{ _isRefreshRequest: true, headers: { isAuth: false } }
);
console.log('刷新令牌响应数据:', refreshResponse);
// const res = refreshResponse.data;
if (refreshResponse.status === 200 && refreshResponse.accessToken) {
localStorage.setItem('access_token', refreshResponse.accessToken);
requestQueue.forEach(cb => cb(refreshResponse.accessToken));
requestQueue = [];
originalRequest.headers['Authorization'] = `Bearer ${refreshResponse.accessToken}`;
return service(originalRequest);
} else {
throw new Error('刷新令牌失败或返回格式错误');
}
} catch (refreshError) {
localStorage.removeItem('access_token');
const currentPath = router.currentRoute.value.fullPath;
ElMessageBox.confirm(
'登录已过期,请重新登录',
'提示',
{
confirmButtonText: '重新登录',
cancelButtonText: '取消',
type: 'warning'
}
).then(() => {
router.push(`/login?redirect=${currentPath}`);
}).catch(() => {
// 用户取消登录,不做操作
});
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
} else {
return new Promise((resolve) => {
requestQueue.push((newToken) => {
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
resolve(service(originalRequest));
});
});
}
}
if (error.response) {
const errorMsg = error.response.data?.message ||
error.response.statusText ||
`请求错误: ${error.response.status}`;
switch (error.response.status) {
case 403:
ElMessage.error('没有权限访问');
break;
case 404:
ElMessage.error('请求的资源不存在');
break;
case 500:
ElMessage.error('服务器内部错误');
break;
default:
ElMessage.error(errorMsg);
}
} else if (error.message.includes('timeout')) {
ElMessage.error('请求超时,请稍后重试');
} else {
ElMessage.error('网络连接错误,请检查网络');
}
return Promise.reject(error);
}
);
const http = {
get: (url, params = {}, isAuth = true) => service.get(url, { params, headers: { isAuth } }),
post: (url, data = {}, isAuth = true) => service.post(url, data, { headers: { isAuth } }),
put: (url, data = {}, isAuth = true) => service.put(url, data, { headers: { isAuth } }),
delete: (url, params = {}, isAuth = true) => service.delete(url, { params, headers: { isAuth } }),
};
export default http;
刷新的关键

使用


更多推荐
所有评论(0)