引言:App Router 时代,认证这件事变复杂了
说实话,当我第一次从 Pages Router 切换到 App Router 的时候,最让我头疼的不是路由语法的变化,而是突然意识到——我之前的认证方案可能根本不够安全。
在 Pages Router 的年代,事情相对简单。我们在 getServerSideProps 里做一次验证,或者在页面级别加个认证检查就完事了。这种"守好大门就行"的思路(也叫边界防御 Perimeter Security),在那个时代勉强说得过去。
但 App Router 把一切都搞复杂了。
服务器组件(Server Components)可以独立获取数据,一个页面里可能有好几个组件各自去查数据库。服务器操作(Server Actions)让客户端可以直接调用服务器端函数,完全绕过传统 API 路由。再加上流式渲染(Streaming),请求和响应的边界已经变得模糊了。在这种新架构下,光守大门肯定是不够的。
而 2025 年初曝出的 CVE-2025-29927 漏洞,更是给了所有人一记响亮的耳光——攻击者只需要加一个特殊的 HTTP 头部,就能彻底绕过中间件的认证保护。如果你的应用只靠中间件来做认证(老实说,很多人都是这么做的),那你的用户数据基本上就是在裸奔。
所以我们需要换一种思路:纵深防御(Defense-in-Depth)。简单来说就是别把鸡蛋放在一个篮子里,在中间件、服务器组件、数据访问层、API 路由等多个层面都做认证检查。即使某一层出了问题,其他层还能兜底。
这篇文章会带你从头到尾实现一套完整的纵深防御认证架构。代码都是可以直接用的,不是那种"示意一下"的伪代码。那么,让我们开始吧。
CVE-2025-29927:一个让整个社区都紧张的漏洞
在讲怎么防御之前,我们得先搞清楚为什么要这么做。CVE-2025-29927 是一个非常好的反面教材。
这个漏洞的原理其实不算复杂:攻击者在 HTTP 请求里加一个 x-middleware-subrequest 头部,Next.js 服务器就会以为这是一个内部子请求,然后直接跳过中间件执行。就这么简单,就这么致命。
哪些版本受影响
受影响的范围相当广:
- 12.x:12.3.5 之前的版本
- 13.x:13.5.9 之前的版本
- 14.x:14.2.25 之前的版本
- 15.x:15.2.3 之前的版本
如果你还在用这些版本,先别往下读了,赶紧升级。真的,这不是建议,这是紧急事项。
漏洞长什么样
来看一个典型的"自以为安全"的中间件写法:
// middleware.ts - 看起来没问题,但其实很脆弱
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value;
// 仅在中间件中验证 token
if (!token || !isValidToken(token)) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*'],
};
看起来挺合理的对吧?但攻击者只需要这样做:
# 正常访问——会被中间件拦下来
curl https://example.com/dashboard
# → 重定向到 /login
# 加一个头部,中间件直接被跳过了
curl -H "x-middleware-subrequest: 1" https://example.com/dashboard
# → 直接看到 /dashboard 的内容,仿佛中间件不存在
想想看,如果你的仪表板页面、管理后台、用户数据全都只靠中间件保护……后果不堪设想。
这件事教会了我们什么
CVE-2025-29927 给我的最大感触是:
- 中间件不是保险箱——它更像是门口的保安,有用但不是万无一失的。它做的事应该是改善体验(比如快速重定向未登录用户),而不是承担全部安全责任。
- 任何单一防线都可能被突破——这在安全领域其实是常识,但我们开发者(包括我自己)经常会犯懒。
- 纵深防御不是"高级需求",是基本要求——每一层都要独立做验证,不能假设前面已经验证过了。
- 安全检查越靠近数据越可靠——中间件可以被绕过,但数据访问层(DAL)是最后一道关卡。
纵深防御架构:四层安全模型
好了,道理讲完了,来看具体怎么做。我推荐的架构是四层安全模型,每一层各司其职,互相补位。
整体架构一览
┌─────────────────────────────────────────────────────────┐
│ Layer 1: Edge/Middleware Layer │
│ 职责:快速过滤、重定向、性能优化 │
│ ✓ 未登录用户快速重定向 │
│ ✓ 基础的 token 存在性检查 │
│ ✗ 不作为唯一安全防线 │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Layer 2: Server Component Layer │
│ 职责:页面和组件级别的认证 │
│ ✓ 获取完整的用户会话 │
│ ✓ 基于角色的条件渲染 │
│ ✓ 布局和页面级别的访问控制 │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Layer 3: Data Access Layer (DAL) │
│ 职责:数据级别的细粒度授权 │
│ ✓ 每次数据访问都验证权限 │
│ ✓ DTO 模式防止数据泄漏 │
│ ✓ 资源级别的访问控制 │
│ ⭐ 这是最关键的安全层 │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Layer 4: API Route/Server Action Layer │
│ 职责:外部 API 和操作的认证与授权 │
│ ✓ 服务器操作的独立验证 │
│ ✓ API 路由的 token 验证 │
│ ✓ 输入验证和速率限制 │
│ ✓ CSRF 保护 │
└─────────────────────────────────────────────────────────┘
这四层是怎么配合的
第一层(中间件)跑在边缘,速度最快。它的活儿就是把明显没登录的用户赶紧打发走,别让他们白白消耗服务器资源。但记住,这层是"锦上添花",不是"安全基石"。
第二层(服务器组件)在服务端渲染时做更详细的检查。这里能拿到完整的用户 session,可以做角色判断、条件渲染这些比较复杂的逻辑。
第三层(数据访问层 DAL)是整个架构的核心。所有数据库操作都走这一层,每次都要重新验证权限。即使前面三层全被突破了,DAL 依然能挡住未授权的数据访问。说白了,这才是真正的安全底线。
第四层(API 路由/服务器操作)处理来自外部的请求,包括 Server Actions。每个请求都要独立验证身份,还要加上输入校验、速率限制等额外保护。
"DAL 已经很强了,为什么还要其他层?"
这个问题我被问过很多次。答案很简单:
- 性能:中间件能在边缘就拦住未登录用户,省掉后续的服务器渲染和数据库查询
- 体验:用户没登录时直接跳转登录页,总比渲染完页面再告诉他"你没权限"要好得多
- 纵深:多一层就多一道保险,攻击者得逐层突破才行
- 代码质量:每层职责清晰,维护和审计起来都更轻松
中间件层:当好"门口保安"
虽然我一直在说中间件不能当唯一防线,但这不代表它没用。恰恰相反,用好中间件能大幅提升用户体验和性能。关键是摆正它的定位:它是保安,不是保险柜。
中间件该干什么
在纵深防御架构里,中间件应该只负责这些事:
- 把没有 session token 的用户快速重定向到登录页
- 做基础的 token 存在性检查(注意,是"存在性"检查,不是完整验证)
- 设置安全相关的 HTTP 头部
- 记录访问日志
- 做基础的速率限制
实现代码
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// 不需要认证的路由
const publicRoutes = ['/login', '/register', '/forgot-password', '/'];
// API 有自己的认证逻辑,不走中间件
const apiRoutes = ['/api'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const isPublicRoute = publicRoutes.some(route =>
pathname === route || pathname.startsWith(`${route}/`)
);
const isApiRoute = apiRoutes.some(route => pathname.startsWith(route));
if (isPublicRoute || isApiRoute) {
return NextResponse.next();
}
// 只检查 token 存不存在,不做完整验证
const sessionToken = request.cookies.get('authjs.session-token')?.value ||
request.cookies.get('__Secure-authjs.session-token')?.value;
if (!sessionToken) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
const response = NextResponse.next();
// 顺手设置一些安全头部,举手之劳
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
return response;
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};
中间件的天花板在哪
用中间件的时候,心里得清楚这几个限制:
- 它可以被绕过——CVE-2025-29927 已经证明了这一点。就算当前版本没漏洞,谁能保证以后不会有?
- Edge Runtime 的限制——中间件跑在边缘运行时,很多 Node.js API 用不了,做不了太复杂的验证
- 性能压力——中间件每个请求都会执行,如果在里面查数据库,那你的响应速度就别想快了
- 管不到 Server Actions——客户端直接调用的服务器操作,中间件基本拦不住
Auth.js v5 集成实战
认证库的选择上,我个人比较推荐 Auth.js v5(就是以前的 NextAuth.js)。它是专门为 App Router 重新设计的,跟服务器组件和服务器操作的集成都做得很好。
安装
# 安装 Auth.js v5
npm install next-auth@beta
# 如果用 Prisma 做数据库适配
npm install @auth/prisma-adapter
核心配置
这个配置文件会比较长,但每一部分都很重要,别跳着看:
// lib/auth.ts
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import Credentials from "next-auth/providers/credentials";
import { prisma } from "@/lib/prisma";
import { compare } from "bcryptjs";
import type { NextAuthConfig } from "next-auth";
// 扩展类型定义,把 role 加进去
declare module "next-auth" {
interface Session {
user: {
id: string;
email: string;
name: string;
role: string;
};
}
interface User {
id: string;
email: string;
name: string;
role: string;
}
}
export const authConfig: NextAuthConfig = {
adapter: PrismaAdapter(prisma),
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 天
},
pages: {
signIn: '/login',
signOut: '/logout',
error: '/auth/error',
},
providers: [
Credentials({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
throw new Error("请提供邮箱和密码");
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
select: {
id: true,
email: true,
name: true,
password: true,
role: true,
}
});
if (!user || !user.password) {
throw new Error("用户不存在或密码未设置");
}
const isPasswordValid = await compare(
credentials.password as string,
user.password
);
if (!isPasswordValid) {
throw new Error("密码错误");
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
};
}
})
],
callbacks: {
async jwt({ token, user, trigger, session }) {
if (user) {
token.id = user.id;
token.role = user.role;
}
if (trigger === "update" && session) {
token.name = session.name;
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
},
};
export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);
在服务器组件里用
// app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
// 第二层防御——即使中间件被绕过,这里还会挡一下
if (!session?.user) {
redirect('/login');
}
return (
欢迎, {session.user.name}
角色: {session.user.role}
);
}
在服务器操作里用
// app/actions/user-actions.ts
'use server';
import { auth } from "@/lib/auth";
import { z } from "zod";
const updateProfileSchema = z.object({
name: z.string().min(2, "名称至少 2 个字符"),
bio: z.string().max(500, "简介不能超过 500 字符").optional(),
});
export async function updateProfile(formData: FormData) {
// 第四层防御——Server Action 必须自己验证身份
const session = await auth();
if (!session?.user) {
throw new Error("未授权:请先登录");
}
const rawData = {
name: formData.get('name'),
bio: formData.get('bio'),
};
const result = updateProfileSchema.safeParse(rawData);
if (!result.success) {
return { error: result.error.flatten().fieldErrors };
}
// 最终还是通过 DAL 来执行更新
await updateUser(session.user.id, result.data);
return { success: true };
}
数据访问层(DAL):真正的安全底线
终于来到了整篇文章最重要的部分。说实话,如果你只能从这篇文章里带走一个概念,那就是 DAL。
数据访问层的核心理念是:不管请求是怎么到达这里的,不管前面的层做了什么检查(或者压根没检查),在碰数据之前,我都要自己验一遍。
DAL 的几条铁律
- 所有数据库操作必须走 DAL——绝对不允许在组件或 Server Action 里直接写
prisma.xxx.findMany() - DAL 不信任任何调用者——每次都自己验证身份和权限
- 最小权限——只返回用户该看到的数据,用 DTO 过滤掉敏感字段
- 只在服务端运行——用
'server-only'包确保代码不会被打包到客户端 - 用
cache()优化性能——同一请求内多次调用不会重复查数据库
基础结构
// lib/dal.ts
import 'server-only';
import { cache } from 'react';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
/**
* 验证用户身份——这是 DAL 的门卫
*/
export const verifySession = cache(async () => {
const session = await auth();
if (!session?.user?.id) {
throw new Error('未授权访问');
}
return {
isAuth: true,
userId: session.user.id,
role: session.user.role
};
});
type UserDTO = {
id: string;
name: string;
email: string;
avatar?: string;
bio?: string;
createdAt: Date;
// 注意看——password、resetToken 这些不在这里
};
/**
* 获取当前用户信息(已过滤敏感字段)
*/
export const getCurrentUser = cache(async (): Promise => {
const session = await verifySession();
const user = await prisma.user.findUnique({
where: { id: session.userId },
select: {
id: true,
name: true,
email: true,
avatar: true,
bio: true,
createdAt: true,
},
});
return user;
});
资源级别的权限检查
光验证"你是谁"还不够,还得检查"你能不能碰这个东西"。这就是资源级别的授权:
// lib/dal.ts(续)
/**
* 获取文章详情——未发布的只有作者和管理员能看
*/
export const getArticleById = cache(async (articleId: string) => {
const session = await verifySession();
const article = await prisma.article.findUnique({
where: { id: articleId },
include: {
author: {
select: { id: true, name: true, avatar: true },
},
},
});
if (!article) {
return null;
}
// 关键:资源级别的权限检查
if (!article.published) {
const isAuthor = article.authorId === session.userId;
const isAdmin = session.role === 'ADMIN';
if (!isAuthor && !isAdmin) {
throw new Error('无权访问此文章');
}
}
return article;
});
/**
* 创建文章——注意 authorId 是强制设置的,不接受外部传入
*/
export const createArticle = async (data: {
title: string;
content: string;
published: boolean;
}) => {
const session = await verifySession();
const article = await prisma.article.create({
data: {
...data,
authorId: session.userId, // 这里很重要:永远用当前用户的 ID
},
});
return article;
};
/**
* 更新文章——只有作者或管理员才行
*/
export const updateArticle = async (
articleId: string,
data: { title?: string; content?: string; published?: boolean; }
) => {
const session = await verifySession();
const existingArticle = await prisma.article.findUnique({
where: { id: articleId },
select: { authorId: true },
});
if (!existingArticle) {
throw new Error('文章不存在');
}
const isAuthor = existingArticle.authorId === session.userId;
const isAdmin = session.role === 'ADMIN';
if (!isAuthor && !isAdmin) {
throw new Error('无权编辑此文章');
}
return await prisma.article.update({
where: { id: articleId },
data,
});
};
DTO:别让敏感数据溜出去
你可能觉得"我查数据库的时候用了 select,已经过滤了啊"。没错,但如果哪天有人不小心改了查询呢?DTO 模式就是双重保险:
// lib/dto.ts
import 'server-only';
export function toUserDTO(user: any): UserDTO {
return {
id: user.id,
name: user.name,
email: user.email,
avatar: user.avatar,
bio: user.bio,
createdAt: user.createdAt,
// password、resetToken?它们在这里根本不存在
};
}
export function toAdminUserDTO(user: any) {
return {
...toUserDTO(user),
emailVerified: user.emailVerified,
role: user.role,
lastLoginAt: user.lastLoginAt,
// 即使是管理员视图,也不包含 password
};
}
服务器组件中的认证模式
服务器组件在服务端渲染,能直接访问数据库和敏感 API。在纵深防御里,它们承担的是第二层的角色。
在布局层做认证
一个常见的做法是在认证路由组的 layout 里统一检查:
// app/(authenticated)/layout.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/dal";
export default async function AuthenticatedLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session?.user) {
redirect('/login');
}
// 通过 DAL 获取用户信息(顺便又验证了一次)
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
return (
{children}
);
}
传用户信息给客户端组件——千万小心
有时候客户端组件需要知道当前用户是谁。但你必须只传非敏感信息,而且要通过一个专门的 Provider 来传:
// components/UserProvider.tsx
'use client';
import { createContext, useContext } from 'react';
// 注意这个类型——只包含可以公开的字段
type SafeUser = {
id: string;
name: string;
avatar?: string;
};
const UserContext = createContext(null);
export function UserProvider({
user,
children
}: {
user: SafeUser;
children: React.ReactNode;
}) {
return (
{children}
);
}
export function useUser() {
const context = useContext(UserContext);
if (!context) {
throw new Error('useUser must be used within UserProvider');
}
return context;
}
性能不用担心
你可能在想:"每个组件都调一次 getCurrentUser(),数据库扛得住吗?"放心,我们在 DAL 里用了 React 的 cache(),同一个请求里不管调多少次,实际只查一次数据库:
// 同一个请求里,这三次调用只会触发一次数据库查询
// layout.tsx
const user1 = await getCurrentUser(); // 查数据库
// page.tsx
const user2 = await getCurrentUser(); // 用缓存
// component.tsx
const user3 = await getCurrentUser(); // 还是用缓存
服务器操作的安全防护
Server Actions 可能是 App Router 里最容易被忽略安全性的地方了。很多开发者把它当成"内部函数"来用,但实际上它可以被客户端直接调用——本质上就是一个公开的 API 端点。必须按 API 的标准来保护它。
完整的安全写法
// app/actions/article-actions.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { createArticle, updateArticle } from '@/lib/dal';
const createArticleSchema = z.object({
title: z.string().min(5, '标题至少 5 个字符').max(200, '标题不能超过 200 字符').trim(),
content: z.string().min(10, '内容至少 10 个字符').max(50000, '内容不能超过 50000 字符'),
published: z.boolean().default(false),
});
type ActionResponse =
| { success: true; data: T }
| { success: false; error: string; fieldErrors?: Record };
export async function createArticleAction(
formData: FormData
): Promise> {
try {
// 第一步:验证身份
const session = await auth();
if (!session?.user) {
return { success: false, error: '未授权:请先登录' };
}
// 第二步:验证输入
const rawData = {
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'true',
};
const result = createArticleSchema.safeParse(rawData);
if (!result.success) {
return {
success: false,
error: '输入数据无效',
fieldErrors: result.error.flatten().fieldErrors,
};
}
// 第三步:通过 DAL 操作数据(DAL 内部还会再验一次)
const article = await createArticle(result.data);
revalidatePath('/dashboard');
return { success: true, data: { articleId: article.id } };
} catch (error) {
console.error('创建文章失败:', error);
// 注意:不要把具体错误信息返回给客户端
return { success: false, error: '创建文章失败,请重试' };
}
}
别忘了速率限制
没有速率限制的 API 就是在邀请攻击者来暴力破解。用 Upstash 的方案可以很方便地实现:
// lib/ratelimit.ts
import { Redis } from '@upstash/redis';
import { Ratelimit } from '@upstash/ratelimit';
export const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
// 常规操作:每 10 秒最多 5 个请求
export const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, '10 s'),
prefix: 'ratelimit:server-action',
});
// 敏感操作(改密码、删账号等):每小时最多 3 次
export const strictRatelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(3, '1 h'),
prefix: 'ratelimit:sensitive',
});
API 路由的认证策略
如果你的应用还需要提供 RESTful API(比如给移动端或者第三方用),API 路由就需要自己的认证机制了。
基于 Session 的认证
// app/api/articles/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { getArticlesByUserId } from '@/lib/dal';
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: '未授权访问' }, { status: 401 });
}
// 还是走 DAL——保持一致性
const articles = await getArticlesByUserId(session.user.id);
return NextResponse.json({ articles });
} catch (error) {
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
}
}
基于 JWT Bearer Token 的认证
给外部系统用的话,JWT Bearer Token 更合适:
// lib/jwt-auth.ts
import 'server-only';
import { jwtVerify, SignJWT } from 'jose';
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
export async function generateToken(payload: {
userId: string;
email: string;
role: string;
}) {
return await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(secret);
}
export async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, secret);
return {
userId: payload.userId as string,
email: payload.email as string,
role: payload.role as string,
};
} catch {
throw new Error('无效的 Token');
}
}
通用的认证封装
每个 API 路由都写一遍认证逻辑太啰嗦了,封装一个高阶函数:
// lib/api-middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyToken } from '@/lib/jwt-auth';
type ApiHandler = (
request: NextRequest,
context: { params?: any; auth: any }
) => Promise;
export function withAuth(handler: ApiHandler) {
return async (request: NextRequest, context: any) => {
try {
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json({ error: '缺少认证 Token' }, { status: 401 });
}
const token = authHeader.substring(7);
const auth = await verifyToken(token);
return await handler(request, { ...context, auth });
} catch {
return NextResponse.json({ error: '认证失败' }, { status: 401 });
}
};
}
会话管理:那些容易忽略的细节
认证做得再好,如果会话管理有漏洞,也白搭。会话劫持、会话固定这些攻击听起来离我们很远,但实际上比想象中常见。
Cookie 的安全配置
// 在 Auth.js 配置里
cookies: {
sessionToken: {
name: `${process.env.NODE_ENV === 'production' ? '__Secure-' : ''}authjs.session-token`,
options: {
httpOnly: true, // JS 访问不了,防 XSS
sameSite: 'lax', // 防 CSRF
path: '/',
secure: process.env.NODE_ENV === 'production', // 生产环境强制 HTTPS
},
},
},
会话失效和 Token 轮换
用户改了密码,或者你怀疑 token 泄漏了,需要能一键让所有会话失效:
// lib/session.ts
import 'server-only';
import { prisma } from '@/lib/prisma';
/**
* 让某个用户的所有会话失效
* 原理:给 sessionVersion 加 1,旧 token 里的版本号就对不上了
*/
export async function invalidateAllUserSessions(userId: string) {
await prisma.user.update({
where: { id: userId },
data: { sessionVersion: { increment: 1 } },
});
}
/**
* 在 JWT 回调里检查版本号是否匹配
*/
export async function isSessionValid(
userId: string,
tokenSessionVersion?: number
) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { sessionVersion: true },
});
if (!user) return false;
return tokenSessionVersion === user.sessionVersion;
}
实战总结:完整的请求流程
最后来看看一个完整的请求是怎么经过四层防御的。假设用户要访问 /dashboard/articles/123 并编辑文章:
用户请求: GET /dashboard/articles/123
↓
┌──────────────────────────────────────────┐
│ Layer 1: Middleware │
│ ✓ session token 存在吗? │
│ ✓ 不存在 → 重定向到 /login │
└──────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ Layer 2: Server Component (page.tsx) │
│ ✓ auth() 拿到完整的 session │
│ ✓ 无效 → 重定向或报错 │
│ ✓ 调 DAL 拿文章数据 │
└──────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ Layer 3: Data Access Layer │
│ ✓ 再次验证用户身份 │
│ ✓ 这篇文章你有权看吗? │
│ ✓ 返回 DTO(敏感字段已过滤) │
└──────────────────────────────────────────┘
↓
用户提交编辑: Server Action
↓
┌──────────────────────────────────────────┐
│ Layer 4: Server Action │
│ ✓ 再验一次身份 │
│ ✓ Zod 校验输入 │
│ ✓ 速率限制检查 │
│ ✓ 调 DAL 执行更新 │
└──────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ Layer 3: DAL(又来了) │
│ ✓ 再次验证身份 │
│ ✓ 你有权编辑这篇文章吗? │
│ ✓ 通过后执行数据库更新 │
└──────────────────────────────────────────┘
看起来验了很多次?没错,这就是纵深防御。每一层都不假设前面已经验过了,所以即使某一层出了问题,数据依然是安全的。
项目文件结构参考
next-app/
├── app/
│ ├── (authenticated)/ # 需要认证的路由组
│ │ ├── layout.tsx # 认证布局(Layer 2)
│ │ ├── dashboard/
│ │ │ └── page.tsx
│ │ └── admin/
│ │ └── page.tsx
│ ├── (public)/ # 公开路由组
│ │ ├── login/page.tsx
│ │ └── register/page.tsx
│ ├── actions/ # 服务器操作(Layer 4)
│ │ ├── auth-actions.ts
│ │ └── article-actions.ts
│ ├── api/
│ │ ├── auth/[...nextauth]/route.ts
│ │ └── v1/articles/route.ts
│ └── middleware.ts # 中间件(Layer 1)
├── lib/
│ ├── auth.ts # Auth.js 配置
│ ├── dal.ts # 数据访问层(Layer 3)
│ ├── dto.ts # DTO 定义
│ ├── prisma.ts
│ ├── ratelimit.ts
│ └── jwt-auth.ts
└── components/
└── UserProvider.tsx
安全检查清单
最后给你一份可以直接拿去用的检查清单。新项目上线前、老项目做安全审计的时候,对着过一遍。
架构层面
- 实施了纵深防御,不依赖单一安全层
- 中间件、服务器组件、DAL、Server Actions/API 都有独立的认证检查
- 所有数据访问都通过 DAL 集中管理
- 用了
server-only保护服务端代码
中间件层
- 中间件只做快速过滤和重定向,不承担核心安全职责
- Next.js 已升级到修复 CVE-2025-29927 的版本
认证与数据层
- 使用了成熟的认证库(推荐 Auth.js v5)
- 服务器组件中独立验证 session
- 用 React
cache()优化了重复调用的性能 - DAL 实施了资源级别的权限检查
- DTO 模式防止敏感数据泄漏
操作与 API 层
- 每个 Server Action 都独立验证身份
- 用 Zod 做了严格的输入验证
- 实施了速率限制
- 错误信息不暴露内部细节
会话管理
- Cookie 配置了 HttpOnly、Secure、SameSite
- 有 token 轮换机制
- 有会话失效功能(比如改密码后踢掉所有旧会话)
记住这五条原则
- 永远不要信任边界——每一层都要自己验证
- 最小权限原则——只给用户该看到的数据
- 纵深优于边界——多层防护比单层铜墙铁壁更可靠
- 安全检查靠近数据——DAL 是最后也是最重要的防线
- 永远不信客户端——所有来自客户端的数据都要校验
安全这件事没有"做完了"的那一天。新的漏洞会不断出现,攻击手法也在持续进化。但只要你的架构是纵深的、分层的,就算某一层出了新漏洞,你的应用也不会一溃千里。这就是纵深防御的价值所在。