Урок 40 из 49 · Месяц 5. React и Zustand
JWT-авторизация, регистрация и Swagger
Содержание урока
- Аутентификация и авторизация
- Что такое JWT
- Access и refresh токены
- Полный цикл
- Swagger: читаем документацию API
- Что смотреть в Swagger
- Try it out и Authorize
- Пишем API-функции по Swagger вручную
- Где хранить токены
- Хранилище авторизации на Zustand
- axios-интерцепторы
- Регистрация с валидацией
- Вход
- Защищённые маршруты
- Выход
- Типичные ошибки
- Практика
- Итоги
Почти любое приложение требует входа: личный кабинет, корзина, заказы. В этом уроке разберём JWT-авторизацию с access- и refresh-токенами, сделаем регистрацию и вход с валидацией, научимся подставлять токен в запросы через интерцепторы axios и закрывать страницы от гостей. А ещё научимся читать API в Swagger и писать запросы по нему вручную.
Аутентификация и авторизация
- Аутентификация — «кто вы?»: проверка логина и пароля.
- Авторизация — «что вам можно?»: свои заказы — да, админка — нет.
HTTP не помнит прошлые запросы, поэтому после входа сервер выдаёт токен — пропуск, который фронтенд прикладывает к каждому запросу. Аналогия: на ресепшене проверили паспорт и выдали пропуск, дальше охрана смотрит только на пропуск.
Что такое JWT
JWT (JSON Web Token) — популярный формат токена из трёх частей через точку:
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJ1c2VyIiwiZXhwIjoxNzYwMDAwMDAwfQ.k3Jf...подпись
header payload signature
- header — алгоритм подписи; payload — данные: id пользователя (
sub), роль, срок действия (exp). - signature — подпись секретным ключом сервера; без ключа токен не подделать.
Access и refresh токены
| Access token | Refresh token | |
|---|---|---|
| Зачем | Доступ к API: прикладывается к каждому запросу | Получить новый access, когда старый истёк |
| Срок жизни | Короткий: 5–15 минут | Длинный: дни или недели |
| Куда отправляется | В заголовке Authorization: Bearer <token> |
Только на /auth/refresh |
Зачем два? Украденный access скоро «протухнет», а refresh редко ходит по сети.
Полный цикл
1. POST /auth/register { email, password } → 201 Created
2. POST /auth/login { email, password } → { access, refresh }
3. GET /auth/me Authorization: Bearer access → { id, email }
4. ...через 15 минут: GET /orders → 401 Unauthorized (access истёк)
5. POST /auth/refresh { refresh } → { access } (новый)
6. Повторяем GET /orders с новым access → 200 OK
7. Выход: удаляем токены → /login
Swagger: читаем документацию API
Какие адреса есть у бэкенда и что им отправлять? Бэкенд публикует документацию в формате OpenAPI, а Swagger UI показывает её интерактивной страницей — обычно по адресу вроде /docs или /swagger (точный подскажет бэкендер).
Что смотреть в Swagger
- Эндпоинты сгруппированы по тегам (
auth,users); у каждого метод и путь:POST /auth/login. - Request body — поля, их типы и обязательность (
*); Example Value / Schema — пример и описание. - Parameters — параметры пути (
{id}) и query-параметры (?page=1). - Responses — коды ответов (
200,400,401,422) и их формат. - Значок замка — нужен токен. Schemas внизу — описания моделей (
User,TokenPair).
Try it out и Authorize
- Откройте
POST /auth/login, нажмите Try it out, заполните тело и Execute — придёт реальный ответ с токенами. - Нажмите Authorize вверху, вставьте access-токен (иногда с префиксом
Bearer— зависит от сервера). Теперь эндпоинты с замком работают прямо из Swagger.
Пишем API-функции по Swagger вручную
В Swagger: POST /auth/login, тело { email, password }, ответ 200: { access, refresh }. Переносим в код:
// src/api/auth.js
import { api } from "./client";
export const register = (body) => api.post("/auth/register", body).then((r) => r.data);
export const login = (body) => api.post("/auth/login", body).then((r) => r.data);
export const getMe = () => api.get("/auth/me").then((r) => r.data);
Где хранить токены
| Место | Переживёт перезагрузку | Доступно из JS | Главный риск |
|---|---|---|---|
| Память (переменная, Zustand без persist) | Нет | Да | Теряется при перезагрузке |
localStorage |
Да | Да | XSS: вредный скрипт на странице прочитает токен |
sessionStorage |
До закрытия вкладки | Да | Тот же XSS |
Cookie с флагом httpOnly |
Да | Нет | CSRF, защищается флагом SameSite и настройками сервера |
- XSS (межсайтовый скриптинг) — злоумышленник запускает свой JavaScript на вашей странице (через уязвимость или заражённую библиотеку) и забирает всё из
localStorage. - httpOnly cookie ставит сервер, и JavaScript (даже вредный) не может её прочитать.
Хранилище авторизации на Zustand
// src/store/useAuthStore.js
import { create } from "zustand";
import { persist } from "zustand/middleware";
export const useAuthStore = create(
persist(
(set) => ({
accessToken: null,
refreshToken: null,
setTokens: ({ access, refresh }) =>
set((state) => ({
accessToken: access,
refreshToken: refresh ?? state.refreshToken,
})),
logout: () => set({ accessToken: null, refreshToken: null }),
}),
{ name: "auth" }
)
);
persist (урок Zustand) сохраняет токены в localStorage. refresh ?? state.refreshToken — если сервер вернул только access, старый refresh остаётся.
axios-интерцепторы
Интерцептор (перехватчик) — функция, через которую axios пропускает каждый запрос или ответ, как таможня посылки.
// src/api/client.js
import axios from "axios";
import { useAuthStore } from "../store/useAuthStore";
const BASE_URL = "https://api.example.com";
export const api = axios.create({ baseURL: BASE_URL });
// 1. Перед каждым запросом — подставляем access
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken;
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// 2. После ответа — ловим 401 и обновляем токен
let refreshPromise = null;
api.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config;
const isRefreshUrl = original?.url?.includes("/auth/refresh");
if (error.response?.status !== 401 || original._retry || isRefreshUrl) {
return Promise.reject(error);
}
original._retry = true;
try {
const { refreshToken, setTokens } = useAuthStore.getState();
if (!refreshToken) throw error;
refreshPromise ??= axios
.post(`${BASE_URL}/auth/refresh`, { refresh: refreshToken })
.then((res) => res.data)
.finally(() => { refreshPromise = null; });
const tokens = await refreshPromise;
setTokens(tokens);
original.headers.Authorization = `Bearer ${tokens.access}`;
return api(original);
} catch (refreshError) {
useAuthStore.getState().logout();
window.location.href = "/login";
return Promise.reject(refreshError);
}
}
);
Разбор:
- Request-интерцептор берёт токен через
getState()(вне компонента хук вызвать нельзя) и добавляет заголовокAuthorization. - Response-интерцептор: первый аргумент — для успешных ответов, второй — для ошибок.
- Не
401, запрос уже повторяли (_retry) или упал сам refresh — отдаём ошибку дальше, иначе будет бесконечный цикл. refreshPromise ??= ...— если пять запросов разом получили401, токен обновится один раз.- Refresh идёт через «чистый»
axios, а не черезapi, чтобы не попасть в свои же интерцепторы. - Новые токены получены — повторяем исходный запрос
api(original), пользователь ничего не заметит. Refresh не удался — выходим на/login.
Регистрация с валидацией
npm install react-hook-form yup @hookform/resolvers
// src/pages/RegisterPage.jsx
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { register as registerUser } from "../api/auth";
const schema = yup.object({
email: yup.string().required("Введите email").email("Неверный email"),
password: yup
.string()
.required("Введите пароль")
.min(8, "Минимум 8 символов")
.matches(/\d/, "Нужна хотя бы одна цифра"),
confirmPassword: yup
.string()
.required("Повторите пароль")
.oneOf([yup.ref("password")], "Пароли не совпадают"),
});
export default function RegisterPage() {
const navigate = useNavigate();
const { register, handleSubmit, setError, formState: { errors } } = useForm({
resolver: yupResolver(schema),
});
const mutation = useMutation({
mutationFn: registerUser,
onSuccess: () => navigate("/login", { state: { message: "Аккаунт создан, войдите" } }),
onError: (error) => {
if (error.response?.status === 400) {
setError("email", { message: "Такой email уже зарегистрирован" });
}
},
});
const onSubmit = ({ email, password }) => mutation.mutate({ email, password });
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input placeholder="Email" {...register("email")} />
<p>{errors.email?.message}</p>
<input type="password" placeholder="Пароль" {...register("password")} />
<p>{errors.password?.message}</p>
<input type="password" placeholder="Повторите пароль" {...register("confirmPassword")} />
<p>{errors.confirmPassword?.message}</p>
<button disabled={mutation.isPending}>Зарегистрироваться</button>
</form>
);
}
- Схема
yup(урок react-hook-form и yup) проверяет поля до отправки;yup.ref("password")ссылается на другое поле — так сверяем пароли. register as registerUser— чтобы не путать сregisterиз react-hook-form.confirmPasswordна сервер не отправляем.- Серверная валидация: сервер может отказать (email занят) —
setErrorпокажет ошибку под полем. Код и формат ошибки смотрите в Swagger.
Вход
const setTokens = useAuthStore((s) => s.setTokens);
const loginMutation = useMutation({
mutationFn: login,
onSuccess: (tokens) => {
setTokens(tokens);
navigate(location.state?.from ?? "/profile", { replace: true });
},
onError: () => setError("root", { message: "Неверный email или пароль" }),
});
Ошибку root выводят над кнопкой: {errors.root?.message}. Не уточняйте, что неверно — email или пароль: это подсказка взломщику.
Защищённые маршруты
// src/components/ProtectedRoute.jsx
import { Navigate, Outlet, useLocation } from "react-router";
import { useAuthStore } from "../store/useAuthStore";
export function ProtectedRoute() {
const token = useAuthStore((s) => s.accessToken);
const location = useLocation();
if (!token) {
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
return <Outlet />;
}
// src/router.jsx
createBrowserRouter([
{
path: "/",
element: <RootLayout />,
children: [
{ index: true, element: <HomePage /> },
{ path: "login", element: <LoginPage /> },
{ path: "register", element: <RegisterPage /> },
{
element: <ProtectedRoute />,
children: [
{ path: "profile", element: <ProfilePage /> },
{ path: "orders", element: <OrdersPage /> },
],
},
],
},
]);
ProtectedRoute— маршрут-обёртка безpath: есть токен — показывает детей через<Outlet />, нет — отправляет на/login.state={{ from: ... }}запоминает, куда шёл пользователь;replaceне даёт «Назад» вернуть на закрытую страницу.
Выход
const queryClient = useQueryClient();
const logout = useAuthStore((s) => s.logout);
function handleLogout() {
logout();
queryClient.clear(); // удаляем кэш с данными прошлого пользователя
navigate("/login");
}
Типичные ошибки
Bearerбез пробела или токен без префикса — сервер вернёт401.- Бесконечный цикл refresh — нет флага
_retryили refresh идёт через тот жеapi. - Не очистили кэш при выходе — следующий пользователь видит чужие данные.
- Токены в
console.logили в адресе — попадают в логи и историю. - Доверие к payload. Роль из токена годится, чтобы спрятать кнопку, но не как защита.
Практика
- Swagger. Откройте Swagger учебного API (его даст ментор). Выпишите для регистрации и входа метод, путь, тело и коды ответов. Войдите через Try it out и Authorize.
- Регистрация. Сделайте форму регистрации с валидацией yup: email, пароль от 8 символов с цифрой, совпадение паролей. Ожидаемый результат: ошибки под полями, запрос не уходит, пока форма невалидна.
- Вход и интерцептор. Реализуйте вход, сохранение токенов в Zustand и request-интерцептор. Во вкладке Network убедитесь, что запрос
/auth/meидёт с заголовкомAuthorization. - Защищённые страницы. Добавьте
ProtectedRouteдля/profileи возврат на исходную страницу после входа. Кнопка «Выйти» очищает токены и кэш. - Refresh. Добавьте response-интерцептор с обновлением токена. Испортите access-токен в Local Storage: запрос должен получить
401, обновить токен и повториться.
Итоги
- JWT состоит из header, payload и signature; payload читается кем угодно, поэтому секретов в нём нет.
- Access-токен короткоживущий и идёт в
Authorization: Bearer, refresh — для получения нового access. - Swagger показывает эндпоинты, тела запросов и ответы; по нему вручную пишем функции API и тестируем запросы через Try it out.
- Самое безопасное хранение — refresh в
httpOnly-cookie, access в памяти;localStorageуязвим для XSS. - Интерцепторы axios подставляют токен и прозрачно обновляют его при
401. ProtectedRouteсNavigateиOutletзакрывает страницы, но настоящая защита — на сервере.