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

身份验证

已登录的用户刷新页面,却被送回了登录屏幕。他们的凭证没有问题。应用在完成检查会话是否存在之前就渲染了路由防护,防护代码把缺失的答案理解成了"已登出"。

要把这个问题处理好,首先要精确定义什么是**会话**:一个状态片段,表示某个用户已登录并携带他们的身份信息,所有者在一个地方,这样每个组件读到的答案都一致。本课程用 Supabase 对着一个 CRM 项目从头构建,但结构对所有身份验证提供商都是一样的:Firebase、Auth0、Clerk,或者你自己的后端。本章涵盖这个结构。

会话状态存在于 Provider 中

用户是否已登录在任何地方都很重要:路由器需要它来保护页面,表头需要它来显示谁已登录,表单需要它来知道往哪里发送数据。这正好是上下文存在的理由,标准做法就是那一章讲的 provider 加 hook 的模式:AuthProvider 拥有会话状态,useAuth hook 把它提供给任何需要的组件。

会话状态有三个有意义的值,第三个是大多数人忽略的:

  • undefined:检查还没完成。应用已加载,正在向提供商询问会话是否存在。
  • null:检查已完成,没有人登录。
  • 一个会话对象:有人已登录,对象携带他们的身份信息。

第三个值就是解决开头那个刷新跳转问题的关键。会话仍然存在于浏览器存储中,但在第一次渲染后的短暂时间内应用还没有读取它。从 undefined 开始让应用能够渲染一个简短的加载状态,而不是错误的答案。

检查现有会话和监听变化都需要在 React 之外进行操作,所以它们属于效果,该效果在第一次渲染后运行一次:

jsx
import { createContext, useContext, useState, useEffect } from 'react'
import { auth } from './authClient'

const AuthContext = createContext(null)

export function AuthProvider({ children }) {
  const [session, setSession] = useState(undefined)

  useEffect(() => {
    async function getInitialSession() {
      const { data } = await auth.getSession()
      setSession(data.session) // 会话对象,或者 null
    }
    getInitialSession()

    const {
      data: { subscription },
    } = auth.onAuthStateChange((_event, session) => {
      setSession(session)
    })
    return () => subscription.unsubscribe()
  }, [])

  return <AuthContext value={{ session }}>{children}</AuthContext>
}

export function useAuth() {
  const context = useContext(AuthContext)
  if (context === null) {
    throw new Error('useAuth must be used within an AuthProvider')
  }
  return context
}

这个效果中有两个任务。getSession 做一次性检查:存储中是否已经有来自上一次访问的会话?然后 onAuthStateChange 设置一个监听器,有点像打开一个安全摄像头,每当用户登录或登出时就会触发,并从此之后保持状态最新。具体的方法名称因提供商而异,但每个身份验证 SDK 都提供这两个操作:读一次当前会话,订阅变化。

注意顺序

一个缓慢的 getSession 可能在监听器已经发送了更新的会话之后才 resolve,从而用旧数据覆盖它。许多 SDK 在订阅时会用初始会话触发监听器,在这种情况下仅从监听器设置状态可以避免这个竞态。

JSON Web Token 是什么

一个字符串随着每个请求传输,以证明谁在发起请求。打开已登录应用的开发者工具,从存储中复制那个字符串,然后问持有它的人能看到什么。答案是全部。会话对象背后是一个 JSON Web Token,三个由点分隔的部分:一个命名签名算法的头部、一个关于用户的声明的负载,和一个签名。每个部分都用 base64url 编码,编码是一个格式化步骤而不是加密:

js
// 一个 token 是三个 base64url 段用点连接
const [header, payload, signature] = token.split('.')
// atob 读取标准 base64,所以先交换这两个 URL 安全字符
JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')))
// { sub: '9f2c...', email: '[email protected]', exp: 1789450000 }

浏览器控制台中的两条语句,不需要密钥。所以 JWT 负载对任何持有 token 的人都是可读的,这让一条规则绝对不变:秘密永远不应该在声明中。标准声明是像 sub 表示用户 ID、exp 表示过期时间这样的东西,以及提供商选择包含的任何其他内容。

签名是阅读者无法伪造的部分。提供商用签名密钥对头部和负载进行签名,在每个请求上服务器验证该签名。有些提供商使用一个单一的共享密钥进行签名和验证,这就是为什么该密钥要保留在服务器上;其他的用私钥签名并发布一个匹配的公钥,任何验证者都可以用它来检查。

无论哪种方式,一个有效的签名证明了 token 是在这里颁发的并且从未被改动过。它对谁读过内容没有任何说明。

Token 存在于浏览器中,在本地存储或 cookie 中,客户端库会自动把它附加到每个请求。它存在的位置是一个安全决策。本地存储可以被页面上的任何 JavaScript 读取,所以一个 XSS bug 会导致 token 被偷;httpOnly cookie 对脚本不可见,这阻止了盗窃,但页面上的脚本在运行时仍然可以冒充用户,而且 cookie 会带来需要自己缓解的 CSRF 问题。

在每个请求上携带 token 是使 JWT 身份验证无状态的原因:服务器不保留谁当前登录的列表。签名的 token 本身就是证明,所以用户在每个请求上证明自己的身份而无需重新发送密码。

Supabase 在那个结构之上有一个约定,这个约定是 Supabase 特有的而不是 JWT 规范的一部分:已登录用户的 token 携带一个值为 authenticatedrole 声明,而在任何人登录前发起的请求获得 anon 角色。

这些未认证的请求随着一个项目 API 密钥传输,该密钥在你的客户端包中发货:旧版密钥叫做 anon key,本身就是一个 JWT,当前的那个叫做可发布密钥,一个 sb_publishable_... 字符串根本不是一个 token。只有在每个它能到达的表上都启用了行级安全的情况下,发货它才是安全的,下一节涵盖这些策略。没有它们,任何打开开发者工具的人都可以读写这些行。

让服务密钥远离客户端

可发布密钥的对等物、服务角色或秘密密钥会绕过每个策略,所以它绝不能进入客户端包或一个 VITE_ 前缀的环境变量,因为这些会被编译到你发货的包中。

注册、登录、登出

这三个流程共享一个形状:调用提供商的 SDK 方法,让身份验证监听器更新会话状态,然后导航。每个身份验证函数通常存在于与会话状态相同的 provider 文件中,通过上下文值暴露,并返回一个调用组件可以作用的平白结果:

jsx
async function signIn(email, password) {
  const { data, error } = await auth.signInWithPassword({
    email: email.toLowerCase(),
    password,
  })
  if (error) return { success: false, error: error.message }
  return { success: true, data }
}

成功时提供商颁发一个 JWT,客户端库存储它并构建会话对象,onAuthStateChange 监听器更新状态。调用 signIn 的组件只需要检查结果并导航:

jsx
const { signIn } = useAuth()

async function handleSubmit(email, password) {
  const { success, error } = await signIn(email, password)
  if (success) navigate('/dashboard')
  else setError(error)
}

另外两个流程是变体。注册将新用户的凭证发送到提供商,提供商存储它们,而且对大多数提供商来说会同时将用户登录,所以相同的导航逻辑适用。登出调用 SDK 的登出方法,该方法清除存储的 token;监听器看到变化,将会话设置为 null,应用导航回一个公开页面。

流程中没有什么是 React 特定的。React 的工作是保持会话状态并在它改变时重新渲染。

保护路由,以及谁实际执行访问控制

将会话状态存在于上下文中,保护路由章节中的身份验证必需布局路由变成一个三元表达式。所有三个会话值都得到一个分支:

jsx
function AuthRequired() {
  const { session } = useAuth()

  if (session === undefined) return <p>Loading...</p>
  return session ? <Outlet /> : <Navigate to="/signin" replace />
}

在它下面嵌套私有页面,未认证的访问者会在任何页面渲染前被重定向。那一章指出了客户端防护是一个用户体验特性;这是另一半。真实的访问控制必须存在于服务器上,用户无法到达的地方。

本课程的服务器端强制执行示例是行级安全,一个数据库特性,其中访问策略附加到表本身。启用它,一切默认被拒绝;然后策略根据请求的 JWT 中的声明(如 role = 'authenticated' 或一行的所有者列与 token 的 sub 匹配)授予访问权。该策略在数据库内对每个查询都被执行,所以即使对于跳过你的 React 应用的请求也成立。

它允许的正好是策略说的东西,一个只携带可发布密钥的请求是一个合法请求,所以一个写成永真保护什么都没有的策略:规则必须命名它检查的声明。路由防护和策略作为一对工作,策略是承重的那一半。

无状态设计是一个有两方面的权衡。服务器在每个请求上跳过会话查询,这是赢。代价是吊销:由于不存在活跃会话的列表,一个被盗或已登出的 token 在过期前在密码学上保持有效。

提供商用短生命周期的访问 token 配对长生命周期的刷新 token 来管理这个问题。客户端库隐藏了这个过程:getSession 检查存储的 token 的过期时间,当发现一个过期的 token 时它悄悄地用刷新 token 交换一个新的访问 token,只在该交换失败时表面 null。即时吊销需要重新引入服务器端状态,一个在每个请求上检查的拒绝列表,这就是为什么短期有效期是默认答案。

前面的两个 token 危害有一个更清晰的框架。签名保证了完整性同时保留了机密性:验证者了解到 token 是未改动的并对谁还读过它没有学到任何东西。

在存储上,客户端 SDK 通常默认为本地存储并依赖短访问 token 的有效期来限制损害,这是一个赌注,即被盗的 token 在有价值前会过期。迁移到 httpOnly cookie 重新定位了问题而不是移除它,因为 CSRF 缓解然后变成你的工作来正确完成。

执行规则推广超越数据库。行级安全是一个实现;原则是每个信任决策必须发生在攻击者无法写入的地方。无论客户端发送什么,包括 JWT 本身,都是攻击者可控的输入直到服务器验证签名。那个验证是单一时刻不可信的输入变成可信的身份,这就是为什么签名密钥是皇冠宝石:无论谁持有它都可以为任何用户铸造有效的 token。

Juno会话是状态,服务器是法则 登录给了你的应用一个会话,那个会话是 React 状态,保持在一个上下文 provider 中,所以每个组件都可以问"有人登录了吗?"它有三个答案:仍在检查、已登出、已登入,那个"仍在检查"的答案让用户在应用查找现有会话时不被弹到登录页面。

在这一切背后是一个随着每个请求传输的签名 token,以证明谁在发起请求,任何持有那个 token 的人都可以读到里面的东西,所以把秘密放在里面。

路由防护礼貌地重定向已登出的访问者,但真正的锁在服务器上。

Juno会话是状态,服务器是法则useAuth hook 构建一个 AuthProvider,完全像一个主题 provider。将会话初始化为 undefined,然后在一个效果中调用 getSession 来处理刷新的情况,订阅身份验证变化来处理之后的一切。

登录、注册和登出都遵循一个形状:调用 SDK,让监听器更新状态,成功时导航。

将会话连接到一个受保护的路由,带有加载分支,把真实的访问规则放在服务器端,使用像行级安全这样的策略,在每个查询上检查 JWT。

Juno会话是状态,服务器是法则 JWT 身份验证用无状态换来了吊销:签名的 token 就是会话,所以没有服务器端拒绝列表就没有什么能让它提前过期,短期有效的访问 token 加刷新轮换是标准缓解。负载对任何持有 token 的人都可读,所以签名保证完整性而秘密保持在声明之外,你存储 token 的地方决定了 XSS 还是 CSRF 是你的问题。

把客户端发送的所有东西视为不可信的,直到签名验证,在攻击者无法写入的地方执行授权:服务器。

接下来:React 中的 TypeScript,其中 props、状态和组件获得类型。