Как стать автором
Обновить
135.4

Расширение Keycloak для перехвата и обработки событий в системе

Время на прочтение9 мин
Количество просмотров13K

Продолжаю тему моего коллеги о 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. В моём проекте он нужен для удобного логирования событий в консоли и парочки конструкторов.

Теперь необходимо создать реализацию необходимых нам интерфейсов, а именно двух:

  1. EventListenerProvider. Дефолтный интерфейс провайдера для перехвата всех событий в системе. Реализация будет содержать саму логику нашего расширения.

  2. 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() {

    }
}

У данного интерфейса необходимо определить три метода:

  1. onEvent метод, перехватывающий обычные события в системе, такие как событие неправильного ввода пароля, удачной авторизации, логаута. В аргументе приходит сам экземпляр события со всей необходимой информацией: тип события, id пользователя и сессии, IP пользователя и т. д.

  2. onAdminEvent перехватывает "админские" события, например: событие сброса пароля пользователя через админскую консоль Keycloak.

  3. 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;
    }

}

Тут уже методов побольше, посмотрим, за что они отвечают:

  1. create будет возвращать наш кастомный провайдер CustomEventListenerProvider. Вызывается при каждом новом событии в системе. Сама же фабрика CustomEventListenerProviderFactory создаётся один раз на протяжении работы Keycloak.

  2. init срабатывает только один раз при первом создании фабрики.

  3. postInit вызывается один раз после инициализации всех фабрик провайдеров в системе.

  4. close выполняется при завершении работы Keycloak сервера.

  5. 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:

Если деплой расширения произошёл успешно, в выпадающем меню поля Event Listeners появится наш плагин.
Если деплой расширения произошёл успешно, в выпадающем меню поля Event Listeners появится наш плагин.

Необходимо выбрать наш плагин и нажать 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, дефолтная реализация которого отвечает за временную блокировку при неправильных попытках ввода пароля, и добавить такое событие.

Пока все попытки не увенчались успехом, но автор не опускает руки, так что, возможно, скоро будут новости по этому поводу.

Теги:
Хабы:
Всего голосов 6: ↑6 и ↓0+6
Комментарии3

Публикации

Информация

Сайт
www.reksoft.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия