Продолжаю тему моего коллеги о Keycloak.
Кому не нужна вода, а просто пример кода, прыгайте сразу сюда.
Keycloak довольно часто используется в качестве решения для управления идентификацией и доступом для современных приложений в рамках enterprise приложений.
Keycloak написан на языке Java, и создатели изначально заложили очень удобную возможность расширять функционал готового решения так называемыми аддонами или официально: extensions.
Расширение представляет собой обычный проект на Java, состоящий из классов, расширяющих дефолтные классы/интерфейсы Keycloak с необходим дополнительным функционалом. Причём расширить можно функционал чуть ли не любого класса Keycloak и для любых целей: от минимального изменения текста сообщения о некорректном вводе пользователем пароля, до привязки Discord'а, как Identity provider'а.
В данной статье речь пойдёт о расширении дефолтного слушателя событий в Keycloak.
Краткая предыстория: была поставлена задача отслеживания события сброса пароля у админа для логирования события и актуализации данных об этом админе в системе.
Исходный код
Необходимо создать обычный Java проект и подрубить несколько библиотек. Для удобства был использован сборщик Maven. Необходимые библиотеки представлены в pom.xml файле ниже:
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>event-listener-keycloak-extension</artifactId>
<parent>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-parent</artifactId>
<version>12.0.4</version>
</parent>
<properties>
<keycloak.version>12.0.4</keycloak.version>
<lombok.version>1.18.20</lombok.version>
</properties>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-saml-core-public</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jboss.logging</groupId>
<artifactId>jboss-logging</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jboss.spec.javax.ws.rs</groupId>
<artifactId>jboss-jaxrs-api_2.1_spec</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>event-listener-keycloak-extension</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>11</source>
<target>11</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
Из необязательных зависимостей здесь только Lombok. В моём проекте он нужен для удобного логирования событий в консоли и парочки конструкторов.
Теперь необходимо создать реализацию необходимых нам интерфейсов, а именно двух:
EventListenerProvider
. Дефолтный интерфейс провайдера для перехвата всех событий в системе. Реализация будет содержать саму логику нашего расширения.EventListenerProviderFactory
. Интерфейс фабрики для инициализации экземпляров провайдераEventListenerProvider
. При каждом новом событии в системе фабрика создаёт новый экземпляр провайдераEventListenerProvider
, и как только провайдер выполнит свою работу - удаляется из памяти. Сама же фабрика создаётся одна и работает на протяжении всего жизненного цикла Keycloak.
EventListenerProvider
Создадим реализацию EventListenerProvider
:
@Slf4j
@NoArgsConstructor
public class CustomEventListenerProvider implements EventListenerProvider {
@Override
public void onEvent(Event event) {
log.info("Caught event {}", EventUtils.toString(event));
}
@Override
public void onEvent(AdminEvent adminEvent, boolean b) {
log.info("Caught admin event {}", EventUtils.toString(adminEvent));
}
@Override
public void close() {
}
}
У данного интерфейса необходимо определить три метода:
onEvent
метод, перехватывающий обычные события в системе, такие как событие неправильного ввода пароля, удачной авторизации, логаута. В аргументе приходит сам экземпляр события со всей необходимой информацией: тип события, id пользователя и сессии, IP пользователя и т. д.onAdminEvent
перехватывает "админские" события, например: событие сброса пароля пользователя через админскую консоль Keycloak.close
своего рода деструктор, вызывается при удалении текущего провайдера.
Перевод объектов событий в текстовый вид я решил вынести в отдельный класс EventUtils
, который представлен ниже.
EventListenerProviderFactory
Второй и последний обязательный необходимый нам класс имплементирует EventListenerProviderFactory
:
public class CustomEventListenerProviderFactory implements EventListenerProviderFactory {
private static final String LISTENER_ID = "event-listener-extension";
@Override
public EventListenerProvider create(KeycloakSession session) {
return new CustomEventListenerProvider();
}
@Override
public void init(Config.Scope scope) {
}
@Override
public void postInit(KeycloakSessionFactory keycloakSessionFactory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return LISTENER_ID;
}
}
Тут уже методов побольше, посмотрим, за что они отвечают:
create
будет возвращать наш кастомный провайдерCustomEventListenerProvider
. Вызывается при каждом новом событии в системе. Сама же фабрикаCustomEventListenerProviderFactory
создаётся один раз на протяжении работы Keycloak.init
срабатывает только один раз при первом создании фабрики.postInit
вызывается один раз после инициализации всех фабрик провайдеров в системе.close
выполняется при завершении работы Keycloak сервера.getId
устанавливает название нашего расширения при создании фабрики.
Это все необходимые классы. Добавим ещё один класс, вспомогательный, только для того, чтобы отобразить Event
и AdminEvent
в текстовом виде со всеми их полями в консоли:
@UtilityClass
public class EventUtils {
public static String toString(AdminEvent adminEvent) {
StringBuilder sb = new StringBuilder();
sb.append("operationType=").append(adminEvent.getOperationType());
sb.append(", realmId=").append(adminEvent.getAuthDetails().getRealmId());
sb.append(", clientId=").append(adminEvent.getAuthDetails().getClientId());
sb.append(", userId=").append(adminEvent.getAuthDetails().getUserId());
sb.append(", ipAddress=").append(adminEvent.getAuthDetails().getIpAddress());
sb.append(", resourcePath=").append(adminEvent.getResourcePath());
if (adminEvent.getError() != null) {
sb.append(", error=").append(adminEvent.getError());
}
return sb.toString();
}
public static String toString(Event event) {
StringBuilder sb = new StringBuilder();
sb.append("type=").append(event.getType());
sb.append(", realmId=").append(event.getRealmId());
sb.append(", clientId=").append(event.getClientId());
sb.append(", userId=").append(event.getUserId());
sb.append(", ipAddress=").append(event.getIpAddress());
if (event.getError() != null) {
sb.append(", error=").append(event.getError());
}
if (event.getDetails() != null) {
for (Map.Entry<String, String> e : event.getDetails().entrySet()) {
sb.append(", ").append(e.getKey());
if (e.getValue() == null || e.getValue().indexOf(' ') == -1) {
sb.append("=").append(e.getValue());
} else {
sb.append("='").append(e.getValue()).append("'");
}
}
}
return sb.toString();
}
}
Осталось указать только путь к нашей фабрике CustomEventListenerProviderFactory
для Keycloak.
Для этого необходимо создать файл с названием org.keycloak.events.EventListenerProviderFactory
по пути src/main/resources/META-INF/services/
. Отсутствующие в проекте директории необходимо создать.
И в данный файл поместить строку:
ru.event.listener.extension.factory.CustomEventListenerProviderFactory
То есть указываем полный путь до класса нашей кастомной фабрики. Только так Keycloak сможет заменить дефолтную фабрику с дефолтным обработчиком событий на нашу. На этом с кодом всё.
Сборка проекта
Теперь необходимо собрать получившееся расширение в JAR файл. Если вы используете Maven, то после сборки, в папке target
появится два JAR файла. Нам нужен тот, что без -sources
. В нашем случае это keycloak-logging-plugin.jar
. Собрать его можно с помощью команды:
mvn clean package
Запуск расширения
Установку и запуск Keycloak я здесь рассматривать не буду, на эту тему есть исчерпывающие статьи. Например, на официальном сайте. Будем считать, что у нас уже стоит работающий keycloak.
Собранный JAR файл keycloak-logging-plugin.jar
необходимо поместить в папку с Keycloak по пути <ДИРЕКТОРИЯ_KEYCLOAK>/standalone/deployments/
, причём нет необходимости перезапускать Keycloak после деплоя. Да, keycloak поддерживает hot swap или замену файлов "на ходу". Как только наш JAR файл окажется в директории, keycloak его задеплоит и будет готов к работе.
Даже если вы потом будете заменять уже находящийся там файл точно таким же, он всё равно задеплоится заново.
Сообщения о начале, а затем и об успешном деплое в консоли Keycloak выглядят примерно так:
19:37:58,203 INFO [org.jboss.as.server.deployment] (MSC service thread 1-1) WFLYSRV0027: Starting deployment of "event-listener-keycloak-extension.jar" (runtime-name: "event-listener-keycloak-extension.jar")
19:37:58,322 INFO [org.keycloak.subsystem.server.extension.KeycloakProviderDeploymentProcessor] (MSC service thread 1-7) Deploying Keycloak provider: event-listener-keycloak-extension.jar
19:37:58,334 WARN [org.keycloak.services] (MSC service thread 1-7) KC-SERVICES0047: event-listener-extension (ru.event.listener.extension.factory.CustomEventListenerProviderFactory) is implementing the internal SPI eventsListener. This SPI is internal and may change without notice
19:37:58,366 INFO [org.jboss.as.server] (DeploymentScanner-threads - 1) WFLYSRV0010: Deployed "event-listener-keycloak-extension.jar" (runtime-name : "event-listener-keycloak-extension.jar")
А в директории рядом с нашим JAR файлом появился такой же с приставкой .deployed
.
Но это ещё не всё. Теперь нам необходимо определить наш плагин как слушатель событий в конфиге Keycloak. Это делается в админской консоли на вкладке Events > Config:
Необходимо выбрать наш плагин и нажать Save.
Проверка работы
Попробуем залогинится каким-нибудь пользователем. В консоли появляется следующая запись:
20:02:14,474 INFO [ru.event.listener.extension.CustomEventListenerProvider] (default task-11) Caught event type=LOGIN, realmId=master, clientId=account-console, userId=8cbc9aec-0c5f-45e0-b614-baf9e96c2278, ipAddress=127.0.0.1, auth_method=openid-connect, auth_type=code, redirect_uri=http://localhost:8080/auth/realms/master/account/#/, consent=no_consent_required, code_id=007a3edc-4541-4648-b1e6-44c30349c001, username=test
Разлогинимся этим же пользователем:
20:03:13,143 INFO [ru.event.listener.extension.CustomEventListenerProvider] (default task-11) Caught event type=LOGOUT, realmId=master, clientId=null, userId=8cbc9aec-0c5f-45e0-b614-baf9e96c2278, ipAddress=127.0.0.1, redirect_uri=http://localhost:8080/auth/realms/master/account/#/
Попробуем залогиниться с некорректным паролем:
20:03:42,204 WARN [org.keycloak.events] (default task-11) type=LOGIN_ERROR, realmId=master, clientId=account-console, userId=8cbc9aec-0c5f-45e0-b614-baf9e96c2278, ipAddress=127.0.0.1, error=invalid_user_credentials, auth_method=openid-connect, auth_type=code, redirect_uri=http://localhost:8080/auth/realms/master/account/#/, code_id=f0d48657-3673-4875-bb72-a7f1d89b6d31, username=test, authSessionParentId=f0d48657-3673-4875-bb72-a7f1d89b6d31, authSessionTabId=h6V1w1C3Zjk
Сбросим пароль пользователю через админскую консоль (AdminEvent
):
20:05:05,045 INFO [ru.event.listener.extension.CustomEventListenerProvider] (default task-20) Caught admin event operationType=ACTION, realmId=master, clientId=cff15a39-3a5d-49c6-baf1-1c8d9dee1ce6, userId=a64026c4-689f-4213-8229-b8ac471150ea, ipAddress=127.0.0.1, resourcePath=users/8cbc9aec-0c5f-45e0-b614-baf9e96c2278/reset-password
Заключение
В данной статье описано только минимальное расширение для отлавливания событий в Keycloak, вы же можете делать с ними всё, что вам необходимо. В моём случае необходимо было отлавливать только события сброса пароля юзеру через админскую консоль, и отправлять логин этого юзера в микросервис через REST запрос. С рабочими исходниками этого проекта можете ознакомиться тут.
P.S.
Кстати, у меня так же была задача на отслеживание события в Keycloak о том, что пользователь сделал максимальное количество неверных попыток ввода пароля, и его временно заблокировали (Keycloak предлагает такой функционал из коробки, называется brute force detection).
Но, либо из соображений безопасности, либо просто из-за того, что фича никому не нужна, такое событие в Keycloak в принципе отсутствует. Только если вручную считать для каждого пользователя количество событий неправильного ввода пароля за промежуток времени, указанные в конфигурации Keycloak, можно такое событие отследить. Но есть подозрения, что из-за отличной гибкости расширений, можно написать свою кастомную реализацию интерфейса BruteForceProtector
, дефолтная реализация которого отвечает за временную блокировку при неправильных попытках ввода пароля, и добавить такое событие.
Пока все попытки не увенчались успехом, но автор не опускает руки, так что, возможно, скоро будут новости по этому поводу.
ink08
Видел реализацию подобного плагина, когда искал примеры расширений :)
dev.to/adwaitthattey/building-an-event-listener-spi-plugin-for-keycloak-2044