From d3a2671fd79dce17f4c3eeb41eb61ef7fe95dcd0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 19:41:45 +0800 Subject: [PATCH] fix(auth): revoke expired admin sessions without restoring login --- src/core/auth/auth.test.ts | 97 +++++++++++++++++++++++++++++++++++++ src/core/auth/auth.ts | 29 +++++++++-- src/shared/api/http.test.ts | 17 +++++++ src/shared/api/http.ts | 3 +- 4 files changed, 141 insertions(+), 5 deletions(-) diff --git a/src/core/auth/auth.test.ts b/src/core/auth/auth.test.ts index 95e75cc5..e8898056 100644 --- a/src/core/auth/auth.test.ts +++ b/src/core/auth/auth.test.ts @@ -241,4 +241,101 @@ describe('admin auth session', () => { expect(auth.refreshToken).toBe('') expect(localStorage.getItem('cc-admin-token')).toBeNull() }) + + it('refreshes a rejected logout once and revokes the rotated pair without restoring local state', async () => { + const previous = token() + const rotated = token({ token_version: 2 }) + localStorage.setItem('cc-admin-token', previous) + localStorage.setItem('cc-admin-refresh-token', 'refresh-old') + mocks.post.mockRejectedValueOnce({ isAxiosError: true, response: { status: 401 } }) + .mockResolvedValueOnce(loginResponse(rotated, 'refresh-rotated')) + .mockResolvedValueOnce({ code: '200', data: null }) + const auth = useAuthStore() + + await expect(auth.logout()).resolves.toBeUndefined() + + expect(mocks.post).toHaveBeenCalledTimes(3) + expect(mocks.post).toHaveBeenNthCalledWith(2, '/auth/refresh', { refreshToken: 'refresh-old' }) + expect(mocks.post).toHaveBeenNthCalledWith(3, '/auth/logout', { refreshToken: 'refresh-rotated' }, { + headers: { Authorization: `Bearer ${rotated}` }, + }) + expect(auth.isAuthenticated).toBe(false) + expect(localStorage.getItem('cc-admin-token')).toBeNull() + expect(localStorage.getItem('cc-admin-refresh-token')).toBeNull() + expect(mocks.listCurrentPlatformPermissionCodes).not.toHaveBeenCalled() + }) + + it('revokes a persisted refresh session when startup already removed its expired access token', async () => { + localStorage.setItem('cc-admin-token', token({ exp: 1 })) + localStorage.setItem('cc-admin-refresh-token', 'refresh-old') + const rotated = token() + mocks.post.mockResolvedValueOnce(loginResponse(rotated, 'refresh-rotated')) + .mockResolvedValueOnce({ code: '200', data: null }) + const auth = useAuthStore() + expect(auth.token).toBe('') + + await auth.logout() + + expect(mocks.post).toHaveBeenCalledTimes(2) + expect(mocks.post).toHaveBeenNthCalledWith(1, '/auth/refresh', { refreshToken: 'refresh-old' }) + expect(mocks.post).toHaveBeenNthCalledWith(2, '/auth/logout', { refreshToken: 'refresh-rotated' }, { + headers: { Authorization: `Bearer ${rotated}` }, + }) + expect(auth.isAuthenticated).toBe(false) + }) + + it('leaves a subsequent login intact while finishing revocation of the previous session', async () => { + localStorage.setItem('cc-admin-token', token()) + localStorage.setItem('cc-admin-refresh-token', 'refresh-old') + let finishRefresh: ((value: ReturnType) => void) | undefined + const started = new Promise(resolve => { + mocks.post.mockRejectedValueOnce({ isAxiosError: true, response: { status: 401 } }) + .mockImplementationOnce(() => { resolve(); return new Promise(done => { finishRefresh = done }) }) + }) + const auth = useAuthStore() + const loggingOut = auth.logout() + // Attach immediately so a regression rejecting early does not become an unhandled rejection. + const result = loggingOut.then(() => null, error => error) + await Promise.race([started, loggingOut.catch(() => undefined)]) + expect(finishRefresh).toBeTypeOf('function') + const newAccess = token({ user_id: '1000002', username: 'second' }) + mocks.post.mockResolvedValueOnce(loginResponse(newAccess, 'refresh-new-login')) + .mockResolvedValueOnce({ code: '200', data: null }) + mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list']) + await auth.login('second', 'password') + const revocationAccess = token({ token_version: 3 }) + finishRefresh?.(loginResponse(revocationAccess, 'refresh-revocation-only')) + + expect(await result).toBeNull() + expect(auth.token).toBe(newAccess) + expect(localStorage.getItem('cc-admin-refresh-token')).toBe('refresh-new-login') + expect(mocks.post).toHaveBeenLastCalledWith('/auth/logout', { refreshToken: 'refresh-revocation-only' }, { + headers: { Authorization: `Bearer ${revocationAccess}` }, + }) + }) + + it('does not refresh or retry an ambiguous logout transport failure', async () => { + localStorage.setItem('cc-admin-token', token()) + localStorage.setItem('cc-admin-refresh-token', 'refresh-old') + const failure = { isAxiosError: true, response: { status: 503 } } + mocks.post.mockRejectedValue(failure) + const auth = useAuthStore() + await expect(auth.logout()).rejects.toBe(failure) + expect(mocks.post).toHaveBeenCalledTimes(1) + expect(auth.isAuthenticated).toBe(false) + }) + + it.each(['refresh', 'second-logout'])('stops after a %s rejection and never restores the logged-out session', async stage => { + localStorage.setItem('cc-admin-token', token()) + localStorage.setItem('cc-admin-refresh-token', 'refresh-old') + const unauthorized = { isAxiosError: true, response: { status: 401 } } + mocks.post.mockRejectedValueOnce(unauthorized) + if (stage === 'second-logout') mocks.post.mockResolvedValueOnce(loginResponse()) + mocks.post.mockRejectedValueOnce(unauthorized) + const auth = useAuthStore() + await expect(auth.logout()).rejects.toBe(unauthorized) + expect(mocks.post).toHaveBeenCalledTimes(stage === 'refresh' ? 2 : 3) + expect(localStorage.getItem('cc-admin-token')).toBeNull() + expect(localStorage.getItem('cc-admin-refresh-token')).toBeNull() + }) }) diff --git a/src/core/auth/auth.ts b/src/core/auth/auth.ts index 24099027..2c206da9 100644 --- a/src/core/auth/auth.ts +++ b/src/core/auth/auth.ts @@ -1,5 +1,6 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' +import { isAxiosError } from 'axios' import type { CurrentUser } from '@chongchong/shared' import { http, @@ -157,9 +158,29 @@ export const useAuthStore = defineStore('admin-auth', () => { } async function revokeTokenPair(accessToken: string, refreshTokenToRevoke: string) { - await http.post('/auth/logout', { refreshToken: refreshTokenToRevoke }, { - headers: { Authorization: `Bearer ${accessToken}` }, - }) + async function revoke(access: string, refresh: string) { + const response = await http.post, ApiEnvelope>( + '/auth/logout', { refreshToken: refresh }, { headers: { Authorization: `Bearer ${access}` } }, + ) + unwrapApiResponse(response) + } + if (accessToken) { + try { + await revoke(accessToken, refreshTokenToRevoke) + return + } catch (error) { + if (!isAxiosError(error) || error.response?.status !== 401) throw error + } + } + // Logout has already cleared local state. Refresh only this captured pair + // for revocation; never apply it to this tab or overwrite a subsequent login. + const response = await http.post, ApiEnvelope>( + '/auth/refresh', { refreshToken: refreshTokenToRevoke }, + ) + const rotated = unwrapApiResponse(response) + sessionFromToken(rotated.accessToken) + if (!rotated.refreshToken) throw new Error('退出时刷新响应缺少会话凭据') + await revoke(rotated.accessToken, rotated.refreshToken) } async function login(account: string, password: string) { @@ -245,7 +266,7 @@ export const useAuthStore = defineStore('admin-auth', () => { const accessToken = token.value const tokenToRevoke = refreshToken.value clearSession() - if (accessToken && tokenToRevoke) { + if (tokenToRevoke) { await revokeTokenPair(accessToken, tokenToRevoke) } } diff --git a/src/shared/api/http.test.ts b/src/shared/api/http.test.ts index 15b361fb..0276365a 100644 --- a/src/shared/api/http.test.ts +++ b/src/shared/api/http.test.ts @@ -18,6 +18,23 @@ afterEach(() => { }) describe('API error diagnostics', () => { + it('keeps a logout bound to its explicit token after a different session has logged in', async () => { + localStorage.setItem('cc-admin-token', 'new-login-access') + let authorization: unknown + http.defaults.adapter = async config => { + authorization = config.headers.Authorization + return { status: 200, statusText: 'OK', headers: {}, config, data: { code: '200', data: null } } + } + try { + await http.post('/auth/logout', { refreshToken: 'previous-refresh' }, { + headers: { Authorization: 'Bearer previous-access' }, + }) + expect(authorization).toBe('Bearer previous-access') + } finally { + localStorage.removeItem('cc-admin-token') + } + }) + it('does not attach a stale access token to anonymous authentication requests', async () => { localStorage.setItem('cc-admin-token', 'stale-large-token') let authorization: unknown diff --git a/src/shared/api/http.ts b/src/shared/api/http.ts index 15e9d239..e1bdbd41 100644 --- a/src/shared/api/http.ts +++ b/src/shared/api/http.ts @@ -91,7 +91,8 @@ http.interceptors.request.use((config) => { const token = localStorage.getItem('cc-admin-token') const isAnonymousAuthEndpoint = typeof config.url === 'string' && ['/auth/login', '/auth/refresh', '/auth/register'].includes(config.url) - if (token && !isAnonymousAuthEndpoint) { + const hasExplicitLogoutToken = config.url === '/auth/logout' && Boolean(config.headers.Authorization) + if (token && !isAnonymousAuthEndpoint && !hasExplicitLogoutToken) { config.headers.Authorization = `Bearer ${token}` } return config