我们相信:世界是美好的,你是我也是。平行空间的世界里面,不同版本的生活也在继续...

JWT双令牌指的是:access_tokenrefresh_token。其设计的目的,是为了解决令牌身份被盗的问题。通过有效期更短的access_token来访问接口,降低令牌丢失所带来的风险。但是,这样设计,又存在着另外一个问题。就是access_token更新的时机问题。当接口访问,发现access_token过期,而refresh_token还在有效期的时候,这个就是令牌刷新的好时机。

苏南大叔:以axios为例,如何做到JWT双令牌无感刷新?拦截器应用 - axios-jwt-双令牌无感刷新
以axios为例,如何做到JWT双令牌无感刷新?拦截器应用(图3-1)

苏南大叔的“程序如此灵动”博客,记录苏南大叔的代码编程经验总结。测试环境:nodejs@20.18.0,express@4.21.2jsonwebtoken@9.0.2express-jwt@8.5.1axios@0.21.1。本文的主要视点以前端axios代码为主要视角,当然,jquery或者fetch之类的类ajax代码,也是可以完成本文需求的。但是,axios的代码,由于拦截器的存在,完成这个需求更加简单容易便于理解。

前文回顾

本文的例子中,服务器端以express+express_jwt为主要手段:

主要实现的是:令牌的颁发与鉴定。在此基础上,发展出来了双令牌策略。参考:

而前端部分,以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为例,如何做到JWT双令牌无感刷新?拦截器应用 - 重复请求的关键语句
以axios为例,如何做到JWT双令牌无感刷新?拦截器应用(图3-2)

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}`);
});

苏南大叔:以axios为例,如何做到JWT双令牌无感刷新?拦截器应用 - 流程效果图
以axios为例,如何做到JWT双令牌无感刷新?拦截器应用(图3-3)

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="刷新" />
如果本文对您有帮助,或者节约了您的时间,欢迎打赏瓶饮料,建立下友谊关系。
本博客不欢迎:各种镜像采集行为。请尊重原创文章内容,转载请保留作者链接。

 【福利】 腾讯云最新爆款活动!1核2G云服务器首年50元!

 【源码】本文代码片段及相关软件,请点此获取更多信息

 【绝密】秘籍文章入口,仅传授于有缘之人   express    axios