优化
This commit is contained in:
+57
-7
@@ -10,10 +10,12 @@ import {
|
||||
import type { PlatformPermissionResource } from '@/shared/types/platformPermission'
|
||||
import { decodeJwtClaims } from './jwt'
|
||||
import { listCurrentPlatformPermissionCodes } from './platformAuthorization'
|
||||
import { coordinateCrossTabRefresh } from './crossTabRefresh'
|
||||
|
||||
const TOKEN_KEY = 'cc-admin-token'
|
||||
const REFRESH_TOKEN_KEY = 'cc-admin-refresh-token'
|
||||
const PERMISSION_CODES_KEY = 'cc-admin-permission-codes'
|
||||
const REFRESH_LOCK_NAME = 'cc-admin-auth-refresh'
|
||||
|
||||
interface LoginResponse {
|
||||
tokenType: string
|
||||
@@ -117,6 +119,33 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
localStorage.removeItem(PERMISSION_CODES_KEY)
|
||||
}
|
||||
|
||||
function applyStoredSession() {
|
||||
const storedToken = localStorage.getItem(TOKEN_KEY) || ''
|
||||
const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || ''
|
||||
if (!storedToken) {
|
||||
sessionGeneration += 1
|
||||
token.value = ''
|
||||
refreshToken.value = ''
|
||||
user.value = null
|
||||
permissions.value = []
|
||||
permissionResources.value = []
|
||||
isServerSession.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const session = sessionFromToken(storedToken)
|
||||
const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown
|
||||
token.value = storedToken
|
||||
refreshToken.value = storedRefreshToken
|
||||
user.value = session.user
|
||||
permissions.value = session.permissions.filter((code) => Array.isArray(cachedPermissions) && cachedPermissions.includes(code))
|
||||
permissionResources.value = []
|
||||
isServerSession.value = true
|
||||
} catch {
|
||||
clearSession()
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeTokenPair(accessToken: string, refreshTokenToRevoke: string) {
|
||||
await http.post('/auth/logout', { refreshToken: refreshTokenToRevoke }, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
@@ -146,15 +175,30 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
}
|
||||
|
||||
async function refreshSession() {
|
||||
if (!refreshToken.value) throw new Error('刷新令牌不存在,请重新登录')
|
||||
const observedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || refreshToken.value
|
||||
if (!observedRefreshToken) throw new Error('刷新令牌不存在,请重新登录')
|
||||
const expectedGeneration = sessionGeneration
|
||||
try {
|
||||
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
||||
'/auth/refresh',
|
||||
{ refreshToken: refreshToken.value },
|
||||
)
|
||||
const result = unwrapApiResponse<LoginResponse>(response)
|
||||
await applySession(result, expectedGeneration)
|
||||
await coordinateCrossTabRefresh({
|
||||
lockName: REFRESH_LOCK_NAME,
|
||||
observedRefreshToken,
|
||||
currentRefreshToken: () => localStorage.getItem(REFRESH_TOKEN_KEY) || '',
|
||||
reuseCurrentSession: async () => {
|
||||
const storedAccessToken = localStorage.getItem(TOKEN_KEY) || ''
|
||||
const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || ''
|
||||
if (!storedAccessToken || !storedRefreshToken) throw new Error('登录状态已过期')
|
||||
await applySession({ tokenType: 'Bearer', accessToken: storedAccessToken, expiresIn: 0, refreshToken: storedRefreshToken, refreshExpiresIn: 0 }, expectedGeneration)
|
||||
},
|
||||
refresh: async (currentRefreshToken) => {
|
||||
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
||||
'/auth/refresh',
|
||||
{ refreshToken: currentRefreshToken },
|
||||
)
|
||||
ensureCurrentGeneration(expectedGeneration)
|
||||
if (!localStorage.getItem(REFRESH_TOKEN_KEY)) throw new SupersededSessionError('登录会话已在其他标签页退出')
|
||||
await applySession(unwrapApiResponse<LoginResponse>(response), expectedGeneration)
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (expectedGeneration === sessionGeneration) clearSession()
|
||||
throw error
|
||||
@@ -186,6 +230,12 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
return token.value
|
||||
})
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', (event) => {
|
||||
if ([TOKEN_KEY, REFRESH_TOKEN_KEY, PERMISSION_CODES_KEY].includes(event.key || '')) applyStoredSession()
|
||||
})
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
const accessToken = token.value
|
||||
const tokenToRevoke = refreshToken.value
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { coordinateCrossTabRefresh } from './crossTabRefresh'
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('cross-tab refresh coordination', () => {
|
||||
it('reuses the rotated session after waiting for another tab', async () => {
|
||||
let currentRefreshToken = 'refresh-old'
|
||||
const refresh = vi.fn()
|
||||
const reuseCurrentSession = vi.fn(async () => 'access-new')
|
||||
vi.stubGlobal('navigator', {
|
||||
locks: {
|
||||
request: vi.fn(async (_name: string, callback: () => Promise<string>) => {
|
||||
currentRefreshToken = 'refresh-new'
|
||||
return callback()
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
await expect(coordinateCrossTabRefresh({
|
||||
lockName: 'admin-refresh',
|
||||
observedRefreshToken: 'refresh-old',
|
||||
currentRefreshToken: () => currentRefreshToken,
|
||||
reuseCurrentSession,
|
||||
refresh,
|
||||
})).resolves.toBe('access-new')
|
||||
|
||||
expect(reuseCurrentSession).toHaveBeenCalledOnce()
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('performs the refresh when the token has not changed', async () => {
|
||||
const refresh = vi.fn(async () => 'access-new')
|
||||
vi.stubGlobal('navigator', {
|
||||
locks: { request: vi.fn(async (_name: string, callback: () => Promise<string>) => callback()) },
|
||||
})
|
||||
|
||||
await expect(coordinateCrossTabRefresh({
|
||||
lockName: 'admin-refresh',
|
||||
observedRefreshToken: 'refresh-old',
|
||||
currentRefreshToken: () => 'refresh-old',
|
||||
reuseCurrentSession: vi.fn(),
|
||||
refresh,
|
||||
})).resolves.toBe('access-new')
|
||||
|
||||
expect(refresh).toHaveBeenCalledWith('refresh-old')
|
||||
})
|
||||
|
||||
it('does not wait for rotation after the shared session was removed', async () => {
|
||||
const refreshError = new Error('session removed')
|
||||
const refresh = vi.fn(async () => { throw refreshError })
|
||||
vi.stubGlobal('navigator', {})
|
||||
|
||||
await expect(coordinateCrossTabRefresh({
|
||||
lockName: 'admin-refresh',
|
||||
observedRefreshToken: 'refresh-old',
|
||||
currentRefreshToken: () => '',
|
||||
reuseCurrentSession: vi.fn(),
|
||||
refresh,
|
||||
})).rejects.toBe(refreshError)
|
||||
|
||||
expect(refresh).toHaveBeenCalledWith('refresh-old')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface CrossTabRefreshOptions<T> {
|
||||
lockName: string
|
||||
observedRefreshToken: string
|
||||
currentRefreshToken: () => string
|
||||
reuseCurrentSession: () => Promise<T>
|
||||
refresh: (refreshToken: string) => Promise<T>
|
||||
}
|
||||
|
||||
export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptions<T>): Promise<T> {
|
||||
const run = async () => {
|
||||
const currentRefreshToken = options.currentRefreshToken()
|
||||
if (currentRefreshToken && currentRefreshToken !== options.observedRefreshToken) {
|
||||
return options.reuseCurrentSession()
|
||||
}
|
||||
return options.refresh(currentRefreshToken || options.observedRefreshToken)
|
||||
}
|
||||
|
||||
if (typeof navigator === 'undefined' || !navigator.locks) {
|
||||
try {
|
||||
return await run()
|
||||
} catch (error) {
|
||||
if (!options.currentRefreshToken()) throw error
|
||||
// Web Locks requires a secure context. On plain HTTP, briefly allow another tab's
|
||||
// successful rotation to reach shared storage before treating this session as expired.
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
const currentRefreshToken = options.currentRefreshToken()
|
||||
if (!currentRefreshToken) throw error
|
||||
if (currentRefreshToken && currentRefreshToken !== options.observedRefreshToken) {
|
||||
return options.reuseCurrentSession()
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return navigator.locks.request(options.lockName, run)
|
||||
}
|
||||
Reference in New Issue
Block a user