Vue+Node.js實(shí)現(xiàn)Token無(wú)感刷新的全流程指南
頁(yè)面基本流程
- 登錄成功后,后端返回 Access Token 和 Refresh Token,前端存儲(chǔ)兩者及各自有效期。
- 每次發(fā)起業(yè)務(wù)請(qǐng)求前,前端判斷 Access Token 是否即將過(guò)期。
- 若即將過(guò)期,先調(diào)用 “刷新 Token 接口”,用有效的 Refresh Token 換取新的 Access Token。
- 用新的 Access Token 發(fā)起原業(yè)務(wù)請(qǐng)求,用戶全程無(wú)感知。
- 若 Refresh Token 也過(guò)期,才會(huì)引導(dǎo)用戶重新登錄。
一、技術(shù)棧與核心約定
前端:Vue 3(適配 Vue 2,只需微調(diào)語(yǔ)法)+ Axios(統(tǒng)一請(qǐng)求攔截)
后端:Node.js + Express + JWT(生成 Token)+ Redis(存儲(chǔ) Refresh Token,可選但推薦)
Token 規(guī)則:
- Access Token:短期有效(1 小時(shí)),用于業(yè)務(wù)請(qǐng)求身份驗(yàn)證
- Refresh Token:長(zhǎng)期有效(7 天),僅用于刷新 Access Token
- 狀態(tài)碼:401 = Access Token 過(guò)期 / 無(wú)效;403 = Refresh Token 過(guò)期 / 無(wú)效
二、前端實(shí)現(xiàn)(核心代碼)
1. 初始化 Axios 實(shí)例(api/index.js)
封裝請(qǐng)求 / 響應(yīng)攔截器,處理 Token 攜帶、刷新和重試邏輯
import axios from 'axios';
import { ElMessage } from 'element-plus'; // 按需引入U(xiǎn)I組件庫(kù)提示(可選)
// 1. 創(chuàng)建Axios實(shí)例
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL, // 環(huán)境變量配置后端地址
timeout: 5000, // 請(qǐng)求超時(shí)時(shí)間
});
// 2. Token存取工具函數(shù)(安全存儲(chǔ)建議用HttpOnly Cookie,此處用localStorage演示)
const TokenKey = {
ACCESS: 'access_token',
REFRESH: 'refresh_token',
};
// 獲取Token
const getAccessToken = () => localStorage.getItem(TokenKey.ACCESS);
const getRefreshToken = () => localStorage.getItem(TokenKey.REFRESH);
// 存儲(chǔ)新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. 刷新狀態(tài)管理(防止并發(fā)請(qǐng)求重復(fù)刷新Token)
let isRefreshing = false; // 是否正在刷新Token
let requestQueue = []; // 等待刷新完成的請(qǐng)求隊(duì)列
// 4. 請(qǐng)求攔截器:自動(dòng)給所有請(qǐng)求添加Access Token
service.interceptors.request.use(
(config) => {
const token = getAccessToken();
if (token) {
// 規(guī)范格式:Bearer + 空格 + Token(后端需對(duì)應(yīng)解析)
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 5. 響應(yīng)攔截器:處理Token過(guò)期邏輯
service.interceptors.response.use(
(response) => response.data, // 直接返回響應(yīng)體,簡(jiǎn)化業(yè)務(wù)層調(diào)用
async (error) => {
const { response, config } = error;
const originalRequest = config; // 原始失敗請(qǐng)求
// 僅處理401狀態(tài)碼(Access Token過(guò)期/無(wú)效),且排除刷新Token本身的請(qǐng)求
if (response?.status === 401 && originalRequest.url !== '/auth/refresh') {
// 避免重復(fù)刷新:正在刷新時(shí),將請(qǐng)求加入隊(duì)列
if (isRefreshing) {
return new Promise((resolve) => {
requestQueue.push(() => {
// 刷新成功后,用新Token重試原始請(qǐng)求
originalRequest.headers.Authorization = `Bearer ${getAccessToken()}`;
resolve(service(originalRequest));
});
});
}
originalRequest._retry = true; // 標(biāo)記該請(qǐng)求已進(jìn)入重試流程
isRefreshing = true; // 開啟刷新狀態(tài)
try {
// 調(diào)用后端刷新接口,用Refresh Token換取新Token
const refreshToken = getRefreshToken();
if (!refreshToken) {
throw new Error('Refresh Token不存在');
}
const refreshRes = await service.post('/auth/refresh', {
refreshToken, // 傳給后端的Refresh Token
});
// 存儲(chǔ)新Token
const { accessToken, refreshToken: newRefreshToken } = refreshRes;
setTokens(accessToken, newRefreshToken);
// 重試隊(duì)列中所有等待的請(qǐng)求
requestQueue.forEach((callback) => callback());
requestQueue = []; // 清空隊(duì)列
// 重試當(dāng)前失敗的請(qǐng)求
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return service(originalRequest);
} catch (refreshError) {
// 刷新失?。≧efresh Token過(guò)期/無(wú)效),強(qiáng)制跳轉(zhuǎn)登錄頁(yè)
removeTokens(); // 清除本地?zé)o效Token
ElMessage.error('登錄已過(guò)期,請(qǐng)重新登錄');
window.location.href = '/login'; // 跳轉(zhuǎn)到登錄頁(yè)
return Promise.reject(refreshError);
} finally {
isRefreshing = false; // 關(guān)閉刷新狀態(tài)
}
}
// 非401錯(cuò)誤(如網(wǎng)絡(luò)錯(cuò)誤、業(yè)務(wù)錯(cuò)誤),直接拋出
ElMessage.error(error.message || '請(qǐng)求失敗');
return Promise.reject(error);
}
);
export default service;
2. 登錄與業(yè)務(wù)請(qǐng)求示例(api/user.js)
import service from './index';
// 登錄:獲取初始雙Token
export const login = (username, password) => {
return service.post('/auth/login', { username, password });
};
// 業(yè)務(wù)請(qǐng)求示例(無(wú)需手動(dòng)處理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. 登錄頁(yè)面使用示例(Login.vue)
<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 {
// 調(diào)用登錄接口,后端返回accessToken和refreshToken
const res = await login(username.value, password.value);
// 存儲(chǔ)Token(實(shí)際已在api攔截器中處理,此處簡(jiǎn)化)
localStorage.setItem('access_token', res.accessToken);
localStorage.setItem('refresh_token', res.refreshToken);
ElMessage.success('登錄成功');
window.location.href = '/home'; // 跳轉(zhuǎn)到首頁(yè)
} catch (error) {
ElMessage.error('登錄失敗,請(qǐng)檢查賬號(hào)密碼');
}
};
</script>
三、后端實(shí)現(xiàn)(Node.js + Express)
1. 依賴安裝
npm install express jsonwebtoken redis cors dotenv // 核心依賴
2. 核心配置(config.js)
require('dotenv').config();
module.exports = {
// JWT密鑰(生產(chǎn)環(huán)境需用環(huán)境變量,避免硬編碼)
JWT_SECRET: process.env.JWT_SECRET || 'your-secret-key-321',
// Token有效期
ACCESS_TOKEN_EXPIRES: '1h', // 1小時(shí)
REFRESH_TOKEN_EXPIRES: '7d', // 7天
// Redis配置(存儲(chǔ)Refresh Token,防止重復(fù)使用)
REDIS: {
host: 'localhost',
port: 6379,
db: 0,
},
};
3. JWT 工具函數(shù)(utils/jwt.js)
javascript
const jwt = require('jsonwebtoken');
const config = require('../config');
// 生成Token
const generateToken = (payload, expiresIn) => {
return jwt.sign(payload, config.JWT_SECRET, { expiresIn });
};
// 驗(yàn)證Token
const verifyToken = (token) => {
try {
return jwt.verify(token, config.JWT_SECRET);
} catch (error) {
throw new Error('Token無(wú)效或已過(guò)期');
}
};
module.exports = { generateToken, verifyToken };
4. Redis 工具函數(shù)(utils/redis.js)
const redis = require('redis');
const config = require('../config');
// 創(chuàng)建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));
// 存儲(chǔ)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(退出登錄時(shí))
const deleteRefreshToken = async (userId) => {
await client.del(`refresh_token:${userId}`);
};
module.exports = { setRefreshToken, getRefreshToken, deleteRefreshToken };
5. 核心接口實(shí)現(xiàn)(routes/auth.js)
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');
// 模擬用戶數(shù)據(jù)庫(kù)(實(shí)際替換為MySQL/MongoDB)
const mockUsers = [
{ id: 1, username: 'admin', password: '123456' },
];
// 1. 登錄接口:生成雙Token
router.post('/login', (req, res) => {
const { username, password } = req.body;
// 驗(yàn)證賬號(hào)密碼
const user = mockUsers.find(
(u) => u.username === username && u.password === password
);
if (!user) {
return res.status(400).json({ message: '賬號(hào)或密碼錯(cuò)誤' });
}
// 生成雙Token(payload中存儲(chǔ)用戶唯一標(biāo)識(shí),避免敏感信息)
const accessToken = generateToken({ userId: user.id }, config.ACCESS_TOKEN_EXPIRES);
const refreshToken = generateToken({ userId: user.id }, config.REFRESH_TOKEN_EXPIRES);
// 存儲(chǔ)Refresh Token到Redis(用于后續(xù)驗(yàn)證)
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. 驗(yàn)證Refresh Token有效性
const payload = verifyToken(refreshToken);
const { userId } = payload;
// 2. 驗(yàn)證Redis中存儲(chǔ)的Refresh Token是否一致(防止偽造)
const storedRefreshToken = await getRefreshToken(userId);
if (storedRefreshToken !== refreshToken) {
return res.status(403).json({ message: 'Refresh Token無(wú)效' });
}
// 3. 生成新的雙Token
const newAccessToken = generateToken({ userId }, config.ACCESS_TOKEN_EXPIRES);
const newRefreshToken = generateToken({ userId }, config.REFRESH_TOKEN_EXPIRES);
// 4. 更新Redis中的Refresh Token(滑動(dòng)過(guò)期,增強(qiáng)安全性)
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已過(guò)期,請(qǐng)重新登錄' });
}
});
// 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)
const express = require('express');
const cors = require('cors');
const authRouter = require('./routes/auth');
const app = express();
const port = 3001;
// 跨域配置(生產(chǎn)環(huán)境需限制origin)
app.use(cors());
// 解析JSON請(qǐng)求體
app.use(express.json());
// 掛載路由
app.use('/api/auth', authRouter);
// 啟動(dòng)服務(wù)
app.listen(port, () => {
console.log(`后端服務(wù)啟動(dòng)成功:http://localhost:${port}`);
});
四、關(guān)鍵注意事項(xiàng)(生產(chǎn)環(huán)境必看)
安全存儲(chǔ) Token
- 不推薦用 localStorage 存儲(chǔ)(易受 XSS 攻擊),優(yōu)先用 HttpOnly Cookie 存儲(chǔ) Refresh Token,前端無(wú)法讀取,避免竊取。
- Access Token 可存在內(nèi)存(如 Vuex/Pinia),頁(yè)面刷新后通過(guò) Cookie 獲取 Refresh Token 重新刷新。
防止重復(fù)刷新
用isRefreshing狀態(tài)和requestQueue隊(duì)列,避免多個(gè)并發(fā)請(qǐng)求同時(shí)觸發(fā)刷新接口,導(dǎo)致 Token 沖突。
Redis 的必要性
存儲(chǔ) Refresh Token 到 Redis,支持 “強(qiáng)制登出”“單點(diǎn)登錄” 功能(如修改密碼后,刪除 Redis 中的舊 Refresh Token,強(qiáng)制用戶重新登錄)。
HTTPS 協(xié)議
生產(chǎn)環(huán)境必須啟用 HTTPS,防止 Token 在傳輸過(guò)程中被中間人竊取。
Token 有效期合理設(shè)置
- Access Token:15 分鐘~2 小時(shí)(越短越安全)。
- Refresh Token:7~30 天(平衡安全性和用戶體驗(yàn))。
五、完整流程梳理
用戶登錄 → 后端驗(yàn)證賬號(hào)密碼 → 返回 Access Token 和 Refresh Token → 前端存儲(chǔ)。
前端發(fā)起業(yè)務(wù)請(qǐng)求 → 攔截器自動(dòng)攜帶 Access Token → 后端驗(yàn)證有效 → 返回業(yè)務(wù)數(shù)據(jù)。
若 Access Token 過(guò)期 → 后端返回 401 → 前端攔截器調(diào)用刷新接口。
刷新接口驗(yàn)證 Refresh Token 有效 → 返回新雙 Token → 前端更新存儲(chǔ),重試原始請(qǐng)求。
若 Refresh Token 過(guò)期 → 前端清除 Token,跳轉(zhuǎn)登錄頁(yè)。
以上就是Vue+Node.js實(shí)現(xiàn)Token無(wú)感刷新的全流程指南的詳細(xì)內(nèi)容,更多關(guān)于Vue Token無(wú)感刷新的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
- 前端登錄token失效實(shí)現(xiàn)雙Token無(wú)感刷新詳細(xì)步驟
- 雙Token實(shí)現(xiàn)無(wú)感刷新的完整代碼示例
- 雙token無(wú)感刷新nodejs+React詳細(xì)解釋(保姆級(jí)教程)
- node.js實(shí)現(xiàn)雙Token+Cookie存儲(chǔ)+無(wú)感刷新機(jī)制的示例
- 前端實(shí)現(xiàn)Token無(wú)感刷新的思路和具體實(shí)現(xiàn)方法
- 前端無(wú)感刷新token的實(shí)現(xiàn)步驟
- Vue項(xiàng)目中實(shí)現(xiàn)無(wú)感Token刷新的示例
- 前端雙token無(wú)感刷新圖文詳解
- 利用Axios實(shí)現(xiàn)無(wú)感知雙Token刷新的詳細(xì)教程
相關(guān)文章
vue實(shí)現(xiàn)日歷表格(element-ui)
這篇文章主要為大家詳細(xì)介紹了vue實(shí)現(xiàn)日歷表格(element-ui),文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2020-09-09
vue如何在for循環(huán)中設(shè)置ref并獲取$refs
眾所周知在寫循環(huán)的時(shí)候給循環(huán)中的數(shù)據(jù)定義ref以便再下面直接通過(guò)this.$ref.來(lái)訪問(wèn),下面這篇文章主要給大家介紹了關(guān)于vue如何在for循環(huán)中設(shè)置ref并獲取$refs的相關(guān)資料,需要的朋友可以參考下2022-12-12
vue實(shí)現(xiàn)過(guò)渡動(dòng)畫Message消息提示組件示例詳解
這篇文章主要為大家介紹了vue實(shí)現(xiàn)過(guò)渡動(dòng)畫Message消息提示組件示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-07-07
vue如何統(tǒng)一樣式(reset.css與border.css)
這篇文章主要介紹了vue如何統(tǒng)一樣式(reset.css與border.css),具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-05-05
使用Vue.js實(shí)現(xiàn)數(shù)據(jù)的雙向綁定
在Vue.js中,雙向數(shù)據(jù)綁定是一項(xiàng)非常強(qiáng)大的功能,它能夠使數(shù)據(jù)和視圖之間保持同步,讓開發(fā)者更加方便地操作數(shù)據(jù),在本文中,我們將介紹如何用Vue.js實(shí)現(xiàn)數(shù)據(jù)的雙向綁定,需要的朋友可以參考下2023-04-04
vue-echarts高度縮小時(shí)autoresize失效的原因和解決辦法
Vue-Echarts是一個(gè)基于ECharts封裝的輕量級(jí)、易用的圖表組件庫(kù),它允許你在Vue.js應(yīng)用中方便地集成ECharts,這是一個(gè)強(qiáng)大而直觀的數(shù)據(jù)可視化庫(kù),本文給大家介紹了vue-echarts高度縮小時(shí)autoresize失效的原因和解決辦法,需要的朋友可以參考下2024-12-12
解決iview多表頭動(dòng)態(tài)更改列元素發(fā)生的錯(cuò)誤的方法
這篇文章主要介紹了解決iview多表頭動(dòng)態(tài)更改列元素發(fā)生的錯(cuò)誤的方法,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2018-11-11
vue-element-admin開發(fā)教程(v4.0.0之前)
本文主要介紹了vue-element-admin開發(fā)教程(v4.0.0之前),文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2022-04-04
Vue3實(shí)現(xiàn)跨頁(yè)面?zhèn)髦档膸追N常見方法
在Vue 3中,跨頁(yè)面?zhèn)髦悼梢酝ㄟ^(guò)多種方式實(shí)現(xiàn),具體選擇哪種方法取決于應(yīng)用的具體需求和頁(yè)面間的關(guān)系,本文列舉了幾種常見的跨頁(yè)面?zhèn)髦捣椒?感興趣的同學(xué)跟著小編來(lái)看看吧2024-04-04

