[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,那么在当前主流操作中,应该算是安全系数比较高的实现了
大体流程图如下:
重新看了一下自己的实现,然后补充一下:
因为我目前做的只准许一个用户登录,所以将数据存储在数据库里就是很简单的 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); 换了个地方调用,目前看来没什么大问题……
1万+

被折叠的 条评论
为什么被折叠?



