Аутентификация и защита в Java
Собрал настоящий JWT-токен и декодировал его среднюю часть без всякого ключа. Получил открытым текстом: {"sub":"user-42","role":"ADMIN","exp":1800000000}. Потом взял поле логина, подставил туда нет' or '1'='1 и склеил SQL-запрос строками - база вернула ВСЕ строки, включая чужой секрет. Тот же ввод в параметризованный запрос вернул ноль строк.
Оба замера - про одно: неверное представление о том, что тебя защищает. Токен подписан, но не спрятан; экранирование кавычек не защищает, а параметр защищает.
// Формулировки: «чем аутентификация отличается от авторизации?», «что внутри JWT и что он гарантирует?», «как хранить пароли?», «что такое небезопасная прямая ссылка на объект?».
Кто ты и что тебе можно
Аутентификация отвечает на вопрос «кто ты» - проверка логина с паролем, токена, сертификата. Авторизация отвечает на вопрос «что тебе можно» - проверка прав на конкретное действие с конкретным объектом. Это два разных шага, и путать их дорого: система, где всё сводится к первому, пускает любого зарегистрированного пользователя куда угодно.
Пароли не хранят и не шифруют - их хешируют алгоритмом, специально сделанным медленным. Я замерил bcrypt на разных значениях стоимости: 4 - шесть миллисекунд, 8 - двадцать, 10 - семьдесят пять, 12 - двести девяносто пять. Каждая единица стоимости примерно удваивает время. Для тебя это незаметная задержка при входе, для перебирающего миллионы вариантов - разница между днём и годом.
// Второй важный кусок замера: я дважды захешировал ОДИН И ТОТ ЖЕ пароль и получил два разных значения, причём оба успешно прошли проверку. Так работает соль - случайная добавка, которая попадает прямо в строку хеша. Благодаря ей одинаковые пароли выглядят по-разному, и заранее посчитанные таблицы соответствий бесполезны.
стоимость 4: 6 мс
стоимость 8: 20 мс
стоимость 10: 75 мс
стоимость 12: 295 мс
один пароль, два хеша:
$2a$10$MOekS8x00CK.jNRyuC856eQNbTnuoH34dp2oWI2xzIkTfunLPbTiG
$2a$10$hWIuM9lthkGitdIjDkK3luCHJ1RJooKrxmhirvaKu1QCz2F0hWCKi
оба проходят проверку- аутентификация / авторизация
- кто ты / что тебе можно
- соль
- случайная добавка в хеш: одинаковые пароли выглядят по-разному
Что на самом деле гарантирует JWT
JWT (JSON Web Token) состоит из трёх частей через точку: заголовок, полезная нагрузка и подпись. Первые две - это обычный base64, кодировка, а не шифрование. Мой замер это показал буквально: роль и идентификатор пользователя читаются без ключа кем угодно, у кого есть токен.
Подпись даёт другое - целостность. Я собрал подделку с ролью повыше и подписал своим ключом: подпись не совпала с той, которую ожидает сервер. То есть содержимое подменить нельзя, а вот прочитать - можно. Отсюда правило: в токен не кладут ничего, что не должно быть видно клиенту.
// Вторая особенность - токен нельзя отозвать. Он самодостаточен: сервер проверяет подпись и срок, никуда не заглядывая. Уволили сотрудника - его токен работает до истечения срока. Поэтому срок жизни делают коротким, минуты, а для продления заводят отдельный токен обновления, который хранится на сервере и отзывается. И проверять надо И подпись, И срок: библиотеки, где алгоритм подписи берётся из самого заголовка токена, исторически позволяли злоумышленнику указать «без подписи».
токен: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQyIiw...
средняя часть, декодированная без ключа:
{"sub":"user-42","role":"ADMIN","exp":1800000000}
подделка с другой ролью: подпись НЕ совпала с ожидаемой- JWT
- подписанный, но не зашифрованный токен: содержимое читается всеми
- токен обновления
- долгоживущий и отзываемый; основной токен делают коротким
Минимум, который спрашивают
Инъекции. Мой третий замер: логин нет' or '1'='1, склеенный в запрос строками, вернул обе строки таблицы вместе с чужим секретом. Тот же ввод, переданный параметром, вернул ноль строк - потому что параметр никогда не становится частью текста запроса, его подставляют отдельно. Никакого экранирования вручную, только параметры.
Небезопасная прямая ссылка на объект. Пользователь меняет /orders/42 на /orders/43 и видит чужой заказ. Проверки роли тут недостаточно: у обоих роль «клиент», и оба имеют право смотреть заказы - вопрос в том, СВОЙ ли это заказ. Значит, проверять надо принадлежность объекта запрашивающему, причём в запросе к базе, а не после выборки.
// Ещё три пункта того же уровня. Разные сообщения при неверном логине и неверном пароле подсказывают, какие учётные записи существуют, - ответ должен быть одинаковым. Ограничение частоты попыток входа обязательно, иначе перебор упирается только в скорость сети. И секреты живут в переменных окружения или хранилище, а не в файле конфигурации под системой контроля версий.
// склейка строк:
select login, secret from users where login = 'нет' or '1'='1'
// вернулось 2 строки: anna/обычный admin/ядерные коды
// параметр:
ps.setString(1, "нет' or '1'='1");
// вернулось 0 строк- параметризованный запрос
- значение подставляется отдельно и не становится частью текста запроса
- прямая ссылка на объект
- подмена идентификатора в адресе даёт доступ к чужим данным
Как отвечать: «Почему проверки роли недостаточно?»
Потому что роль отвечает на вопрос, можно ли пользователю выполнять такое действие вообще, а не на вопрос, можно ли ему выполнять его с ЭТИМ объектом. Классический пример: два обычных клиента, у обоих роль «клиент», оба имеют право смотреть заказы. Один меняет в адресе /orders/42 на /orders/43 и видит чужой заказ - права формально есть, принадлежность никто не проверил. Это называется небезопасной прямой ссылкой на объект. Поэтому я проверяю владение, и делаю это в самом запросе к базе - выбираю заказ по паре идентификаторов, заказа и владельца, - а не выбираю по одному и сравниваю после. И держу в голове, что скрытие идентификатора не защита: угадать последовательный номер тривиально, а случайный идентификатор просто снижает вероятность, но не решает задачу.
Почему это сильный ответ: разведены право на действие и право на объект, дан конкретный сценарий и названо, где именно ставить проверку, плюс отвергнут популярный ложный обходной путь.
На чём валят
- −Считать, что JWT прячет содержимое. Я декодировал роль и идентификатор без всякого ключа - подпись защищает от подмены, а не от чтения.
- −Собирать запрос склейкой строк. Ввод
нет' or '1'='1у меня вернул всю таблицу; параметр вернул ноль строк. - −Проверять только роль. Подмена идентификатора в адресе даёт доступ к чужим объектам при формально верных правах.
- −Хранить пароли обычным быстрым хешем и без соли. Нужен медленный алгоритм со стоимостью и случайной добавкой.
- −Разные ответы на неверный логин и неверный пароль. Так подтверждается, какие учётные записи существуют.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 17, остальные разбираются в тренажёре.
- Что представляет собой JWT (JSON Web Token) и как он устроен?A)Зашифрованный файл на сервере, где хранится сессия пользователя, а клиенту выдаётся ссылка на негоB)Три части (header.payload.signature) в base64url; подпись подтверждает целостностьC)Случайный идентификатор сессии, по которому сервер каждый раз ищет данные пользователя в своей базеD)Пара логин-пароль, закодированная в base64 и передаваемая в каждом запросе для повторного входа
показать ответ и разбор
+B)Три части (header.payload.signature) в base64url; подпись подтверждает целостность// разбор: JWT — самодостаточный токен из трёх частей через точку: HEADER (алгоритм/тип), PAYLOAD (claims: кто пользователь, роли, срок жизни exp, издатель) и SIGNATURE. Всё закодировано base64url (это НЕ шифрование — payload читается любым!), а ПОДПИСЬ (HMAC секретом или RSA-парой) позволяет серверу проверить, что токен ВЫДАН ИМ и НЕ ИЗМЕНЁН, не обращаясь к БД. Отсюда его сила — STATELESS аутентификация: вся нужная информация в самом токене. Отсюда и правила: не класть в payload секреты (он читаем), обязательно ставить срок жизни, проверять подпись/exp/issuer.
- В чём главный компромисс stateless JWT по сравнению с серверной сессией?A)JWT занимает меньше места, чем session id, поэтому его передача быстрее и безопаснее сессииB)JWT хранится на сервере, а сессия — на клиенте, поэтому JWT проще отозвать в нужный моментC)Не нужен серверный стор (масштабируется), но токен трудно отозвать до истечения срокаD)JWT работает только по HTTP, а серверные сессии — только по HTTPS, в этом их основное различие
показать ответ и разбор
+C)Не нужен серверный стор (масштабируется), но токен трудно отозвать до истечения срока// разбор: Серверная сессия: сервер хранит состояние (session store), клиент носит короткий session-id (кука) — легко ОТОЗВАТЬ (удалить сессию), но нужен общий стор и «липкость»/репликация при масштабировании. Stateless JWT: состояние в самом токене, сервер лишь проверяет подпись — не нужен серверный стор, любой инстанс валидирует токен сам (отлично для горизонтального масштаба/микросервисов). Плата: токен ДЕЙСТВИТЕЛЕН до exp, и мгновенно ОТОЗВАТЬ его трудно (он не хранится на сервере) — при компрометации/логауте нужны обходы: короткий TTL + refresh-токены, чёрные списки, версия ключа. Выбор — масштабируемость против управляемости отзыва.
- Что такое CORS и какую задачу он решает?A)Способ шифрования запросов между разными доменами, заменяющий собой протокол HTTPS для APIB)Ограничение частоты запросов с одного адреса, защищающее сервер от чрезмерной нагрузки (rate limit)C)Механизм кэширования ответов сервера на стороне браузера для ускорения повторных обращений к APID)Механизм, разрешающий браузеру кросс-доменные запросы через заголовки сервера (в обход same-origin)
показать ответ и разбор
+D)Механизм, разрешающий браузеру кросс-доменные запросы через заголовки сервера (в обход same-origin)// разбор: Браузеры по умолчанию соблюдают SAME-ORIGIN POLICY: JS со страницы одного origin (протокол+домен+порт) не может свободно читать ответы запросов к ДРУГОМУ origin. CORS (Cross-Origin Resource Sharing) — механизм, которым СЕРВЕР через заголовки (Access-Control-Allow-Origin и др.) ЯВНО разрешает браузеру такие кросс-доменные запросы. Для «непростых» запросов браузер сначала шлёт preflight (OPTIONS). Важно: CORS — это браузерная защита; серверные клиенты (curl, другой бэкенд) её не соблюдают, поэтому CORS НЕ заменяет аутентификацию/авторизацию. Типичная ошибка — путать «ошибку CORS» в консоли с проблемой сервера, хотя настраивается она на сервере.
- Как правильно хранить пароли пользователей в БД?A)Хешировать медленным адаптивным алгоритмом с солью (bcrypt/argon2), не хранить в открытом видеB)Хранить в открытом виде, но в отдельной защищённой таблице с ограниченным доступом по паролю админаC)Шифровать симметричным ключом, чтобы при необходимости можно было расшифровать и показать пользователюD)Хешировать быстрым MD5 или SHA-1 без соли — этого достаточно для защиты учётных данных пользователей
показать ответ и разбор
+A)Хешировать медленным адаптивным алгоритмом с солью (bcrypt/argon2), не хранить в открытом виде// разбор: Пароли НИКОГДА не хранят в открытом виде и не шифруют обратимо (расшифровать нельзя должно быть в принципе). Их пропускают через ОДНОСТОРОННИЙ хеш с СОЛЬЮ (случайной на каждый пароль — против радужных таблиц и одинаковых паролей) и, критически, МЕДЛЕННЫМ АДАПТИВНЫМ алгоритмом с настраиваемой стоимостью: bcrypt, scrypt, argon2 — они специально дорогие, чтобы перебор был невыгоден даже на GPU. Быстрые MD5/SHA-1/SHA-256 для паролей НЕ годятся (миллиарды хешей/сек). При входе сравнивают хеши. В Spring Security это PasswordEncoder (BCryptPasswordEncoder). Так даже при утечке БД пароли остаются практически невосстановимыми.
- Почему нельзя доверять данным из запроса (например, роли или id пользователя из тела/параметра)?A)Потому что данные из запроса приходят в неверной кодировке, и сервер не может их прочитатьB)Клиент полностью контролирует запрос — авторизацию решают по проверенному токену/сессии на сервереC)Потому что параметры запроса передаются медленно, и к моменту обработки они успевают устаретьD)Потому что HTTP запрещает передавать в запросе идентификаторы и роли — только в заголовках ответа
показать ответ и разбор
+B)Клиент полностью контролирует запрос — авторизацию решают по проверенному токену/сессии на сервере// разбор: Любые данные запроса (тело, query, заголовки, кроме проверенных сервером) ПОЛНОСТЬЮ подконтрольны клиенту — их легко подделать (curl/Postman/перехват). Поэтому НЕЛЬЗЯ решать авторизацию по «роли»/«userId», ПРИСЛАННЫМ клиентом: злоумышленник пришлёт role=admin или чужой userId. Личность и права берут из ПРОВЕРЕННОГО СЕРВЕРОМ контекста — валидного (подпись+срок) JWT или серверной сессии, — и на нём же проверяют доступ к конкретному ресурсу (в т.ч. что ресурс принадлежит ЭТОМУ пользователю — защита от IDOR). Классика уязвимостей — доверять клиентскому id/роли и менять /orders/1001 на /orders/1002. Сервер — единственный источник истины об авторизации.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.