From 2bc5c5df503000be732b8d67a4bb77b3e86c144c Mon Sep 17 00:00:00 2001 From: kongkx Date: Mon, 7 Sep 2026 20:08:27 +0800 Subject: [PATCH] fix(auth): reject consumer-only admin sessions --- src/auth/permissions.ts | 1 + src/auth/store.test.ts | 24 +++++++++++++++++++++++- src/auth/store.ts | 11 ++++++++++- src/routes/_authed.tsx | 4 ++-- src/routes/_authed/roles.tsx | 4 +++- src/routes/login.tsx | 4 ++-- 6 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/auth/permissions.ts b/src/auth/permissions.ts index ca09f3b..e7e60b5 100644 --- a/src/auth/permissions.ts +++ b/src/auth/permissions.ts @@ -44,6 +44,7 @@ export const PERM = { ROLE_DELETE: 'auth:role:delete', ROLE_ASSIGN: 'auth:role:assign', PERMISSION_READ: 'auth:permission:read', + PERMISSION_MANAGE: 'auth:permission:manage', USER_READ: 'admin:user:read', USER_MANAGE: 'admin:user:manage', } as const diff --git a/src/auth/store.test.ts b/src/auth/store.test.ts index c9f02ce..07df265 100644 --- a/src/auth/store.test.ts +++ b/src/auth/store.test.ts @@ -1,5 +1,5 @@ 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 = { kind: 'user' as const, @@ -34,4 +34,26 @@ describe('can()', () => { expect(hasRole('admin')).toBe(true) 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) + }) }) diff --git a/src/auth/store.ts b/src/auth/store.ts index 2fc256d..8baf3f3 100644 --- a/src/auth/store.ts +++ b/src/auth/store.ts @@ -50,9 +50,18 @@ export function useCan(permission: string): boolean { 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 { const { accessToken, user, roles, permissions } = useAuthStore.getState() if (!accessToken) return false - if (user && roles.length === 0 && permissions.length === 0) return false + if (user && !hasAdminAccess(roles, permissions)) return false return true } diff --git a/src/routes/_authed.tsx b/src/routes/_authed.tsx index df8d3e2..5087b8d 100644 --- a/src/routes/_authed.tsx +++ b/src/routes/_authed.tsx @@ -4,7 +4,7 @@ import { useEffect } from 'react' import { LogOut, Moon, Sun } from 'lucide-react' import { toast } from 'sonner' 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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' @@ -44,7 +44,7 @@ function AuthedLayout() { if (me.data) { const roles = me.data.meta?.roles ?? [] const permissions = me.data.meta?.permissions ?? [] - if (roles.length === 0 && permissions.length === 0) { + if (!hasAdminAccess(roles, permissions)) { useAuthStore.getState().clear() toast.error('账号未分配管理后台权限,已退出登录') void navigate({ to: '/login' }) diff --git a/src/routes/_authed/roles.tsx b/src/routes/_authed/roles.tsx index 498974e..a8c07e6 100644 --- a/src/routes/_authed/roles.tsx +++ b/src/routes/_authed/roles.tsx @@ -47,7 +47,9 @@ export const Route = createFileRoute('/_authed/roles')({ function RolesPage() { 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 roles = useQuery({ queryKey: ['roles'], queryFn: fetchRoles }) diff --git a/src/routes/login.tsx b/src/routes/login.tsx index a555ef0..b0c96ad 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -6,7 +6,7 @@ import { useState } from 'react' import { toast } from 'sonner' import { loginWithPassword, fetchMe } from '@/api/modules/auth' 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' @@ -46,7 +46,7 @@ function LoginPage() { const me = await fetchMe() const roles = me.meta?.roles ?? [] const permissions = me.meta?.permissions ?? [] - if (roles.length === 0 && permissions.length === 0) { + if (!hasAdminAccess(roles, permissions)) { useAuthStore.getState().clear() toast.error('该账号未分配管理后台权限,无法登录') return