第 22 章

认证系统:Auth.js(NextAuth v5)完全指南

第22章:认证系统:Auth.js(NextAuth v5)完全指南

认证系统看起来简单,实际上是应用中最容易出错的部分。Auth.js v5 专为 App Router 设计,关键创新是配置分拆——Edge 兼容配置与完整 Node.js 配置分离。

本章核心问题:为什么需要配置分拆?JWT 与数据库 session 策略如何选择?如何实现基于角色的访问控制?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

认证的本质复杂性

认证系统看起来简单,实际上是应用中最容易出错的部分。你需要同时处理:会话管理、密码哈希、OAuth 流程、CSRF 防护、角色权限、会话失效……每一个细节出错都可能导致安全漏洞。Auth.js(即 NextAuth v5)的价值不只是"省事",而是把这些细节的正确实现打包成一个经过社区审计的库。

NextAuth v5 相比 v4 有重大架构调整,专为 Next.js App Router 设计。最关键的变化是配置分拆:一份用于 Edge Runtime(Middleware),一份用于完整 Node.js 环境(Server Components、API Routes)。理解这个分拆是用好 Auth.js v5 的基础。

为什么要分拆配置

Next.js Middleware 运行在 Edge Runtime——一个基于 V8 的精简环境,不支持 Node.js 内置模块(如 cryptofs)和许多 npm 包(如 bcrypt)。Edge Runtime 的优势是冷启动极快、可在 CDN 边缘节点执行,适合做路由级别的身份验证检查。

但完整的认证逻辑(特别是 Credentials provider 的密码验证)依赖 bcrypt,这是一个 Node.js native addon,无法在 Edge 运行。解决方案是配置分拆:

完整配置实现

npm install next-auth@beta @auth/prisma-adapter bcryptjs
npm install -D @types/bcryptjs
// auth.config.ts — Edge 兼容配置
import type { NextAuthConfig } from 'next-auth'
import GitHub from 'next-auth/providers/github'
import Google from 'next-auth/providers/google'

export const authConfig: NextAuthConfig = {
  pages: {
    signIn: '/login',
    error: '/login',
  },
  providers: [
    GitHub({
      clientId: process.env.GITHUB_ID,
      clientSecret: process.env.GITHUB_SECRET,
    }),
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
  ],
  callbacks: {
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user
      const isProtected = nextUrl.pathname.startsWith('/dashboard')
      if (isProtected) return isLoggedIn
      return true
    },
    jwt({ token, user }) {
      if (user) {
        token.id = user.id
        token.role = (user as any).role
      }
      return token
    },
    session({ session, token }) {
      if (session.user) {
        session.user.id = token.id as string
        session.user.role = token.role as string
      }
      return session
    },
  },
}
// auth.ts — 完整配置(仅 Node.js)
import NextAuth from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import bcrypt from 'bcryptjs'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from '@/lib/prisma'
import { authConfig } from './auth.config'

export const { handlers, auth, signIn, signOut } = NextAuth({
  ...authConfig,
  adapter: PrismaAdapter(prisma),
  session: { strategy: 'jwt' },
  providers: [
    ...authConfig.providers,
    Credentials({
      async authorize(credentials) {
        const { email, password } = credentials as {
          email: string
          password: string
        }

        if (!email || !password) return null

        const user = await prisma.user.findUnique({
          where: { email },
          select: {
            id: true,
            email: true,
            name: true,
            password: true,
            role: true,
          },
        })

        if (!user || !user.password) return null

        const passwordMatch = await bcrypt.compare(password, user.password)
        if (!passwordMatch) return null

        return {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role,
        }
      },
    }),
  ],
})

注意 session: { strategy: 'jwt' } 的选择。数据库 session 策略(默认)每次请求都需要查询数据库验证 session。JWT 策略把 session 信息编码在签名的 token 里,无需数据库查询,适合高并发和无服务器场景。代价是 token 吊销较复杂(需要额外的 blacklist 机制)。

配置 API 路由和 Middleware

// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlers
// middleware.ts — 使用 Edge 兼容的 authConfig
import NextAuth from 'next-auth'
import { authConfig } from './auth.config'

export const { auth: middleware } = NextAuth(authConfig)

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}

Middleware 使用 authConfig(不含 Credentials),可以在 Edge 安全运行。authorized 回调在每次匹配路由的请求上执行——这是路由级别的守卫,在页面渲染之前就生效。

Level 2 · 它是怎么运行的(3-5年经验)

扩展 Session 类型

Auth.js 默认的 session.user 类型只有 nameemailimage。我们加了 idrole,需要声明类型扩展:

// types/next-auth.d.ts
import type { DefaultSession } from 'next-auth'

declare module 'next-auth' {
  interface Session {
    user: {
      id: string
      role: string
    } & DefaultSession['user']
  }
}

这是 TypeScript 的模块扩充(Module Augmentation)。之后在任何地方使用 session.user.idsession.user.role 都有完整类型支持。

在 Server Components 中使用认证

// app/dashboard/page.tsx
import { auth } from '@/auth'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  const session = await auth()

  if (!session) redirect('/login')

  return (
    <div>
      <h1>Welcome, {session.user.name}</h1>
      <p>Your role: {session.user.role}</p>
    </div>
  )
}

auth() 在 Server Component 中直接调用,读取当前请求的 session。不需要任何 API 调用,不需要 useSession hook。这是 App Router 的最大优势之一:服务端渲染时直接拿到认证信息,无需客户端 hydration 后再发起认证检查。

登录表单与 Server Actions

// app/login/actions.ts
'use server'

import { signIn } from '@/auth'
import { AuthError } from 'next-auth'

export async function loginWithCredentials(formData: FormData) {
  try {
    await signIn('credentials', {
      email: formData.get('email'),
      password: formData.get('password'),
      redirectTo: '/dashboard',
    })
  } catch (error) {
    if (error instanceof AuthError) {
      switch (error.type) {
        case 'CredentialsSignin':
          return { error: '邮箱或密码错误' }
        default:
          return { error: '登录失败,请稍后重试' }
      }
    }
    throw error // 重新抛出 redirect 等非错误异常
  }
}
// app/login/page.tsx
'use client'

import { useActionState } from 'react'
import { loginWithCredentials } from './actions'

export default function LoginPage() {
  const [state, action, isPending] = useActionState(loginWithCredentials, undefined)

  return (
    <form action={action} className="space-y-4">
      <div>
        <label htmlFor="email">邮箱</label>
        <input id="email" name="email" type="email" required />
      </div>
      <div>
        <label htmlFor="password">密码</label>
        <input id="password" name="password" type="password" required />
      </div>
      {state?.error && (
        <p className="text-red-500">{state.error}</p>
      )}
      <button type="submit" disabled={isPending}>
        {isPending ? '登录中...' : '登录'}
      </button>
    </form>
  )
}

注意 signIn 在成功时会抛出一个 redirect 异常(Next.js 内部机制),所以 catch 块里需要重新抛出非 AuthError 的异常,否则 redirect 会被吞掉。

OAuth 登录

// app/login/page.tsx(添加 OAuth 按钮)
import { signIn } from '@/auth'

// OAuth 登录使用 Server Action
async function loginWithGitHub() {
  'use server'
  await signIn('github', { redirectTo: '/dashboard' })
}

async function loginWithGoogle() {
  'use server'
  await signIn('google', { redirectTo: '/dashboard' })
}

// 在 JSX 中
<form action={loginWithGitHub}>
  <button type="submit">使用 GitHub 登录</button>
</form>
<form action={loginWithGoogle}>
  <button type="submit">使用 Google 登录</button>
</form>

Level 3 · 规范怎么定义的(资深)

在 Client Components 中使用认证

有时候 Client Component 需要知道当前用户信息(比如条件渲染 UI 元素):

// app/layout.tsx — 提供 session 给客户端
import { SessionProvider } from 'next-auth/react'
import { auth } from '@/auth'

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const session = await auth()

  return (
    <html>
      <body>
        <SessionProvider session={session}>
          {children}
        </SessionProvider>
      </body>
    </html>
  )
}
// components/UserMenu.tsx
'use client'

import { useSession, signOut } from 'next-auth/react'

export function UserMenu() {
  const { data: session, status } = useSession()

  if (status === 'loading') return <div>加载中...</div>
  if (!session) return null

  return (
    <div>
      <span>{session.user.name}</span>
      <button onClick={() => signOut({ callbackUrl: '/' })}>
        退出登录
      </button>
    </div>
  )
}

SessionProvider 在 Layout 里初始化(作为 Server Component),把服务端拿到的 session 传给客户端,避免客户端额外的网络请求。useSession 在 Client Component 里消费这个 session。

基于角色的访问控制

在 Middleware 的 authorized 回调里实现精细的路由保护:

// auth.config.ts(扩展的 authorized 回调)
callbacks: {
  authorized({ auth, request: { nextUrl } }) {
    const isLoggedIn = !!auth?.user
    const userRole = (auth?.user as any)?.role

    // 管理员路由
    if (nextUrl.pathname.startsWith('/admin')) {
      return isLoggedIn && userRole === 'ADMIN'
    }

    // 登录用户路由
    if (nextUrl.pathname.startsWith('/dashboard')) {
      return isLoggedIn
    }

    // 已登录用户访问登录页,重定向到 dashboard
    if (nextUrl.pathname === '/login' && isLoggedIn) {
      return Response.redirect(new URL('/dashboard', nextUrl))
    }

    return true
  },
},

保护 Server Actions

Middleware 保护的是路由,但 Server Actions 可以从任何地方调用。Server Actions 内部必须有自己的权限检查:

// app/admin/actions.ts
'use server'

import { auth } from '@/auth'

export async function deleteUser(userId: string) {
  const session = await auth()

  // 永远不要信任客户端,Server Action 必须独立验证权限
  if (!session || session.user.role !== 'ADMIN') {
    throw new Error('Forbidden')
  }

  await prisma.user.delete({ where: { id: userId } })
  revalidatePath('/admin/users')
}

这是安全编程的基本原则:每一层都做权限验证。Middleware 是第一道防线(拦截非法路由访问),Server Action 是第二道防线(防止直接调用绕过路由保护)。

用户注册

注册不在 Auth.js 的处理范围内(它只处理认证),需要自己实现:

// app/register/actions.ts
'use server'

import bcrypt from 'bcryptjs'
import { prisma } from '@/lib/prisma'
import { signIn } from '@/auth'

export async function registerUser(formData: FormData) {
  const email = formData.get('email') as string
  const password = formData.get('password') as string
  const name = formData.get('name') as string

  // 验证
  if (!email || !password || password.length < 8) {
    return { error: '请检查输入信息' }
  }

  // 检查邮箱唯一性
  const existing = await prisma.user.findUnique({ where: { email } })
  if (existing) return { error: '该邮箱已注册' }

  // 哈希密码(cost factor 12 是安全与性能的平衡点)
  const hashedPassword = await bcrypt.hash(password, 12)

  await prisma.user.create({
    data: { email, name, password: hashedPassword },
  })

  // 注册成功后直接登录
  await signIn('credentials', { email, password, redirectTo: '/dashboard' })
}

bcrypt 的 cost factor(这里是 12)决定哈希计算的时间。cost 12 在现代服务器上约 250ms,对用户几乎无感知,但对暴力破解的攻击者意味着每秒最多尝试 4 次。这是 bcrypt 设计的核心——adaptive cost,随硬件发展可以调高。

配置回顾

Auth.js v5 在 Next.js 15 里的完整配置包含四个文件:

每个文件的职责清晰,遵循最小权限原则——Edge 运行的代码只包含 Edge 兼容的依赖,Node.js 专有的逻辑(bcrypt、Prisma adapter)只在 Node.js 环境加载。

Level 4 · 边界与陷阱(所有人)

陷阱1:signIn 成功时会抛出 redirect 异常——catch 块里需要重新抛出非 AuthError 的异常,否则 redirect 会被吞掉。

陷阱2:bcrypt 是 Node.js native addon,在 Edge Runtime 不可用——这正是配置分拆(auth.config.ts vs auth.ts)存在的原因。

陷阱3:Middleware 保护的是路由,Server Actions 必须有独立的权限检查——攻击者可以直接调用 Server Action 绕过路由保护。

本章评分
4.5  / 5  (7 评分)

💬 留言讨论