JavaRush /Курсы /Spring Security /Пароль нельзя хранить открыто

Пароль нельзя хранить открыто

Spring Security
6 уровень , 0 лекция
Открыта

1. Пароль — это секрет, а не «ещё одно поле в JSON»

Если до этого мы думали про безопасность как про правила доступа к endpoint’ам, то сегодня переключаемся на другой слой: на сами credentials. Пароль — не «просто значение, которое ввёл пользователь». Это секрет, который даёт доступ. Ошибка в обращении с секретом обнуляет любые красивые hasRole() и даже самый аккуратный SecurityFilterChain.

Начнём с простой мысли: многие данные пользователя можно «прочитать» и даже утечь ими неприятно, но не критично. Например, отображаемое имя или bio из профиля — да, жалко, да, неприятно, но мир не рухнул. Пароль — другая категория. Он не про «информацию», он про «доступ». Поэтому пароль нельзя хранить как обычное поле в объекте или строку в конфиге: любая копия этой строки автоматически становится ключом от аккаунта.

Посмотрим на пример, который технически компилируется, выглядит «логично», и именно поэтому опасен:

package com.example.securecontent.security.demo;

class CredentialsStore {
    // В реальном приложении пароль никогда не должен "просто лежать" как строка в состоянии объекта.
    String username = "anna";

    // Плохо: пароль хранится в открытом виде (raw), и любая утечка этой строки = доступ к аккаунту.
    String storedPassword = "qwerty123";
}

Проблема здесь даже не в том, что это «учебный» класс. Проблема в модели: пароль как обычная строка, которая где-то «лежит». Как только вы в голове разрешили себе «пусть полежит», дальше начинаются компромиссы: сначала в коде, потом в application.yml, потом в тестовых данных, потом «давайте в лог выведем, чтобы проверить», и внезапно вы сами построили систему, где пароль путешествует по миру как турист с чемоданом без замка.

Ещё один важный нюанс — психологический. В обычном CRUD-проекте мы привыкли, что всё хранится в базе: email, телефон, дата рождения, статус, и так далее. Пароль — исключение. Он не должен становиться частью долгоживущего состояния «как есть». И это нужно принять как принцип ещё до технических деталей вроде hashing и PasswordEncoder.

2. Цена утечки: plain-text ломает всё

В этой части важно не впасть в паранойю, но и не свалиться в «да у нас маленький проект, кому мы нужны». В реальности утечки чаще происходят не из-за спецагента в чёрном плаще, а из-за обычной инженерной жизни: резервные копии, дампы, логи, багрепорты, скриншоты, «скинь мне SQL из локалки», «а давай быстро покажем демо на митинге». Plain-text пароль делает каждую такую бытовую ситуацию потенциальной катастрофой.

Если пароль хранится в открытом виде, то компрометация выглядит так: кто-то получил доступ к месту хранения — и всё, пароль уже «украден». Не «может быть украден», не «если сильно постараться», а просто украден, потому что это та же самая строка, которую пользователь вводит при логине. Системе уже нечем защищаться: злоумышленник не должен ничего «взламывать» дальше, он просто проходит authentication как легитимный пользователь.

И здесь появляется второй эффект, который новичкам часто кажется «не про программирование». Люди массово переиспользуют пароли. Если вы храните пароли открыто, то утечка из вашего учебного (или внутреннего) сервиса может стать входом в чужие аккаунты пользователя: почта, соцсети, банковские приложения. Это одна из причин, почему «внутренний сервис» не даёт вам морального права хранить пароль как строку. Это не только про вашу систему, это про пользователя.

В сквозном проекте Secure Content Platform API мы как раз и строим платформу, где будут роли, приватные черновики, админские операции. Представьте, что у вас есть админ-аккаунт (пусть даже тестовый), и его пароль лежит открыто. В какой-то момент этот пароль окажется в логе или дампе. После этого любой, кто увидел эту строку, может стать админом, и дальше уже не важно, насколько аккуратно вы закрывали /api/admin/**.

3. Утечки из логов, seed и дампов

Самая коварная часть — пароль редко «утекает из базы» как в кино. Чаще он расползается по инфраструктуре незаметно. И именно поэтому «не хранить пароль открыто» означает не только «не положить в таблицу», но и вообще не допускать, чтобы raw password жил дольше пары секунд обработки запроса и попадал в любые побочные каналы.

Ниже — таблица типичных мест, где пароли оказываются чаще, чем вам хочется. Это не список «страшилок», а обычные сценарии разработки и эксплуатации:

Где появляется пароль Как это выглядит в коде/процессе Почему это опасно
Логи приложения System.out.println(...),
log.info(...)
с паролем
Логи живут долго, часто уходят в централизованный сборщик, доступны многим
Seed-данные и демо-пользователи «Давайте добавим
admin/admin
в репозиторий»
Это попадает в Git навсегда (даже если потом удалили)
Дампы памяти / heap dump Пароль был в
String
и попал в дамп
Дампы часто пересылают «для анализа», и там оказывается всё подряд
Исключения и stacktrace throw new ...("password=" + rawPassword) Ошибка уходит в логи/мониторинг/тикеты вместе с секретом
Отладка и дебаггер Поставили breakpoint, сфоткали экран, скинули скрин Очень человеческий сценарий. И поэтому особенно частый
Трассировка/observability Автоматически логируются тела запросов Если логируется request body, пароль уехал в мониторинг

Самый простой, но показательный пример — отладочный вывод:

String rawPassword = "qwerty123"; // raw пароль: существует только "на входе", не для хранения и не для логов

// Плохо: даже "временный" вывод делает секрет частью логов (и часто — надолго).
System.out.println("password=" + rawPassword);

Смешно, что этот код часто пишут «на две минуты». Но логи обычно живут гораздо дольше двух минут. Они попадают в CI, в контейнерные логи, в ELK/Graylog/Splunk, в архивы, и дальше никто уже не вспомнит, что это было «временно».

Отдельная боль — автоматическое логирование объектов. В Java это выглядит особенно невинно, потому что toString() «сам всё красиво покажет». И вот тут легко выстрелить себе в ногу рекордом:

// DTO с паролем опасен сам по себе: его нельзя логировать целиком.
public record LoginRequest(String username, String password) {}

class Demo {
    public static void main(String[] args) {
        LoginRequest req = new LoginRequest("anna", "qwerty123");

        // Плохо: record по умолчанию печатает ВСЕ поля в toString(), включая пароль.
        // То же самое случится, если сделать log.info("request={}", req).
        System.out.println(req); // LoginRequest[username=anna, password=qwerty123]
    }
}

Комментарий в коде — это не шутка: record действительно печатает все поля. Значит, если вы где-то делаете log.info("request={}", req), то пароль уехал в лог. И это может произойти даже без вашей явной злой воли: иногда «умные» логгеры или фильтры логируют request body целиком.

И ещё один нюанс для тех, кто любит говорить «ну это же просто строка». В Java String иммутабелен. Если вы положили пароль в String, вы уже не можете «обнулить его в памяти». Он будет жить до тех пор, пока его не соберёт GC, и может попасть в дампы. Мы не будем сегодня уходить в глубокий трек управления памятью секретов, но полезно знать: хранить пароль как строку «где-то надолго» — плохая идея даже на уровне JVM-реальности.

4. Идеальные URL-правила не спасают при утечке пароля

После Дня 5 у вас может появиться очень приятное чувство контроля: мы написали SecurityFilterChain, у нас есть permitAll() для public-зоны, authenticated() для личной, hasRole() для админки, всё красиво. И это действительно важно. Но сегодня мы должны увидеть неприятную правду: защита URL-правилами отвечает на вопрос «можно ли этому пользователю сюда», а хранение пароля отвечает на вопрос «а кто вообще стал этим пользователем».

Если пароль утёк, злоумышленник не будет «обходить» ваши правила доступа. Он просто пройдёт authentication как пользователь, чей пароль он знает. И тогда все ваши механизмы будут работать против вас: SecurityContext заполнится, Authentication будет выглядеть легитимным, логи покажут «обычный запрос пользователя», а не атаку. То есть утечка пароля превращает злоумышленника в «нормального пользователя» с точки зрения системы.

На этом месте особенно важно вспомнить из прошлых дней, что Spring Security не «проверяет доступ в контроллере». Он принимает решения в фильтрах и на основе Authentication. Если credentials скомпрометированы, то система честно и дисциплинированно будет обслуживать злоумышленника. Не потому что Spring Security плохой, а потому что он не умеет читать мысли и отличать «настоящего» пользователя от «того, кто узнал пароль».

Есть и второй эффект, менее очевидный, но очень практичный: если пароль хранится открыто, то его может увидеть кто угодно внутри команды. Любой сотрудник с доступом к базе, логам или репозиторию фактически получает возможность «быть пользователем». Это ломает не только безопасность, но и банальную ответственность. Нельзя расследовать инциденты, нельзя доверять аудит-логам, нельзя даже честно сказать «это сделал пользователь», потому что это мог сделать кто угодно, кто увидел пароль.

Так что хорошая новость: SecurityFilterChain нам всё ещё нужен. Плохая новость: без правильной модели хранения пароля он остаётся «забором вокруг дома, ключ от которого лежит на улице».

5. Плохая модель: сохранили строку и сравнили equals()

Сейчас мы сознательно «сломаем» один привычный рефлекс программирования: «если нужно проверить, совпадает ли значение, давайте сравним». Для многих задач это нормально: сравнить email, сравнить slug, сравнить статус. Но пароль — не такая сущность. Как только вы пишете inputPassword.equals(storedPassword), вы автоматически признаёте, что storedPassword — это тот же пароль, который вводит пользователь. А значит, он хранится открыто.

Вот пример, который выглядит как учебный, но на самом деле демонстрирует именно неправильную архитектуру:

String inputPassword = "qwerty123";  // raw пароль, который ввёл пользователь
String storedPassword = "qwerty123"; // плохо: это значит, что в хранилище лежит raw пароль

// Плохо: "equals" подразумевает сравнение двух одинаковых секретов.
// В правильной модели вы сравниваете raw пароль с ХРАНИМЫМ значением через PasswordEncoder.matches(...).
boolean authenticated = inputPassword.equals(storedPassword);

System.out.println(authenticated); // true

Да, это работает. И именно в этом ловушка. Такой код «работает» только потому, что вы уже проиграли: вы уже решили хранить пароль в сыром виде.

Правильная модель выглядит иначе: вы храните не пароль, а некоторую подготовленную строку (мы позже назовём её passwordHash или encodedPassword), и проверка — это не сравнение двух одинаковых строк, а специальная процедура сопоставления «введённого пароля» с «сохранённым значением». В Spring Security для этого существует PasswordEncoder.matches(...), а не «ручной equals». Сегодня мы пока не уходим в детали PasswordEncoder, но важно, чтобы мозг перестроился уже сейчас: пароль — не «сравниваемое значение», а проверяемый секрет.

И вот здесь возникает любопытный момент, который полезно понять заранее, чтобы завтра не удивляться. В нормальной системе один и тот же пароль, введённый двумя разными пользователями, не должен превращаться в одинаковую «сохранённую строку». Если вы ожидаете, что «одинаковый ввод → одинаковое хранение», вы всё ещё мыслите паролем как обычным полем. А пароль — не обычное поле.

6. Модель хранения пароля без криптографии

Нам очень хочется сразу прыгнуть в названия алгоритмов, но методически лучше сделать паузу и зафиксировать архитектурный принцип простыми словами. Идея такая: backend не должен хранить «то, что ввёл пользователь». Он должен хранить значение, которое позволяет проверить пароль, но не позволяет его «восстановить обратно». Система должна уметь ответить «совпало/не совпало», но не должна иметь кнопку «покажи пароль».

С точки зрения жизненного цикла это выглядит так. При создании пользователя (или при смене пароля) raw пароль приходит в приложение на входе, дальше максимально быстро превращается в «значение для хранения», и raw версия перестаёт быть частью дальнейшего процесса. При логине raw пароль снова приходит на входе, но вместо сравнения строк система выполняет проверку через специальный механизм, используя сохранённое значение.

Эту мысль удобно держать в голове как маленькую блок-схему:

flowchart TD
    %% Важно: на схеме нет шага "достать пароль обратно" — система должна уметь проверять, но не восстанавливать.
    A["Пользователь ввёл пароль (raw)"] --> B["Приложение подготовило значение для хранения"]
    B --> C["Храним только подготовленное значение (не raw)"]

    D["Пользователь ввёл пароль при логине (raw)"] --> E["Проверяем raw против сохранённого значения"]
    E --> F["Совпало? да/нет"]

Обратите внимание: на схеме нигде нет шага «достать пароль обратно». И это нормально. Если вы в какой-то момент ловите себя на желании «расшифровать пароль и посмотреть», значит вы мысленно едете не туда.

Как это связано с Spring Security на прикладном уровне? В обычной username/password модели UserDetailsService отдаёт UserDetails со сохранённым значением пароля (не raw), а DaoAuthenticationProvider использует PasswordEncoder для проверки. То есть Spring Security из коробки ожидает, что вы храните не «пароль пользователя», а «то, с чем можно проверить его ввод».

Пока достаточно принять сам принцип: система хранит не пароль, а значение для проверки. Дальше из этого сразу вырастают два инженерных вопроса: почему для пароля нужна необратимая, а не обратимая модель, и как эта проверка собирается через PasswordEncoder.

7. Правила для Secure Content Platform API

С этого места мы начинаем превращать принцип в «инженерную привычку». Даже в самом простом user store эти правила уже должны работать: несколько решений можно зафиксировать прямо сейчас и не наступать на самые частые грабли.

Первое правило очень простое: если вы где-то создаёте модель аккаунта, поле должно называться не password, а хотя бы passwordHash или encodedPassword. Название — это не косметика. Оно прямо влияет на то, как вы будете думать об этом значении. password провоцирует на «давайте выведем, давайте сравним». passwordHash сразу напоминает: это не оригинал.

Второе правило: логировать можно событие и контекст, но не секрет. Например, «неудачная попытка входа username=anna» — это нормальный лог. «неудачная попытка входа username=anna password=qwerty123» — это ваш будущий пост на тему «как мы случайно устроили утечку».

Вот пример аккуратного логирования без секретов:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

class LoginAuditLog {
    private static final Logger log = LoggerFactory.getLogger(LoginAuditLog.class);

    void loginFailed(String username) {
        // Ок: логируем только идентификатор пользователя (контекст), а не секрет (пароль).
        log.warn("Login failed for username={}", username);

        // Важно: сюда никогда не добавляем raw пароль, даже "на минутку для отладки".
    }
}

Третье правило связано с DTO и toString(). Если у вас появляется объект, который несёт пароль (например, request DTO), то нельзя логировать его целиком. Даже если вы «просто посмотрели в дебаге», очень легко потом случайно сделать log.info("{}", request) и получить пароль в логах. Поэтому безопасная привычка: логируйте отдельные поля, которые не являются секретами, чаще всего это username/email и, возможно, IP/UA (но и там нужно знать меру).

И, наконец, четвёртое правило — про «учебные удобства». Spring Boot может сгенерировать пароль и вывести его в консоль на старте. Это полезно для первого запуска и наблюдения secure-by-default. Но это именно учебный или диагностический механизм. Его нельзя превращать в привычку «пароли в логах — это нормально». Это нормально только как временный мост, чтобы вы вообще смогли войти и увидеть поведение системы.

8. Типичные ошибки при обращении с паролями

Эта тема кажется слишком очевидной ровно до первого раза, когда вы находите пароль в логах. А потом начинается классическая стадия принятия: отрицание, гнев, торг, депрессия, поиск того, кто «это закоммитил», и наконец осознание, что «это я пять минут назад лог добавил». Поэтому лучше заранее знать самые частые ошибки.

Ошибка №1: хранить пароль как поле password и думать, что потом “поменяем на хеш”.
На практике “потом” часто не наступает. Код с raw паролем успевает обрасти логированием, тестами, seed-данными, документацией, и превращается в привычку. Гораздо правильнее сразу назвать поле passwordHash и принять правило: raw пароль существует только на входе и не сохраняется.

Ошибка №2: логировать пароль “буквально на минутку, чтобы понять, почему не работает”.
Логи — это не временный блокнот. Они часто улетают в централизованное хранилище, попадают в CI, хранятся месяцами и доступны большему кругу людей, чем исходный код. Даже одна строка password=... в логах может быть достаточной для компрометации аккаунта, особенно если пароль переиспользуется.

Ошибка №3: логировать DTO целиком и не замечать, что toString() печатает секреты.
Records и многие автогенераторы toString() выводят все поля. Если DTO содержит пароль, то log.info("{}", dto) печатает пароль. Опасность в том, что это выглядит «культурно» и «красиво», поэтому легко не заметить. Привычка должна быть обратной: логируем только безопасные поля, чаще всего username.

Ошибка №4: считать, что “у нас внутренний сервис / учебный проект”, поэтому можно хранить raw пароли.
Учебные проекты часто живут дольше и путешествуют больше, чем прод. Они разлетаются по ноутбукам, чатам, репозиториям, демонстрациям, и очень легко оказываются в чужих руках. Плюс пароль — это всегда риск для пользователя из-за переиспользования. Внутренность проекта не отменяет базовую гигиену.

Ошибка №5: надеяться, что Spring Security “сам всё защитит”, даже если пароль лежит открыто.
Spring Security защищает доступ на уровне запросов и формирует SecurityContext на основе Authentication. Но если злоумышленник получил пароль, он становится «нормальным» пользователем с точки зрения системы. Поэтому хранение пароля — это фундамент, на котором только и имеет смысл строить остальные механики.

1
Задача
Spring Security, 6 уровень, 0 лекция
Недоступна
Безопасное сообщение о неудачном входе
Безопасное сообщение о неудачном входе
1
Задача
Spring Security, 6 уровень, 0 лекция
Недоступна
Модель аккаунта без raw password в состоянии объекта
Модель аккаунта без raw password в состоянии объекта
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ