Skip to content
This page has been auto-translated and may contain errors.View in English

受保护的路由

应用的某些部分只应该对已登录用户可见:一个仪表板、账户页面,或者任何获取个人数据的地方。React Router 没有专门的"受保护路由"功能,也不需要。来自路由嵌套路由的概念组合成一个模式:一个布局路由,检查用户是否已登录,然后渲染它的子元素或重定向到登录页。

在写任何代码之前要明确一点。在客户端守护路由是一个用户体验功能。它防止未登录的访客进入那些会获取数据并显示损坏、空白屏幕的页面。这永远不是安全保护,因为客户端包中的所有东西都是可以检查和绕过的。

真正的保护发生在服务器上,服务器必须拒绝向未经身份验证的请求交付数据。身份验证涵盖该强制执行的工作方式;本章讨论的是客户端部分。

auth-required 布局路由

一个布局路由是一个父路由,它的元素渲染共享的 UI 加上一个用于子元素的 Outlet。一个无路径布局路由根本不添加 URL 段;它纯粹是为了包装而存在。这使它成为登录检查的完美之地:用一个无路径布局路由包装你想保护的每个路由,它的唯一工作是决定 Outlet 是否渲染。

jsx
<Route path="/" element={<Layout />}>
  <Route index element={<Home />} />
  <Route path="login" element={<Login />} />

  <Route element={<AuthRequired />}>
    <Route path="host" element={<Dashboard />} />
    <Route path="host/vans" element={<HostVans />} />
    <Route path="host/vans/:id" element={<HostVanDetail />} />
  </Route>
</Route>

所有嵌套在 <Route element={<AuthRequired />}> 内的内容现在都在守护之后,包括带参数的路由,比如 host/vans/:id。稍后保护一个新页面只需一行:把它的路由移到包装器内。

守护本身有两个分支。如果用户已登录,渲染 Outlet 使匹配的子元素出现。如果没有,渲染一些东西把他们送到登录页。不渲染任何东西就是全部诀窍:如果一个受保护的组件永远不渲染,里面的任何数据获取永远不会启动。

jsx
import { Outlet, Navigate } from 'react-router-dom'

export default function AuthRequired() {
  const authenticated = false // 实际会话检查的占位符

  if (!authenticated) {
    return <Navigate to="/login" />
  }
  return <Outlet />
}

这个课程用一个布尔值伪造检查,后来用 localStorage 中的值,以保持重点在路由上。真正的检查读取会话状态,通常通过上下文传递;同样,身份验证涵盖该状态实际来自哪里。

版本说明

这里的例子从 react-router-dom 导入,在 v6 和 v7 中都有效;路由涵盖 v7 对包的改变。本节课程全程教授 v6。

Link 渲染一个锚点并等待被点击。Navigate 跳过等待:一旦它渲染,路由就把用户移动到它的 to 路径。这使它成为渲染逻辑的重定向工具。

你的组件在渲染中决定用户不应该在这里,并返回 <Navigate to="/login" /> 而不是任何 UI。还有一个 useNavigate hook 返回一个用于从事件处理程序重定向的函数,这就是登录表单本身在成功提交后发送用户继续的方式。

登陆一个空白登录页而没有解释会令人困惑,所以守护应该说明为什么用户最终出现在这里。Navigate 接受与 Link 相同的 state prop,所以重定向可以给目标一个原因。

jsx
if (!authenticated) {
  return (
    <Navigate
      to="/login"
      state={{ message: '你必须先登录' }}
    />
  )
}
jsx
import { useLocation } from 'react-router-dom'

function Login() {
  const location = useLocation()

  return (
    <>
      {location.state?.message && <h3>{location.state.message}</h3>}
      <h1>登录你的账户</h1>
      {/* 表单... */}
    </>
  )
}

可选链接很重要,因为直接点击到登录页的人从不通过守护,发现 location.state 设置为 null

历史栈和 replace

到目前为止的模式有一个你能感觉到的 bug。访问一个受保护的页面而未登录,被重定向,登录,然后按浏览器的后退按钮。与你在这一切开始前所在的页面不同,你登陆了登录页面,完整地显示它的"你必须先登录"消息。

浏览器维护一个历史栈:每次导航推送一个新项,后退弹出到前一个。重定向在受保护页面之后推送了 /login 到栈,登录表单自己的导航在其上方推送了受保护页面。后退直接走入剩余物。

修复是使重定向替换当前项而不是推送新的。在 Navigate 组件上是 replace prop;在 useNavigate 函数上是一个选项。

jsx
// 在 AuthRequired 中:重定向为被阻挡的页面交换自己
return <Navigate to="/login" state={{ message: '你必须先登录' }} replace />

// 在 Login 中,在成功提交后
navigate('/host', { replace: true })

两者都到位后,登录迂回永远不会在历史中存活。从受保护页面后退现在去往用户之前所在的任何地方,登录页面无法通过意外重新访问。一个好的经验法则:任何用户没有要求的导航,这就是重定向,应该替换

回到他们要去的地方

一个粗糙的边缘仍然存在。登录表单硬编码 navigate('/host', ...),所以试图到达 /host/vans/2 的用户在登录后被倾倒在 /host。守护知道被阻挡的 URL,因为 AuthRequired 内的 useLocation 描述了用户试图渲染的页面。在相同的导航状态中沿着传递它。

jsx
export default function AuthRequired() {
  const location = useLocation()
  const authenticated = false

  if (!authenticated) {
    return (
      <Navigate
        to="/login"
        state={{
          message: '你必须先登录',
          from: location.pathname + location.search + location.hash,
        }}
        replace
      />
    )
  }
  return <Outlet />
}

pathname 单独不是完整的地址:在 /host/vans?type=luxury 被阻挡的用户试图去往过滤的列表,丢掉查询字符串会剥去过滤。添加 searchhash 再现路由跟踪的 URL 的每个部分。登录页面读取值回,对于直接来到登录页并因此没有状态的人有一个回退。

jsx
function Login() {
  const location = useLocation()
  const navigate = useNavigate()
  const from = location.state?.from || '/host'

  function handleLogin() {
    // ...成功时:
    navigate(from, { replace: true })
  }
  // ...
}

现在一个任何受保护页面的共享链接存活登录迂回:被阻挡,重定向,登录,并传递到确切的 URL 他们想要。

const authenticated = false 占位符隐藏了这个模式在真实应用中遇到的第一个 bug。会话状态异步解决:在第一次渲染时应用已经问了认证提供者是否存在会话并还没有听到回音。一个双路守护读取那个沉默作为"已登出"并重定向一个已登入的用户,所以刷新受保护页面会短暂地把他们送到登录屏幕,然后应用纠正自己。反而建模三种状态,这与身份验证在提供者侧建立的形状相同。

jsx
import { Outlet, Navigate, useLocation } from 'react-router-dom'
import { useAuth } from './AuthProvider'

export default function AuthRequired() {
  const { session } = useAuth() // 在解决时 undefined,在登出时 null
  const location = useLocation()

  if (session === undefined) {
    return <p>检查你的会话中...</p>
  }
  if (session === null) {
    return (
      <Navigate
        to="/login"
        state={{
          message: '你必须先登录',
          from: location.pathname + location.search + location.hash,
        }}
        replace
      />
    )
  }
  return <Outlet />
}

useAuth 是一个自定义 hook 在上下文提供者上,来自上下文的模式:提供者拥有会话状态并且每个组件读取相同的答案。三个值就是全部重点。undefined 意味着检查仍在进行中,null 意味着它返回为空,会话对象意味着有人已登入。

加载分支是人们省去的那个,它是守护在刷新后存活的和反弹已登入用户的区别。保持那个分支轻,因为它在每个受保护页面的第一次绘制上渲染,并给它足够的呈现使屏幕在检查运行时看起来不破坏。

Navigate 是一个声明式重定向:渲染它就是指令。在引擎盖下,它在渲染提交后从一个 effect 调用路由的导航函数,因为导航是一个副作用并且一个组件在它渲染时无法引起一个。提前返回它也短路了整个子树,这是在布局级别守护的真正收获:从不挂载的组件从不运行他们的 effect,所以他们的获取从不启动。守护坐在一个单一窒息点而不是被撒在每个受保护的组件中作为每页检查。

导航状态乘坐浏览器的历史项,带有路由参数作用通过的所有后果。replace 直接映射到 history.replaceState 语义:项被覆盖,所以重定向不留任何痕迹可走进。

写成组件的守护必须在它能决定任何东西之前渲染,所以未解决的会话最好买一个加载状态。React Router 的数据 API 更早地移动决定:一个加载器在路由的元素渲染前运行,从它抛出 redirect 意味着受保护的组件根本不被创建。

jsx
import { redirect } from 'react-router-dom'

async function hostLoader({ request }) {
  const session = await getSession()
  if (!session) {
    const url = new URL(request.url)
    const from = url.pathname + url.search + url.hash
    throw redirect(`/login?from=${encodeURIComponent(from)}`)
  }
  return null
}

那个版本在查询字符串中携带来源,移动带来一个后果要在它发送前捕获。历史状态由你自己的守护写入;查询字符串由发送链接的任何人输入。

验证 from 值在导航前

/login?from=https://phish.example/host 是任何人都可以发布的有效 URL,登录处理程序读取 from 并将其交给 navigate 会在用户认证的瞬间将用户传递到非现场,已看他们在你的真实登录页上输入密码。仅当它以单个 / 开始时才接受该值,这拒绝绝对 URL 和协议相对 //host 形式,浏览器将其视为非现场。

jsx
const raw = searchParams.get('from') || ''
const from = /^\/(?!\/)/.test(raw) ? raw : '/host'
Juno整个分支的一个守门人 与其在每个私人页面内检查登录,你用一个守门人组件包装所有那些路由。如果用户已登入,它渲染 Outlet 页面正常显示。如果他们没有,它渲染 Navigate,一旦它出现就把他们迅速带到登录页。

添加 replace 将重定向从后退按钮的记忆中保留出去,沿着传递他们去往的地方让登录页在之后将他们直接送回那里。

记住这对用户的友好;服务器仍然必须自己保护数据。

Juno整个分支的一个守门人 用一个无路径布局路由包装受保护的路由,它的元素从 useAuth hook 读取会话并分支三种方式:在会话为 undefined 时的加载线,在它为 nullNavigate to="/login",会话对象到达后 Outlet。跳过那第一个分支就是反弹已登入用户到登录页的原因。

给重定向一个 state 对象携带消息加上 location.pathname + location.search + location.hash 作为 from,标记它 replace,在登录处理程序中用一个明智的回退调用 navigate(from, { replace: true })。保持服务器在每个请求上执行访问强制,不论如何。

Juno整个分支的一个守门人 在布局级别守护短路子树在它挂载前,所以受保护的组件永远不渲染并且他们的获取永远不启动。导航状态生活在历史项本身,replacehistory.replaceState 在路由服装中:重定向应该总是替换使迂回不留下项。

组件守护仍然必须渲染一次才能决定,所以在数据路由器设置中检查移入一个加载器抛出 redirect 在元素存在前。一旦来源 URL 在查询字符串中旅行它是攻击者提供,所以在导航到它之前针对前导单个斜线验证它。

没有什么是强制执行;服务器拥有那个。

接下来:React 如何渲染,在每个后续性能决定之后的心智模型。