JavaRush /Курсы /Spring Security /Маппинг UserAccount...

Маппинг UserAccount в UserDetails

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

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 или
email
getUsername() Идентифицировать пользователя в auth flow
Пароль passwordHash getPassword() Провайдер сравнит введённый пароль через PasswordEncoder
Права roles (и позже, возможно, permissions) getAuthorities() Дать Spring Security данные для
hasRole
/
hasAuthority
Состояние enabled,
accountNonLocked
isEnabled(),
isAccountNonLocked()
Запретить вход заблокированному/disabled аккаунту
Профиль displayName,
bio
,
avatarPath
не нужен Профиль не участвует в проверке пароля и выдаче 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 должен оставаться компактным.

1
Задача
Spring Security, 15 уровень, 1 лекция
Недоступна
Маппинг роли EDITOR в authorities
Маппинг роли EDITOR в authorities
1
Задача
Spring Security, 15 уровень, 1 лекция
Недоступна
Маппинг состояний enabled и accountNonLocked
Маппинг состояний enabled и accountNonLocked
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ