最近,我总是回忆起大三的时候,为了体检(安装)(强迫),我提议在学校的新系统中添加无感刷新token功能。当时我只是听到这个东西,甚至不明白是怎么回事,所以直到几个月后出来实习,我才真正添加这个功能。
先说一个场景:我们都知道 JWT。假设某个系统把它说出来token保留时间设置为5分钟。然后,如果用户在这段时间结束时碰巧有其他事情,回来后点击提交,发现弹出用户凭证无效,请重新登录。多么悲惨的话啊! 当然,有很多类似的场景。毫无疑问,如果过期时间很短,用户必须每隔一段时间重新登录以获得新的凭证,这将极大地挫伤用户的热情。但如果设置时间过长,用户数据的安全性将大大降低。
关于这一点,当时我与学弟们讨论了些许时间,最后得出以“埋点”为辅助,或监听用户行为,延长用户凭证存储时间 —— 可以发现,如果用户在过期前(很短的时间)回来,这里只是一个巧妙的极限场景。但实际上并没有解决上述问题。 最后还得借助双token,也就是标题所说的无感刷新token”。
解决方案也很简单:两个token存储时间不同。token用于请求应用数据,长token用于获取新的短token。
后端设计逻辑
作者必须在这里使用Koa实现有三个步骤:
- 后端有两个字段,分别保存长度token,而且每段时间更新(?是否有其他计划 !可以用koa的
ctx.state
创造全局状态变量。但就开发成本而言,必须是setInterval要低一些); - 为长token、短token约定单独的code前端调查判断方便;
- 请求头中新增两个字段,表示长短token(JWT思想),便于后端处理;
在router文件夹下的index.js文件中:
const router = require("koa-router")(); let accessToken = "s_token"; //短token let refreshToken = "l_token"; //长token // 30min刷新一次短token setInterval(() => {
accessToken = "s_tk" Math.random(); }, 300000); // 12小时刷新一次长度token setInterval(() => {
refreshToken = "l_tk" Math.random(); }, 7200000); // 登录接口后拿到token,存到前端 router.get("/login", async (ctx) => {
ctx.body = {
returncode: 0, accessToken, refreshToken, }; })
;
// 获取短token router
.
get
(
"/refresh"
,
async
(
ctx
)
=>
{
//接收的请求头字段都是小写的
let
{
pass
}
= ctx
.headers
;
if
(pass
!== refreshToken
)
{
ctx
.body
=
{
returncode
:
108
,
info
:
"长token过期,重新登录"
,
}
;
}
else
{
ctx
.body
=
{
returncode
:
0
, accessToken
,
}
;
}
}
)
;
// 获取应用数据时后端校验一次token,这里如果过期了返回后前端不能跳登录页而是要重新拿token,续期 router
.
get
(
"/getData"
,
async
(
ctx
)
=>
{
let
{
authorization
}
= ctx
.headers
;
if
(authorization
!== accessToken
)
{
ctx
.body
=
{
returncode
:
104
,
info
:
"token过期"
,
}
;
}
else
{
ctx
.body
=
{
code
:
200
,
returncode
:
0
,
data
:
{
数据
}
,
}
;
}
}
)
; module
.exports
= router
;
然后在主文件中引用:
const Koa = require('koa')
const app = new Koa();
const cors = require('koa2-cors');
const index = require('./router/index')
app.use(cors());
app.use(index.routes(),index.allowedMethods())
app.listen(8088,() => {
console.log('server is listening on port 8088')
})
前端封装逻辑
新建config文件夹,处理请求事宜。 在config下的token_enum.js文件中,存放一些变量:
/* localStorage存储字段 */
export const ACCESS_TOKEN = "s_tk"; //短token
export const REFRESH_TOKEN = "l_tk"; //长token、
/* HTTP请求头字段 */
export const AUTH = "Authorization"; //存放短token
export const PASS = "PASS"; //存放长token
然后在code_map.js中存放前后端约定好的code:
// 在其它客户端被登录
export const CODE_LOGGED_OTHER = 106;
// 重新登陆
export const CODE_RELOGIN = 108;
// token过期
export const CODE_TOKEN_EXPIRED = 104;
//接口请求成功
export const CODE_SUCCESS = 0;
新建service文件夹,在其中的index.js文件中:
import axios from "axios";
import {
refreshAccessToken, NoneTokenRequestList, clearAuthAndRedirect } from "./refresh";
import {
CODE_LOGGED_OTHER,
CODE_RELOGIN,
CODE_TOKEN_EXPIRED,
CODE_SUCCESS,
} from "../config/code_map.js";
import {
ACCESS_TOKEN, AUTH } from "../config/token_enum.js";
const service = axios.create({
baseURL: "//127.0.0.1:8088",
timeout: 30000,
});
// 劫持请求,往request-header添加短token,用于后端处理
service.interceptors.request.use(
(config) => {
let {
headers } = config;
const s_tk = localStorage.getItem(ACCESS_TOKEN);
s_tk &&
Object.assign(headers, {
[AUTH]: s_tk,
});
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 劫持响应,
service.interceptors.response.use(
(response) => {
let {
config, data } = response;
//retry:第一次请求过期,接口调用refreshAccessToken,第二次重新请求,还是过期则reject出去
let {
retry } = config;
return new Promise((resolve, reject) => {
if (data["returncode"] !== CODE_SUCCESS) {
if ([CODE_LOGGED_OTHER, CODE_RELOGIN].includes(data.returncode)) {
clearAuthAndRedirect();
} else if (data["returncode"] === CODE_TOKEN_EXPIRED && !retry) {
//当前是第一次调用发现token失效的情况
config.retry = true;
NoneTokenRequestList(() => resolve(service(config)));
refreshAccessToken();
} else {
return reject(data);
}
} else {
resolve(data);
}
});
},
(error) => {
return Promise.reject(error);
}
);
export default service;
在同级新建refresh.js文件:
import service from "./index.js";
import {
ACCESS_TOKEN, REFRESH_TOKEN, PASS } from "../config/token_enum.js";
let subscribers = [];
let pending = false; //同时请求多个过期链接,保证只请求一次获取短token
export const NoneTokenRequestList = (request) => {
subscribers.push(request);
};
export const retryRequest = () => {
subscribers.forEach((request) => request());
subscribers = [];
};
export const refreshAccessToken = async () => {
if (!pending) {
try {
pending = true;
const l_tk = localStorage.getItem(REFRESH_TOKEN);
if (l_tk) {
/* 利用长token重新获取短token */
const {
accessToken } = await service.get(
"/refresh",
Object.assign({
}, {
headers: {
[PASS]: l_tk } })
);
localStorage.setItem(ACCESS_TOKEN, accessToken);
retryRequest();
}
return;
} catch (e) {
clearAuthAndRedirect();
return;
} finally {
pending = false;
}
}
};
/* 清除长短token,并定位到登录页(在项目中使用路由跳转) */
export const clearAuthAndRedirect = () =>{
localStorage.removeItem(ACCESS_TOKEN)
window.location.href = '/login'
}
我觉得这样设计还很流批的一点是:它是上层封装,基本是不侵入业务代码的。
尾记
上海这一波极限操作搞得我学校也回不去,家也回不去,,,竟无语凝噎。想了想高中考完聚餐别人拍班照的时候我因为不胜酒力在旁边吐,现在又因为疫情回不去学校。合着我注定不能出现在班级照片中呗┗( ▔, ▔ )┛,一时竟悲从中来。
感谢学弟@*辉给我提供的一些想法,以及让我能虽然不在学校还能在学校系统中查看实际使用和与其他功能联动的效果,毕竟没有经过检验的想法注定只能是想法。我就有许多这种想法,但是马上就彻底跟学校分开了,不能白嫖学校资源了。一想到此,虽值五一佳节,更悲伤不已。