Пошук уроків, статей та іншого контенту
Перевірятимете сесію та права безпосередньо в Server Components перед отриманням і відображенням даних.
Автентифікація відповідає на запитання:
Хто виконує запит?
Авторизація відповідає на запитання:
Чи має цей користувач право виконати дію або переглянути дані?
У Next.js із App Router Server Components можуть перевірити сесію безпосередньо на сервері:
прочитати cookie із сесійним ідентифікатором;
знайти користувача в сховищі сесій;
перевірити його роль або права;
лише після цього отримати приватні дані;
передати в JSX тільки дозволені дані.
Це зручно, тому що запит до бази даних виконується на сервері, а секрети та службові дані не потрапляють у браузер.
Розглянемо сторінку /admin, яку можуть переглядати лише адміністратори.
У прикладі будуть такі файли:
lib/
auth.ts
reports.ts
app/
admin/
page.tsxПрипустимо, що після входу користувача застосунок встановлює HttpOnly-cookie:
session_id=...Створення сесії під час входу не є частиною цього прикладу. Тут важливо, як перевіряти вже наявну сесію в Server Component.
Створимо серверний модуль lib/auth.ts:
import 'server-only';
import { cookies } from 'next/headers';
export type UserRole = 'user' | 'admin';
export type User = {
id: string;
email: string;
role: UserRole;
};
type Session = {
userId: string;
};
// У реальному застосунку ці дані зберігатимуться в базі даних або Redis.
const sessions = new Map<string, Session>([
['demo-session', { userId: 'user-1' }],
]);
const users = new Map<string, User>([
[
'user-1',
{
id: 'user-1',
email: 'admin@example.com',
role: 'admin',
},
],
[
'user-2',
{
id: 'user-2',
email: 'user@example.com',
role: 'user',
},
],
]);
export async function getCurrentUser(): Promise<User | null> {
const cookieStore = await cookies();
const sessionId = cookieStore.get('session_id')?.value;
if (!sessionId) {
return null;
}
const session = sessions.get(sessionId);
if (!session) {
return null;
}
return users.get(session.userId) ?? null;
}Директива import 'server-only' захищає модуль від випадкового імпорту в Client Component. Це особливо важливо для модулів, які працюють із сесіями, базою даних або секретами.
У сучасних версіях Next.js cookies() є асинхронною функцією, тому її потрібно викликати з await.
Перевіряти роль можна безпосередньо в Server Component.
Створимо функцію отримання приватних звітів:
import 'server-only';
export type Report = {
id: string;
title: string;
revenue: number;
};
const reports: Report[] = [
{
id: 'report-1',
title: 'Звіт за січень',
revenue: 125_000,
},
{
id: 'report-2',
title: 'Звіт за лютий',
revenue: 142_500,
},
];
export async function getAdminReports(): Promise<Report[]> {
// Тут у реальному застосунку буде запит до бази даних.
return reports;
}Тепер створимо сторінку app/admin/page.tsx:
import { redirect } from 'next/navigation';
import { getCurrentUser } from '@/lib/auth';
import { getAdminReports } from '@/lib/reports';
export default async function AdminPage() {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
if (user.role !== 'admin') {
redirect('/forbidden');
}
const reports = await getAdminReports();
return (
<main>
<h1>Панель адміністратора</h1>
<p>Користувач: {user.email}</p>
<ul>
{reports.map((report) => (
<li key={report.id}>
{report.title}: {report.revenue} грн
</li>
))}
</ul>
</main>
);
}Порядок виконання тут має значення:
отримується поточний користувач;
неавторизований користувач перенаправляється на /login;
користувач без ролі admin перенаправляється на /forbidden;
тільки після перевірок викликається getAdminReports;
дані відображаються в розмітці.
Якщо користувач не має доступу, приватний запит до даних узагалі не виконується.
redirect як частина перевірки доступуФункція redirect із next/navigation завершує поточний рендеринг і виконує перенаправлення.
if (!user) {
redirect('/login');
}Після такого виклику код нижче не виконується. Тому TypeScript може розуміти, що після перевірки:
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
// Тут user вже розглядається як User, а не User | null.
console.log(user.email);Типові варіанти поведінки:
перенаправити неавторизованого користувача на /login;
перенаправити авторизованого, але недостатньо привілейованого користувача на /forbidden;
повернути notFound(), якщо потрібно приховати сам факт існування ресурсу.
Ролі недостатньо, якщо доступ залежить від власника ресурсу. Наприклад, користувач може переглядати лише власні документи.
import 'server-only';
export type Document = {
id: string;
ownerId: string;
title: string;
};
const documents: Document[] = [
{
id: 'document-1',
ownerId: 'user-1',
title: 'Адміністративний документ',
},
{
id: 'document-2',
ownerId: 'user-2',
title: 'Особистий документ',
},
];
export async function getDocumentForUser(
documentId: string,
userId: string,
): Promise<Document | null> {
const document = documents.find((item) => item.id === documentId);
if (!document || document.ownerId !== userId) {
return null;
}
return document;
}Сторінка документа спочатку перевіряє сесію, а потім передає user.id у функцію отримання даних:
import { notFound, redirect } from 'next/navigation';
import { getCurrentUser } from '@/lib/auth';
import { getDocumentForUser } from '@/lib/documents';
type DocumentPageProps = {
params: Promise<{
id: string;
}>;
};
export default async function DocumentPage({
params,
}: DocumentPageProps) {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
const { id } = await params;
const document = await getDocumentForUser(id, user.id);
if (!document) {
notFound();
}
return (
<main>
<h1>{document.title}</h1>
</main>
);
}Перевірка власника має виконуватися на сервері. Не можна покладатися лише на те, що клієнтський компонент приховає посилання на чужий документ.
Щоб не дублювати перевірки на багатьох сторінках, можна створити функції-вимоги:
import 'server-only';
import { redirect } from 'next/navigation';
import { getCurrentUser, type User, type UserRole } from '@/lib/auth';
export async function requireUser(): Promise<User> {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
return user;
}
export async function requireRole(role: UserRole): Promise<User> {
const user = await requireUser();
if (user.role !== role) {
redirect('/forbidden');
}
return user;
}Тоді сторінка стає коротшою:
import { requireRole } from '@/lib/require-user';
import { getAdminReports } from '@/lib/reports';
export default async function AdminPage() {
const user = await requireRole('admin');
const reports = await getAdminReports();
return (
<main>
<h1>Панель адміністратора</h1>
<p>Користувач: {user.email}</p>
<ul>
{reports.map((report) => (
<li key={report.id}>
{report.title}: {report.revenue} грн
</li>
))}
</ul>
</main>
);
}Такі функції зменшують ризик випадково пропустити перевірку на новій сторінці.
Сесія користувача робить сторінку динамічною, оскільки результат залежить від cookie. Якщо приватні дані отримуються через fetch, потрібно уважно налаштовувати кешування.
Для даних, які не можна повторно використовувати між користувачами, можна вказати:
const response = await fetch('https://internal.example.test/reports', {
cache: 'no-store',
});Або передавати ідентифікатор користувача в запит:
const response = await fetch(
`https://internal.example.test/users/${user.id}/reports`,
{
cache: 'no-store',
},
);Головний принцип:
Не кешуйте персональні або рольові дані так, щоб вони могли бути показані іншому користувачу.
Server Component може передати в Client Component лише ті дані, які дозволено показати в браузері:
import { requireRole } from '@/lib/require-user';
import { AdminFilters } from './AdminFilters';
import { getAdminReports } from '@/lib/reports';
export default async function AdminPage() {
const user = await requireRole('admin');
const reports = await getAdminReports();
return (
<main>
<h1>Панель адміністратора</h1>
<AdminFilters
userEmail={user.email}
reports={reports}
/>
</main>
);
}Не потрібно передавати:
хеші паролів;
токени сесій;
секретні ключі;
службові поля з бази даних;
дані інших користувачів.
Client Component може приховати або показати елемент інтерфейсу, але остаточна перевірка доступу має залишатися на сервері.
Перевірка під час відображення сторінки не захищає автоматично всі наступні дії користувача.
Наприклад, якщо на сторінці адміністратора є форма видалення звіту, перевірку ролі потрібно виконати і в обробнику цієї дії:
'use server';
import { requireRole } from '@/lib/require-user';
export async function deleteReport(reportId: string) {
const user = await requireRole('admin');
// Перевірка виконана безпосередньо перед небезпечною операцією.
await deleteReportFromDatabase(reportId, user.id);
}Навіть якщо кнопка видалення доступна лише адміністратору, запит можна сформувати вручну. Тому сервер має перевіряти права кожного разу перед захищеною операцією.
Ненадійний варіант:
'use client';
if (user?.role === 'admin') {
return <AdminData />;
}Клієнтський код можна змінити або викликати потрібний endpoint без цього інтерфейсу. Перевірка в Client Component корисна для інтерфейсу, але не для захисту даних.
Неправильно:
const reports = await getAdminReports();
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}У цьому випадку приватні дані вже були отримані до перевірки. Спочатку потрібно перевірити користувача і права, а вже потім виконувати запит.
Не можна довіряти ролі, яку передав клієнт:
const role = searchParams.role;
if (role === 'admin') {
// Небезпечно: значення контролює користувач.
}Роль потрібно отримувати із серверної сесії або бази даних.
Не передавайте в компонент або браузер більше даних, ніж потрібно. Зручніше явно формувати безпечний об’єкт:
const publicUser = {
id: user.id,
email: user.email,
};Перевірка user.role === 'user' не означає, що користувач має доступ до будь-якого документа. Для ресурсів із власником потрібно перевіряти зв’язок:
document.ownerId === user.idІноді краще повернути notFound(), а не /forbidden. Так користувач не дізнається, чи існує запитуваний ресурс.
Server Components можуть читати сесію через cookies() на сервері.
Автентифікація визначає користувача, а авторизація — його права.
Спочатку перевіряйте сесію та роль, потім отримуйте приватні дані.
Для ресурсів конкретного користувача додатково перевіряйте власника.
redirect() підходить для перенаправлення на сторінку входу або заборони доступу.
Не покладайтеся на перевірки в Client Components.
Приватні дані не повинні потрапляти в спільний кеш.
Перевірку прав потрібно повторювати перед захищеними операціями, а не лише під час відображення сторінки.