Разбираем суть веб-аутентификации — JWT, сессии, cookie, CORS и XSS — с помощью простой банковской аналогии. Всё, что нужно знать фронтенду и бэкенду.
Существует странный ритуал, который веб-разработчики по всему миру соблюдают с зарождения компьютеров и по сей день. Этот ритуал — реализация аутентификации. Вы, возможно, уже не раз проходили через этот ритуал. Но действительно ли вы понимаете, что происходит? Эта статья сделает этот ритуал менее загадочным. Вы узнаете о токенах, авторизации, 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 сервер выдает клиенту токен с зашифрованной информацией — так называемым «claim». При последующих запросах клиент передает этот токен. Сервер проверяет, что токен подлинный (т.е. был выдан этим сервером), а затем считывает полезную нагрузку, чтобы определить, кто вы — по ID пользователя, имени, email и т.п. На основе этих данных сервер разрешает или отказывает в доступе.
Представьте, что токен — это банкнота. Банкир (сервер) может напечатать новую банкноту (выдать токен). Деньги дают вам доступ к товарам и услугам. JWT — то же самое, только для защищенных сервисов. Если вы хотите использовать деньги, в банке или в магазине, сотрудники проверят, настоящая ли банкнота. Если настоящая — можно использовать.
Сложно отозвать токен: сервер должен вести черный список недействительных токенов, как банк ведет список украденных банкнот.
Для фронтендера главное отличие — в том, что сервер возвращает при успешной авторизации.
Сессионная аутентификация возвращает не токен с информацией, а простой session id
. С точки зрения бэкенда, это требует хранения сессий в базе. Сессия — это объект, аналогичный JWT, содержащий ID пользователя, срок действия и т.д. Сервер контролирует аутентификацию: он может «разлогинить» пользователя, просто удалив сессию. JWT удалить невозможно, так как он не хранится на сервере.
Сессионная аутентификация — это как банковская карта. Банкир проверяет личность и выдает карту с истекающим сроком действия. Карта — это просто идентификатор, ее легко отозвать. При попытке «заплатить» (получить доступ к ресурсу) магазин связывается с банком, чтобы проверить карту и баланс. Аналогично — сервер проверяет session id
.
Банки хранят деньги в стальных сейфах с сигнализацией и, возможно, драконами. Серверы тоже могут безопасно общаться между собой. А вот веб-сайты — как частные лица. Им приходится придумывать, как хранить токены безопасно.
Что бы вы ни выбрали — JWT или session id — это ценности. В статье я буду называть их «токенами аутентификации», то есть тем, что вы показываете серверу, чтобы подтвердить свою личность. Чтобы каждый запрос к серверу сопровождался токеном, его нужно где-то хранить на клиенте. Мы рассмотрим именно случай веб-сайтов, где запросы отправляет браузер. Часто документация не уточняет, относится ли она к взаимодействию сервер-сервер или браузер-сервер. Второй случай сложнее и требует специальных подходов.
Итак, банкир выдал вам карту или пачку банкнот. Куда вы их спрячете? В подошву ботинка, в почтовый ящик, под матрас или в модную поясную сумку?
Аутентифицированный контент делится на два типа:
Два сценария:
Можно комбинировать оба: неавторизованные пользователи не видят ни страницы, ни данных.
Коротко: чтобы защитить доступ к 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 — это как кошелек. В нем можно хранить и карточки лояльности, и деньги. Он у вас в сумке, недоступен посторонним (если вы не достаете его в опасных местах). Вы всегда готовы «заплатить» (подтвердить личность), когда нужно.
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".
Для API-вызовов можно использовать web storage: localStorage или sessionStorage.
Они проще в использовании, так как доступны из JavaScript. Вы можете:
headers: {
"Authorization": `Bearer ${window.localStorage.getItem("auth_token")}`
}
Плюс: такой подход не уязвим для CSRF-атак, ведь токен не передается автоматически. Но он уязвим для XSS, т.к. токен можно украсть через вредоносный скрипт.
Если вы выбираете web storage — обязательно защищайтесь от XSS.
В JWT не стоит хранить критически важную информацию. Даже если токен украдут и расшифруют — вред будет минимальным. JWT должны быть короткоживущими — 5–10 минут. Ведь их сложно отозвать. Чтобы не заставлять пользователя логиниться каждые 5 минут, применяют refresh-токен — долгоживущий, однократный токен, обычно хранящийся в cookie на ограниченном пути (/refresh
).
Аналогия: чтобы снять деньги в банке, вы используете ID (refresh-токен), а не каждый раз получаете банкноту (JWT).
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-заголовков) и не будет слать запросы, если чувствует, что не получит ответа.
Если запрос считается «несложным» (например, обычный GET), preflight не нужен. Но при fetch с POST, Authorization или credentials — браузер сначала отправляет OPTIONS-запрос (preflight) на тот же URL. Сервер должен ответить заголовками Access-Control-Allow-*, иначе браузер заблокирует основной запрос.
Сравните: магазин принимает только карты Visa. Если вы пришли с MasterCard — кассир вежливо откажет.
Атрибут SameSite управляет тем, когда cookie будет отправляться. Обычно стоит значение Lax
.
Примеры:
Strict
: cookie передаются только при навигации внутри сайта.Lax
: cookie передаются и при переходе с другого сайта (через ссылку).None
: cookie всегда передаются (если Secure тоже установлен).Если настроено неправильно — сервер не получит cookie, и вы будете считаться неавторизованным.
Браузеры могут добавлять заголовки Sec-Fetch-*
, которые помогают серверу понять контекст запроса.
Например: Sec-Fetch-Site: same-origin
— можно не проверять Origin
.
Но не все браузеры поддерживают эти заголовки (Safari — нет), так что это вспомогательная мера.
Сервер-сервер запросы не подчиняются CORS. Кто-то может создать API-прокси, оборачивающий ваш API. Но запускать сервер стоит денег и оставляет следы. Поэтому злоумышленники предпочитают использовать браузеры жертвы — и вот тут помогает CORS.
Это две разные ситуации:
Если вы храните токен в storage, вы не сможете защитить саму HTML-страницу, только API.
localStorage
или sessionStorage
— это как хранить деньги в почтовом ящике. Если кто-то вскроет ящик (XSS), он украдет токен.
Если вы храните токен в storage, то вставляете его в заголовок:
Authorization: Bearer <token>
Но это не работает для HTML, так как браузер не вставит заголовок автоматически при переходе по ссылке. Для страниц лучше использовать cookie.
Basic Auth — устаревший и небезопасный способ аутентификации. Имя и пароль передаются с каждым запросом.
Иногда подходит для временных демо-страниц. Браузер сам добавит Authorization
-заголовок при навигации. Вот почему в fetch
и XHR
используется общий параметр credentials
, а не cookies
.
Вы дочитали? Поздравляю — теперь вы почти банковский аудитор по безопасности!
Вот краткий итог:
localStorage
, защищайтесь от XSS.path
, SameSite
, Secure
.fetch
, XHR
) укажите credentials: "include"
, и не забудьте заголовок Access-Control-Allow-Credentials
.