This commit is contained in:
2026-08-07 14:47:11 +08:00
parent e7cc0cf18b
commit 7ac173fac4
21 changed files with 1714 additions and 791 deletions
+57 -7
View File
@@ -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
+64
View File
@@ -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')
})
})
+37
View File
@@ -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)
}