Auth.js (NextAuth v5) 深度集成

认证是 Web 应用的第一道防线。Auth.js(NextAuth v5)是 Next.js 生态中最成熟的认证方案——支持 80+ OAuth 提供商、JWT/Database 双策略、Middleware 路由保护,且与 App Router 深度集成。本章从零搭建完整的认证系统,讲清每个配置背后的安全原理。

1. 架构与安装

1.1 Auth.js 的工作原理

Auth.js 的核心流程:

用户点击"登录" → 重定向到 OAuth 提供商 → 用户授权 → 回调到你的应用
→ Auth.js 验证授权码 → 创建 Session → 返回 Cookie → 后续请求自动携带

它帮你处理了 OAuth 协议中最复杂的部分——授权码交换、PKCE 验证、Token 刷新、CSRF 防护。你只需要配置 Provider 和 Callback。

1.2 安装

pnpm add next-auth@beta
# 生成加密密钥(用于签名 JWT 和加密 Cookie)
npx auth secret
# 会自动添加 AUTH_SECRET 到 .env.local

1.3 核心配置

// auth.ts(项目根目录)
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
import Google from 'next-auth/providers/google'
import Credentials from 'next-auth/providers/credentials'
 
export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    GitHub,   // 自动读取 AUTH_GITHUB_ID 和 AUTH_GITHUB_SECRET
    Google,   // 自动读取 AUTH_GOOGLE_ID 和 AUTH_GOOGLE_SECRET
  ],
})
// app/api/auth/[...nextauth]/route.ts — API 路由
import { handlers } from '@/auth'
 
export const { GET, POST } = handlers
# .env.local
AUTH_SECRET=your-generated-secret
 
# GitHub OAuth
AUTH_GITHUB_ID=your-github-client-id
AUTH_GITHUB_SECRET=your-github-client-secret
 
# Google OAuth
AUTH_GOOGLE_ID=your-google-client-id
AUTH_GOOGLE_SECRET=your-google-client-secret

环境变量命名约定:Auth.js v5 自动识别 AUTH_<PROVIDER>_IDAUTH_<PROVIDER>_SECRET 格式,不需要在代码中手动传递 clientId/clientSecret

2. Provider 配置

2.1 OAuth 提供商

OAuth 是最推荐的登录方式——用户不需要在你的应用中创建密码(密码存储和保护是一个大负担)。

// auth.ts
import GitHub from 'next-auth/providers/github'
import Google from 'next-auth/providers/google'
import Discord from 'next-auth/providers/discord'
 
export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    GitHub({
      // 自定义请求的权限范围
      authorization: { params: { scope: 'read:user user:email' } },
    }),
    Google({
      // 强制显示账号选择器(即使只有一个 Google 账号)
      authorization: { params: { prompt: 'select_account' } },
    }),
    Discord,
  ],
})

2.2 邮箱密码登录(Credentials)

Credentials Provider 让你用自己的数据库验证用户名/密码。注意:Auth.js 官方不推荐这种方式——因为密码存储、暴力破解防护、密码重置流程都需要你自己实现。但有些项目确实需要:

import Credentials from 'next-auth/providers/credentials'
import { z } from 'zod'
import bcrypt from 'bcryptjs'
import { db } from '@/lib/db'
import { users } from '@/lib/db/schema'
 
export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    Credentials({
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        // 1. 验证输入格式
        const parsed = z.object({
          email: z.string().email(),
          password: z.string().min(8),
        }).safeParse(credentials)
 
        if (!parsed.success) return null
 
        // 2. 查找用户
        const user = await db.query.users.findFirst({
          where: eq(users.email, parsed.data.email),
        })
 
        if (!user || !user.passwordHash) return null
 
        // 3. 验证密码(bcrypt 比较是恒时间的,防止时序攻击)
        const isValid = await bcrypt.compare(parsed.data.password, user.passwordHash)
 
        if (!isValid) return null
 
        // 4. 返回用户对象(不包含密码!)
        return {
          id: user.id,
          email: user.email,
          name: user.name,
          image: user.image,
        }
      },
    }),
  ],
})

为什么用 bcrypt? 它自带 salt(防止彩虹表攻击)和可调的 work factor(防暴力破解)。bcrypt.compare 是恒时间比较——无论密码对不对,耗时相同,防止通过响应时间推断密码。

2.3 Magic Link(邮件链接登录)

用户输入邮箱 → 收到一封包含一次性链接的邮件 → 点击链接完成登录。无需密码,安全且体验好:

import Resend from 'next-auth/providers/resend'
 
export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    Resend({
      from: '[email protected]',
      // 需要 Database adapter 存储验证 Token
    }),
  ],
  adapter: DrizzleAdapter(db),  // Magic Link 必须使用 Database 策略
})
# .env.local
AUTH_RESEND_KEY=re_your-resend-api-key

3. Session 策略

3.1 JWT vs Database

Auth.js 支持两种 Session 存储策略:

特性JWT(默认)Database
存储位置加密后存在 Cookie 中数据库表 + Cookie 中只存 Session ID
服务端查询不需要(解密 Cookie 即可)每次都查数据库
可扩展性好(无状态)需要数据库支持
即时注销❌ 做不到(JWT 过期前一直有效)✅ 删除数据库记录即可
Session 大小受 Cookie 4KB 限制不限
适用场景大多数应用需要即时注销、多设备管理

为什么 JWT 不能即时注销? JWT 是自包含的——服务端解密后就能验证,不需要查询外部存储。这意味着即使你"注销"了(删除了 Cookie),如果有人之前复制了这个 JWT,它在过期前仍然有效。Database Session 则不同——注销时删除数据库记录,下次验证时查不到记录就拒绝访问。

3.2 JWT 策略配置

export const { handlers, signIn, signOut, auth } = NextAuth({
  session: {
    strategy: 'jwt',
    maxAge: 30 * 24 * 60 * 60,  // 30 天过期
  },
  providers: [...],
 
  callbacks: {
    // JWT 回调:Token 创建和更新时调用
    async jwt({ token, user, account }) {
      // 首次登录时,user 和 account 不为空
      if (user) {
        token.id = user.id
        token.role = user.role  // 自定义字段
      }
      // 可以在这里添加或更新 Token 中的数据
      return token
    },
 
    // Session 回调:客户端读取 Session 时调用
    async session({ session, token }) {
      // 把 Token 中的数据传递到 Session
      session.user.id = token.id as string
      session.user.role = token.role as string
      return session
    },
  },
})

3.3 Database 策略配置

pnpm add drizzle-orm @auth/drizzle-adapter
import { DrizzleAdapter } from '@auth/drizzle-adapter'
import { db } from '@/lib/db'
 
export const { handlers, signIn, signOut, auth } = NextAuth({
  adapter: DrizzleAdapter(db),
  session: {
    strategy: 'database',
    maxAge: 30 * 24 * 60 * 60,
  },
  providers: [...],
})

Auth.js 需要以下数据库表(Drizzle Adapter 会自动使用):

// lib/db/auth-schema.ts
import { pgTable, text, timestamp, primaryKey, integer } from 'drizzle-orm/pg-core'
 
export const users = pgTable('user', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  name: text('name'),
  email: text('email').unique(),
  emailVerified: timestamp('emailVerified', { mode: 'date' }),
  image: text('image'),
})
 
export const accounts = pgTable('account', {
  userId: text('userId').notNull().references(() => users.id, { onDelete: 'cascade' }),
  type: text('type').notNull(),
  provider: text('provider').notNull(),
  providerAccountId: text('providerAccountId').notNull(),
  refresh_token: text('refresh_token'),
  access_token: text('access_token'),
  expires_at: integer('expires_at'),
  token_type: text('token_type'),
  scope: text('scope'),
  id_token: text('id_token'),
}, (account) => [
  primaryKey({ columns: [account.provider, account.providerAccountId] }),
])
 
export const sessions = pgTable('session', {
  sessionToken: text('sessionToken').primaryKey(),
  userId: text('userId').notNull().references(() => users.id, { onDelete: 'cascade' }),
  expires: timestamp('expires', { mode: 'date' }).notNull(),
})
 
export const verificationTokens = pgTable('verificationToken', {
  identifier: text('identifier').notNull(),
  token: text('token').notNull(),
  expires: timestamp('expires', { mode: 'date' }).notNull(),
}, (vt) => [
  primaryKey({ columns: [vt.identifier, vt.token] }),
])

3.4 TypeScript 类型扩展

当你给 Session 添加了自定义字段(如 role),需要扩展类型声明:

// types/next-auth.d.ts
import { DefaultSession } from 'next-auth'
 
declare module 'next-auth' {
  interface Session {
    user: {
      id: string
      role: string
    } & DefaultSession['user']
  }
 
  interface User {
    role: string
  }
}

4. 获取 Session

4.1 Server Component

// app/dashboard/page.tsx
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
 
export default async function DashboardPage() {
  const session = await auth()
 
  if (!session?.user) {
    redirect('/login')
  }
 
  return (
    <div>
      <h1>Welcome, {session.user.name}</h1>
      <p>Email: {session.user.email}</p>
      <img src={session.user.image ?? ''} alt="Avatar" />
    </div>
  )
}

4.2 Client Component

'use client'
 
import { useSession } from 'next-auth/react'
 
export function UserMenu() {
  const { data: session, status } = useSession()
 
  if (status === 'loading') return <div>Loading...</div>
  if (!session) return <a href="/login">登录</a>
 
  return (
    <div>
      <span>{session.user.name}</span>
      <button onClick={() => signOut()}>退出</button>
    </div>
  )
}

需要在 layout 中添加 SessionProvider:

// app/layout.tsx
import { SessionProvider } from 'next-auth/react'
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <SessionProvider>{children}</SessionProvider>
      </body>
    </html>
  )
}

4.3 Server Action

'use server'
 
import { auth } from '@/auth'
 
export async function updateProfile(data: FormData) {
  const session = await auth()
  if (!session?.user) throw new Error('Unauthorized')
 
  await db.update(users)
    .set({ name: data.get('name') as string })
    .where(eq(users.id, session.user.id))
}

4.4 API Route

// app/api/user/route.ts
import { auth } from '@/auth'
 
export async function GET() {
  const session = await auth()
 
  if (!session?.user) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }
 
  return Response.json({ user: session.user })
}

5. Middleware 路由保护

5.1 基础保护

Middleware 在请求到达页面之前运行——是保护路由的第一道防线:

// middleware.ts
import { auth } from '@/auth'
import { NextResponse } from 'next/server'
 
export default auth((req) => {
  const isLoggedIn = !!req.auth
  const isOnDashboard = req.nextUrl.pathname.startsWith('/dashboard')
  const isOnAdmin = req.nextUrl.pathname.startsWith('/admin')
  const isOnAuth = req.nextUrl.pathname.startsWith('/login') ||
    req.nextUrl.pathname.startsWith('/register')
 
  // 未登录访问受保护页面 → 重定向到登录页
  if (!isLoggedIn && (isOnDashboard || isOnAdmin)) {
    const loginUrl = new URL('/login', req.nextUrl.origin)
    loginUrl.searchParams.set('callbackUrl', req.nextUrl.pathname)
    return NextResponse.redirect(loginUrl)
  }
 
  // 已登录访问登录页 → 重定向到仪表盘
  if (isLoggedIn && isOnAuth) {
    return NextResponse.redirect(new URL('/dashboard', req.nextUrl.origin))
  }
 
  // 管理员页面权限检查
  if (isOnAdmin && req.auth?.user?.role !== 'admin') {
    return NextResponse.redirect(new URL('/dashboard', req.nextUrl.origin))
  }
 
  return NextResponse.next()
})
 
export const config = {
  matcher: ['/dashboard/:path*', '/admin/:path*', '/login', '/register'],
}

5.2 matcher 配置

export const config = {
  // 只对这些路径运行 Middleware(性能优化——静态资源不需要鉴权)
  matcher: [
    '/((?!api/auth|_next/static|_next/image|favicon.ico).*)',
    // 排除 Auth.js API、静态文件、图片优化、favicon
  ],
}

6. 登录/注册 UI

6.1 登录页面

// app/login/page.tsx
import { signIn, providerMap } from '@/auth'
import { AuthError } from 'next-auth'
import { redirect } from 'next/navigation'
 
export default async function LoginPage({
  searchParams,
}: {
  searchParams: Promise<{ callbackUrl?: string; error?: string }>
}) {
  const { callbackUrl, error } = await searchParams
 
  return (
    <div className="flex min-h-screen items-center justify-center">
      <div className="w-full max-w-md space-y-6 p-8">
        <h1 className="text-2xl font-bold text-center">登录</h1>
 
        {error && (
          <div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
            {error === 'CredentialsSignin' ? '邮箱或密码错误' : '登录失败,请重试'}
          </div>
        )}
 
        {/* OAuth 登录按钮 */}
        <div className="space-y-3">
          <form
            action={async () => {
              'use server'
              await signIn('github', { redirectTo: callbackUrl ?? '/dashboard' })
            }}
          >
            <button
              type="submit"
              className="w-full flex items-center justify-center gap-2 bg-gray-900 text-white py-2 px-4 rounded-lg hover:bg-gray-800"
            >
              <GitHubIcon className="w-5 h-5" />
              使用 GitHub 登录
            </button>
          </form>
 
          <form
            action={async () => {
              'use server'
              await signIn('google', { redirectTo: callbackUrl ?? '/dashboard' })
            }}
          >
            <button
              type="submit"
              className="w-full flex items-center justify-center gap-2 border border-gray-300 py-2 px-4 rounded-lg hover:bg-gray-50"
            >
              <GoogleIcon className="w-5 h-5" />
              使用 Google 登录
            </button>
          </form>
        </div>
 
        <div className="relative">
          <div className="absolute inset-0 flex items-center">
            <div className="w-full border-t" />
          </div>
          <div className="relative flex justify-center text-sm">
            <span className="bg-white px-2 text-gray-500">或使用邮箱登录</span>
          </div>
        </div>
 
        {/* 邮箱密码登录 */}
        <form
          action={async (formData: FormData) => {
            'use server'
            try {
              await signIn('credentials', {
                email: formData.get('email'),
                password: formData.get('password'),
                redirectTo: callbackUrl ?? '/dashboard',
              })
            } catch (error) {
              if (error instanceof AuthError) {
                redirect(`/login?error=${error.type}`)
              }
              throw error
            }
          }}
          className="space-y-4"
        >
          <input
            name="email"
            type="email"
            placeholder="邮箱"
            required
            className="w-full px-3 py-2 border rounded-lg"
          />
          <input
            name="password"
            type="password"
            placeholder="密码"
            required
            className="w-full px-3 py-2 border rounded-lg"
          />
          <button
            type="submit"
            className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700"
          >
            登录
          </button>
        </form>
      </div>
    </div>
  )
}

6.2 退出登录

import { signOut } from '@/auth'
 
export function SignOutButton() {
  return (
    <form
      action={async () => {
        'use server'
        await signOut({ redirectTo: '/' })
      }}
    >
      <button type="submit">退出登录</button>
    </form>
  )
}

7. Callbacks 深入

7.1 Callback 执行顺序

登录流程: signIn → jwt → session → redirect
后续请求: jwt → session

7.2 signIn 回调

控制谁可以登录——可以限制域名、检查白名单等:

callbacks: {
  async signIn({ user, account, profile }) {
    // 只允许公司邮箱登录
    if (account?.provider === 'google') {
      return profile?.email?.endsWith('@company.com') ?? false
    }
 
    // 检查用户是否被封禁
    if (user.id) {
      const dbUser = await db.query.users.findFirst({
        where: eq(users.id, user.id),
      })
      if (dbUser?.banned) return false
    }
 
    return true
  },
}

7.3 Account Linking

当用户用 GitHub 和 Google 登录使用同一个邮箱时,默认会创建两个账号。启用 Account Linking 可以合并:

export const { handlers, signIn, signOut, auth } = NextAuth({
  // ...
  callbacks: {
    async signIn({ user, account }) {
      // 检查是否已有同邮箱的用户
      if (user.email) {
        const existingUser = await db.query.users.findFirst({
          where: eq(users.email, user.email),
        })
 
        if (existingUser && account) {
          // 把新的 OAuth 账号关联到已有用户
          await db.insert(accounts).values({
            userId: existingUser.id,
            type: account.type,
            provider: account.provider,
            providerAccountId: account.providerAccountId,
            access_token: account.access_token,
            refresh_token: account.refresh_token,
          })
        }
      }
      return true
    },
  },
})

本章小结

  • 安装next-auth@beta + AUTH_SECRET + Provider 环境变量
  • Provider:OAuth(推荐)、Credentials(需自行处理密码安全)、Magic Link(需数据库)
  • Session 策略:JWT(默认,无状态,不能即时注销)vs Database(有状态,支持即时注销)
  • 获取 Session:Server Component 用 auth(),Client Component 用 useSession()
  • 路由保护:Middleware 是第一道防线,在请求到达页面前拦截未认证用户
  • CallbackssignIn 控制准入,jwt 定制 Token,session 传递数据到客户端