import { computed, ref } from 'vue' import { defineStore } from 'pinia' import { isAxiosError } from 'axios' import type { CurrentUser } from '@chongchong/shared' import { http, registerAuthRefreshHandler, unwrapApiResponse, type ApiEnvelope, } from '@/shared/api/http' 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 accessToken: string expiresIn: number | string refreshToken: string refreshExpiresIn: number | string } class ExpiredAccessTokenError extends Error {} class SupersededSessionError extends Error {} function parseLifetimeSeconds(value: number | string, field: string) { const normalized = typeof value === 'number' ? value : /^\d+$/.test(value.trim()) ? Number(value) : Number.NaN if (!Number.isSafeInteger(normalized) || normalized < 0) { throw new Error(`登录响应中的 ${field} 格式无效`) } return normalized } function sessionFromToken(token: string) { const claims = decodeJwtClaims(token) if (claims.app_code !== 'PLATFORM') throw new Error('账号不属于当前应用端') if (claims.exp && claims.exp * 1000 <= Date.now()) throw new ExpiredAccessTokenError('登录状态已过期') return { user: { id: String(claims.user_id ?? ''), name: claims.username || '平台用户', account: claims.username || '', roles: claims.roles ?? [], } satisfies CurrentUser, } } export const useAuthStore = defineStore('admin-auth', () => { const token = ref(localStorage.getItem(TOKEN_KEY) || '') const refreshToken = ref(localStorage.getItem(REFRESH_TOKEN_KEY) || '') const user = ref(null) const permissions = ref([]) const permissionResources = ref([]) const isServerSession = ref(false) let sessionGeneration = 0 if (token.value) { try { const session = sessionFromToken(token.value) const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown if (!Array.isArray(cachedPermissions)) throw new Error('权限缓存格式无效') user.value = session.user permissions.value = cachedPermissions.filter((code): code is string => typeof code === 'string') isServerSession.value = true } catch (error) { token.value = '' localStorage.removeItem(TOKEN_KEY) localStorage.removeItem(PERMISSION_CODES_KEY) if (!(error instanceof ExpiredAccessTokenError) || !refreshToken.value) { refreshToken.value = '' localStorage.removeItem(REFRESH_TOKEN_KEY) } } } const isAuthenticated = computed(() => Boolean(token.value)) const permissionCodes = computed>(() => new Set(permissions.value)) function ensureCurrentGeneration(expectedGeneration: number) { if (expectedGeneration !== sessionGeneration) { throw new SupersededSessionError('登录会话已被后续操作替代') } } function applyPermissionCodes(codes: string[]) { permissions.value = codes localStorage.setItem(PERMISSION_CODES_KEY, JSON.stringify(codes)) } async function applySession(result: LoginResponse, expectedGeneration: number) { const session = sessionFromToken(result.accessToken) ensureCurrentGeneration(expectedGeneration) token.value = result.accessToken refreshToken.value = result.refreshToken user.value = session.user isServerSession.value = true localStorage.setItem(TOKEN_KEY, token.value) localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken.value) try { const activePermissionCodes = await listCurrentPlatformPermissionCodes() ensureCurrentGeneration(expectedGeneration) permissionResources.value = [] applyPermissionCodes(activePermissionCodes) } catch (error) { ensureCurrentGeneration(expectedGeneration) throw error } } function clearSession() { sessionGeneration += 1 token.value = '' refreshToken.value = '' user.value = null permissions.value = [] permissionResources.value = [] isServerSession.value = false localStorage.removeItem(TOKEN_KEY) localStorage.removeItem(REFRESH_TOKEN_KEY) 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 // A different account in another tab supersedes this tab's pending login/refresh responses. if (user.value?.id !== session.user.id || user.value?.account !== session.user.account) { sessionGeneration += 1 } token.value = storedToken refreshToken.value = storedRefreshToken user.value = session.user permissions.value = Array.isArray(cachedPermissions) ? cachedPermissions.filter((code): code is string => typeof code === 'string') : [] permissionResources.value = [] isServerSession.value = true } catch { clearSession() } } async function revokeTokenPair(accessToken: string, refreshTokenToRevoke: string) { 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) { const expectedGeneration = ++sessionGeneration try { const response = await http.post, ApiEnvelope>( '/auth/login', { loginId: account, password }, ) const result = unwrapApiResponse(response) await applySession(result, expectedGeneration) } catch (error) { if (expectedGeneration === sessionGeneration) { const issuedAccessToken = token.value const issuedRefreshToken = refreshToken.value clearSession() if (issuedAccessToken && issuedRefreshToken) { void revokeTokenPair(issuedAccessToken, issuedRefreshToken).catch(() => undefined) } } throw error } } async function refreshSession() { const observedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || refreshToken.value if (!observedRefreshToken) throw new Error('刷新令牌不存在,请重新登录') const expectedGeneration = sessionGeneration try { await coordinateCrossTabRefresh({ lockName: REFRESH_LOCK_NAME, observedRefreshToken, currentRefreshToken: () => localStorage.getItem(REFRESH_TOKEN_KEY) || '', reuseCurrentSession: async () => { applyStoredSession() ensureCurrentGeneration(expectedGeneration) if (!token.value || !refreshToken.value) throw new Error('登录状态已过期') }, refresh: async (currentRefreshToken) => { const response = await http.post, ApiEnvelope>( '/auth/refresh', { refreshToken: currentRefreshToken }, ) ensureCurrentGeneration(expectedGeneration) if (!localStorage.getItem(REFRESH_TOKEN_KEY)) throw new SupersededSessionError('登录会话已在其他标签页退出') await applySession(unwrapApiResponse(response), expectedGeneration) }, }) } catch (error) { if (expectedGeneration === sessionGeneration) clearSession() throw error } } async function restoreSession() { // The constructor has already validated a persisted access token. Reusing // a still-valid token lets the app mount immediately; refresh only remains // necessary after the access token has expired and the constructor has // retained the persisted refresh token for recovery. if (isAuthenticated.value) return true if (!refreshToken.value) return false try { await refreshSession() return true } catch { return false } } registerAuthRefreshHandler(async () => { await refreshSession() if (!token.value) throw new Error('登录状态刷新失败') 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 clearSession() if (tokenToRevoke) { await revokeTokenPair(accessToken, tokenToRevoke) } } return { token, refreshToken, user, permissions, permissionResources, isServerSession, permissionCodes, isAuthenticated, login, refreshSession, restoreSession, logout, } })