学堂 学堂 学堂公众号手机端

浅析使用 JWT 的正确姿势

lewis 4年前 (2021-06-28) 阅读数 5 #技术
浅析使用 JWT 的正确姿势

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

现状

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

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

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


Authorization 授权Information Exchange 信息交换

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

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

现在的用法有以下缺陷:

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

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

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

对应的优化点就来了:

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

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

jwt-refresh-token-flowJWT (JSON Web Token) automatic prolongation of expiration

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

登录成功后颁发两个 token:accessToken 有效期 1 小时,refreshToken 有效期 1 天accessToken 失效后返回 401,前端通过 refreshToken 获取新的 accessToken 和新的 refreshTokenrefreshToken 失效后返回 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 的有效期进行测试

后端部分的源码在这里

前端部分的源码在这里

还有在线的体验地址

版权声明

本文仅代表作者观点,不代表博信信息网立场。

热门