以axios为例,如何做到JWT双令牌无感刷新?拦截器应用
发布于 作者:苏南大叔 来源:程序如此灵动~JWT
双令牌指的是:access_token
和refresh_token
。其设计的目的,是为了解决令牌身份被盗的问题。通过有效期更短的access_token
来访问接口,降低令牌丢失所带来的风险。但是,这样设计,又存在着另外一个问题。就是access_token
更新的时机问题。当接口访问,发现access_token
过期,而refresh_token
还在有效期的时候,这个就是令牌刷新的好时机。
苏南大叔的“程序如此灵动”博客,记录苏南大叔的代码编程经验总结。测试环境:nodejs@20.18.0
,express@4.21.2
,jsonwebtoken@9.0.2
,express-jwt@8.5.1
,axios@0.21.1
。本文的主要视点以前端axios
代码为主要视角,当然,jquery
或者fetch
之类的类ajax
代码,也是可以完成本文需求的。但是,axios
的代码,由于拦截器的存在,完成这个需求更加简单容易便于理解。
前文回顾
本文的例子中,服务器端以express
+express_jwt
为主要手段:
- https://newsn.net/say/express-catch.html
- https://newsn.net/say/express-jwt.html
- https://newsn.net/say/express-jwt-2.html
主要实现的是:令牌的颁发与鉴定。在此基础上,发展出来了双令牌策略。参考:
而前端部分,以axios
为主要技术手段,通过拦截器简化操作,并完成令牌的无感刷新。
服务端鉴权失败设定
算上苏南大叔自己设定的"令牌类型不匹配"错误,express-jwt
可以发出以下这些鉴权失败的错误信息。
UnauthorizedError: No authorization token was found
UnauthorizedError: jwt malformed
UnauthorizedError: The token has been revoked
UnauthorizedError: jwt expired
UnauthorizedError: 令牌类型不匹配
它们的原始错误码都是401
,错误类型都是UnauthorizedError
,不同的是message
不同。通过express
定义在所有路由之后的全局中间件,可以对上述错误截获处理。
axios 自动带令牌
axios.interceptors.request.use(function (req) {
if (req.url != "/login" && req.url != "/refresh") {
const access_token = localStorage.getItem("access_token");
if (access_token) {
req.headers.authorization = "Bearer " + access_token;
}
}
if (req.url == "/refresh") {
const refresh_token = localStorage.getItem("refresh_token");
if (refresh_token) {
req.headers.authorization = "Bearer " + refresh_token;
}
}
return req;
});
axios 拦截特定错误
如果上述错误,
- 被拦截处理成正常的
200
的话,在axios
里面就表现为.then((res)=>res.data)
。 - 被修改为
403
或者500
或者保持为401
的话,在axios
里面就表现为.catch((err)=>{})
。
故事从这里开始,axios
需要处理服务器端的401
错误。所以,并不需要服务器端错误处理中间件处理为return res.send({})
。
axios.interceptors.response.use(
function (response) {
return response;
},
async function (error) {
let { data, config } = error.response;
if (error.response.status === 401) {
if (!config.url.includes("/refresh")) {
// ...
}
}
}
);
/refresh
接口引发的401
,那就是refresh_token
出了问题,必须重新登陆解决问题。- 其他接口引发的
401
,那必定是access_token
出了问题,所以可以自动/refresh
来解决问题。
找到时机刷新令牌
设定拦截器,准备刷新令牌。
//...
if (localStorage.getItem("refresh_token")) {
const res = await refresh(); // 需要refresh函数做配合
if (res.status === 200 && res.data.access_token) {
//...
}
}
//...
function refresh() {
return axios.get("/refresh").then(function (res) {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
res.data.access_token &&
localStorage.setItem("access_token", res.data.access_token);
res.data.refresh_token &&
localStorage.setItem("refresh_token", res.data.refresh_token);
return res; // 为拦截器里面的401做准备
});
}
axios 重发当前请求
为了做到无感刷新,就必须及时请求refresh
接口。等到其接口返回正常内容后,重复原请求,否则就称不上“无感”了。这一步是“无感”操作的关键。
let { data, config } = error.response;
//...
return axios(config);
//...
完整代码
express.js
:
const express = require("express");
const app = express();
app.use(express.json());
const path = require("path");
app.get("/", function (req, res) {
let root = path.join(process.cwd(), "wwwroot");
res.sendFile(path.join(root, "form.html"));
});
app.get("/favicon.ico", function (req, res) {
res.send("");
});
app.use(express.static("static"));
//##################################
// jwt的公共部分
//##################################
const jwt = require("jsonwebtoken");
const { expressjwt, UnauthorizedError } = require("express-jwt");
const options_access = {
secret: "sunan_access",
algorithms: ["HS256"],
};
const options_access_sign = {
expiresIn: "10000", //10秒后的任何接口操作,会引发refresh,正常应该设置为3h或者1d之类的,这里仅为演示
algorithm: options_access.algorithms[0],
};
const options_refresh = {
secret: "sunan_refresh",
algorithms: ["HS384"],
};
const options_refresh_sign = {
expiresIn: "30d", //30天都不动一下的话,就默认登出了
algorithm: options_refresh.algorithms[0],
};
let authCheckAccess = expressjwt(options_access);
let authCheckRefresh = expressjwt(options_refresh);
//##################################
// 生成Token
//##################################
app.post("/login", (req, res) => {
const { user, password } = req.body;
if (user == "sunan" && password == "dashu") {
const access_token = jwt.sign(
{ user, type: "access" },
options_access.secret,
options_access_sign
);
const refresh_token = jwt.sign(
{ user, type: "refresh" },
options_refresh.secret,
options_refresh_sign
);
res.json({ access_token, refresh_token });
} else {
res.json({ message: "用户名或密码错误" });
}
});
//##################################
// 局部路由使用express-jwt中间件,
// 鉴权成功的话会生成req.auth,也就是payload信息
//##################################
app.get("/api/*", authCheckAccess, (req, res) => {
if (req.auth && req.auth.type === "access") {
res.send({
url: req.url,
username: req.auth.user,
type: req.auth.type,
});
} else {
res.status(401).send("Unauthorized");
}
});
//##################################
// 令牌刷新
//##################################
app.get("/refresh", authCheckRefresh, (req, res) => {
if (req.auth && req.auth.type === "refresh") {
const access_token = jwt.sign(
{ user: req.auth.user, type: "access" },
options_access.secret,
options_access_sign
);
const refresh_token = jwt.sign(
{ user: req.auth.user, type: "refresh" },
options_refresh.secret,
options_refresh_sign
);
res.send({
url: req.url,
username: req.auth.user,
type: req.auth.type,
access_token,
refresh_token,
});
} else {
console.log(req.auth.type);
throw new UnauthorizedError(401, Error("令牌类型不匹配"));
}
});
//##################################
// 全局错误捕获、错误改写
//##################################
// app.use((err, req, res, next) => {
// if (err.name == "UnauthorizedError") {
// return res.send({
// status: -999,
// message: err.message,
// });
// }
// });
let server = app.listen(3222, function () {
let port = server.address().port;
console.log(`访问地址为 http://localhost:${port}`);
});
index.html
:
<script src="/axios.min.js"></script>
<script>
function login(user, password) {
axios
.post("/login", { user: "sunan", password: "dashu" })
.then(function (res) {
console.log(res.data);
res.data.access_token && localStorage.setItem("access_token", res.data.access_token);
res.data.refresh_token && localStorage.setItem("refresh_token", res.data.refresh_token);
});
}
function fetchRemote(url) {
axios.get(url).then(function (res) {
console.log(res.data);
});
}
axios.interceptors.request.use(function (req) {
if (req.url != "/login" && req.url != "/refresh") {
const access_token = localStorage.getItem("access_token");
if (access_token) {
req.headers.authorization = "Bearer " + access_token;
}
}
if (req.url == "/refresh") {
const refresh_token = localStorage.getItem("refresh_token");
if (refresh_token) {
req.headers.authorization = "Bearer " + refresh_token;
}
}
return req;
});
axios.interceptors.response.use(
function (response) {
return response;
},
async function (error) {
let { data, config } = error.response;
let ok = false;
if (error.response.status === 401) {
if (!config.url.includes("/refresh")) {
if (localStorage.getItem("refresh_token")) {
const res = await refresh();
// console.log(res); // 需要refresh函数做配合
if (res.status === 200 && res.data.access_token) {
ok = true;
return axios(config); // 重发请求的关键
}
}
}
if (!ok) {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
alert("请重新登陆");
return Promise.reject(error);
}
} else {
return error.response;
}
}
);
function refresh() {
return axios.get("/refresh").then(function (res) {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
res.data.access_token &&
localStorage.setItem("access_token", res.data.access_token);
res.data.refresh_token &&
localStorage.setItem("refresh_token", res.data.refresh_token);
return res; // 为拦截器里面的401做准备
});
}
</script>
<input type="button" onclick="login('sunan','dashu')" value="登陆" />
<input type="button" onclick="fetchRemote('/api/user')" value="接口1" />
<input type="button" onclick="fetchRemote('/api/coin')" value="接口2" />
<input type="button" onclick="refresh()" value="刷新" />
本博客不欢迎:各种镜像采集行为。请尊重原创文章内容,转载请保留作者链接。