281 lines
9.4 KiB
TypeScript
281 lines
9.4 KiB
TypeScript
import { computed, ref } from 'vue'
|
|
import { defineStore } from 'pinia'
|
|
import type { CurrentUser } from '@chongchong/shared'
|
|
import {
|
|
http,
|
|
registerAuthRefreshHandler,
|
|
unwrapApiResponse,
|
|
type ApiEnvelope,
|
|
} from '@/shared/api/http'
|
|
import type { PlatformPermissionResource } from '@/shared/types/platformPermission'
|
|
import { clearOverseasAccessToken, replaceOverseasAccessToken } from '@/shared/api/overseasHttp'
|
|
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
|
|
overseasAccessToken?: string | null
|
|
overseasExpiresIn?: 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<CurrentUser | null>(null)
|
|
const permissions = ref<string[]>([])
|
|
const permissionResources = ref<PlatformPermissionResource[]>([])
|
|
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<ReadonlySet<string>>(() => 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)
|
|
replaceOverseasAccessToken(
|
|
result.overseasAccessToken ?? null,
|
|
parseLifetimeSeconds(result.overseasExpiresIn ?? 0, 'overseasExpiresIn'),
|
|
)
|
|
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
|
|
clearOverseasAccessToken()
|
|
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
|
|
if (token.value !== storedToken) clearOverseasAccessToken()
|
|
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) {
|
|
await http.post('/auth/logout', { refreshToken: refreshTokenToRevoke }, {
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
})
|
|
}
|
|
|
|
async function login(account: string, password: string) {
|
|
const expectedGeneration = ++sessionGeneration
|
|
try {
|
|
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
|
'/auth/login',
|
|
{ loginId: account, password },
|
|
)
|
|
const result = unwrapApiResponse<LoginResponse>(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<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
|
|
}
|
|
}
|
|
|
|
async function restoreSession() {
|
|
if (isAuthenticated.value) {
|
|
if (!refreshToken.value) return true
|
|
try {
|
|
await refreshSession()
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
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 (accessToken && tokenToRevoke) {
|
|
await revokeTokenPair(accessToken, tokenToRevoke)
|
|
}
|
|
}
|
|
|
|
return {
|
|
token,
|
|
refreshToken,
|
|
user,
|
|
permissions,
|
|
permissionResources,
|
|
isServerSession,
|
|
permissionCodes,
|
|
isAuthenticated,
|
|
login,
|
|
refreshSession,
|
|
restoreSession,
|
|
logout,
|
|
}
|
|
})
|