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]
Обратите внимание на место, где чаще всего и «утекают права»: это маппинг UserAccount → UserDetails. Репозиторий обычно отдаёт вам 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 — и точка. Всё остальное в момент логина только мешает.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ