Пошук уроків, статей та іншого контенту
Спроєктуєте потік автентифікації в Next.js між браузером, Server Components, Route Handlers і базою даних.
Автентифікація визначає, хто користувач, а авторизація — що цьому користувачеві дозволено робити.
У Next.js потік автентифікації зазвичай має такий вигляд:
Користувач вводить дані у браузері.
Браузер надсилає їх до Route Handler.
Route Handler перевіряє дані через серверний шар і базу даних.
Сервер створює сесію.
Ідентифікатор сесії зберігається в HttpOnly cookie.
Під час наступних запитів браузер автоматично надсилає cookie.
Server Components або Route Handlers знаходять за cookie поточного користувача.
Сервер повертає користувачеві лише дозволені дані.
Важливо: браузер не повинен напряму підключатися до бази даних.
Браузер:
відображає форму входу;
надсилає HTTP-запити;
автоматично додає cookie до запитів на той самий домен;
показує результат, який надіслав сервер.
Браузер не повинен самостійно вирішувати, чи є користувач автентифікованим. Наприклад, приховування кнопки в інтерфейсі не є захистом.
Route Handler — це серверний HTTP-обробник у файлі route.ts.
Він підходить для:
входу користувача;
виходу користувача;
реєстрації;
оновлення сесії;
API-запитів, яким потрібна автентифікація.
Route Handler має доступ до cookie та бази даних і може встановлювати або видаляти cookie.
Server Components виконуються на сервері. Вони можуть:
прочитати cookie;
перевірити сесію;
отримати дані з бази даних;
показати різний інтерфейс для різних користувачів.
Секретні дані та доступ до бази даних не потрапляють у браузер, якщо вони використовуються лише на сервері.
У базі даних зазвичай зберігають:
користувачів;
хеші паролів;
сесії;
зв’язок сесії з користувачем;
час завершення сесії.
База даних є джерелом істини. Cookie лише повідомляє серверу, яку сесію потрібно перевірити.
Один із поширених варіантів архітектури — сесія на сервері.
Після успішного входу сервер:
створює випадковий ідентифікатор сесії;
зберігає його разом з ідентифікатором користувача;
надсилає ідентифікатор у cookie.
Наприклад:
Cookie: session_id=випадковий_ідентифікаторПід час наступного запиту сервер:
читає session_id з cookie;
шукає сесію;
отримує пов’язаного користувача;
перевіряє, чи сесія ще дійсна.
У cookie не потрібно зберігати пароль, роль або повний об’єкт користувача. Найкраще зберігати лише непрозорий ідентифікатор сесії.
Для сесійної cookie зазвичай використовують такі параметри:
httpOnly — JavaScript у браузері не може прочитати cookie;
secure — cookie надсилається лише через HTTPS;
sameSite: "lax" — зменшує ризик деяких CSRF-атак;
path: "/" — cookie доступна для всього застосунку;
maxAge або expires — час життя cookie.
httpOnly захищає cookie від прямого читання через document.cookie, але не замінює серверну перевірку прав доступу.
Нижче наведено спрощений приклад для App Router. Замість бази даних використовується Map, щоб приклад можна було запустити без додаткових бібліотек.
Mapпідходить лише для навчального прикладу. Після перезапуску сервера всі користувачі та сесії зникнуть. У реальному застосунку ці дані потрібно зберігати в базі даних.
Файл lib/auth-store.ts:
import { cookies } from "next/headers";
type User = {
id: string;
email: string;
name: string;
};
type Session = {
userId: string;
expiresAt: number;
};
// Демонстраційні дані замість бази даних.
const users: User[] = [
{
id: "user-1",
email: "anna@example.com",
name: "Анна",
},
];
const sessions = new Map<string, Session>();
export function findUserByEmail(email: string) {
return users.find((user) => user.email === email) ?? null;
}
export function createSession(userId: string) {
const sessionId = crypto.randomUUID();
sessions.set(sessionId, {
userId,
expiresAt: Date.now() + 1000 * 60 * 60 * 24 * 7,
});
return sessionId;
}
export function deleteSession(sessionId: string) {
sessions.delete(sessionId);
}
export function findUserById(userId: string) {
return users.find((user) => user.id === userId) ?? null;
}
export async function getCurrentUser() {
const cookieStore = await cookies();
const sessionId = cookieStore.get("session_id")?.value;
if (!sessionId) {
return null;
}
const session = sessions.get(sessionId);
if (!session) {
return null;
}
if (session.expiresAt < Date.now()) {
sessions.delete(sessionId);
return null;
}
return findUserById(session.userId);
}Функція getCurrentUser є серверною точкою доступу до поточного користувача. Server Components можуть її викликати, щоб перевірити сесію.
Файл app/api/login/route.ts:
import { cookies } from "next/headers";
import { createSession, findUserByEmail } from "@/lib/auth-store";
export async function POST(request: Request) {
const formData = await request.formData();
const email = String(formData.get("email") ?? "");
const password = String(formData.get("password") ?? "");
const user = findUserByEmail(email);
// Для навчального прикладу пароль фіксований.
// У реальному застосунку потрібно перевіряти хеш пароля.
const isValidPassword = password === "demo-password";
if (!user || !isValidPassword) {
return Response.json(
{ error: "Неправильна електронна пошта або пароль" },
{ status: 401 },
);
}
const sessionId = createSession(user.id);
const cookieStore = await cookies();
cookieStore.set("session_id", sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
return Response.redirect(new URL("/", request.url), 303);
}Цей обробник:
читає дані форми;
знаходить користувача;
перевіряє пароль;
створює сесію;
встановлює cookie;
перенаправляє користувача на головну сторінку.
Паролі не можна зберігати у відкритому вигляді. У справжньому застосунку пароль порівнюють із хешем, який зберігається в базі даних.
Файл app/api/logout/route.ts:
import { cookies } from "next/headers";
import { deleteSession } from "@/lib/auth-store";
export async function POST(request: Request) {
const cookieStore = await cookies();
const sessionId = cookieStore.get("session_id")?.value;
if (sessionId) {
deleteSession(sessionId);
cookieStore.delete("session_id");
}
return Response.redirect(new URL("/", request.url), 303);
}Під час виходу потрібно зробити дві дії:
видалити сесію на сервері;
видалити cookie в браузері.
Видалення лише cookie недостатньо, якщо серверна сесія все ще залишається дійсною.
Файл app/login/page.tsx:
export default function LoginPage() {
return (
<main>
<h1>Вхід</h1>
<form action="/api/login" method="post">
<label>
Електронна пошта
<input
type="email"
name="email"
required
placeholder="anna@example.com"
/>
</label>
<label>
Пароль
<input
type="password"
name="password"
required
/>
</label>
<button type="submit">Увійти</button>
</form>
<p>Демо-пароль: demo-password</p>
</main>
);
}Форма надсилає POST-запит до /api/login. Для цього прикладу не потрібен клієнтський JavaScript.
Файл app/page.tsx:
import Link from "next/link";
import { getCurrentUser } from "@/lib/auth-store";
export default async function HomePage() {
const user = await getCurrentUser();
return (
<main>
<h1>Головна сторінка</h1>
{user ? (
<>
<p>Ви увійшли як {user.name}.</p>
<form action="/api/logout" method="post">
<button type="submit">Вийти</button>
</form>
</>
) : (
<>
<p>Ви не автентифіковані.</p>
<Link href="/login">Увійти</Link>
</>
)}
</main>
);
}Цей Server Component читає cookie на сервері та отримує поточного користувача. Об’єкт user не потрібно передавати через браузер для самої перевірки сесії.
У реальному проєкті замість Map будуть запити до бази даних.
Мінімальна модель може містити дві таблиці.
usersУ ній зберігають:
id;
email;
name;
password_hash;
час створення.
sessionsУ ній зберігають:
id або хеш ідентифікатора сесії;
user_id;
expires_at;
час створення.
Під час входу Route Handler:
знаходить користувача за email;
перевіряє пароль проти password_hash;
створює запис у sessions;
встановлює cookie.
Під час перевірки користувача Server Component:
читає cookie;
знаходить сесію в таблиці sessions;
перевіряє термін дії;
отримує користувача з таблиці users.
Доступ до клієнта бази даних потрібно тримати в серверному коді. Не імпортуйте модуль бази даних у Client Component.
Перевірка має виконуватися безпосередньо перед отриманням захищених даних або виконанням захищеної дії.
Наприклад:
Server Component перевіряє користувача перед відображенням приватної сторінки;
Route Handler перевіряє користувача перед зміною даних;
серверний код перевіряє роль перед видаленням або редагуванням ресурсу.
Приховування посилання або кнопки в інтерфейсі не є перевіркою доступу. Користувач може вручну надіслати запит до Route Handler, тому сам Route Handler також повинен перевіряти сесію.
До публічних маршрутів можуть належати:
/;
/login;
/register;
сторінки з відкритим контентом.
До захищених маршрутів можуть належати:
/dashboard;
/profile;
/settings;
операції зміни даних.
Захищена сторінка повинна перевірити користувача на сервері. Якщо користувача немає, сервер може перенаправити його на сторінку входу або повернути помилку.
Наприклад, Server Component може використати серверне перенаправлення:
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth-store";
export default async function DashboardPage() {
const user = await getCurrentUser();
if (!user) {
redirect("/login");
}
return (
<main>
<h1>Панель користувача</h1>
<p>Вітаємо, {user.name}!</p>
</main>
);
}Навіть якщо сторінка захищена таким способом, Route Handlers для дій із даними теж повинні окремо перевіряти користувача.
localStorageТокен у localStorage доступний JavaScript-коду сторінки. Якщо в застосунку з’явиться XSS-вразливість, такий токен можуть викрасти.
Для сесій у браузері зазвичай використовують HttpOnly cookie.
Не можна вважати безпечними такі дані, отримані від браузера:
userId;
role;
isAdmin;
назву поточного користувача.
Користувач може змінити їх перед відправленням. Сервер повинен отримувати ідентичність користувача із серверної сесії.
Cookie не повинна містити пароль. Паролі потрібно зберігати у вигляді стійких хешів на сервері.
Умова на кшталт if (user) { показати кнопку } впливає лише на відображення. Вона не забороняє прямий виклик API.
Перевірка повинна бути також у Route Handler або іншому серверному коді, який виконує дію.
Map або глобальна змінна зручні для демонстрації, але непридатні для production:
дані зникають після перезапуску;
різні екземпляри застосунку не бачать спільні сесії;
масштабування стає некоректним.
Для production сесії потрібно зберігати у спільній базі даних або іншому серверному сховищі.
Сесія повинна мати термін дії. Під час кожної перевірки потрібно враховувати expires_at і видаляти прострочені сесії.
Браузер надсилає облікові дані до Route Handler.
Route Handler перевіряє їх на сервері та створює сесію.
Ідентифікатор сесії зберігається в HttpOnly cookie.
Server Components можуть читати cookie й отримувати поточного користувача.
База даних зберігає користувачів і серверні сесії.
Кожен захищений Route Handler повинен самостійно перевіряти сесію.
Дані з браузера не можна вважати доказом особи або прав користувача.
Map у прикладі замінюється реальною базою даних у production.