优化构架
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
post: vi.fn(),
|
||||
listCurrentPlatformPermissionCodes: vi.fn(),
|
||||
refreshHandler: null as null | (() => Promise<string>),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { post: mocks.post },
|
||||
registerAuthRefreshHandler: (handler: () => Promise<string>) => { mocks.refreshHandler = handler },
|
||||
unwrapApiResponse: <T>(response: { code: string; message: string; data: T }) => {
|
||||
if (response.code !== '200') throw new Error(response.message)
|
||||
return response.data
|
||||
},
|
||||
getApiErrorDetails: (error: { code?: string; message?: string }) => ({
|
||||
message: error.message || '接口请求失败',
|
||||
code: error.code,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./platformAuthorization', () => ({
|
||||
listCurrentPlatformPermissionCodes: mocks.listCurrentPlatformPermissionCodes,
|
||||
}))
|
||||
|
||||
import { useAuthStore } from './auth'
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() { return values.size },
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
}
|
||||
}
|
||||
|
||||
function token(overrides: Record<string, unknown> = {}) {
|
||||
const claims = {
|
||||
user_id: '1000001',
|
||||
username: 'platform_admin',
|
||||
app_code: 'PLATFORM',
|
||||
roles: ['PLATFORM_ADMIN'],
|
||||
permissions: ['platform:warehouse:list', 'platform:order:list'],
|
||||
exp: Math.floor(Date.now() / 1000) + 1800,
|
||||
...overrides,
|
||||
}
|
||||
const payload = Buffer.from(JSON.stringify(claims)).toString('base64url')
|
||||
return `header.${payload}.signature`
|
||||
}
|
||||
|
||||
function loginResponse(accessToken = token(), refreshToken = 'refresh-next') {
|
||||
return {
|
||||
code: '200',
|
||||
message: 'success',
|
||||
data: {
|
||||
tokenType: 'Bearer',
|
||||
accessToken,
|
||||
expiresIn: 1800,
|
||||
refreshToken,
|
||||
refreshExpiresIn: 604800,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('admin auth session', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
setActivePinia(createPinia())
|
||||
mocks.post.mockReset()
|
||||
mocks.listCurrentPlatformPermissionCodes.mockReset()
|
||||
mocks.refreshHandler = null
|
||||
})
|
||||
|
||||
it('intersects JWT permissions with the current authenticated permission snapshot', async () => {
|
||||
mocks.post.mockResolvedValue(loginResponse())
|
||||
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
|
||||
const auth = useAuthStore()
|
||||
|
||||
await auth.login('warehouse_operator', 'password')
|
||||
|
||||
expect(auth.isAuthenticated).toBe(true)
|
||||
expect([...auth.permissionCodes]).toEqual(['platform:warehouse:list'])
|
||||
expect(auth.permissionResources).toEqual([])
|
||||
expect(localStorage.getItem('cc-admin-permission-codes'))
|
||||
.toBe('["platform:warehouse:list"]')
|
||||
})
|
||||
|
||||
it('rejects login and clears the session when the current permission snapshot is unavailable', async () => {
|
||||
mocks.post.mockResolvedValue(loginResponse())
|
||||
mocks.listCurrentPlatformPermissionCodes.mockRejectedValue({ code: 'S0503', message: 'IAM 服务不可用' })
|
||||
const auth = useAuthStore()
|
||||
|
||||
await expect(auth.login('platform_admin', 'password')).rejects.toMatchObject({ code: 'S0503' })
|
||||
expect(auth.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('cc-admin-token')).toBeNull()
|
||||
expect(localStorage.getItem('cc-admin-refresh-token')).toBeNull()
|
||||
})
|
||||
|
||||
it('revokes an issued token pair when the permission snapshot prevents login completion', async () => {
|
||||
mocks.post
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce({ code: '200', message: 'success', data: null })
|
||||
mocks.listCurrentPlatformPermissionCodes.mockRejectedValue({ code: 'S0503', message: 'IAM 服务不可用' })
|
||||
const auth = useAuthStore()
|
||||
|
||||
await expect(auth.login('platform_admin', 'password')).rejects.toMatchObject({ code: 'S0503' })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mocks.post).toHaveBeenNthCalledWith(2, '/auth/logout', { refreshToken: 'refresh-next' }, {
|
||||
headers: { Authorization: expect.stringContaining('Bearer header.') },
|
||||
})
|
||||
})
|
||||
|
||||
it('restores an expired access session with the persisted refresh token', async () => {
|
||||
localStorage.setItem('cc-admin-token', token({ exp: Math.floor(Date.now() / 1000) - 30 }))
|
||||
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
|
||||
mocks.post.mockResolvedValue(loginResponse(token(), 'refresh-rotated'))
|
||||
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
|
||||
const auth = useAuthStore()
|
||||
|
||||
await expect(auth.restoreSession()).resolves.toBe(true)
|
||||
|
||||
expect(mocks.post).toHaveBeenCalledWith('/auth/refresh', { refreshToken: 'refresh-old' })
|
||||
expect(auth.refreshToken).toBe('refresh-rotated')
|
||||
expect(auth.isAuthenticated).toBe(true)
|
||||
})
|
||||
|
||||
it('does not let an in-flight refresh restore a session after logout', async () => {
|
||||
localStorage.setItem('cc-admin-token', token())
|
||||
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
|
||||
let resolveRefresh: ((value: ReturnType<typeof loginResponse>) => void) | undefined
|
||||
mocks.post.mockImplementation((url: string) => {
|
||||
if (url === '/auth/refresh') {
|
||||
return new Promise((resolve) => { resolveRefresh = resolve })
|
||||
}
|
||||
return Promise.resolve({ code: '200', message: 'success', data: null })
|
||||
})
|
||||
const auth = useAuthStore()
|
||||
|
||||
const refreshing = auth.refreshSession()
|
||||
await Promise.resolve()
|
||||
await auth.logout()
|
||||
resolveRefresh?.(loginResponse(token(), 'refresh-rotated'))
|
||||
|
||||
await expect(refreshing).rejects.toThrow('登录会话已被后续操作替代')
|
||||
expect(auth.isAuthenticated).toBe(false)
|
||||
expect(auth.refreshToken).toBe('')
|
||||
expect(localStorage.getItem('cc-admin-token')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
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 { decodeJwtClaims } from './jwt'
|
||||
import { listCurrentPlatformPermissionCodes } from './platformAuthorization'
|
||||
|
||||
const TOKEN_KEY = 'cc-admin-token'
|
||||
const REFRESH_TOKEN_KEY = 'cc-admin-refresh-token'
|
||||
const PERMISSION_CODES_KEY = 'cc-admin-permission-codes'
|
||||
|
||||
interface LoginResponse {
|
||||
tokenType: string
|
||||
accessToken: string
|
||||
expiresIn: number
|
||||
refreshToken: string
|
||||
refreshExpiresIn: number
|
||||
}
|
||||
|
||||
class ExpiredAccessTokenError extends Error {}
|
||||
class SupersededSessionError extends Error {}
|
||||
|
||||
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,
|
||||
permissions: claims.permissions ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
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 = session.permissions.filter((code) => cachedPermissions.includes(code))
|
||||
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 = []
|
||||
const activePermissionCodeSet = new Set(activePermissionCodes)
|
||||
applyPermissionCodes(session.permissions.filter((code) => activePermissionCodeSet.has(code)))
|
||||
} 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)
|
||||
}
|
||||
|
||||
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',
|
||||
{ username: 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() {
|
||||
if (!refreshToken.value) 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)
|
||||
} catch (error) {
|
||||
if (expectedGeneration === sessionGeneration) clearSession()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreSession() {
|
||||
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
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface PlatformJwtClaims {
|
||||
user_id?: string | number
|
||||
username?: string
|
||||
app_code?: string
|
||||
roles?: string[]
|
||||
permissions?: string[]
|
||||
tenant_id?: string | number
|
||||
membership_id?: string | number
|
||||
exp?: number
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string): string {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
return atob(padded)
|
||||
}
|
||||
|
||||
export function decodeJwtClaims(token: string): PlatformJwtClaims {
|
||||
const payload = token.split('.')[1]
|
||||
if (!payload) throw new Error('登录响应中的 accessToken 格式无效')
|
||||
try {
|
||||
return JSON.parse(decodeBase64Url(payload)) as PlatformJwtClaims
|
||||
} catch {
|
||||
throw new Error('登录响应中的 accessToken 无法解析')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listCurrentPlatformPermissionCodes } from './platformAuthorization'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
describe('platformAuthorization', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('loads the current user permission snapshot', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: ['platform:warehouse:list'] })
|
||||
|
||||
await expect(listCurrentPlatformPermissionCodes()).resolves.toEqual(['platform:warehouse:list'])
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/authorization/permissions')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export async function listCurrentPlatformPermissionCodes() {
|
||||
const response = await http.get<ApiEnvelope<string[]>, ApiEnvelope<string[]>>(
|
||||
'/platform/v1/iam/authorization/permissions',
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
Reference in New Issue
Block a user