# token
**Repository Path**: yanjiushen/token
## Basic Information
- **Project Name**: token
- **Description**: 基于双 Token 实现无感刷新机制
(Express框架、Node.js)
- **Primary Language**: Unknown
- **License**: Not specified
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 1
- **Created**: 2024-02-02
- **Last Updated**: 2024-11-08
## Categories & Tags
**Categories**: Uncategorized
**Tags**: token无感刷新
## README
# 基于双 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` 项目:
```shell
npm init
```

安装 `express`
```shell
npm i -S express
```
新建 `app.js` 文件
```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` :文件改动时自动重启服务器
```shell
npm i -S nodemon
```
打开 `package.json`,修改运行指令:

运行项目:
```shell
npm run test
```

访问 `http://localhost:3000` 可以看到 `hello world`,代表服务跑成功了:

### 创建登录接口
在 `app.js` 添加一个 `login` 的 `post` 接口:
```js
app.post("/login", (req, res) => {
let { userName, password } = req.body;
res.json({
code: 10000,
data: {
userName,
password,
},
});
});
```

在 postman 里访问下这个接口:

成功接收到了参数
然后实现一下登录逻辑:

```js
// 用户列表
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`
```js
npm i -S jsonwebtoken
```

```js
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` 中
```js
npm i -S express-jwt
```
配置
```js
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: "登录过期,请重新登录",
});
}
});
```

我们再定义个需要登录访问的接口:
```js
// 获取用户信息
app.post("/getUserInfo", (req, res) => {
res.json({
code: 10000,
data: req.auth,
});
});
```
带上 `token` 访问这个接口:

试一下错误的 `token`:

### 刷新 Token
然后我们实现刷新 `token` 的接口:
```js
// 刷新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`
```html
```
然后创建个 `request.js` 来管理所有接口:
```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`,测试登录接口:
```html
双Token实现无感刷新
```
```js
// 登录
async function login() {
const res = await userLogin("admin", "123456");
console.log(res);
}
```
接口调用成功了,我们拿到了 `userInfo`、`access_token`、`refresh_token`

然后我们把 `token` 存到 `localStorage` 里,因为后面还要用。
```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);
}
```

在 `request.js` 中增加 `getUserInfo` 接口:
```js
// 获取用户信息
async function getUserInfo() {
return await axiosInstance.post("/getUserInfo");
}
```
访问接口:
```html
```
```js
// 获取用户信息
async function userInfo() {
const { data } = await getUserInfo();
console.log(data);
}
```
点击 `获取用户信息` 按钮,接口返回了 `401`。

因为访问接口时没带上 `token,我们可以在` `interceptor` 里做这个。
`interceptor` 是 `axios` 提供的机制,可以在请求前、响应后加上一些通用处理逻辑:

添加 `token` 的逻辑就很适合放在 `interceptor` 里:

```js
// 请求拦截器
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`:
```js
// 刷新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` 的接口里,我们拿到新的 `access_token` 和 `refresh_token` 后,更新本地的 `token`。
测试下:
我手动改了 `access_token` 让它失效后,点击 `获取用户信息` 按钮,发现发了三个请求:

第一次访问 `获取用户信息` 接口返回 `401`,自动调了 `refresh` 接口来刷新,之后又重新访问了 `获取用户信息` 接口。
这样,基于 `axios` `interceptor` 的无感刷新 `token` 就完成了。
但现在还不完美,比如点击按钮的时候,我同时调用了 `3` 次 `获取用户信息` 接口:
```js
// 获取用户信息
async function userInfo() {
const { data } = await [getUserInfo(), getUserInfo(), getUserInfo()];
console.log(data);
}
```
这时候三个接口用的 `token` 都失效了,会刷新几次呢?

是 `3` 次。
多刷新几次也没啥,不影响功能。
但做的再完美一点可以处理下:

加一个 `refreshing` 的标记,如果在刷新,那就返回一个 `promise`,并且把它的 `resolve` 方法还有 `config` 加到队列里。
当 `refresh` 成功之后,重新发送队列中的请求,并且把结果通过 `resolve` 返回。
```js
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
```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
```html
双Token实现无感刷新
```
### public/html/index.js
```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
```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;
}
}
);
```