基于 TanStack Router 的角色访问控制(RBAC)实战指南
【免费下载链接】router🤖 A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router
本篇技术指南讲解如何在 TanStack Router 应用中落地完整的基于角色的访问控制(RBAC)与基于权限的路由体系:从扩展认证上下文携带角色/权限数据开始,借助beforeLoad在路由加载前完成权限校验与重定向,再到组件级细粒度权限守卫和高级权限模式。读完本文,你将掌握一套可直接复制到项目中的"认证上下文 + 路由守卫 + 组件守卫"三层权限实现方案,并理解其背后的路由上下文类型系统与beforeLoad执行机制。
方案总览
在 TanStack Router 中实现 RBAC 的核心思路可以概括为三步:
- 扩展认证上下文:在用户模型中增加
roles(角色)与permissions(权限)字段,并封装hasRole、hasAnyRole、hasPermission、hasAnyPermission等判定方法; - 创建受角色保护的布局路由:利用路径前缀布局(pathless layout)路由(如
_admin、_moderator、_users)作为"权限边界",在其beforeLoad中校验当前用户是否满足角色/权限要求,不满足则throw redirect()跳转到未授权页面; - 在组件内部做细粒度控制:通过
PermissionGuard这类条件渲染组件,控制按钮、表单等局部 UI 的可见性。
这三层设计天然利用了 TanStack Router 的两个核心机制:类型安全的 router context(由createRootRouteWithContext约束根上下文类型)和beforeLoad路由守卫(在路由及所有子路由加载之前执行)。
注意:路由守卫只是 UI 层面的授权边界,不是数据授权边界。任何返回私有数据的 Server Function、Server Route 或 API 端点都必须自行校验请求权限,因为它们可以被独立于路由之外直接请求。这一点在 认证指南 中有明确强调。
扩展认证上下文
1. 为用户模型添加角色与权限
首先在src/auth.tsx中改造认证上下文。User接口新增roles: string[]与permissions: string[]两个数组字段,同时对外暴露四个判定方法:单角色hasRole、多角色任一hasAnyRole、单权限hasPermission、多权限任一hasAnyPermission:
// src/auth.tsx import React, { createContext, useContext, useState } from 'react' interface User { id: string username: string email: string roles: string[] permissions: string[] } interface AuthState { isAuthenticated: boolean user: User | null hasRole: (role: string) => boolean hasAnyRole: (roles: string[]) => boolean hasPermission: (permission: string) => boolean hasAnyPermission: (permissions: string[]) => boolean login: (username: string, password: string) => Promise<void> logout: () => void } const AuthContext = createContext<AuthState | undefined>(undefined) export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState<User | null>(null) const [isAuthenticated, setIsAuthenticated] = useState(false) const hasRole = (role: string) => { return user?.roles.includes(role) ?? false } const hasAnyRole = (roles: string[]) => { return roles.some((role) => user?.roles.includes(role)) ?? false } const hasPermission = (permission: string) => { return user?.permissions.includes(permission) ?? false } const hasAnyPermission = (permissions: string[]) => { return ( permissions.some((permission) => user?.permissions.includes(permission), ) ?? false ) } const login = async (username: string, password: string) => { const response = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }) if (response.ok) { const userData = await response.json() setUser(userData) setIsAuthenticated(true) } else { throw new Error('Authentication failed') } } const logout = () => { setUser(null) setIsAuthenticated(false) } return ( <AuthContext.Provider value={{ isAuthenticated, user, hasRole, hasAnyRole, hasPermission, hasAnyPermission, login, logout, }} > {children} </AuthContext.Provider> ) } export function useAuth() { const context = useContext(AuthContext) if (context === undefined) { throw new Error('useAuth must be used within an AuthProvider') } return context }判定方法统一使用?? false兜底,保证当user为null(未登录)时任何权限/角色查询都返回false,而不是抛错或返回undefined,避免在路由守卫中引入隐性空值问题。
2. 更新 Router Context 类型
认证状态需要进入路由的上下文,才能被beforeLoad、loader以及路由组件访问。更新src/routes/__root.tsx,用createRootRouteWithContext<MyRouterContext>()代替普通的createRootRoute():
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router' interface AuthState { isAuthenticated: boolean user: { id: string username: string email: string roles: string[] permissions: string[] } | null hasRole: (role: string) => boolean hasAnyRole: (roles: string[]) => boolean hasPermission: (permission: string) => boolean hasAnyPermission: (permissions: string[]) => boolean login: (username: string, password: string) => Promise<void> logout: () => void } interface MyRouterContext { auth: AuthState } export const Route = createRootRouteWithContext<MyRouterContext>()({ component: () => ( <div> <Outlet /> </div> ), })为什么必须用createRootRouteWithContext?从源码看,这是类型系统将认证状态"注入"路由的关键入口。packages/react-router/src/route.tsx 中,createRootRouteWithContext<TRouterContext>()返回一个工厂函数,它把TRouterContext作为泛型参数透传给底层的createRootRoute,从而让根路由的 context 类型被严格约束为{ auth: AuthState }:
export function createRootRouteWithContext<TRouterContext extends {}>() { return <...>(options?: RootRouteOptions<...TRouterContext...>) => { return createRootRoute<...TRouterContext...>(options) } }一旦你在createRouter时未提供完整且类型匹配的 context,TypeScript 会直接报错;同时,createRootRouteWithContext也取代了已废弃的rootRouteWithContext(见 route.tsx 的@deprecated注释)。关于 router context 的更完整介绍(依赖注入、context 合并、面包屑等用法)可参阅 Router Context 指南。
关键点:
MyRouterContext只需包含会直接传给createRouter的初始内容;其余在beforeLoad中追加的 context 会被自动推断。此外,React hooks 不能直接在beforeLoad/loader中使用(违反 Rules of Hooks),所以认证状态必须通过 context 传递——这正是router.context的存在意义,详见 Router Context 指南 的"Using React Context/Hooks"一节。
创建受角色保护的布局路由
RBAC 最优雅的落地方式是利用路径前缀布局(pathless layout)路由。文件路由中,以下划线开头命名的目录(如_authenticated、_admin)不会产生 URL 段,只充当布局与守卫边界。我们在此基础上再叠一层_admin,让所有位于其下的路由都继承管理员校验。
1. 管理员专属路由
创建src/routes/_authenticated/_admin.tsx,在beforeLoad中校验admin角色,不满足则携带当前地址重定向到未授权页:
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router' export const Route = createFileRoute('/_authenticated/_admin')({ beforeLoad: ({ context, location }) => { if (!context.auth.hasRole('admin')) { throw redirect({ to: '/unauthorized', search: { redirect: location.href, }, }) } }, component: AdminLayout, }) function AdminLayout() { return ( <div> <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"> <strong>Admin Area:</strong> You have administrative privileges. </div> <Outlet /> </div> ) }2. 多角色访问
创建src/routes/_authenticated/_moderator.tsx,允许admin或moderator任一角色进入,并在重定向参数中附上reason: 'insufficient_role',供未授权页区分展示文案:
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router' export const Route = createFileRoute('/_authenticated/_moderator')({ beforeLoad: ({ context, location }) => { const allowedRoles = ['admin', 'moderator'] if (!context.auth.hasAnyRole(allowedRoles)) { throw redirect({ to: '/unauthorized', search: { redirect: location.href, reason: 'insufficient_role', }, }) } }, component: ModeratorLayout, }) function ModeratorLayout() { const { auth } = Route.useRouteContext() return ( <div> <div className="bg-blue-100 border border-blue-400 text-blue-700 px-4 py-3 rounded mb-4"> <strong>Moderator Area:</strong> Role: {auth.user?.roles.join(', ')} </div> <Outlet /> </div> ) }注意这里用到了Route.useRouteContext()——它是 TanStack Router 提供的类型安全 context 访问钩子,能直接拿到父级beforeLoad返回的、合并后的 context,包括根路由注入的auth。在beforeLoad中我们只能通过参数context访问,而在组件内部则使用Route.useRouteContext(),二者都受类型约束保护。
3. 基于权限的路由
角色是"你是谁",权限是"你能做什么"。创建src/routes/_authenticated/_users.tsx,改用细粒度权限字符串(如users:read、users:write)作为准入条件:
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router' export const Route = createFileRoute('/_authenticated/_users')({ beforeLoad: ({ context, location }) => { const requiredPermissions = ['users:read', 'users:write'] if (!context.auth.hasAnyPermission(requiredPermissions)) { throw redirect({ to: '/unauthorized', search: { redirect: location.href, reason: 'insufficient_permissions', }, }) } }, component: () => <Outlet />, })关于beforeLoad的执行时机
理解beforeLoad是理解整个 RBAC 方案的前提。根据 认证路由指南,路由加载流程的相对顺序如下:
- 路由匹配(自上而下):
route.params.parse→route.validateSearch - 路由加载(含预加载):
route.beforeLoad→route.onError - 路由加载(并行):
route.component.preload?→route.load
两个关键语义决定了守卫的有效性:
- 父路由的
beforeLoad先于所有子路由的beforeLoad执行,它本质上是"该路由及其全部子路由"的中间件; - 如果在
beforeLoad中抛出错误或redirect(),所有子路由都不会尝试加载。
这意味着把 RBAC 校验放在_admin、_moderator这类布局路由上,就能一次性保护其下所有页面,无需在每页重复编写校验逻辑。redirect()函数支持与navigate相同的选项(如replace: true),可以用重定向替换而非追加历史记录。若校验过程本身可能抛错(网络失败、token 校验等),建议用try/catch包裹,并通过isRedirect(error)区分"有意的重定向"与"真正的错误"——这是 认证路由指南 明确推荐的健壮写法。
创建具体受保护页面
布局路由负责"准入",具体页面负责展示。以下两个示例位于受保护布局之下,因此自动继承了父级守卫。
1. 管理后台首页
创建src/routes/_authenticated/_admin/dashboard.tsx:
import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/_authenticated/_admin/dashboard')({ component: AdminDashboard, }) function AdminDashboard() { const { auth } = Route.useRouteContext() return ( <div className="p-6"> <h1 className="text-3xl font-bold mb-6">Admin Dashboard</h1> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="bg-white p-6 rounded-lg shadow"> <h2 className="text-xl font-semibold mb-2">User Management</h2> <p className="text-gray-600">Manage all users in the system</p> <button className="mt-4 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"> View Users </button> </div> <div className="bg-white p-6 rounded-lg shadow"> <h2 className="text-xl font-semibold mb-2">System Settings</h2> <p className="text-gray-600">Configure system-wide settings</p> <button className="mt-4 bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700"> Open Settings </button> </div> <div className="bg-white p-6 rounded-lg shadow"> <h2 className="text-xl font-semibold mb-2">Reports</h2> <p className="text-gray-600">View system reports and analytics</p> <button className="mt-4 bg-purple-600 text-white px-4 py-2 rounded hover:bg-purple-700"> View Reports </button> </div> </div> <div className="mt-8 bg-gray-100 p-4 rounded"> <h3 className="font-semibold">Your Info:</h3> <p>Username: {auth.user?.username}</p> <p>Roles: {auth.user?.roles.join(', ')}</p> <p>Permissions: {auth.user?.permissions.join(', ')}</p> </div> </div> ) }2. 用户管理页(页面级二次校验)
创建src/routes/_authenticated/_users/manage.tsx。除了父级布局的users:read/users:write任一校验外,本页在beforeLoad中再收紧为必须持有users:write——这就是"布局守卫 + 页面守卫"的组合用法,前者控制入口,后者控制具体操作能力:
import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/_authenticated/_users/manage')({ beforeLoad: ({ context }) => { // Additional permission check at the page level if (!context.auth.hasPermission('users:write')) { throw new Error('You need write permissions to manage users') } }, component: UserManagement, }) function UserManagement() { const { auth } = Route.useRouteContext() const canEdit = auth.hasPermission('users:write') const canDelete = auth.hasPermission('users:delete') return ( <div className="p-6"> <h1 className="text-3xl font-bold mb-6">User Management</h1> <div className="bg-white rounded-lg shadow overflow-hidden"> <table className="min-w-full"> <thead className="bg-gray-50"> <tr> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"> Name </th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"> Email </th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"> Role </th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"> Actions </th> </tr> </thead> <tbody className="divide-y divide-gray-200"> <tr> <td className="px-6 py-4 whitespace-nowrap">John Doe</td> <td className="px-6 py-4 whitespace-nowrap">john@example.com</td> <td className="px-6 py-4 whitespace-nowrap"> <span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800"> User </span> </td> <td className="px-6 py-4 whitespace-nowrap text-sm"> {canEdit && ( <button className="text-blue-600 hover:text-blue-900 mr-4"> Edit </button> )} {canDelete && ( <button className="text-red-600 hover:text-red-900"> Delete </button> )} </td> </tr> </tbody> </table> </div> <div className="mt-6 p-4 bg-blue-50 rounded"> <h3 className="font-semibold text-blue-800">Your Permissions:</h3> <ul className="text-blue-700 text-sm"> {auth.user?.permissions.map((permission) => ( <li key={permission}>✓ {permission}</li> ))} </ul> </div> </div> ) }这里的canEdit/canDelete属于"声明式按钮级控制":在表格行内根据权限条件渲染操作按钮,未授权用户看不到相应操作入口。
创建未授权页面
所有守卫重定向的落点是/unauthorized。创建src/routes/unauthorized.tsx,通过validateSearch对重定向目标与失败原因做类型安全校验,并根据reason显示差异化的提示文案:
import { createFileRoute, Link } from '@tanstack/react-router' export const Route = createFileRoute('/unauthorized')({ validateSearch: (search) => ({ redirect: (search.redirect as string) || '/dashboard', reason: (search.reason as string) || 'insufficient_permissions', }), component: UnauthorizedPage, }) function UnauthorizedPage() { const { redirect, reason } = Route.useSearch() const { auth } = Route.useRouteContext() const reasonMessages = { insufficient_role: 'You do not have the required role to access this page.', insufficient_permissions: 'You do not have the required permissions to access this page.', default: 'You are not authorized to access this page.', } const message = reasonMessages[reason as keyof typeof reasonMessages] || reasonMessages.default return ( <div className="min-h-screen flex items-center justify-center bg-gray-50"> <div className="max-w-md w-full bg-white shadow-lg rounded-lg p-8 text-center"> <div className="mb-6"> <div className="mx-auto w-16 h-16 bg-red-100 rounded-full flex items-center justify-center"> <svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" /> </svg> </div> </div> <h1 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h1> <p className="text-gray-600 mb-6">{message}</p> <div className="mb-6 text-sm text-gray-500"> <p> <strong>Your roles:</strong> {auth.user?.roles.join(', ') || 'None'} </p> <p> <strong>Your permissions:</strong>{' '} {auth.user?.permissions.join(', ') || 'None'} </p> </div> <div className="space-y-3"> <Link to="/dashboard" className="block w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700 transition-colors" > Go to Dashboard </Link> <Link to={redirect} className="block w-full bg-gray-200 text-gray-800 py-2 px-4 rounded hover:bg-gray-300 transition-colors" > Try Again </Link> </div> </div> </div> ) }页面同时展示了当前用户的角色与权限列表,帮助用户理解"为什么被拒"。redirect参数从守卫处一路透传,用户点击"Try Again"即可返回原目标页。
组件级权限检查
布局守卫解决"页面能不能进",组件守卫解决"界面元素能不能看/点"。二者结合构成完整的权限闭环。
1. 条件渲染 Hook
创建src/hooks/usePermissions.ts。这里直接通过useRouter()读取router.options.context.auth,绕开组件树层级限制,在任何深层组件中都能拿到认证状态:
import { useRouter } from '@tanstack/react-router' export function usePermissions() { const router = useRouter() const auth = router.options.context.auth return { hasRole: auth.hasRole, hasAnyRole: auth.hasAnyRole, hasPermission: auth.hasPermission, hasAnyPermission: auth.hasAnyPermission, user: auth.user, } }2. 权限守卫组件
创建src/components/PermissionGuard.tsx。它同时支持角色与权限两套条件,并可通过requireAll切换"全部满足"与"任一满足"两种语义,未通过时渲染fallback:
interface PermissionGuardProps { children: React.ReactNode roles?: string[] permissions?: string[] requireAll?: boolean fallback?: React.ReactNode } export function PermissionGuard({ children, roles = [], permissions = [], requireAll = false, fallback = null, }: PermissionGuardProps) { const { hasAnyRole, hasAnyPermission, hasRole, hasPermission } = usePermissions() const hasRequiredRoles = roles.length === 0 || (requireAll ? roles.every((role) => hasRole(role)) : hasAnyRole(roles)) const hasRequiredPermissions = permissions.length === 0 || (requireAll ? permissions.every((permission) => hasPermission(permission)) : hasAnyPermission(permissions)) if (hasRequiredRoles && hasRequiredPermissions) { return <>{children}</> } return <>{fallback}</> }3. 使用权限守卫
在组件中组合使用:roles控制"谁可见",permissions控制"谁可操作",requireAll实现"角色与权限同时满足":
import { PermissionGuard } from '../components/PermissionGuard' function SomeComponent() { return ( <div> <h1>Dashboard</h1> <PermissionGuard roles={['admin']}> <button className="bg-red-600 text-white px-4 py-2 rounded"> Admin Only Button </button> </PermissionGuard> <PermissionGuard permissions={['users:write']} fallback={<p className="text-gray-500">You cannot edit users</p>} > <button className="bg-blue-600 text-white px-4 py-2 rounded"> Edit Users </button> </PermissionGuard> <PermissionGuard roles={['admin', 'moderator']} permissions={['content:moderate']} requireAll={true} > <button className="bg-yellow-600 text-white px-4 py-2 rounded"> Moderate Content (Admin/Mod + Permission) </button> </PermissionGuard> </div> ) }高级权限模式
基础 RBAC 之外,实际业务往往需要更复杂的判定逻辑。以下两种模式均以"权限是字符串集合"为前提,通过约定式命名扩展语义。
1. 基于资源的权限
权限不只是全局布尔值,还可以绑定到具体资源。通过"资源拥有者 + 权限字符串 + 角色优先"的组合,实现"管理员通吃、拥有者可编辑自己资源、版主可在特定权限下编辑任意资源"的典型规则:
// Check if user can edit a specific resource function canEditResource(auth: AuthState, resourceId: string, ownerId: string) { // Admin can edit anything if (auth.hasRole('admin')) return true // Owner can edit their own resources if (auth.user?.id === ownerId && auth.hasPermission('resource:edit:own')) return true // Moderators can edit with permission if (auth.hasRole('moderator') && auth.hasPermission('resource:edit:any')) return true return false } // Usage in component function ResourceEditor({ resource }) { const { auth } = Route.useRouteContext() if (!canEditResource(auth, resource.id, resource.ownerId)) { return <div>You cannot edit this resource</div> } return <EditForm resource={resource} /> }2. 基于时间的权限
通过约定式权限命名permission:time:start:end携带时间窗口,实现"仅在营业时段内可操作"等限时权限:
function hasTimeBasedPermission(auth: AuthState, permission: string) { const userPermissions = auth.user?.permissions || [] const hasPermission = userPermissions.includes(permission) // Check if permission has time restrictions const timeRestricted = userPermissions.find((p) => p.startsWith(`${permission}:time:`), ) if (timeRestricted) { const [, , startHour, endHour] = timeRestricted.split(':') const currentHour = new Date().getHours() return ( currentHour >= parseInt(startHour) && currentHour <= parseInt(endHour) ) } return hasPermission }常见问题排查
角色/权限数据未加载
问题:路由中的user.roles、user.permissions为undefined。
解决:确认认证 API 返回了完整的用户数据。最常见的原因是对login的响应体缺少roles/permissions字段,或字段名不一致(如服务端返回role单数、perms缩写)。在写入setUser前打日志核对结构:
const login = async (username: string, password: string) => { const response = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }) if (response.ok) { const userData = await response.json() // Ensure userData includes roles and permissions console.log('User data:', userData) // Debug log setUser(userData) setIsAuthenticated(true) } }权限校验过于严格(用户被锁在门外)
问题:本应可访问的区域被守卫拦截。
解决:采用角色层级(角色继承)代替平铺的硬编码判断。定义roleHierarchy让高阶角色隐式包含低阶角色,例如admin自动拥有moderator与user的全部权限,从而避免每个守卫都要列全所有角色:
const roleHierarchy = { admin: ['admin', 'moderator', 'user'], moderator: ['moderator', 'user'], user: ['user'], } const hasRole = (requiredRole: string) => { const userRoles = user?.roles || [] return userRoles.some((userRole) => roleHierarchy[userRole]?.includes(requiredRole), ) }权限检查过多导致性能问题
问题:大量重复的权限计算拖慢渲染。
解决:用useMemo缓存派生权限结果,仅当roles/permissions引用变化时才重算:
import { useMemo } from 'react' function usePermissions() { const { auth } = Route.useRouteContext() const permissions = useMemo( () => ({ canEditUsers: auth.hasPermission('users:write'), canDeleteUsers: auth.hasPermission('users:delete'), isAdmin: auth.hasRole('admin'), isModerator: auth.hasAnyRole(['admin', 'moderator']), }), [auth.user?.roles, auth.user?.permissions], ) return permissions }注意依赖数组使用auth.user?.roles与auth.user?.permissions这两个引用作为变更信号——只要用户对象或其数组被替换,派生权限就会重新计算;这也是登录/登出后 UI 及时刷新的关键。
常用后续步骤
完成基础 RBAC 后,可以继续深入:
- 如何搭建基础认证(Basic Authentication) —— 核心认证实现,本方案中
AuthProvider的完整版本; - 如何集成认证提供商(Auth Providers) —— 接入 Auth0、Clerk、Supabase 等外部认证服务,替代自建的
/api/login。
相关资源
- 认证路由指南 —— 覆盖
beforeLoad执行顺序、redirect()用法、认证失败处理与isRedirect最佳实践,是理解本方案底层机制的前置读物; - Router Context 指南 —— 深入讲解 router context 的类型约束、初始注入、
router.invalidate()失效机制与逐层合并规则。
仓库中还有可直接参考的完整示例:examples/react/authenticated-routes与examples/react/authenticated-routes-firebase展示了"React Context +createRootRouteWithContext+RouterProvider注入 context"的标准工程化形态;examples/react下的start-basic-auth等示例则演示了结合 TanStack Start 的服务端认证流程。将本指南中的 RBAC 守卫叠加在这些认证底座之上,即可快速产出生产可用的权限系统。
【免费下载链接】router🤖 A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考