趣动WillGo手机版
233.06MB · 2025-10-14
想象一下,你正在沉浸式地刷着短视频,或者在电商网站上挑选心仪的商品,突然,屏幕上弹出一个刺眼的提示:“登录已过期,请重新登录!” 瞬间,好心情烟消云散,你不得不中断手头的操作,重新输入账号密码。这种体验是不是糟糕透顶?就像你正在厨房里大展厨艺,突然停电了一样,所有的努力都白费了。
在Web应用中,为了保障用户数据安全,我们通常会设置会话(Session)或令牌(Token)的有效期。但过短的有效期会频繁打断用户体验,过长的有效期又会增加安全风险。那么,有没有一种方法,既能保证安全,又能让用户“无感”地保持在线呢?答案就是——双Token认证机制,配合无感刷新技术,让你的应用像拥有“永动机”一样,持续为用户提供丝滑体验!
双Token机制,顾名思义,就是使用两种不同类型的令牌来管理用户认证状态。它们就像一对“黄金搭档”,各司其职,共同守护着你的应用安全和用户体验。
Access Token,我们称之为短令牌,它的生命周期通常较短(比如15分钟到1小时)。它就像你进入一个高级俱乐部的临时通行证。每次你向服务器发起请求(比如获取个人信息、发布动态),都会带着这个通行证。服务器会快速验证它的有效性,如果有效,就放行;如果无效,就拒绝。由于它有效期短,即使被恶意截获,造成的损失也有限。
Refresh Token,我们称之为长令牌,它的生命周期相对较长(比如7天、30天甚至更久)。它就像你俱乐部会员卡的续费凭证。当你的Access Token过期时,你不需要重新登录,而是拿着这个Refresh Token去向服务器“续费”,服务器验证Refresh Token有效后,会给你颁发一个新的Access Token(通常还会附带一个新的Refresh Token)。由于它只用于刷新Access Token,并且通常有额外的安全措施(比如只能使用一次,或者绑定IP),所以安全性更高。
为了更直观地理解双Token的无感刷新流程,我们来看一张流程图:
流程解析:
前端实现无感刷新的核心在于请求拦截器和响应拦截器。我们将使用Vue作为前端框架,Axios作为HTTP请求库。
用户登录成功后,将服务器返回的Access Token和Refresh Token存储起来。通常Access Token会存储在内存中(Vuex/Pinia),或者Cookie中,而Refresh Token为了安全考虑,可以存储在HttpOnly的Cookie中,或者LocalStorage中。
// api.js
import axios from 'axios';
const service = axios.create({
baseURL: 'http://localhost:3000',
timeout: 5000,
});
// 登录函数
export const userLogin = async (data) => {
const res = await service.post('/login', data);
const { access_token, refresh_token, userInfo } = res.data;
if (access_token) {
localStorage.setItem('access_token', access_token); // 示例:存储在LocalStorage
localStorage.setItem('refresh_token', refresh_token); // 示例:存储在LocalStorage
// 也可以将access_token存储在Vuex/Pinia或内存中,refresh_token存储在HttpOnly Cookie中
}
return res;
};
// ... 其他API请求
通过Axios的请求拦截器,在每次发送请求前,自动将Access Token添加到请求头中。
// api.js (续)
// 请求拦截器
service.interceptors.request.use(
(config) => {
const accessToken = localStorage.getItem('access_token');
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// ...
这是无感刷新的核心逻辑。当服务器返回401错误(表示Access Token过期)时,我们利用响应拦截器,在不打断用户操作的情况下,悄悄地进行令牌刷新。
// api.js (续)
let isRefreshing = false; // 标记是否正在刷新Token
let requests = []; // 存储刷新Token期间的请求
// 刷新Token的函数
async function refreshTokenRequest() {
try {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) {
// 如果没有Refresh Token,直接跳转登录页
window.location.href = '/login';
return Promise.reject('No refresh token found');
}
const res = await service.get('/refresh', {
params: { token: refreshToken },
});
const { access_token, refresh_token } = res.data;
localStorage.setItem('access_token', access_token);
localStorage.setItem('refresh_token', refresh_token);
return Promise.resolve(access_token);
} catch (error) {
// Refresh Token也失效,跳转登录页
window.location.href = '/login';
return Promise.reject(error);
}
}
// 响应拦截器
service.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const { config, response } = error;
if (response && response.status === 401) {
// 避免/refresh接口本身因为401而无限循环刷新
if (config.url === '/refresh') {
window.location.href = '/login';
return Promise.reject(error);
}
// 如果正在刷新,则将当前请求加入队列等待
if (isRefreshing) {
return new Promise((resolve) => {
requests.push(() => {
resolve(service(config));
});
});
}
isRefreshing = true;
try {
const newAccessToken = await refreshTokenRequest();
// 刷新成功后,将队列中的请求重新发起
requests.forEach((cb) => cb(newAccessToken));
requests = []; // 清空队列
// 重新发起之前失败的请求
return service(config);
} catch (refreshError) {
// 刷新失败,跳转登录页
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default service;
代码解析:
isRefreshing
标志位:防止在短时间内因多个请求同时401而触发多次Token刷新,造成不必要的资源浪费和潜在的竞态条件。requests
队列:当Token正在刷新时,所有后续的API请求都会被暂停并加入到这个队列中。一旦Token刷新成功,队列中的请求会被重新执行。refreshTokenRequest
函数:专门用于向后端发起Refresh Token请求,获取新的Access Token和Refresh Token。后端主要负责Access Token和Refresh Token的生成、验证以及刷新逻辑。这里我们以Node.js + Koa2为例,并使用jsonwebtoken
库来处理JWT(JSON Web Token)。
用户登录成功后,后端会根据用户信息生成Access Token和Refresh Token,并设置不同的有效期。
// utils/jwt.js
const jwt = require('jsonwebtoken');
const secret = 'your_secret_key'; // 生产环境中请使用更复杂的密钥,并妥善保管
// 生成Token
function generateToken(payload, expiresIn) {
return jwt.sign(payload, secret, { expiresIn });
}
// 验证Token
function verifyToken(token) {
return jwt.verify(token, secret);
}
module.exports = { generateToken, verifyToken };
// app.js (Koa2 示例)
const Koa = require('koa');
const Router = require('@koa/router');
const bodyParser = require('koa-bodyparser');
const cors = require('@koa/cors');
const { generateToken, verifyToken } = require('./utils/jwt');
const app = new Koa();
const router = new Router();
app.use(cors());
app.use(bodyParser());
const users = [
{ id: 1, username: 'testuser', password: 'password123', email: '[email protected]' },
];
router.post('/login', async (ctx) => {
const { username, password } = ctx.request.body;
const user = users.find((u) => u.username === username && u.password === password);
if (!user) {
ctx.status = 401;
ctx.body = { message: '用户名或密码错误' };
return;
}
const payload = { id: user.id, username: user.username };
const accessToken = generateToken(payload, '1h'); // 1小时有效期
const refreshToken = generateToken(payload, '7d'); // 7天有效期
ctx.body = {
message: '登录成功',
access_token: accessToken,
refresh_token: refreshToken,
userInfo: { id: user.id, username: user.username, email: user.email },
};
});
// ... 其他需要认证的路由
app.use(router.routes()).use(router.allowedMethods());
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
后端提供一个专门的接口用于刷新令牌。这个接口只接收Refresh Token,并验证其有效性。如果有效,就颁发新的Access Token和Refresh Token。
// app.js (Koa2 示例 续)
router.get('/refresh', async (ctx) => {
const { token: oldRefreshToken } = ctx.query;
if (!oldRefreshToken) {
ctx.status = 401;
ctx.body = { message: 'Refresh Token缺失' };
return;
}
try {
const decoded = verifyToken(oldRefreshToken);
const payload = { id: decoded.id, username: decoded.username };
const newAccessToken = generateToken(payload, '1h');
const newRefreshToken = generateToken(payload, '7d');
ctx.body = {
message: 'Token刷新成功',
access_token: newAccessToken,
refresh_token: newRefreshToken,
};
} catch (error) {
ctx.status = 401;
ctx.body = { message: 'Refresh Token无效或已过期,请重新登录' };
}
});
// 示例:一个需要认证的API
router.get('/profile', async (ctx) => {
const authHeader = ctx.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
ctx.status = 401;
ctx.body = { message: '未提供Access Token' };
return;
}
const accessToken = authHeader.split(' ')[1];
try {
const decoded = verifyToken(accessToken);
ctx.body = { message: `欢迎,${decoded.username}!这是您的个人资料。`, user: decoded };
} catch (error) {
ctx.status = 401;
ctx.body = { message: 'Access Token无效或已过期' };
}
});
// ...
双Token认证机制结合无感刷新,无疑是提升用户体验和应用安全性的一个优雅解决方案。它通过将令牌分为短效和长效两种,实现了安全与便利的平衡。当Access Token过期时,用户无需感知,系统会在后台悄悄地完成令牌刷新,确保用户体验的连贯性。
当然,在实际应用中,还有一些细节需要考虑:
希望通过这篇博客,你能对双Token认证机制和无感刷新有更深入的理解。告别频繁登录,让你的应用拥有更流畅的用户体验吧!
What Are Refresh Tokens and How to Use Them Securely - Auth0 Blog
JWT Security Best Practices - Curity
233.06MB · 2025-10-14
126.01MB · 2025-10-14
238.09MB · 2025-10-14