11 · 用户认证
把登录态提升为应用级状态,接入真实 Supabase Auth,并让聊天主页成为受保护页面。
课时资源
一、学习目标
本课所在阶段:第五阶段 · 真实产品基础能力。
学完这一课后,你应该能够:
- 说清楚为什么聊天应用进入产品阶段后,下一步自然就是用户身份边界
- 看懂
AuthProvider、AuthContext、ProtectedRoute和聊天主页之间的协作关系 - 理解邮箱密码登录、邮箱注册和 GitHub OAuth 登录这三条链路是如何汇合到同一套会话恢复逻辑里的
- 明白为什么这一课升级的是认证边界,而不是聊天主链路本身
二、问题背景
到了第十课,应用已经具备真实模型、多模态输入、工具面板和会话记忆,但它仍然存在一个明显问题:谁都可以直接打开主页开始聊天。
真实产品里,如果没有用户身份边界,后面很快会碰到三个问题:
- 谁在使用这个应用,没有明确答案
- 登录前和登录后的页面访问范围没有区分
- 未来要接数据库权限和用户隔离时,没有稳定的用户状态入口
所以这一课真正要解决的,不是"做一个登录页",而是把认证状态变成应用级状态,并让聊天主页工作在这个认证边界之内。
三、核心概念
这一课最重要的概念是:认证状态和聊天状态是两条不同的链路,但它们会在页面层汇合。
当前代码里,这条链路分成四层:
AuthProvider + AuthContext负责保存当前用户、暴露signIn、signUp、signInWithOAuth、signOutProtectedRoute负责决定主页是否允许访问app/login/page.tsx负责组织登录、注册和 OAuth 入口app/page.tsx继续承接聊天页面,但整个页面已经运行在登录态之内
这里有一个很重要的判断:
- 聊天主链路并没有因为接认证而被推翻
- 认证只是从外面包住了聊天应用
这样后面继续接数据库、RLS 和用户隔离时,代码结构才不会乱。
四、整体流程
五、Supabase 配置与控制台操作
这一课虽然重点是认证边界,但如果 Supabase 控制台没有先配好,代码本身是跑不起来的。按当前代码,你至少要在 Supabase 里完成下面这些操作。
1. 先创建 Supabase 项目
在 Supabase 后台新建一个项目后,先记住两项配置:
Project URLPublishable key
这两个值会进入本课使用的环境变量:
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=your-publishable-key
NEXT_PUBLIC_APP_URL=http://localhost:3000这里的对应关系直接来自当前代码:
app/database/supabase.ts读取前两个变量app/api/auth/signup/route.ts会用NEXT_PUBLIC_APP_URL生成邮件回跳地址
2. 在 Authentication 里打开邮箱密码登录
Supabase 控制台里进入:
Authentication -> Providers -> Email
确认下面两件事:
- Email provider 已启用
- 允许邮箱密码登录
如果你希望注册后必须先点邮件确认链接,再进入应用,也可以保留邮件确认。当前代码已经兼容这两种情况:
- 有 session:直接登录成功
- 没有 session:前端显示"请查收验证邮件"
3. 配置 GitHub OAuth Provider
Supabase 控制台里进入:
Authentication -> Providers -> GitHub
你需要填入 GitHub OAuth App 的:
Client IDClient 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注意两点:
- 不要把真实密钥写进站点文档或提交到公开仓库
- 当前目录的
.gitignore已经忽略了.env*
如果你还想让聊天主链路同时可用,那还要继续补模型相关变量,比如 GOOGLE_API_KEY 或 OpenAI 兼容配置;但那是这套课程前面阶段已经讲过的模型环境,不是这一课新增的 Supabase 要求。
六、运行过程
1. 应用根部先注入认证上下文
app/layout.tsx 已经不是单纯的样式外壳,而是把整棵应用包进:
<AuthProvider>{children}</AuthProvider>这一步的意义是,登录态不再只是登录页自己的局部状态,而是整个应用都能访问的共享状态。
2. 登录页只负责组织认证入口
login/page.tsx 并不直接写 Supabase SDK 细节,而是把动作交给 useAuth() 暴露出来的方法:
signInsignUpsignInWithOAuth
这样页面层更像"认证交互编排",而不是"认证实现细节堆积区"。
3. 后端认证路由负责写入 cookie
不管是邮箱密码登录,还是 OAuth 回跳,真正把登录态落地的是服务端路由,它们统一把 sb-access-token 写进 cookie。
这一步很重要,因为后面 /api/auth/session、受保护页面判断,以及更进一步的服务端鉴权,都要依赖这份稳定 cookie。
4. AuthContext 在初始化时恢复用户
AuthContext.tsx 的 useEffect 做了两件关键事:
- 处理 OAuth 回跳后的
access_token或错误信息 - 请求
/api/auth/session恢复当前用户
所以这一课的登录态并不是"只在登录按钮点击后存在",而是刷新页面后也能恢复。
5. 主页通过 ProtectedRoute 变成受保护页面
当前实现里,主页最外层已经变成:
<ProtectedRoute>
<main className="app-shell">...</main>
</ProtectedRoute>这意味着聊天页面不再默认公开可见。未登录时会直接跳回 /login。
6. 聊天主链路继续沿用上一课
这一课很重要的一点是:认证接进来了,但聊天主链路没有回退。
app/page.tsx 里保留的仍然是:
modelIdtoolIdsattachmentsthreadIdtool.callmessage.start/delta/end
也就是说,这一课升级的是"谁能用应用",不是"聊天怎么工作"。
七、关键代码解析
app/layout.tsx - 在应用根部注入 AuthProvider,让登录态变成全局可访问状态。
关键代码:
<html lang="zh-CN">
<body>
<AuthProvider>{children}</AuthProvider>
</body>
</html>代码解析:
这一步决定了认证状态的作用域:
- 如果只在登录页维护用户状态,主页和其他页面拿不到
- 放进根布局之后,认证状态才真正成为应用级状态
- 这也是后面继续接用户级数据库权限的基础
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');代码解析:
这里的作用不是重复拿用户,而是把"回跳成功"变成"应用状态真的恢复完成":
- 先把 token 写进 cookie
- 再通过
/api/auth/session统一拿当前用户 - 最后把结果放进
AuthContext
这样邮箱密码登录和 OAuth 登录最终都会汇合到同一套用户恢复逻辑。
app/components/ProtectedRoute.tsx - 负责在未登录时拦截主页访问,避免聊天应用直接暴露给匿名用户。
关键代码:
if (!isLoading && !isAuthenticated) {
router.replace('/login');
}代码解析:
这段代码让"登录态存在"从一个概念,变成一个真正影响页面可访问性的规则:
AuthContext回答的是"当前用户是谁"ProtectedRoute回答的是"这个用户能不能进这个页面"- 两者分开后,职责更清楚,也更容易扩展到不同访问级别
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 这条链路。
为什么聊天主页没有因为接认证而重写?
因为认证边界和聊天边界应该相互独立。这样后面升级认证实现时,不需要推翻聊天主链路。
十、练习题
- 解释
AuthContext、ProtectedRoute、login/page.tsx各自负责什么。 - 说明邮箱密码登录和 GitHub OAuth 在"会话恢复"这一步为什么会汇合。
- 为什么
AuthProvider要放进layout.tsx,而不是只放在登录页里?
十一、总结
这一课真正完成的是:把聊天应用推进到"有真实用户身份"的状态。
从这一课开始,应用已经不只是"谁都能直接访问的聊天 Demo",而是一个有登录态、有受保护页面、并能继续承接数据库权限和用户隔离的产品基础壳子。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。