本系列文章将围绕Next.js技术栈,旨在为AI Agent开发者提供一套完整的客户端侧工程实践指南。
认证(Authentication) 解决"你是谁"的问题,鉴权(Authorization) 解决"你能做什么"的问题。这两者构成了应用安全的基石,实施不当将导致严重的安全漏洞。
一、认证方案选型
在 Next.js 生态中实现认证,主要有三种技术路线:
标准需求| C[Auth.js
原NextAuth.js] B -->|高度定制
企业内建| D[手动实现 JWT] B -->|托管服务
全功能| E[Clerk / Supabase Auth] C --> F[支持OAuth社交登录
内置Session管理
App Router深度集成] D --> G[完全自定义控制
需编写更多代码
自行处理安全细节] E --> H[ 功能最全面
零维护成本
产生服务费用
厂商锁定风险]
方案选择建议:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 初创项目/MVP | Auth.js | 快速搭建,社区活跃 |
| 企业内部系统 | 手动JWT | 已有认证基础设施 |
| SaaS产品 | Clerk/Supabase | 节省开发时间,专注业务 |
| 高安全要求 | 手动JWT + 审计 | 完全掌控安全策略 |
对于大多数应用场景,Auth.js(原 NextAuth.js v5) 是最佳选择:支持数十种 OAuth 提供商(Google、GitHub、微信等),内置 Session 管理机制,与 Next.js App Router 深度集成。
本章重点讲解 Auth.js 方案,同时剖析 JWT 手动实现的底层原理。
二、Auth.js 完整认证实现
1. 安装与基础配置
bash
npm install next-auth@beta
(1)实例化Next-Auth
typescript
// 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';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
import type { DefaultSession } from 'next-auth';
// 扩展 Session 类型
declare module 'next-auth' {
interface Session {
user: {
id: string;
role: string;
} & DefaultSession['user'];
}
}
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
// GitHub OAuth 登录
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
// Google OAuth 登录
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
// 邮箱密码登录(Credentials Provider)
Credentials({
name: '邮箱密码',
credentials: {
email: { label: '邮箱', type: 'email', placeholder: 'example@email.com' },
password: { label: '密码', type: 'password' },
},
async authorize(credentials) {
// 参数验证
if (!credentials?.email || !credentials?.password) {
return null;
}
// 查询用户
const user = await db.users.findUnique({
where: { email: credentials.email as string },
});
// 用户不存在或无密码
if (!user || !user.hashedPassword) {
return null;
}
// 验证密码
const isValid = await bcrypt.compare(
credentials.password as string,
user.hashedPassword
);
if (!isValid) {
return null;
}
// 返回用户信息(敏感信息不返回)
return {
id: user.id,
name: user.name,
email: user.email,
image: user.avatar,
role: user.role,
};
},
}),
],
// Session 配置
session: {
strategy: 'jwt', // 使用 JWT,无需数据库存储 Session
maxAge: 30 * 24 * 60 * 60, // 30 天过期
},
// 自定义页面路径
pages: {
signIn: '/login', // 自定义登录页
signOut: '/logout', // 自定义登出页
error: '/auth/error', // 错误页面
},
// Callbacks:自定义认证流程
callbacks: {
/**
* JWT Token 回调
* 在 token 创建/更新时触发
*/
async jwt({ token, user }) {
// 首次登录时,将用户信息添加到 token
if (user) {
token.id = user.id;
token.role = user.role;
}
return token;
},
/**
* Session 回调
* 每次读取 session 时触发
*/
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
},
// 调试模式(仅开发环境)
debug: process.env.NODE_ENV === 'development',
});
(2)定义route handler
typescript
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';
// 导出 GET 和 POST 处理器
export const { GET, POST } = handlers;
(3)填写配置信息
bash
# .env.local
AUTH_SECRET="your-secret-key-min-32-chars" # 使用 openssl rand -base64 32 生成
GITHUB_ID="your-github-app-id"
GITHUB_SECRET="your-github-app-secret"
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
安全提示 :
AUTH_SECRET必须至少 32 个字符,生产环境务必使用强随机字符串。
2. 登录页面实现
ts
// app/login/page.tsx
import { signIn } from '@/auth';
import { AuthError } from 'next-auth';
import { redirect } from 'next/navigation';
import Link from 'next/link';
interface LoginPageProps {
searchParams: Promise<{ callbackUrl?: string; error?: string }>;
}
export default async function LoginPage({
searchParams
}: LoginPageProps) {
const { callbackUrl, error } = await searchParams;
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md p-8 bg-white rounded-2xl shadow-lg border border-gray-100">
<h1 className="text-2xl font-bold text-center mb-8 text-gray-900">
欢迎登录
</h1>
{/* 错误提示 */}
{error && (
<div
className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm border border-red-200"
role="alert"
>
{error === 'CredentialsSignin'
? '邮箱或密码错误,请重试'
: '登录失败,请稍后重试'}
</div>
)}
{/* 邮箱密码登录表单 */}
<form
action={async (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"
>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-1"
>
邮箱地址
</label>
<input
id="email"
name="email"
type="email"
required
autoComplete="email"
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none 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 text-gray-700 mb-1"
>
密码
</label>
<input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
登录
</button>
</form>
{/* 分隔线 */}
<div className="my-6 flex items-center gap-4">
<hr className="flex-1 border-gray-300" />
<span className="text-gray-400 text-sm">或使用以下方式登录</span>
<hr className="flex-1 border-gray-300" />
</div>
{/* OAuth 登录按钮 */}
<div className="space-y-3">
{/* GitHub 登录 */}
<form
action={async () => {
'use server';
await signIn('github', {
redirectTo: callbackUrl || '/dashboard',
});
}}
>
<button
type="submit"
className="w-full flex items-center justify-center gap-2 border border-gray-300 py-2 rounded-lg hover:bg-gray-50 transition-colors"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
使用 GitHub 登录
</button>
</form>
{/* Google 登录 */}
<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 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.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<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="mt-6 text-center text-sm text-gray-600">
还没有账号?{' '}
<Link
href="/register"
className="text-blue-600 hover:text-blue-700 font-medium"
>
立即注册
</Link>
</p>
</div>
</div>
);
}
三、Session 获取与管理
Auth.js 提供了多种场景下的 Session 获取方式:
1. 服务端组件中获取
ts
// 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?callbackUrl=/dashboard');
}
return (
<div className="container mx-auto py-8">
<h1 className="text-2xl font-bold mb-4">
欢迎,{session.user.name}
</h1>
<p className="text-gray-600">邮箱:{session.user.email}</p>
<p className="text-gray-600">角色:{session.user.role}</p>
</div>
);
}
2. 客户端组件中获取
ts
// components/UserMenu.tsx
'use client';
import { useSession, signOut } from 'next-auth/react';
import Image from 'next/image';
import Link from 'next/link';
export function UserMenu() {
const { data: session, status } = useSession();
// 加载中状态
if (status === 'loading') {
return (
<div className="w-8 h-8 rounded-full bg-gray-200 animate-pulse" />
);
}
// 未登录
if (!session) {
return (
<Link
href="/login"
className="px-4 py-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
登录
</Link>
);
}
// 已登录
return (
<div className="flex items-center gap-3">
{session.user.image && (
<Image
src={session.user.image}
alt={`${session.user.name} 的头像`}
width={32}
height={32}
className="rounded-full border border-gray-200"
/>
)}
<span className="text-sm text-gray-700">{session.user.name}</span>
<button
onClick={() => signOut({ callbackUrl: '/' })}
className="text-sm text-gray-500 hover:text-red-600 transition-colors"
>
退出
</button>
</div>
);
}
需在根布局中包裹 SessionProvider:
ts
// app/layout.tsx
import { SessionProvider } from 'next-auth/react';
import { auth } from '@/auth';
import type { ReactNode } from 'react';
interface RootLayoutProps {
children: ReactNode;
}
export default async function RootLayout({
children
}: RootLayoutProps) {
const session = await auth();
return (
<html lang="zh-CN">
<body>
<SessionProvider session={session}>
{children}
</SessionProvider>
</body>
</html>
);
}
四、Middleware:路由级认证保护
中间件(Middleware )运行在 Edge Runtime ,在请求到达路由处理程序之前执行。这是实现路由级别认证保护的最佳位置------比在每个页面单独检查 Session 高效得多。
1. 基础路由保护
typescript
// middleware.ts(项目根目录)
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export default auth((req: NextRequest) => {
const { nextUrl } = req;
const session = req.auth;
const isLoggedIn = !!session?.user;
// 定义受保护路由
const protectedRoutes = [
'/dashboard',
'/profile',
'/settings',
'/admin',
];
const isProtectedRoute = protectedRoutes.some(route =>
nextUrl.pathname.startsWith(route)
);
// 未登录访问受保护页面 → 重定向到登录页
if (isProtectedRoute && !isLoggedIn) {
const redirectUrl = new URL('/login', nextUrl);
redirectUrl.searchParams.set('callbackUrl', nextUrl.pathname);
return NextResponse.redirect(redirectUrl);
}
// 已登录访问登录/注册页 → 重定向到仪表板
if (isLoggedIn && ['/login', '/register'].includes(nextUrl.pathname)) {
return NextResponse.redirect(new URL('/dashboard', nextUrl));
}
return NextResponse.next();
});
// 配置中间件匹配规则(排除静态资源)
export const config = {
matcher: [
/*
* 匹配所有路径,除了:
* - api(API 路由)
* - _next/static(静态文件)
* - _next/image(图片优化)
* - favicon.ico(网站图标)
* - public 文件夹
*/
'/((?!api|_next/static|_next/image|favicon.ico|public).*)',
],
};
2. 基于角色的访问控制(RBAC)
typescript
// middleware.ts
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export default auth((req: NextRequest) => {
const { nextUrl } = req;
const session = req.auth;
const userRole = session?.user?.role;
// 管理员路由保护
if (nextUrl.pathname.startsWith('/admin')) {
// 未登录
if (!session?.user) {
return NextResponse.redirect(new URL('/login', nextUrl));
}
// 非管理员
if (userRole !== 'admin') {
return NextResponse.redirect(new URL('/403', nextUrl));
}
}
// 编辑者路由保护
if (nextUrl.pathname.startsWith('/editor')) {
if (!session?.user) {
return NextResponse.redirect(new URL('/login', nextUrl));
}
const allowedRoles = ['admin', 'editor'];
if (!allowedRoles.includes(userRole || '')) {
return NextResponse.redirect(new URL('/403', nextUrl));
}
}
return NextResponse.next();
});
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
五、手动实现 JWT 认证(深入理解)
虽然推荐使用 Auth.js,但理解 JWT 认证的底层原理对应对复杂场景至关重要。许多企业内部系统已有后端 API 提供 JWT,前端只需处理 Token 的存储和传递。
1. JWT 工作流程
2. 安全 Token 管理
一般可以把Token存在HTTPOnly Cookie里。
typescript
// lib/session.ts
import { SignJWT, jwtVerify } from 'jose';
import { cookies } from 'next/headers';
// JWT 密钥(生产环境从环境变量读取)
const SECRET_KEY = new TextEncoder().encode(
process.env.JWT_SECRET || 'fallback-secret-change-in-production'
);
/**
* 创建 Session Token
* @param userId - 用户ID
* @param role - 用户角色
*/
export async function createSession(
userId: string,
role: string
): Promise<void> {
const token = await new SignJWT({ userId, role })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d') // 7 天过期
.sign(SECRET_KEY);
const cookieStore = await cookies();
// 将 JWT 存储在 HttpOnly Cookie 中
cookieStore.set('session', token, {
httpOnly: true, // JavaScript 无法读取,防止 XSS
secure: process.env.NODE_ENV === 'production', // 仅 HTTPS
sameSite: 'lax', // 防止 CSRF
maxAge: 7 * 24 * 60 * 60, // 7 天
path: '/',
});
}
/**
* 验证并读取 Session
* @returns Session 载荷或 null
*/
export async function getSession(): Promise<{
userId: string;
role: string
} | null> {
const cookieStore = await cookies();
const token = cookieStore.get('session')?.value;
if (!token) {
return null;
}
try {
const { payload } = await jwtVerify(token, SECRET_KEY);
return payload as { userId: string; role: string };
} catch (error) {
// Token 无效或已过期
console.error('Invalid token:', error);
return null;
}
}
/**
* 删除 Session(退出登录)
*/
export async function deleteSession(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete('session');
}
为什么使用 Cookie 而非 localStorage?
localStorage 可被 JavaScript 访问,易受 XSS 攻击(恶意脚本窃取 Token)。HttpOnly Cookie 无法被 JavaScript 访问,XSS 攻击无法窃取 Token。这是 Web 安全的核心实践之一。
六、安全防护最佳实践
1. 敏感信息绝不暴露于客户端
typescript
// ❌ 危险:在客户端存储权限信息
localStorage.setItem('isAdmin', 'true');
// ✅ 正确:仅在服务端验证权限
export async function DELETE(request: Request) {
const session = await getSession();
const user = await db.users.findUnique({
where: { id: session?.userId }
});
if (user?.role !== 'admin') {
return new Response('Forbidden', { status: 403 });
}
await deleteData();
}
2. 密码哈希存储
typescript
import bcrypt from 'bcryptjs';
// 注册时:哈希密码
const saltRounds = 12; // 10-12 为合理范围
const hashedPassword = await bcrypt.hash(plainPassword, saltRounds);
await db.users.create({
data: { email, hashedPassword }
});
// 登录时:验证密码
const isValid = await bcrypt.compare(plainPassword, hashedPassword);
if (!isValid) {
throw new Error('密码错误');
}
绝对禁止明文存储密码,即使数据库泄露,哈希密码也能保护用户安全。
3. 限制登录尝试次数
typescript
// lib/rate-limit.ts
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
const MAX_ATTEMPTS = 5;
const LOCKOUT_DURATION = 15 * 60; // 15 分钟
/**
* 检查登录速率限制
*/
export async function checkRateLimit(email: string): Promise<{
allowed: boolean;
remainingAttempts?: number;
lockoutUntil?: Date;
}> {
const key = `login_attempts:${email}`;
const attempts = await redis.get<number>(key) || 0;
if (attempts >= MAX_ATTEMPTS) {
const ttl = await redis.ttl(key);
return {
allowed: false,
lockoutUntil: new Date(Date.now() + ttl * 1000),
};
}
return {
allowed: true,
remainingAttempts: MAX_ATTEMPTS - attempts,
};
}
/**
* 记录登录失败
*/
export async function recordFailedAttempt(email: string): Promise<void> {
const key = `login_attempts:${email}`;
await redis.incr(key);
await redis.expire(key, LOCKOUT_DURATION);
}
/**
* 重置登录尝试计数
*/
export async function resetLoginAttempts(email: string): Promise<void> {
const key = `login_attempts:${email}`;
await redis.del(key);
}
4. CSRF 防护
Auth.js 内置 CSRF 保护。若手动实现 JWT,需添加 CSRF Token:
typescript
// 生成 CSRF Token
import { randomBytes } from 'crypto';
export function generateCsrfToken(): string {
return randomBytes(32).toString('hex');
}
// 验证 CSRF Token
export function verifyCsrfToken(
tokenFromCookie: string,
tokenFromBody: string
): boolean {
return tokenFromCookie === tokenFromBody;
}
七、本章小结
通过本章学习,你应该掌握了:
- Auth.js 的完整配置与使用方法
- OAuth 社交登录与邮箱密码登录的实现
- Session 在服务端和客户端组件中的获取方式
- Middleware 路由保护与 RBAC 权限控制
- JWT 手动实现的底层原理与安全存储
- 常见安全陷阱及防护策略(XSS、CSRF、暴力破解)
下一章《从原理到实践深度剖析缓存策略》将继续更深入地剖析 Next.js 的多层缓存架构,揭示其工作原理、最佳实践以及常见陷阱。