Пошук уроків, статей та іншого контенту
Створите безпечні форми входу й реєстрації, обробите валідацію та покажете користувачу помилки.
Форми входу та реєстрації повинні:
перевіряти дані на сервері;
не зберігати паролі у відкритому вигляді;
повертати зрозумілі, але безпечні повідомлення про помилки;
не розкривати, чи існує конкретний email;
створювати сесію після успішного входу;
зберігати ідентифікатор сесії в HttpOnly cookie.
Клієнтська валідація покращує UX, але не замінює серверну. Користувач може надіслати запит до API напряму, не використовуючи вашу форму.
У прикладі використано:
App Router;
Route Handlers;
zod для валідації;
bcryptjs для хешування паролів;
HttpOnly cookie для ідентифікатора сесії.
Встановіть залежності:
npm install zod bcryptjsapp/
api/
login/
route.ts
register/
route.ts
login/
page.tsx
register/
page.tsx
components/
AuthForm.tsx
lib/
auth-store.ts
validation.tsДля стислості користувачі та сесії зберігатимуться в пам’яті. Це підходить лише для демонстрації: після перезапуску сервера всі дані буде втрачено. У реальному застосунку використовуйте базу даних і сховище сесій.
Створимо окремі схеми для реєстрації та входу.
// lib/validation.ts
import { z } from "zod";
export const registerSchema = z
.object({
email: z
.string()
.trim()
.toLowerCase()
.email("Введіть коректний email")
.max(254, "Email занадто довгий"),
password: z
.string()
.min(8, "Пароль має містити щонайменше 8 символів")
.max(72, "Пароль занадто довгий"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "Паролі не збігаються",
});
export const loginSchema = z.object({
email: z
.string()
.trim()
.toLowerCase()
.email("Введіть коректний email")
.max(254, "Email занадто довгий"),
password: z.string().min(1, "Введіть пароль"),
});Важливо, що .toLowerCase() застосовується на сервері. Так User@Example.com і user@example.com не стануть двома різними обліковими записами.
// lib/auth-store.ts
import bcrypt from "bcryptjs";
import { randomUUID } from "node:crypto";
type User = {
id: string;
email: string;
passwordHash: string;
};
const users = new Map<string, User>();
const sessions = new Map<string, string>();
export function findUserByEmail(email: string) {
return users.get(email);
}
export async function createUser(email: string, password: string) {
const passwordHash = await bcrypt.hash(password, 12);
const user: User = {
id: randomUUID(),
email,
passwordHash,
};
users.set(email, user);
return user;
}
export async function checkPassword(
password: string,
passwordHash: string,
) {
return bcrypt.compare(password, passwordHash);
}
export function createSession(userId: string) {
const sessionId = randomUUID();
sessions.set(sessionId, userId);
return sessionId;
}
export function getUserIdBySession(sessionId: string) {
return sessions.get(sessionId);
}bcrypt.hash перетворює пароль на односторонній хеш. Навіть якщо база даних стане доступною зловмиснику, він не отримає паролі безпосередньо.
Не потрібно створювати власний алгоритм хешування. Використовуйте перевірені бібліотеки, такі як bcrypt, bcryptjs або Argon2.
Route Handler обробляє POST-запити до /api/register.
// app/api/register/route.ts
import { NextResponse } from "next/server";
import {
createSession,
createUser,
findUserByEmail,
} from "@/lib/auth-store";
import { registerSchema } from "@/lib/validation";
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ message: "Некоректний формат запиту" },
{ status: 400 },
);
}
const result = registerSchema.safeParse(body);
if (!result.success) {
const errors: Record<string, string> = {};
for (const issue of result.error.issues) {
const field = issue.path[0];
if (typeof field === "string" && !errors[field]) {
errors[field] = issue.message;
}
}
return NextResponse.json({ errors }, { status: 400 });
}
const { email, password } = result.data;
const existingUser = findUserByEmail(email);
if (existingUser) {
// Не повідомляємо зайвих деталей у складніших системах.
return NextResponse.json(
{ errors: { email: "Не вдалося створити обліковий запис" } },
{ status: 400 },
);
}
const user = await createUser(email, password);
const sessionId = createSession(user.id);
const response = NextResponse.json(
{ message: "Обліковий запис створено" },
{ status: 201 },
);
response.cookies.set("session", sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
return response;
}Параметри cookie мають важливе значення:
httpOnly не дозволяє JavaScript у браузері прочитати cookie;
secure передає cookie лише через HTTPS у production;
sameSite: "lax" обмежує відправлення cookie з більшості сторонніх запитів;
path: "/" робить cookie доступною для всього застосунку;
maxAge визначає час життя сесії.
Під час входу не варто повідомляти, що саме неправильне: email чи пароль. Повідомлення на кшталт «Користувача з таким email не знайдено» допомагає перевіряти, які адреси зареєстровані.
// app/api/login/route.ts
import bcrypt from "bcryptjs";
import { NextResponse } from "next/server";
import {
createSession,
findUserByEmail,
} from "@/lib/auth-store";
import { loginSchema } from "@/lib/validation";
// Дійсний хеш використовується, щоб перевірка відсутнього користувача
// не завершувалася миттєво.
const DUMMY_HASH = bcrypt.hashSync("not-a-real-password", 12);
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ message: "Некоректний формат запиту" },
{ status: 400 },
);
}
const result = loginSchema.safeParse(body);
if (!result.success) {
const errors: Record<string, string> = {};
for (const issue of result.error.issues) {
const field = issue.path[0];
if (typeof field === "string" && !errors[field]) {
errors[field] = issue.message;
}
}
return NextResponse.json({ errors }, { status: 400 });
}
const { email, password } = result.data;
const user = findUserByEmail(email);
// Перевірка виконується навіть без знайденого користувача.
const passwordIsValid = await bcrypt.compare(
password,
user?.passwordHash ?? DUMMY_HASH,
);
if (!user || !passwordIsValid) {
return NextResponse.json(
{ message: "Неправильний email або пароль" },
{ status: 401 },
);
}
const sessionId = createSession(user.id);
const response = NextResponse.json({
message: "Вхід виконано",
});
response.cookies.set("session", sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
return response;
}Перевірка з DUMMY_HASH не дає запиту для неіснуючого email завершуватися набагато швидше, ніж запиту для існуючого користувача. Це зменшує ризик визначення зареєстрованих адрес за часом відповіді.
Створимо спільний клієнтський компонент. Він надсилає дані на API та показує помилки конкретних полів.
// components/AuthForm.tsx
"use client";
import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
type AuthFormProps = {
mode: "login" | "register";
};
type FieldErrors = Record<string, string>;
export default function AuthForm({ mode }: AuthFormProps) {
const router = useRouter();
const isRegister = mode === "register";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [errors, setErrors] = useState<FieldErrors>({});
const [formError, setFormError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setErrors({});
setFormError("");
setIsSubmitting(true);
const payload = isRegister
? { email, password, confirmPassword }
: { email, password };
try {
const response = await fetch(
isRegister ? "/api/register" : "/api/login",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
},
);
const data = await response.json();
if (!response.ok) {
setErrors(data.errors ?? {});
setFormError(data.message ?? "");
return;
}
router.push("/account");
router.refresh();
} catch {
setFormError(
"Не вдалося виконати запит. Спробуйте ще раз.",
);
} finally {
setIsSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? "email-error" : undefined}
required
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email}
</p>
)}
</div>
<div>
<label htmlFor="password">Пароль</label>
<input
id="password"
name="password"
type="password"
autoComplete={isRegister ? "new-password" : "current-password"}
value={password}
onChange={(event) => setPassword(event.target.value)}
aria-invalid={Boolean(errors.password)}
aria-describedby={
errors.password ? "password-error" : undefined
}
required
/>
{errors.password && (
<p id="password-error" role="alert">
{errors.password}
</p>
)}
</div>
{isRegister && (
<div>
<label htmlFor="confirmPassword">
Підтвердження пароля
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(event) =>
setConfirmPassword(event.target.value)
}
aria-invalid={Boolean(errors.confirmPassword)}
aria-describedby={
errors.confirmPassword
? "confirm-password-error"
: undefined
}
required
/>
{errors.confirmPassword && (
<p id="confirm-password-error" role="alert">
{errors.confirmPassword}
</p>
)}
</div>
)}
{formError && <p role="alert">{formError}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting
? "Зачекайте..."
: isRegister
? "Зареєструватися"
: "Увійти"}
</button>
</form>
);
}noValidate вимикає стандартні браузерні повідомлення, щоб користувач бачив єдиний формат помилок від застосунку. Серверна валідація при цьому залишається обов’язковою.
Атрибут autoComplete допомагає менеджерам паролів правильно працювати з формою:
current-password — для входу;
new-password — для реєстрації.
// app/login/page.tsx
import AuthForm from "@/components/AuthForm";
export default function LoginPage() {
return (
<main>
<h1>Вхід</h1>
<AuthForm mode="login" />
</main>
);
}// app/register/page.tsx
import AuthForm from "@/components/AuthForm";
export default function RegisterPage() {
return (
<main>
<h1>Реєстрація</h1>
<AuthForm mode="register" />
</main>
);
}Після успішної реєстрації або входу API встановлює cookie, тому клієнтському JavaScript не потрібно зберігати токен у localStorage.
Помилки можна поділити на два типи:
Вони стосуються конкретного значення:
некоректний email;
занадто короткий пароль;
паролі не збігаються.
Такі повідомлення потрібно показувати поруч із відповідним полем.
Вони стосуються всієї операції:
неправильні облікові дані;
тимчасова помилка сервера;
неможливо виконати запит.
Таке повідомлення краще показувати над кнопкою або під формою.
Не показуйте користувачу необроблений текст винятку з сервера. Він може містити назву таблиці, SQL-запит, шлях до файлу або іншу внутрішню інформацію.
У демонстраційному сховищі є кілька обмежень. У реальному застосунку потрібно:
зберігати користувачів у базі даних;
створити унікальний індекс для email;
зберігати лише хеш пароля;
зберігати сесії в базі або спеціальному сховищі;
видаляти прострочені сесії;
обробляти конфлікт унікальності email на рівні бази даних;
використовувати HTTPS;
додати rate limiting для входу та реєстрації;
не записувати паролі в логи;
перевіряти Origin або використовувати CSRF-захист для сценаріїв, де SameSite недостатньо.
Особливо важливо не покладатися лише на перевірку:
if (existingUser) {
// створення користувача
}Два паралельні запити можуть одночасно пройти цю перевірку. Унікальність email має гарантувати база даних.
Користувач може відправити запит через DevTools, Postman або власний скрипт. Усі правила мають повторно перевірятися на сервері.
Ніколи не зберігайте пароль у базі даних або cookie. Для перевірки достатньо зберігати хеш.
localStorage для сесіїТокен у localStorage може бути прочитаний JavaScript-кодом у разі XSS-атаки. Для сесій, якими керує сервер, краще використовувати HttpOnly cookie.
Повідомлення «користувача не знайдено» розкриває наявність email у системі. Для входу використовуйте загальне повідомлення:
Неправильний email або парольCookie без HttpOnly, Secure і SameSite може бути доступнішою для атак або випадково передаватися в небажаних контекстах.
Без disabled користувач може кілька разів натиснути кнопку та створити кілька паралельних запитів.
Навіть якщо кнопка або поле приховані в інтерфейсі, це не є захистом. Сервер має самостійно перевіряти всі отримані поля.
Валідація даних повинна виконуватися на сервері.
zod допомагає описувати схеми та повертати помилки полів.
Паролі потрібно хешувати за допомогою bcrypt або іншого перевіреного алгоритму.
Сесійний ідентифікатор варто зберігати в HttpOnly cookie.
Повідомлення про помилки входу не повинні розкривати, чи існує email.
Клієнтська форма має показувати помилки полів і загальні помилки окремо.
In-memory сховище підходить лише для прикладу; у production потрібні база даних, унікальність email і повноцінне сховище сесій.