双Token实现无感刷新

基于双 Token 实现无感刷新机制

介绍

服务端把用户信息放入 token 里,设置一个过期时间,客户端请求的时候通过 authorizationheader 携带 token,服务端验证通过,就可以从中取到用户信息。

但是这样有个问题:

token 是有过期时间的,比如 3 天,那过期后再访问就需要重新登录了。

这样体验并不好。

想想你在用某个 app 的时候,用着用着突然跳到登录页了,告诉你需要重新登录了。

是不是体验很差?

所以要加上续签机制,也就是延长 token 过期时间。

主流的方案是通过双 token,一个 access_token、一个 refresh_token

登录成功之后,返回这两个 token

在这里插入图片描述

访问接口时带上 access_token 访问:

在这里插入图片描述

access_token 过期时,通过 refresh_token 来刷新,拿到新的 access_tokenrefresh_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 添加一个 loginpost 接口:

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 是通过 authorizationheader 携带 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);
}

接口调用成功了,我们拿到了 userInfoaccess_tokenrefresh_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 里做这个。

interceptoraxios 提供的机制,可以在请求前、响应后加上一些通用处理逻辑:

在这里插入图片描述

添加 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 的接口里,我们拿到新的 accessTokenrefreshToken 后,更新本地的 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 一次了。

这样,我们就基于 axiosinterceptor 实现了完美的双 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;
    }
  }
);

部分内容改编自:微信公众号文章

  • 18
    点赞
  • 38
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值