Next.js 中的认证与会话
本文档面向从后端获取会话(令牌或会话 ID)并需要存储、续期和验证会话的前端应用。如果项目使用了某个库或提供商(Auth.js、Better Auth、Clerk 等),会话机制由其接管,但第 2、6–10 节的规则仍然适用。代码示例请参考 references/implementation.md。
规则优先级。 用户指令和项目 agent 文件优先级更高。如果后端有不同要求(例如,自行设置 cookie),则遵循后端,并将差异记录在 README 或 ADR 中。
标注说明:MUST — 必须遵守,不可违反;SHOULD — 默认情况下应遵守,如有偏离需说明理由。
1. 模型选择
| 模型 | 适用场景 |
|---|---|
| BFF: Next Route Handlers 从后端获取令牌并存入 HttpOnly Cookie | 后端在响应体中返回令牌。Next 项目的默认选项 |
| 后端自行设置 HttpOnly Cookie | 前端和 API 位于同一站点,后端由你控制 |
| 库或提供商 | 需要 OAuth、MFA、用户管理——不要自行实现 |
| Access 令牌在内存中,Refresh 令牌在 HttpOnly Cookie 中 | 没有 BFF 的 SPA |
令牌存储在 localStorage/sessionStorage | 禁止: 任何 XSS 攻击都会泄露会话 |
第二个选择是 BFF 代理哪些请求:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 仅代理 login / refresh / logout,其余请求自带 cookie 直接访问 API | BFF 不是瓶颈;代码量更少 | API 需要从 cookie 中读取令牌并配置 CORS;cookie 必须能送达 API 的域名 |
| 所有请求都通过 BFF | API 无需关心 cookie 和 CORS;单一源 | 每个请求都多一次跳转;BFF 需要扩展 |
选择结果需记录在 ADR 中。
2. Cookie(MUST)
- 所有能提供访问权限的令牌(access、refresh、会话 ID)都必须设置
HttpOnly。 - 生产环境必须设置
Secure。本地开发时,浏览器允许在http://localhost上使用Secure,但在其他 http 主机上不允许。 - 默认使用
SameSite=Lax。Strict对 CSRF 防护更强,但用户通过邮件或外部网站链接访问时,首次点击会显示未认证状态——需有意识地选择并记录原因。 - 设置
Path=/;过期时间取自令牌的exp字段或后端响应,不可随意设定。 - SHOULD:在 cookie 名称中使用
__Host-前缀(要求Secure、Path=/、不含Domain)——防止从子域覆盖 cookie。 - Cookie 的设置和删除只能由服务端代码(Route Handler、Server Action、
proxy.ts)完成。Server Component 不能设置 cookie。 - 仅能通过 JS 读取的 cookie 不是会话载体。像
is_authenticated这样的标志(不含HttpOnly)只能作为 UI 提示,绝不能用于访问决策。
3. BFF:Route Handlers(MUST)
- 登录接口必须使用 schema(
zod)验证请求体;格式不正确则返回400。 - 修改数据的处理程序必须检查
Origin头是否等于应用的 origin:SameSite=Lax无法防御来自同一站点子域的请求。Server Actions 会自行比较Origin与Host。 - 客户端的上下文应传递给后端:
X-Forwarded-For、User-Agent——否则后端的速率限制只能看到服务器的 IP。 - 后端错误应转换为带有
kind和状态码(401、403、422、429带Retry-After、503)的响应,不要将后端的原始错误文本暴露出去。 - 刷新令牌: 如果后端拒绝了 refresh 令牌——删除 cookie 并返回
401。如果后端不可用(网络、5xx)——不要动 cookie,返回503:临时故障不应导致用户登出。 - 登出: 在后端撤销令牌(记录日志但不展示错误),无论如何都要删除 cookie。
4. 会话校验位置(MUST)
| 位置 | 职责 | 不做什么 |
|---|---|---|
proxy.ts(Next 16+,旧版为 middleware.ts) | 乐观性检查:根据是否存在 cookie 跳转到登录页或主页 | 不查询数据库或 API;不是唯一防护 |
数据访问层(server-only 模块中的 verifySession()) | 在数据附近进行校验:Server Components、Route Handlers、Server Actions | — |
| 布局 | 不做任何事:布局在导航时不会重新渲染 | 不检查访问权限 |
| 后端 | 真相来源:在每次请求中验证令牌 | — |
- 每个 Route Handler 和 Server Action 都是公共入口点,必须自行调用
verifySession()。 proxy.ts检查的是 refresh cookie(或会话 ID):access 令牌可能已过期,但会话仍有效。- 路径匹配按段进行:
/app和/app/...都匹配,但/apple不匹配。 - 如果启用了
cacheComponents(Next 16.4 及之后项目的默认行为),会话读取和用户数据缓存有各自的规则:在编写 DAL 之前,请阅读node_modules/next/dist/docs/01-app/02-guides/authentication-with-cache-components.md。 - 权限(角色、归属)必须基于已校验的数据:通过签名(JWKS、
jose)或后端响应。未经验证的 JWT payload 不能作为访问依据。 - Server Components 无法更新 cookie。如果在服务端渲染时 access 令牌已过期:要么由
proxy.ts更新会话(它可以在响应中设置 cookie——但仅限文档导航,不适用于 prefetch,并需考虑 refresh 令牌轮换),要么页面渲染客户端组件进行加载和刷新。
5. 客户端:无竞态刷新(MUST)
- “未认证”的判定: HTTP
401,或者在 GraphQL 响应(状态码为200)中,至少有一个错误的extensions.code === "UNAUTHENTICATED"(使用errors.some,而非errors[0])。如果后端不返回代码,只能与消息文本比较——且必须在单一函数中,附注释和测试。 - 单次请求(Single-flight): 并发的多个请求收到 401 后,应等待一个共享的 refresh 请求,并使用其结果。Promise 在
finally中重置。 - 原始请求只重试一次。如果重试后仍返回 401,则会话已失效,不再进行第二次刷新。
- refresh 请求本身不应经过拦截器(否则会导致递归)。
- refresh 的结果必须区分三种情况:成功(
true)→ 重试请求;被拒(false)→ 会话过期;网络或 5xx(异常)→network错误,不清除用户登录状态。 - 登出期间应阻止 refresh:否则,在登出瞬间失败的请求可能会立即恢复会话。
- 会话过期后:清理请求缓存,跳转到登录页,并携带当前页面的
callbackUrl。 - 被重试的请求体必须是可重复读取的(字符串、
FormData),不能是一次性的ReadableStream。
6. 登录后重定向(MUST)
callbackUrl(next、returnTo)必须通过getSafeRedirect(raw, fallback, appOrigin)函数校验:拒绝\和控制字符(浏览器会将/\.evildomain.com和/\t/evildomain.com转换为//evildomain.com),使用new URL(raw, appOrigin)解析,比较 origin 并只返回路径部分。仅检查“以/开头且非//”是不够的。- 相应的测试集见
references/implementation.md第 7 节。 - 登录后,服务器组件必须能读取到新的 cookie:使用全页面导航(
window.location.assign)或router.replace加router.refresh()。
7. 登出(MUST)
- 服务端删除 cookie(第 3 节)。
- 客户端清理请求缓存(
queryClient.clear())和所有客户端上存储的用户数据:下一个用户在同一标签页中不应看到任何前任的数据,哪怕是一瞬间。 - 导航到登录页,且不能通过“回退”按钮回到缓存的受保护页面(使用
replace或全页面导航)。 - 登出确认是可选的(根据产品需求);但请求期间按钮必须禁用。
- SHOULD:其他标签页应能得知登出事件(通过
BroadcastChannel或基于storage事件的标志),否则它们会继续展示数据直到遇到第一个 401。
8. 链接中的令牌(密码重置、邮箱确认)
- 令牌从 URL 中读取一次并存入状态,然后移除令牌后的地址(
router.replace(pathname)):令牌不会留在浏览器历史中,也不会通过Referer泄露。 - 这些页面应设置
Referrer-Policy: no-referrer,且不加载任何第三方脚本(分析、插件)。 - 令牌不应被记录到日志。令牌的一次性和有效期由后端保证;前端只需展示清晰的“链接已过期”界面,并提供“请求新链接”的操作。
9. 登录和密码重置表单
- 密码重置接口对已存在和不存在的邮箱应返回相同的响应(“如果账户存在,邮件已发送”),不得透露哪些邮箱已注册。如果后端泄露了此信息,应将其记录为限制。
- 请求发送期间应禁用按钮,防止重复提交。
- 遇到
429状态:如果存在Retry-After头,则显示等待时间信息。 - 输入字段:邮箱使用
type="email"和autoComplete="email";密码在登录时使用autoComplete="current-password",在注册和修改密码时使用"new-password";不应阻止粘贴。 - “显示密码”按钮应使用
type="button",带有aria-label和aria-pressed,且不应从密码字段夺走焦点。 - “邮箱或密码错误”这样的提示不应指出具体哪一项错误。
- 服务端错误应根据其代码映射到对应字段;通用的错误应显示在表单顶部。
10. 数据与日志
- 依赖于用户身份的响应不应进入公共缓存(
"use cache"、CDN)。每次新的服务端请求都应该使用新的 QueryClient。 - 日志中不得包含令牌、密码或邮箱。最多只能记录用户 ID 和错误类型
kind。 - 会话令牌的 payload 应保持最小:只包含 ID 和角色,不含个人数据。
11. 测试
- 单元测试:
getSafeRedirect(完整的测试集来自 references);“未认证”判定(401、GraphQL 错误码、空响应体、返回 HTML 而非 JSON 的情况)。 - 单次请求测试: N 个并发请求返回 401 → 只进行一次 refresh,N 次重试;refresh 被拒 → 无重试,
onSessionExpired调用一次;refresh 网络失败 →network错误,不登出;成功 refresh 后再次遇到 401 → 不进行第二次 refresh;登出期间 → 不发起 refresh。 - Route Handlers 测试: cookie 的标志位;refresh 被拒 → cookie 被删除;后端不可用 → cookie 保留,返回
503;请求体错误 → 返回400;Origin不匹配 → 返回403。 - 端到端测试: 登录 → 访问受保护页面 → 登出 → 受保护页面不可访问;access 令牌过期(在测试环境中使用短 TTL)→ 透明地完成 refresh;密码重置链接 → 令牌从地址栏消失。
12. 完成定义
- 没有令牌可从 JavaScript 读取;
localStorage未用于会话。 - Cookie 设置了
HttpOnly、Secure(生产环境)、SameSite、Path,过期时间取自令牌。 - 区分了 refresh 令牌被拒和后端不可达的情况。
- 单次刷新(single-flight)有明确结果,仅重试一次,登出期间阻止刷新——以上均有测试覆盖。
-
proxy.ts仅做乐观性检查;实际校验在 Handlers、Actions 和 Server Components 中通过 DAL 完成。 -
getSafeRedirect带有完整的测试集。 - 登出会清理客户端的缓存和存储。
- URL 中的令牌在读取后从地址栏中移除。
- 日志中不包含令牌或个人数据。
- 模型选择和代理方案已记录在 ADR 中。