实战指南)
基于 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) Promisevoid logout: () void } const AuthContext createContextAuthState | undefined(undefined) export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] useStateUser | 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用createRootRouteWithContextMyRouterContext()代替普通的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) Promisevoid logout: () void } interface MyRouterContext { auth: AuthState } export const Route createRootRouteWithContextMyRouterContext()({ component: () ( div Outlet / /div ), })为什么必须用createRootRouteWithContext从源码看这是类型系统将认证状态注入路由的关键入口。packages/react-router/src/route.tsx 中createRootRouteWithContextTRouterContext()返回一个工厂函数它把TRouterContext作为泛型参数透传给底层的createRootRoute从而让根路由的 context 类型被严格约束为{ auth: AuthState }export function createRootRouteWithContextTRouterContext extends {}() { return ...(options?: RootRouteOptions...TRouterContext...) { return createRootRoute...TRouterContext...(options) } }一旦你在createRouter时未提供完整且类型匹配的 contextTypeScript 会直接报错同时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 classNamebg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4 strongAdmin 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 classNamebg-blue-100 border border-blue-400 text-blue-700 px-4 py-3 rounded mb-4 strongModerator 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.tsximport { createFileRoute } from tanstack/react-router export const Route createFileRoute(/_authenticated/_admin/dashboard)({ component: AdminDashboard, }) function AdminDashboard() { const { auth } Route.useRouteContext() return ( div classNamep-6 h1 classNametext-3xl font-bold mb-6Admin Dashboard/h1 div classNamegrid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 div classNamebg-white p-6 rounded-lg shadow h2 classNametext-xl font-semibold mb-2User Management/h2 p classNametext-gray-600Manage all users in the system/p button classNamemt-4 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 View Users /button /div div classNamebg-white p-6 rounded-lg shadow h2 classNametext-xl font-semibold mb-2System Settings/h2 p classNametext-gray-600Configure system-wide settings/p button classNamemt-4 bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 Open Settings /button /div div classNamebg-white p-6 rounded-lg shadow h2 classNametext-xl font-semibold mb-2Reports/h2 p classNametext-gray-600View system reports and analytics/p button classNamemt-4 bg-purple-600 text-white px-4 py-2 rounded hover:bg-purple-700 View Reports /button /div /div div classNamemt-8 bg-gray-100 p-4 rounded h3 classNamefont-semiboldYour Info:/h3 pUsername: {auth.user?.username}/p pRoles: {auth.user?.roles.join(, )}/p pPermissions: {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 classNamep-6 h1 classNametext-3xl font-bold mb-6User Management/h1 div classNamebg-white rounded-lg shadow overflow-hidden table classNamemin-w-full thead classNamebg-gray-50 tr th classNamepx-6 py-3 text-left text-xs font-medium text-gray-500 uppercase Name /th th classNamepx-6 py-3 text-left text-xs font-medium text-gray-500 uppercase Email /th th classNamepx-6 py-3 text-left text-xs font-medium text-gray-500 uppercase Role /th th classNamepx-6 py-3 text-left text-xs font-medium text-gray-500 uppercase Actions /th /tr /thead tbody classNamedivide-y divide-gray-200 tr td classNamepx-6 py-4 whitespace-nowrapJohn Doe/td td classNamepx-6 py-4 whitespace-nowrapjohnexample.com/td td classNamepx-6 py-4 whitespace-nowrap span classNameinline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800 User /span /td td classNamepx-6 py-4 whitespace-nowrap text-sm {canEdit ( button classNametext-blue-600 hover:text-blue-900 mr-4 Edit /button )} {canDelete ( button classNametext-red-600 hover:text-red-900 Delete /button )} /td /tr /tbody /table /div div classNamemt-6 p-4 bg-blue-50 rounded h3 classNamefont-semibold text-blue-800Your Permissions:/h3 ul classNametext-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 classNamemin-h-screen flex items-center justify-center bg-gray-50 div classNamemax-w-md w-full bg-white shadow-lg rounded-lg p-8 text-center div classNamemb-6 div classNamemx-auto w-16 h-16 bg-red-100 rounded-full flex items-center justify-center svg classNamew-8 h-8 text-red-600 fillnone strokecurrentColor viewBox0 0 24 24 path strokeLinecapround strokeLinejoinround strokeWidth{2} dM12 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 classNametext-2xl font-bold text-gray-900 mb-4Access Denied/h1 p classNametext-gray-600 mb-6{message}/p div classNamemb-6 text-sm text-gray-500 p strongYour roles:/strong {auth.user?.roles.join(, ) || None} /p p strongYour permissions:/strong{ } {auth.user?.permissions.join(, ) || None} /p /div div classNamespace-y-3 Link to/dashboard classNameblock 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} classNameblock 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切换全部满足与任一满足两种语义未通过时渲染fallbackinterface 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 h1Dashboard/h1 PermissionGuard roles{[admin]} button classNamebg-red-600 text-white px-4 py-2 rounded Admin Only Button /button /PermissionGuard PermissionGuard permissions{[users:write]} fallback{p classNametext-gray-500You cannot edit users/p} button classNamebg-blue-600 text-white px-4 py-2 rounded Edit Users /button /PermissionGuard PermissionGuard roles{[admin, moderator]} permissions{[content:moderate]} requireAll{true} button classNamebg-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 divYou 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 createRootRouteWithContextRouterProvider注入 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),仅供参考