[Redux Toolkit] RTKQ & Refresh Token 的方法

[Redux Toolkit] RTKQ & Refresh Token 的方法

省流:太长不想看,可以直接拖到最后拉 RTKQ 的解决方法

先简述一下问题的背景情况,起源于自己做的一个私人练手项目的 jwt token 验证,结构大体如下:

  • access token,其中 access token 的过期时间比较短——目前设置成 15 分钟

    该 token 本身会包含更多的信息,具体取决于项目需求,以目前主流的 OIDC 来说,规范可以包含以下信息:

    在这里插入图片描述

    可以看到,常见的用户信息都在 OIDC 的规范里

    一开始的时候其实没有想做的很复杂,只打算用 access token,所以把它放到了 local storage,毕竟这样持久化方便一些;后来 refresh token 做了,也就没有从 local storage 里面替换掉

    👀 比较好的实现是,在有了 refresh token 之后,access token 应该存在 memory 里,而不是 local storage,这样可以更好的避免 XSS 攻击

    ⚠️ 但这样做,也会导致 access token 在页面刷新后丢失,需要通过 refresh token 重新拉取,相对而言,前端的逻辑会更加的复杂一些

  • refresh token,它的过期时间会很久——目前设置成 7 天

    refresh token 就包含最少信息了,目前有的信息只有 user id 和 jti——一个随机生成的数值。其核心逻辑就是,当用户更新 refresh token 时,会通过当前 jti 和 id 去后台进行调用,如果这个组合在数据库中不存在,那么就表示:

    • 当前搭配从未出现过,这代表 refresh token 的 jwt secret 可能已经泄露

    • 当前 refresh token 可能已经被使用过

      当用户 refresh token 后,是会将数据库里之前的 token 删除,这样可以保证已经使用过的 token 无法重新被使用

      虽然这样的处理暂时无法支持多设备的实现——其实不少 app 也是不支持多设备的,多端登录会自动登出另一个设备。总体来说问题不大。而且解决方法也挺简单,到时候其实可以加一个 device id,通过 device id+jti+用户 id 依旧可以保持唯一性

      device id 也是可以通过 随机+local storage 进行实现

从另外一方面来说,因为 refresh token 的维持时间比较久,因此存储 token 的位置最好不能通过 JS 直接访问,也因此,httpOnly(JS 无法访问) + secure(production 环境,只能通过 HTTPS 访问)这种组合,让 cookie 成了最好的存储方式

这样的配置,搭配上 jwt 本身所包含的 secret——该 secret 如果通过 aws 进行存储的话,则是可以设置一定周期进行 rotation。如果再搭配上 MFA,那么在当前主流操作中,应该算是安全系数比较高的实现了

大体流程图如下:

User Frontend Backend DB Visit Website Check HttpOnly refresh token (via /refresh-token) Validate refresh token (userId + jti) Token exists Issue new access token (15min) Invalidate old refresh token (delete jti) Issue new refresh token (7d + new jti) Set HttpOnly cookie + return access token Store access token in memory Token not found or expired 401 Unauthorized alt [Valid] [Invalid / Expired] Access protected API (/me) Validate access token (JWT) Return data Call /refresh-token Repeat refresh flow alt [Token valid] [Token expired] Logout Call /logout Delete refresh token Clear refresh cookie Remove access token from memory User Frontend Backend DB

重新看了一下自己的实现,然后补充一下:

因为我目前做的只准许一个用户登录,所以将数据存储在数据库里就是很简单的 id+jti 的搭配:

const mongoose = require("mongoose");

const refreshTokenSchema = new mongoose.Schema({
  userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
  jti: { type: String, required: true, unique: true },
});

module.exports = mongoose.model("RefreshToken", refreshTokenSchema);

但是,如果要保证多设备,并且加了一个 deviceId 后,可能会遇到一些比如说用户有多余的设备,登陆后没有主动登出这种问题,这样就会造成冗余的数据。这种情况下,可以加一个 exp timestamp,定期跑一下 deleteMany({ exp: { $lt: new Date() } }) 即可

从 axios 入手

这里只是记录一下解决思路,具体代码删了很多,而且也没能跑通,所以大体记一下 💡 和部分记得的实现

总体来说从 axios 入手——如果没有使用 RTKQ 的话,是可以解决这个问题的,我最终也是完成了 refresh-token 调用结束后,再重新调用上一个 api。不过这里的问题在于:RTKQ 会 cache 整个 api,这也就包括 header,也因此不管我怎么修改 header,RTKQ 最终还是会使用 cache 的 header

换言之,我更新了 token,在 local storage 和 state 里都进行了更新,RTKQ 无法捕捉到这个更新,依旧使用了就的 jwt token。这里我试了很多,方法,比如:

  • 传统的实现

    即保存一个 array 保存所有需要被延迟调用的 API,和一个 boolean 进行需要将当前 API 存储到 array 中,还是释放 array 里的 api 这一判断

  • 使用 promise array

    和传统实现一致,只不过 Promise.all 可以等一个 array,因此就不需要进行 boolean 的判断了

    如果当前 promise array 不为 null,则说明正在等 refresh-token,否则直接进行调用

    找了下,实现大体和这篇 使用 axios 实现无感刷新 token,并总结经验 差不多:

    import axios from "axios"; //引用
    import {
      getToken,
      DelToken,
      DelUserInfo,
      refreshToken,
    } from "./operateToken";
    let http = axios.create();
    http.defaults.withCredentials = true; // 允许携带cookie
    
    /* 被挂起的请求数组 */
    let refreshSubscribers = [];
    
    /* push所有请求到数组中 */
    function subscribeTokenRefresh(cb) {
      refreshSubscribers.push(cb);
    }
    let isRefreshing = false;
    
    /* 刷新请求(refreshSubscribers数组中的请求得到新的token之后会自执行,用新的token去请求数据) */
    function onRrefreshed(access) {
      refreshSubscribers.map((cb) => cb(access));
    }
    
    //请求拦截
    http.interceptors.request.use(async (config) => {
      // console.log(config,'----------发起');
      let retry = new Promise((resolve, reject) => {
        /* (token) => {...}这个函数就是回调函数 */
        subscribeTokenRefresh((access) => {
          config.headers.Authorization = "Bearer " + access;
          /* 将请求挂起 */
          resolve(config);
        });
      });
      let resetTime = sessionStorage.getItem("resetTime");
      let currentTime = Date.now();
      if (currentTime > Number(resetTime)) {
        //token超期
        if (!isRefreshing) {
          isRefreshing = true;
          onRrefreshed(await refreshToken());
          isRefreshing = false;
        }
      } else {
        //token未超期,直接返回token不改变
        onRrefreshed(getToken());
      }
    
      return retry;
    });
    //响应拦截
    http.interceptors.response.use(
      (response) => {
        // console.log(response,'----------返回');
        return response;
      },
      (error) => {
        // console.log(error,'----error------返回');
        let response = error.response;
        const status = response.status;
        if (status === 401) {
          // 判断状态码是401 跳转到登录
          DelToken();
          DelUserInfo();
          alert(error.message);
          location.reload();
        }
        return Promise.reject(error);
      }
    );
    //抛出模块
    export default http;
    
  • 从 async/await 锁住进程

    这是上面的方法都不工作后做的测试,核心逻辑是,等 refresh token 返回,将 token 存到 local storage,使用 setTimeout 手动等了 20ms,最终再进行重新调用一次

    这个方法因为通过新的语法 🔒 进程,没有用 callback,所以可以比较直观地确定,第二个调用的 api 一定发生在 refresh-token 调用后,这也是我最终放弃从 axios interceptor 着手解决的原因

    这个和前面两种的解决方法相比有个致命的问题,那就是当同时调用多个 api,而所有的 token 都过期时,就会反复调用新的 refresh-token。但是 refresh-token 是具有唯一性的,在某个 race condition 就会发生已经被删除的 refresh-token 最后抵达,最后破坏整个验证流程

    大体流程可以参考:Automating access token refreshing via interceptors in axios

    /**
     * Wrap the interceptor in a function, so that it can be re-instantiated
     */
    function createAxiosResponseInterceptor() {
      const interceptor = axios.interceptors.response.use(
        (response) => response,
        (error) => {
          // Reject promise if usual error
          if (error.response.status !== 401) {
            return Promise.reject(error);
          }
    
          /*
           * When response code is 401, try to refresh the token.
           * Eject the interceptor so it doesn't loop in case
           * token refresh causes the 401 response.
           *
           * Must be re-attached later on or the token refresh will only happen once
           */
          axios.interceptors.response.eject(interceptor);
    
          return axios
            .post("/api/refresh_token", {
              refresh_token: this._getToken("refresh_token"),
            })
            .then((response) => {
              saveToken();
              error.response.config.headers["Authorization"] =
                "Bearer " + response.data.access_token;
              // Retry the initial call, but with the updated token in the headers.
              // Resolves the promise if successful
              return axios(error.response.config);
            })
            .catch((error2) => {
              // Retry failed, clean up and reject the promise
              destroyToken();
              this.router.push("/login");
              return Promise.reject(error2);
            })
            .finally(createAxiosResponseInterceptor); // Re-attach the interceptor by running the method
        }
      );
    }
    createAxiosResponseInterceptor(); // Execute the method once during start
    

从 RTKQ 入手

问题的关键其实不在 axios,而是在 RTKQ——毕竟是 RTKQ cache 了整个 api 请求,所以最终解决方案还是得回到 RTKQ。通过 How to ‘refresh token’ using RTK Query 这个 post,发现了官网上其实也有答案:Automatic re-authorization by extending fetchBaseQuery,简单修改了一下,让它能和 axios 进行工作:

const axiosBaseQueryWithReauth = async (args, api, extraOptions) => {
  await mutex.waitForUnlock(); // Wait here if another refresh is in progress

  let result = await axiosBaseQuery(args, api, extraOptions);

  const isExpired =
    result.error?.status === 401 &&
    typeof result.error.data?.message === "string" &&
    result.error.data.message.includes("expired");

  if (isExpired) {
    if (!mutex.isLocked()) {
      const release = await mutex.acquire();
      try {
        const state = api.getState();
        const isAdmin = state.auth?.isAdmin;
        const isSeller = state.auth?.isSeller;

        const refreshAxios = getAxiosInstance({ isAdmin, isSeller }); 

        const refreshResult = await refreshAxios.post("/refresh-token");

        const newAccessToken = refreshResult?.data?.accessToken;
        if (newAccessToken) {
          storeAuthToken(newAccessToken);
          api.dispatch({
            type: "auth/updateToken",
            payload: newAccessToken,
          });
        } else {
          return result;
        }
      } finally {
        release();
      }
    } else {
      await mutex.waitForUnlock();
    }

    result = await axiosBaseQuery(args, api, extraOptions);
  }

  return result;
};

对比一下官网原生实现:

import { fetchBaseQuery } from "@reduxjs/toolkit/query";
import { tokenReceived, loggedOut } from "./authSlice";
import { Mutex } from "async-mutex";

// create a new mutex
const mutex = new Mutex();
const baseQuery = fetchBaseQuery({ baseUrl: "/" });
const baseQueryWithReauth = async (args, api, extraOptions) => {
  // wait until the mutex is available without locking it
  await mutex.waitForUnlock();
  let result = await baseQuery(args, api, extraOptions);
  if (result.error && result.error.status === 401) {
    // checking whether the mutex is locked
    if (!mutex.isLocked()) {
      const release = await mutex.acquire();
      try {
        const refreshResult = await baseQuery(
          "/refreshToken",
          api,
          extraOptions
        );
        if (refreshResult.data) {
          api.dispatch(tokenReceived(refreshResult.data));
          // retry the initial query
          result = await baseQuery(args, api, extraOptions);
        } else {
          api.dispatch(loggedOut());
        }
      } finally {
        // release must be called once the mutex should be released again.
        release();
      }
    } else {
      // wait until the mutex is available without locking it
      await mutex.waitForUnlock();
      result = await baseQuery(args, api, extraOptions);
    }
  }
  return result;
};

其实可以看到,本质上是没什么特别大的区别的,我就是把 result = await axiosBaseQuery(args, api, extraOptions); 换了个地方调用,目前看来没什么大问题……

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值