← назад к разделу

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.rolesrealm-роли. Это роли уровня всего realm (одного «царства» пользователей в Keycloak). Их видят все приложения этого realm. Сюда попадают общие роли вроде customer, admin.
  • resource_access.<client>.rolesclient-роли. Привязаны к конкретному клиенту (приложению), зарегистрированному в 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_ и ищет authority ROLE_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 уточняет на уровне записи:

  1. RBAC (@PreAuthorize на методе): есть ли вообще роль customer? Нет — сразу отказ, до бизнес-логики.
  2. 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.

Что почитать дальше