什么是token?
JSON Web Token 入门教程 - 阮一峰的网络日志
你是否曾被突然弹出的“登录已过期”提示打断工作节奏?是否厌倦了在重要操作时被迫重新认证的糟糕体验?
本文揭秘的双Token无感刷新机制,正是解决这些痛点的终极方案。
双 Token 无感刷新机制详解:从原理到代码实现
为什么需要双token?
传统的基于单 Token 的认证方案在 Token 过期时会给用户带来突然的中断,体验很差。而 双 Token 无感刷新机制 正是为了解决这一痛点而设计的方案。我将结合一段完整的 Koa 后端代码,讲解这一机制的实现原理。
一、传统单 Token 认证
// utils/jwt.js - 传统的verify中间件
function verify() {
return async(ctx, next) => {
const token = ctx.headers.authorization; // 1. 从请求头获取Token
if (token) {
try {
const decoded = jwt.verify(token, '666'); // 2. 校验Token
if (decoded.id) {
ctx.userId=decoded.id; // 3. 校验成功,将用户信息注入上下文
await next();
}
} catch (error) { // 4. 校验失败(过期或篡改)
ctx.status = 401; // 5. 返回401状态码
ctx.body = { code: '0', msg: '登录失效' };
}
} else { // 6. 根本没有Token
ctx.status = 401;
ctx.body = { code: '0', msg: '请重新登录' };
}
}
}
问题:
- 前端在请求头中携带
Authorization。 - 后端校验 Token 有效性。
- 若有效,继续处理请求;若过期或无效,则返回
401。 - 前端收到
401,必须强制跳转到登录页面。 - 当用户正在操作时,Token 突然过期,会立刻被中断,体验非常不友好。
二、双 Token 方案的核心
为了解决这个问题,可以引入两个 Token:
- Access Token (短 Token):用于访问受保护资源,生命周期短(如1小时)。
- Refresh Token (长 Token):专用于在 Access Token 过期后,获取新的双 Token,生命周期长(如7天)。
其核心工作流程如下图所示,它清晰地展示了两个 Token 如何协同工作以实现“无感”刷新:
1. 登录:双 Token
在登录接口中,不再只返回一个 Token,而是同时生成并返回两个 Token。
// controllers/index.js - 登录接口
router.post("/login", async (ctx) => {
let { username, password } = ctx.request.body;
// ... 数据库验证逻辑 ...
if (res.length) { // 验证成功
let data = { // 准备存入Token的数据
id: res[0].id,
username: res[0].username,
nickname: res[0].nickname,
create_time: res[0].create_time,
};
// 核心代码:生成两种Token
const access_token = sign(data, "1h"); // 短Token,1小时后过期
const refresh_token = sign(data, "7d"); // 长Token,7天后过期
ctx.body = {
code: "1",
msg: "登录成功",
data: data,
access_token: access_token, // 发给前端
refresh_token: refresh_token, // 发给前端
};
}
});
前端任务:登录成功后,必须将 access_token 和 refresh_token 安全地存储在本地(例如 localStorage 或 sessionStorage)。
2. 无感刷新的关键:Refresh 接口
这是实现无感刷新的最关键部分。它接收一个旧的 Refresh Token,验证通过后,返回一套全新的双 Token。
// controllers/index.js - 刷新Token接口
router.post("/refresh", (ctx) => {
// 1. 从请求体中获取长Token
const { refresh_token } = ctx.request.body;
// 2. 使用专用函数验证长Token
const decoded = refreshVerify(refresh_token);
if (decoded.id) { // 3. 验证通过
const data = { // 从旧Token中解析出的用户数据
id: decoded.id,
username: decoded.username,
nickname: decoded.nickname,
create_time: decoded.create_time,
};
// 4. 生成全新的双Token
const new_access_token = sign(data, "1h");
const new_refresh_token = sign(data, "7d");
ctx.body = {
code: "1",
msg: "token刷新成功",
access_token: new_access_token,
refresh_token: new_refresh_token,
};
} else {
// 5. 长Token也过期或无效了
ctx.status = 416; // 定义一个特殊的状态码,表示Refresh Token失效
ctx.body = {
code: "0",
msg: "登录失效",
};
}
});
3. 专用的长 Token 验证函数
注意,刷新接口使用了 refreshVerify 函数,而不是普通的 verify验证token函数 。这是因为刷新接口不应该被 verify 拦截。
// utils/jwt.js - 专用的RefreshToken验证函数
function refreshVerify(token) {
try {
const decoded = jwt.verify(token, '666');
if (decoded.id) {
return decoded; // 验证成功,返回解码后的数据
}
} catch (error) {
return false; // 验证失败,返回false
}
}
三、前端部分
const originalRequest = res.config;
// 重新请求新的短 token和长 token
const refresh_token = localStorage.getItem("refresh_token");
if (refresh_token) {
axios
.post("/user/refresh", {
refresh_token: refresh_token,
})
.then((res) => {
if (res.code === "1") {
localStorage.setItem("access_token", res.access_token);
localStorage.setItem("refresh_token", res.refresh_token);
// 将之前没有发送成功的请求再次发送
// 更新原始请求的 Authorization 头
originalRequest.headers.Authorization = res.access_token;
// 重新发送原始请求
return axios(originalRequest);
}
});
1. 保存原始请求
const originalRequest = res.config;
- 当请求因 Token 过期失败时,保存原始请求的配置信息
- 这包含了请求的 URL、方法、参数等重要信息
- 目的是在获取新 Token 后能够重新发送这个请求
2. 获取 Refresh Token
const refresh_token = localStorage.getItem("refresh_token");
- 从本地存储中获取长时效的 Refresh Token
- Refresh Token
- 它是用来获取新的 Access Token 的凭证
3. 发起刷新请求
axios.post("/user/refresh", {
refresh_token: refresh_token,
})
- 向服务器的
/user/refresh端点发送 POST 请求 - 携带当前有效的 Refresh Token 作为凭证
- 服务器会验证这个 Token 并返回一对新的 Access Token 和 Refresh Token
4. 处理刷新响应
.then((res) => {
if (res.code === "1") {
localStorage.setItem("access_token", res.access_token);
localStorage.setItem("refresh_token", res.refresh_token);
- 检查服务器返回的响应码,通常 "1" 表示成功
- 将新的 Access Token 和 Refresh Token 保存到本地存储
- 这样后续的请求就可以使用新的 Token
5. 更新请求头并重试
originalRequest.headers.Authorization = res.access_token;
return axios(originalRequest);
- 使用新的 Access Token 更新原始请求的认证头
- 重新发送之前失败的请求
- 这样用户不会感知到 Token 刷新的过程,实现了"无感"刷新
总结:
- 响应拦截器:在 Axios 或 Fetch 的响应拦截器中,监听
401状态码。 - 防止并发:设置一个标志(如
isRefreshing)和一个请求队列,防止多个请求同时触发刷新。 发起刷新请求:当收到401,检查是否正在刷新。如果没有,则用存储的refresh_token调用/user/refresh接口。 - 处理刷新结果:
- 成功:将新的
access_token和refresh_token保存到本地,并重试之前所有失败的请求。 - 失败(收到416):清除所有本地 Token,跳转到登录页。
- 成功:将新的
- 替换Token重试:刷新成功后,用新的
access_token替换原请求的Authorization头,重新发送请求。
结语:告别频繁登录
现在就开始实践吧! 文中的完整代码示例已经为你铺平了道路,从后端签发到前端拦截,从并发控制到错误处理,让你的应用告别登录中断。