杜绝数据库字段泄露:专为 Next.js 应用设计的数据访问层
了解在 Next.js 服务器组件中,数据访问层如何集中处理身份验证检查和字段过滤,从而确保敏感列不会意外传送到浏览器。
服务器组件允许你直接从组件中查询数据库。但问题在于:无论你传递给客户端组件的是什么对象,都会被序列化并发送到浏览器,其中包括那些你本不想显示的字段。数据访问层(DAL)位于组件与数据库之间,从而将身份验证、权限控制以及字段过滤集中在一处处理。本文将介绍如何构建数据访问层、其中应包含哪些内容,以及何时值得为此增加额外的文件。
什么是数据访问层
DAL是一个专用文件夹,用于存储所有的数据库查询。组件从不直接调用Prisma,而是调用诸如getProfile这样的函数,这些函数决定调用者能看到什么内容以及返回哪些字段。可以将其视为通往用户界面的检查点:它负责验证请求者身份,并剔除屏幕上不需要的所有数据。Next.js的数据安全文档建议新项目采用这种方法。
典型的架构是将每个域的认证辅助函数与查询模块放在一起:
src/
data/
auth.ts # Authentication helpers
user.ts # User queries
posts.ts # Post queries
让这一机制正常运行的规则很简单:data/目录之外的任何地方都不得导入数据库客户端。
原始查询如何导致数据泄露
直接查询会返回整行数据。在下面的代码片段中,组件获取用户信息后直接将其传递给卡片组件:
// Without DAL - directly in a component
const user = await prisma.user.findUnique({ where: { id } })
return <ProfileCard user={user} />
该对象包含了所有字段:密码哈希、电子邮件、电话号码以及内部编号。如果ProfileCard是客户端组件,整个对象会被序列化到页面数据中,任何打开网络标签页的人都能读取它,即便该卡片仅显示姓名。
解决办法是返回一个专门设计的对象,而非原始记录。这个数据访问层函数会筛选出个人资料视图实际使用的三个字段:
// data/user.ts
export async function getProfile(id: string) {
const user = await prisma.user.findUnique({ where: { id } })
return {
name: user.name,
avatar: user.avatar,
bio: user.bio
}
}
该组件调用getProfile(id)后会得到一个安全对象;密码哈希仍保留在服务器端。也可以通过Prisma的select在查询时进行过滤,同时还需要处理findUnique返回null的情况,否则user.name会引发错误。
为何按组件划分的安全机制会失效
在不断扩大的代码库中,更大的风险在于每个组件都自行实现访问规则,从而导致这些规则逐渐脱节。想象同一个应用中有两名开发者:第一名开发者基于用于执行身份验证检查的DAL函数来构建个人资料页面(该代码片段重复了上述过滤函数,但目前还没有进行任何验证;受保护的版本将在下一节中展示):
// data/user.ts
export async function getProfile(id: string) {
const user = await prisma.user.findUnique({ where: { id } })
return {
name: user.name,
avatar: user.avatar,
bio: user.bio
}
}
第二名开发者则构建了设置页面,并直接查询Prisma,从URL中读取用户ID,却从不检查当前是否有用户登录:
// pages/settings/page.tsx - Developer B wrote this
export default async function SettingsPage({ searchParams }) {
// No auth check!
const user = await prisma.user.findUnique({
where: { id: searchParams.id }
})
// Returns everything, including sensitive fields
return <Settings user={user} />
}
目前存在两个漏洞:首先没有身份验证机制,因此任何能够修改id参数的人都可以加载其他人的设置;其次,包含敏感字段在内的完整记录会被显示在用户界面中。(尽管有pages/这样的注释,但这实际上仍是app/目录下的App Router页面。)由于安全逻辑分散在各个页面中,只要有一个检查被忽略,就可能导致安全问题,因此审计时必须检查所有与数据库交互的组件。
将身份验证和过滤功能集中处理
通过使用DAL,所有调用者都需要经过相同的检查。getCurrentUser会读取会话信息并返回对应用户或null;而requireAuth则在没有用户登录时将用户重定向到登录页面。这两者都被封装在React的cache机制中,因此在一个请求内的多次调用会复用第一次获取的结果:
// data/auth.ts
import { cache } from 'react'
export const getCurrentUser = cache(async () => {
const session = await getSession()
if (!session) return null
return session.user
})
export const requireAuth = cache(async () => {
const user = await getCurrentUser()
if (!user) redirect('/login')
return user
})
遗漏了部分导入:getSession 来自身份验证库,而 redirect 则来自 next/navigation。
getProfile 现在要求用户已登录,并且仅向账号所有者显示邮箱地址:
// data/user.ts
import 'server-only'
import { requireAuth } from './auth'
export async function getProfile(id: string) {
const viewer = await requireAuth()
const user = await prisma.user.findUnique({ where: { id } })
return {
name: user.name,
avatar: user.avatar,
email: viewer.id === user.id ? user.email : null
}
}
每个调用者都能免费获得身份验证和过滤功能;第二位开发者无法跳过函数中内置的校验。需要注意的是,requireAuth 只能回答“是否有用户已登录?”这一问题。而该用户是否有权编辑这篇文章则属于授权范畴,也应处理在数据访问层中。关于这种分离的更深入探讨,请参阅身份验证与授权在代码中的各自定位。
页面结构变得极为简洁。它们通过数据访问层获取数据并进行渲染:
// Any component - simple and secure
export default async function ProfilePage({ params }) {
const profile = await getProfile(params.id)
return <Profile profile={profile} />
}
该组件不包含任何安全代码,审计时只需检查data/目录即可,无需逐一审查数百个组件。在较新的Next.js版本中,params是一个Promise对象,因此需要先使用await将其处理。
从DAL函数并行获取数据
控制面板通常会依次执行多个独立的查询:
// Sequential fetching - slow
export default async function Dashboard() {
const user = await prisma.user.findUnique({ where: { id } })
const posts = await prisma.post.findMany({ where: { authorId: id } })
const stats = await prisma.stats.findFirst({ where: { userId: id } })
return <DashboardUI user={user} posts={posts} stats={stats} />
}
每个await操作都会等待前一个操作完成,因此三个100毫秒的查询总共需要大约300毫秒。由于这些查询是相互独立的,该DAL函数会使用Promise.all同时启动它们,并将结果映射到所需的字段中:
// data/dashboard.ts
export async function getDashboardData(userId: string) {
const [user, posts, stats] = await Promise.all([
prisma.user.findUnique({ where: { id: userId } }),
prisma.post.findMany({ where: { authorId: userId } }),
prisma.stats.findFirst({ where: { userId } })
])
return {
user: { name: user.name, avatar: user.avatar },
posts: posts.map(p => ({ id: p.id, title: p.title })),
stats: { views: stats.views, followers: stats.followers }
}
}
总耗时降至最慢查询的水平,约为100毫秒。这一提升得益于Promise.all而非DAL本身,但通过数据函数可使代码结构保持一致,并将过滤操作与数据获取操作结合在一起。依赖查询仍需等待。同样,由于requireAuth使用了cache(),许多组件可以在每次请求仅读取一次会话信息的情况下调用已认证的DAL函数。
使用仅服务器端可用的代码保护该层
在DAL中的每个文件开头都加上以下导入语句:
import 'server-only'
如果导入server-only包的模块被用于客户端代码,构建过程将会失败,因此若不小心将DAL函数导入到客户端组件中,会在生产环境之前就出现明显的错误。这一规范由工具来强制执行。
DAL中应包含哪些内容
每个DAL函数应专注于四项任务:
- 身份验证:使用缓存过的辅助函数确认是否有已登录的用户,从而确保每次请求仅进行一次验证。
- 权限授权:确认该用户是否有权限访问特定记录,例如是否可以查看个人资料或编辑帖子。
- 数据过滤:仅返回界面需要显示的字段。如果不确定某个字段是否必要,则将其省略。
- 敏感信息处理:只有DAL应当读取诸如
DATABASE_URL这样的敏感配置,这样就能避免在组件代码中暴露连接细节。
服务器端操作应使用相同的函数,这样写入操作就会与读取操作接受同样的校验;详见在每个服务器端操作中添加授权机制。
何时需要使用数据访问层
凡是处理用户数据、身份验证或敏感信息的场景都应使用数据访问层;在带有账户功能的实际应用中,这是基本要求而非额外配置。仅在没有用户数据、用于临时测试或构建静态网站的场景下才可省略它,即便如此,返回结构化的对象仍是一种值得保持的良好习惯。
核心要点
- 任何传送到客户端组件的内容最终都会到达浏览器,因此绝不能将原始的数据库记录直接传递过这一边界。
- 应将所有查询路由到
data/文件夹中,由该文件夹负责处理身份验证、权限控制以及字段筛选。
cache 中,这样每次请求只需进行一次会话读取即可处理多次数据访问层调用。Promise.all 来处理独立的查询任务。server-only,这样如果导入位置错误,会在构建阶段失败而非通过安全审查。服务器组件让数据库访问变得简单;而数据访问层则能确保这种便利不会悄无声息地导致数据泄露。
相关阅读
- 在 Next.js App Router 代码库中分离领域层、数据层和用户界面层 —— 通过 Pokédex 案例展示如何利用 Prisma、Zod、Cookie 认证及缓存功能将 Next.js App Router 应用拆分为领域层、数据层和展示层。
- 面向高级应用架构的20种Next.js 16进阶模式 — 梳理了服务器优先设计、缓存、流式传输、PPR、并行路由与拦截路由等用于构建可扩展Next.js 16应用程序的各类模式。
- 失败路径基准测试:Next.js、Remix与过时授权数据 — 通过一个案例分析,探讨了权限过期错误如何影响Next.js与Remix的选择,以及如何从数据变更、缓存生命周期和调试便捷性等方面评估各类框架。