Skip to content

Lesson 21:用户认证 — NextAuth.js v5 登录体系 ​

🧩 本节信息卡(学习前先看) ​

  • 阶段定位:Phase 3(实战篇)
  • 推荐时长:90~150 分钟(首次学习)
  • 先修要求:完成 L19–20 的数据库模型、商品表单与受保护 Actions
  • 学习产出:完成注册、登录、角色授权,并验收 L20 管理功能
✅ 本节完成标准(自检清单)
  • [ ] 我可以独立复现文中的核心代码片段
  • [ ] 我能解释“为什么这样实现”,而不只是“照着写”
  • [ ] 我记录了至少 1 个踩坑点和修复方法

🧭 本节统一学习流程 ​

  1. 学习目标:先明确本节要解决的业务问题与核心 API。
  2. 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
  3. 原理深挖:理解为什么这样设计,以及常见误区。
  4. 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
  5. 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。

建议节奏:阅读 20% + 编码 60% + 复盘 20%。

🎯 本节目标:为电商平台搭建课程使用的用户认证流程,包括注册、登录、OAuth 社交登录和路由守卫。

📦 本节产出:注册/登录页面、可选 GitHub OAuth、请求入口检查和服务端角色授权。

一、认证 (Authentication) vs 授权 (Authorization) ​


二、安装 NextAuth.js v5 ​

bash
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_,也不要提交真实值:

dotenv
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
// 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)
}

运行迁移更新数据库:

bash
npx prisma migrate dev --name add-auth-tables

2.2 配置 Auth ​

先补类型扩展,去掉 as any,让 session.user.id 和角色有明确类型:

ts
// 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 }
}
ts
// 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 字节,中文字符不等于一个字节,因此服务端需显式检查。这里只做密码哈希,不把它称为可逆“加密”。

ts
// 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 路由 ​

ts
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth"
export const { GET, POST } = handlers
export const runtime = 'nodejs'

三、用户注册 ​

ts
// 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')
}
tsx
// 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 异常要继续交给框架处理。

ts
// 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
}
tsx
// 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>
  )
}
tsx
// 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 发布说明。

ts
// 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:

ts
// 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,不接受客户端传来的角色:

ts
// 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())
bash
node --env-file=.env --import tsx scripts/make-admin.ts [email protected]

添加退出入口,并在 L17 的根布局中导入、渲染 <UserMenu />(放在导航栏内):

tsx
// 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 ​

JWTSession
数据库查询解码会话通常无需查询;本课授权仍查当前角色通常需读取会话记录,可配合缓存
吊销能力可用短有效期、会话版本或撤销列表;需额外机制可删除会话记录,需考虑缓存失效
取舍减少会话存储查询,但要处理权限变化与撤销可集中管理会话,但增加存储依赖

七、练习 ​

  1. 为已实现的用户菜单添加可选头像,并测试退出后再次访问管理页。
  2. 用普通用户和管理员分别测试 L20 的页面与 Action,确认绕过页面直接调用 Action 也会被拒绝。
  3. 修改 session.maxAge,观察会话有效期变化。全局有效期配置并不等于按用户选择的“记住我”功能。

📌 本节小结 ​

你做了什么你学到了什么
配置了 NextAuth.js v5 认证系统OAuth + Credentials 双模式认证
实现了用户注册(密码哈希存储)bcrypt.hash 与安全密码存储
创建了登录页面和会话获取Server Component 中 auth() 获取会话
编写了 Middleware 路由守卫请求级认证检查与角色权限控制
—JWT vs Database Session 的架构差异

项目驱动 · 边写边学 · React 19