From 6acb6653697bee18939e12d992839efb1bcd0a0d Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 19 Sep 2026 13:23:28 +0800 Subject: [PATCH] fix: prevent expired-token login refresh loop --- src/layouts/index.tsx | 10 +-- src/utils/http.tsx | 5 +- test/front/http-auth.test.cjs | 152 ++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 9 deletions(-) create mode 100644 test/front/http-auth.test.cjs diff --git a/src/layouts/index.tsx b/src/layouts/index.tsx index f8e5c1ec..7c94f91a 100644 --- a/src/layouts/index.tsx +++ b/src/layouts/index.tsx @@ -114,13 +114,9 @@ export default function () { history.push('/error'); } }) - .catch((error) => { - const responseStatus = error.response.status; - if (responseStatus !== 401) { - history.push('/error'); - } else { - window.location.reload(); - } + .catch(() => { + // Health is anonymous; a failure must not trigger a page reload loop. + history.push('/error'); }) .finally(() => setInitLoading(false)); }; diff --git a/src/utils/http.tsx b/src/utils/http.tsx index 904fd076..466dc085 100644 --- a/src/utils/http.tsx +++ b/src/utils/http.tsx @@ -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) { + localStorage.removeItem(config.authKey); if (history.location.pathname !== '/login') { - localStorage.removeItem(config.authKey); history.push('/login'); } } else { diff --git a/test/front/http-auth.test.cjs b/test/front/http-auth.test.cjs new file mode 100644 index 00000000..caaaf6c5 --- /dev/null +++ b/test/front/http-auth.test.cjs @@ -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]]); +});