浅析使用 JWT 的正确姿势
在很长的一段时间里,我都没有正确的使用 jwt,意识到这个问题之后,把我最真实的思考和总结拿出来和大家分享下,欢迎一起讨论
现状
先说下我以前是怎么使用的:
- 登录成功后,将 userId 放进 payload 生成 jwt(有效期 8 小时),然后把 jwt 发送到前端,前端存储下来
- 前端每一次访问 API 需在 header 中携带 jwt
- 后端先解析 jwt,拿到 userId 后,数据库查询此用户的权限列表(用户-角色-权限)
- 拿到用户的权限列表后,和当前接口所需的权限进行匹配,匹配成功返回数据,失败返回 401
jwt 标准
先来查查标准是怎么样的,首先参考了jwt.io上面对使用场景的说明:
- Authorization 授权
- Information Exchange 信息交换
对于上面的信息,我个人的理解是两个方面:
- 允许用户访问路由、服务和资源,在我这里就是接口所需的权限,也可以 SSO 登录,我这里目前不需要
- 可以确定当前用户的身份,在我这里就是 userId 了
优化
现在的用法有以下缺陷:
- 每次调用接口都需要进行数据库查询权限(用户-角色-权限),浪费资源
- 登录成功 8 小时后,即使用户一直在不停的使用系统,但 jwt 还是会失效,需要重新登录
第一点好说,把权限列表也放进 payload,解析完毕直接和接口所需权限进行对比。
第二点,把有效期延长到一个星期,一个月?但是仍然会发生正在用着用着 jwt 就失效了,需要重新登录的情况,时间设置的太长也不安全,因为 jwt 本身就是无状态的,而且权限变更了怎么办,难道要等很久才生效吗,这么看来必须要刷新 jwt 了。
对应的优化点就来了:
- 把权限列表放进 payload,不用每次都去数据库查询
- 让用户无感的刷新 jwt
刷新 jwt 方案
这里参考了 stackoverflow 上面的讨论:
然后我确定了我的刷新流程:
- 登录成功后颁发两个 token:accessToken 有效期 1 小时,refreshToken 有效期 1 天
- accessToken 失效后返回 401,前端通过 refreshToken 获取新的 accessToken 和新的 refreshToken
- refreshToken 失效后返回 403,需要重新登录
也就是说,登录成功后,在 refreshToken 有效期内,都可以继续操作,并且顺延有效期,再也不会出现用着用着突然需要重新登录的情况了。这俩有效期可以自行调整,我这里考虑的是 accessToken 最好不能太长,不然调整权限后生效期太短。
后端调整
新增用来刷新 token 的接口,大部分逻辑和登录是一样的,验证 refreshToken 后,返回新的 accessToken 和新的 refreshToken
前端调整
主要的难点在前端部分,前端的刷新逻辑:
- 登录成功后在前端存储 accessToken 和 refreshToken,以后的每一次调用 API 都需要携带 accessToken
- 用户一小时后继续操作后端返回 401,此时 accessToken 失效,把这一阶段的所有请求都缓存下来
- 使用 refreshToken 获取新的 accessToken 和新的 refreshToken
- 使用新的 accessToken 重新发起刚才所有缓存下来的请求
- 一天之后用户再次操作,后端返回 401,此时 accessToken 失效,把这一阶段的所有请求都缓存下来
- 使用 refreshToken 获取,后端返回 403,跳转到登录页重新登录
此处需要考虑的是并发请求,需要把 accessToken 失效后期间所有的请求都缓存下来,并且在获取到有效 accessToken 后继续所有未完成的请求
目前我使用的是 axios,使用的拦截器,在此贴出部分核心代码:
// 响应拦截器
axios.interceptors.response.use(
response => {
const data = response.data;
// 没有code但是http状态为200表示外部请求成功
if (!data.code && response.status === 200) return data;
// 根据返回的code值来做不同的处理(和后端的私有约定)
switch (data.code) {
case 200:
return data;
default:
}
// 若不是正确的返回code,且已经登录,就抛出错误
throw data;
},
err => {
// 这里是返回 http 状态码不为 200和304 时候的错误处理
if (err && err.response) {
switch (err.response.status) {
case 400:
err.message = '请求错误';
break;
case 401:
// accesstoken 错误
if (router.currentRoute.path === '/login') {
break;
}
// 判断是否有 refreshToken
const root = useRootStore();
if (!root.refreshToken) {
logout();
break;
}
// 进入刷新 token 流程
// 本次请求的所有配置信息,包含了 url、method、data、header 等信息
const config = err?.config;
const requestPromise = new Promise(resolve => {
addRequestList(() => {
// 注意这里的createRequest函数执行的时候是在resolve开始执行的时候,并且返回一个新的Promise,这个新的Promise会代替接口调用的那个
resolve(createRequest(config));
});
});
refreshTokenRequest();
// 这里很重要,因为本次请求 401 了,要返回给调用接口的方法返回一个新的请求
return requestPromise;
case 403:
// 403 这里说明刷新token失败,登录已经到期,需要重新登录
// 10 秒后清除所有缓存的请求
setTimeout(() => {
clearTempRequestList();
}, 10000);
logout();
break;
default:
}
}
return Promise.reject(err);
}
);
刷新部分的逻辑代码:
import axios from 'axios';
import http from './index';
import { useRootStore } from '@/store/root';
// 临时的请求函数列表
const tempRequestList = [];
// 发起刷新token的标志位,防止重复刷新请求
let isRefreshing = false;
// 1min 内刷新过token标志位
// 为了防止并发的时候,刷新请求完毕,tempRequestList也已经清空,之后仍有请求返回403,造成重复刷新
let refreshTokenWithin1Minute = false;
const refreshTokenRequest = () => {
if (isRefreshing) {
return;
}
if (refreshTokenWithin1Minute) {
for (const request of tempRequestList) {
request();
}
tempRequestList.length = 0;
return;
}
isRefreshing = true;
refreshTokenWithin1Minute = true;
const root = useRootStore();
// 使用刷新token请求新的accesstoken和刷新token
const params = {
refreshToken: root.refreshToken
};
http.post('/api/v1/refresh-token', params).then(({ data }) => {
root.updateAccessToken(data.token);
root.updateRefreshToken(data.refreshToken);
root.updateUserId(data.userId);
for (const request of tempRequestList) {
request();
}
// 1 min 后清除标志位
setTimeout(() => {
refreshTokenWithin1Minute = false;
}, 60000);
tempRequestList.length = 0;
isRefreshing = false;
});
};
const addRequestList = request => {
tempRequestList.push(request);
};
const clearTempRequestList = () => {
tempRequestList.length = 0;
};
const createRequest = config => {
// 这里必须更新 header 中的 AccessToken
const root = useRootStore();
config.headers['Authorization'] = 'Bearer ' + root.accessToken;
return axios(config);
};
export { refreshTokenRequest, createRequest, addRequestList, clearTempRequestList };
源码
前后端有提供源码,可以利用源码调整两个 token 的有效期进行测试
后端部分的源码在这里
前端部分的源码在这里
还有在线的体验地址
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。