页面基本流程
- 登录成功后,后端返回 Access Token 和 Refresh Token,前端存储两者及各自有效期。
- 每次发起业务请求前,前端判断 Access Token 是否即将过期。
- 若即将过期,先调用 "刷新 Token 接口",用有效的 Refresh Token 换取新的 Access Token。
- 用新的 Access Token 发起原业务请求,用户全程无感知。
- 若 Refresh Token 也过期,才会引导用户重新登录。
一、技术栈与核心约定
-
前端:Vue 3(适配 Vue 2,只需微调语法)+ Axios(统一请求拦截)
-
后端:Node.js + Express + JWT(生成 Token)+ Redis(存储 Refresh Token,可选但推荐)
-
Token 规则:
- Access Token:短期有效(1 小时),用于业务请求身份验证
- Refresh Token:长期有效(7 天),仅用于刷新 Access Token
- 状态码:401 = Access Token 过期 / 无效;403 = Refresh Token 过期 / 无效
二、前端实现(核心代码)
1. 初始化 Axios 实例(api/index.js)
封装请求 / 响应拦截器,处理 Token 携带、刷新和重试逻辑
javascript
import axios from 'axios';
import { ElMessage } from 'element-plus'; // 按需引入UI组件库提示(可选)
// 1. 创建Axios实例
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL, // 环境变量配置后端地址
timeout: 5000, // 请求超时时间
});
// 2. Token存取工具函数(安全存储建议用HttpOnly Cookie,此处用localStorage演示)
const TokenKey = {
ACCESS: 'access_token',
REFRESH: 'refresh_token',
};
// 获取Token
const getAccessToken = () => localStorage.getItem(TokenKey.ACCESS);
const getRefreshToken = () => localStorage.getItem(TokenKey.REFRESH);
// 存储新Token
const setTokens = (accessToken, refreshToken) => {
localStorage.setItem(TokenKey.ACCESS, accessToken);
localStorage.setItem(TokenKey.REFRESH, refreshToken);
};
// 清除Token(退出登录用)
const removeTokens = () => {
localStorage.removeItem(TokenKey.ACCESS);
localStorage.removeItem(TokenKey.REFRESH);
};
// 3. 刷新状态管理(防止并发请求重复刷新Token)
let isRefreshing = false; // 是否正在刷新Token
let requestQueue = []; // 等待刷新完成的请求队列
// 4. 请求拦截器:自动给所有请求添加Access Token
service.interceptors.request.use(
(config) => {
const token = getAccessToken();
if (token) {
// 规范格式:Bearer + 空格 + Token(后端需对应解析)
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 5. 响应拦截器:处理Token过期逻辑
service.interceptors.response.use(
(response) => response.data, // 直接返回响应体,简化业务层调用
async (error) => {
const { response, config } = error;
const originalRequest = config; // 原始失败请求
// 仅处理401状态码(Access Token过期/无效),且排除刷新Token本身的请求
if (response?.status === 401 && originalRequest.url !== '/auth/refresh') {
// 避免重复刷新:正在刷新时,将请求加入队列
if (isRefreshing) {
return new Promise((resolve) => {
requestQueue.push(() => {
// 刷新成功后,用新Token重试原始请求
originalRequest.headers.Authorization = `Bearer ${getAccessToken()}`;
resolve(service(originalRequest));
});
});
}
originalRequest._retry = true; // 标记该请求已进入重试流程
isRefreshing = true; // 开启刷新状态
try {
// 调用后端刷新接口,用Refresh Token换取新Token
const refreshToken = getRefreshToken();
if (!refreshToken) {
throw new Error('Refresh Token不存在');
}
const refreshRes = await service.post('/auth/refresh', {
refreshToken, // 传给后端的Refresh Token
});
// 存储新Token
const { accessToken, refreshToken: newRefreshToken } = refreshRes;
setTokens(accessToken, newRefreshToken);
// 重试队列中所有等待的请求
requestQueue.forEach((callback) => callback());
requestQueue = []; // 清空队列
// 重试当前失败的请求
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return service(originalRequest);
} catch (refreshError) {
// 刷新失败(Refresh Token过期/无效),强制跳转登录页
removeTokens(); // 清除本地无效Token
ElMessage.error('登录已过期,请重新登录');
window.location.href = '/login'; // 跳转到登录页
return Promise.reject(refreshError);
} finally {
isRefreshing = false; // 关闭刷新状态
}
}
// 非401错误(如网络错误、业务错误),直接抛出
ElMessage.error(error.message || '请求失败');
return Promise.reject(error);
}
);
export default service;
2. 登录与业务请求示例(api/user.js)
javascript
import service from './index';
// 登录:获取初始双Token
export const login = (username, password) => {
return service.post('/auth/login', { username, password });
};
// 业务请求示例(无需手动处理Token)
export const getUserInfo = () => {
return service.get('/user/info');
};
// 退出登录:清除Token
export const logout = () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
window.location.href = '/login';
};
3. 登录页面使用示例(Login.vue)
xml
<template>
<div>
<input v-model="username" placeholder="用户名" />
<input v-model="password" type="password" placeholder="密码" />
<button @click="handleLogin">登录</button>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { login } from '@/api/user';
import { ElMessage } from 'element-plus';
const username = ref('');
const password = ref('');
const handleLogin = async () => {
try {
// 调用登录接口,后端返回accessToken和refreshToken
const res = await login(username.value, password.value);
// 存储Token(实际已在api拦截器中处理,此处简化)
localStorage.setItem('access_token', res.accessToken);
localStorage.setItem('refresh_token', res.refreshToken);
ElMessage.success('登录成功');
window.location.href = '/home'; // 跳转到首页
} catch (error) {
ElMessage.error('登录失败,请检查账号密码');
}
};
</script>
三、后端实现(Node.js + Express)
1. 依赖安装
arduino
npm install express jsonwebtoken redis cors dotenv // 核心依赖
2. 核心配置(config.js)
arduino
require('dotenv').config();
module.exports = {
// JWT密钥(生产环境需用环境变量,避免硬编码)
JWT_SECRET: process.env.JWT_SECRET || 'your-secret-key-321',
// Token有效期
ACCESS_TOKEN_EXPIRES: '1h', // 1小时
REFRESH_TOKEN_EXPIRES: '7d', // 7天
// Redis配置(存储Refresh Token,防止重复使用)
REDIS: {
host: 'localhost',
port: 6379,
db: 0,
},
};
3. JWT 工具函数(utils/jwt.js)
javascript
javascript
const jwt = require('jsonwebtoken');
const config = require('../config');
// 生成Token
const generateToken = (payload, expiresIn) => {
return jwt.sign(payload, config.JWT_SECRET, { expiresIn });
};
// 验证Token
const verifyToken = (token) => {
try {
return jwt.verify(token, config.JWT_SECRET);
} catch (error) {
throw new Error('Token无效或已过期');
}
};
module.exports = { generateToken, verifyToken };
4. Redis 工具函数(utils/redis.js)
javascript
const redis = require('redis');
const config = require('../config');
// 创建Redis客户端
const client = redis.createClient({
host: config.REDIS.host,
port: config.REDIS.port,
db: config.REDIS.db,
});
// 连接Redis
client.connect().catch((err) => console.error('Redis连接失败:', err));
// 存储Refresh Token(key: userId, value: refreshToken)
const setRefreshToken = async (userId, refreshToken) => {
// 有效期与Refresh Token一致(7天)
await client.setEx(`refresh_token:${userId}`, 60 * 60 * 24 * 7, refreshToken);
};
// 获取Refresh Token
const getRefreshToken = async (userId) => {
return await client.get(`refresh_token:${userId}`);
};
// 删除Refresh Token(退出登录时)
const deleteRefreshToken = async (userId) => {
await client.del(`refresh_token:${userId}`);
};
module.exports = { setRefreshToken, getRefreshToken, deleteRefreshToken };
5. 核心接口实现(routes/auth.js)
php
const express = require('express');
const router = express.Router();
const { generateToken, verifyToken } = require('../utils/jwt');
const { setRefreshToken, getRefreshToken, deleteRefreshToken } = require('../utils/redis');
const config = require('../config');
// 模拟用户数据库(实际替换为MySQL/MongoDB)
const mockUsers = [
{ id: 1, username: 'admin', password: '123456' },
];
// 1. 登录接口:生成双Token
router.post('/login', (req, res) => {
const { username, password } = req.body;
// 验证账号密码
const user = mockUsers.find(
(u) => u.username === username && u.password === password
);
if (!user) {
return res.status(400).json({ message: '账号或密码错误' });
}
// 生成双Token(payload中存储用户唯一标识,避免敏感信息)
const accessToken = generateToken({ userId: user.id }, config.ACCESS_TOKEN_EXPIRES);
const refreshToken = generateToken({ userId: user.id }, config.REFRESH_TOKEN_EXPIRES);
// 存储Refresh Token到Redis(用于后续验证)
setRefreshToken(user.id, refreshToken);
// 返回双Token给前端
res.json({
code: 200,
message: '登录成功',
data: { accessToken, refreshToken },
});
});
// 2. 刷新Token接口:用有效Refresh Token换取新双Token
router.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(403).json({ message: 'Refresh Token不能为空' });
}
try {
// 1. 验证Refresh Token有效性
const payload = verifyToken(refreshToken);
const { userId } = payload;
// 2. 验证Redis中存储的Refresh Token是否一致(防止伪造)
const storedRefreshToken = await getRefreshToken(userId);
if (storedRefreshToken !== refreshToken) {
return res.status(403).json({ message: 'Refresh Token无效' });
}
// 3. 生成新的双Token
const newAccessToken = generateToken({ userId }, config.ACCESS_TOKEN_EXPIRES);
const newRefreshToken = generateToken({ userId }, config.REFRESH_TOKEN_EXPIRES);
// 4. 更新Redis中的Refresh Token(滑动过期,增强安全性)
await setRefreshToken(userId, newRefreshToken);
// 5. 返回新Token
res.json({
code: 200,
data: { accessToken: newAccessToken, refreshToken: newRefreshToken },
});
} catch (error) {
return res.status(403).json({ message: 'Refresh Token已过期,请重新登录' });
}
});
// 3. 退出登录接口:删除Redis中的Refresh Token
router.post('/logout', async (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(400).json({ message: 'Token不能为空' });
}
try {
const payload = verifyToken(token);
await deleteRefreshToken(payload.userId);
res.json({ code: 200, message: '退出登录成功' });
} catch (error) {
res.status(400).json({ message: '退出登录失败' });
}
});
module.exports = router;
6. 后端入口文件(app.js)
ini
const express = require('express');
const cors = require('cors');
const authRouter = require('./routes/auth');
const app = express();
const port = 3001;
// 跨域配置(生产环境需限制origin)
app.use(cors());
// 解析JSON请求体
app.use(express.json());
// 挂载路由
app.use('/api/auth', authRouter);
// 启动服务
app.listen(port, () => {
console.log(`后端服务启动成功:http://localhost:${port}`);
});
四、关键注意事项(生产环境必看)
-
安全存储 Token
- 不推荐用 localStorage 存储(易受 XSS 攻击),优先用 HttpOnly Cookie 存储 Refresh Token,前端无法读取,避免窃取。
- Access Token 可存在内存(如 Vuex/Pinia),页面刷新后通过 Cookie 获取 Refresh Token 重新刷新。
-
防止重复刷新
- 用
isRefreshing状态和requestQueue队列,避免多个并发请求同时触发刷新接口,导致 Token 冲突。
- 用
-
Redis 的必要性
- 存储 Refresh Token 到 Redis,支持 "强制登出""单点登录" 功能(如修改密码后,删除 Redis 中的旧 Refresh Token,强制用户重新登录)。
-
HTTPS 协议
- 生产环境必须启用 HTTPS,防止 Token 在传输过程中被中间人窃取。
-
Token 有效期合理设置
- Access Token:15 分钟~2 小时(越短越安全)。
- Refresh Token:7~30 天(平衡安全性和用户体验)。
五、完整流程梳理
- 用户登录 → 后端验证账号密码 → 返回 Access Token 和 Refresh Token → 前端存储。
- 前端发起业务请求 → 拦截器自动携带 Access Token → 后端验证有效 → 返回业务数据。
- 若 Access Token 过期 → 后端返回 401 → 前端拦截器调用刷新接口。
- 刷新接口验证 Refresh Token 有效 → 返回新双 Token → 前端更新存储,重试原始请求。
- 若 Refresh Token 过期 → 前端清除 Token,跳转登录页。