1. Роль «опасных» shortcut’ов
Если вы когда-нибудь открывали документацию или чужие примеры по Spring Security, то наверняка видели куски кода, которые выглядят как магия на минималках: один вызов — и у вас уже есть пользователь, пароль, роли, и всё как будто работает. В этот момент мозг новичка радостно делает вывод: «Ага, значит, безопасность — это просто пара строк». Так вот, цель этой секции — вернуть мозг на землю, но мягко, без травм.
Проблема в том, что после PasswordEncoder, matches(...) и формата {id} появляется соблазн взять shortcut “для примера” и незаметно вернуть пароль в мир обычных строк. Поэтому дальше смотрим не на то, как сделать короче любой ценой, а на то, какие упрощения безопасны, а какие ломают саму модель.
Spring Security даёт shortcut’ы по очень практической причине: иногда нужно быстро запустить пример и показать механику фильтров, SecurityContext и authorizeHttpRequests(...), не тратя полдня на «как правильно хранить пользователей». Учебный shortcut — это как одноразовая пластиковая ложка: ей можно поесть на пикнике, но если вы начинаете строить с её помощью кухню, получается… странно. Проблема начинается тогда, когда shortcut превращается в ментальную модель, то есть в «так и должно быть в нормальном приложении».
Чтобы было проще дальше сравнивать, держим в голове простую таблицу (и да, таблица — это не bullet list, это культурный способ не потеряться):
| Вопрос | Учебный shortcut | Нормальная инженерная модель |
|---|---|---|
| Где живёт пароль? | В коде строкой «для удобства» | Пароль вводится пользователем, хранится только в encoded form |
| Кто решает, как его кодировать? | «Как-то само» внутри удобного билдера | Явный PasswordEncoder baseline в конфигурации |
| Что хранится в UserDetails? | «Как получилось» | Предсказуемая строка (обычно с {id}), которую умеет проверить encoder |
| Что мы тренируем? | Быстро увидеть работу security | Построить устойчивую привычку и понятную архитектуру |
2. User.withDefaultPasswordEncoder(): что делает
На первый взгляд User.withDefaultPasswordEncoder() выглядит как добрый помощник. Он обещает: «Ты дай мне username, пароль и роли, а я сам всё заэнкодю». И действительно, если вы только начинаете, это позволяет очень быстро собрать UserDetails и не тонуть в выборе алгоритмов. Но именно из-за этой «доброты» метод и опасен: он скрывает важные решения и создаёт иллюзию, что пароль можно спокойно держать в коде и вообще “потом разберёмся”.
Посмотрим на минимальный пример:
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
public class ShortcutDemo {
public static void main(String[] args) {
// Учебный shortcut: encoder выбирается "где-то внутри" билдера
UserDetails user = User.withDefaultPasswordEncoder()
.username("anna")
// Raw-пароль прямо в коде: это и есть главная опасная привычка
.password("password")
// Роли задаются как строки: это удобно для демо, но не модель для продакшена
.roles("USER")
.build();
System.out.println(user.getUsername()); // anna
// Здесь будет уже encoded-строка (обычно с префиксом {bcrypt}), а не raw-пароль
System.out.println(user.getPassword()); // {bcrypt}$2a$10$...
}
}
Обратите внимание на две вещи. Во-первых, raw‑пароль "password" прямо в исходнике. Во-вторых, user.getPassword() возвращает не "password", а строку с префиксом {bcrypt} и bcrypt‑хэшем (он будет каждый раз разный из‑за соли, и это нормально). То есть withDefaultPasswordEncoder() делает примерно такую идею: берёт какой‑то «дефолтный» encoder и кодирует пароль при сборке пользователя.
Ключевая тонкость здесь в том, что этот shortcut не обязан совпадать с тем encoder’ом, который вы выбрали в приложении как baseline. И даже если сейчас «совпало», через неделю вы можете поменять PasswordEncoder bean, а кусок с withDefaultPasswordEncoder() останется прежним и начнёт вести себя не так, как вы ожидаете.
3. Привычка withDefaultPasswordEncoder(): риски
Очень хочется сказать: «Да ладно, он же делает bcrypt, значит безопасно». Но проблема не в bcrypt, а в привычках, которые закрепляются в голове и в кодовой базе. Эта секция не про то, что метод “плохой и запретный”, а про то, что он учит вас неправильной архитектуре.
Во-первых, raw‑пароль в исходниках — это не «просто строка», это секрет, который попадёт в git‑историю, в code review, в скриншоты, в копипасты в чат, в ваш мозг (а это уже вообще необратимо). Даже если репозиторий приватный, утечки бывают не только через “хакеров”, но и через банальные ошибки: кто-то запушил в публичный репо, кто-то выложил кусок кода в StackOverflow, кто-то записал видео, где пароль видно в IDE. И да, в учебном проекте это особенно легко сделать случайно.
Во-вторых, withDefaultPasswordEncoder() закрепляет идею «пароль — это значение конфигурации». В реальном приложении пароль — это то, что пользователь вводит на границе (login/registration), и вы кодируете его в нормальном сервисном потоке, а не в @Configuration классе на старте приложения.
В-третьих, shortcut может конфликтовать с тем, что вы настроили как baseline. Например, вы решили (осознанно или случайно) использовать BCryptPasswordEncoder как единственный PasswordEncoder bean, а stored‑пароль у вас внезапно имеет префикс {bcrypt}. Для DelegatingPasswordEncoder это нормально, а для «чистого» BCryptPasswordEncoder — нет.
Давайте покажем ситуацию, которую новичок встречает чаще, чем хотелось бы: “почему пароль не матчится, хотя я же bcrypt использую”.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
class PasswordEncoderConfig {
@Bean
PasswordEncoder passwordEncoder() {
// Явно выбираем конкретный encoder (важно: это НЕ DelegatingPasswordEncoder)
return new BCryptPasswordEncoder();
}
}
И где-то рядом в примере (или в вашем коде после копипаста):
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
class DemoUserFactory {
static UserDetails demoUser() {
// Здесь пароль будет сохранён в формате с {id}-префиксом (обычно {bcrypt})
return User.withDefaultPasswordEncoder()
.username("anna")
// Raw-пароль, который билдер сам закодирует "внутри"
.password("password")
.roles("USER")
.build();
}
}
На первый взгляд “всё про bcrypt”, но фактически вы смешали две модели. В первом куске encoder ожидает bcrypt‑строку без {id}-префикса, во втором вы получили строку вида {bcrypt}$2a$10$.... Результат — странные ошибки аутентификации, которые начинаются не с «неверный пароль», а с “что-то не так с encoder’ом”. И новичок обычно лечит это самым коротким способом: {noop}. Вот как раз поэтому мы и разбираем {noop} отдельно.
4. {noop}: пароль без кодирования
Когда вы впервые видите {noop}, очень легко подумать: «О, это какой-то простой режим кодирования. Типа “no operation”, ну ладно». И дальше рука сама добавляет {noop} в пароль, чтобы “заработало”. Это тот момент, где Spring Security пытается быть честным, а человек пытается быть быстрым. Давайте выберем честность: она обычно дешевле, чем «быстро».
DelegatingPasswordEncoder устроен так, что он смотрит на строку пароля, вытаскивает из неё {id} и решает, каким encoder’ом проверять. Примерно так:
flowchart TD
A["stored password string"] --> B{"Есть префикс {id}?"}
B -- "да" --> C["выбрать encoder по id"]
B -- "нет" --> D["id = null → ошибка / нет маппинга"]
C --> E["encoder.matches(raw, stored)"]
{noop} — это один из таких id. Он означает: «пароль хранится как есть, без хеширования». То есть строка {noop}password буквально содержит пароль password в открытом виде, просто с припиской “я ничего не кодировал”.
Это легко увидеть в лоб:
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
public class NoopDemo {
public static void main(String[] args) {
// Делегирующий encoder выбирает алгоритм по префиксу вида {id}
PasswordEncoder encoder =
PasswordEncoderFactories.createDelegatingPasswordEncoder();
// {noop} означает: "храню пароль как plain text, просто сравни строкой"
boolean ok = encoder.matches("password", "{noop}password");
System.out.println(ok); // true
}
}
С технической точки зрения всё работает, потому что NoOp‑encoder делает по сути raw.equals(stored). Но с точки зрения безопасности — это прямое нарушение того принципа, который мы закрепили в первой лекции дня: пароль не должен жить в долговременном состоянии в сыром виде.
Иногда {noop} встречается в легаси‑системах, когда старые пароли ещё не мигрировали на нормальный формат. В таком случае {noop} может быть временной частью стратегии миграции. Но ключевое слово здесь — временной. В учебном проекте {noop} чаще всего появляется не из-за “легаси”, а из-за желания “чтобы просто заработало”. И вот это желание мы сейчас аккуратно ломаем.
5. Ошибка id "null" в PasswordEncoder
Есть отдельный вид боли, который объединяет тысячи студентов по всему миру. Вы создаёте пользователя, запускаете приложение, делаете запрос — и получаете сообщение примерно такого характера: “There is no PasswordEncoder mapped for the id ‘null’”. Человек гуглит, находит ответ “добавь {noop}”, добавляет — и всё начинает работать. А потом через месяц этот же человек строит приложение и искренне не понимает, почему “вроде всё правильно, но как-то небезопасно”.
Давайте разберём, откуда берётся эта ошибка, простыми словами. Она возникает тогда, когда у вас в приложении используется DelegatingPasswordEncoder (например, вы сделали baseline через PasswordEncoderFactories.createDelegatingPasswordEncoder()), но stored‑пароль не содержит префикса {id}.
То есть вы где-то сделали примерно так:
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
class BadUserFactory {
static UserDetails user() {
return User.withUsername("anna")
// В stored value нет {id}-префикса, и это не хэш: DelegatingPasswordEncoder не поймёт формат
.password("password")
.roles("USER")
.build();
}
}
На уровне Java всё ок: строка же строка. Но на уровне DelegatingPasswordEncoder это “строка неизвестного формата”. Он смотрит: {id} нет → значит id = null → значит он не знает, каким encoder’ом проверять → и честно падает.
И вот здесь важно не сделать неправильный вывод. Проблема не в том, что Spring “слишком строгий”. Проблема в том, что мы попытались хранить пароль в формате, который не соответствует выбранному encoder baseline.
Правильные варианты починки зависят от того, какую модель вы выбрали:
Если вы выбрали DelegatingPasswordEncoder, то stored‑строка должна быть вида {bcrypt}... (или {pbkdf2}..., {argon2}..., и так далее). То есть вы должны либо вызвать encoder.encode(...), либо заранее подготовить хэш и сохранить его целиком.
Если вы выбрали конкретный BCryptPasswordEncoder, то stored‑строка должна быть “голым bcrypt‑хэшем” без {bcrypt}. Тогда и код, который генерирует этот хэш, должен быть согласован.
А вот “добавить {noop}” — это не решение, это выключатель безопасности. Он не лечит формат, он говорит системе: «проверяй пароль как plain text». Да, приложение “заработает”. Но вы только что договорились с собой, что пароль — обычная строка. А это ровно то, что мы сегодня не хотим закреплять.
6. Быстро, но честно: безопасные альтернативы
В этой секции мы попробуем удержать баланс: мы всё ещё в учебном проекте, нам хочется простоты и воспроизводимости, но мы не хотим учиться плохому. Поэтому идея такая: мы можем использовать небольшие “учебные упрощения”, но такие, которые не ломают базовую модель “пароль хранится только в encoded form”.
Один из самых удобных способов — иметь маленькую утилиту, которая один раз генерирует вам encoded‑строку, а дальше вы уже используете именно её. Важно: даже если вы дальше храните эту строку в коде или в application-local.yml, вы храните не raw password, а его encoded form.
Пример простого генератора (в учебном проекте это можно держать в отдельном пакете вроде security.dev):
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
public class PasswordHashGenerator {
public static void main(String[] args) {
// Базовая фабрика, которая создаёт DelegatingPasswordEncoder с набором алгоритмов
PasswordEncoder encoder =
PasswordEncoderFactories.createDelegatingPasswordEncoder();
// Генерируем stored value (обычно это будет строка с {bcrypt}...)
String encoded = encoder.encode("password");
System.out.println(encoded); // {bcrypt}$2a$10$...
}
}
Смысл этого класса не в том, чтобы он жил в продакшене. Смысл в том, чтобы вы перестали писать raw‑пароль в конфигурации пользователей. Вы запускаете этот main(), копируете результат и используете его как stored value. Да, это “ручной шаг”. Но он очень хорошо тренирует мысль: “пароль хранится не в исходном виде”.
Дальше пользователя можно собрать уже “честным” способом: builder без shortcut‑кодирования, потому что пароль уже encoded.
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
class DevUsers {
// Важно: это пример encoded-строки (в реальном проекте вы подставляете сгенерированное значение целиком)
static final String ANNA_PASSWORD =
"{bcrypt}$2a$10$k2Q0uQhS7m3uJ..."; // пример
static UserDetails anna() {
return User.withUsername("anna")
// Здесь мы уже храним stored value в правильном формате, без "магии" внутри билдера
.password(ANNA_PASSWORD)
.roles("USER")
.build();
}
}
Обратите внимание: тут уже нет withDefaultPasswordEncoder(). Это принципиально важно как педагогика. Мы больше не делаем вид, что система сама “как-то заэнкодит”. Мы говорим: “пароль уже в encoded form, я его таким и храню”.
Если вам неудобно держать хэш в коде (и да, в реальном мире это обычно не хочется делать), можно вынести его в конфигурацию. Это не идеальная безопасность (хэш тоже чувствительная штука), но как учебный шаг это лучше, чем raw‑пароль.
import org.springframework.beans.factory.annotation.Value;
public class DevUserPasswords {
private final String annaPassword;
public DevUserPasswords(@Value("${app.dev-users.anna-password}") String annaPassword) {
// Получаем encoded-строку из конфигурации (это не "секрет-менеджмент", но дисциплина лучше, чем raw в коде)
this.annaPassword = annaPassword;
}
public String annaPassword() {
return annaPassword;
}
}
После такой разовой генерации в конфигурации пользователя уже живёт не секрет, а stored value.
7. Dev‑пользователь в проекте
Давайте аккуратно привяжем тему к нашему сквозному проекту Secure Content Platform API. Здесь нам не нужен второй раз полный разбор user store. Нам нужен один факт: даже временный dev‑пользователь не должен собираться на raw password или {noop}.
После разовой генерации encoded‑строки dev‑пользователь выглядит примерно так:
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
class DevUsers {
static UserDetails anna() {
return User.withUsername("anna")
// Важно: здесь лежит уже stored value, а не raw-пароль
.password("{bcrypt}$2a$10$k2Q0uQhS7m3uJ...") // пример
.roles("USER")
.build();
}
}
Этого сниппета достаточно, чтобы увидеть правило. Конкретный user store здесь не важен: пользователь может жить в памяти, в БД или в любом другом хранилище, но пароль всё равно остаётся encoded value, а не обычной строкой. Если в dev‑конфигурации появляется raw password “для удобства”, shortcut уже превратился из демо в дыру.
Если сравнить этот вариант с withDefaultPasswordEncoder() и {noop}, разница в коде кажется небольшой. Но разница в голове огромная: stored password — это отдельный артефакт, который проверяется через PasswordEncoder, а raw password не становится постоянной частью конфигурации приложения.
8. Типичные ошибки при работе с паролями
В этой теме ошибки почти всегда выглядят одинаково: человек видит, что “что-то не работает”, гуглит, находит “быстрое решение”, и внезапно оно действительно работает. На этом месте мозг делает вывод “значит, так правильно”. Поэтому полезно заранее проговорить самые частые ловушки, чтобы в момент отладки вы не выбирали самое короткое, а выбирали самое здоровое.
Ошибка №1: воспринимать User.withDefaultPasswordEncoder() как “нормальный способ создавать пользователей”.
У метода есть честное назначение — короткие примеры и демо. Но если вы начинаете строить на нём проект, вы закрепляете идею, что пароль — это строка в коде, а кодирование — это “где-то там внутри билдера”. В реальном приложении пароль кодируется в явном месте, через явный PasswordEncoder, и хранится в encoded form. Если вы хотите компактности — лучше один раз сгенерировать хэш и использовать его как stored value.
Ошибка №2: лечить любой конфликт с PasswordEncoder добавлением {noop}.
{noop} — это не “ремонт”, это отключение механизма. Он нужен либо для демонстрации, либо как временная совместимость с легаси‑паролями, но в учебном проекте он чаще всего появляется из-за лени. Если вы однажды привыкли “чинить” ошибки {noop}‑ом, вы почти гарантированно унесёте эту привычку в реальную работу.
Ошибка №3: смешивать DelegatingPasswordEncoder и “чистый” BCryptPasswordEncoder, не понимая формата строки.
Очень распространённая ситуация: где-то у вас stored value вида {bcrypt}..., а где-то encoder ожидает строку без префикса; или наоборот, stored value без {id}, а encoder — делегирующий. На глаз это “всё про bcrypt”, но для кода это разные форматы. Лечится это не “магией”, а дисциплиной: один baseline encoder на приложение и соответствующий ему формат stored value.
Ошибка №4: думать, что если stored value выглядит как “набор символов”, то его можно спокойно логировать.
Да, bcrypt‑хэш не равен raw‑паролю. Но это всё ещё чувствительная информация: утечка хэшей — это не “ну и ладно”, это материал для оффлайн‑подбора (особенно если у пользователей слабые пароли). Поэтому печатать хэши в логи “просто посмотреть” — плохая привычка. Для отладки лучше проверять поведение через matches(...), а не устраивать себе “выставку хэшей” в консоли.
Ошибка №5: строить mental model “пароль можно сравнивать строками, просто иногда надо добавить префикс”.
Если вы поймали себя на мысли “ну если не матчится — добавлю {noop} или {bcrypt} и поедем”, остановитесь. Правильная модель такая: пароль всегда проверяется через PasswordEncoder.matches(raw, stored), а stored value — это формат, согласованный с вашим encoder baseline. Префиксы не “украшают строку”, они фиксируют формат и правила проверки.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ