token 超时刷新

需求场景

我们知道,token 用于保持会话和用户身份认证,它具有时效性。之前的策略是,一旦 token 过期,就强制用户重新登录,如果此时用户正在操作表单,提交时突然被强制登录,那用户体验实在太差了。

正确的策略是,如果该用户是活跃用户的话,当 token 过期时,动态地刷新 token,延长时效。若该用户不是活跃用户,则强制重新登录。


判断活跃用户

那如何判断是否是活跃用户呢?例如,用户在 30 分钟内没有任何操作,则判定为非活跃用户,在 30 分钟内有操作,则判定为活跃用户。当然,这个时间视情况而定。

活跃用户需要在前端进行判断,具体思路是:

在 localStorage 中保存一个 “last_operation_time” 字段,其值为用户最后一次操作的时间。那这个值是怎么来的呢? 监听 mousemovekeypress 事件,事件一旦被触发就更新 last_operation_time 的值,让其始终是最后一次操作的时间。

当 token 过期时,用现在的时间减去 last_operation_time,若其差值大于某一规定阈值,怎判定为非活跃用户,否则为活跃用户。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function resetLastTime(): void {
const currentTime = new Date().getTime();
localStorage.setItem("last_operation_time", currentTime.toString());
}

window.addEventListener("mousemove", resetLastTime, true);
window.addEventListener("keypress", resetLastTime, true);


// axios 拦截器
request.interceptors.response.use(res => {
return res.data;
}, error => {
if (error.response.status === 401) {
const currentTime = new Date().getTime();
const last_operation_time = localStorage.getItem("last_operation_time");
if (currentTime - Number(last_operation_time) > 30*60*1000) {
router.push("/login");
}
}
});

动态刷新 token

动态刷新 token 的思路:

(1)用户登录成功,后台生成两个 token(JWT):access_token 和 refresh_token,一同返回给前端进行保存。

  • access_token 用于权限验证,每次请求都需要进行传输,时效较短;

  • refresh_token 用于刷新 access_token,只在 access_token 过期时被携带去请求新的 access_token,时效较长。

(2)当 access_token 过期时,后台返回 401 错误。前端接收到响应后,判断用户是否为活跃用户,若为非活跃用户,则强制重新登录。否则,请求刷新 access_token 的接口(’/refresh’),并发送 refresh_token。

(3)后台收到请求后,判断 refresh_token 是否已经过期。如果已经过期,返回 401 错误,前端强制重新登录;如果没有过期,那么生成新的 access_token(或者同时生成新的 refresh_token)返回给前端,前端更新 token,并重新发起上一次失败的请求。

单个请求

前端代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
request.interceptors.response.use(res => {
return res.data;
}, async error => {
if (error.response.status === 401) {
// 如果 refresh_token 也过期,则重新登录
if (error.config.url === "/refresh") {
router.push("/login");
} else {
// 判断是否为活跃用户
const currentTime = new Date().getTime();
const last_operation_time = localStorage.getItem("last_operation_time");
// 非活跃用户
if (currentTime - Number(last_operation_time) > 30 * 60 * 1000) {
router.push("/login");
localStorage.clear();
}
// 活跃用户
else {
// config 里面包含请求的配置信息
const {config} = error;

// 请求刷新 token
const res: any = await request.post("/refresh", {refresh_token: localStorage.getItem("refresh_token")});
// 更新本地 token
localStorage.setItem("access_token", res.access_token);
// 更新请求头中的 token,并立即重新发起失败的请求
config.headers.Authorization = res.access_token;
return request(config);
}
}
}
});
多个请求

但是如果在 access_token 过期时同时发起了多个请求,那么就会多次刷新 token,这种情况应该怎么解决呢?

我们通过一个变量 isRefreshing 去控制是否在刷新 token 的状态,如果正在刷新 token,那么之后的请求用一个数组队列保存起来,当 token 刷新完之后,再去逐个重新发起这些请求。

那么如何做到让这些请求处于等待中呢?为了解决这个问题,我们得借助 Promise。将请求存进队列中后,同时返回一个 Promise,让这个 Promise 一直处于 Pending 状态(即不调用 resolve),此时这个请求就会一直等啊等,只要我们不执行 resolve,这个请求就会一直在等待。当刷新请求的接口返回来后,我们再调用 resolve,逐个重试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
let isRefreshing: boolean = false;
let requestList: ((token: string) => void)[] = [];


request.interceptors.response.use(res => {
return res.data;
}, async error => {
if (error.response.status === 401) {
// 如果 refresh_token 也过期,则重新登录
if (error.config.url === "/refresh") {
router.push("/login");
} else {
// 判断是否为活跃用户
const currentTime = new Date().getTime();
const last_operation_time = localStorage.getItem("last_operation_time");
// 非活跃用户重新登录
if (currentTime - Number(last_operation_time) > 30 * 60 * 1000) {
router.push("/login");
localStorage.clear();
}
// 活跃用户刷新 token
else {
// 没在刷新则去请求刷新接口
if (!isRefreshing) {
isRefreshing = true;
const {config} = error;

// 请求刷新 token
const res: any = await request.post("/refresh", {refresh_token: localStorage.getItem("refresh_token")});
// 更新本地 token
localStorage.setItem("access_token", res.access_token);
// 重发队列中的请求
requestList.forEach(cb => {
cb(res.access_token);
});
requestList = [];
isRefreshing = false;
// 重发本次请求
config.headers.Authorization = res.access_token;
return request(config);
}
// 正在刷新则等待刷新完成
else {
return new Promise(resolve => {
requestList.push((token: string) => {
error.config.headers.Authorization = token;
resolve(request(error.config));
});
});
}
}
}
}
});

pyjwt 生成双 token

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def generate_jwt(payload, expiry, secret=None):
"""
生成jwt
:param payload: dict 载荷
:param expiry: datetime 有效期
:param secret: 密钥
:return: jwt
"""
_payload = {'exp': expiry}
_payload.update(payload)

if not secret:
secret = current_app.config['JWT_SECRET']

token = jwt.encode(_payload, secret, algorithm='HS256')
return token.decode()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def _generate_tokens(self, user_id, with_refresh_token=True):
"""
生成token 和refresh_token
:param user_id: 用户id
:return: token, refresh_token
"""
# 颁发JWT
#expiry :截止时间---两小时后
now = datetime.utcnow()
expiry = now + timedelta(hours=2)
token = generate_jwt({'user_id': user_id, 'refresh': False}, expiry)
refresh_token = None
if with_refresh_token:
refresh_expiry = now + timedelta(days=current_app.config['JWT_REFRESH_DAYS'])
refresh_token = generate_jwt({'user_id': user_id, 'refresh': True}, refresh_expiry)
return token, refresh_token