fix: prevent expired-token login refresh loop

This commit is contained in:
whyour
2026-09-19 13:23:28 +08:00
parent d9833c17d5
commit 6acb665369
3 changed files with 158 additions and 9 deletions
+2 -6
View File
@@ -114,13 +114,9 @@ export default function () {
history.push('/error');
}
})
.catch((error) => {
const responseStatus = error.response.status;
if (responseStatus !== 401) {
.catch(() => {
// Health is anonymous; a failure must not trigger a page reload loop.
history.push('/error');
} else {
window.location.reload();
}
})
.finally(() => setInitLoading(false));
};
+3 -2
View File
@@ -49,9 +49,9 @@ const errorHandler = function (
if ([502, 504].includes(responseStatus)) {
history.push('/error');
} else if (responseStatus === 401) {
localStorage.removeItem(config.authKey);
if (history.location.pathname !== '/login') {
message.error(intl.get('登录已过期,请重新登录'));
localStorage.removeItem(config.authKey);
history.push('/login');
}
} else {
@@ -84,6 +84,7 @@ let _request = axios.create({
});
const apiWhiteList = [
`${config.baseUrl}api/health`,
`${config.baseUrl}api/user/login`,
`${config.baseUrl}open/auth/token`,
`${config.baseUrl}api/user/two-factor/login`,
@@ -106,8 +107,8 @@ _request.interceptors.response.use(async (response) => {
if ([502, 504].includes(responseStatus)) {
history.push('/error');
} else if (responseStatus === 401) {
if (history.location.pathname !== '/login') {
localStorage.removeItem(config.authKey);
if (history.location.pathname !== '/login') {
history.push('/login');
}
} else {
+152
View File
@@ -0,0 +1,152 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const fs = require('node:fs');
const path = require('node:path');
const ts = require('typescript');
const axios = require('axios');
function fixture(pathname = '/login', baseUrl = '/') {
const storage = new Map([['token', 'expired-token']]);
const pushes = [];
const history = { location: { pathname }, push: (p) => pushes.push(p) };
const config = { authKey: 'token', baseUrl };
const mocks = {
axios,
'react-intl-universal': { get: (s) => s },
antd: {
message: { config() {}, error() {} },
notification: { error() {} },
},
'./config': config,
'@umijs/max': { history },
'./httpError': { getErrorDetails: () => [] },
};
const source = fs.readFileSync(
path.join(__dirname, '../../src/utils/http.tsx'),
'utf8',
);
const { outputText } = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
jsx: ts.JsxEmit.React,
esModuleInterop: true,
},
});
const module = { exports: {} };
new Function('require', 'module', 'exports', 'localStorage', outputText)(
(name) => {
assert.ok(Object.hasOwn(mocks, name), name);
return mocks[name];
},
module,
module.exports,
{
getItem: (k) => storage.get(k) ?? null,
removeItem: (k) => storage.delete(k),
},
);
return { request: module.exports.request, storage, pushes };
}
for (const pathname of ['/login', '/dashboard']) {
test(`401 clears expired credentials on ${pathname}`, async () => {
const { request, storage, pushes } = fixture(pathname);
await assert.rejects(
request.get('/api/user', {
adapter: async (config) => {
throw new axios.AxiosError(
'Unauthorized',
'ERR_BAD_REQUEST',
config,
null,
{ status: 401, data: { message: 'expired' }, config },
);
},
}),
);
assert.equal(storage.has('token'), false);
assert.deepEqual(pushes, pathname === '/login' ? [] : ['/login']);
});
}
for (const baseUrl of ['/', '/panel/']) {
test(`health is anonymous while protected requests keep credentials (${baseUrl})`, async () => {
const { request } = fixture('/login', baseUrl);
const headers = [];
const adapter = async (config) => {
headers.push(config.headers.get('Authorization'));
return { status: 200, data: { code: 200 }, config };
};
await request.get(`${baseUrl}api/health`, { adapter });
await request.get(`${baseUrl}api/user`, { adapter });
assert.deepEqual(headers, [undefined, 'Bearer expired-token']);
});
}
function healthFixture(get) {
const source = fs.readFileSync(
path.join(__dirname, '../../src/layouts/index.tsx'),
'utf8',
);
const ast = ts.createSourceFile(
'index.tsx',
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
let initializer;
function visit(node) {
if (
ts.isVariableDeclaration(node) &&
node.name.getText(ast) === 'getHealthStatus'
)
initializer = node.initializer;
ts.forEachChild(node, visit);
}
visit(ast);
assert.ok(initializer, 'layout health bootstrap exists');
const { outputText } = ts.transpileModule(
`const bootstrap = ${initializer.getText(ast)};`,
{
compilerOptions: { target: ts.ScriptTarget.ES2020 },
},
);
const events = [];
const bootstrap = new Function(
'request',
'config',
'history',
'window',
'getSystemInfo',
'setInitLoading',
`${outputText}; return bootstrap;`,
)(
{ get },
{ apiPrefix: '/api/' },
{ push: (p) => events.push(p) },
{ location: { reload: () => events.push('reload') } },
() => events.push('system'),
(value) => events.push(['loading', value]),
);
return { bootstrap, events };
}
for (const status of [401, 503, undefined]) {
test(`health failure ${
status ?? 'network'
} settles without reloading`, async () => {
const { bootstrap, events } = healthFixture(async () => {
throw status ? { response: { status } } : new Error('Network Error');
});
bootstrap();
await new Promise(setImmediate);
assert.deepEqual(events, ['/error', ['loading', false]]);
});
}
test('healthy bootstrap continues loading system information', async () => {
const { bootstrap, events } = healthFixture(async () => ({
data: { status: 'ok' },
}));
bootstrap();
await new Promise(setImmediate);
assert.deepEqual(events, ['system', ['loading', false]]);
});