fix(auth): revoke expired admin sessions without restoring login
This commit is contained in:
@@ -241,4 +241,101 @@ describe('admin auth session', () => {
|
|||||||
expect(auth.refreshToken).toBe('')
|
expect(auth.refreshToken).toBe('')
|
||||||
expect(localStorage.getItem('cc-admin-token')).toBeNull()
|
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<typeof loginResponse>) => void) | undefined
|
||||||
|
const started = new Promise<void>(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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+25
-4
@@ -1,5 +1,6 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
import type { CurrentUser } from '@chongchong/shared'
|
import type { CurrentUser } from '@chongchong/shared'
|
||||||
import {
|
import {
|
||||||
http,
|
http,
|
||||||
@@ -157,9 +158,29 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function revokeTokenPair(accessToken: string, refreshTokenToRevoke: string) {
|
async function revokeTokenPair(accessToken: string, refreshTokenToRevoke: string) {
|
||||||
await http.post('/auth/logout', { refreshToken: refreshTokenToRevoke }, {
|
async function revoke(access: string, refresh: string) {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
const response = await http.post<ApiEnvelope<null>, ApiEnvelope<null>>(
|
||||||
})
|
'/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<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
||||||
|
'/auth/refresh', { refreshToken: refreshTokenToRevoke },
|
||||||
|
)
|
||||||
|
const rotated = unwrapApiResponse<LoginResponse>(response)
|
||||||
|
sessionFromToken(rotated.accessToken)
|
||||||
|
if (!rotated.refreshToken) throw new Error('退出时刷新响应缺少会话凭据')
|
||||||
|
await revoke(rotated.accessToken, rotated.refreshToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(account: string, password: string) {
|
async function login(account: string, password: string) {
|
||||||
@@ -245,7 +266,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
|||||||
const accessToken = token.value
|
const accessToken = token.value
|
||||||
const tokenToRevoke = refreshToken.value
|
const tokenToRevoke = refreshToken.value
|
||||||
clearSession()
|
clearSession()
|
||||||
if (accessToken && tokenToRevoke) {
|
if (tokenToRevoke) {
|
||||||
await revokeTokenPair(accessToken, tokenToRevoke)
|
await revokeTokenPair(accessToken, tokenToRevoke)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,23 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('API error diagnostics', () => {
|
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 () => {
|
it('does not attach a stale access token to anonymous authentication requests', async () => {
|
||||||
localStorage.setItem('cc-admin-token', 'stale-large-token')
|
localStorage.setItem('cc-admin-token', 'stale-large-token')
|
||||||
let authorization: unknown
|
let authorization: unknown
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ http.interceptors.request.use((config) => {
|
|||||||
const token = localStorage.getItem('cc-admin-token')
|
const token = localStorage.getItem('cc-admin-token')
|
||||||
const isAnonymousAuthEndpoint = typeof config.url === 'string'
|
const isAnonymousAuthEndpoint = typeof config.url === 'string'
|
||||||
&& ['/auth/login', '/auth/refresh', '/auth/register'].includes(config.url)
|
&& ['/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}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
|
|||||||
Reference in New Issue
Block a user