1. Назначение маппинга UserAccount → UserDetails
После поиска аккаунта в БД остаётся самый важный кусок всей DB-backed схемы — toUserDetails(account). Метод маленький, но именно здесь мы решаем, какие данные увидит DaoAuthenticationProvider: логин, hash пароля, флаги состояния аккаунта и authorities. Если этот шаг собран криво, дальше аутентификация может пройти, а вот права и состояния начнут вести себя как лотерея.
Если честно, маппинг выглядит как бытовуха: взяли поля из одного объекта, переложили в другой. Но в security это не «переложили», а «собрали пропуск в здание», по которому охрана (Spring Security) будет пускать или разворачивать пользователя. И если пропуск собран криво — охрана не виновата, она просто выполняет инструкции.
Spring Security специально не знает ничего о ваших JPA-энтити, таблицах, репозиториях и «как мы там всё красиво смоделировали». Он знает только контракт: ему нужен объект UserDetails, который будет содержать логин, пароль (точнее, его hash), набор прав (authorities) и флаги состояния аккаунта. Именно поэтому UserDetailsService используется DaoAuthenticationProvider для получения имени, пароля и дополнительных атрибутов пользователя при username/password-аутентификации.
Если попытаться «обмануть систему» и вернуть из UserDetailsService что-то случайное, например вашу JPA-сущность целиком, вы получите смесь проблем: от лишних данных в памяти до неожиданных lazy-loading сюрпризов. Поэтому мы делаем простой, скучный и надёжный мост: UserAccount (домен/БД) → UserDetails (security-модель).
Небольшая схема, чтобы держать в голове путь данных:
flowchart TD
DB[(PostgreSQL)]
Repo[UserAccountRepository]
UDS[CustomUserDetailsService]
UD[UserDetails]
DAP[DaoAuthenticationProvider]
DB --> Repo --> UDS --> UD --> DAP
2. UserDetails: паспорт, UserAccount: учётка
Очень частая ошибка новичка — воспринимать UserDetails как «ещё одну модель пользователя». И тогда рука тянется положить туда всё подряд: displayName, bio, avatarPath, любимый цвет кнопки «Сохранить», а на сдачу — всю историю публикаций. Но по смыслу UserDetails — это минимальный security-слепок, который нужен Spring Security, чтобы принять решение о входе и о правах.
Если посмотреть на контракт UserDetails, он довольно честно говорит, что ему нужно: getUsername(), getPassword(), getAuthorities(), а также флаги isEnabled(), isAccountNonLocked(), isAccountNonExpired(), isCredentialsNonExpired(). Это всё — не «для красоты». Это те данные, которые участвуют в принятии решения, можно ли аутентифицировать пользователя и какие authorities ему выдать.
Полезно держать сравнение в виде таблицы. Она не про «как правильно во всём мире», а про здравый смысл именно в нашем учебном проекте:
| Что это | UserAccount (БД / домен) | UserDetails (security view) | Зачем |
|---|---|---|---|
| Логин | username или |
getUsername() | Идентифицировать пользователя в auth flow |
| Пароль | passwordHash | getPassword() | Провайдер сравнит введённый пароль через PasswordEncoder |
| Права | roles (и позже, возможно, permissions) | getAuthorities() | Дать Spring Security данные для / |
| Состояние | enabled, |
isEnabled(), |
Запретить вход заблокированному/disabled аккаунту |
| Профиль | displayName, , |
не нужен | Профиль не участвует в проверке пароля и выдаче authorities |
Главная идея: UserAccount — это «как хранится пользователь», а UserDetails — «что нужно security-слою прямо сейчас». Когда вы держите это разделение, код становится не только безопаснее, но и проще сопровождать.
3. Логин и пароль: username и passwordHash
На этом этапе хочется сказать «ну это же очевидно», но статистика багов говорит обратное: именно в этих двух полях люди умудряются сделать самые громкие ошибки. Тут важно понимать: UserDetailsService возвращает данные, которые уже лежат в системе, а не то, что ввёл пользователь в форме логина. То, что ввёл пользователь, попадёт в отдельный объект Authentication как credentials, а потом DaoAuthenticationProvider сравнит это с тем, что вернул UserDetailsService.
То есть UserDetails#getPassword() — это hash, который мы уже сохранили в базе раньше. Провайдер сравнит raw password (введённый) с encoded password (из БД) через PasswordEncoder.matches(...). Мы не делаем это руками, иначе потеряем смысл DaoAuthenticationProvider.
Мини-реализация маппинга «логин + hash пароля» обычно выглядит так:
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
private UserDetails toUserDetails(UserAccount account) {
// В security-контракте username — это идентификатор входа (что именно: логин/почта — решает проект)
return User.withUsername(account.getUsername())
// ВАЖНО: сюда кладём не сырой пароль, а hash из базы (encoded password)
.password(account.getPasswordHash())
// Authorities прикрепляются к пользователю на этапе загрузки UserDetails
.authorities(roleNames(account.getRoles()))
.build();
}
Здесь важна даже не сама форма кода, а смысл. Мы выбираем account.getUsername() как то, что будет «именем пользователя» в security-контексте, и кладём passwordHash как пароль. Да, слово password в builder’е выглядит так, будто мы кладём туда сырой пароль, но на деле это “password used to authenticate” — в нашем случае именно hash.
И ещё один тонкий момент: имя метода loadUserByUsername вас провоцирует думать, что вход всегда по username. На самом деле это просто историческое название в API. В эту строку может попасть и email, и логин, и «что решили считать идентификатором входа». Просто внутри проекта это должно быть консистентно. Мы не выбираем между username и email прямо здесь (это тема отдельного разговора), но маппинг обязан быть согласован с тем, по чему вы ищете в репозитории.
4. Состояния аккаунта: enabled и accountNonLocked
Когда начинаешь работать с реальными пользователями в БД, быстро выясняется, что аккаунт может существовать, но при этом не иметь права входить. Например, его отключили админом, или он заблокирован. Это не «бизнес-логика где-нибудь потом», а прямое требование к аутентификации: таких пользователей нельзя аутентифицировать, даже если пароль верный.
UserDetails прямо содержит методы isEnabled() и isAccountNonLocked(), которые участвуют в решении, можно ли аутентифицировать пользователя. А класс User (тот самый org.springframework.security.core.userdetails.User) поддерживает эти флаги в своём конструкторе и модели.
И вот здесь начинается классическая ловушка: в builder’е User вы часто встретите методы в отрицательной форме:
- disabled(true/false) — не enabled(...)
- accountLocked(true/false) — не accountNonLocked(...)
Поэтому в коде появляются честные «двойные отрицания», которые сначала выглядят как таракан в супе, а потом становятся нормой жизни:
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
private UserDetails toUserDetails(UserAccount account) {
return User.withUsername(account.getUsername())
.password(account.getPasswordHash())
// В базе enabled=true означает «можно входить», а в builder — disabled=false
.disabled(!account.isEnabled())
// В базе accountNonLocked=true означает «не заблокирован», а в builder — accountLocked=false
.accountLocked(!account.isAccountNonLocked())
// Права (authorities) должны быть готовы к использованию в hasRole/hasAuthority
.authorities(roleNames(account.getRoles()))
.build();
}
Здесь мы делаем две вещи, которые очень легко перепутать. Во-первых, disabled(!enabled): если аккаунт в БД выключен, то disabled(true). Во-вторых, accountLocked(!accountNonLocked): если в БД accountNonLocked=false, то в builder’е мы выставляем accountLocked(true).
В User.UserBuilder эти методы существуют и именно так и называются (disabled, accountLocked), так что это не «кастомная магия» — это стандартная модель Spring Security.
Про остальные два флага (accountNonExpired, credentialsNonExpired) мы сейчас не усложняем: в учебном проекте на этом этапе их можно оставить дефолтными (то есть «не истекло»). Главное — корректно протащить enabled и locked, потому что это реально используемые состояния в нашем домене.
5. Роли: authorities и ROLE_
В этот момент многие думают: «Ну роли же у нас в БД есть. Значит, всё». Но Spring Security принимает решения об авторизации на основе GrantedAuthority. И роли — это просто один из вариантов authorities (обычно с префиксом ROLE_). В документации прямо проговаривается, что hasRole — это shortcut для hasAuthority, который добавляет ROLE_ (или другой настроенный префикс).
Из этого следует неприятная, но полезная мысль: если вы забыли префикс, у вас может быть роль ADMIN в БД, но в runtime у пользователя будет authority "ADMIN", а ваши правила написаны как hasRole("ADMIN"), и вы получите “почему-то 403”. И “почему-то” будет ровно на четыре символа: ROLE_.
Самый простой и предсказуемый подход — хранить роли в коде как enum, а при маппинге делать строки authorities с явным префиксом:
import java.util.Set;
private String[] roleNames(Set<Role> roles) {
return roles.stream()
// Spring Security по умолчанию ожидает роли в виде authorities с префиксом ROLE_
.map(role -> "ROLE_" + role.name())
.toArray(String[]::new);
}
Так мы получим "ROLE_USER", "ROLE_EDITOR", "ROLE_ADMIN" — ровно то, что ожидают многие механизмы Spring Security.
Почему это важно именно на уровне UserDetailsService? Потому что authorities обычно «прикрепляются» к пользователю именно на этапе загрузки UserDetails. И дальше эти authorities попадают в Authentication и используются авторизацией. В Spring Security authorities рассматриваются как высокоуровневые права, и роли — один из стандартных примеров таких прав.
Ещё один практический совет: не размазывайте логику преобразования ролей по проекту. Как только вы начнёте делать "ROLE_" + ... в трёх местах, у вас появится четвёртое место, где вы забудете это сделать. И вы снова получите “почему-то 403”, но уже на более изощрённом уровне.
6. Практика маппинга
Где держать маппинг
Код маппинга — это не та часть проекта, которую хочется читать по утрам с кофе. Но именно эта часть должна быть максимально скучной и предсказуемой. Хороший маппинг — как хороший ремень безопасности: о нём вспоминают только в момент аварии, и тогда уже поздно выяснять, что вы прикрутили его к бардачку.
В нашем случае самый практичный вариант — держать маппинг в отдельном private-методе рядом с loadUserByUsername(). Тогда сам loadUserByUsername() остаётся коротким: нашёл аккаунт, не нашёл — бросил UsernameNotFoundException, нашёл — перевёл в UserDetails. А маппинг — отдельная маленькая функция без побочных эффектов.
Если вы попытаетесь встроить маппинг прямо в репозиторий, у вас «репозиторий внезапно начинает знать о security». Если попытаетесь в конфигурации — у вас security-конфигурация раздуется. Если в контроллере — ну… давайте не будем травмировать контроллеры, они и так много пережили.
И да, это тот редкий случай, когда метод toUserDetails(account) — отличное имя. Оно не претендует на философию, оно честно говорит: «перевожу в security-представление».
Пример: loadUserByUsername и toUserDetails
Теперь сложим пазл: репозиторий возвращает UserAccount, CustomUserDetailsService превращает его в UserDetails, и дальше Spring Security делает свою работу. Главное — оставить этот код максимально линейным, чтобы вы могли читать его в полусонном состоянии и всё равно понимать, что происходит (это реальное требование к security-коду, потому что проблемы часто всплывают в пятницу вечером).
Вот компактный вариант loadUserByUsername(), который уже выглядит как «боевой», а не как заглушка:
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
@Override
public UserDetails loadUserByUsername(String username) {
return repo.findByUsername(username).map(this::toUserDetails)
.orElseThrow(() -> new UsernameNotFoundException(username));
}
Обратите внимание, насколько это “без магии”. Нет ручной проверки пароля. Нет if(hash.equals(raw)). Нет попыток решать авторизацию. Мы просто грузим данные и отдаём их в ожидаемом контракте. Именно такой стиль и делает UserDetailsService хорошим участником связки с DaoAuthenticationProvider.
А вот тот самый toUserDetails(account), который фиксирует всё важное: hash пароля, состояния аккаунта и authorities:
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
private UserDetails toUserDetails(UserAccount account) {
return User.withUsername(account.getUsername())
.password(account.getPasswordHash())
.disabled(!account.isEnabled())
.accountLocked(!account.isAccountNonLocked())
.authorities(roleNames(account.getRoles()))
.build();
}
Если этот метод сделан правильно, то дальше вы получите очень понятное поведение системы. Disabled пользователь не сможет аутентифицироваться, даже с правильным паролем. Locked пользователь тоже. Пользователь без нужной роли залогинится, но получит запрет на доступ там, где требуется другая роль. И это именно то, чего мы добиваемся: предсказуемости.
7. Типичные ошибки при маппинге UserAccount → UserDetails
В этом месте полезно честно признать: ошибки здесь будут. Не потому что вы плохой разработчик, а потому что security любит «маленькие строки, которые решают всё». Хорошая новость в том, что эти ошибки почти всегда повторяются, и вы можете научиться узнавать их по запаху.
Ошибка №1: положить в UserDetails сырой пароль вместо passwordHash.
Это выглядит особенно коварно, если вы тестируете на одном пользователе и у вас “вроде работает”. На самом деле вы ломаете всю модель: DaoAuthenticationProvider ожидает encoded password из user store и сравнивает его через PasswordEncoder. Сырой пароль в базе или в UserDetails — это уже не shortcut, а реальная уязвимость и архитектурная ошибка.
Ошибка №2: перепутать “locked” и “non locked”.
Когда вы видите accountNonLocked в БД, а в builder’е — accountLocked, мозг пытается сделать “как-нибудь”. В итоге вы случайно блокируете всех пользователей или, наоборот, разрешаете вход заблокированным. Правильное правило простое: если в БД accountNonLocked=false, то в builder надо ставить accountLocked(true).
Ошибка №3: забыть, что hasRole ожидает ROLE_-префикс.
Вы храните роли в БД как ADMIN, возвращаете authority "ADMIN", а в правилах используете hasRole("ADMIN"). И потом начинается любимая игра новичка “почему 403, я же админ”. Ответ: вы админ в БД, но не ROLE_ADMIN в Spring Security.
Ошибка №4: вернуть пустой набор authorities и считать это нормой без явного решения.
Технически пользователь может существовать с пустыми ролями, но тогда он почти всегда будет вести себя странно: логинится, но «ничего нельзя». Иногда это допустимая модель (например, роль назначает админ позже), но тогда это должно быть сознательное решение и хорошо диагностируемое состояние. Иначе вы будете тратить время на поиски «где отвалилась авторизация», хотя отвалилась она на уровне данных.
Ошибка №5: тащить в UserDetails профильные поля.
displayName и avatarPath кажутся невинными, но вы постепенно превращаете security-слой в склад всего подряд. Это раздувает контекст, усложняет сериализацию, может тянуть за собой неожиданные зависимости и вообще ломает разделение UserAccount / UserProfile. UserDetails должен оставаться компактным.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ