fix(auth): reject consumer-only admin sessions
This commit is contained in:
@@ -44,6 +44,7 @@ export const PERM = {
|
|||||||
ROLE_DELETE: 'auth:role:delete',
|
ROLE_DELETE: 'auth:role:delete',
|
||||||
ROLE_ASSIGN: 'auth:role:assign',
|
ROLE_ASSIGN: 'auth:role:assign',
|
||||||
PERMISSION_READ: 'auth:permission:read',
|
PERMISSION_READ: 'auth:permission:read',
|
||||||
|
PERMISSION_MANAGE: 'auth:permission:manage',
|
||||||
USER_READ: 'admin:user:read',
|
USER_READ: 'admin:user:read',
|
||||||
USER_MANAGE: 'admin:user:manage',
|
USER_MANAGE: 'admin:user:manage',
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
+23
-1
@@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it } from 'vitest'
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
import { can, hasRole, useAuthStore } from '@/auth/store'
|
import { can, hasAdminAccess, hasRole, isAuthenticated, useAuthStore } from '@/auth/store'
|
||||||
|
|
||||||
const user = {
|
const user = {
|
||||||
kind: 'user' as const,
|
kind: 'user' as const,
|
||||||
@@ -34,4 +34,26 @@ describe('can()', () => {
|
|||||||
expect(hasRole('admin')).toBe(true)
|
expect(hasRole('admin')).toBe(true)
|
||||||
expect(hasRole('super-admin')).toBe(false)
|
expect(hasRole('super-admin')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not treat consumer roles or permissions as admin access', () => {
|
||||||
|
expect(hasAdminAccess(['client'], ['exc:demand:read'])).toBe(false)
|
||||||
|
expect(hasAdminAccess(['service-provider'], ['exc:quote:create'])).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts administrative permissions and the super-admin role', () => {
|
||||||
|
expect(hasAdminAccess(['admin'], ['admin:category:read'])).toBe(true)
|
||||||
|
expect(hasAdminAccess(['developer'], ['auth:api-docs:read'])).toBe(true)
|
||||||
|
expect(hasAdminAccess(['super-admin'], [])).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a hydrated consumer session', () => {
|
||||||
|
useAuthStore.getState().setTokens({
|
||||||
|
access_token: 'token',
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expires_in: 3600,
|
||||||
|
})
|
||||||
|
useAuthStore.getState().setSession(user, ['client'], ['exc:demand:read'])
|
||||||
|
|
||||||
|
expect(isAuthenticated()).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+10
-1
@@ -50,9 +50,18 @@ export function useCan(permission: string): boolean {
|
|||||||
return roles.includes('super-admin') || permissions.includes(permission)
|
return roles.includes('super-admin') || permissions.includes(permission)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasAdminAccess(roles: string[], permissions: string[]): boolean {
|
||||||
|
return (
|
||||||
|
roles.includes('super-admin') ||
|
||||||
|
permissions.some((permission) =>
|
||||||
|
permission.startsWith('admin:') || permission.startsWith('auth:'),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function isAuthenticated(): boolean {
|
export function isAuthenticated(): boolean {
|
||||||
const { accessToken, user, roles, permissions } = useAuthStore.getState()
|
const { accessToken, user, roles, permissions } = useAuthStore.getState()
|
||||||
if (!accessToken) return false
|
if (!accessToken) return false
|
||||||
if (user && roles.length === 0 && permissions.length === 0) return false
|
if (user && !hasAdminAccess(roles, permissions)) return false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect } from 'react'
|
|||||||
import { LogOut, Moon, Sun } from 'lucide-react'
|
import { LogOut, Moon, Sun } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { fetchMe } from '@/api/modules/auth'
|
import { fetchMe } from '@/api/modules/auth'
|
||||||
import { can, isAuthenticated, useAuthStore } from '@/auth/store'
|
import { can, hasAdminAccess, isAuthenticated, useAuthStore } from '@/auth/store'
|
||||||
import { NAV_GROUPS } from '@/components/layout/nav'
|
import { NAV_GROUPS } from '@/components/layout/nav'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -44,7 +44,7 @@ function AuthedLayout() {
|
|||||||
if (me.data) {
|
if (me.data) {
|
||||||
const roles = me.data.meta?.roles ?? []
|
const roles = me.data.meta?.roles ?? []
|
||||||
const permissions = me.data.meta?.permissions ?? []
|
const permissions = me.data.meta?.permissions ?? []
|
||||||
if (roles.length === 0 && permissions.length === 0) {
|
if (!hasAdminAccess(roles, permissions)) {
|
||||||
useAuthStore.getState().clear()
|
useAuthStore.getState().clear()
|
||||||
toast.error('账号未分配管理后台权限,已退出登录')
|
toast.error('账号未分配管理后台权限,已退出登录')
|
||||||
void navigate({ to: '/login' })
|
void navigate({ to: '/login' })
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ export const Route = createFileRoute('/_authed/roles')({
|
|||||||
|
|
||||||
function RolesPage() {
|
function RolesPage() {
|
||||||
const canCreate = useCan(PERM.ROLE_CREATE)
|
const canCreate = useCan(PERM.ROLE_CREATE)
|
||||||
const canUpdate = useCan(PERM.ROLE_UPDATE)
|
const canUpdateRole = useCan(PERM.ROLE_UPDATE)
|
||||||
|
const canManagePermissions = useCan(PERM.PERMISSION_MANAGE)
|
||||||
|
const canUpdate = canUpdateRole && canManagePermissions
|
||||||
const canDelete = useCan(PERM.ROLE_DELETE)
|
const canDelete = useCan(PERM.ROLE_DELETE)
|
||||||
|
|
||||||
const roles = useQuery({ queryKey: ['roles'], queryFn: fetchRoles })
|
const roles = useQuery({ queryKey: ['roles'], queryFn: fetchRoles })
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useState } from 'react'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { loginWithPassword, fetchMe } from '@/api/modules/auth'
|
import { loginWithPassword, fetchMe } from '@/api/modules/auth'
|
||||||
import { ApiError } from '@/api/client'
|
import { ApiError } from '@/api/client'
|
||||||
import { useAuthStore, isAuthenticated } from '@/auth/store'
|
import { hasAdminAccess, useAuthStore, isAuthenticated } from '@/auth/store'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -46,7 +46,7 @@ function LoginPage() {
|
|||||||
const me = await fetchMe()
|
const me = await fetchMe()
|
||||||
const roles = me.meta?.roles ?? []
|
const roles = me.meta?.roles ?? []
|
||||||
const permissions = me.meta?.permissions ?? []
|
const permissions = me.meta?.permissions ?? []
|
||||||
if (roles.length === 0 && permissions.length === 0) {
|
if (!hasAdminAccess(roles, permissions)) {
|
||||||
useAuthStore.getState().clear()
|
useAuthStore.getState().clear()
|
||||||
toast.error('该账号未分配管理后台权限,无法登录')
|
toast.error('该账号未分配管理后台权限,无法登录')
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user