JavaRush /Курсы /Spring Security /Загрузка authorities из БД

Загрузка authorities из БД

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

1. Authorities: отдельный класс багов

Когда мы впервые переводим пользователей в БД, мозг по привычке ищет проблему там, где «всё самое страшное»: в пароле и PasswordEncoder. И это логично: пароль — штука чувствительная, а кодировщики выглядят как магия. Но в живых проектах очень часто пароль вообще ни при чём: аутентификация проходит, а авторизация — нет, и пользователь начинает получать запреты на доступ к тем endpoint’ам, которые вроде как «по роли должны быть доступны».

Давайте зафиксируем важную мысль простыми словами. Аутентификация отвечает на вопрос «ты кто?», а авторизация — «а тебе можно?». В Spring Security это разделение видно технически: DaoAuthenticationProvider может успешно вернуть Authentication (то есть “пользователь подтверждён”), но внутри этого Authentication может быть пустой или неправильный набор GrantedAuthority. И тогда дальше, когда запрос попадёт на правило вида hasRole("EDITOR") или hasAuthority("user:manage"), ответ будет отрицательным.

Это особенно коварно в проекте вроде Secure Content Platform API, где зоны очень разные: /api/public/** почти всегда открыта, /api/me/** требует просто аутентификации, а /api/editor/** и /api/admin/** завязаны на роли. Если роли загрузились неверно, получится странный UX: “я вошёл, значит я существую”, но “я редактор, но мне нельзя в редакторскую зону”. И вы, как разработчик, начинаете сомневаться в реальности, а не в строках "ROLE_".

2. Путь authorities от БД до SecurityContext

Чтобы чинить проблемы с правами, полезно держать в голове короткую «карту пути authority» — от таблицы в БД до SecurityContext. В DB-backed схеме источник прав — это ваши данные в UserAccount (например, roles), а потребитель — Spring Security, который ждёт набор GrantedAuthority внутри UserDetails, а потом переносит их в итоговый Authentication.

Упрощённо это можно представить так:

flowchart TD
    DB[(DB: user_account + roles)] --> Repo[UserAccountRepository]
    Repo --> UDS[CustomUserDetailsService]
    UDS --> UD[UserDetails: username + passwordHash + authorities]
    UD --> Provider[DaoAuthenticationProvider]
    Provider --> Auth["Authentication(authenticated=true)"]
    Auth --> SC[SecurityContext]

Обратите внимание на место, где чаще всего и «утекают права»: это маппинг UserAccountUserDetails. Репозиторий обычно отдаёт вам UserAccount, роли в нём могут быть пустыми, не подгруженными или храниться в неожиданном формате. А UserDetails — уже “финальный” объект для security-слоя: если вы положили в него неправильные authorities, дальше Spring Security честно будет использовать то, что вы ему дали.

В коде это выглядит примерно так: loadUserByUsername(...) загрузил аккаунт, затем toUserDetails(account) собрал UserDetails, и где-то внутри вызвал ваш метод roleNames(...) или аналогичный. Если roleNames(...) вернул пустой массив или строки без нужного префикса — вы уже проиграли. Не потому что Spring Security плохой, а потому что он буквально выполняет вашу инструкцию.

Самая неприятная часть — такие ошибки часто не проявляются на этапе логина. Логин может пройти “успешно”, потому что пароль совпал и аккаунт не заблокирован. А вот запрет доступа всплывает позже, когда вы идёте в защищённую зону. Поэтому у этого класса багов типичный симптом: “вход работает, но роль не работает”.

3. Пустые роли: вход есть, доступа нет

На словах кажется, что пользователь «без ролей» — это просто «очень простой пользователь». Но в безопасности нейтральных состояний почти не бывает. Пустые роли в БД — это либо баг данных (пользователя создали неправильно), либо баг миграции, либо баг маппинга (роли были, но вы их не увидели). А ещё это может быть сознательная модель, но тогда она должна быть зафиксирована как правило проекта и отражаться в доступах.

В нашем курсе и проекте роли USER / EDITOR / ADMIN — опорная часть модели. Если у пользователя нет ни одной роли, вы получаете странное состояние. Он может аутентифицироваться (пароль верный), но дальше:

  • в зону, где достаточно “просто быть аутентифицированным”, он может попасть;
  • в зоны, завязанные на роли (/api/editor/**, /api/admin/**), он не попадёт;
  • и вы начинаете отлаживать SecurityFilterChain, хотя проблема сидит в данных.

Самое опасное здесь — не запрет, а непредсказуемость. Сегодня у вас есть endpoint, доступный любому аутентифицированному пользователю (например, /api/me), и пользователь “без ролей” туда попадёт. Завтра вы добавили правило “только ROLE_USER”, и этот же пользователь внезапно сломался. Если такие аккаунты существуют, система будет постоянно «болтаться» между сценариями. Это ужасно для поддержки и очень плохо для безопасности, потому что исключения и неожиданные ветки обычно приводят к хаотичным “временным фиксам”.

Поэтому на fundamentals-уровне разумно сделать простое правило: пустой набор ролей — это некорректное состояние, и его лучше ловить рано и громко. Например, прямо в маппинге ролей:

import java.util.Set;

public final class RoleMapping {

    public static void requireNonEmpty(Set
   roles, String username) {
        // В security лучше падать рано и громко: пустые роли почти всегда означают баг данных/маппинга.
        if (roles == null || roles.isEmpty()) {
            throw new IllegalStateException("User has no roles: " + username);
        }
    }
}

Да, это выглядит “жёстко”. Но в учебном проекте жёсткость — это не зло, а способ быстро увидеть проблему и перестать гадать. Если у вас в БД случайно появился пользователь без ролей, лучше увидеть это как явную ошибку, чем получить “тихий” запрет доступа где-то через три шага.

Если же вы сознательно хотите поддерживать “гостя с логином, но без роли” (иногда такое бывает, например “email подтверждён не полностью”), тогда это уже дизайн-решение модели аккаунта. Но обратите внимание: в нашем текущем дне мы ещё не строим богатый lifecycle — сегодня нам важно, чтобы DB-backed auth работал предсказуемо, а не как лотерея.

4. ROLE_ prefix и hasRole(...)

Теперь про самую популярную причину “я редактор, но меня не пускает”: префикс ROLE_. Я часто шучу, что ROLE_ — это как пробел в пароле от Wi‑Fi: кажется мелочью, но ломает всё. И тут шутка, к сожалению, слишком жизненная.

Суть проблемы

В Spring Security роль — это не отдельная сущность. Это authority с соглашением по имени. То есть “роль ADMIN” в терминах GrantedAuthority обычно представляется строкой "ROLE_ADMIN".

Из этого вытекает очень конкретное следствие: когда вы в конфигурации пишете hasRole("ADMIN"), Spring Security под капотом проверяет наличие authority "ROLE_ADMIN". Если вы загрузили в UserDetails authority "ADMIN" (без префикса), то hasRole("ADMIN") не сработает. И наоборот, если вы используете hasAuthority("ROLE_ADMIN"), а загрузили "ADMIN", то тоже не сработает.

Чтобы не путаться, удобно запомнить маленькую таблицу (и да, это тот случай, когда таблица спасает нервы):

Что написано в правилах доступа Что реально должно быть в GrantedAuthority
hasRole("ADMIN") ROLE_ADMIN
hasRole("EDITOR") ROLE_EDITOR
hasAuthority("ROLE_ADMIN") ROLE_ADMIN
hasAuthority("ADMIN") ADMIN

А теперь добавим ещё одну «миноопасную» ситуацию: вы решили хранить роли в БД уже с префиксом ROLE_ (например, строкой "ROLE_ADMIN"), а в маппинге добавляете префикс ещё раз. Тогда получаете "ROLE_ROLE_ADMIN". И это выглядит смешно ровно до момента, когда вы пытаетесь понять, почему админ “не админ”.

Типичный баг: потерянный префикс

private String[] roleNames(UserAccount account) {
    return account.getRoles().stream()
            // Здесь возвращаются "ADMIN"/"EDITOR"/"USER" без префикса, и hasRole(...) потом не сработает.
            .map(role -> role.name())
            .toArray(String[]::new);
}

С этим кодом пользователь будет иметь authorities "ADMIN", "EDITOR", "USER". Если вы в SecurityFilterChain используете hasRole("ADMIN"), доступ не сработает.

Правильная версия для ролей:

private String[] roleNames(UserAccount account) {
    RoleMapping.requireNonEmpty(account.getRoles(), account.getUsername());

    return account.getRoles().stream()
            // hasRole("ADMIN") проверяет "ROLE_ADMIN", поэтому префикс добавляем явно и в одном месте.
            .map(role -> "ROLE_" + role.name())
            .toArray(String[]::new);
}

Единый способ работы с префиксом

Самая частая причина подобных багов — “размазанный маппинг”. Один разработчик добавил "ROLE_" в одном месте, другой — в другом, третий — вообще решил “а давайте хранить в БД как есть”. На fundamentals-уровне лучше всего работает правило: одно место, один способ. Один метод roleNames(...), один префикс, одна договорённость.

Если вам хочется ещё чуть больше дисциплины, можно вынести построение authority в маленький utility (без фанатизма):

public final class Authorities {

    private Authorities() {
    }

    public static String role(String roleName) {
        // Единая конвенция проекта: все роли в Spring Security идут как authorities с префиксом ROLE_.
        return "ROLE_" + roleName;
    }
}

И тогда маппинг становится почти самодокументируемым:

private String[] roleNames(UserAccount account) {
    return account.getRoles().stream()
            // Не размазываем строковую магию по проекту: вся логика префикса в Authorities.role(...).
            .map(role -> Authorities.role(role.name()))
            .toArray(String[]::new);
}

Да, это всего лишь строка. Но это строка, которая определяет, попадёт ли пользователь в /api/admin/**. В безопасности «всего лишь строка» обычно и есть главная проблема.

5. Lazy loading ролей в аутентификации

Тема “ленивой загрузки” звучит так, будто это какая-то философия: “не будем грузить сразу, вдруг не понадобится”. И в общем случае идея нормальная. Но в аутентификации роли всегда понадобятся прямо сейчас, потому что уже в ближайшие миллисекунды после логина система будет принимать решения “можно/нельзя”. Поэтому ленивая загрузка ролей — это частая причина неожиданных падений и пустых authorities.

Что такое lazy loading в очень простых словах: сущность UserAccount загрузили из БД, но связанные данные roles не загрузили сразу. Вместо этого ORM оставляет “заглушку”: когда вы впервые обратитесь к account.getRoles(), ORM попытается сходить в БД и догрузить роли. Это “попытается” — ключевое слово. Чтобы это сработало, должно выполняться условие: у ORM должна быть живая “сессия” (в JPA обычно это активный persistence context внутри транзакции).

А вот где начинается магия (в плохом смысле). loadUserByUsername(...) вызывается из Spring Security в процессе аутентификации. И если в этот момент вы:

- загрузили UserAccount без ролей, - вышли за границы транзакции или persistence context, - а потом попытались собрать authorities из account.getRoles(),

то у ORM уже нет возможности догрузить роли, и вы получите классическую ошибку вида LazyInitializationException. Новички обычно видят это как “что-то сломалось в Spring Security”. На деле Spring Security вообще ни при чём — он просто попросил user details, а вы дали ему объект, который не может сам себя корректно собрать.

Для понимания можно представить это как ситуацию с пропуском в офис. Вам дали карточку сотрудника (UserAccount), но не приложили список доступов (roles). И говорят: “не переживай, доступы подтянутся потом, когда будешь проходить турникет”. Но турникет стоит в другом здании, связи нет, охранник злой — и “потом” превращается в “никогда”.

6. Как гарантировать загрузку ролей

Нам здесь нужен не новый обязательный snapshot проекта, а одна гарантия: к моменту toUserDetails(...) роли уже доступны. Этого можно добиться разными техниками. Ниже — два понятных варианта, которые полезны и как рабочие решения, и как способы быстро диагностировать, почему authorities оказались пустыми.

Вариант A: отдельный репозиторный метод «пользователь + роли»

Это мой любимый вариант для учебного проекта, потому что он самый читаемый. Мы явно говорим: “для аутентификации мне нужен пользователь вместе с ролями”. И делаем отдельный метод репозитория.

Это самый прямой путь: сам auth-запрос сразу привозит user + roles, без надежды на lazy loading.

Например, через fetch join:

import java.util.Optional;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface UserAccountRepository {

    @Query("""
           select a from UserAccount a
           left join fetch a.roles
           where a.username = :username
           """)
    // Явно загружаем роли в том же запросе, чтобы не зависеть от lazy-loading в момент аутентификации.
    Optional<UserAccount> findByUsernameWithRoles(@Param("username") String username);
}

(Если у вас ManyToMany, иногда добавляют select distinct a ..., чтобы избежать дублей, но для первого понимания достаточно и так.)

И тогда CustomUserDetailsService использует именно этот метод:

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;

@Override
public UserDetails loadUserByUsername(String username) {
    // Берём именно "пользователь + роли", иначе дальше в toUserDetails(...) можем получить пустые authorities.
    UserAccount account = repository.findByUsernameWithRoles(username)
            .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));

    return toUserDetails(account);
}

Плюс такого подхода в том, что вы не надеетесь на “потом”. Роли пришли сразу — значит, toUserDetails(...) может безопасно собрать authorities.

Идея здесь важнее конкретного синтаксиса. Эту же гарантию можно выразить через fetch join, @EntityGraph или другой читаемый репозиторный механизм. Главное — чтобы auth-путь действительно приносил user + roles, а не обещал сделать это “когда-нибудь потом”.

Вариант B: транзакция вокруг loadUserByUsername(...)

Второй путь — дать ORM шанс догрузить роли “лениво”, но гарантировать, что в момент обращения к getRoles() persistence context ещё жив. Для этого часто помечают loadUserByUsername(...) транзакцией:

import org.springframework.transaction.annotation.Transactional;

@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) {
    // Транзакция держит persistence context живым, чтобы ленивые связи (roles) успели догрузиться при обращении.
    UserAccount account = repository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));

    return toUserDetails(account);
}

Смысл здесь не в том, что “Spring Security требует транзакцию”. Смысл в том, что вы создаёте безопасную среду, где ленивые связи могут догрузиться. Это помогает, но есть тонкость: такой код может скрыть проблему структуры запросов. То есть у вас всё будет “как будто работает”, но под капотом может происходить несколько запросов вместо одного.

Это рабочая техника, но полезно видеть её именно как способ удержать lazy-loading под контролем, а не как второй обязательный baseline проекта. Если auth-репозиторий уже умеет явно привозить роли, поведение обычно получается прозрачнее.

Самое важное здесь — не синтаксис, а гарантия. Надёжнее всего, когда auth-запрос явно приносит пользователя вместе с roles. Транзакция тоже может удержать всё рабочим, но тогда часть поведения остаётся завязанной на persistence context.

Какой бы вариант вы ни выбрали, смысл один: toUserDetails(...) не должен надеяться, что roles “как-нибудь подтянутся потом”.

7. Диагностика authorities в SecurityContext

Когда вы подозреваете, что роли загружаются не так, как вы думаете, самый быстрый способ — перестать думать и начать смотреть. Но смотреть надо аккуратно: в security-логах нельзя случайно утянуть лишнее (например, пароль, токен, или весь объект аккаунта). Нас интересует только имя пользователя и список authorities.

Один из простых учебных приёмов — временно залогировать то, что вы реально отдаёте в UserDetails. Например, прямо в toUserDetails(...):

import java.util.Arrays;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;

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

    private UserDetails toUserDetails(UserAccount account) {
        String[] authorities = roleNames(account);

        // Логируем только username и authorities: пароль/хеши/токены сюда тащить не нужно.
        // Такой лог лучше держать на DEBUG и использовать временно для диагностики.
        log.debug("Loaded user={} authorities={}", account.getUsername(), Arrays.toString(authorities));

        return User.withUsername(account.getUsername())
                .password(account.getPasswordHash())
                .authorities(authorities)
                .build();
    }
}

Обратите внимание, что мы не логируем passwordHash и тем более raw password (его тут и нет). Мы логируем только то, что помогает понять проблему: какие authorities Spring Security получит на вход.

Если в логе вы видите authorities=[ADMIN], а ожидаете ROLE_ADMIN, вы уже знаете, куда копать. Если вы видите authorities=[] или вообще не видите лог (значит, toUserDetails(...) не вызвался так, как вы думаете), это тоже полезный сигнал.

В проекте также удобно проверять доступ “контрольным” endpoint’ом. Например, если у вас уже есть /api/admin/users, он должен быть доступен только админу. Если админ не проходит, в 90% случаев проблема либо в том, как вы назвали authority, либо в том, что роли не загрузились.

8. Типичные ошибки при загрузке authorities из БД

Ошибка №1: “Пользователь вошёл — значит роли точно есть”.
Это самая обманчивая логика. Пароль может провериться, enabled=true, аккаунт не заблокирован — и аутентификация пройдёт. Но если authorities пустые или неправильные, авторизация будет ломаться позже, и вы потратите время не там. Привычка проверять authorities так же внимательно, как пароль, экономит часы.

Ошибка №2: потеря или удвоение ROLE_ префикса.
Когда в одном месте вы используете hasRole("ADMIN"), а в другом загружаете "ADMIN" без префикса, система выглядит “сломанной”, хотя формально всё работает. Удвоение префикса (ROLE_ROLE_ADMIN) ещё веселее: оно выглядит почти как правда, поэтому глаз его легко пропускает. Спасает только одно: единое место маппинга и единая договорённость по строкам.

Ошибка №3: “Роли подтянутся потом” при ленивой загрузке.
Lazy loading — удобный механизм, но в аутентификации он часто превращается в мину. Если роли нужны прямо сейчас, лучше явно загрузить их “прямо сейчас”: отдельным методом репозитория или через @EntityGraph/fetch join. Надежда на “потом” обычно заканчивается исключением или пустым набором authorities.

Ошибка №4: пустой список ролей воспринимается как нормальное состояние.
Иногда разработчик видит roles=[] и думает: “ну бывает”. В security-модели это почти всегда означает несогласованность данных или маппинга. Если вы не зафиксировали отдельную роль/состояние для “пользователя без прав”, лучше считать пустые роли ошибкой и ловить её сразу, пока баг не стал “особенностью поведения”.

Ошибка №5: в auth-запрос тащатся лишние доменные данные.
Когда запрос на аутентификацию внезапно начинает грузить профиль, аватар, черновики и половину контента, вы увеличиваете вероятность lazy-проблем и усложняете отладку. UserDetailsService должен быть скучным и предсказуемым: аккаунт, пароль-хеш, состояния, роли/authorities — и точка. Всё остальное в момент логина только мешает.

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