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:
4
.env
4
.env
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user