fix(auth): 修复令牌过期处理和会话失效通知机制

- 移除 VITE_SERVICE_LOGOUT_CODES 中的 1002023000 状态码
- 将 VITE_SERVICE_EXPIRED_TOKEN_CODES 从 1002023001 改为 1002023000
- 修改 fetchRefreshToken 函数使用 params 传递 refreshToken 并设置 skipAuth
- 添加 skipAuth 配置选项避免给公开接口带上过期 access 头
- 实现 notifySessionExpired 函数确保并发请求只弹一次会话失效提示
- 在登录成功后复位会话失效标志以支持下次正常提示
- 更新 handleExpiredRequest 使用 refreshTokenPromise 替代 refreshTokenFn
This commit is contained in:
2026-05-15 13:38:41 +08:00
parent 7a4d831c10
commit 543d1a59a9
7 changed files with 69 additions and 19 deletions

4
.env
View File

@@ -33,7 +33,7 @@ VITE_SERVICE_SUCCESS_CODE=0
# 后端登出状态码;当返回这些 code 时,前端会登出并跳回登录页 # 后端登出状态码;当返回这些 code 时,前端会登出并跳回登录页
# 典型场景token 无效、登录状态失效、账号被踢下线、后端要求强制重新登录 # 典型场景token 无效、登录状态失效、账号被踢下线、后端要求强制重新登录
VITE_SERVICE_LOGOUT_CODES=401,1002023000 VITE_SERVICE_LOGOUT_CODES=401
# 后端弹窗登出状态码;当返回这些 code 时,前端会先弹窗再登出 # 后端弹窗登出状态码;当返回这些 code 时,前端会先弹窗再登出
# 典型场景:账号被禁用、密码已重置、登录安全策略触发、需要用户先确认后再重新登录 # 典型场景:账号被禁用、密码已重置、登录安全策略触发、需要用户先确认后再重新登录
@@ -41,7 +41,7 @@ VITE_SERVICE_MODAL_LOGOUT_CODES=7777,7778
# token 过期状态码;当返回这些 code 时,前端会尝试刷新 token 并重发请求 # token 过期状态码;当返回这些 code 时,前端会尝试刷新 token 并重发请求
# 典型场景accessToken 过期但 refreshToken 仍有效、短期登录凭证失效但允许无感续期 # 典型场景accessToken 过期但 refreshToken 仍有效、短期登录凭证失效但允许无感续期
VITE_SERVICE_EXPIRED_TOKEN_CODES=1002023001 VITE_SERVICE_EXPIRED_TOKEN_CODES=1002023000
# 静态路由模式下定义的超级管理员角色 # 静态路由模式下定义的超级管理员角色
VITE_STATIC_SUPER_ROLE=R_SUPER VITE_STATIC_SUPER_ROLE=R_SUPER

View File

@@ -106,14 +106,25 @@ export async function fetchGetUserInfo(force = false): Promise<ServiceRequestRes
* *
* @param refreshToken 刷新 token * @param refreshToken 刷新 token
*/ */
export function fetchRefreshToken(refreshToken: string) { export async function fetchRefreshToken(refreshToken: string): Promise<ServiceRequestResult<Api.Auth.LoginToken>> {
return request<Api.Auth.LoginToken>({ // 后端要求 refreshToken 通过 query 参数传递,且 Content-Type 为 form-urlencoded
// skipAuth: 不注入过期 access 头,否则会被网关拦下死循环(网关一律校验 Authorization不看 PermitAll
const result = await request<BackendLoginToken>({
url: `${SYSTEM_SERVICE_PREFIX}/auth/refresh-token`, url: `${SYSTEM_SERVICE_PREFIX}/auth/refresh-token`,
method: 'post', method: 'post',
data: { params: { refreshToken },
refreshToken headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
} skipAuth: true
}); });
if (result.error || !result.data) {
return result as ServiceRequestResult<Api.Auth.LoginToken>;
}
return {
...result,
data: mapLoginToken(result.data)
};
} }
/** /**

View File

@@ -3,6 +3,13 @@ import type { InternalAxiosRequestConfig } from 'axios';
declare module 'axios' { declare module 'axios' {
interface AxiosRequestConfig { interface AxiosRequestConfig {
dedupe?: boolean; dedupe?: boolean;
/**
* 跳过 Authorization 注入。
*
* 用于公开接口refresh-token / login / register 等 PermitAll 路径),
* 避免给它们带上过期 access 头被网关拦截。
*/
skipAuth?: boolean;
} }
} }

View File

@@ -5,7 +5,7 @@ import { localStg } from '@/utils/storage';
import { getServiceBaseURL } from '@/utils/service'; import { getServiceBaseURL } from '@/utils/service';
import { $t } from '@/locales'; import { $t } from '@/locales';
import { applyApiEncrypt } from './api-encrypt'; import { applyApiEncrypt } from './api-encrypt';
import { getAuthorization, handleExpiredRequest, showErrorMsg } from './shared'; import { getAuthorization, handleExpiredRequest, notifySessionExpired, showErrorMsg } from './shared';
import { withDedupe } from './dedupe'; import { withDedupe } from './dedupe';
import type { RequestInstanceState } from './type'; import type { RequestInstanceState } from './type';
@@ -29,8 +29,12 @@ export const request = withDedupe(
return response.data.data; return response.data.data;
}, },
async onRequest(config) { async onRequest(config) {
const Authorization = getAuthorization(); // skipAuth 为 true 的请求不注入 Authorization——避免给公开接口如 refresh-token
Object.assign(config.headers, { Authorization }); // 带上过期 access 头被网关拦截(网关只看 Authorization不区分路由是否 PermitAll
if (!config.skipAuth) {
const Authorization = getAuthorization();
Object.assign(config.headers, { Authorization });
}
applyApiEncrypt(config); applyApiEncrypt(config);
return config; return config;
@@ -55,10 +59,11 @@ export const request = withDedupe(
request.state.errMsgStack = request.state.errMsgStack.filter(msg => msg !== response.data.msg); request.state.errMsgStack = request.state.errMsgStack.filter(msg => msg !== response.data.msg);
} }
// 当后端返回码命中 `logoutCodes` 时,表示用户需要退出登录并跳转到登录页 // 当后端返回码命中 `logoutCodes` 时,表示登录态已失效,需要提示后退出登录
// 走 notifySessionExpired 而不是裸 resetStore保证并发请求只弹一次 toast、只清一次状态
const logoutCodes = import.meta.env.VITE_SERVICE_LOGOUT_CODES?.split(',') || []; const logoutCodes = import.meta.env.VITE_SERVICE_LOGOUT_CODES?.split(',') || [];
if (logoutCodes.includes(responseCode)) { if (logoutCodes.includes(responseCode)) {
handleLogout(); notifySessionExpired();
return null; return null;
} }

View File

@@ -12,8 +12,6 @@ export function getAuthorization() {
/** 刷新 token */ /** 刷新 token */
async function handleRefreshToken() { async function handleRefreshToken() {
const { resetStore } = useAuthStore();
const rToken = localStg.get('refreshToken') || ''; const rToken = localStg.get('refreshToken') || '';
const { error, data } = await fetchRefreshToken(rToken); const { error, data } = await fetchRefreshToken(rToken);
if (!error) { if (!error) {
@@ -22,25 +20,48 @@ async function handleRefreshToken() {
return true; return true;
} }
resetStore(); notifySessionExpired();
return false; return false;
} }
export async function handleExpiredRequest(state: RequestInstanceState) { export async function handleExpiredRequest(state: RequestInstanceState) {
if (!state.refreshTokenFn) { if (!state.refreshTokenPromise) {
state.refreshTokenFn = handleRefreshToken(); state.refreshTokenPromise = handleRefreshToken();
} }
const success = await state.refreshTokenFn; const success = await state.refreshTokenPromise;
setTimeout(() => { setTimeout(() => {
state.refreshTokenFn = null; state.refreshTokenPromise = null;
}, 1000); }, 1000);
return success; return success;
} }
// 会话失效一次性锁:保证 N 个并发请求只弹一次 toast、只 resetStore 一次
let sessionExpiredNotified = false;
/**
* 通知用户会话已失效,弹一次 toast 后清状态、跳登录。
*
* 多个并发请求触发时只会真正执行一次;登录成功后由 resetSessionExpiredFlag() 复位。
*/
export function notifySessionExpired() {
if (sessionExpiredNotified) return;
sessionExpiredNotified = true;
window.$message?.error('登录已过期,请重新登录');
const { resetStore } = useAuthStore();
resetStore();
}
/** 登录成功后复位一次性锁,让下一次会话失效仍能正常提示 */
export function resetSessionExpiredFlag() {
sessionExpiredNotified = false;
}
export function showErrorMsg(state: RequestInstanceState, message: string) { export function showErrorMsg(state: RequestInstanceState, message: string) {
if (!state.errMsgStack?.length) { if (!state.errMsgStack?.length) {
state.errMsgStack = []; state.errMsgStack = [];

View File

@@ -3,5 +3,7 @@ export interface RequestInstanceState {
refreshTokenPromise: Promise<boolean> | null; refreshTokenPromise: Promise<boolean> | null;
/** 请求错误信息栈 */ /** 请求错误信息栈 */
errMsgStack: string[]; errMsgStack: string[];
// 索引签名是 @sa/axios 的 defaultState 类型约束(要求 Record<string, unknown>)的硬要求,不能删
// 字段名对齐已通过把 shared.ts 里的 refreshTokenFn 全部改成 refreshTokenPromise 来消除隐患
[key: string]: unknown; [key: string]: unknown;
} }

View File

@@ -3,6 +3,7 @@ import { useRoute } from 'vue-router';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { useLoading } from '@sa/hooks'; import { useLoading } from '@sa/hooks';
import { clearAuthApiCache, fetchGetUserInfo, fetchLogin } from '@/service/api'; import { clearAuthApiCache, fetchGetUserInfo, fetchLogin } from '@/service/api';
import { resetSessionExpiredFlag } from '@/service/request/shared';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import { localStg } from '@/utils/storage'; import { localStg } from '@/utils/storage';
import { SetupStoreId } from '@/enum'; import { SetupStoreId } from '@/enum';
@@ -149,6 +150,9 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
token.value = loginToken.token; token.value = loginToken.token;
// 复位会话失效一次性锁,让下一次会话失效仍能正常提示
resetSessionExpiredFlag();
return true; return true;
} }