Remix+Go后端JWT静默登录:Refresh Token回写Cookie难题
Remix + Go后端Token刷新正确实现方案
核心问题拆解
- 竞态根源:多个后端请求(action/loader)同时检测到ACCESS_TOKEN过期,会重复触发REFRESH_TOKEN刷新请求,导致Redis中旧token被多次删除、新token覆盖混乱,且Set-Cookie头传递冲突
- Remix后端无法直接操作客户端Cookie,必须通过响应头
Set-Cookie传递,因此需要统一刷新逻辑,避免并发冲突
统一Token刷新逻辑(后端全局拦截)
不要在单个action/loader中零散处理刷新,而是封装全局Axios实例,结合锁机制避免竞态,统一管理session和Cookie。
1. 封装带刷新逻辑的Axios实例
import axios from "axios"; import { createCookieSessionStorage } from "@remix-run/node"; import mem from "mem"; // 单实例部署用mem做内存锁,多实例建议用Redis分布式锁 // 初始化Session存储,配置Cookie参数 const sessionStorage = createCookieSessionStorage({ cookie: { name: "auth_session", httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: 7 * 24 * 60 * 60, // 与Go后端REFRESH_TOKEN有效期保持一致 }, }); // 封装去重的refresh请求:同一旧REFRESH_TOKEN短时间内只触发一次刷新 const refreshTokens = mem( async (oldRefreshToken: string): Promise<{ accessToken: string; refreshToken: string; setCookie: string }> => { // 调用Go后端的/refresh接口 const response = await axios.post("http://your-go-backend/refresh", { refresh_token: oldRefreshToken, }); const newAccessToken = response.data.access_token; const newRefreshToken = response.data.refresh_token; // 更新Session并生成Set-Cookie头 const session = await sessionStorage.getSession(); session.set("access_token", newAccessToken); session.set("refresh_token", newRefreshToken); const setCookieHeader = await sessionStorage.commitSession(session); return { accessToken: newAccessToken, refreshToken: newRefreshToken, setCookie: setCookieHeader }; }, { key: (oldToken) => `refresh_lock_${oldToken}`, maxAge: 5000, // 5秒内同一旧Token只允许一次刷新请求 } ); // 创建全局API客户端 const apiClient = axios.create({ baseURL: "http://your-go-backend" }); // 请求拦截器:自动携带ACCESS_TOKEN apiClient.interceptors.request.use(async (config) => { // 从请求头传入Remix的request对象,用于获取Session const remixRequest = config.headers["remix-request"] as Request; const session = await sessionStorage.getSession(remixRequest.headers.get("Cookie")); const accessToken = session.get("access_token"); if (accessToken) { config.headers.Authorization = `Bearer ${accessToken}`; } return config; }); // 响应拦截器:处理TOKEN过期 apiClient.interceptors.response.use( (res) => res, async (error) => { const originalRequest = error.config; // 仅处理401且未重试过的请求 if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; // 标记已重试,避免无限循环 const remixRequest = originalRequest.headers["remix-request"] as Request; const session = await sessionStorage.getSession(remixRequest.headers.get("Cookie")); const oldRefreshToken = session.get("refresh_token"); if (!oldRefreshToken) { // 无有效REFRESH_TOKEN,直接返回错误 return Promise.reject(error); } try { // 获取新Token和Set-Cookie头 const { accessToken, setCookie } = await refreshTokens(oldRefreshToken); // 更新原请求的Authorization头 originalRequest.headers.Authorization = `Bearer ${accessToken}`; // 把Set-Cookie头暂存,后续统一返回给客户端 originalRequest._setCookie = setCookie; // 重新发送请求 return apiClient(originalRequest); } catch (refreshErr) { // 刷新失败,清除Session并引导登录 const session = await sessionStorage.getSession(remixRequest.headers.get("Cookie")); const clearCookie = await sessionStorage.destroySession(session); return Promise.reject({ ...refreshErr, _clearCookie: clearCookie, }); } } return Promise.reject(error); } ); export default apiClient;
2. 在Action/Loader中使用封装好的客户端
import { ActionArgs, json, redirect } from "@remix-run/node"; import apiClient from "~/utils/api-client"; export const action = async ({ request }: ActionArgs) => { try { // 传入Remix的request对象,供拦截器获取Session const resA = await apiClient.get("/getA", { headers: { "remix-request": request } }); const resB = await apiClient.get("/getB", { headers: { "remix-request": request } }); const resC = await apiClient.get("/getC", { headers: { "remix-request": request } }); // 收集所有需要设置的Cookie头 const headers = new Headers(); // 检查是否有刷新后生成的Set-Cookie const setCookie = resA.config._setCookie || resB.config._setCookie || resC.config._setCookie; if (setCookie) { headers.append("Set-Cookie", setCookie); } return json( { dataA: resA.data, dataB: resB.data, dataC: resC.data }, { headers } ); } catch (error) { // 处理刷新失败的情况,清除Cookie并跳转登录 if (error._clearCookie) { return redirect("/login", { headers: { "Set-Cookie": error._clearCookie }, }); } return json({ error: "请求失败" }, { status: 500 }); } };
竞态问题解决关键点
- 用
mem做请求去重:同一旧REFRESH_TOKEN在5秒内只触发一次刷新,避免并发请求冲突;多实例部署时替换为Redis分布式锁 - 标记
_retry字段:防止单个请求因刷新失败陷入无限循环 - 统一通过SessionStorage管理Cookie:避免手动拼接
Set-Cookie头出错,确保Cookie的安全性和正确性
Go后端配合注意事项
/refresh接口处理完刷新后,立即从Redis中删除旧的REFRESH_TOKEN,防止重复使用- 无需在Go后端设置
Set-Cookie头,完全由Remix后端处理Session和Cookie
内容的提问来源于stack exchange,提问作者SamuelTJackson
相关产品推荐
相关产品推荐

