Keycloak проверил пароль и выдал пользователю токен — отлично. Но дальше встаёт вопрос: а что этому пользователю можно? Может ли он удалять чужие заказы? Видеть админскую панель? Сам по себе токен не запрещает ничего — он лишь говорит, кто пришёл и с какими ролями. Превратить это в реальные ограничения — задача вашего приложения. Разберём с нуля, как роли из токена становятся правами.
Аутентификация и авторизация — это разные вещи
Эти два слова постоянно путают, а они про разное:
- Аутентификация — «кто ты?». Пользователь ввёл логин и пароль, Keycloak его узнал и выдал токен. На этом аутентификация закончена.
- Авторизация — «что тебе можно?». Это уже про доступ к конкретным действиям: создать заказ, посмотреть чужой профиль, зайти в админку.
Аналогия: аутентификация — это охранник на входе, который сверил ваше лицо с пропуском. Авторизация — это замки на дверях внутри: пропуск пустил вас в здание, но не в каждый кабинет.
Keycloak делает первую часть. Вторую — решает приложение, опираясь на роли, зашитые в токен. Дальше всё про неё.
Где роли лежат внутри токена
Токен от Keycloak — это JWT (JSON Web Token): по сути JSON-объект, подписанный сервером. Внутри лежат claims — поля с информацией о пользователе. Если декодировать токен (например, на jwt.io), роли видно прямо в нём.
Keycloak различает два вида ролей и кладёт их в разные места:
{
"sub": "a1b2c3d4-...",
"preferred_username": "ivan",
"realm_access": {
"roles": ["customer", "premium"]
},
"resource_access": {
"orders-service": {
"roles": ["order-manager"]
}
},
"scope": "openid profile email"
}
Что здесь что:
realm_access.roles— realm-роли. Это роли уровня всего realm (одного «царства» пользователей в Keycloak). Их видят все приложения этого realm. Сюда попадают общие роли вродеcustomer,admin.resource_access.<client>.roles— client-роли. Привязаны к конкретному клиенту (приложению), зарегистрированному в Keycloak. Например, рольorder-managerимеет смысл только внутри сервиса заказов.scope— это про права самого клиента-приложения, не пользователя. Например, согласие на чтение профиля. Тоже участвует в доступе, но иначе (об этом ниже).
Главный практический вывод: realm-роли и client-роли лежат по разным ключам. Если ваше приложение ищет роли не там, оно их просто не увидит — и решит, что у пользователя их нет.
Зачем нужен маппинг ролей: GrantedAuthority
Spring Security не знает про Keycloak. Внутри себя он работает со своим понятием — GrantedAuthority («выданное право»). Это просто строка-метка вроде ROLE_admin или SCOPE_profile, которую Spring потом сверяет с правилами доступа.
Проблема: Spring по умолчанию не умеет доставать роли из realm_access.roles — это формат, специфичный для Keycloak. Из коробки он смотрит только в claim scope и делает из него authority с префиксом SCOPE_. Realm- и client-роли он при этом игнорирует.
Значит, нужен переводчик: компонент, который возьмёт роли из нужных мест токена и превратит их в GrantedAuthority. В Spring Security он называется JwtAuthenticationConverter.
Аналогия: токен — это паспорт на иностранном языке. GrantedAuthority — это записи в понятной Spring форме. JwtAuthenticationConverter — переводчик, который читает паспорт и заполняет внутреннюю анкету.
Настраиваем JwtAuthenticationConverter
Сначала минимальная настройка resource server — приложения, которое принимает и проверяет токены. В application.yml указываем, кому верить:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://keycloak.example.com/realms/myrealm
По issuer-uri Spring сам найдёт JWKS (набор публичных ключей Keycloak) и будет проверять подпись каждого токена — чтобы никто не подделал роли. Это работает автоматически, ключи кешируются.
Теперь сам переводчик ролей. Достаём realm_access.roles и навешиваем на каждую роль префикс ROLE_:
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Map<String, Object> realmAccess = jwt.getClaim("realm_access");
if (realmAccess == null) {
return List.of();
}
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) realmAccess.get("roles");
if (roles == null) {
return List.<GrantedAuthority>of();
}
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());
});
return converter;
}
После этого роль customer из токена станет authority ROLE_customer, которую Spring уже понимает.
Зачем именно префикс ROLE_ — в следующем разделе, это самая частая точка ошибок.
Префикс ROLE_: главная путаница
В Spring есть два похожих способа проверить право, и разница между ними ловит почти всех новичков:
hasRole('customer')— Spring сам добавляет префиксROLE_и ищет authorityROLE_customer.hasAuthority('ROLE_customer')— Spring ищет authority ровно как написано, ничего не добавляя.
То есть hasRole('customer') и hasAuthority('ROLE_customer') — это одно и то же. А вот hasRole('ROLE_customer') будет искать ROLE_ROLE_customer и никогда не сработает.
Из этого следует правило выбора при маппинге:
- если в конвертере вы добавили
ROLE_к ролям — пользуйтесьhasRole('customer')(без префикса в аргументе); - если не добавляли и authority называется просто
customer— тогда толькоhasAuthority('customer'), потому чтоhasRoleбудет искать несуществующийROLE_customer.
Самая частая ошибка: роли из токена замаппили без префикса, а в коде написали hasRole('admin'). Доступ молча не работает — Spring ищет ROLE_admin, а в authority лежит admin. Поэтому удобнее один раз добавить ROLE_ в конвертере и везде писать hasRole.
RBAC: доступ по ролям
RBAC (Role-Based Access Control) — доступ по ролям. Самый простой и частый подход: «у тебя роль admin → пускаем в админку». Никаких дополнительных условий, только наличие роли.
В Spring это ставится аннотацией @PreAuthorize прямо над методом:
@RestController
class OrderController {
@PreAuthorize("hasRole('admin')")
@DeleteMapping("/orders/{id}")
void deleteOrder(@PathVariable Long id) { ... }
@PreAuthorize("hasRole('customer')")
@PostMapping("/orders")
void createOrder(@RequestBody OrderRequest req) { ... }
}
Чтобы @PreAuthorize работала, метод-уровневую защиту надо включить:
@Configuration
@EnableMethodSecurity
class SecurityConfig { ... }
RBAC отвечает на вопрос «какому типу пользователей вообще можно сюда?». Это контроль на уровне действия (endpoint), не на уровне конкретной записи. Например, RBAC скажет «редактировать заказы может любой customer» — но не сможет проверить, свой ли это заказ. Для этого нужен ABAC.
Проверка по scope
Иногда нужно проверить не роль пользователя, а разрешение клиента-приложения — то самое поле scope. Spring по умолчанию делает из каждого scope authority с префиксом SCOPE_:
@PreAuthorize("hasAuthority('SCOPE_orders:read')")
@GetMapping("/orders")
List<Order> list() { ... }
Когда что использовать простыми словами: роль отвечает «кто пользователь» (customer, admin), scope — «что приложению разрешено делать от его имени» (читать заказы, но не удалять). Для бизнес-доступа в большинстве сервисов хватает ролей; scope чаще нужен в сценариях с внешними клиентами и публичными API.
ABAC: когда роли недостаточно
Представьте: у Ивана роль customer, и RBAC разрешает любому customer редактировать заказы. Но Иван открывает заказ Петра и меняет адрес доставки. Роль-то у него правильная — RBAC пропустит. А доступ всё равно неправильный.
Проблема в том, что роль не знает про конкретную запись. «Можно редактировать заказы» и «можно редактировать этот заказ» — разные вопросы.
ABAC (Attribute-Based Access Control) — доступ по атрибутам. Решение проверяет не только роль, но и атрибуты: кто владелец ресурса, кто пользователь, какой статус у записи. Самый частый случай ABAC — проверка владения: совпадает ли владелец заказа с тем, кто пришёл в токене.
Идентификатор пользователя берут из claim sub токена (стабильный уникальный id пользователя в Keycloak):
@Service
class OrderService {
void updateAddress(Long orderId, Address address, Jwt jwt) {
Order order = repository.findById(orderId).orElseThrow();
String currentUserId = jwt.getSubject(); // claim "sub"
if (!order.getOwnerId().equals(currentUserId)) {
throw new AccessDeniedException("not your order");
}
order.changeAddress(address);
}
}
Здесь проверка зависит от данных (владелец заказа), а не только от роли — это и есть суть ABAC. Такую проверку держат в одном месте (в сервисе или отдельном компоненте доступа), а не размазывают по контроллерам.
Частый и разумный компромисс: роль admin проверку владения обходит — администратору можно трогать чужие записи. Но каждое такое действие стоит писать в журнал (audit), чтобы было видно, кто и что менял.
RBAC и ABAC вместе
На практике их не противопоставляют, а складывают. RBAC отсеивает на входе, ABAC уточняет на уровне записи:
- RBAC (
@PreAuthorizeна методе): есть ли вообще рольcustomer? Нет — сразу отказ, до бизнес-логики. - ABAC (проверка в сервисе): а этот заказ принадлежит пользователю? Нет — отказ.
Так грубый фильтр (роль) стоит дёшево и срабатывает рано, а дорогая проверка (загрузить запись, сравнить владельца) выполняется только если роль уже подошла.
Частые ошибки
Соберём грабли, на которые наступают чаще всего:
- Ищут роли не в том claim. Realm-роли — в
realm_access.roles, client-роли — вresource_access.<client>.roles. Если читать толькоrealm_access, client-роли потеряются (и наоборот). - Забыли про префикс
ROLE_. Замаппили роль какadmin, а в кодеhasRole('admin')(Spring ищетROLE_admin). Доступ молча не работает. Либо добавляйтеROLE_в конвертере, либо пишитеhasAuthority. - Двойной префикс.
hasRole('ROLE_admin')ищетROLE_ROLE_admin. ВhasRoleпрефикс не пишут. - Полагаются на RBAC там, где нужен ABAC. Роль
customerне гарантирует, что заказ — его. Без проверки владения один пользователь правит данные другого. - Не включили
@EnableMethodSecurity. Без неё аннотации@PreAuthorizeпросто игнорируются — а кажется, что защита есть. - Доверяют ролям без проверки подписи. Роли в JWT — обычный текст. Без проверки подписи (через
issuer-uri/JWKS) их можно подделать. Никогда не парсите токен «руками» в обход resource server.
Коротко
- Аутентификация — «кто ты» (делает Keycloak), авторизация — «что тебе можно» (решает приложение по ролям).
- Realm-роли лежат в
realm_access.roles, client-роли — вresource_access.<client>.roles, права приложения — вscope. - Spring из коробки роли Keycloak не видит — нужен
JwtAuthenticationConverter, который маппит их вGrantedAuthority. hasRole('x')сам добавляетROLE_;hasAuthority('y')ищет точное имя. Это разные вещи — отсюда большинство ошибок.- RBAC — доступ по роли, ставится через
@PreAuthorize(+@EnableMethodSecurity); контроль на уровне действия. - ABAC — доступ по атрибутам; чаще всего это проверка владения: сравнить владельца записи с
subиз токена. - На практике RBAC и ABAC складывают: роль фильтрует рано, проверка владения уточняет на уровне записи.
- Роли в JWT — это текст; доверять им можно только после проверки подписи через
issuer-uri/JWKS.
Что почитать дальше
- Keycloak: realm, client, роли — базовые понятия, на которых строится доступ.