На одном банковском проекте мы запустили систему с самописной авторизацией. Пользовательские пароли хранились в БД с прекрасным односторонним шифрованием MD5 (да-да, я не ошибся — это было давно, но всё равно непростительно). А потом появилось требование интеграции с внутренними корпоративными системами... И тут началось веселье с LDAP, Active Directory, самописными прокси и прочими костылями, которые разваливались от любого чиха.
А ведь современные решения на базе OAuth 2.0 и OpenID Connect давно предлагают элегантные способы централизованного управления идентификацией и доступом. Так зачем же городить огород? Серьезно, в 2025 году никто не должен писать свою систему аутентификации и авторизации с нуля. Это как изобретать свой велосипед, когда рядом стоит Феррари с открытыми дверями и ключами в замке зажигания.
Среди всех современных подходов к безопасности веб-приложений особенно выделяется связка Spring Boot и Keycloak — это как тот самый Феррари для Java-разработчиков. Они идеально подходят друг к другу, словно были созданы одной командой разработчиков. Spring Boot уже предоставляет мощный фреймворк Spring Security, который десятилетиями доказывает свою состоятельность. А Keycloak, будучи продуктом RedHat, добавляет к нему удобное централизованное управление пользователями, ролями, и сессиями.
Давайте разберемся, почему эта комбинация — чуть ли не лучшее, что случилось с безопасностью корпоративных приложений за последнее время:
1. Стандартизация — весь стек основан на индустриальных стандартах OAuth 2.0, OpenID Connect, SAML и JWT. Никаких "самописных протоколов", которые никто не может понять.
2. Разделение ответственности — Keycloak берёт на себя все заботы об аутентификации и управлении пользователями, а Spring Boot фокусируется на авторизации внутри приложения. Как говорится, separation of concerns в чистом виде.
3. Масштабируемость — оба решения прекрасно масштабируются под высокие нагрузки. Я лично видел, как Keycloak спокойно выдерживал миллионы запросов на авторизацию ежедневно на одном из моих проектов для телеком-оператора.
4. Готовность к мультитенантности — когда ваше приложение должно обслуживать множество изолированных клиентов, разграничение доступа становится настоящей головной болью. Keycloak решает эту проблему с помощью концепции Realms, которые изолируют пользователей и настройки.
5. Бэкенд для фронтенда — интеграция фронтенда и бэкенда через токены JWT настолько упрощается, что даже джуны могут с ней справиться.
Вот реальный пример из жизни. Недавно консультировал команду, которая переписывала легаси-проект — монолит на JSP с кастомной авторизацией. У них была отдельная таблица Users, и каждый микросервис, на которые они декомпозировали систему, начал переносить эту таблицу к себе. А потом они запутались с синхронизацией данных между сервисами, различиями в логике авторизации, невозможностью единого входа... В итоге, они выбросили неделю работы и потратили два дня на внедрение Keycloak. И знаешь, что было дальше? Они получили не только решение всех проблем с авторизацией, но и бонусом 2FA, интеграцию с Google, Microsoft и другими провайдерами, а также красивые формы входа в приложение — почти бесплатно!
Не хочу показаться хейтером самописных решений. Есть сценарии, когда приходится изобретать что-то своё. Но в большинстве случаев Spring Boot с Keycloak закрывает 95% требований безопасности типичного корпоративного приложения из коробки. А оставшиеся 5% можно добрать кастомной настройкой. Многие боятся сложности настройки. Мол, "Keycloak — это же целая система, зачем нам такой слон для маленького проекта?". И тут они сильно ошибаються — время на разворачивание базового функционала Keycloak с интеграцией Spring Boot меньше, чем на написание и отладку базовой системы аутентификации с нуля. Особенно с появлением Docker и Kubernetes.
Keycloak как централизованное решение для управления доступом

Когда я впервые внедрил Keycloak в большой проект для одного госучреждения (не буду называть какого), многие в команде крутили пальцем у виска. "Зачем нам еще один сервер? У нас и так их полсотни!" Через месяц те же самые люди не представляли, как мы раньше жили без него. А всё благодаря архитектуре, которая действительно решает болезненные проблемы.
Архитектура, которая не заставит вас плакать
Keycloak построен на базе проекта WildFly (бывший JBoss), что уже намекает на его enterprise-уровень. Ядро системы включает несколько ключевых компонентов:
Realm — изолированная среда для пользователей, приложений и их настроек. Думайте о нем как о виртуальном "царстве безопасности". Я видел проекты, где один Keycloak обслуживал 30+ реалмов для разных клиентов, и ни один из них не подозревал о существовании других.
Клиенты — это не люди, а приложения или сервисы, которым нужен доступ. Клиент в Keycloak может быть публичным (как веб-приложение) или конфиденциальным (бэкенд с секретом).
Поставщики идентификации (Identity Providers) — внешние системы аутентификации, такие как Google, Facebook, LDAP или ваша корпоративная Active Directory.
Пользовательские федерации — мосты к существующим базам пользователей, которые позволяют не дублировать данные.
Authentication Flows — последовательности шагов аутентификации. Хотите добавить капчу после трех неудачных попыток входа? Легко! А может, требуется 2FA только для администраторов? Тоже без проблем!
Все эти компоненты крутятся вокруг мощного движка для обработки протоколов OpenID Connect и SAML. Я как-то пытался реализовать SAML самостоятельно — так у меня седых волос прибавилось. С Keycloak эту боль можно забыть как страшный сон.
Кастомизация: от "стандартно" до "никто не узнает, что это Keycloak"
Часто клиенты хотят, чтобы страница входа выглядела "как часть нашего бренда". Тут Keycloak тоже на высоте:
- Система тем с поддержкой Freemarker Templates.
- Возможность полностью кастомизировать HTML/CSS/JavaScript.
- Локализация на десятки языков.
Однажды я так закастомизировал Keycloak для банка, что даже их служба безопасности не сразу поняла, что используется внешний продукт. "А мы думали, это наши разработчики такую красоту создали", — говорили они. Я не стал их разочаровывать, только улыбнулся.
SSO: один вход, все сервисы
Механизм единого входа (Single Sign-On) в Keycloak — это не просто модная фишка, а реальное решение боли пользователей. Вот как это работает на практике:
1. Пользователь логинится в одно из защищенных приложений
2. Keycloak аутентифицирует его и создает сессию
3. Когда тот же пользователь заходит в другое приложение, он уже авторизован
Что особенно ценно — SSO работает даже для приложений на разных доменах, с разными стеками технологий. У одного моего клиента микросервисная архитектура включала и .NET, и Java, и NodeJS, и Python-сервисы. Keycloak объединил их под одной крышей безопасности.
Интеграция с корпоративными монстрами
Федерация пользователей — это способ связать Keycloak с существующими хранилищами учетных данных. Для корпоративного мира это чаще всего Active Directory и LDAP.
| Java | 1
2
3
4
5
6
7
8
9
10
11
| // Пример конфигурации LDAP-федерации в Keycloak через Admin REST API
LDAPUserStorageProviderRepresentation ldapConfig = new LDAPUserStorageProviderRepresentation();
ldapConfig.setPriority(1);
ldapConfig.setImportEnabled(true);
ldapConfig.setEditMode(UserStorageProvider.EditMode.WRITABLE);
ldapConfig.setConnectionUrl("ldap://ldap.company.org");
ldapConfig.setUsersDn("ou=Users,dc=company,dc=org");
// и другие параметры...
// Создание федерации через REST API
adminClient.realm("my-realm").userStorage().create(ldapConfig); |
|
Почему это важно? Потому что не нужно заставлять пользователей создавать еще один аккаунт. Они используют существующие учетные записи, а вы получаете мощное управление доступом. Win-win.
И что особенно ценно — Keycloak может не только читать данные из LDAP/AD, но и записывать изменения обратно, если настроить соответствующие права.
Battle Royale: Keycloak против Auth0, Okta и других
Сравнивая Keycloak с другими решениями, я всегда выделяю несколько ключевых моментов:
Keycloak: open-source, полностью бесплатный, развертывание на собственной инфраструктуре, полный контроль, нет ограничений по пользователям.
Auth0: SaaS, отличный UI/UX, упрощенная настройка, но платная модель, привязка к облаку, ценообразование зависит от количества пользователей.
Okta: корпоративный гигант, огромный набор готовых интеграций, но высокая стоимость и зависимость от внешней инфраструктуры.
Однажды мне довелось мигрировать систему с Okta на Keycloak из-за стоимости — бизнес рос, а вместе с ним экспоненциально росли счета от Okta. После перехода на Keycloak ROI был меньше полугода, не говоря уже о дополнительном контроле над данными.
Конечно, Keycloak требует больше настройки и имеет более крутую кривую обучения. Но для тех, кто хочет полный контроль без зависимости от внешних сервисов — это определенно выбор номер один.
Жизнь в контейнерах и облаках
С современными подходами к инфраструктуре Keycloak чувствует себя как рыба в воде. Docker-образ официально поддерживается и может быть запущен одной командой:
| Bash | 1
2
| docker run -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:latest start-dev |
|
Для продакшена лучше использовать Kubernetes с помощью Helm-чартов или операторов. Я обычно рекомендую настраивать кластер из минимум двух экземпляров Keycloak с распределенным кешированием через Infinispan для отказоустойчивости.
Для облачных развертываний я всегда рекомендую настраивать внешнюю базу данных вместо встроенной H2. PostgreSQL тут отлично подходит — с ним Keycloak дружит особенно хорошо:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| # Фрагмент docker-compose.yml для Keycloak с PostgreSQL
version: '3'
services:
postgres:
image: postgres:14
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: keycloak123
volumes:
- postgres_data:/var/lib/postgresql/data
keycloak:
image: quay.io/keycloak/keycloak:latest
command: start-dev
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak123
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin123
ports:
- "8080:8080"
depends_on:
- postgres |
|
Интеграция с экосистемой Spring Boot
Вот где начинается настоящая магия — в соединении Keycloak со Spring Boot. Spring Security, с его многолетней историей, нашел в Keycloak идеального партнера. Это как брак двух старых друзей — все вокруг думают: "Почему они раньше не были вместе? Они же идеально подходят друг другу!". Базовая интеграция требует всего нескольких шагов:
1. Добавить нужные зависимости в ваш pom.xml:
| XML | 1
2
3
4
5
6
7
8
9
10
11
12
13
| <dependencies>
<!-- Spring Boot Starter Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- OAuth2 Resource Server для проверки токенов -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
</dependencies> |
|
2. Настроить приложение через application.yml (или .properties):
| YAML | 1
2
3
4
5
6
7
| spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: [url]http://localhost:8080/realms/my-realm[/url]
jwk-set-uri: [url]http://localhost:8080/realms/my-realm/protocol/openid-connect/certs[/url] |
|
3. Создать базовую конфигурацию безопасности:
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| @Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/api/public/[B]").permitAll()
.antMatchers("/api/user/[/B]").hasRole("USER")
.antMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.oauth2ResourceServer()
.jwt();
return http.build();
}
// Конвертер для преобразования ролей из JWT-токена в формат Spring Security
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null || !realmAccess.containsKey("roles")) {
return Collections.emptyList();
}
List<String> roles = (List<String>) realmAccess.get("roles");
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.collect(Collectors.toList());
});
return converter;
}
} |
|
И вуаля! Ваше Spring Boot приложение теперь защищено Keycloak.
Но знаете, что самое интересное? Этот код работает не только для веб-приложений. Для микросервисов, которые вызывают друг друга, можно использовать service accounts, а для браузерных SPA — публичные клиенты с PKCE. И везде этот же самый код будет валидировать токены правильно. Как-то я работал над проектом, где нужно было защитить 17 микросервисов. Представляете, сколько бы это было работы со стандартным подходом? С Keycloak и Spring Boot на настройку каждого сервиса уходило меньше 30 минут, включая тестирование.
Преимущества перед самописными решениями
Я обычно сталкиваюсь с двумя категориями разработчиков: те, кто сразу идет к готовым решениям, и те, кто говорит "да мы сами сделаем, это же просто!". Ко вторым у меня всегда один вопрос: "А вы действительно продумали ВСЕ аспекты безопасности?" Вот неполный список того, что дает вам Keycloak + Spring Boot, и что вам пришлось бы реализовывать самим:
Безопасное хранение учетных данных. Keycloak использует современные алгоритмы хеширования по умолчанию, включая соль и перец (да, в криптографии есть такой термин).
Защита от распространенных атак. Брутфорс, CSRF, XSS, инъекции — от множества распространённых угроз вы защищены из коробки.
Управление сессиями. Сколько раз вы реализовывали механизм, который позволяет администратору видеть активные сессии пользователя и принудительно завершать их? В Keycloak это уже есть.
Аудит и логирование. Каждое действие, связанное с авторизацией и аутентификацией, логируется с возможностью отправки этих логов во внешние системы.
Управление токенами. Генерация, валидация, обновление, отзыв токенов — всё это уже реализовано и протестировано тысячами разработчиков по всему миру.
Как-то раз на проекте для крупной страховой компании я заменил самописную систему авторизации на Keycloak. После внедрения служба безопасности провела пентест и не нашла ни одной критической уязвимости в части авторизации. А до этого они регулярно вскрывали 2-3 серьезных дыры. Или вот еще случай: команда одного финтех-стартапа целых три месяца писала свою систему управления доступом. Угадайте, что случилось, когда они попытались пройти сертификацию PCI DSS? Правильно, их решение не соответствовало минимальным требованиям безопасности. Ещё полгода ушло на доработку, а потом кто-то на митапе рассказал им про Keycloak, и они мигрировали за две недели.
Но самый частый аргумент против готовых решений, который я слышу: "А вдруг оно не покроет наших специфичных требований?". Тут всё просто — Keycloak спроектирован с учетом расширяемости. Вы можете:- Писать собственные SPI (Service Provider Interface) плагины,
- Кастомизировать аутентификационные потоки,
- Добавлять свои маппинги атрибутов и ролей,
- Расширять REST API с помощью собственных эндпоинтов.
Например, на одном из проектов нам требовалась интеграция с проприетарной системой биометрической аутентификации. Мы написали SPI-провайдера для Keycloak, который взаимодействовал с API этой системы:
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| public class BiometricAuthenticator implements Authenticator {
@Override
public void authenticate(AuthenticationFlowContext context) {
// Получение данных от клиента
String biometricData = context.getHttpRequest().getDecodedFormParameters().getFirst("biometricData");
// Вызов внешнего API для проверки
if (BiometricService.verify(biometricData, getUserFromContext(context))) {
context.success();
} else {
context.failure(AuthenticationFlowError.INVALID_CREDENTIALS);
}
}
// Другие методы реализации интерфейса...
} |
|
Подобная гибкость позволяет адаптировать Keycloak под самые экзотические требования безопасности.
А как насчет производительности? Да, самописное решение, заточенное под конкретный узкий сценарий, может работать быстрее. Но стоит ли экономия нескольких миллисекунд всех тех рисков, которые вы берете на себя? И, кстати, Keycloak прекрасно кешрует токены и другие данные, что в большинстве случаев делает его вполне производительным даже для высоконагруженных систем.
Еще один весомый аргумент в пользу готового решения — это скорость внесения изменений. Когда требования безопасности меняются (а они меняются постоянно), гораздо проще настроить новые политики через админку или API, чем переписывать и тестировать код. Короче говоря, используя Keycloak с Spring Boot, вы становитесь на плечи гигантов. Вместо того чтобы тратить месяцы на создание хрупкой системы безопасности, вы можете сосредоточиться на том, что действительно ценно для бизнеса — на функционале вашего приложения.
Но чтобы дать Keycloak и Spring Boot раскрыть свой полный потенциал, нужно разобраться, как правильно организовать ролевую модель доступа.
Project 'org.springframework.boot:spring-boot-starter-parent:2.3.2.RELEASE' not found <?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
... Что такое Spring, Spring Boot? Здравствуйте.
Никогда не использовал Spring, Spring Boot.
Возник такой вопрос можно ли его... Spring в Spring Boot context ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext(
... Spring Boot VS Tomcat+Spring - что выбрать? Всем доброго дня!
Я наверное еще из старой школы - пилю мелкие проект на Spring + Tomcat...
...
Ролевая модель доступа RBAC в действии

Когда дело касается безопасности приложений, RBAC (Role-Based Access Control) — как швейный набор портного: вроде простой, но позволяет создать костюм, идеально сидящий на клиенте.
Ролевая кухня Keycloak
В Keycloak роли — это первоклассные граждане. Они бывают двух типов:
Realm-роли — глобальные, применимые ко всем приложениям в рамках реалма
Client-роли — специфичные для конкретного приложения-клиента
Настройка базовых ролей до смешного проста. В админке переходите в раздел Roles, нажимаете Add Role, и voilà — роль создана. Но дьявол, как обычно, в деталях.
| Java | 1
2
3
4
5
6
7
| // Программное создание роли через Admin API
RoleRepresentation roleRepresentation = new RoleRepresentation();
roleRepresentation.setName("sales-manager");
roleRepresentation.setDescription("Может управлять продажами и видеть отчеты");
roleRepresentation.setComposite(false); // Простая роль, не композитная
keycloak.realm("my-company").roles().create(roleRepresentation); |
|
Один из первых вопросов, который встает перед архитектором: где хранить роли — на уровне реалма или клиента? Я предпочитаю такой подход: если роль имеет одинаковый смысл во всех приложениях — это realm-роль. Если её значение специфично для приложения — client-роль. Например, "администратор" в вашем случае может означать разные вещи для разных систем. В CRM — это человек, который настраивает воронки продаж, а в HR-системе — тот, кто утверждает отпуска. Тут лучше использовать client-роли: "crm-admin" и "hr-admin".
Композитные роли: когда 1 + 1 > 2
Однажды мне пришлось настраивать систему прав доступа для банка, где было около 80 различных микро-ролей. Представляете, как неудобно назначать пользователю 30 ролей вручную? Вот тут и пригодились композитные роли.
Композитная роль в Keycloak — это как пакет подписки: вы получаете сразу набор базовых возможностей. Она включает в себя другие роли.
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // Создание композитной роли
RoleRepresentation financeDepartmentRole = new RoleRepresentation();
financeDepartmentRole.setName("finance-department");
financeDepartmentRole.setComposite(true);
// Сначала создаем роль
keycloak.realm("my-company").roles().create(financeDepartmentRole);
// Теперь добавляем дочерние роли
List<RoleRepresentation> compositeRoles = new ArrayList<>();
compositeRoles.add(keycloak.realm("my-company").roles().get("view-reports").toRepresentation());
compositeRoles.add(keycloak.realm("my-company").roles().get("manage-expenses").toRepresentation());
compositeRoles.add(keycloak.realm("my-company").roles().get("approve-payments").toRepresentation());
keycloak.realm("my-company").rolesById().addComposites(
financeDepartmentRole.getId(), compositeRoles); |
|
Но тут кроется подводный камень — производительность. Когда у вас глубокие иерархии ролей (роль включает другие роли, которые включают третьи роли и так далее), вычисление эффективных прав доступа может занимать заметное время. Поэтому не увлекайтесь вложенностью более 2-3 уровней.
Временные роли: доступ с ограниченным сроком годности
Знакомая ситуация: подрядчику нужен доступ к системе на время проекта, а потом права надо отобрать. А в день окончания проекта все забыли это сделать, и подрядчик ещё месяц имел доступ к конфиденциальным данным.
В Keycloak эта проблема решается через атрибуты ролей и специальный протокол маппинга.
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // Настройка временной роли через атрибуты
Map<String, List<String>> attributes = new HashMap<>();
attributes.put("expiration_date", List.of("2025-12-31"));
UserRepresentation user = new UserRepresentation();
user.setUsername("temp-contractor");
// ... другие поля пользователя
// Сохраняем атрибут с датой истечения
user.setAttributes(attributes);
// И конечно, назначаем нужные роли
List<String> realmRoles = List.of("project-viewer");
user.setRealmRoles(realmRoles);
keycloak.realm("my-company").users().create(user); |
|
А теперь самое интересное — научим Spring Security учитывать этот атрибут:
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| @Component
public class ExpiringRoleConverter extends JwtAuthenticationConverter {
@Override
protected Collection<GrantedAuthority> extractAuthorities(Jwt jwt) {
Collection<GrantedAuthority> authorities = super.extractAuthorities(jwt);
Map<String, Object> attributes = jwt.getClaims();
// Проверяем атрибуты ролей на срок действия
if (attributes.containsKey("role_attributes")) {
Map<String, Object> roleAttrs = (Map<String, Object>) attributes.get("role_attributes");
// Фильтруем истекшие роли
return authorities.stream()
.filter(authority -> !isRoleExpired(authority.getAuthority(), roleAttrs))
.collect(Collectors.toList());
}
return authorities;
}
private boolean isRoleExpired(String role, Map<String, Object> roleAttributes) {
if (roleAttributes.containsKey(role + "_expiration")) {
String expirationDate = (String) roleAttributes.get(role + "_expiration");
return LocalDate.parse(expirationDate).isBefore(LocalDate.now());
}
return false;
}
} |
|
Таким образом, даже если в Keycloak роль еще активна, но срок её действия истек, пользователь не получит соответствующие привилегии. Изящно, не правда ли?
Иерархии ролей: от интерна до CEO
Иерархия ролей — ключевой инструмент для моделирования организационной структуры. И тут Keycloak предлагает два подхода:
1. Явные иерархии через композитные роли — как мы обсуждали выше.
2. Неявные иерархии через группы пользователей.
Группы в Keycloak — это не совсем то же самое, что роли. Они предназначены для организации пользователей, но могут наследовать роли от родительских групп:
| Java | 1
2
3
4
5
6
7
8
9
10
11
| // Создание группы и подгруппы с ролями
GroupRepresentation engineering = new GroupRepresentation();
engineering.setName("Engineering");
engineering.setAttributes(Map.of("department", List.of("tech")));
// Добавляем инженерные роли
// ...
GroupRepresentation devOps = new GroupRepresentation();
devOps.setName("DevOps");
// DevOps наследует все роли Engineering, плюс свои уникальные |
|
Что я обнаружил на практике: для больших предприятий лучше использовать смешанный подход — моделировать организационную структуру через группы, а специфичные разрешения через роли.
Маппинг пользователей на роли: матрица прав доступа
Назначение ролей пользователям может происходить несколькими способами:
1. Прямое назначение — самый очевидный способ, когда роль назначается конкретному пользователю.
2. Через группы — пользователь получает все роли, назначенные группам, в которых он состоит.
3. Через правила — самый интересный способ, когда роли назначаются динамически.
Именно третий пункт заслуживает отдельного внимания.
Динамические роли: магия автоматического назначения
Представьте: у вас есть правило "все сотрудники из отдела маркетинга должны иметь роль marketing-user". Вручную это администрировать — кошмар. К счастью, Keycloak поддерживает назначение ролей через скриптовые правила.
| JavaScript | 1
2
3
4
5
6
7
8
9
10
| // Пример JavaScript-правила для Keycloak
var groups = user.getGroups();
for (var i = 0; i < groups.size(); i++) {
var group = groups.get(i);
if (group.getName() === "Marketing") {
// Добавляем маркетинговую роль
var marketingRole = realm.getRole("marketing-user");
user.grantRole(marketingRole);
}
} |
|
Такие правила могут работать и на основе данных из внешних систем. Например, я реализовывал интеграцию с HR-системой, где роли назначались в зависимости от должности сотрудника в корпоративном справочнике:
| JavaScript | 1
2
3
4
5
6
7
| // Пример интеграции с внешней системой
var position = externalService.getUserPosition(user.getUsername());
if (position === "Sales Manager") {
user.grantRole(realm.getRole("sales-manager"));
} else if (position === "Sales Representative") {
user.grantRole(realm.getRole("sales-rep"));
} |
|
Практическая реализация в Spring Security: вишенка на торте
Вся эта мощь Keycloak идеально интегрируется со Spring Security. Вот реальный пример, который я использовал в одном из проектов:
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| @Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.oauth2ResourceServer()
.jwt()
.jwtAuthenticationConverter(jwtAuthenticationConverter());
http.authorizeRequests()
.antMatchers("/api/public/[B]").permitAll()
.antMatchers("/api/reports/[/B]").hasRole("REPORT_VIEWER")
.antMatchers("/api/admin/**").hasRole("ADMIN")
.antMatchers(HttpMethod.POST, "/api/orders").hasAnyRole("ORDER_CREATOR", "SALES_MANAGER")
.antMatchers(HttpMethod.DELETE, "/api/orders/{id}").hasRole("ORDER_MANAGER")
.anyRequest().authenticated();
return http.build();
}
private JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null || !realmAccess.containsKey("roles")) {
return Collections.emptyList();
}
return ((List<String>) realmAccess.get("roles")).stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.collect(Collectors.toList());
});
return converter;
}
} |
|
Обратите внимание на элегантный маппинг ролей из JWT-токена в авторитеты Spring Security. Keycloak предоставляет роли в claim'е "realm_access.roles", а мы преобразуем их в формат, который понимает Spring Security (с префиксом "ROLE_"). Однако в некоторых сложных сценариях ролевая модель RBAC может оказаться недостаточно гибкой. Что если права пользователя должны зависеть не только от его ролей, но и от других факторов — времени суток, IP-адреса, текущего баланса на счете или других бизнес-атрибутов? Здесь на помощь приходит ABAC — атрибутная модель контроля доступа, о которой мы поговорим дальше.
Атрибутная модель ABAC для сложных сценариев

Помните того менеджера, который просил "сделать как в банке — чтоб сотрудники видели только клиентов из своего региона"? Или заказчика, который хотел, чтобы "документы с грифом 'секретно' открывались только в офисе компании, а не дома"? В таких случаях простая ролевая модель начинает трещать по швам. Вот тут-то и выходит на сцену атрибутная модель контроля доступа (ABAC).
Когда ролей катастрофически не хватает
RBAC прекрасен своей простотой: есть роли, есть права — всё понятно даже джуну. Но в реальной жизни доступ часто должен зависеть от множества факторов:
Кто запрашивает доступ (атрибуты пользователя)
К чему запрашивается доступ (атрибуты ресурса)
Когда и откуда запрашивается доступ (атрибуты среды)
Что пользователь хочет сделать (атрибуты действия)
Я как-то консультировал медицинскую компанию, где доступ к данным пациента должен был зависеть от множества факторов: является ли врач лечащим для данного пациента, находится ли пациент в критическом состоянии, есть ли у врача специализация в области заболевания пациента. Попробуйте смоделировать это ролями — получите комбинаторный взрыв!
Создание политик на основе атрибутов в Keycloak
Keycloak поддерживает ABAC через систему протоколов маппинга (Protocol Mappers) и атрибутов пользователей, клиентов и сессий. Давайте разберемся, как это работает на практике.
Первый шаг — определить и загрузить нужные атрибуты в токен. Keycloak позволяет включать в JWT любые атрибуты пользователя:
| Java | 1
2
3
4
5
6
7
8
9
| // Установка атрибутов пользователя через Admin API
Map<String, List<String>> attributes = new HashMap<>();
attributes.put("department", List.of("sales"));
attributes.put("region", List.of("north"));
attributes.put("clearance_level", List.of("confidential"));
UserRepresentation user = keycloak.realm("my-company").users().get(userId).toRepresentation();
user.setAttributes(attributes);
keycloak.realm("my-company").users().get(userId).update(user); |
|
Затем нужно настроить Protocol Mapper, чтобы эти атрибуты попадали в токен:
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // Создание Protocol Mapper для добавления атрибута в токен
ProtocolMapperRepresentation mapper = new ProtocolMapperRepresentation();
mapper.setName("department-mapper");
mapper.setProtocol("openid-connect");
mapper.setProtocolMapper("oidc-usermodel-attribute-mapper");
// Настройка маппера
Map<String, String> config = new HashMap<>();
config.put("user.attribute", "department");
config.put("claim.name", "department");
config.put("jsonType.label", "String");
config.put("id.token.claim", "true");
config.put("access.token.claim", "true");
mapper.setConfig(config);
// Добавление маппера к клиенту
keycloak.realm("my-company").clients().get(clientId).getProtocolMappers().createMapper(mapper); |
|
После этого атрибуты будут доступны в JWT-токене, и Spring Security сможет их использовать для принятия решений о доступе.
Интеграция с внешними источниками данных
В крупных системах атрибуты часто хранятся во внешних системах — CRM, ERP, HR-системах. Я обычно решаю эту проблему двумя способами:
1. Синхронизация атрибутов — периодически подгружаем атрибуты из внешних систем в Keycloak.
2. Запрос атрибутов в реальном времени — в момент аутентификации обращаемся к внешним API.
Для первого подхода можно использовать User Storage SPI Keycloak:
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| public class ExternalAttributeProvider implements UserStorageProvider {
@Override
public UserModel getUserById(String id, RealmModel realm) {
UserModel user = delegate.getUserById(id, realm);
if (user != null) {
// Запрос к внешнему API
Map<String, String> externalAttrs = externalService.getUserAttributes(user.getUsername());
// Обновление атрибутов в Keycloak
for (Map.Entry<String, String> attr : externalAttrs.entrySet()) {
user.setAttribute(attr.getKey(), Collections.singletonList(attr.getValue()));
}
}
return user;
}
// Другие методы интерфейса...
} |
|
Для второго подхода лучше использовать Authentication SPI:
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| public class ExternalAttributeAuthenticator implements Authenticator {
@Override
public void authenticate(AuthenticationFlowContext context) {
AuthenticationSessionModel authSession = context.getAuthenticationSession();
UserModel user = context.getUser();
// Запрашиваем атрибуты в реальном времени
Map<String, String> attrs = externalService.getAttributes(user.getUsername());
// Добавляем их во временное хранилище сессии
for (Map.Entry<String, String> attr : attrs.entrySet()) {
authSession.setUserSessionNote("ext_" + attr.getKey(), attr.getValue());
}
// Продолжаем аутентификацию
context.success();
}
// Другие методы...
} |
|
Контекстно-зависимые политики доступа
Теперь самое интересное — как использовать эти атрибуты в Spring Security. Я обычно пишу кастомный AccessDecisionVoter или PermissionEvaluator.
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
| @Component
public class AttributeBasedPermissionEvaluator implements PermissionEvaluator {
@Override
public boolean hasPermission(Authentication auth, Object targetDomainObject, Object permission) {
Jwt jwt = (Jwt) auth.getPrincipal();
// Получаем атрибуты пользователя из JWT
String userDepartment = jwt.getClaimAsString("department");
String userRegion = jwt.getClaimAsString("region");
// Получаем атрибуты ресурса
if (targetDomainObject instanceof Document) {
Document doc = (Document) targetDomainObject;
// Проверка на основе атрибутов
if ("VIEW".equals(permission)) {
// Проверка департамента
if (!doc.getDepartment().equals(userDepartment)) {
return false;
}
// Проверка региона
if (!doc.getRegion().equals(userRegion)) {
return false;
}
// Проверка времени доступа для секретных документов
if ("SECRET".equals(doc.getClassification())) {
// Проверяем, рабочее ли время
LocalTime now = LocalTime.now();
return now.isAfter(LocalTime.of(9, 0)) &&
now.isBefore(LocalTime.of(18, 0));
}
return true;
}
}
return false;
}
@Override
public boolean hasPermission(Authentication auth, Serializable targetId,
String targetType, Object permission) {
// Реализация для случая, когда у нас есть только ID ресурса
// ...
return false;
}
} |
|
Потом этот evaluator используется в аннотациях на методах контроллера:
| Java | 1
2
3
4
5
| @GetMapping("/documents/{id}")
@PreAuthorize("hasPermission(#id, 'Document', 'VIEW')")
public ResponseEntity<Document> getDocument(@PathVariable("id") Long id) {
// Логика получения документа
} |
|
Скриптовые политики для особо извращенных случаев
Иногда логика доступа настолько сложна, что ее трудно выразить в коде. Тут на помощь приходят скриптовые политики. В Keycloak можно создавать JavaScript-правила, которые будут выполняться на сервере:
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| // Пример скриптовой политики в Keycloak
var context = $evaluation.getContext();
var attributes = context.getAttributes();
var resource = $evaluation.getResource();
// Получаем атрибуты пользователя, ресурса и среды
var userDepartment = attributes.getValue('department').asString(0);
var documentDepartment = resource.getAttribute('department').asString(0);
var currentTime = attributes.getValue('current_time').asString(0);
// Сложная бизнес-логика
if (userDepartment === documentDepartment) {
// Базовый доступ разрешен
if (resource.getAttribute('classification').asString(0) === 'TOP_SECRET') {
// Для совсекретных документов доп. проверки
var currentHour = parseInt(currentTime.split(':')[0]);
if (currentHour >= 9 && currentHour < 18) {
$evaluation.grant();
} else {
$evaluation.deny('Доступ к совершенно секретным документам только в рабочее время');
}
} else {
$evaluation.grant();
}
} else {
$evaluation.deny('Доступ только к документам своего отдела');
} |
|
Производительность ABAC: цена гибкости
Не буду скрывать: ABAC имеет свою цену. Вычисление множества атрибутов и применение сложных политик занимает время. На одном проекте я столкнулся с деградацией производительности на 120-150мс на запрос, когда мы перешли от RBAC к ABAC.
Оптимизация тут сводится к нескольким стратегиям:
1. Кеширование атрибутов — храним часто используемые атрибуты в Redis/Memcached,
2. Минимизация внешних запросов — включаем в JWT все атрибуты, которые нужны для большинства решений,
3. Асинхронная предзагрузка — загружаем атрибуты для вероятных следующих действий пользователя,
4. Комбинирование с RBAC — используем RBAC для грубой фильтрации и ABAC только для сложных случаев,
Гибридный подход: лучшее из двух миров
В реальных проектах я почти всегда использую гибридный подход RBAC + ABAC. Типичный сценарий:
1. Используем роли для базовой фильтрации: "Только пользователи с ролью doc-viewer могут видеть документы"
2. Применяем атрибутные политики для тонкой настройки: "Пользователь видит только документы своего департамента"
| Java | 1
2
3
4
5
6
7
8
9
| @GetMapping("/documents")
@PreAuthorize("hasRole('DOC_VIEWER') && hasPermission(returnObject, 'VIEW')")
public List<Document> getDocuments() {
// Получаем все документы
List<Document> allDocs = documentService.findAll();
// Permission Evaluator применит атрибутное фильтрование
return allDocs;
} |
|
Такой подход сочетает понятность RBAC с гибкостью ABAC.
Я как-то работал над системой электронного документооборота для правительственной организации, где требования безопасности были просто параноидальными. Мы начали с чистого RBAC и быстро уткнулись в стену: для моделирования всех вариантов доступа потребовалось бы создать более 1000 ролей! Перейдя на гибридный RBAC+ABAC, мы сократили число ролей до 30, а все тонкие настройки реализовали через атрибуты.
Но знаете, что самое сложное в реализации ABAC? Объяснить заказчику, как это работает! Я провел целую неделю, рисуя схемы и диаграммы, чтобы служба безопасности наконец поняла и приняла этот подход.
А теперь давайте перейдем к самому интересному — практической реализации всего этого добра в живом проекте на Spring Boot.
Практическая реализация

Я обещал практическую реализацию, и сейчас мы шаг за шагом развернём полноценное приложение с защищёнными эндпоинтами, настроим JWT-авторизацию, добавим рефреш токенов и прочие вкусности.
Подготовка почвы: запускаем Keycloak
Прежде чем мучить Spring Boot, поднимем сервер Keycloak. В 2025 году без Docker это делать просто неприлично:
| Java | 1
2
| docker run -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:latest start-dev |
|
После запуска заходим в админку (http://localhost:8080/admin/), создаём новый realm (назовём его "app-realm"), добавляем клиента (client-id: "spring-app") и несколько тестовых пользователей с ролями.
Помню, на одном проекте я тупил два часа, не понимая, почему токены не валидируются. Оказалось, забыл включить Protocol Mappers для ролей. Не повторяйте моих ошибок — проверьте, что маппер realm_access активирован для вашего клиента!
Настройка адаптера Keycloak для Spring Boot
Первым делом добавляем нужные зависимости. В pom.xml:
| XML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| <dependencies>
<!-- Spring Boot стартеры -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
</dependencies> |
|
Обратите внимание — я не использую устаревший keycloak-spring-boot-starter. С появлением нативной поддержки OAuth2 Resource Server в Spring Security он стал излишним. В 2025 году мы используем стандартные механизмы Spring для обработки JWT.
Основная конфигурация приложения
Теперь настроим application.yml:
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| server:
port: 8081 # Чтобы не конфликтовал с Keycloak
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: [url]http://localhost:8080/realms/app-realm[/url]
jwk-set-uri: [url]http://localhost:8080/realms/app-realm/protocol/openid-connect/certs[/url]
logging:
level:
org.springframework.security: DEBUG # Для отладки, в продакшене убрать! |
|
Ключевые параметры здесь:
issuer-uri — URL реалма Keycloak, который выпускает токены,
jwk-set-uri — URL с публичными ключами для проверки подписи токенов
SecurityConfig: мозг всей безопасности
Теперь создадим конфигурацию Spring Security:
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
| @Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable()) // В API с токенами CSRF не нужен
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/public/[B]").permitAll()
.requestMatchers("/api/user/[/B]").hasRole("USER")
.requestMatchers("/api/admin/[B]").hasRole("ADMIN")
.requestMatchers("/api/manager/[/B]").hasRole("MANAGER")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter())));
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
// Кастомный конвертер для извлечения ролей из токена
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null || !realmAccess.containsKey("roles")) {
return Collections.emptyList();
}
List<String> roles = (List<String>) realmAccess.get("roles");
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.collect(Collectors.toList());
});
return converter;
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:3000")); // Ваш фронтенд
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
} |
|
Самый хитрый момент здесь — конвертер JWT, который превращает роли из токена в авторитеты Spring Security. В Keycloak роли приходят в поле realm_access.roles, и нам нужно добавить префикс ROLE_, чтобы Spring Security их корректно распознал. Однажды целый день бился над проблемой, когда роли не работали — оказалось, я забыл сделать .toUpperCase(). Spring Security по умолчанию ожидает роли в верхнем регистре!
Создаем защищенные эндпоинты
Теперь напишем контроллеры для тестирования нашей защиты:
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| @RestController
@RequestMapping("/api")
public class DemoController {
@GetMapping("/public/hello")
public String publicEndpoint() {
return "Это публичный эндпоинт, доступен всем!";
}
@GetMapping("/user/profile")
public Map<String, Object> userProfile(@AuthenticationPrincipal Jwt jwt) {
return Map.of(
"sub", jwt.getSubject(),
"name", jwt.getClaimAsString("name"),
"email", jwt.getClaimAsString("email"),
"roles", jwt.getClaimAsMap("realm_access")
);
}
@GetMapping("/admin/settings")
public Map<String, String> adminSettings() {
return Map.of(
"setting1", "значение1",
"setting2", "значение2",
"adminOnly", "секретная информация"
);
}
@GetMapping("/manager/reports")
public List<String> managerReports() {
return List.of("Отчет за Q1", "Отчет за Q2", "Прогноз на год");
}
} |
|
Обратите внимание на параметр @AuthenticationPrincipal Jwt jwt — это позволяет нам получить доступ к данным из токена прямо в контроллере. Очень удобно для извлечения информации о пользователе!
Проверяем токены и claims
В сложных приложениях часто нужны более детальные проверки. Например, доступ к ресурсу только для пользователей определённого департамента. Добавим метод с кастомной проверкой:
| Java | 1
2
3
4
5
| @GetMapping("/documents/{id}")
@PreAuthorize("hasRole('USER') and @documentSecurityService.hasAccess(#id, authentication)")
public DocumentDto getDocument(@PathVariable Long id) {
return documentService.findById(id);
} |
|
И соответствующий сервис безопасности:
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| @Service
public class DocumentSecurityService {
public boolean hasAccess(Long documentId, Authentication authentication) {
Jwt jwt = (Jwt) authentication.getPrincipal();
// Проверяем атрибуты пользователя из токена
String userDepartment = jwt.getClaimAsString("department");
// Получаем документ и его атрибуты
Document doc = documentRepository.findById(documentId).orElse(null);
if (doc == null) return false;
// Проверяем соответствие департамента
return doc.getDepartment().equals(userDepartment);
}
} |
|
Это и есть ABAC в действии — решение о доступе принимается на основе атрибутов пользователя и ресурса.
Механизм refresh токенов
JWT-токены имеют ограниченный срок действия (обычно 5-15 минут). Для бесшовного продления сессии используются refresh токены. В Spring Boot это можно реализовать так:
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| @RestController
@RequestMapping("/auth")
public class AuthController {
@Value("${app.keycloak.client-id}")
private String clientId;
@Value("${app.keycloak.client-secret}")
private String clientSecret;
@Value("${app.keycloak.token-uri}")
private String tokenUri;
@PostMapping("/refresh")
public ResponseEntity<Map<String, String>> refreshToken(@RequestBody RefreshTokenRequest request) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("grant_type", "refresh_token");
map.add("client_id", clientId);
map.add("client_secret", clientSecret);
map.add("refresh_token", request.getRefreshToken());
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(map, headers);
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<Map> response = restTemplate.exchange(
tokenUri,
HttpMethod.POST,
entity,
Map.class
);
return ResponseEntity.ok(response.getBody());
}
} |
|
На клиенте (например, в React) можно реализовать автоматическое обновление токена при ошибке 401:
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| script
// Пример на React с axios
axios.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
// Если получили 401 и еще не пытались обновить токен
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// Обновляем токен
const response = await axios.post('/auth/refresh', {
refreshToken: localStorage.getItem('refresh_token')
});
// Сохраняем новые токены
localStorage.setItem('access_token', response.data.access_token);
localStorage.setItem('refresh_token', response.data.refresh_token);
// Повторяем запрос с новым токеном
originalRequest.headers['Authorization'] = 'Bearer ' + response.data.access_token;
return axios(originalRequest);
} catch (refreshError) {
// Если обновить не удалось - редиректим на логин
window.location = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
); |
|
Кастомизация процесса аутентификации и logout
Keycloak предоставляет гибкие возможности для кастомизации аутентификации. Например, можно добавить свои шаги в поток аутентификации. Однако это делается в админке Keycloak или через его API, а не на стороне Spring Boot.
Для logout процесса в SPA приложениях нужно:
1. Удалить токены на клиенте (из localStorage или cookies).
2. Вызвать endpoint logout в Keycloak.
| JavaScript | 1
2
3
4
5
6
7
8
9
10
11
| // Пример логаута на клиенте
function logout() {
// Удаляем токены
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
// Редирект на Keycloak logout
window.location.href =
'http://localhost:8080/realms/app-realm/protocol/openid-connect/logout' +
'?redirect_uri=' + encodeURIComponent(window.location.origin);
} |
|
Настройка CORS и обработка preflight запросов
Одна из самых частых головных болей при работе с разными доменами — настройка CORS (Cross-Origin Resource Sharing). В нашей конфигурации мы уже добавили базовую настройку, но давайте рассмотрим ее подробнее:
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| @Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// Разрешенные домены
config.setAllowedOrigins(Arrays.asList(
"http://localhost:3000",
"https://your-production-domain.com"
));
// Разрешенные методы
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
// Разрешенные заголовки
config.setAllowedHeaders(Arrays.asList(
"Authorization",
"Content-Type",
"X-Requested-With"
));
// Разрешаем отправлять куки и заголовки авторизации
config.setAllowCredentials(true);
// Кешируем предполетный ответ на 1 час
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
} |
|
Особое внимание обратите на setMaxAge — этот параметр указывает браузеру, как долго можно кешировать результат preflight-запроса (OPTIONS). Установка адекватного значения может значительно снизить количество лишних запросов.
Сколько раз я слышал: "У меня API работает в Postman, но не работает из браузера!" — и почти всегда проблема была в CORS. Причём в продакшене это может проявляться по-разному из-за кеширования и сетевых особенностей, так что тестируйте настройки CORS тщательно. В моей практике был случай, когда настройки CORS "магически" работали на одних машинах и не работали на других. Оказалось, некоторые браузеры интерпретируют спецификацию чуть иначе, особенно в отношении wildcard-значений (*). Лучше всего явно перечислять все домены и заголовки.
Вот вам бонус — удобный контроллер для тестирования CORS:
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| @RestController
@RequestMapping("/api/test")
public class CorsTestController {
@GetMapping("/cors")
public Map<String, String> testCors(HttpServletRequest request) {
return Map.of(
"origin", Optional.ofNullable(request.getHeader("Origin")).orElse("не указан"),
"method", request.getMethod(),
"status", "OK"
);
}
@PostMapping("/cors")
public Map<String, String> testCorsPost(@RequestBody(required = false) Map<String, Object> body) {
return Map.of(
"received", body != null ? body.toString() : "пустой запрос",
"status", "OK"
);
}
} |
|
Этот контроллер поможет быстро проверить, правильно ли работают ваши настройки CORS.
Осваивая практическую сторону интеграции Spring Boot с Keycloak, вы закладываете прочный фундамент безопасности. Однако в реальных проектах неизбежно столкнетесь с подводными камнями — давайте взглянем на них и на то, как их обойти.
Подводные камни и решения из практики
Производительность при большом количестве пользователей
Помню случай с финтех-стартапом, который выстрелил, и за месяц база пользователей выросла с 10 тысяч до миллиона. Keycloak внезапно стал узким местом всей системы. Приложение тормозило, а количество ошибок росло как снежный ком.
Что мы сделали:
1. Оптимизация базы данных — перевели Keycloak с MySQL на PostgreSQL и настроили правильную индексацию. Это дало прирост в 40% производительности.
2. Кастомная имплементация UserStorageProvider — вместо хранения всех пользователей в БД Keycloak мы создали слой, который подгружал информацию о пользователе из основного хранилища по требованию.
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| @Override
public UserModel getUserByUsername(String username, RealmModel realm) {
// Проверяем локальный кэш
if (cache.containsKey(username)) {
return cache.get(username);
}
// Если нет в кэше, запрашиваем из основного хранилища
UserEntity entity = userRepository.findByUsername(username);
if (entity == null) return null;
// Создаем модель пользователя и кэшируем
UserModel user = createUserModel(entity, realm);
cache.put(username, user);
return user;
} |
|
3. Агрессивное кеширование — настроили двухуровневый кэш с использованием Infinispan (встроенного в Keycloak) и Redis для распределенного кэширования между экземплярами.
Масштабирование Keycloak в высоконагруженных системах
В одном проекте для телеком-оператора нам потребовалось обрабатывать 500+ запросов аутентификации в секунду. Стандартная конфигурация Keycloak с этим не справлялась. Наше решение включало:
1. Кластеризация — подняли кластер из 6 экземпляров Keycloak за балансировщиком нагрузки (nginx).
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| # Фрагмент docker-compose для кластера Keycloak
version: '3'
services:
keycloak1:
image: quay.io/keycloak/keycloak:latest
environment:
KC_HOSTNAME: auth.example.com
KC_CACHE: ispn
KC_CACHE_STACK: kubernetes
# Кластерные настройки
KC_CACHE_OWNERS: "2"
KC_CACHE_REMOTE_TIMOUT: "10000"
# ... другие настройки
keycloak2:
# Аналогично, но с другими портами
# ... еще экземпляры
loadbalancer:
image: nginx:latest
ports:
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf |
|
2. Выделение отдельного реалма для массовой аутентификации — разные типы клиентов аутентифицировались через разные реалмы, что позволило оптимизировать каждый под свои задачи.
3. Асинхронные обработчики событий — все неблокирующие операции (логгирование, аудит, обновление статистики) были вынесены в асинхронные обработчики с использованием Apache Kafka.
| Java | 1
2
3
4
5
| @Override
public void onEvent(AdminEvent event, boolean includeRepresentation) {
// Вместо синхронной обработки - отправляем в очередь
kafkaTemplate.send("keycloak-audit", new KeycloakAuditEvent(event));
} |
|
4. Preload кэша — при старте Keycloak предзагружал самые популярные конфигурации и пользовательские данные в кэш.
Отладка проблем авторизации
О, эта головная боль! Сколько раз я слышал: "У меня токен есть, а доступа нет". Вот что помогает в таких ситуациях:
1. JWT-дебаггер — держите под рукой инструмент для декодирования JWT-токенов. Иногда проблема может быть банальной — неправильный формат роли или отсутствующий claim.
2. Включение Debug-логгирования — в application.yml установите:
| YAML | 1
2
3
4
| logging:
level:
org.springframework.security: DEBUG
org.keycloak: DEBUG |
|
3. Тестовый эндпоинт с дампом авторизации — я всегда добавляю такой для быстрой диагностики:
| Java | 1
2
3
4
5
6
7
8
9
10
11
| @GetMapping("/debug/auth-info")
public Map<String, Object> debugAuthInfo(@AuthenticationPrincipal Jwt jwt) {
Map<String, Object> info = new HashMap<>();
info.put("token_claims", jwt.getClaims());
info.put("authorities", SecurityContextHolder.getContext()
.getAuthentication().getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
info.put("request_url", request.getRequestURL().toString());
return info;
} |
|
4. Проксирование с Wireshark/Burp — иногда проблема может быть в транспорте или заголовках, а не в самой авторизации.
Миграция с устаревших систем
Мне часто приходилось заменять самописные системы авторизации на Keycloak. Самый большой вызов — как мигрировать пользователей без сброса их паролей?
Решение: Кастомный PasswordHashProvider. Keycloak позволяет подключить свою реализацию хеширования паролей, что дает возможность проверять пароли в старом формате:
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| public class LegacyPasswordHashProvider implements PasswordHashProvider {
@Override
public boolean verify(String password, PasswordHashInput hashInput) {
String storedHash = hashInput.getHashedPassword();
// Определяем, старый ли это формат
if (isLegacyHash(storedHash)) {
// Проверяем по старому алгоритму
return legacyHasher.verify(password, storedHash);
}
// Иначе используем стандартную проверку
return defaultProvider.verify(password, hashInput);
}
@Override
public String encode(String password, int iterations) {
// Всегда кодируем новые пароли в новом формате
return defaultProvider.encode(password, iterations);
}
// Другие методы интерфейса...
} |
|
Кроме того, мы использовали пошаговую миграцию:
1. Сначала Keycloak работает параллельно со старой системой (read-only)
2. Затем добавляем запись в обе системы при регистрации/изменении
3. Наконец, полностью переходим на Keycloak
Стратегии кеширования токенов и метаданных
Умное кеширование — ключ к производительности. Вот наши любимые практики:
1. Кеширование токенов на клиенте — храним access/refresh токены в локальном хранилище, используя библиотеки типа oidc-client или react-oidc-context.
2. Backend for Frontend (BFF) с кешированием — промежуточный слой между фронтендом и сервисами, который кеширует токены и управляет авторизацией:
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| @Service
public class TokenCacheService {
private final Cache<String, UserTokenSet> tokenCache;
// Конструктор, инициализирующий кеш
public UserTokenSet getTokens(String userId) {
return tokenCache.get(userId, key -> {
// Если нет в кеше - запрашиваем из Keycloak
return keycloakClient.getUserTokens(userId);
});
}
// Методы для обновления и инвалидации кеша
} |
|
3. Продвинутое управление сессиями — используем Redis для централизованного хранения сессий и быстрого отзыва токенов при необходимости.
Один из самых болезненных уроков я получил, когда не настроил тайм-ауты сессий правильно. Ситуация: администратор отозвал права у пользователя в Keycloak, но из-за долгого TTL кеша пользователь еще 15 минут имел доступ к системе. С тех пор мы всегда реализуем механизм принудительной инвалидации кешей при изменении прав доступа.
Демо-апп
Давайте перейдем от теории к практике и соберем всё воедино — я подготовил полноценное демо-приложение, которое вы можете использовать как отправную точку для своих проектов. Это не "hello world", а реальный пример микросервиса с документами, где реализованы все те крутые штуки, о которых мы говорили выше.
Как-то меня пригласили преподавать на курсах по Spring Security, и я потратил неделю, собирая идеальный пример. Студенты оценили — один из них даже сказал: "Я впервые понял, как всё это работает вместе". Для меня это был аргумент сохранить и доработать проект.
Структура нашего супер-проекта
Проект организован как стандартное Spring Boot приложение с модульной структурой:
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| spring-boot-keycloak-demo
├── src
│ ├── main
│ │ ├── java
│ │ │ └── com.securitydemo
│ │ │ ├── config # Конфигурации Spring и Keycloak
│ │ │ ├── controller # REST-контроллеры
│ │ │ ├── dto # Data Transfer Objects
│ │ │ ├── model # Модели данных
│ │ │ ├── repository # Репозитории для доступа к данным
│ │ │ ├── security # Кастомные компоненты безопасности
│ │ │ ├── service # Бизнес-логика
│ │ │ └── Application.java # Точка входа
│ │ └── resources
│ │ ├── application.yml # Основная конфигурация
│ │ └── data.sql # Скрипт для инициализации БД
│ └── test # Тесты
└── pom.xml # Зависимости Maven |
|
Полный код основных компонентов
Давайте начнем с конфигурации Maven (pom.xml):
| XML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
| <?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<groupId>com.securitydemo</groupId>
<artifactId>spring-boot-keycloak-demo</artifactId>
<version>1.0.0</version>
<name>Spring Boot Keycloak Demo</name>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Утилиты и вспомогательные библиотеки -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- База данных -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Тесты -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project> |
|
Теперь основная конфигурация приложения (application.yml):
| YAML | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| server:
port: 8081
servlet:
context-path: /api
spring:
application:
name: document-service
# Конфигурация базы данных (используем H2 для демо)
datasource:
url: jdbc:h2:mem:documentdb
username: sa
password:
driver-class-name: org.h2.Driver
h2:
console:
enabled: true
path: /h2-console
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
format_sql: true
# Настройки для OAuth2 и Keycloak
security:
oauth2:
resourceserver:
jwt:
issuer-uri: [url]http://localhost:8080/realms/document-realm[/url]
jwk-set-uri: [url]http://localhost:8080/realms/document-realm/protocol/openid-connect/certs[/url]
# Наши кастомные настройки
app:
keycloak:
client-id: document-app
client-secret: your-client-secret-here
token-uri: [url]http://localhost:8080/realms/document-realm/protocol/openid-connect/token[/url]
# Логирование для отладки
logging:
level:
org.springframework.security: INFO
com.securitydemo: DEBUG |
|
Теперь создадим точку входа в приложение (Application.java):
| Java | 1
2
3
4
5
6
7
8
9
10
11
| package com.securitydemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
} |
|
Самая интересная часть — конфигурация безопасности (SecurityConfig.java):
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
| package com.securitydemo.config;
import com.securitydemo.security.JwtAuthenticationConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationConverter jwtAuthenticationConverter;
public SecurityConfig(JwtAuthenticationConverter jwtAuthenticationConverter) {
this.jwtAuthenticationConverter = jwtAuthenticationConverter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/public/**", "/h2-console/**").permitAll()
.requestMatchers(HttpMethod.GET, "/documents").hasAnyRole("USER", "ADMIN")
.requestMatchers(HttpMethod.POST, "/documents").hasAnyRole("EDITOR", "ADMIN")
.requestMatchers(HttpMethod.PUT, "/documents/**").hasAnyRole("EDITOR", "ADMIN")
.requestMatchers(HttpMethod.DELETE, "/documents/[B]").hasRole("ADMIN")
.requestMatchers("/admin/[/B]").hasRole("ADMIN")
.requestMatchers("/debug/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter)));
// Для доступа к консоли H2 (не использовать в продакшене!)
http.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.disable()));
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:3000"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "X-Requested-With"));
configuration.setExposedHeaders(List.of("Content-Disposition"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
} |
|
Конвертер токенов JWT (JwtAuthenticationConverter.java):
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
| package com.securitydemo.security;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.stream.Collectors;
@Component
public class JwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
Collection<SimpleGrantedAuthority> authorities = extractAuthorities(jwt);
return new JwtAuthenticationToken(jwt, authorities);
}
private Collection<SimpleGrantedAuthority> extractAuthorities(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null || !realmAccess.containsKey("roles")) {
return Collections.emptyList();
}
List<String> roles = (List<String>) realmAccess.get("roles");
Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();
// Добавляем стандартные роли с префиксом ROLE_
roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.forEach(authorities::add);
// Извлекаем дополнительные атрибуты для ABAC
Map<String, Object> resourceAccess = jwt.getClaimAsMap("resource_access");
if (resourceAccess != null && resourceAccess.containsKey("document-app")) {
Map<String, Object> clientAccess = (Map<String, Object>) resourceAccess.get("document-app");
if (clientAccess != null && clientAccess.containsKey("roles")) {
List<String> clientRoles = (List<String>) clientAccess.get("roles");
clientRoles.stream()
.map(role -> new SimpleGrantedAuthority("CLIENT_" + role.toUpperCase()))
.forEach(authorities::add);
}
}
// Добавляем атрибуты для ABAC
String department = jwt.getClaimAsString("department");
if (department != null) {
authorities.add(new SimpleGrantedAuthority("DEPARTMENT_" + department.toUpperCase()));
}
return authorities;
}
} |
|
Модель документа (Document.java):
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
| package com.securitydemo.model;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "documents")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Document {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(length = 2000)
private String content;
@Column(nullable = false)
private String department;
@Column(nullable = false)
private String classification; // PUBLIC, CONFIDENTIAL, SECRET
@Column(nullable = false)
private String createdBy;
@Column(nullable = false)
private LocalDateTime createdAt;
@Column
private LocalDateTime modifiedAt;
@Column
private String modifiedBy;
} |
|
DTO для документов (DocumentDto.java):
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| package com.securitydemo.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DocumentDto {
private Long id;
private String title;
private String content;
private String department;
private String classification;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime modifiedAt;
private String modifiedBy;
} |
|
Репозиторий для работы с документами (DocumentRepository.java):
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| package com.securitydemo.repository;
import com.securitydemo.model.Document;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface DocumentRepository extends JpaRepository<Document, Long> {
List<Document> findByDepartment(String department);
List<Document> findByClassification(String classification);
List<Document> findByDepartmentAndClassification(String department, String classification);
@Query("SELECT d FROM Document d WHERE d.department = :department AND " +
"(:classification = 'PUBLIC' OR d.classification = :classification)")
List<Document> findByDepartmentAndAccessibleClassification(String department, String classification);
} |
|
Сервис безопасности, реализующий ABAC логику (DocumentSecurityService.java):
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
| package com.securitydemo.security;
import com.securitydemo.model.Document;
import com.securitydemo.repository.DocumentRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
@Service
@Slf4j
public class DocumentSecurityService {
private final DocumentRepository documentRepository;
public DocumentSecurityService(DocumentRepository documentRepository) {
this.documentRepository = documentRepository;
}
public boolean hasReadAccess(Long documentId, Authentication authentication) {
Document doc = documentRepository.findById(documentId).orElse(null);
if (doc == null) return false;
Jwt jwt = (Jwt) authentication.getPrincipal();
return hasReadAccess(doc, jwt);
}
public boolean hasReadAccess(Document doc, Jwt jwt) {
// Проверка на админа - может читать любой документ
List<String> roles = getRolesFromToken(jwt);
if (roles.contains("ADMIN")) return true;
// Получаем атрибуты пользователя
String userDepartment = jwt.getClaimAsString("department");
String userClearance = jwt.getClaimAsString("clearance_level");
// Департамент должен совпадать
if (!doc.getDepartment().equals(userDepartment)) {
log.debug("Доступ запрещен: департаменты не совпадают");
return false;
}
// Проверка уровня доступа
if ("PUBLIC".equals(doc.getClassification())) {
// Публичные документы доступны всем сотрудникам департамента
return true;
} else if ("CONFIDENTIAL".equals(doc.getClassification())) {
// Конфиденциальные документы требуют соответствующего уровня доступа
if (userClearance == null || !"CONFIDENTIAL".equals(userClearance) && !"SECRET".equals(userClearance)) {
log.debug("Доступ запрещен: недостаточный уровень доступа для конфиденциального документа");
return false;
}
} else if ("SECRET".equals(doc.getClassification())) {
// Секретные документы требуют уровня SECRET и только в рабочее время
if (userClearance == null || !"SECRET".equals(userClearance)) {
log.debug("Доступ запрещен: недостаточный уровень доступа для секретного документа");
return false;
}
// Проверка рабочего времени (9:00 - 18:00)
LocalTime now = LocalTime.now();
if (now.isBefore(LocalTime.of(9, 0)) || now.isAfter(LocalTime.of(18, 0))) {
log.debug("Доступ запрещен: попытка доступа к секретному документу вне рабочего времени");
return false;
}
}
return true;
}
public boolean hasWriteAccess(Long documentId, Authentication authentication) {
Document doc = documentRepository.findById(documentId).orElse(null);
if (doc == null) return false;
Jwt jwt = (Jwt) authentication.getPrincipal();
// Проверка на админа и редактора
List<String> roles = getRolesFromToken(jwt);
if (roles.contains("ADMIN")) return true;
if (!roles.contains("EDITOR")) return false;
// Редакторы могут изменять только документы своего департамента
String userDepartment = jwt.getClaimAsString("department");
return doc.getDepartment().equals(userDepartment);
}
public boolean hasDeleteAccess(Long documentId, Authentication authentication) {
// Только админы могут удалять документы
Jwt jwt = (Jwt) authentication.getPrincipal();
List<String> roles = getRolesFromToken(jwt);
return roles.contains("ADMIN");
}
private List<String> getRolesFromToken(Jwt jwt) {
try {
return (List<String>) ((Map<String, Object>) jwt.getClaims()
.get("realm_access")).get("roles");
} catch (Exception e) {
log.error("Ошибка при извлечении ролей: {}", e.getMessage());
return List.of();
}
}
} |
|
Бизнес-сервис для работы с документами (DocumentService.java):
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
| package com.securitydemo.service;
import com.securitydemo.dto.DocumentDto;
import com.securitydemo.model.Document;
import com.securitydemo.repository.DocumentRepository;
import com.securitydemo.security.DocumentSecurityService;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class DocumentService {
private final DocumentRepository documentRepository;
private final DocumentSecurityService securityService;
public DocumentService(DocumentRepository documentRepository,
DocumentSecurityService securityService) {
this.documentRepository = documentRepository;
this.securityService = securityService;
}
public List<DocumentDto> getAllDocuments() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
Jwt jwt = (Jwt) auth.getPrincipal();
// Получаем департамент пользователя
String userDepartment = jwt.getClaimAsString("department");
// Получаем все документы департамента
List<Document> documents = documentRepository.findByDepartment(userDepartment);
// Фильтруем по ABAC-правилам
return documents.stream()
.filter(doc -> securityService.hasReadAccess(doc, jwt))
.map(this::convertToDto)
.collect(Collectors.toList());
}
public DocumentDto getDocumentById(Long id) {
Document document = documentRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Документ не найден"));
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (!securityService.hasReadAccess(id, auth)) {
throw new RuntimeException("Доступ запрещен");
}
return convertToDto(document);
}
@Transactional
public DocumentDto createDocument(DocumentDto documentDto) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
Jwt jwt = (Jwt) auth.getPrincipal();
Document document = new Document();
document.setTitle(documentDto.getTitle());
document.setContent(documentDto.getContent());
// Устанавливаем департамент пользователя
String userDepartment = jwt.getClaimAsString("department");
document.setDepartment(userDepartment);
// Устанавливаем классификацию
document.setClassification(documentDto.getClassification());
// Метаданные
document.setCreatedBy(jwt.getSubject());
document.setCreatedAt(LocalDateTime.now());
Document saved = documentRepository.save(document);
return convertToDto(saved);
}
@Transactional
public DocumentDto updateDocument(Long id, DocumentDto documentDto) {
Document document = documentRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Документ не найден"));
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (!securityService.hasWriteAccess(id, auth)) {
throw new RuntimeException("Доступ запрещен");
}
Jwt jwt = (Jwt) auth.getPrincipal();
document.setTitle(documentDto.getTitle());
document.setContent(documentDto.getContent());
// Нельзя менять департамент документа
document.setClassification(documentDto.getClassification());
document.setModifiedBy(jwt.getSubject());
document.setModifiedAt(LocalDateTime.now());
Document updated = documentRepository.save(document);
return convertToDto(updated);
}
@Transactional
public void deleteDocument(Long id) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (!securityService.hasDeleteAccess(id, auth)) {
throw new RuntimeException("Доступ запрещен");
}
documentRepository.deleteById(id);
}
private DocumentDto convertToDto(Document document) {
DocumentDto dto = new DocumentDto();
dto.setId(document.getId());
dto.setTitle(document.getTitle());
dto.setContent(document.getContent());
dto.setDepartment(document.getDepartment());
dto.setClassification(document.getClassification());
dto.setCreatedBy(document.getCreatedBy());
dto.setCreatedAt(document.getCreatedAt());
dto.setModifiedAt(document.getModifiedAt());
dto.setModifiedBy(document.getModifiedBy());
return dto;
}
} |
|
Контроллер для работы с документами (DocumentController.java):
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| package com.securitydemo.controller;
import com.securitydemo.dto.DocumentDto;
import com.securitydemo.service.DocumentService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/documents")
public class DocumentController {
private final DocumentService documentService;
public DocumentController(DocumentService documentService) {
this.documentService = documentService;
}
@GetMapping
public ResponseEntity<List<DocumentDto>> getAllDocuments() {
return ResponseEntity.ok(documentService.getAllDocuments());
}
@GetMapping("/{id}")
public ResponseEntity<DocumentDto> getDocumentById(@PathVariable Long id) {
return ResponseEntity.ok(documentService.getDocumentById(id));
}
@PostMapping
@PreAuthorize("hasAnyRole('EDITOR', 'ADMIN')")
public ResponseEntity<DocumentDto> createDocument(@RequestBody DocumentDto documentDto) {
DocumentDto created = documentService.createDocument(documentDto);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@PutMapping("/{id}")
@PreAuthorize("hasAnyRole('EDITOR', 'ADMIN')")
public ResponseEntity<DocumentDto> updateDocument(@PathVariable Long id,
@RequestBody DocumentDto documentDto) {
return ResponseEntity.ok(documentService.updateDocument(id, documentDto));
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<Void> deleteDocument(@PathVariable Long id) {
documentService.deleteDocument(id);
return ResponseEntity.noContent().build();
}
} |
|
Контроллер для отладки (DebugController.java):
| Java | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| package com.securitydemo.controller;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/debug")
public class DebugController {
@GetMapping("/auth-info")
public Map<String, Object> getAuthInfo() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
Jwt jwt = (Jwt) auth.getPrincipal();
Map<String, Object> info = new HashMap<>();
info.put("token_claims", jwt.getClaims());
info.put("authorities", auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
return info;
}
} |
|
И, наконец, скрипт инициализации базы данных (data.sql):
| SQL | 1
2
3
4
5
6
7
8
9
| -- Создаем демо-документы
INSERT INTO documents (title, content, department, classification, created_by, created_at)
VALUES ('Ежемесячный отчет', 'Содержимое отчета за январь', 'SALES', 'PUBLIC', 'system', CURRENT_TIMESTAMP());
INSERT INTO documents (title, content, department, classification, created_by, created_at)
VALUES ('Финансовый план', 'Финансовый план на квартал', 'FINANCE', 'CONFIDENTIAL', 'system', CURRENT_TIMESTAMP());
INSERT INTO documents (title, content, department, classification, created_by, created_at)
VALUES ('Стратегия развития', 'Секретная стратегия развития компании', 'MANAGEMENT', 'SECRET', 'system', CURRENT_TIMESTAMP()); |
|
Настройка Keycloak для демо-приложения
Для полноценной работы приложения нужно настроить Keycloak. Вот шаги, которые необходимо выполнить:
1. Создать реалм `document-realm`
2. Создать клиент `document-app` с настройками:
- Client Protocol: openid-connect
- Access Type: confidential
- Standard Flow Enabled: ON
- Direct Access Grants: ON
- Service Accounts Enabled: ON
- Valid Redirect URIs: http://localhost:3000/*
3. Создать роли:
- user - базовый пользователь
- editor - может редактировать документы
- admin - полный доступ
4. Создать группы для департаментов:
- sales - отдел продаж
- finance - финансовый отдел
- management - руководство
5. Создать пользователей с разными ролями и атрибутами:
- Пользователь 1:
- Username: user1
- Password: password
- Roles: user
- Department: sales
- Clearance: public
- Пользователь 2:
- Username: editor1
- Password: password
- Roles: user, editor
- Department: finance
- Clearance: confidential
- Пользователь 3:
- Username: admin1
- Password: password
- Roles: user, editor, admin
- Department: management
- Clearance: secret
6. Настроить Protocol Mappers для клиента document-app, чтобы включить атрибуты в токен:
- Mapper для department
- Mapper для clearance_level
Запуск и тестирование приложения
Чтобы запустить и протестировать приложение, следуйте этим шагам:
1. Запустите Keycloak:
| Java | 1
2
| docker run -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:latest start-dev |
|
2. Настройте Keycloak как описано выше
3. Запустите Spring Boot приложение:
4. Получите токен для тестирования через cURL:
| Java | 1
2
3
4
5
6
7
| curl -X POST http://localhost:8080/realms/document-realm/protocol/openid-connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password" \
-d "client_id=document-app" \
-d "client_secret=your-client-secret-here" \
-d "username=user1" \
-d "password=password" |
|
5. Используйте полученный токен для вызова API:
| Java | 1
2
| curl -X GET http://localhost:8081/api/documents \
-H "Authorization: Bearer YOUR_TOKEN_HERE" |
|
Для полного тестирования RBAC и ABAC можно поэкспериментировать с разными пользователями и запросами. Вот несколько сценариев:
User1 (sales, public) может видеть только публичные документы отдела продаж
Editor1 (finance, confidential) может видеть публичные и конфиденциальные документы финансового отдела и редактировать их
Admin1 (management, secret) имеет полный доступ ко всем документам руководства, включая секретные (только в рабочее время)
Заключение и потенциальные улучшения
Приведенное демо-приложение демонстрирует ключевые принципы интеграции Spring Boot с Keycloak и реализации RBAC+ABAC в реальном проекте. Конечно, это только отправная точка, и в реальной системе вы, вероятно, захотите добавить:
Кеширование токенов для улучшения производительности
Обработку исключений для более элегантной обработки ошибок безопасности
Аудит действий пользователей в системе
Интеграцию с внешними системами для получения дополнительных атрибутов
UI-интерфейс для удобного взаимодействия с системой
Помню, как одна команда взяла этот пример за основу, расширила его для поддержки мультитенантности, и за три месяца развернула полноценную документную систему для холдинга с 15 дочерними компаниями. Keycloak управлял доступом для 2000+ пользователей, а Spring Boot надежно защищал миллионы документов с разными уровнями секретности.
Надеюсь, этот практический пример поможет вам лучше понять, как использовать мощь Spring Boot и Keycloak для построения безопасных приложений. И помните — хорошая безопасность не та, которую видно, а та, которая работает незаметно для пользователей, но непреодолимо для злоумышленников.
Spring Boot или Spring MVC? Добрый день форумчане. Прошу совета у опытных коллег знающих и работающих с фреймворком Spring.... Keycloak сессия юзера Кто нибудь сталкивался с проблемой получения данных последнего сеанса юзера? А если быть конкретным... KeyCloak SSO Хочу сделать приложение и в качестве Auth провайдера использовать KeyCloak. Кейклок мне нужен... Keycloak (docker) в Azure App Service + mysql Добрый день.
Помогите пожалуйста советом или ссылками.
Если сервис keycloak (докер образ),... Java spring boot настройка статического контента css и js Доброго времени суток.
Второй день как приступил к изучению спринг. Использую относительно новую... Spring Boot - работа с Mysql Я новичок в Spring'е, прошу камнями не забрасывать, возможно вопросы покажутся простыми... но... Eclipse maven spring-boot-starter-security Здравствуйте. Есть проект на спринг-буте. Вот pom:
<?xml version="1.0" encoding="UTF-8"?>... Spring Boot + Security + Data При поднятии сервера приложения в консоле выводит сообщение
19-Apr-2016 16:20:13.584 SEVERE... Spring Boot exception handling (не могу обратиться к странице) Spring Boot exception handling
Подскажите почему возникает эта ошибка? не могу обратиться к... Как использовать netflix eureka и feign без spring-boot? Добрый день. Я пытаюсь перейти на микросервисную архитектуру. У меня есть сервер eureka и сервисы... Groovy для инициализации java beans в spring boot Добрый день. Вообщем вопрос сводится в целом к тому, как получить возможность горячей подмены бинов... Spring boot multitenancy Здраствуйте, помогите пожалуйста внедрить этот проект в мой spring boot проект. Мне необхидимо...
|