Files
admin-web/src/core/auth/auth.ts
T

293 lines
10 KiB
TypeScript

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<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)
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<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) {
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() {
// 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,
}
})