vue-vben-admin 5.8.0 修复解读:Ant Design Vue Next 变体的 refresh/logout 请求 withCredentials 处理
【免费下载链接】vue-vben-adminA modern vue admin panel built with Vue3, Shadcn UI, Vite, TypeScript, and Monorepo. It's fast!项目地址: https://gitcode.com/GitHub_Trending/vu/vue-vben-admin
本文基于
apps/web-antdv-next的 5.8.0 版本 CHANGELOG 记录,深入解读该版本中一项关键的认证修复——在refresh(刷新 accessToken)与logout(退出登录)两个请求上显式携带withCredentials配置,并结合仓库源码与后端 Mock 实现,剖析其背后的 httpOnly Cookie 刷新令牌机制、请求拦截器刷新流程与验证方式。
一、这次 5.8.0 修复了什么
在 CHANGELOG 的5.8.0版本下,有一条 Patch Changes 记录(PR #8308):
fix(apps): pass withCredentials as request config for refresh/logout
修复者kilisamemarisaaa将withCredentials作为请求配置传入 refresh 与 logout 两个接口。这虽然只是一行配置的变更,但它修复的是一个在**跨域 + 携带 Cookie 的刷新令牌(Refresh Token)**场景下极易出现、且难以排查的认证失效问题。
要理解这次修复的价值,需要先回答三个问题:
withCredentials是什么、为什么刷新令牌必须依赖它;- 为什么
login能正常工作,而refresh/logout却可能拿不到 Cookie; - 修复之后,完整的令牌刷新链路是如何串起来的。
下文将逐层展开,并用仓库源码作为证据。
二、withCredentials:让跨域请求带上 Cookie
浏览器对跨域请求默认不会携带 Cookie。XMLHttpRequest/fetch的withCredentials选项用于指示浏览器在跨域请求中是否携带目标域名的 Cookie 与 HTTP 认证信息。
在 vue-vben-admin 中,前端应用与后端接口往往部署在不同的源(例如前端localhost:5173、Mock 后端localhost:5320,参见各应用vite.config.ts中的代理配置),因此只要涉及"通过 Cookie 传递凭证"的接口,就必须显式开启withCredentials: true,否则请求到达后端时 Cookie 为空,后端会认为请求未携带刷新令牌而直接拒绝。
而 vue-vben-admin 恰好把refreshToken 存放在 httpOnly Cookie中(而不是像 accessToken 那样放在请求头Authorization: Bearer <token>里),这正是本次修复的技术背景,详见下文第三节。
修复点对应的源码
本次修复落实到web-antdv-next的认证 API 定义中,见 apps/web-antdv-next/src/api/core/auth.ts:
/** * 刷新accessToken */ export async function refreshTokenApi() { return baseRequestClient.post<AuthApi.RefreshTokenResult>( '/auth/refresh', undefined, { withCredentials: true, }, ); } /** * 退出登录 */ export async function logoutApi() { return baseRequestClient.post('/auth/logout', undefined, { withCredentials: true, }); }从代码可以看到:
refreshTokenApi使用baseRequestClient(不携带 Authorization 头的裸请求客户端)调用POST /auth/refresh,并把withCredentials: true作为第三参数(请求配置)传入;logoutApi同样以withCredentials: true调用POST /auth/logout;- 而
loginApi走的是requestClient,登录成功后由后端通过Set-Cookie种下刷新令牌,因此登录请求本身不依赖已有 Cookie。
baseRequestClient的定义位于 apps/web-antdv-next/src/api/request.ts:
export const requestClient = createRequestClient(apiURL, { responseReturn: 'data', }); export const baseRequestClient = new RequestClient({ baseURL: apiURL });三、为什么要靠 Cookie 传递刷新令牌:Mock 后端的凭证设计
在web-antdv-next的 Mock 后端(Nitro 应用)中,刷新令牌的生命周期完全由 Cookie 驱动。核心实现在 apps/backend-mock/utils/cookie-utils.ts:
export function clearRefreshTokenCookie(event: H3Event<EventHandlerRequest>) { deleteCookie(event, 'jwt', { httpOnly: true, sameSite: 'none', secure: true, }); } export function setRefreshTokenCookie( event: H3Event<EventHandlerRequest>, refreshToken: string, ) { setCookie(event, 'jwt', refreshToken, { httpOnly: true, maxAge: 24 * 60 * 60, // unit: seconds sameSite: 'none', secure: true, }); } export function getRefreshTokenFromCookie(event: H3Event<EventHandlerRequest>) { const refreshToken = getCookie(event, 'jwt'); return refreshToken; }该 Cookie 的关键属性值得逐条对照:
| Cookie 属性 | 取值 | 作用 |
|---|---|---|
httpOnly | true | 脚本无法通过document.cookie读取,降低 XSS 窃取刷新令牌的风险 |
sameSite | none | 允许跨站携带 Cookie(配合跨域部署) |
secure | true | 仅允许 HTTPS 传输(本地开发环境需注意) |
maxAge | 24 * 60 * 60秒(1 天) | 刷新令牌 Cookie 的存活时长 |
刷新令牌本身是 JWT,由 apps/backend-mock/utils/jwt-utils.ts 生成与校验:
const ACCESS_TOKEN_SECRET = 'access_token_secret'; const REFRESH_TOKEN_SECRET = 'refresh_token_secret'; export function generateAccessToken(user: UserInfo) { return jwt.sign(user, ACCESS_TOKEN_SECRET, { expiresIn: '7d' }); } export function generateRefreshToken(user: UserInfo) { return jwt.sign(user, REFRESH_TOKEN_SECRET, { expiresIn: '30d', }); }可以看到默认策略是:accessToken 有效期 7 天、走请求头;refreshToken 有效期 30 天、走 httpOnly Cookie。accessToken 过期后,前端用 refreshToken 换取新的 accessToken,无需用户重新输入密码。
四、登录 → 刷新 → 退出的完整链路
1. 登录:种下刷新令牌 Cookie
登录接口 apps/backend-mock/api/auth/login.post.ts 在校验用户名密码通过后,同时生成 accessToken 与 refreshToken:
const accessToken = generateAccessToken(findUser); const refreshToken = generateRefreshToken(findUser); setRefreshTokenCookie(event, refreshToken); return useResponseSuccess({ ...findUser, accessToken, });即:accessToken 通过响应体返回给前端(随后被存入 access store),refreshToken 通过Set-Cookie写入浏览器的 httpOnly Cookie。
2. 刷新:读取 Cookie 并签发新 accessToken
刷新接口 apps/backend-mock/api/auth/refresh.post.ts 的处理逻辑:
export default defineEventHandler(async (event) => { const refreshToken = getRefreshTokenFromCookie(event); if (!refreshToken) { return forbiddenResponse(event); } clearRefreshTokenCookie(event); const userinfo = verifyRefreshToken(refreshToken); if (!userinfo) { return forbiddenResponse(event); } const findUser = MOCK_USERS.find( (item) => item.username === userinfo.username, ); if (!findUser) { return forbiddenResponse(event); } const accessToken = generateAccessToken(findUser); setRefreshTokenCookie(event, refreshToken); return accessToken; });这段代码清晰展示了 Cookie 刷新机制的两个关键点:
- 读取凭证的唯一途径就是请求携带的
jwtCookie(getRefreshTokenFromCookie)。如果前端请求没有withCredentials: true,跨域场景下 Cookie 不会被携带,接口直接返回 403——这正是本次修复要解决的场景; - 旋转式刷新(Refresh Token Rotation):每次刷新都会先
clearRefreshTokenCookie,验证成功后重新setRefreshTokenCookie,即刷新令牌的 Cookie 被重新种下(Mock 实现中复用了原 refreshToken,生产环境可签发新令牌并作废旧令牌)。
3. 退出:清除 Cookie
退出接口 apps/backend-mock/api/auth/logout.post.ts 同样依赖 Cookie:
export default defineEventHandler(async (event) => { const refreshToken = getRefreshTokenFromCookie(event); if (!refreshToken) { return useResponseSuccess(''); } clearRefreshTokenCookie(event); return useResponseSuccess(''); });如果logout请求没有携带withCredentials: true,服务端读取不到jwtCookie,虽然接口仍返回成功,但服务端无法真正清除服务端视角下的会话状态;而前端清除的只是内存中的 accessToken,刷新令牌 Cookie 仍残留在浏览器中,造成"退出后刷新令牌依然有效"的隐患。修复后浏览器会随请求携带 Cookie,服务端得以可靠地清除它。
五、前端拦截器如何串起整条刷新链路
refreshTokenApi/logoutApi被谁调用?答案在 apps/web-antdv-next/src/api/request.ts 中定义的两个拦截器中。
1. 请求拦截器:为每个请求注入 Authorization 头
client.addRequestInterceptor({ fulfilled: async (config) => { const accessStore = useAccessStore(); config.headers.Authorization = formatToken(accessStore.accessToken); config.headers['Accept-Language'] = preferences.app.locale; return config; }, });从 access store 中读取当前 accessToken,拼成Bearer <token>写入Authorization头,并附带当前语言偏好。
2. 响应拦截器:401 时自动刷新并重放请求
authenticateResponseInterceptor位于 packages/effects/request/src/request-client/preset-interceptors.ts,其核心逻辑:
rejected: async (error) => { const { config, response } = error; // 如果不是 401 错误,直接抛出异常 if (response?.status !== 401) { throw error; } // 判断是否启用了 refreshToken 功能 // 如果没有启用或者已经是重试请求了,直接跳转到重新登录 if (!enableRefreshToken || config.__isRetryRequest) { await doReAuthenticate(); throw error; } // 如果正在刷新 token,则将请求加入队列,等待刷新完成 if (client.isRefreshing) { return new Promise((resolve) => { client.refreshTokenQueue.push((newToken: string) => { config.headers.Authorization = formatToken(newToken); resolve(client.request(config.url, { ...config })); }); }); } // 标记开始刷新 token client.isRefreshing = true; // 标记当前请求为重试请求,避免无限循环 config.__isRetryRequest = true; try { const newToken = await doRefreshToken(); // 处理队列中的请求 client.refreshTokenQueue.forEach((callback) => callback(newToken)); // 清空队列 client.refreshTokenQueue = []; return client.request(error.config.url, { ...error.config }); } catch (refreshError) { // 如果刷新 token 失败,处理错误(如强制登出或跳转登录页面) client.refreshTokenQueue.forEach((callback) => callback('')); client.refreshTokenQueue = []; console.error('Refresh token failed, please login again.'); await doReAuthenticate(); throw refreshError; } finally { client.isRefreshing = false; } },而doRefreshToken正是调用本次修复的refreshTokenApi:
async function doRefreshToken() { const accessStore = useAccessStore(); const resp = await refreshTokenApi(); const newToken = resp.data; accessStore.setAccessToken(newToken); return newToken; }刷新失败时的兜底doReAuthenticate则会清空 accessToken,并根据preferences.app.loginExpiredMode决定是弹出登录过期弹窗还是直接登出:
async function doReAuthenticate() { console.warn('Access token or refresh token is invalid or expired. '); const accessStore = useAccessStore(); const authStore = useAuthStore(); accessStore.setAccessToken(null); if ( preferences.app.loginExpiredMode === 'modal' && accessStore.isAccessChecked ) { accessStore.setLoginExpired(true); } else { await authStore.logout(); } }刷新失败时的报错文案与状态码映射
同一文件中的errorMessageResponseInterceptor负责把网络错误与 HTTP 状态码翻译成用户可读的提示,其中 401 对应"未登录或登录已过期":
case 401: { errorMessage = $t('ui.fallback.http.unauthorized'); break; }六、修复的验证方式
修复是否生效,可以通过以下方式在本地验证:
- 启动应用与 Mock 后端:在
web-antdv-next目录执行pnpm dev(对应 package.json 中的"dev": "pnpm vite --mode development"),同时启动apps/backend-mock。 - 登录:调用
loginApi登录成功后,在浏览器开发者工具中确认响应Set-Cookie: jwt=...,且该 Cookie 属性包含HttpOnly、SameSite=None、Secure。 - 观察刷新请求:将 accessToken 改短使其过期(或直接构造一个 401 响应),触发
authenticateResponseInterceptor,在 Network 面板检查POST /auth/refresh请求的Request Headers 中是否携带了Cookie: jwt=...。修复前该请求不带 Cookie、返回 403;修复后携带 Cookie 并返回新的 accessToken,随后原请求被自动重放。 - 观察退出请求:点击退出登录,检查
POST /auth/logout请求同样携带Cookie,响应后服务端清除jwtCookie。
如果部署在跨域环境,还需确保后端Access-Control-Allow-Credentials: true且Access-Control-Allow-Origin不是*(必须为具体源),否则即使前端开启withCredentials,浏览器也会拦截带凭证的跨域响应。
七、5.8.0 版本中该应用的其他同步更新
本次修复属于@vben/web-antdv-next应用的补丁变更,随同版本还同步升级了一批底层依赖包(均为5.8.0版本),它们在 CHANGELOG 中列出,并在 package.json 中以workspace:*的形式引用:
| 依赖包 | 说明 |
|---|---|
@vben/styles | 全局样式与主题样式(含antdv-next适配样式) |
@vben/plugins | 应用插件集合 |
@vben/preferences | 全局偏好配置(含enableRefreshToken、loginExpiredMode等开关) |
@vben/layouts | 布局系统 |
@vben/common-ui | 通用 UI 组件 |
@vben/access | 权限控制 |
@vben/hooks | 组合式函数 |
@vben/constants | 常量定义 |
@vben/request | 请求客户端与拦截器(本次修复依赖的RequestClient即来自此包) |
@vben/icons | 图标 |
@vben/locales | 国际化 |
@vben/stores | Pinia store 集合 |
@vben/types | 类型定义 |
@vben/utils | 工具函数 |
UI 层使用antdv-next(Ant Design Vue Next)组件库,因此该应用的启动入口 bootstrap.ts 会引入@vben/styles/antdv-next样式,并通过 adapter/component/index.ts 完成组件适配器的初始化。
八、小结
web-antdv-next5.8.0 的这则补丁变更虽小,却是"httpOnly Cookie 刷新令牌"安全模型能否在跨域环境下闭环的关键一环:
- 修复前:
refresh/logout请求不带withCredentials,浏览器不携带jwtCookie,刷新令牌失效、退出无法清理服务端会话; - 修复后:两个接口显式携带凭证,配合请求拦截器实现 401 自动刷新、请求重放与失败兜底登出,形成完整的令牌生命周期管理。
如果你正在基于 vue-vben-admin 的任一应用变体(web-antd、web-antdv-next、web-ele、web-naive、web-tdesign、playground)进行二次开发,且后端将刷新令牌放入 httpOnly Cookie,请务必检查自己的refreshTokenApi/logoutApi是否同样携带了withCredentials: true——这是极易被遗漏、又直接影响登录态稳定性的细节。
【免费下载链接】vue-vben-adminA modern vue admin panel built with Vue3, Shadcn UI, Vite, TypeScript, and Monorepo. It's fast!项目地址: https://gitcode.com/GitHub_Trending/vu/vue-vben-admin
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考