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.local1.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>_ID 和 AUTH_<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-key3. 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-adapterimport { 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 是第一道防线,在请求到达页面前拦截未认证用户
- Callbacks:
signIn控制准入,jwt定制 Token,session传递数据到客户端