身份验证
已登录的用户刷新页面,却被送回了登录屏幕。他们的凭证没有问题。应用在完成检查会话是否存在之前就渲染了路由防护,防护代码把缺失的答案理解成了"已登出"。
要把这个问题处理好,首先要精确定义什么是**会话**:一个状态片段,表示某个用户已登录并携带他们的身份信息,所有者在一个地方,这样每个组件读到的答案都一致。本课程用 Supabase 对着一个 CRM 项目从头构建,但结构对所有身份验证提供商都是一样的:Firebase、Auth0、Clerk,或者你自己的后端。本章涵盖这个结构。
会话状态存在于 Provider 中
用户是否已登录在任何地方都很重要:路由器需要它来保护页面,表头需要它来显示谁已登录,表单需要它来知道往哪里发送数据。这正好是上下文存在的理由,标准做法就是那一章讲的 provider 加 hook 的模式:AuthProvider 拥有会话状态,useAuth hook 把它提供给任何需要的组件。
会话状态有三个有意义的值,第三个是大多数人忽略的:
undefined:检查还没完成。应用已加载,正在向提供商询问会话是否存在。null:检查已完成,没有人登录。- 一个会话对象:有人已登录,对象携带他们的身份信息。
第三个值就是解决开头那个刷新跳转问题的关键。会话仍然存在于浏览器存储中,但在第一次渲染后的短暂时间内应用还没有读取它。从 undefined 开始让应用能够渲染一个简短的加载状态,而不是错误的答案。
检查现有会话和监听变化都需要在 React 之外进行操作,所以它们属于效果,该效果在第一次渲染后运行一次:
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 编码,编码是一个格式化步骤而不是加密:
// 一个 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 携带一个值为 authenticated 的 role 声明,而在任何人登录前发起的请求获得 anon 角色。
这些未认证的请求随着一个项目 API 密钥传输,该密钥在你的客户端包中发货:旧版密钥叫做 anon key,本身就是一个 JWT,当前的那个叫做可发布密钥,一个 sb_publishable_... 字符串根本不是一个 token。只有在每个它能到达的表上都启用了行级安全的情况下,发货它才是安全的,下一节涵盖这些策略。没有它们,任何打开开发者工具的人都可以读写这些行。
让服务密钥远离客户端
可发布密钥的对等物、服务角色或秘密密钥会绕过每个策略,所以它绝不能进入客户端包或一个 VITE_ 前缀的环境变量,因为这些会被编译到你发货的包中。
注册、登录、登出
这三个流程共享一个形状:调用提供商的 SDK 方法,让身份验证监听器更新会话状态,然后导航。每个身份验证函数通常存在于与会话状态相同的 provider 文件中,通过上下文值暴露,并返回一个调用组件可以作用的平白结果:
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 的组件只需要检查结果并导航:
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 的工作是保持会话状态并在它改变时重新渲染。
保护路由,以及谁实际执行访问控制
将会话状态存在于上下文中,保护路由章节中的身份验证必需布局路由变成一个三元表达式。所有三个会话值都得到一个分支:
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 的人都可以读到里面的东西,所以把秘密放在里面。
路由防护礼貌地重定向已登出的访问者,但真正的锁在服务器上。
接下来:React 中的 TypeScript,其中 props、状态和组件获得类型。

