сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Java-веб

Аутентификация и защита в 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, остальные разбираются в тренажёре.

  1. #security_auth1 / 5
    Что представляет собой 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.

  2. #security_auth2 / 5
    В чём главный компромисс stateless JWT по сравнению с серверной сессией?
    A)JWT занимает меньше места, чем session id, поэтому его передача быстрее и безопаснее сессии
    B)JWT хранится на сервере, а сессия — на клиенте, поэтому JWT проще отозвать в нужный момент
    C)Не нужен серверный стор (масштабируется), но токен трудно отозвать до истечения срока
    D)JWT работает только по HTTP, а серверные сессии — только по HTTPS, в этом их основное различие
    показать ответ и разбор
    +C)Не нужен серверный стор (масштабируется), но токен трудно отозвать до истечения срока

    // разбор: Серверная сессия: сервер хранит состояние (session store), клиент носит короткий session-id (кука) — легко ОТОЗВАТЬ (удалить сессию), но нужен общий стор и «липкость»/репликация при масштабировании. Stateless JWT: состояние в самом токене, сервер лишь проверяет подпись — не нужен серверный стор, любой инстанс валидирует токен сам (отлично для горизонтального масштаба/микросервисов). Плата: токен ДЕЙСТВИТЕЛЕН до exp, и мгновенно ОТОЗВАТЬ его трудно (он не хранится на сервере) — при компрометации/логауте нужны обходы: короткий TTL + refresh-токены, чёрные списки, версия ключа. Выбор — масштабируемость против управляемости отзыва.

  3. #security_auth3 / 5
    Что такое CORS и какую задачу он решает?
    A)Способ шифрования запросов между разными доменами, заменяющий собой протокол HTTPS для API
    B)Ограничение частоты запросов с одного адреса, защищающее сервер от чрезмерной нагрузки (rate limit)
    C)Механизм кэширования ответов сервера на стороне браузера для ускорения повторных обращений к API
    D)Механизм, разрешающий браузеру кросс-доменные запросы через заголовки сервера (в обход 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» в консоли с проблемой сервера, хотя настраивается она на сервере.

  4. #security_auth4 / 5
    Как правильно хранить пароли пользователей в БД?
    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). Так даже при утечке БД пароли остаются практически невосстановимыми.

  5. #security_auth5 / 5
    Почему нельзя доверять данным из запроса (например, роли или id пользователя из тела/параметра)?
    A)Потому что данные из запроса приходят в неверной кодировке, и сервер не может их прочитать
    B)Клиент полностью контролирует запрос — авторизацию решают по проверенному токену/сессии на сервере
    C)Потому что параметры запроса передаются медленно, и к моменту обработки они успевают устареть
    D)Потому что HTTP запрещает передавать в запросе идентификаторы и роли — только в заголовках ответа
    показать ответ и разбор
    +B)Клиент полностью контролирует запрос — авторизацию решают по проверенному токену/сессии на сервере

    // разбор: Любые данные запроса (тело, query, заголовки, кроме проверенных сервером) ПОЛНОСТЬЮ подконтрольны клиенту — их легко подделать (curl/Postman/перехват). Поэтому НЕЛЬЗЯ решать авторизацию по «роли»/«userId», ПРИСЛАННЫМ клиентом: злоумышленник пришлёт role=admin или чужой userId. Личность и права берут из ПРОВЕРЕННОГО СЕРВЕРОМ контекста — валидного (подпись+срок) JWT или серверной сессии, — и на нём же проверяют доступ к конкретному ресурсу (в т.ч. что ресурс принадлежит ЭТОМУ пользователю — защита от IDOR). Классика уязвимостей — доверять клиентскому id/роли и менять /orders/1001 на /orders/1002. Сервер — единственный источник истины об авторизации.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.