Зупиніть витоки полів бази даних: шар доступу до даних для додатків 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 є клієнтським компонентом, весь об’єкт серіалізується у вміст сторінки, і будь-хто, у кого відкрита вкладка мережі, може його прочитати, навіть якщо картка відображає лише ім’я.
Рішення полягає у поверненні спеціально створеного об’єкта замість сирого запису. Ця функція 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
}
}
Компонент викликає getProfile(id) та отримує захищений об’єкт; хеш пароля залишається на сервері. Також можна використовувати фільтрацію у запиті за допомогою select від Prisma, а також потрібно обробляти ситуацію, коли 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, при цьому ніколи не перевіряючи, хто увійшов у систему:
// 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 Router у директорії app/). Оскільки логіка безпеки розташована на різних сторінках, достатньо однієї забутої перевірки, і під час аудиту необхідно перевірити кожен компонент, який взаємодіє з базою даних.
Об’єднання аутентифікації та фільтрації в одному місці
За допомогою DAL кожен користувач проходить однакові перевірки. getCurrentUser читає інформацію з сеансу та повертає користувача або null; requireAuth перенаправляє на сторінку входу, якщо ніхто не увійшов. Обидва функції опиняються всередині cache в React, тому багаторазові виклики протягом одного запиту використовують перший результат:
// 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 відповідає лише на запитання „Чи хтось увійшов?“ Чи може цей користувач редагувати цю публікацію — це питання авторизації, і воно також має знаходитися у DAL. Для більш детального розгляду цього розділення дивіться де мають знаходитися автентифікація та авторизація у вашому коді.
Сторінки стають простими. Вони отримують дані через DAL та їх відображають:
// 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 з автентифікацією, при цьому сесія читається лише один раз на запит.
Захист шару за допомогою параметра server-only
Кожен файл у DAL слід починати з такого імпорту:
import 'server-only'
Пакет server-only призводить до невдачі під час збирання проекту, якщо модуль, який його імпортує, потрапляє у код клієнта; тому функція DAL, випадково імпортована у клієнтський компонент, спричиняє чітку помилку ще до запуску в продакшені. Ця конвенція стає частиною правил, які забезпечують інструменти.
Що має знаходитися всередині DAL
Нехай кожна функція DAL зосереджується на чотирьох завданнях:
- Аутентифікація: перевірити, чи є увійшлий користувач, використовуючи кешовані допоміжні функції, щоб перевірка виконувалася лише один раз на запит.
- Авторизація: переконатися, що цей користувач має право отримати доступ до конкретної запису, наприклад, чи може він переглядати профіль або редагувати повідомлення.
- Фільтрація: повертати лише ті поля, які відображає екран. Якщо ви не впевнені, чи потрібне певне поле, просто пропустіть його.
- Конфіденційна інформація: лише DAL має право читати конфіденційні налаштування, такі як
DATABASE_URL, що дозволяє уникнути розміщення деталей підключення в коді компонентів.
Дії сервера повинні використовувати ті самі функції, щоб операції запису проходили ті самі перевірки, що й операції читання; див. авторизацію всередині кожної дії сервера.
Коли DAL є доцільним
Використовуйте його для всього, що працює з даними користувачів, автентифікацією чи конфіденційною інформацією; у продакшн-додатку з обліковими записами це базова вимога, а не щось додаткове. Уникайте його лише для тимчасових прототипів чи статичних сайтів без даних користувачів, і навіть там повернення чітко визначених об’єктів — це корисна звичка, яку варто зберігати.
Основні висновки
- Усе, що потрапляє до клієнтського компонента, потрапляє до браузера, тому ніколи не передавайте сирі рядки бази даних через цей кордон.
- Направляйте кожен запит через папку
data/, яка відповідає за автентифікацію, авторизацію та вибір полів.
cache у React, щоб багато викликів DAL коштували одного читання сесії на запит.Promise.all усередині функцій DAL для незалежних запитів.server-only у кожному файлі DAL, щоб неправильний імпорт призводив до провалу компіляції, а не перевірки безпеки.Компоненти сервера полегшують доступ до бази даних; шар доступу до даних гарантує, що ця простота не перетвориться на витік даних.
Пов’язана література
- Розділення шарів домену, даних та інтерфейсу в кодбазі Next.js App Router — дослідження на прикладі Pokédex, яке показує, як розділити додаток Next.js App Router на шари домену, даних та візуалізації за допомогою Prisma, Zod, автентифікації через cookie та кешування.
- 20 передових шаблонів Next.js 16 для архітектури додатків високого рівня — огляд підходу сервер-на-першому місці, кешування, стрімінгу, PPR, паралельних та перехоплюючих маршрутів, а також інших шаблонів для створення масштабованих додатків на Next.js 16.
- Тестування сценарію невдачі: Next.js, Remix та застарілі дані авторизації — приклад дослідження того, як помилка застарілих дозволів впливає на вибір між Next.js та Remix, а також як оцінювати фреймворки за критеріями змін, терміну життя кешу та можливостей дебаггінгу.