# 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`: ![image](readme_file/1.png) 访问接口时带上 `access_token` 访问: ![image](readme_file/2.png) 当 `access_token` 过期时,通过 `refresh_token` 来刷新,拿到新的 `access_token` 和 `refresh_token` ![image](readme_file/3.png) 这里的 `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 ``` ![image](readme_file/4.png) 安装 `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`,修改运行指令: ![image](readme_file/5.png) 运行项目: ```shell npm run test ``` ![image](readme_file/6.png) 访问 `http://localhost:3000` 可以看到 `hello world`,代表服务跑成功了: ![image](readme_file/7.png) ### 创建登录接口 在 `app.js` 添加一个 `login` 的 `post` 接口: ```js app.post("/login", (req, res) => { let { userName, password } = req.body; res.json({ code: 10000, data: { userName, password, }, }); }); ``` ![image](readme_file/8.png) 在 postman 里访问下这个接口: ![image](readme_file/9.png) 成功接收到了参数 然后实现一下登录逻辑: ![image](readme_file/10.png) ```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", }, }); }); ``` ![image](readme_file/11.png) ### 生成 Token 安装 `jwt` 生成 `token` ```js npm i -S jsonwebtoken ``` ![image](readme_file/12.png) ```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, }, }); }); ``` 测试: ![image](readme_file/13.png) 登录之后,访问别的接口只要带上这个 `access_token` 就好了。 前面讲过,`jwt` 是通过 `authorization` 的 `header` 携带 `token`,格式是 `Bearer xxxx` 也就是这样: ![image](readme_file/14.png) ### 解析 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: "登录过期,请重新登录", }); } }); ``` ![image](readme_file/15.png) 我们再定义个需要登录访问的接口: ```js // 获取用户信息 app.post("/getUserInfo", (req, res) => { res.json({ code: 10000, data: req.auth, }); }); ``` 带上 `token` 访问这个接口: ![image](readme_file/16.png) 试一下错误的 `token`: ![image](readme_file/17.png) ### 刷新 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` 验证。 ![image](readme_file/18.png) 测试一下: 登录之后拿到 `refreshToken`: ![image](readme_file/20.png) 然后带上这个 `token` 访问刷新接口: ![image](readme_file/19.png) 返回了新的 `token`,这种方式也叫做无感刷新。 ## 前端页面使用 前面已经托管了静态文件,所以直接新建文件 `public/html/index.html` `public/html/index.js` ![image](readme_file/21.png) ![image](readme_file/22.png) 增加跳过 `Token` 路径 ![image](readme_file/23.png) 使用 `http://localhost:3000/html/index.html` 访问页面 ![image](readme_file/24.png) 先在 `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` ![image](readme_file/25.png) 然后我们把 `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); } ``` ![image](readme_file/26.png) 在 `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`。 ![image](readme_file/27.png) 因为访问接口时没带上 `token,我们可以在` `interceptor` 里做这个。 `interceptor` 是 `axios` 提供的机制,可以在请求前、响应后加上一些通用处理逻辑: ![image](readme_file/28.png) 添加 `token` 的逻辑就很适合放在 `interceptor` 里: ![image](readme_file/29.png) ```js // 请求拦截器 axiosInstance.interceptors.request.use((config) => { const accessToken = localStorage.getItem("accessToken"); if (accessToken) { config.headers.authorization = "Bearer " + accessToken; } return config; }); ``` 现在再点击 `获取用户信息` 按钮,接口就正常响应了: ![image](readme_file/30.png) 因为 `axios` 的拦截器里给它带上了 `token`: ![image](readme_file/31.png) 那当 `token` 失效的时候,刷新 `token` 的逻辑在哪里做呢? 很明显,也可以放在 `interceptor` 里。 比如我们改下 `localStorage` 里的 `access_token`,手动让它失效。 ![image](readme_file/32.png) 这时候再点击 `获取用户信息` 按钮,提示的就是 `token` 失效的错误了: ![image](readme_file/33.png) 我们在 `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` 让它失效后,点击 `获取用户信息` 按钮,发现发了三个请求: ![image](readme_file/34.png) 第一次访问 `获取用户信息` 接口返回 `401`,自动调了 `refresh` 接口来刷新,之后又重新访问了 `获取用户信息` 接口。 这样,基于 `axios` `interceptor` 的无感刷新 `token` 就完成了。 但现在还不完美,比如点击按钮的时候,我同时调用了 `3` 次 `获取用户信息` 接口: ```js // 获取用户信息 async function userInfo() { const { data } = await [getUserInfo(), getUserInfo(), getUserInfo()]; console.log(data); } ``` 这时候三个接口用的 `token` 都失效了,会刷新几次呢? ![image](readme_file/35.png) 是 `3` 次。 多刷新几次也没啥,不影响功能。 但做的再完美一点可以处理下: ![image](readme_file/36.png) 加一个 `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; } } ); ``` 测试下: ![image](readme_file/37.png) 现在就是并发请求只 `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; } } ); ```