Вход и выход пользователя
Аутентификация — проверка, кто пользователь. После успешного входа его идентификатор записывают в сессию из прошлого урока. Затем любой loader или action может прочитать сессию и решить, пускать ли посетителя. В блоге писать статьи сможет только вошедший автор.
Проверка пароля
Создайте файл app/auth.server.ts. Пароли хранят только в виде хеша; здесь для этого используется встроенный модуль node:crypto:
// app/auth.server.ts
import { scryptSync, timingSafeEqual } from "node:crypto";
import { redirect } from "react-router";
import { getSession } from "./sessions.server";
type User = { id: string; email: string; passwordHash: string; salt: string };
// В реальном проекте пользователи лежат в таблице users.
const users: User[] = [];
export function hashPassword(password: string, salt: string) {
return scryptSync(password, salt, 32).toString("hex");
}
export async function verifyLogin(email: string, password: string) {
const user = users.find((u) => u.email === email);
if (!user) return null;
const hash = Buffer.from(hashPassword(password, user.salt), "hex");
return timingSafeEqual(hash, Buffer.from(user.passwordHash, "hex")) ? user : null;
}
export async function requireUserId(request: Request) {
const session = await getSession(request.headers.get("Cookie"));
const userId = session.get("userId");
if (!userId) throw redirect("/login");
return userId;
}requireUserId либо возвращает идентификатор, либо выбрасывает перенаправление на /login, и выполнение загрузчика на этом заканчивается.
Страница входа
Добавьте маршрут route("login", "routes/login.tsx"):
// app/routes/login.tsx
import { Form, data, redirect } from "react-router";
import type { Route } from "./+types/login";
import { verifyLogin } from "../auth.server";
import { commitSession, getSession } from "../sessions.server";
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const user = await verifyLogin(String(form.get("email")), String(form.get("password")));
if (!user) {
return data({ error: "Неверный email или пароль" }, { status: 400 });
}
const session = await getSession(request.headers.get("Cookie"));
session.set("userId", user.id);
return redirect("/", { headers: { "Set-Cookie": await commitSession(session) } });
}
export default function Login({ actionData }: Route.ComponentProps) {
return (
<Form method="post">
<input name="email" type="email" placeholder="anna@example.com" />
<input name="password" type="password" />
{actionData?.error && <p>{actionData.error}</p>}
<button type="submit">Войти</button>
</Form>
);
}При верных данных пользователь попадёт на главную уже вошедшим, при неверных увидит сообщение об ошибке.
Защита страницы
В загрузчике и действии app/routes/new-post.tsx первой строкой вызовите проверку:
// app/routes/new-post.tsx
import { requireUserId } from "../auth.server";
export async function loader({ request }: Route.LoaderArgs) {
await requireUserId(request);
return null;
}Гость, открывший /posts/new, будет перенаправлен на /login. Ту же строку добавьте в начало action.
Выход
Выход — действие, которое удаляет сессию. Маршрут route("logout", "routes/logout.tsx"):
// app/routes/logout.tsx
import { redirect } from "react-router";
import type { Route } from "./+types/logout";
import { destroySession, getSession } from "../sessions.server";
export async function action({ request }: Route.ActionArgs) {
const session = await getSession(request.headers.get("Cookie"));
return redirect("/", { headers: { "Set-Cookie": await destroySession(session) } });
}В шапке разместите форму: Form method="post" action="/logout" с кнопкой «Выйти». Выход делают через POST, а не ссылкой, чтобы его не вызвала предзагрузка или чужая картинка.
Частая ошибка
Защитить только loader, а action оставить открытым. Скрыть форму от гостя мало: запрос POST на /posts/new можно отправить и без формы. Вызывайте requireUserId в каждом action, который меняет данные.