1
头图

浅析使用 JWT 的正确姿势

在很长的一段时间里,我都没有正确的使用 jwt,意识到这个问题之后,把我最真实的思考和总结拿出来和大家分享下,欢迎一起讨论

现状

先说下我以前是怎么使用的:

  1. 登录成功后,将 userId 放进 payload 生成 jwt(有效期 8 小时),然后把 jwt 发送到前端,前端存储下来
  2. 前端每一次访问 API 需在 header 中携带 jwt
  3. 后端先解析 jwt,拿到 userId 后,数据库查询此用户的权限列表(用户-角色-权限)
  4. 拿到用户的权限列表后,和当前接口所需的权限进行匹配,匹配成功返回数据,失败返回 401

jwt 标准

先来查查标准是怎么样的,首先参考了jwt.io上面对使用场景的说明:

  1. Authorization 授权
  2. Information Exchange 信息交换

对于上面的信息,我个人的理解是两个方面:

  1. 允许用户访问路由、服务和资源,在我这里就是接口所需的权限,也可以 SSO 登录,我这里目前不需要
  2. 可以确定当前用户的身份,在我这里就是 userId 了

优化

现在的用法有以下缺陷:

  1. 每次调用接口都需要进行数据库查询权限(用户-角色-权限),浪费资源
  2. 登录成功 8 小时后,即使用户一直在不停的使用系统,但 jwt 还是会失效,需要重新登录

第一点好说,把权限列表也放进 payload,解析完毕直接和接口所需权限进行对比。

第二点,把有效期延长到一个星期,一个月?但是仍然会发生正在用着用着 jwt 就失效了,需要重新登录的情况,时间设置的太长也不安全,因为 jwt 本身就是无状态的,而且权限变更了怎么办,难道要等很久才生效吗,这么看来必须要刷新 jwt 了。

对应的优化点就来了:

  1. 把权限列表放进 payload,不用每次都去数据库查询
  2. 让用户无感的刷新 jwt

刷新 jwt 方案

这里参考了 stackoverflow 上面的讨论:

  1. jwt-refresh-token-flow
  2. JWT (JSON Web Token) automatic prolongation of expiration

然后我确定了我的刷新流程:

  1. 登录成功后颁发两个 token:accessToken 有效期 1 小时,refreshToken 有效期 1 天
  2. accessToken 失效后返回 401,前端通过 refreshToken 获取新的 accessToken 和新的 refreshToken
  3. refreshToken 失效后返回 403,需要重新登录

也就是说,登录成功后,在 refreshToken 有效期内,都可以继续操作,并且顺延有效期,再也不会出现用着用着突然需要重新登录的情况了。这俩有效期可以自行调整,我这里考虑的是 accessToken 最好不能太长,不然调整权限后生效期太短。

后端调整

新增用来刷新 token 的接口,大部分逻辑和登录是一样的,验证 refreshToken 后,返回新的 accessToken 和新的 refreshToken

前端调整

主要的难点在前端部分,前端的刷新逻辑:

  1. 登录成功后在前端存储 accessToken 和 refreshToken,以后的每一次调用 API 都需要携带 accessToken
  2. 用户一小时后继续操作后端返回 401,此时 accessToken 失效,把这一阶段的所有请求都缓存下来
  3. 使用 refreshToken 获取新的 accessToken 和新的 refreshToken
  4. 使用新的 accessToken 重新发起刚才所有缓存下来的请求
  5. 一天之后用户再次操作,后端返回 401,此时 accessToken 失效,把这一阶段的所有请求都缓存下来
  6. 使用 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 的有效期进行测试

后端部分的源码在这里

前端部分的源码在这里

还有在线的体验地址


hezhongfeng
257 声望452 粉丝

coder