Lesson 21:用户认证 — NextAuth.js v5 登录体系
🧩 本节信息卡(学习前先看)
- 阶段定位:Phase 3(实战篇)
- 推荐时长:90~150 分钟(首次学习)
- 先修要求:完成 L19–20 的数据库模型、商品表单与受保护 Actions
- 学习产出:完成注册、登录、角色授权,并验收 L20 管理功能
✅ 本节完成标准(自检清单)
- [ ] 我可以独立复现文中的核心代码片段
- [ ] 我能解释“为什么这样实现”,而不只是“照着写”
- [ ] 我记录了至少 1 个踩坑点和修复方法
🧭 本节统一学习流程
- 学习目标:先明确本节要解决的业务问题与核心 API。
- 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
- 原理深挖:理解为什么这样设计,以及常见误区。
- 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
- 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。
建议节奏:阅读 20% + 编码 60% + 复盘 20%。
🎯 本节目标:为电商平台搭建课程使用的用户认证流程,包括注册、登录、OAuth 社交登录和路由守卫。
📦 本节产出:注册/登录页面、可选 GitHub OAuth、请求入口检查和服务端角色授权。
一、认证 (Authentication) vs 授权 (Authorization)
二、安装 NextAuth.js v5
npm install [email protected] @auth/prisma-adapter@2 bcryptjs@3 zod@4截至本次校对,Auth.js v5 仍是预发布版本,所以固定 [email protected],不要把它描述成稳定版。bcryptjs@3 自带类型,不需要 @types/bcryptjs。本节沿用 Next.js 15.5 与 Prisma 6,参见 Auth.js v5 迁移文档。
在 .env.local 配置以下服务端变量,不要加 NEXT_PUBLIC_,也不要提交真实值:
AUTH_SECRET=替换为随机密钥
AUTH_URL=http://localhost:3000
# GitHub OAuth 可选;不配置下面两项时只启用邮箱密码登录
AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET=用 openssl rand -base64 32 生成 AUTH_SECRET。AUTH_URL 要与当前应用源站一致,尤其使用 next start 验收时;部署时改为规范 HTTPS 地址。只有确认代理正确处理 Host 标头时才配置 AUTH_TRUST_HOST=true,参见 Auth.js 部署文档。启用 OAuth 时,在 GitHub Developer settings 创建 OAuth App,本地回调地址设为 http://localhost:3000/api/auth/callback/github;部署环境使用自己的 HTTPS 域名。不要开启危险的按同邮箱自动关联账户选项。
2.1 扩展 Prisma Schema
NextAuth 的 Prisma Adapter 需要额外的数据库表来存储 OAuth 账户和会话:
// prisma/schema.prisma — 在原有 User 模型基础上补充
model User {
id String @id @default(cuid())
email String @unique
emailVerified DateTime?
name String?
password String? // OAuth 用户不需要密码,改为可选
image String?
role String @default("customer")
accounts Account[]
sessions Session[]
orders Order[]
createdAt DateTime @default(now())
}
// OAuth 第三方账户关联表
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
// 数据库 Session 表(使用 JWT 策略时可选)
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}运行迁移更新数据库:
npx prisma migrate dev --name add-auth-tables2.2 配置 Auth
先补类型扩展,去掉 as any,让 session.user.id 和角色有明确类型:
// src/types/next-auth.d.ts
import type { DefaultSession } from 'next-auth'
import 'next-auth/jwt'
declare module 'next-auth' {
interface Session {
user: { id: string; role: string } & DefaultSession['user']
}
interface User { role?: string }
}
declare module 'next-auth/jwt' {
interface JWT { role?: string }
}// src/lib/auth-validation.ts
import 'server-only'
import { z } from 'zod'
export const LoginSchema = z.object({
email: z.string().trim().toLowerCase().email().max(254),
password: z.string().min(1).max(72)
.refine(value => Buffer.byteLength(value, 'utf8') <= 72, '密码最多 72 字节'),
})
export const RegisterSchema = LoginSchema.extend({
name: z.string().trim().min(1).max(50),
password: z.string().min(12, '密码至少 12 个字符').max(72)
.refine(value => Buffer.byteLength(value, 'utf8') <= 72, '密码最多 72 字节'),
})bcrypt 处理的密码上限是 72 字节,中文字符不等于一个字节,因此服务端需显式检查。这里只做密码哈希,不把它称为可逆“加密”。
// src/lib/auth.ts
import 'server-only'
import NextAuth from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import GitHub from 'next-auth/providers/github'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from './prisma'
import { LoginSchema } from './auth-validation'
import bcrypt from 'bcryptjs'
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
...(process.env.AUTH_GITHUB_ID && process.env.AUTH_GITHUB_SECRET ? [GitHub] : []),
Credentials({
credentials: {
email: { label: '邮箱', type: 'email' },
password: { label: '密码', type: 'password' },
},
async authorize(credentials) {
const parsed = LoginSchema.safeParse(credentials)
if (!parsed.success) return null
const user = await prisma.user.findUnique({ where: { email: parsed.data.email } })
if (!user?.password) return null
if (!(await bcrypt.compare(parsed.data.password, user.password))) return null
return { id: user.id, email: user.email, name: user.name, role: user.role }
},
}),
],
pages: { signIn: '/login' },
session: { strategy: 'jwt', maxAge: 60 * 60 * 24 },
callbacks: {
async signIn({ user, account }) {
// 课程 User.email 必填;拒绝不能提供邮箱的 OAuth 账户。
return account?.provider === 'credentials' || !!user.email
},
async jwt({ token, user }) {
if (user) token.role = user.role ?? 'customer'
return token
},
async session({ session, token }) {
session.user.id = token.sub ?? ''
session.user.role = token.role ?? 'customer'
return session
},
},
})Credentials 不替你注册用户或哈希密码;它使用下面的注册 Action 写入用户。OAuth 用户由 adapter 持久化,role 默认是 customer。JWT 中的角色仅用于显示,重要授权每次从数据库查询当前角色。参见 Credentials 官方说明 与 类型扩展。
2.3 挂载 API 路由
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth"
export const { GET, POST } = handlers
export const runtime = 'nodejs'三、用户注册
// src/app/register/actions.ts
'use server'
import { prisma } from '@/lib/prisma'
import { RegisterSchema } from '@/lib/auth-validation'
import { Prisma } from '@prisma/client'
import bcrypt from 'bcryptjs'
import { redirect } from 'next/navigation'
type RegisterState = { error: string } | null
export async function registerUser(
_prevState: RegisterState, formData: FormData,
): Promise<RegisterState> {
const parsed = RegisterSchema.safeParse({
email: formData.get('email'), password: formData.get('password'), name: formData.get('name'),
})
if (!parsed.success) return { error: parsed.error.issues[0].message }
const { email, name, password } = parsed.data
const hashedPassword = await bcrypt.hash(password, 12)
try {
await prisma.user.create({
data: { email, name, password: hashedPassword, role: 'customer' },
})
} catch (error) {
// 数据库唯一约束也处理同时注册同一邮箱的竞态。
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
return { error: '无法使用该邮箱注册,请登录或使用其他邮箱' }
}
throw error
}
redirect('/login?registered=true')
}// src/app/register/page.tsx
'use client'
import { useActionState } from 'react'
import { registerUser } from './actions'
import Link from 'next/link'
export default function RegisterPage() {
const [state, action, isPending] = useActionState(registerUser, null)
return (
<div className="max-w-md mx-auto px-4 py-20">
<h1 className="text-2xl font-bold text-center mb-8">注册 ShopNext</h1>
{state?.error && (
<div role="alert" className="bg-red-50 text-red-600 p-3 rounded-xl mb-4 text-sm">
{state.error}
</div>
)}
<form action={action} className="space-y-4">
<input name="name" aria-label="昵称" placeholder="昵称" required maxLength={50}
className="w-full border rounded-xl px-4 py-3" />
<input name="email" aria-label="邮箱" type="email" autoComplete="email" placeholder="邮箱" required
className="w-full border rounded-xl px-4 py-3" />
<input name="password" aria-label="密码" type="password" autoComplete="new-password" placeholder="密码 (至少12位,最多72字节)" required minLength={12} maxLength={72}
className="w-full border rounded-xl px-4 py-3" />
<button type="submit" disabled={isPending}
className="w-full bg-indigo-600 text-white py-3 rounded-xl font-medium hover:bg-indigo-700 disabled:opacity-50">
{isPending ? '注册中...' : '创建账号'}
</button>
</form>
<p className="text-center text-sm text-gray-500 mt-6">
已有账号? <Link href="/login" className="text-indigo-600">去登录</Link>
</p>
</div>
)
}四、登录页面
把预期的凭据错误返回给表单;成功登录的 redirect 异常要继续交给框架处理。
// src/app/login/actions.ts
'use server'
import { signIn } from '@/lib/auth'
import { AuthError } from 'next-auth'
type LoginState = { error: string } | null
export async function login(_previous: LoginState, formData: FormData): Promise<LoginState> {
try {
await signIn('credentials', {
email: formData.get('email'),
password: formData.get('password'),
redirectTo: '/products',
})
} catch (error) {
if (error instanceof AuthError) return { error: '登录失败,请检查邮箱和密码后重试' }
throw error
}
return null
}// src/app/login/LoginForm.tsx
'use client'
import { useActionState } from 'react'
import { login } from './actions'
export default function LoginForm() {
const [state, action, pending] = useActionState(login, null)
return (
<form action={action} className="space-y-4">
{state?.error && <p role="alert" className="text-red-600">{state.error}</p>}
<input name="email" aria-label="邮箱" type="email" autoComplete="email" placeholder="邮箱" required className="w-full border rounded-xl px-4 py-3" />
<input name="password" aria-label="密码" type="password" autoComplete="current-password" placeholder="密码" required className="w-full border rounded-xl px-4 py-3" />
<button type="submit" disabled={pending} className="w-full bg-indigo-600 text-white py-3 rounded-xl disabled:opacity-50">
{pending ? '登录中…' : '登录'}
</button>
</form>
)
}// src/app/login/page.tsx
import { signIn } from '@/lib/auth'
import Link from 'next/link'
import LoginForm from './LoginForm'
export default function LoginPage() {
const githubEnabled = !!(process.env.AUTH_GITHUB_ID && process.env.AUTH_GITHUB_SECRET)
return (
<div className="max-w-md mx-auto px-4 py-20">
<h1 className="text-2xl font-bold text-center mb-8">登录 ShopNext</h1>
{githubEnabled && (
<form action={async () => {
'use server'
await signIn('github', { redirectTo: '/products' })
}}>
<button className="w-full bg-gray-900 text-white py-3 rounded-xl mb-4">使用 GitHub 登录</button>
</form>
)}
<LoginForm />
<Link href="/register" className="block mt-6 text-indigo-600">创建账号</Link>
</div>
)
}五、Middleware 路由守卫
Middleware 对 matcher 匹配的请求做提前检查,改善跳转体验。最终权限由页面数据访问和 Actions 再验证。本配置导入了 Prisma,须使用 Next.js 15.5 已稳定支持的 Node.js Middleware,不能直接在 Edge 运行,见 Next.js 15.5 发布说明。
// src/middleware.ts
import { auth } from '@/lib/auth'
import { NextResponse } from 'next/server'
export default auth((req) => {
const isLoggedIn = !!req.auth?.user?.id
const isProtected = ['/admin', '/checkout', '/orders'].some(
path => req.nextUrl.pathname === path || req.nextUrl.pathname.startsWith(`${path}/`),
)
if (isProtected && !isLoggedIn) {
return NextResponse.redirect(new URL('/login', req.nextUrl))
}
return NextResponse.next()
})
// 配置 Middleware 匹配哪些路径
export const config = {
matcher: ['/admin/:path*', '/checkout/:path*', '/orders/:path*'],
runtime: 'nodejs',
}现在完整替换 L20 的默认拒绝模块。管理员操作重新读取数据库中的角色,避免降权后旧 JWT 仍带有 admin:
// src/lib/authorization.ts
import 'server-only'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { redirect } from 'next/navigation'
export async function requireUser() {
const session = await auth()
if (!session?.user?.id) redirect('/login')
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { id: true, role: true, email: true, name: true },
})
if (!user) redirect('/login')
return user
}
export async function requireAdmin() {
const user = await requireUser()
if (user.role !== 'admin') redirect('/products')
return user
}创建本地管理员:先通过 /register 注册自己的邮箱,再由开发者在项目终端运行下面的脚本。浏览器注册永远写入 customer,不接受客户端传来的角色:
// scripts/make-admin.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
const email = process.argv[2]?.trim().toLowerCase()
if (!email) throw new Error('请提供已注册的邮箱')
await prisma.user.update({ where: { email }, data: { role: 'admin' } })
console.log('该账户已设为管理员;请重新登录更新界面会话信息。')
}
main().catch(error => { console.error(error); process.exitCode = 1 })
.finally(() => prisma.$disconnect())node --env-file=.env --import tsx scripts/make-admin.ts [email protected]添加退出入口,并在 L17 的根布局中导入、渲染 <UserMenu />(放在导航栏内):
// src/components/UserMenu.tsx
import { auth, signOut } from '@/lib/auth'
import Link from 'next/link'
export default async function UserMenu() {
const session = await auth()
if (!session?.user) return <Link href="/login">登录</Link>
return (
<div className="flex gap-3">
<span>{session.user.name ?? session.user.email}</span>
<form action={async () => {
'use server'
await signOut({ redirectTo: '/login' })
}}><button type="submit">退出登录</button></form>
</div>
)
}验收 L20:未登录访问管理页应跳转登录;普通用户不能读取管理列表或调用商品写入 Action;管理员能新增商品,错误字段会显示提示。删除被订单引用的商品应显示失败,不能删掉订单历史。编辑页面完成练习后,用同一套授权检查验证更新。
本节未包含邮件验证、找回密码、登录/注册限流和账户锁定。对公网开放前,需在服务端或网关实现这些能力;单靠表单 required、Middleware 或 bcrypt 不能防止暴力尝试。GitHub OAuth 验收还需要真实的测试应用配置。
六、🧠 深度专题:JWT vs Session
| JWT | Session | |
|---|---|---|
| 数据库查询 | 解码会话通常无需查询;本课授权仍查当前角色 | 通常需读取会话记录,可配合缓存 |
| 吊销能力 | 可用短有效期、会话版本或撤销列表;需额外机制 | 可删除会话记录,需考虑缓存失效 |
| 取舍 | 减少会话存储查询,但要处理权限变化与撤销 | 可集中管理会话,但增加存储依赖 |
七、练习
- 为已实现的用户菜单添加可选头像,并测试退出后再次访问管理页。
- 用普通用户和管理员分别测试 L20 的页面与 Action,确认绕过页面直接调用 Action 也会被拒绝。
- 修改
session.maxAge,观察会话有效期变化。全局有效期配置并不等于按用户选择的“记住我”功能。
📌 本节小结
| 你做了什么 | 你学到了什么 |
|---|---|
| 配置了 NextAuth.js v5 认证系统 | OAuth + Credentials 双模式认证 |
| 实现了用户注册(密码哈希存储) | bcrypt.hash 与安全密码存储 |
| 创建了登录页面和会话获取 | Server Component 中 auth() 获取会话 |
| 编写了 Middleware 路由守卫 | 请求级认证检查与角色权限控制 |
| — | JWT vs Database Session 的架构差异 |