第五阶段 · 真实产品基础能力

11 · 用户认证

把登录态提升为应用级状态,接入真实 Supabase Auth,并让聊天主页成为受保护页面。

课时资源

一、学习目标

本课所在阶段:第五阶段 · 真实产品基础能力

学完这一课后,你应该能够:

  • 说清楚为什么聊天应用进入产品阶段后,下一步自然就是用户身份边界
  • 看懂 AuthProviderAuthContextProtectedRoute 和聊天主页之间的协作关系
  • 理解邮箱密码登录、邮箱注册和 GitHub OAuth 登录这三条链路是如何汇合到同一套会话恢复逻辑里的
  • 明白为什么这一课升级的是认证边界,而不是聊天主链路本身

二、问题背景

到了第十课,应用已经具备真实模型、多模态输入、工具面板和会话记忆,但它仍然存在一个明显问题:谁都可以直接打开主页开始聊天。

真实产品里,如果没有用户身份边界,后面很快会碰到三个问题:

  • 谁在使用这个应用,没有明确答案
  • 登录前和登录后的页面访问范围没有区分
  • 未来要接数据库权限和用户隔离时,没有稳定的用户状态入口

所以这一课真正要解决的,不是"做一个登录页",而是把认证状态变成应用级状态,并让聊天主页工作在这个认证边界之内。

三、核心概念

这一课最重要的概念是:认证状态和聊天状态是两条不同的链路,但它们会在页面层汇合。

当前代码里,这条链路分成四层:

  1. AuthProvider + AuthContext 负责保存当前用户、暴露 signInsignUpsignInWithOAuthsignOut
  2. ProtectedRoute 负责决定主页是否允许访问
  3. app/login/page.tsx 负责组织登录、注册和 OAuth 入口
  4. app/page.tsx 继续承接聊天页面,但整个页面已经运行在登录态之内

这里有一个很重要的判断:

  • 聊天主链路并没有因为接认证而被推翻
  • 认证只是从外面包住了聊天应用

这样后面继续接数据库、RLS 和用户隔离时,代码结构才不会乱。

四、整体流程

五、Supabase 配置与控制台操作

这一课虽然重点是认证边界,但如果 Supabase 控制台没有先配好,代码本身是跑不起来的。按当前代码,你至少要在 Supabase 里完成下面这些操作。

1. 先创建 Supabase 项目

在 Supabase 后台新建一个项目后,先记住两项配置:

  • Project URL
  • Publishable key

这两个值会进入本课使用的环境变量:

NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=your-publishable-key
NEXT_PUBLIC_APP_URL=http://localhost:3000

这里的对应关系直接来自当前代码:

2. 在 Authentication 里打开邮箱密码登录

Supabase 控制台里进入:

Authentication -> Providers -> Email

确认下面两件事:

  1. Email provider 已启用
  2. 允许邮箱密码登录

如果你希望注册后必须先点邮件确认链接,再进入应用,也可以保留邮件确认。当前代码已经兼容这两种情况:

  • 有 session:直接登录成功
  • 没有 session:前端显示"请查收验证邮件"

3. 配置 GitHub OAuth Provider

Supabase 控制台里进入:

Authentication -> Providers -> GitHub

你需要填入 GitHub OAuth App 的:

  • Client ID
  • Client Secret

当前课程代码只接了 GitHub,没有接 Google 或其他 provider,这一点在 AuthContext.tsx 里也写死成了:

type OAuthProvider = 'github';

4. 配置正确的回调地址

这一课最容易配错的就是回调 URL。按当前代码,GitHub OAuth 的回跳地址应该配置成:

http://localhost:3000/api/auth/callback

如果你部署到线上,再换成线上域名对应的:

https://your-domain.com/api/auth/callback

原因很直接:

  • 前端发起 OAuth 时,用的是 redirectTo = ${window.location.origin}/api/auth/callback
  • 服务端注册时,也会把 NEXT_PUBLIC_APP_URL 拼成同一个 callback 地址

所以本课里,GitHub OAuth App、Supabase Provider 配置、项目 .env 三者里的 callback 必须保持一致。

5. 本地 .env 至少要准备哪些变量

这一课如果只想先把认证链路跑通,最小集合至少包括:

NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=your-publishable-key
NEXT_PUBLIC_APP_URL=http://localhost:3000

注意两点:

  1. 不要把真实密钥写进站点文档或提交到公开仓库
  2. 当前目录的 .gitignore 已经忽略了 .env*

如果你还想让聊天主链路同时可用,那还要继续补模型相关变量,比如 GOOGLE_API_KEY 或 OpenAI 兼容配置;但那是这套课程前面阶段已经讲过的模型环境,不是这一课新增的 Supabase 要求。

六、运行过程

1. 应用根部先注入认证上下文

app/layout.tsx 已经不是单纯的样式外壳,而是把整棵应用包进:

<AuthProvider>{children}</AuthProvider>

这一步的意义是,登录态不再只是登录页自己的局部状态,而是整个应用都能访问的共享状态。

2. 登录页只负责组织认证入口

login/page.tsx 并不直接写 Supabase SDK 细节,而是把动作交给 useAuth() 暴露出来的方法:

  • signIn
  • signUp
  • signInWithOAuth

这样页面层更像"认证交互编排",而不是"认证实现细节堆积区"。

不管是邮箱密码登录,还是 OAuth 回跳,真正把登录态落地的是服务端路由,它们统一把 sb-access-token 写进 cookie。

这一步很重要,因为后面 /api/auth/session、受保护页面判断,以及更进一步的服务端鉴权,都要依赖这份稳定 cookie。

4. AuthContext 在初始化时恢复用户

AuthContext.tsxuseEffect 做了两件关键事:

  • 处理 OAuth 回跳后的 access_token 或错误信息
  • 请求 /api/auth/session 恢复当前用户

所以这一课的登录态并不是"只在登录按钮点击后存在",而是刷新页面后也能恢复。

5. 主页通过 ProtectedRoute 变成受保护页面

当前实现里,主页最外层已经变成:

<ProtectedRoute>
  <main className="app-shell">...</main>
</ProtectedRoute>

这意味着聊天页面不再默认公开可见。未登录时会直接跳回 /login

6. 聊天主链路继续沿用上一课

这一课很重要的一点是:认证接进来了,但聊天主链路没有回退。

app/page.tsx 里保留的仍然是:

  • modelId
  • toolIds
  • attachments
  • threadId
  • tool.call
  • message.start/delta/end

也就是说,这一课升级的是"谁能用应用",不是"聊天怎么工作"。

七、关键代码解析

app/layout.tsx - 在应用根部注入 AuthProvider,让登录态变成全局可访问状态。

关键代码:

<html lang="zh-CN">
  <body>
    <AuthProvider>{children}</AuthProvider>
  </body>
</html>

代码解析:

这一步决定了认证状态的作用域:

  1. 如果只在登录页维护用户状态,主页和其他页面拿不到
  2. 放进根布局之后,认证状态才真正成为应用级状态
  3. 这也是后面继续接用户级数据库权限的基础

app/contexts/AuthContext.tsx - 本课最关键的认证状态中心。这里负责初始化用户、恢复会话、执行登录注册和 GitHub OAuth。

关键代码:为什么 OAuth 回跳后还要再请求 /api/auth/session

if (accessToken) {
  document.cookie = `sb-access-token=${accessToken}; path=/; max-age=${60 * 60 * 24 * 7}; SameSite=lax`;
  window.history.replaceState({}, '', window.location.pathname);
}

const response = await fetch('/api/auth/session');

代码解析:

这里的作用不是重复拿用户,而是把"回跳成功"变成"应用状态真的恢复完成":

  1. 先把 token 写进 cookie
  2. 再通过 /api/auth/session 统一拿当前用户
  3. 最后把结果放进 AuthContext

这样邮箱密码登录和 OAuth 登录最终都会汇合到同一套用户恢复逻辑。

app/components/ProtectedRoute.tsx - 负责在未登录时拦截主页访问,避免聊天应用直接暴露给匿名用户。

关键代码:

if (!isLoading && !isAuthenticated) {
  router.replace('/login');
}

代码解析:

这段代码让"登录态存在"从一个概念,变成一个真正影响页面可访问性的规则:

  1. AuthContext 回答的是"当前用户是谁"
  2. ProtectedRoute 回答的是"这个用户能不能进这个页面"
  3. 两者分开后,职责更清楚,也更容易扩展到不同访问级别

app/login/page.tsx - 登录页入口。这里负责切换登录/注册模式,并承接 OAuth 回跳后的错误提示。

app/components/AuthForm.tsx - 统一承接邮箱密码、确认密码、GitHub OAuth 按钮和对应 loading 状态。

app/api/auth/signin/route.ts - 邮箱密码登录入口。成功后把 sb-access-token 写进 cookie。

app/api/auth/callback/route.ts - GitHub OAuth 回跳入口。负责用 code 换 session,并把 token 落进 cookie。

app/page.tsx - 聊天主页本身。它基本保留了上一课的真实模型、多模态和工具链路,只是在最外层被 ProtectedRoute 包住。

九、练习题

为什么这一课不只做一个登录页?

因为真实产品里登录页不是目标,稳定的用户状态才是目标。只有登录态变成应用级状态,后面的数据库权限和用户隔离才有意义。

为什么这一课同时保留邮箱密码和 GitHub OAuth?

因为它们能代表两类常见认证入口,但最终都会回到同一套 cookie 和 session 恢复逻辑。

Supabase 控制台里最容易配错的是什么?

最常见的是回调地址不一致。GitHub OAuth App、Supabase Provider 配置、.env 里的应用地址,必须都对齐到 /api/auth/callback 这条链路。

为什么聊天主页没有因为接认证而重写?

因为认证边界和聊天边界应该相互独立。这样后面升级认证实现时,不需要推翻聊天主链路。

十、练习题

  1. 解释 AuthContextProtectedRoutelogin/page.tsx 各自负责什么。
  2. 说明邮箱密码登录和 GitHub OAuth 在"会话恢复"这一步为什么会汇合。
  3. 为什么 AuthProvider 要放进 layout.tsx,而不是只放在登录页里?

十一、总结

这一课真正完成的是:把聊天应用推进到"有真实用户身份"的状态。

从这一课开始,应用已经不只是"谁都能直接访问的聊天 Demo",而是一个有登录态、有受保护页面、并能继续承接数据库权限和用户隔离的产品基础壳子。

登录以继续阅读

解锁完整文档、代码示例及更多高级功能。

立即登录

On this page