Существует странный ритуал, который веб-разработчики по всему миру соблюдают с зарождения компьютеров и по сей день. Этот ритуал — реализация аутентификации. Вы, возможно, уже не раз проходили через этот ритуал. Но действительно ли вы понимаете, что происходит? Эта статья сделает этот ритуал менее загадочным. Вы узнаете о токенах, авторизации, CORS, учетных данных, HTTP-заголовках и прочем. Чтобы реализовать хорошую систему аутентификации, не обязательно быть волшебником. Достаточно быть хорошим банкиром!
Пароль совпал. Что дальше?
Аутентификация — это процесс сопоставления запроса с уникальным субъектом, обычно человеком. Далее идет авторизация — процесс предоставления (или отказа в) доступе к ресурсу, будь то веб-страница или API-эндпоинт.
Я сосредоточусь на той части, которую сам долго не мог понять: что происходит после того, как пользователь отправил форму входа и сервер подтвердил корректность его учетных данных?
app.post("/login", async (request, response) => {
const { body } = request;
const user = await getUserByCredentials(body.username, body.password);
if (user) {
// А ЧТО ДАЛЬШЕ???
}
});
Типичный эндпоинт входа. В этой статье пойдет речь о том, что происходит после этого.
Мы поговорим о токенах, CORS, cookies и заголовках. Эти концепции могут казаться абстрактными, но с банковской аналогией они станут более понятными.
Аутентификация держится на токенах, как банки — на деньгах
Есть два популярных подхода:
- JWT (JSON Web Token) — открытый стандарт, ниже вы найдете ссылки для изучения подробнее.
- Аутентификация на основе сессий — скорее шаблон, чем стандарт, реализация может отличаться, но мы рассмотрим суть.
Как фронтенд-разработчик, вы, возможно, не решаете, какой подход использовать. Но вы должны понимать оба.
Как бэкенд- или фуллстек-разработчик, вы сами выбираете подход, и вам тем более важно понимать, что вы делаете. Это ведь ваша работа, правда?
JWT — это как банкнота
При использовании JWT сервер выдает клиенту токен с зашифрованной информацией — так называемым «claim». При последующих запросах клиент передает этот токен. Сервер проверяет, что токен подлинный (т.е. был выдан этим сервером), а затем считывает полезную нагрузку, чтобы определить, кто вы — по ID пользователя, имени, email и т.п. На основе этих данных сервер разрешает или отказывает в доступе.
Представьте, что токен — это банкнота. Банкир (сервер) может напечатать новую банкноту (выдать токен). Деньги дают вам доступ к товарам и услугам. JWT — то же самое, только для защищенных сервисов. Если вы хотите использовать деньги, в банке или в магазине, сотрудники проверят, настоящая ли банкнота. Если настоящая — можно использовать.
Сложно отозвать токен: сервер должен вести черный список недействительных токенов, как банк ведет список украденных банкнот.
Аутентификация на сессиях — это как банковская карта
Для фронтендера главное отличие — в том, что сервер возвращает при успешной авторизации.
Сессионная аутентификация возвращает не токен с информацией, а простой session id. С точки зрения бэкенда, это требует хранения сессий в базе. Сессия — это объект, аналогичный JWT, содержащий ID пользователя, срок действия и т.д. Сервер контролирует аутентификацию: он может «разлогинить» пользователя, просто удалив сессию. JWT удалить невозможно, так как он не хранится на сервере.
Сессионная аутентификация — это как банковская карта. Банкир проверяет личность и выдает карту с истекающим сроком действия. Карта — это просто идентификатор, ее легко отозвать. При попытке «заплатить» (получить доступ к ресурсу) магазин связывается с банком, чтобы проверить карту и баланс. Аналогично — сервер проверяет session id.
Токены так же ценны, как банкноты и карты. Их нельзя терять!
Банки хранят деньги в стальных сейфах с сигнализацией и, возможно, драконами. Серверы тоже могут безопасно общаться между собой. А вот веб-сайты — как частные лица. Им приходится придумывать, как хранить токены безопасно.
Где вы храните свои деньги?
Что бы вы ни выбрали — JWT или session id — это ценности. В статье я буду называть их «токенами аутентификации», то есть тем, что вы показываете серверу, чтобы подтвердить свою личность. Чтобы каждый запрос к серверу сопровождался токеном, его нужно где-то хранить на клиенте. Мы рассмотрим именно случай веб-сайтов, где запросы отправляет браузер. Часто документация не уточняет, относится ли она к взаимодействию сервер-сервер или браузер-сервер. Второй случай сложнее и требует специальных подходов.
Итак, банкир выдал вам карту или пачку банкнот. Куда вы их спрячете? В подошву ботинка, в почтовый ящик, под матрас или в модную поясную сумку?
Не все запросы равны: загрузка веб-страницы против вызова API
Аутентифицированный контент делится на два типа:
- Веб-страницы — возвращают HTML, вызываются автоматически при переходе по URL.
- API-эндпоинты — возвращают JSON и другие данные, вызываются из JavaScript.
Два сценария:
- Вы хотите защитить саму веб-страницу, чтобы пользователь не видел даже её структуру без входа.
- Вы хотите защитить вызовы к API, чтобы пользователь мог видеть HTML, но не получить данные.
Можно комбинировать оба: неавторизованные пользователи не видят ни страницы, ни данных.
Сценарий 1: Защита веб-страницы с помощью cookie
Коротко: чтобы защитить доступ к HTML-странице, нужно использовать cookie, желательно HTTP-only и secure. Технически cookie — это данные, которые отправляются с каждым запросом к определенному домену. Это часть протокола HTTP. Когда сайт делит cookie на «обязательные» и остальные — как раз обязательные часто отвечают за аутентификацию.
Установить cookie можно как с клиента (через JavaScript), так и с сервера (через заголовок Set-Cookie). Именно сервер может установить HTTP-only cookie, которую нельзя прочитать из JavaScript. А secure cookie передается только через HTTPS.
Важно: HTTP-only cookie защищает от XSS (JavaScript-атаки), но не от CSRF (подделка запросов). Нужно применять комбинацию защитных мер.
При входе в систему сервер должен ответить заголовком Set-Cookie, и браузер автоматически сохранит токен.
Такой cookie — это как кошелек. В нем можно хранить и карточки лояльности, и деньги. Он у вас в сумке, недоступен посторонним (если вы не достаете его в опасных местах). Вы всегда готовы «заплатить» (подтвердить личность), когда нужно.
Сценарий 2: Защита API-вызовов. Вариант с cookie
API питают ваш сайт данными. Самый простой способ защитить вызовы — использовать cookie, как и с HTML. Но, в отличие от страниц, JavaScript-запросы требуют дополнительной настройки для передачи cookie. Если cookie HTTP-only, вы не можете прочитать его в JS и вставить в Authorization-заголовок. Вы должны передавать cookie напрямую.
Используя fetch, вы добавляете:
fetch("http://localhost:8000/account/signup", {
method: "POST",
body: JSON.stringify({ username: "foo", password: "barbar" }),
credentials: "include"
});
Аналогичная настройка withCredentials есть в XMLHttpRequest. Кроме того, сервер должен разрешить отправку учетных данных, установив Access-Control-Allow-Credentials: true в ответе. Если этого не сделать — браузер проигнорирует Set-Cookie, и вы не поймете, почему все ломается. Проверьте credentials и CORS.
Банковская аналогия: вы всегда носите карту и банкноты в кошельке. Но для некоторых операций вам нужна предоплаченная карта, которую вы забыли взять. Это и есть отсутствие credentials: "include".
Сценарий 2: хранение токена в web storage
Для API-вызовов можно использовать web storage: localStorage или sessionStorage.
Они проще в использовании, так как доступны из JavaScript. Вы можете:
- Получить токен из ответа сервера,
- Сохранить его в localStorage,
- Добавить в заголовок Authorization:
headers: {
"Authorization": `Bearer ${window.localStorage.getItem("auth_token")}`
}
Плюс: такой подход не уязвим для CSRF-атак, ведь токен не передается автоматически. Но он уязвим для XSS, т.к. токен можно украсть через вредоносный скрипт.
Если вы выбираете web storage — обязательно защищайтесь от XSS.
Особенности JWT: что в них должно быть и зачем нужен refresh token
В JWT не стоит хранить критически важную информацию. Даже если токен украдут и расшифруют — вред будет минимальным. JWT должны быть короткоживущими — 5–10 минут. Ведь их сложно отозвать. Чтобы не заставлять пользователя логиниться каждые 5 минут, применяют refresh-токен — долгоживущий, однократный токен, обычно хранящийся в cookie на ограниченном пути (/refresh).
Аналогия: чтобы снять деньги в банке, вы используете ID (refresh-токен), а не каждый раз получаете банкноту (JWT).
CORS — это как диалог магазина с банком
CORS (Cross-Origin Resource Sharing) — механизм, с помощью которого сервер указывает, какие источники могут обращаться к его API. Если браузер считает, что источник запроса недопустим, он даже не отправит запрос. Например, сайт https://www.foobar.com может обращаться только к API на https://api.foobar.com, если сервер это разрешает.
Для этого сервер должен в ответах указывать заголовки:
Access-Control-Allow-Origin: https://www.foobar.com
Access-Control-Allow-Credentials: true
Браузер очень вежлив: он не будет говорить с «грубыми» серверами (без CORS-заголовков) и не будет слать запросы, если чувствует, что не получит ответа.
Вежливый диалог: preflight-запросы
Если запрос считается «несложным» (например, обычный GET), preflight не нужен. Но при fetch с POST, Authorization или credentials — браузер сначала отправляет OPTIONS-запрос (preflight) на тот же URL. Сервер должен ответить заголовками Access-Control-Allow-*, иначе браузер заблокирует основной запрос.
Сравните: магазин принимает только карты Visa. Если вы пришли с MasterCard — кассир вежливо откажет.
SameSite: будет ли cookie отправлен при кросс-сайт запросе?
Атрибут SameSite управляет тем, когда cookie будет отправляться. Обычно стоит значение Lax.
Примеры:
Strict: cookie передаются только при навигации внутри сайта.Lax: cookie передаются и при переходе с другого сайта (через ссылку).None: cookie всегда передаются (если Secure тоже установлен).
Если настроено неправильно — сервер не получит cookie, и вы будете считаться неавторизованным.
Sec-Fetch-заголовки: интересный, но нестабильный способ определения источника запроса
Браузеры могут добавлять заголовки Sec-Fetch-*, которые помогают серверу понять контекст запроса.
Например: Sec-Fetch-Site: same-origin — можно не проверять Origin.
Но не все браузеры поддерживают эти заголовки (Safari — нет), так что это вспомогательная мера.
CORS касается только браузеров
Сервер-сервер запросы не подчиняются CORS. Кто-то может создать API-прокси, оборачивающий ваш API. Но запускать сервер стоит денег и оставляет следы. Поэтому злоумышленники предпочитают использовать браузеры жертвы — и вот тут помогает CORS.
Не путайте доступ к веб-странице с API-вызовом
Это две разные ситуации:
- Доступ к HTML через URL — должен использовать cookie.
- Вызов API через JavaScript — можно использовать Authorization-заголовок.
Если вы храните токен в storage, вы не сможете защитить саму HTML-страницу, только API.
Web Storage = почтовый ящик
localStorage или sessionStorage — это как хранить деньги в почтовом ящике. Если кто-то вскроет ящик (XSS), он украдет токен.
JWT в заголовке — только для API
Если вы храните токен в storage, то вставляете его в заголовок:
Authorization: Bearer <token>
Но это не работает для HTML, так как браузер не вставит заголовок автоматически при переходе по ссылке. Для страниц лучше использовать cookie.
Basic Auth
Basic Auth — устаревший и небезопасный способ аутентификации. Имя и пароль передаются с каждым запросом.
Иногда подходит для временных демо-страниц. Браузер сам добавит Authorization-заголовок при навигации. Вот почему в fetch и XHR используется общий параметр credentials, а не cookies.
Заключение
Вы дочитали? Поздравляю — теперь вы почти банковский аудитор по безопасности!
Вот краткий итог:
- JWT — как банкнота, sessionId — как кредитка. Любой токен нужно хранить надежно.
- HTTP-only secure cookie — безопасное место в браузере. Но если вы выбираете
localStorage, защищайтесь от XSS. - Cookies отправляются автоматически, если правильно задать
path,SameSite,Secure. - Для JavaScript-запросов (
fetch,XHR) укажитеcredentials: "include", и не забудьте заголовокAccess-Control-Allow-Credentials. - CORS — это вежливый способ общения между сайтами и API. Без нужных заголовков запросы просто не работают.
- Не путайте API и HTML-доступ. Cookie — для страниц, заголовки — для API.
- Basic Auth — исключение, использовать осторожно.
- BFF (backend-for-frontend) — способ обойти ограничения API, когда нельзя изменить его реализацию.