mcpField
A
auth-and-guards

auth-and-guards

github.com/tanstack开发15.3k207 次安装4 天前更新
代码仓库

关于 auth-and-guards

面向 TanStack Router 开发者,讲解如何用路由守卫控制导航体验,并通过路由上下文传递认证状态。涵盖登录重定向、角色与权限检查,并强调私有数据仍须在服务器端接口验证。

主要功能

  1. 01用 beforeLoad 保护路由布局
  2. 02通过路由上下文传递认证状态
  3. 03登录后返回原页面
  4. 04按角色或权限控制访问
  5. 05处理认证失败与重定向
  6. 06区分路由守卫和接口认证

适用场景

  1. 01未登录时将受保护路由重定向到登录页
  2. 02根据角色限制管理页面访问
  3. 03按权限限制特定页面操作
  4. 04避免将路由守卫误作服务器数据保护

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/tanstack/router --skill auth-and-guards

安装后在 Claude Code 中输入/auth-and-guards确认已加载。

详细介绍

身份验证与路由守卫

此技能涵盖身份验证的路由侧。 路由守卫负责用户体验和导航控制;数据/API 边界仍应由读取或修改私有数据的服务器函数、服务器路由或 API 端点负责。有关服务器端原语——会话 Cookie(HttpOnly/Secure/SameSite)、外部会话辅助工具、OAuth state + PKCE、密码重置枚举防护、CSRF、速率限制——请参阅 @tanstack/start-client-core#start-core/auth-server-primitives。

重要: 路由守卫(beforeLoad)不会保护在该路由中声明的 createServerFn。服务器函数是可独立访问的 API 端点,不受调用它的路由限制。请参阅下文“路由守卫不会保护服务器函数”。

设置

在无路径布局路由(_authenticated)中使用 beforeLoad + redirect() 来保护路由:

// src/routes/_authenticated.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated')({
  beforeLoad: ({ context, location }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({
        to: '/login',
        search: {
          redirect: location.href,
        },
      })
    }
  },
  // component defaults to Outlet — no need to declare it
})

放在 src/routes/_authenticated/ 下的任何路由文件都会自动受到保护:

// src/routes/_authenticated/dashboard.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/dashboard')({
  component: DashboardComponent,
})

function DashboardComponent() {
  const { auth } = Route.useRouteContext()
  return <div>Welcome, {auth.user?.username}</div>
}

核心模式

用于身份验证状态的路由器上下文

身份验证状态通过 createRootRouteWithContext 和 RouterProvider 的 context 属性传入路由器:

// src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'

interface AuthState {
  isAuthenticated: boolean
  user: { id: string; username: string; email: string } | null
  login: (username: string, password: string) => Promise<void>
  logout: () => void
}

interface MyRouterContext {
  auth: AuthState
}

export const Route = createRootRouteWithContext<MyRouterContext>()({
  component: () => <Outlet />,
})
// src/router.tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'

export const router = createRouter({
  routeTree,
  context: {
    auth: undefined!, // placeholder — filled by RouterProvider context prop
  },
})

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}
// src/App.tsx
import { RouterProvider } from '@tanstack/react-router'
import { AuthProvider, useAuth } from './auth'
import { router } from './router'

function InnerApp() {
  const auth = useAuth()
  // context prop injects live auth state WITHOUT recreating the router
  return <RouterProvider router={router} context={{ auth }} />
}

function App() {
  return (
    <AuthProvider>
      <InnerApp />
    </AuthProvider>
  )
}

路由器只使用占位值创建一次。RouterProvider 的 context 属性会在每次渲染时注入实时身份验证状态,从而避免在身份验证状态变化时重新创建路由器(否则会重置缓存并重建路由树)。

基于重定向且支持返回原页面的身份验证

将当前位置保存在搜索参数中,以便登录后重定向回原页面:

// src/routes/_authenticated.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated')({
  beforeLoad: ({ context, location }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({
        to: '/login',
        search: { redirect: location.href },
      })
    }
  },
})
// src/routes/login.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
import { useState, type FormEvent } from 'react'

// Validate redirect target to prevent open redirect attacks
function sanitizeRedirect(url: unknown): string {
  if (typeof url !== 'string' || !url.startsWith('/') || url.startsWith('//')) {
    return '/'
  }
  return url
}

export const Route = createFileRoute('/login')({
  validateSearch: (search) => ({
    redirect: sanitizeRedirect(search.redirect),
  }),
  beforeLoad: ({ context, search }) => {
    if (context.auth.isAuthenticated) {
      throw redirect({ to: search.redirect })
    }
  },
  component: LoginComponent,
})

function LoginComponent() {
  const { auth } = Route.useRouteContext()
  const search = Route.useSearch()
  const navigate = Route.useNavigate()
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault()
    try {
      await auth.login(username, password)
      navigate({ to: search.redirect })
    } catch {
      setError('Invalid credentials')
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      {error && <div>{error}</div>}
      <input value={username} onChange={(e) => setUsername(e.target.value)} />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit">Sign In</button>
    </form>
  )
}
非重定向身份验证(内嵌登录)

不进行重定向,而是在 Outlet 的位置显示登录表单:

// src/routes/_authenticated.tsx
import { createFileRoute, Outlet } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated')({
  component: AuthenticatedLayout,
})

function AuthenticatedLayout() {
  const { auth } = Route.useRouteContext()

  if (!auth.isAuthenticated) {
    return <LoginForm />
  }

  return <Outlet />
}

这样 URL 保持不变——用户仍停留在同一页面,看到的是登录表单,而不是受保护的内容。身份验证完成后,<Outlet /> 会渲染,子路由也会显示。

基于角色和权限的 RBAC

为身份验证状态扩展角色/权限辅助方法,然后在 beforeLoad 中进行检查:

// src/auth.tsx
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
}

仅限管理员访问的布局路由:

// src/routes/_authenticated/_admin.tsx
import { createFileRoute, redirect } 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 },
      })
    }
  },
})

多角色访问:

// src/routes/_authenticated/_moderator.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/_moderator')({
  beforeLoad: ({ context, location }) => {
    if (!context.auth.hasAnyRole(['admin', 'moderator'])) {
      throw redirect({
        to: '/unauthorized',
        search: { redirect: location.href },
      })
    }
  },
})

基于权限的访问:

// src/routes/_authenticated/_users.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/_users')({
  beforeLoad: ({ context, location }) => {
    if (!context.auth.hasAnyPermission(['users:read', 'users:write'])) {
      throw redirect({
        to: '/unauthorized',
        search: { redirect: location.href },
      })
    }
  },
})

页面级权限检查(嵌套在已受角色保护的布局下):

// src/routes/_authenticated/_users/manage.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/_users/manage')({
  beforeLoad: ({ context }) => {
    if (!context.auth.hasPermission('users:write')) {
      throw new Error('Write permission required')
    }
  },
  component: UserManagement,
})

function UserManagement() {
  const { auth } = Route.useRouteContext()
  const canDelete = auth.hasPermission('users:delete')

  return (
    <div>
      <h1>User Management</h1>
      {canDelete && <button>Delete User</button>}
    </div>
  )
}
处理身份验证检查失败(isRedirect)

beforeLoad 中有 try/catch 时,重定向(通过抛出异常实现)可能会被吞掉。使用 isRedirect 重新抛出:

import { createFileRoute, redirect, isRedirect } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated')({
  beforeLoad: async ({ context, location }) => {
    try {
      const user = await verifySession(context.auth)
      if (!user) {
        throw redirect({
          to: '/login',
          search: { redirect: location.href },
        })
      }
      return { user }
    } catch (error) {
      if (isRedirect(error)) throw error // re-throw redirect, don't swallow it
      // Actual error — redirect to login
      throw redirect({
        to: '/login',
        search: { redirect: location.href },
      })
    }
  },
})

常见错误

重要:路由守卫不会保护服务器函数

beforeLoad 重定向保护的是路由 UI,而不是在其中声明的服务器函数。createServerFn 会生成 RPC 端点,可通过其声明的 HTTP 方法直接访问,无论哪个路由渲染了调用它的 UI。攻击者不必加载 /_authenticated/orders——他们可以直接调用这个 GET RPC 端点。

// WRONG — handler has no auth check; the route guard doesn't help
import { createServerFn } from '@tanstack/react-start'
import { createFileRoute, redirect } from '@tanstack/react-router'

const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
  return db.orders.findMany() // ← anyone can hit the RPC
})

export const Route = createFileRoute('/_authenticated/orders')({
  beforeLoad: ({ context }) => {
    if (!context.auth.isAuthenticated) throw redirect({ to: '/login' })
  },
  loader: () => getMyOrders(),
})
// CORRECT — auth enforced on the handler itself, via middleware
import { createServerFn } from '@tanstack/react-start'
import { authMiddleware } from '~/server/auth-middleware'

const getMyOrders = createServerFn({ method: 'GET' })
  .middleware([authMiddleware])
  .handler(async ({ context }) => {
    return db.orders.findMany({ where: { userId: context.session.userId } })
  })

经验法则:每个会访问用户数据的 createServerFn、服务器路由或 API 端点都需要使用 authMiddleware(或在处理程序中进行等效检查)。路由守卫负责页面体验;端点守卫负责数据。完整的会话/中间件模式请参阅 @tanstack/start-client-core#start-core/auth-server-primitives。

重要:匿名用户访问的目标页面仍可能泄露受保护数据

保护整个匿名响应,而不只是 API 调用。如果公开的登录页或未授权页面的标题、文案、搜索参数或序列化的加载器状态中提及受保护用户、租户、记录或资源,仍会泄露数据。测试直接匿名请求并跟随重定向。确认处理程序在读取私有数据前就拒绝请求、不会运行任何受保护的加载器、最终 HTML 和序列化状态中不包含受保护身份,并且重定向只包含经过净化的相对返回 URL。

高优先级:在组件中而不是 beforeLoad 中检查身份验证

在组件级别检查身份验证会导致重定向前短暂显示受保护内容:

// WRONG — protected content renders briefly before redirect
export const Route = createFileRoute('/_authenticated/dashboard')({
  component: () => {
    const auth = useAuth()
    if (!auth.isAuthenticated) return <Navigate to="/login" />
    return <Dashboard />
  },
})

// CORRECT — beforeLoad runs before any rendering
export const Route = createFileRoute('/_authenticated/dashboard')({
  beforeLoad: ({ context }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({ to: '/login' })
    }
  },
  component: Dashboard,
})
高优先级:不要在 try/catch 中重新抛出重定向

redirect() 的工作方式是抛出异常。如果 beforeLoad 中有 try/catch,重定向就会被吞掉:

// WRONG — redirect is caught and swallowed
beforeLoad: async ({ context }) => {
  try {
    await validateSession(context.auth)
  } catch (e) {
    console.error(e) // swallows the redirect!
  }
}

// CORRECT — use isRedirect to distinguish intentional redirects from errors
import { isRedirect } from '@tanstack/react-router'

beforeLoad: async ({ context }) => {
  try {
    await validateSession(context.auth)
  } catch (e) {
    if (isRedirect(e)) throw e
    console.error(e)
  }
}
中优先级:有条件地渲染根路由组件

无论身份验证状态如何,根路由都会始终渲染。你无法有条件地渲染其组件:

// WRONG — root route always renders, this doesn't protect anything
export const Route = createRootRoute({
  component: () => {
    if (!isAuthenticated()) return <Login />
    return <Outlet />
  },
})

// CORRECT — use a pathless layout route for auth boundaries
// src/routes/_authenticated.tsx
export const Route = createFileRoute('/_authenticated')({
  beforeLoad: ({ context }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({ to: '/login' })
    }
  },
})

将受保护的路由设为 _authenticated 布局路由的子路由。公开路由(登录、主页等)则放在其外部。

交叉引用

  • 另请参阅:router-core/data-loading/SKILL.md — beforeLoad 在 loader 之前运行;身份验证上下文通过路由上下文传递给 loader
  • 另请参阅:start-core/auth-server-primitives/SKILL.md — 服务端会话 Cookie、OAuth state + PKCE、CSRF、密码重置强化、防速率限制(身份验证的服务端部分)
  • 另请参阅:start-core/middleware/SKILL.md — 用于保护单个 createServerFn 调用的 authMiddleware 工厂模式

评论

支持 Markdown