基于双 Token 实现无感刷新机制
介绍
服务端把用户信息放入 token
里,设置一个过期时间,客户端请求的时候通过 authorization
的 header
携带 token
,服务端验证通过,就可以从中取到用户信息。
但是这样有个问题:
token
是有过期时间的,比如 3
天,那过期后再访问就需要重新登录了。
这样体验并不好。
想想你在用某个 app
的时候,用着用着突然跳到登录页了,告诉你需要重新登录了。
是不是体验很差?
所以要加上续签机制,也就是延长 token
过期时间。
主流的方案是通过双 token
,一个 access_token
、一个 refresh_token
。
登录成功之后,返回这两个 token
:
访问接口时带上 access_token
访问:
当 access_token
过期时,通过 refresh_token
来刷新,拿到新的 access_token
和 refresh_token
这里的 access_token
就是我们之前的 token
。
为什么多了个 refresh_token
就能简化呢?
因为如果你重新登录,是不是需要再填一遍用户名密码?而有了 refresh_token
之后,只要带上这个 token
就能标识用户,不需要传用户名密码就能拿到新 token
。
而 access_token
一般过期时间设置的比较短,比如 30
分钟,refresh_token
设置的过期时间比较长,比如 7
天。
这样,只要你 7
天内访问一次,就能刷新 token
,再续 7
天,一直不需要登录。
但如果你超过 7
天没访问,那 refresh_token
也过期了,就需要重新登录了。
想想你常用的 APP
,是不是没再重新登录过?
而不常用的 APP
,再次打开是不是就又要重新登录了?
这种一般都是双 token
做的。
实现
创建服务
新建个 node
项目:
npm init
安装 express
npm i -S express
新建 app.js
文件
const express = require("express");
const app = express();
// 解析 JSON 格式的请求体数据 req.body
app.use(express.json());
// 托管静态文件
app.use(express.static("public")); // 访问路径 http://localhost:3000/html/index.html
// 新增接口
app.get("/", (req, res) => {
res.end("Hello World");
});
app.listen(3000, () => {
console.log("服务器已启动:3000");
});
安装 nodemon
:文件改动时自动重启服务器
npm i -S nodemon
打开 package.json
,修改test
命令:
运行项目:
npm run test
访问 http://localhost:3000
可以看到 hello world
,代表服务跑成功了:
创建登录接口
在 app.js
添加一个 login
的 post
接口:
app.post("/login", (req, res) => {
let { userName, password } = req.body;
res.json({
code: 10000,
data: {
userName,
password,
},
});
});
在 postman
里访问下这个接口:
成功接收到了参数
然后实现一下登录逻辑:
// 用户列表
const userList = [{ userName: "admin", password: "123456" }];
app.post("/login", (req, res) => {
let { userName, password } = req.body;
const user = userList.find((user) => {
return user.userName == userName;
});
if (!user) {
return res.json({
code: 20000,
msg: "用户不存在",
});
}
if (user.password !== password) {
return res.json({
code: 20000,
msg: "密码错误",
});
}
const userInfo = {
userName,
};
res.json({
code: 10000,
data: {
userInfo,
accessToken: "aaa",
refreshToken: "bbb",
},
});
});
生成 Token
安装 jwt
生成 token
npm i -S jsonwebtoken
const jwt = require("jsonwebtoken");
app.post("/login", (req, res) => {
let { userName, password } = req.body;
const user = userList.find((user) => {
return user.userName == userName;
});
if (!user) {
return res.json({
code: 20000,
msg: "用户不存在",
});
}
if (user.password !== password) {
return res.json({
code: 20000,
msg: "密码错误",
});
}
const userInfo = {
userName,
};
const accessToken = jwt.sign(userInfo, "abcdefg", {
expiresIn: "0.5h",
});
const refreshToken = jwt.sign(userInfo, "abcdefg", {
expiresIn: "7d",
});
res.json({
code: 10000,
data: {
userInfo,
accessToken,
refreshToken,
},
});
});
测试:
登录之后,访问别的接口只要带上这个 access_token
就好了。
前面讲过,jwt
是通过 authorization
的 header
携带 token
,格式是 Bearer xxxx
也就是这样:
解析 Token
安装 express-jwt
解析 token
用户信息将被保存在 req.auth
中
npm i -S express-jwt
配置
const { expressjwt: expressJWT } = require("express-jwt");
app.use(
expressJWT({ secret: "abcdefg", algorithms: ["HS256"] }).unless({
path: ["/login"], // 跳过token验证
})
);
// 配置中间件-错误捕获
app.use(function (err, req, res, next) {
// token解析失败
const authorization = req.headers["authorization"];
if (!authorization) {
return res.status(401).json({
code: 20000,
msg: "用户未登录",
});
}
if (err.name === "UnauthorizedError") {
return res.status(401).json({
code: 20000,
msg: "登录过期,请重新登录",
});
}
});
我们再定义个需要登录访问的接口:
// 获取用户信息
app.post("/getUserInfo", (req, res) => {
res.json({
code: 10000,
data: req.auth,
});
});
带上 token
访问这个接口:
试一下错误的 token
:
刷新 Token
然后我们实现刷新 token
的接口:
// 刷新Token
app.get("/refresh", (req, res) => {
const token = req.query.token;
let userName = "";
try {
const userInfo = jwt.verify(token, "abcdefg");
userName = userInfo.userName;
} catch (error) {
return res.status(401).json({
code: 20000,
msg: "token 失效,请重新登录",
});
}
const userInfo = {
userName,
};
const accessToken = jwt.sign(userInfo, "abcdefg", {
expiresIn: "0.5h",
});
const refreshToken = jwt.sign(userInfo, "abcdefg", {
expiresIn: "7d",
});
res.json({
code: 10000,
data: {
accessToken,
refreshToken,
},
});
});
定义了个 get
接口,参数是 refresh_token
,将此接口加入白名单跳过 Token
验证。
测试一下:
登录之后拿到 refreshToken
:
然后带上这个 token
访问刷新接口:
返回了新的 token
,这种方式也叫做无感刷新。
前端页面使用
前面已经托管了静态文件,所以直接新建文件 public/html/index.html
public/html/index.js
增加跳过 Token
路径
使用 http://localhost:3000/html/index.html
访问页面
先在 index.html
中引入 axios
<script src="https://cdn.jsdelivr.net/npm/axios@1.1.2/dist/axios.min.js"></script>
然后创建个 request.js
来管理所有接口:
const axiosInstance = axios.create({
baseURL: "http://localhost:3000",
timeout: 30000,
});
// 登录
async function userLogin(userName, password) {
return await axiosInstance.post("/login", {
userName,
password,
});
}
在 index.html
中引入request.js
,测试登录接口:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>双Token实现无感刷新</title>
<script src="https://cdn.jsdelivr.net/npm/axios@1.1.2/dist/axios.min.js"></script>
<script src="./index.js"></script>
<script src="./request.js"></script>
</head>
<body>
<button onclick="login()">登录</button>
</body>
</html>
// 登录
async function login() {
const res = await userLogin("admin", "123456");
console.log(res);
}
接口调用成功了,我们拿到了 userInfo
、access_token
、refresh_token
然后我们把 token
存到 localStorage
里,因为后面还要用。
// 登录
async function login() {
const { data } = await userLogin("admin", "123456");
console.log(data);
localStorage.setItem("accessToken", data.data.accessToken);
localStorage.setItem("refreshToken", data.data.refreshToken);
}
在 request.js
中增加 getUserInfo
接口:
// 获取用户信息
async function getUserInfo() {
return await axiosInstance.post("/getUserInfo");
}
访问接口:
<button onclick="userInfo()">获取用户信息</button>
// 获取用户信息
async function userInfo() {
const { data } = await getUserInfo();
console.log(data);
}
点击 获取用户信息
按钮,接口返回了 401
用户未登录。
因为访问接口时没带上 token,我们可以在
interceptor
里做这个。
interceptor
是 axios
提供的机制,可以在请求前、响应后加上一些通用处理逻辑:
添加 token
的逻辑就很适合放在 interceptor
里:
// 请求拦截器
axiosInstance.interceptors.request.use((config) => {
const accessToken = localStorage.getItem("accessToken");
if (accessToken) {
config.headers.authorization = "Bearer " + accessToken;
}
return config;
});
现在再点击 获取用户信息
按钮,接口就正常响应了:
因为 axios
的拦截器里给它带上了 token
:
那当 token
失效的时候,刷新 token
的逻辑在哪里做呢?
很明显,也可以放在 interceptor
里。
比如我们改下 localStorage
里的 access_token
,手动让它失效。
这时候再点击 获取用户信息
按钮,提示的就是 token
失效的错误了:
我们在 interceptor
里判断下,如果失效了就刷新 token
:
// 刷新Token
async function refreshToken() {
const res = await axiosInstance.get("/refresh", {
params: {
token: localStorage.getItem("refreshToken"),
},
});
if (res.status == 200) {
localStorage.setItem("accessToken", res.data.data.accessToken);
localStorage.setItem("refreshToken", res.data.data.refreshToken);
}
return res;
}
// 响应拦截器
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
let { status, data, config } = error.response;
if (status === 401 && !config.url.includes("/refresh")) {
const res = await refreshToken(); // 刷新Token
if (res.status === 200) {
return axiosInstance(config); // 重发请求
} else {
alert(data.msg || "登录过期,请重新登录");
return {};
}
} else {
return error.response;
}
}
);
响应的 interceptor
有两个参数,当返回 200
时,走第一个处理函数,直接返回 response
。
当返回的不是 200
时,走第二个处理函数 ,判断下如果返回的是 401
,就调用刷新 token
的接口。
这里还要排除下 /refresh
接口,也就是刷新失败不继续刷新。
刷新 token
成功,就重发之前的请求,否则,提示重新登录。
其他错误直接返回。
刷新 token
的接口里,我们拿到新的 accessToken
和 refreshToken
后,更新本地的 token
。
测试下:
我手动改了 accessToken
让它失效后,点击 获取用户信息
按钮,发现发了三个请求:
第一次访问 获取用户信息
接口返回 401
,自动调了 refresh
接口来刷新,之后又重新访问了 获取用户信息
接口。
这样,基于 axios interceptor
的无感刷新 token
就完成了。
但现在还不完美,比如点击按钮的时候,同时调用了 3
次 获取用户信息
接口:
// 获取用户信息
async function userInfo() {
const { data } = await [getUserInfo(), getUserInfo(), getUserInfo()];
console.log(data);
}
这时候三个接口用的 token
都失效了,会刷新几次呢?
是 3
次。
多刷新几次也没啥,不影响功能。
但做的再完美一点可以处理下:
加一个 refreshing
的标记,如果在刷新,那就返回一个 promise
,并且把它的 resolve
方法还有 config
加到队列里。
当 refresh
成功之后,重新发送队列中的请求,并且把结果通过 resolve
返回。
let refreshing = false;
let queue = []; // 请求队列
// 响应拦截器
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
let { status, data, config } = error.response;
if (refreshing) {
return new Promise((resolve) => {
queue.push({
config,
resolve,
});
});
}
if (status === 401 && !config.url.includes("/refresh")) {
refreshing = true;
const res = await refreshToken(); // 刷新Token
refreshing = false;
if (res.status === 200) {
queue.forEach(({ config, resolve }) => {
resolve(axiosInstance(config));
});
return axiosInstance(config); // 重发请求
} else {
alert(data.msg || "登录过期,请重新登录");
return {};
}
} else {
return error.response;
}
}
);
测试下:
现在就是并发请求只 refresh
一次了。
这样,我们就基于 axios
的 interceptor
实现了完美的双 token
无感刷新机制。
源码
app.js
const express = require("express");
const jwt = require("jsonwebtoken");
const { expressjwt: expressJWT } = require("express-jwt");
const app = express();
app.use(
expressJWT({ secret: "abcdefg", algorithms: ["HS256"] }).unless({
path: ["/login", "/refresh", /^\/html\//], // 跳过token验证
})
);
// 配置中间件-错误捕获
app.use(function (err, req, res, next) {
// token解析失败
const authorization = req.headers["authorization"];
if (!authorization) {
return res.status(401).json({
code: 20000,
msg: "用户未登录",
});
}
if (err.name === "UnauthorizedError") {
return res.status(401).json({
code: 20000,
msg: "登录过期,请重新登录",
});
}
});
// 解析 JSON 格式的请求体数据 req.body
app.use(express.json());
// 托管静态文件
app.use(express.static("public")); // 访问路径 http://localhost:3000/html/index.html
// 设置路由规则
app.get("/", (req, res) => {
res.end("Hello World");
});
// 用户列表
const userList = [{ userName: "admin", password: "123456" }];
app.post("/login", (req, res) => {
console.log(req.body);
let { userName, password } = req.body;
const user = userList.find((user) => {
return user.userName == userName;
});
if (!user) {
return res.json({
code: 20000,
msg: "用户不存在",
});
}
if (user.password !== password) {
return res.json({
code: 20000,
msg: "密码错误",
});
}
const userInfo = {
userName,
};
const accessToken = jwt.sign(userInfo, "abcdefg", {
expiresIn: "0.5h",
});
const refreshToken = jwt.sign(userInfo, "abcdefg", {
expiresIn: "7d",
});
res.json({
code: 10000,
data: {
userInfo,
accessToken,
refreshToken,
},
});
});
// 获取用户信息
app.post("/getUserInfo", (req, res) => {
res.json({
code: 10000,
data: req.auth,
});
});
// 刷新Token
app.get("/refresh", (req, res) => {
const token = req.query.token;
let userName = "";
try {
const userInfo = jwt.verify(token, "abcdefg");
userName = userInfo.userName;
} catch (error) {
return res.status(401).json({
code: 20000,
msg: "token 失效,请重新登录",
});
}
const userInfo = {
userName,
};
const accessToken = jwt.sign(userInfo, "abcdefg", {
expiresIn: "0.5h",
});
const refreshToken = jwt.sign(userInfo, "abcdefg", {
expiresIn: "7d",
});
res.json({
code: 10000,
data: {
accessToken,
refreshToken,
},
});
});
app.listen(3000, () => {
console.log("服务器已启动:3000");
});
public/html/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>双Token实现无感刷新</title>
<script src="https://cdn.jsdelivr.net/npm/axios@1.1.2/dist/axios.min.js"></script>
<script src="./index.js"></script>
<script src="./request.js"></script>
</head>
<body>
<button onclick="login()">登录</button>
<button onclick="userInfo()">获取用户信息</button>
</body>
</html>
public/html/index.js
// 登录
async function login() {
const { data } = await userLogin("admin", "123456");
console.log(data);
localStorage.setItem("accessToken", data.data.accessToken);
localStorage.setItem("refreshToken", data.data.refreshToken);
}
// 获取用户信息
async function userInfo() {
const { data } = await [getUserInfo(), getUserInfo(), getUserInfo()];
console.log(data);
}
public/html/request.js
const axiosInstance = axios.create({
baseURL: "http://localhost:3000",
timeout: 30000,
});
// 登录
async function userLogin(userName, password) {
return await axiosInstance.post("/login", {
userName,
password,
});
}
// 获取用户信息
async function getUserInfo() {
return await axiosInstance.post("/getUserInfo");
}
// 刷新Token
async function refreshToken() {
const res = await axiosInstance.get("/refresh", {
params: {
token: localStorage.getItem("refreshToken"),
},
});
if (res.status == 200) {
localStorage.setItem("accessToken", res.data.data.accessToken);
localStorage.setItem("refreshToken", res.data.data.refreshToken);
}
return res;
}
// 请求拦截器
axiosInstance.interceptors.request.use((config) => {
const accessToken = localStorage.getItem("accessToken");
if (accessToken) {
config.headers.authorization = "Bearer " + accessToken;
}
return config;
});
let refreshing = false;
let queue = []; // 请求队列
// 响应拦截器
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
let { status, data, config } = error.response;
if (refreshing) {
return new Promise((resolve) => {
queue.push({
config,
resolve,
});
});
}
if (status === 401 && !config.url.includes("/refresh")) {
refreshing = true;
const res = await refreshToken(); // 刷新Token
refreshing = false;
if (res.status === 200) {
queue.forEach(({ config, resolve }) => {
resolve(axiosInstance(config));
});
return axiosInstance(config); // 重发请求
} else {
alert(data.msg || "登录过期,请重新登录");
return {};
}
} else {
return error.response;
}
}
);
部分内容改编自:微信公众号文章