本章目标:全面掌握 Next.js 中的认证体系——从 NextAuth.js v5(Auth.js)的安装配置、多种登录方式、Session 管理,到角色权限控制(RBAC),并了解 Lucia / Clerk 等替代方案的选型策略。
11.1 认证方案选型
三大主流方案对比
| 维度 | NextAuth.js v5 | Lucia | Clerk |
|---|---|---|---|
| 类型 | 开源库 | 开源库 | SaaS 服务 |
| 定价 | 免费 | 免费 | 免费层 + 付费 |
| 数据库 | 自带 Adapter(Prisma 等) | 你自己管理 | Clerk 托管 |
| UI 组件 | 需自建 | 需自建 | 提供完整 UI |
| OAuth | 内置 30+ Provider | 内置支持 | 内置支持 |
| Session 策略 | JWT / Database | 仅 Database | 托管 |
| App Router | ✅ 原生支持 | ✅ 原生支持 | ✅ 原生支持 |
| Edge Runtime | ⚠️ 部分支持 | ✅ 良好 | ✅ 良好 |
| 自定义程度 | 高 | 极高 | 中 |
| 学习曲线 | 中 | 高 | 低 |
| 适合场景 | 通用 Web 应用 | 需要完全控制 | 快速上线 / MVP |
本教程选择:NextAuth.js v5
理由:
- 生态最成熟:社区最大、文档最全、问题解答最多
- 与 Next.js 深度集成:App Router 原生支持
- 灵活性高:JWT / Database Session 可选
- OAuth 支持广泛:GitHub / Google / 微信 等 30+ Provider
11.2 NextAuth.js v5 安装与配置
安装
npm install next-auth@beta @auth/prisma-adapter
npm install bcryptjs
npm install -D @types/bcryptjs
注意:NextAuth.js v5 目前仍以
next-auth@beta发布,最终将迁移为next-auth@5。
环境变量
# .env
# NextAuth
AUTH_SECRET="your-random-secret-at-least-32-chars"
# 生成方式:npx auth secret
AUTH_URL="http://localhost:3000"
# OAuth Providers
AUTH_GITHUB_ID="your-github-client-id"
AUTH_GITHUB_SECRET="your-github-client-secret"
AUTH_GOOGLE_ID="your-google-client-id"
AUTH_GOOGLE_SECRET="your-google-client-secret"
核心配置文件
// auth.ts(项目根目录)
import NextAuth from 'next-auth';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/prisma';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import Credentials from 'next-auth/providers/credentials';
import { compare } from 'bcryptjs';
import type { NextAuthConfig } from 'next-auth';
export const authConfig: NextAuthConfig = {
adapter: PrismaAdapter(prisma),
// Session 策略:JWT(无状态,适合 Serverless)
session: {
strategy: 'jwt',
maxAge: 60 * 60 * 24 * 7, // 7 天
},
// OAuth + 邮箱密码 Provider
providers: [
GitHub({
clientId: process.env.AUTH_GITHUB_ID!,
clientSecret: process.env.AUTH_GITHUB_SECRET!,
}),
Google({
clientId: process.env.AUTH_GOOGLE_ID!,
clientSecret: process.env.AUTH_GOOGLE_SECRET!,
}),
Credentials({
name: 'credentials',
credentials: {
email: { label: '邮箱', type: 'email' },
password: { label: '密码', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.password) {
return null;
}
const isValid = await compare(
credentials.password as string,
user.password
);
if (!isValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
};
},
}),
],
// 自定义页面路径
pages: {
signIn: '/login',
signUp: '/register',
error: '/auth/error',
verifyRequest: '/auth/verify',
},
// 回调函数
callbacks: {
// JWT 回调:将用户信息写入 token
async jwt({ token, user, trigger, session }) {
// 首次登录时,将用户信息写入 token
if (user) {
token.id = user.id;
token.role = (user as any).role;
}
// 支持客户端触发 session 更新
if (trigger === 'update' && session) {
token.name = session.name;
token.picture = session.picture;
}
return token;
},
// Session 回调:从 token 读取信息到 session
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
// 登录成功后重定向
async redirect({ url, baseUrl }) {
// 相对路径重定向
if (url.startsWith('/')) return `${baseUrl}${url}`;
// 同域重定向
if (new URL(url).origin === baseUrl) return url;
// 默认重定向到首页
return baseUrl;
},
},
// 事件钩子
events: {
// 新用户注册时
async createUser({ user }) {
console.log('New user created:', user.email);
},
// OAuth 登录关联账号时
async linkAccount({ user, account, profile }) {
console.log('Account linked:', account.provider, user.email);
},
},
};
export const {
handlers,
auth,
signIn,
signOut,
} = NextAuth(authConfig);
路由挂载
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';
export const { GET, POST } = handlers;
类型扩展
// types/next-auth.d.ts
import 'next-auth';
import 'next-auth/jwt';
declare module 'next-auth' {
interface Session {
user: {
id: string;
email: string;
name?: string | null;
image?: string | null;
role: string;
};
}
interface User {
id: string;
email: string;
name?: string | null;
role: string;
}
}
declare module 'next-auth/jwt' {
interface JWT {
id: string;
role: string;
}
}
11.3 登录 / 注册页面
登录页面
// app/(auth)/login/page.tsx
import { signIn } from '@/auth';
import { LoginForm } from '@/app/components/auth/LoginForm';
import { redirect } from 'next/navigation';
import { auth } from '@/auth';
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ callbackUrl?: string }>;
}) {
// 已登录用户直接重定向
const session = await auth();
if (session?.user) {
redirect('/dashboard');
}
const params = await searchParams;
const callbackUrl = params.callbackUrl || '/dashboard';
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-full max-w-md space-y-8 px-4">
<div className="text-center">
<h1 className="text-3xl font-bold">欢迎回来</h1>
<p className="mt-2 text-gray-600">登录你的账号</p>
</div>
{/* 登录表单 */}
<LoginForm callbackUrl={callbackUrl} />
{/* 分割线 */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-gray-50 text-gray-500">或使用第三方登录</span>
</div>
</div>
{/* OAuth 按钮 */}
<div className="space-y-3">
<form
action={async () => {
'use server';
await signIn('github', { redirectTo: callbackUrl });
}}
>
<button
type="submit"
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
GitHub 登录
</button>
</form>
<form
action={async () => {
'use server';
await signIn('google', { redirectTo: callbackUrl });
}}
>
<button
type="submit"
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
Google 登录
</button>
</form>
</div>
<p className="text-center text-sm text-gray-600">
还没有账号?{' '}
<a href="/register" className="text-blue-600 hover:underline">
立即注册
</a>
</p>
</div>
</div>
);
}
登录表单组件
// app/components/auth/LoginForm.tsx
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
type LoginState = {
error?: string;
};
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors font-medium"
>
{pending ? '登录中...' : '登录'}
</button>
);
}
export function LoginForm({ callbackUrl }: { callbackUrl: string }) {
const router = useRouter();
const [state, formAction] = useActionState<LoginState, FormData>(
async (prevState, formData) => {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
if (!email || !password) {
return { error: '请填写邮箱和密码' };
}
try {
const result = await signIn('credentials', {
email,
password,
redirect: false,
callbackUrl,
});
if (result?.error) {
return { error: '邮箱或密码错误' };
}
if (result?.url) {
router.push(result.url);
router.refresh();
}
} catch {
return { error: '登录失败,请稍后重试' };
}
return {};
},
{ error: undefined }
);
return (
<form action={formAction} className="space-y-4">
{state.error && (
<div className="p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{state.error}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
邮箱
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="your@email.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1">
密码
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" name="remember" className="rounded" />
记住我
</label>
<a href="/auth/forgot-password" className="text-sm text-blue-600 hover:underline">
忘记密码?
</a>
</div>
<SubmitButton />
</form>
);
}
注册页面
// app/(auth)/register/page.tsx
import { RegisterForm } from '@/app/components/auth/RegisterForm';
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function RegisterPage() {
const session = await auth();
if (session?.user) {
redirect('/dashboard');
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-full max-w-md space-y-8 px-4">
<div className="text-center">
<h1 className="text-3xl font-bold">创建账号</h1>
<p className="mt-2 text-gray-600">开始你的创作之旅</p>
</div>
<RegisterForm />
<p className="text-center text-sm text-gray-600">
已有账号?{' '}
<a href="/login" className="text-blue-600 hover:underline">
立即登录
</a>
</p>
</div>
</div>
);
}
注册 Server Action
// app/actions/auth.ts
'use server';
import { prisma } from '@/lib/prisma';
import { hash } from 'bcryptjs';
import { z } from 'zod';
import { signIn } from '@/auth';
const registerSchema = z.object({
name: z.string().min(2, '昵称至少 2 个字符').max(50),
email: z.string().email('邮箱格式不正确'),
password: z.string().min(8, '密码至少 8 个字符'),
});
export type RegisterState = {
error?: string;
fieldErrors?: Record<string, string>;
};
export async function register(
prevState: RegisterState,
formData: FormData
): Promise<RegisterState> {
const raw = {
name: formData.get('name'),
email: formData.get('email'),
password: formData.get('password'),
};
const validation = registerSchema.safeParse(raw);
if (!validation.success) {
const fieldErrors: Record<string, string> = {};
for (const issue of validation.error.issues) {
const field = issue.path[0]?.toString() ?? 'form';
fieldErrors[field] = issue.message;
}
return { fieldErrors };
}
const { name, email, password } = validation.data;
// 检查邮箱是否已注册
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return { error: '该邮箱已被注册' };
}
// 创建用户
const hashedPassword = await hash(password, 12);
const user = await prisma.user.create({
data: {
name,
email,
password: hashedPassword,
emailVerified: new Date(),
},
});
// 自动登录
await signIn('credentials', {
email,
password,
redirectTo: '/dashboard',
});
return {};
}
11.4 Session 管理
在 Server Component 中获取 Session
// app/dashboard/layout.tsx
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session?.user) {
redirect('/login?callbackUrl=/dashboard');
}
return (
<div className="flex min-h-screen">
<aside className="w-64 bg-gray-900 text-white p-4">
<div className="flex items-center gap-3 mb-8">
{session.user.image ? (
<img
src={session.user.image}
alt={session.user.name || ''}
className="w-10 h-10 rounded-full"
/>
) : (
<div className="w-10 h-10 rounded-full bg-blue-600 flex items-center justify-center text-white font-bold">
{session.user.name?.[0] || session.user.email[0]}
</div>
)}
<div className="overflow-hidden">
<p className="font-medium truncate">{session.user.name}</p>
<p className="text-sm text-gray-400 truncate">{session.user.email}</p>
</div>
</div>
<nav className="space-y-1">
<a href="/dashboard" className="block px-3 py-2 rounded hover:bg-gray-800">
概览
</a>
<a href="/dashboard/articles" className="block px-3 py-2 rounded hover:bg-gray-800">
文章管理
</a>
<a href="/dashboard/settings" className="block px-3 py-2 rounded hover:bg-gray-800">
设置
</a>
</nav>
</aside>
<main className="flex-1 p-8">
{children}
</main>
</div>
);
}
在 Client Component 中获取 Session
// app/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 className="animate-pulse h-10 w-32 bg-gray-200 rounded" />;
}
if (!session?.user) {
return (
<a href="/login" className="text-sm text-blue-600 hover:underline">
登录
</a>
);
}
return (
<div className="flex items-center gap-3">
<span className="text-sm">{session.user.name || session.user.email}</span>
<button
onClick={() => signOut({ callbackUrl: '/' })}
className="text-sm text-red-600 hover:underline"
>
退出
</button>
</div>
);
}
注意:在 Client Component 中使用
useSession需要在根布局中包裹SessionProvider:
// app/providers.tsx
'use client';
import { SessionProvider } from 'next-auth/react';
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}
// app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
在 Server Action 中获取 Session
// app/actions/article.ts
'use server';
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export async function createArticle(formData: FormData) {
// 获取当前用户 session
const session = await auth();
if (!session?.user?.id) {
redirect('/login');
}
// session.user.id 是类型安全的
const article = await prisma.article.create({
data: {
title: formData.get('title') as string,
content: formData.get('content') as string,
authorId: session.user.id,
},
});
return article;
}
在 Route Handler 中获取 Session
// app/api/me/route.ts
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
export async function GET() {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return NextResponse.json(session.user);
}
11.5 角色权限控制(RBAC)
权限辅助函数
// lib/auth-utils.ts
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
// 获取当前用户(未登录返回 null)
export async function getCurrentUser() {
const session = await auth();
return session?.user ?? null;
}
// 要求登录(未登录重定向)
export async function requireAuth() {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
return user;
}
// 要求特定角色
export async function requireRole(role: string | string[]) {
const user = await requireAuth();
const roles = Array.isArray(role) ? role : [role];
if (!roles.includes(user.role)) {
redirect('/403');
}
return user;
}
// 检查权限(不重定向,返回 boolean)
export async function hasPermission(resource: string, action: string) {
const user = await getCurrentUser();
if (!user) return false;
const permissions: Record<string, string[]> = {
admin: ['article:*', 'comment:*', 'user:*', 'settings:*'],
editor: ['article:create', 'article:read', 'article:update', 'comment:*', 'user:read'],
user: ['article:create', 'article:read', 'comment:create', 'comment:read', 'user:read'],
};
const userPermissions = permissions[user.role] || [];
const requiredPermission = `${resource}:${action}`;
return userPermissions.some(
(perm) =>
perm === requiredPermission ||
perm === `${resource}:*` ||
perm === '*:*'
);
}
在页面中应用权限
// app/admin/page.tsx
import { requireRole } from '@/lib/auth-utils';
import { prisma } from '@/lib/prisma';
export default async function AdminPage() {
// 只有管理员可以访问
const user = await requireRole('admin');
const [userCount, articleCount, commentCount] = await Promise.all([
prisma.user.count(),
prisma.article.count(),
prisma.comment.count(),
]);
return (
<div>
<h1 className="text-2xl font-bold mb-6">管理后台</h1>
<p className="text-gray-600 mb-8">欢迎,{user.name}(管理员)</p>
<div className="grid grid-cols-3 gap-6">
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold">用户</h3>
<p className="text-3xl font-bold text-blue-600 mt-2">{userCount}</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold">文章</h3>
<p className="text-3xl font-bold text-green-600 mt-2">{articleCount}</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold">评论</h3>
<p className="text-3xl font-bold text-purple-600 mt-2">{commentCount}</p>
</div>
</div>
</div>
);
}
条件渲染组件
// app/components/auth/RoleGuard.tsx
import { auth } from '@/auth';
type RoleGuardProps = {
roles: string | string[];
children: React.ReactNode;
fallback?: React.ReactNode;
};
export async function RoleGuard({ roles, children, fallback }: RoleGuardProps) {
const session = await auth();
const userRole = session?.user?.role;
const requiredRoles = Array.isArray(roles) ? roles : [roles];
const hasAccess = userRole && requiredRoles.includes(userRole);
if (!hasAccess) {
return fallback ? <>{fallback}</> : null;
}
return <>{children}</>;
}
使用方式:
// app/articles/[slug]/page.tsx
import { RoleGuard } from '@/app/components/auth/RoleGuard';
export default async function ArticlePage({ params }: { params: { slug: string } }) {
const article = await getArticle(params.slug);
return (
<article>
<h1>{article.title}</h1>
{/* 只有作者或管理员可以看到编辑按钮 */}
<RoleGuard roles={['admin', 'editor']}>
<a href={`/articles/${article.slug}/edit`} className="text-blue-600">
编辑文章
</a>
</RoleGuard>
{/* 只有管理员可以看到删除按钮 */}
<RoleGuard roles={['admin']} fallback={<span className="text-gray-400">无权限删除</span>}>
<button className="text-red-600">删除文章</button>
</RoleGuard>
</article>
);
}
权限保护 API
// app/api/v1/admin/users/route.ts
import { NextResponse } from 'next/server';
import { requireRole } from '@/lib/auth-utils';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
await requireRole('admin');
} catch {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
role: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
});
return NextResponse.json(users);
}
11.6 OAuth 集成详解
GitHub OAuth 配置
- 前往 GitHub Developer Settings
- 创建 OAuth App:
- Homepage URL:
http://localhost:3000 - Authorization callback URL:
http://localhost:3000/api/auth/callback/github
- Homepage URL:
- 复制 Client ID 和 Client Secret 到
.env
自定义 OAuth Callback
// auth.ts 中扩展 callbacks
callbacks: {
// OAuth 登录时的账号关联逻辑
async signIn({ user, account, profile }) {
if (account?.provider === 'github') {
// 检查是否已有同邮箱的账号
const existingUser = await prisma.user.findUnique({
where: { email: user.email! },
include: { accounts: true },
});
if (existingUser && existingUser.accounts.length === 0) {
// 关联到已有账号
await prisma.account.create({
data: {
userId: existingUser.id,
type: account.type,
provider: account.provider,
providerAccountId: account.providerAccountId,
access_token: account.access_token,
token_type: account.token_type,
},
});
user.id = existingUser.id;
}
}
return true;
},
}
多账号关联
// app/dashboard/settings/accounts/page.tsx
import { auth } from '@/auth';
import { prisma } from '@/lib/prisma';
import { LinkAccountButton } from '@/app/components/auth/LinkAccountButton';
export default async function AccountsSettingsPage() {
const session = await auth();
if (!session?.user?.id) return null;
const accounts = await prisma.account.findMany({
where: { userId: session.user.id },
select: { provider: true, providerAccountId: true },
});
const linkedProviders = accounts.map((a) => a.provider);
const providers = [
{ id: 'github', name: 'GitHub', icon: '🐙' },
{ id: 'google', name: 'Google', icon: '🔍' },
];
return (
<div>
<h2 className="text-xl font-bold mb-4">关联账号</h2>
<div className="space-y-3">
{providers.map((provider) => {
const isLinked = linkedProviders.includes(provider.id);
return (
<div
key={provider.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="flex items-center gap-3">
<span className="text-2xl">{provider.icon}</span>
<div>
<p className="font-medium">{provider.name}</p>
<p className="text-sm text-gray-500">
{isLinked ? '已关联' : '未关联'}
</p>
</div>
</div>
{isLinked ? (
<span className="text-sm text-green-600">✓ 已连接</span>
) : (
<LinkAccountButton provider={provider.id} />
)}
</div>
);
})}
</div>
</div>
);
}
11.7 Middleware 路由保护
// middleware.ts(项目根目录)
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// 公开路由(不需要登录)
const publicRoutes = [
'/',
'/login',
'/register',
'/auth/error',
'/auth/verify',
'/articles',
'/about',
];
// 管理员路由
const adminRoutes = ['/admin'];
// API 公开路由
const publicApiRoutes = ['/api/auth', '/api/v1/articles'];
export default async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 跳过静态资源和 Next.js 内部路由
if (
pathname.startsWith('/_next') ||
pathname.startsWith('/api/auth') ||
pathname.includes('.')
) {
return NextResponse.next();
}
const session = await auth();
// API 路由保护
if (pathname.startsWith('/api/')) {
if (publicApiRoutes.some((route) => pathname.startsWith(route))) {
return NextResponse.next();
}
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return NextResponse.next();
}
// 公开路由直接放行
if (publicRoutes.some((route) => pathname === route || pathname.startsWith(route + '/'))) {
return NextResponse.next();
}
// 需要登录的路由
if (!session) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
// 管理员路由检查
if (adminRoutes.some((route) => pathname.startsWith(route))) {
if (session.user.role !== 'admin') {
return NextResponse.redirect(new URL('/403', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: [
// 匹配所有路由,排除静态文件
'/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)',
],
};
11.8 安全最佳实践
密码安全
// lib/password.ts
import { hash, compare } from 'bcryptjs';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return hash(password, SALT_ROUNDS);
}
export async function verifyPassword(
password: string,
hashedPassword: string
): Promise<boolean> {
return compare(password, hashedPassword);
}
// 密码强度验证
export function validatePasswordStrength(password: string): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (password.length < 8) {
errors.push('密码至少 8 个字符');
}
if (!/[A-Z]/.test(password)) {
errors.push('至少包含一个大写字母');
}
if (!/[a-z]/.test(password)) {
errors.push('至少包含一个小写字母');
}
if (!/[0-9]/.test(password)) {
errors.push('至少包含一个数字');
}
if (!/[^A-Za-z0-9]/.test(password)) {
errors.push('至少包含一个特殊字符');
}
return { valid: errors.length === 0, errors };
}
Session 安全
// auth.ts 中的安全配置
export const authConfig: NextAuthConfig = {
session: {
strategy: 'jwt',
maxAge: 60 * 60 * 24 * 7, // 7 天
updateAge: 60 * 60 * 24, // 每 24 小时更新一次
},
// Cookie 安全设置
cookies: {
sessionToken: {
name: 'auth-session',
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NODE_ENV === 'production',
},
},
},
};
登出保护
// app/actions/auth.ts
export async function logout() {
'use server';
await signOut({ redirectTo: '/' });
}
// app/components/LogoutButton.tsx
'use client';
import { signOut } from 'next-auth/react';
export function LogoutButton() {
return (
<button
onClick={() => signOut({ callbackUrl: '/' })}
className="text-sm text-red-600 hover:underline"
>
退出登录
</button>
);
}
本章小结
Key Takeaways
- NextAuth.js v5 是 Next.js 认证的首选方案:与 App Router 深度集成,支持 JWT / Database Session
- JWT Session 适合 Serverless:无状态,不需要查库验证
auth()函数是获取 Session 的统一方式:Server Component / Server Action / Route Handler / Middleware 均可使用- RBAC 需要在每一层做检查:页面级(redirect)、API 级(401/403)、Server Action 级(验证)
- Middleware 做路由守卫:统一拦截,避免在每个页面重复认证逻辑
- OAuth 集成注意账号关联:同一邮箱的多个 OAuth Provider 应该关联到同一用户
下一步
下一章我们将深入 Middleware 中间件——不仅用于认证,还可以实现国际化、A/B 测试、请求重写、日志监控等高级功能。
参考资料
- NextAuth.js v5 官方文档
- NextAuth.js + Prisma Adapter
- OAuth 2.0 规范
- JWT.io - JSON Web Tokens
- OWASP Authentication Cheat Sheet
- Lucia Auth
- Clerk Next.js 集成
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。