Данная статья написана для людей, имеющих базовый опыт работы с java и тех, кто хотя бы что-то слышал о Keycloak. В ней будет рассказано о том, как написать свой плагин, позволяющий рабоать с внешней базой данных пользователей через API сервис. Я постараюсь объяснить данную тему доступным языком и предоставить практические примеры, чтобы вы могли легко применить полученные знания на практике.
Коллеги, на связи Ваш код еще не готов, сэр! и сегодня мы поговорим о Keycloak, а точнее о том, как написать свой собственный плагин, позволяющий взаимодействовать с внешней базой данных пользователей посредством отправки HTTP-запросов к удаленному сервису.
Но прежде чем мы начнем, возникает вопрос, что же такое Keycloak? По традиции, возьму определение из Вики.
Keycloak — это система управления идентификацией и доступом, которая обеспечивает аутентификацию и авторизацию для веб-приложений и сервисов. Он позволяет легко управлять пользователями и поддерживает различные протоколы, такие как OAuth 2.0 и OpenID Connect, обеспечивая единый вход (SSO) для пользователей.
На первый взгляд, это самодостаточная технология, которая сочетает в себе все, что только можно представить. Однако, представьте, вы хотите интегрировать в уже существующий проект данную технологию, но у вас уже есть база данных пользователей, которую вы хотите использовать. Но авторы предусмотрели и этот момент. Как написано в официальной документации:
Вы можете использовать SPI для хранения пользователей (User Storage SPI), чтобы писать расширения для Keycloak, которые подключаются к внешним базам данных пользователей и хранилищам учетных данных. Встроенная поддержка LDAP и Active Directory является примером реализации этого SPI в действии. По умолчанию Keycloak использует свою локальную базу данных для создания, обновления и поиска пользователей, а также для проверки учетных данных. Однако часто у организаций есть существующие внешние проприетарные базы данных пользователей, которые они не могут мигрировать в модель данных Keycloak. В таких ситуациях разработчики приложений могут написать реализации User Storage SPI, чтобы связать внешнее хранилище пользователей и внутреннюю модель объектов пользователей, которую Keycloak использует для входа пользователей и их управления.
То есть Keycloak предоставляет возможность расширять свою функциональность и все, что необходимо сделать, это реализовать ряд интерфейсов. Список этих интерфейсов и их описание, будут рассмотрены ниже, когда мы дойдем до написания основного функционала.
А сейчас предлагаю перейти к написания самого плагина и начнем с создания пустого maven проекта. Я полагаю, что на вашей рабочей машине уже установлены java, maven и настроены все переменные окружения.
mvn archetype:generate -DgroupId=org.example -DartifactId=keycloak-api-plugin -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false
Рис. 1 Команда для создания пустого maven проекта
после того, как мы создали проект, добавим необходимые зависимости, для этого дополним pom.xml файл:
<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>
<groupId>org.example</groupId>
<artifactId>keycloak_api_plugin</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>keycloak_api_plugin</name>
<url>http://maven.apache.org</url>
<properties>
<keycloak.version>26.2.2</keycloak.version>
<postgresql.version>42.7.3</postgresql.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<repositories>
<repository>
<id>keycloak-releases</id>
<url>https://s01.oss.sonatype.org/content/repositories/releases</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.13.4.2</version>
</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>javax.ws.rs</groupId>
<artifactId>javax.ws.rs-api</artifactId>
<version>2.1</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.13.3</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-client</artifactId>
<version>3.15.1.Final</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.38</version>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-jackson2-provider</artifactId>
<version>3.15.1.Final</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.mindrot</groupId>
<artifactId>jbcrypt</artifactId>
<version>0.4</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<artifactSet>
<includes>
<include>org.mindrot:jbcrypt</include>
</includes>
</artifactSet>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Рис. 2 Основные зависимости
В данном POM-файле указаны все необходимые зависимости для интеграции с Keycloak, включая ключевые компоненты, такие как keycloak-core, keycloak-server-spi, keycloak-server-spi-private и keycloak-services, которые обеспечивают функциональность сервера аутентификации. Также присутствуют библиотеки для работы с JSON, такие как jackson-databind и jackson-annotations, а также JAX-RS компоненты, включая resteasy-client и resteasy-jackson2-provider. Все зависимости, относящиеся к Keycloak, используют одну и ту же версию, что упрощает управление и гарантирует совместимость.
После того, как мы определились с зависимостями, зададим базовую структуру.
keycloak-api-plugin
│
├── pom.xml
│
└── src
└── main
└── java
└── org
└── example
└── keycloakapiplugin
├── adapter
│ └── UserAdapter.java
│
├── api
│ └── UsersApiService.java
│
├── dto
│ └── UserResponseDto.java
│
├── entity
│ └── UserEntity.java
│
├── mapper
│ └── UserMapper.java
│
├── ExternalUserStorageProvider.java
│
└── ExternalUserStorageProviderFactory.java
Рис. 3 Структура maven проекта
Когда структура определена, давайте кратко рассмотрим каждый компонент и выясним, за что он отвечает.
ExternalUserStorageProviderFactory - Фабрика провайдера, создающая экземпляры ExternalUserStorageProvider. Определяет имя и описание плагина для Keycloak.
ExternalUserStorageProvider - Основной провайдер, реализующий интерфейсы для работы с пользователями (поиск, аутентификация, запросы). Интегрируется с внешним API для получения данных пользователей.
UserEntity - Модель данных пользователя, содержащая базовые поля (id, username, email и т.д.). Используется для внутреннего представления данных.
UserResponseDto - DTO для получения данных пользователя из внешнего API. Содержит аннотации для работы с JSON.
UsersApiService - Сервис для взаимодействия с внешним API пользователей через HTTP-запросы. Реализует методы поиска пользователей по разным критериям.
UserAdapter - Адаптер, преобразующий UserEntity в модель Keycloak. Реализует методы работы с атрибутами пользователя для Keycloak.
Теперь, когда мы в общих чертах разобрались, какой компонент за что отвечает, предлагаю вернуться к документации, а именно к следующим строкам раздела User Storage SPI.
Когда среда выполнения Keycloak нуждается в поиске пользователя, например, когда пользователь входит в систему, она выполняет ряд шагов для его локализации. Сначала она проверяет, есть ли пользователь в кэше пользователей; если пользователь найден, используется это представление в памяти. Затем система ищет пользователя в локальной базе данных Keycloak. Если пользователь не найден, она проходит через реализации провайдеров
User Storage SPI, чтобы выполнить запрос пользователя, пока один из них не вернет искомого пользователя. Провайдер запрашивает внешнее хранилище пользователей и сопоставляет внешнее представление данных пользователя с метамоделью пользователя Keycloak.
Реализации провайдеров User Storage SPI также могут выполнять сложные запросы по критериям, выполнять операции CRUD с пользователями, проверять и управлять учетными данными или выполнять массовые обновления многих пользователей одновременно. Это зависит от возможностей внешнего хранилища.
Из сказанного выше, можно сделать вывод, что ключевой «фигурой» в данном контексте является пользователь, с него мы и начнем.
Создадим сущность UserEntity с набором необходимых полей, для интеграции с keycloak:
package org.example.keycloakapiplugin.entity;
import lombok.Getter;
import lombok.Setter;
import java.util.Date;
@Getter @Setter
public class UserEntity {
private String id;
private String username;
private String email;
private String password;
private Date createdAt;
private String phone;
}
Рис. 4 Модель пользователя
Эта модель описывает структуру данных, с помощью которой осуществляется взаимодействие Keycloak и внешнего сервиса.
В свою очередь, внешний сервис отдает нам следующую модель данных:
package org.example.keycloakapiplugin.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.*;
import java.util.Date;
@Getter @Setter
@AllArgsConstructor
@Data
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class UserResponseDto {
private String id;
private String username;
private String email;
private String password;
private String phone;
private Date createdAt;
}
Рис. 5 DTO для передачи данных пользователя
Также нам доступен ряд основных endpoints:
Получить всех пользователей: GET /users - возвращает список пользователей.
Добавить пользователя: POST /users - добавляет нового пользователя.
Получить пользователя по ID: GET /users/{id} - возвращает пользователя по уникальному ID.
Получить пользователя по имени: GET /users/username/{username} - возвращает пользователя по имени.
Получить пользователя по email: GET /users/email/{email} - возвращает пользователя по электронной почте.
Удалить пользователя по ID: DELETE /users/{id} - удаляет пользователя по уникальному ID.
Для преобразования внешних данный в необходимую нам структуру (UserEntity) напишем простой маппер, задача которого преобразовывать один тип объектов в другой.
package org.example.keycloakapiplugin.mapper;
import org.example.keycloakapiplugin.entity.UserEntity;
import org.example.keycloakapiplugin.dto.UserResponseDto;
import java.util.List;
import java.util.stream.Collectors;
public class UserMapper {
public static UserEntity mapToUserEntity(UserResponseDto dto) {
if (dto == null) {
return null;
}
UserEntity userEntity = new UserEntity();
userEntity.setEmail(dto.getEmail());
userEntity.setPhone(dto.getPhone());
userEntity.setUsername(dto.getEmail());
userEntity.setId(dto.getId());
userEntity.setPassword(dto.getPassword());
userEntity.setCreatedAt(dto.getCreatedAt());
return userEntity;
}
public static List<UserEntity> mapToUserEntityList(List<UserResponseDto> dtoList) {
return dtoList.stream().map(UserMapper::mapToUserEntity).collect(Collectors.toUnmodifiableList());
}
}
Рис. 6 Класс UserMapper преобразует DTO в сущности пользователей и списки
Теперь, когда мы определились с данными, предлагаю написать сам класс, содержащий ряд методов, отвечающих за взаимодействие с внешним апи. Его основная задача - отправка запросов по http и обработка ответов.
package org.example.keycloakapiplugin.mapper.api;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
import org.jboss.logging.Logger;
import org.keycloak.storage.StorageId;
import org.example.keycloakapiplugin.mapper.dto.UserResponseDto;
import org.example.keycloakapiplugin.mapper.entity.UserEntity;
import org.example.keycloakapiplugin.mapper.mapper.UserMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Collections;
import java.util.List;
public class UsersApiService {
private static final ObjectMapper objectMapper = new ObjectMapper();
private static final Logger logger = Logger.getLogger(UsersApiService.class);
private static final String USER_BY_USERNAME_URL = "http://localhost:8081/users/username/";
private static final String USER_SERVICE_URL = "http://localhost:8081/users";
private static final String USER_BY_ID_URL = "http://localhost:8081/users/";
private static final String USER_BY_EMAIL_URL = "http://localhost:8081/users/email/";
private final HttpClient httpClient;
public UsersApiService() {
this.httpClient = HttpClient.newHttpClient();
}
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
@Getter
@Setter
public static class ApiResponse {
@JsonIgnoreProperties(ignoreUnknown = true)
private List<UserResponseDto> data;
}
public List<UserEntity> getAllUsers() {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(USER_SERVICE_URL))
.header("Accept", "application/json")
.GET()
.build();
try {
HttpResponse<String> response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
ApiResponse apiResponse = objectMapper.readValue(response.body(), ApiResponse.class);
if (apiResponse != null && apiResponse.getData() != null) {
logger.infof("Successfully loaded %d users from external service", apiResponse.getData().size());
return UserMapper.mapToUserEntityList(apiResponse.getData());
} else {
logger.error("Received empty response from user service");
}
} else {
logger.errorf("Failed to load users from service. Status code: %d, Response: %s",
response.statusCode(), response.body());
}
} catch (IOException | InterruptedException e) {
logger.error("Error while fetching users from external service", e);
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
}
return Collections.emptyList();
}
public UserEntity getUserByUsername(String username) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(USER_BY_USERNAME_URL + username))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
UserResponseDto userResponse = objectMapper.readValue(response.body(), UserResponseDto.class);
return UserMapper.mapToUserEntity(userResponse);
}
} catch (IOException | InterruptedException e) {
logger.error("Error while fetching user by username from external service", e);
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
}
logger.info("could not find getUserByUsername: " + username);
return null;
}
public UserEntity getUserById(String id) {
logger.info("getUserById: " + id);
String externalId = StorageId.externalId(id);
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(USER_BY_ID_URL + externalId))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
UserResponseDto userResponse = objectMapper.readValue(response.body(), UserResponseDto.class);
return UserMapper.mapToUserEntity(userResponse);
} else {
logger.error("Failed to get user by id. Status code: %d, Response: %s");
}
} catch (IOException | InterruptedException e) {
logger.error("Error while fetching user by id from external service", e);
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
}
logger.info("could not find user by id: " + id);
return null;
}
public UserEntity getUserByEmail(String email) {
logger.info("getUserByEmail: " + email);
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(USER_BY_EMAIL_URL + email))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
UserResponseDto userResponse = objectMapper.readValue(response.body(), UserResponseDto.class);
return UserMapper.mapToUserEntity(userResponse);
} else {
logger.error("Failed to get user by email. Status code: %d, Response: %s");
}
} catch (IOException | InterruptedException e) {
logger.error("Error while fetching user by email from external service", e);
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
}
return null;
}
}
Рис. 7 Класс UsersApiService
обеспечивает взаимодействие с внешним сервисом для получения и управления данными пользователей.
Что мы видим в данном классе? В конструкторе создается экземпляр httpClient для запросов и набор методов для получения пользовательских данных. Рассмотрим один из методов, а именно getAllUsers.
/**
* Получает список всех пользователей из внешнего сервиса через HTTP-запрос.
*
* @return список сущностей UserEntity или пустой список в случае ошибки
*/
public List<UserEntity> getAllUsers() {
// Создаем HTTP GET-запрос к указанному URL
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(USER_SERVICE_URL)) // URL внешнего сервиса пользователей
.header("Accept", "application/json") // Запрашиваем данные в JSON-формате
.GET()
.build();
try {
// Отправляем запрос и получаем ответ
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
// Обрабатываем успешный ответ (HTTP 200)
if (response.statusCode() == 200) {
// Преобразуем JSON-тело ответа в объект ApiResponse
ApiResponse apiResponse = objectMapper.readValue(response.body(), ApiResponse.class);
// Проверяем наличие данных в ответе
if (apiResponse != null && apiResponse.getData() != null) {
logger.infof("Successfully loaded %d users from external service", apiResponse.getData().size());
// Маппим DTO пользователей на сущности Keycloak
return UserMapper.mapToUserEntityList(apiResponse.getData());
} else {
logger.error("Received empty response from user service");
}
} else {
// Логируем ошибку при неуспешном статусе ответа
logger.errorf("Failed to load users from service. Status code: %d, Response: %s",
response.statusCode(), response.body());
}
} catch (IOException e) {
// Обработка ошибок ввода-вывода
logger.error("Error while fetching users from external service", e);
} catch (InterruptedException e) {
// Особый случай прерывания потока
logger.error("Request was interrupted", e);
Thread.currentThread().interrupt(); // Восстанавливаем флаг прерывания
}
// Возвращаем пустой список в случае любых ошибок
return Collections.emptyList();
}
Рис. 8 Метод getAllUsers
Метод getAllUsers формирует запрос с помощью билдера, в котором указываются адрес внешнего сервиса, необходимые заголовки и тип запроса. После отправки запроса, в случае успешного ответа, полученные данные мапятся в сущность UserEntity, и метод возвращает результат. Остальные методы реализованы аналогичным образом, поэтому не будем на них подробно останавливаться.
Далее перейдём к самой интересной части — реализации Provider в Keycloak.
Но прежде чем погрузиться в код, давайте в очередной раз обратимся к документации, а именно к разделу Capability Interfaces и рассмотрим какие интерфейсы нам доступны и зачем они нужны.
Если кратко, то в данном разделе говорится о том, что интерфейс UserStorageProvider (интерфейс в Keycloak, который позволяет разработчикам интегрировать собственные хранилища пользователей в систему управления идентификацией Keycloak) не содержит методов для поиска или управления пользователями. Эти методы определены в других интерфейсах, в зависимости от возможностей внешнего хранилища пользователей. Например, некоторые хранилища могут быть только для чтения и выполнять лишь простые запросы и проверку учетных данных. И вам нужно реализовать только те интерфейсы, которые соответствуют вашим потребностям и возможностям.
И такие интерфейсы нам любезно предоставляют. Рассмотрим их:
org.keycloak.storage.user.UserLookupProvider
Интерфейс, необходимый для получения информации о пользователях из внешнего хранилища. Позволяет осуществлять поиск пользователей по различным критериям, таким как идентификатор, имя пользователя и электронная почта.
org.keycloak.storage.user.UserQueryMethodsProvider
Интерфейс, который определяет методы для выполнения сложных запросов, используемых для поиска одного или нескольких пользователей. Позволяет управлять пользователями через административную консоль, обеспечивая возможность фильтрации и пагинации результатов.
org.keycloak.storage.user.UserCountMethodsProvider
Интерфейс, который предоставляет методы для выполнения запросов на подсчет пользователей. Позволяет получать общее количество пользователей в хранилище, а также количество пользователей по заданным критериям поиска.
org.keycloak.storage.user.UserQueryProvider
Интерфейс, который объединяет функциональность UserQueryMethodsProvider и UserCountMethodsProvider. Обеспечивает возможность выполнения как запросов на поиск пользователей, так и запросов на их подсчет.
org.keycloak.storage.user.UserRegistrationProvider
Интерфейс, который позволяет добавлять и удалять пользователей в внешнем хранилище. Обеспечивает методы для регистрации новых пользователей и удаления существующих.
org.keycloak.storage.user.UserBulkUpdateProvider
Интерфейс, который поддерживает массовое обновление данных нескольких пользователей одновременно. Позволяет выполнять операции обновления для группы пользователей, что упрощает управление учетными записями.
org.keycloak.credential.CredentialInputValidator
Интерфейс, который позволяет проверять различные типы учетных данных, такие как пароли. Обеспечивает методы для валидации введенных пользователем данных при аутентификации.
org.keycloak.credential.CredentialInputUpdater
Интерфейс, который поддерживает обновление различных типов учетных данных. Позволяет изменять или обновлять учетные данные пользователей, такие как пароли или другие методы аутентификации.
Так как нам необходимо взаимодействовать в внешним сервисом, проверять пароли пользователей и иметь возможность просматривать и изменять пользователей из админки, то понадобится реализовать следующий набор интерфейсов:
UserStorageProvider,
UserLookupProvider,
UserQueryProvider,
CredentialInputValidator,
CredentialInputUpdater
Эти интерфейсы предоставляют набор самодокументированных методов, названия которых отражают суть их функционала, например, getUserById или searchForUserStream. Мы не будем углубляться в детали их реализации, лишь отметим, что эти методы используют функции из класса UsersApiService для получения пользователей, а затем, с помощью адаптера, преобразуют полученные данные из внешнего сервиса в UserModel.
package org.example.keycloakapiplugin;
import org.keycloak.component.ComponentModel;
import org.keycloak.credential.CredentialInput;
import org.keycloak.credential.CredentialInputUpdater;
import org.keycloak.credential.CredentialInputValidator;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.storage.ReadOnlyException;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.user.UserLookupProvider;
import org.keycloak.storage.user.UserQueryProvider;
import org.mindrot.jbcrypt.BCrypt;
import org.example.keycloakapiplugin.adapter.UserAdapter;
import org.example.keycloakapiplugin.api.UsersApiService;
import org.example.keycloakapiplugin.entity.UserEntity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import java.util.stream.Stream;
public class ExternalUserStorageProvider implements
UserStorageProvider,
UserLookupProvider,
UserQueryProvider,
CredentialInputValidator,
CredentialInputUpdater {
protected KeycloakSession session;
protected ComponentModel model;
private static final Logger logger = LoggerFactory.getLogger(ExternalUserStorageProvider.class);
private final UsersApiService usersApiService;
public ExternalUserStorageProvider(KeycloakSession session, ComponentModel model) {
logger.debug("Creating new PropertyFileUserStorageProvider instance");
this.session = session;
this.model = model;
this.usersApiService = new UsersApiService();
}
@Override
public UserModel getUserByUsername(RealmModel realm, String username) {
logger.info("getUserByUsername: " + username);
UserEntity userEntity = this.usersApiService.getUserByUsername(username);
if (userEntity != null) {
return new UserAdapter(session, realm, model, userEntity);
} else {
logger.error("Failed to get user by username. Status code: %d, Response: %s");
}
logger.info("could not find getUserByUsername: " + username);
return null;
}
@Override
public UserModel getUserById(RealmModel realm, String id) {
UserEntity userEntity = this.usersApiService.getUserById(id);
if (userEntity != null) {
return new UserAdapter(session, realm, model, userEntity);
} else {
logger.error("Failed to get user by id. Status code: %d, Response: %s");
}
logger.info("could not find user by id: " + id);
return null;
}
@Override
public UserModel getUserByEmail(RealmModel realm, String email) {
logger.info("getUserByEmail: " + email);
UserEntity userEntity = this.usersApiService.getUserByEmail(email);
if (userEntity != null) {
return new UserAdapter(session, realm, model, userEntity);
} else {
logger.error("Failed to get user by email. Status code: %d, Response: %s");
}
logger.error("Error while fetching user by email from external service");
return null;
}
@Override
public boolean supportsCredentialType(String credentialType) {
logger.debug("Checking support for credential type: {}", credentialType);
boolean supported = PasswordCredentialModel.TYPE.equals(credentialType);
logger.debug("Credential type {} supported: {}", credentialType, supported);
return supported;
}
@Override
public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
logger.debug("Checking if credential type {} is configured for user: {}", credentialType, user.getUsername());
boolean configured = supportsCredentialType(credentialType) &&
this.usersApiService.getUserByUsername(user.getUsername()) != null;
logger.debug("Credential type {} configured for user {}: {}", credentialType, user.getUsername(), configured);
return configured;
}
@Override
public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
logger.debug("Validating credential for user: {}", user.getUsername());
if (!supportsCredentialType(input.getType())) {
logger.debug("Credential type not supported: {}", input.getType());
return false;
}
UserEntity userEntity = this.usersApiService.getUserByUsername(user.getUsername());
if (userEntity == null) {
logger.debug("User entity not found for username: {}", user.getUsername());
return false;
}
// Получаем хэш пароля из внешней системы
String storedPasswordHash = userEntity.getPassword();
String inputPassword = input.getChallengeResponse();
return BCrypt.checkpw(inputPassword, storedPasswordHash);
}
@Override
public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) {
logger.warn("Attempt to update credential for read-only storage provider, user: {}", user.getUsername());
throw new ReadOnlyException("User storage is read-only");
}
@Override
public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) {
logger.debug("disableCredentialType called for user: {}, type: {}", user.getUsername(), credentialType);
// Read-only - ничего не делаем
}
@Override
public Stream<String> getDisableableCredentialTypesStream(RealmModel realm, UserModel user) {
logger.debug("getDisableableCredentialTypesStream called for user: {}", user.getUsername());
return Stream.empty();
}
@Override
public void close() {
logger.info("Provider closed successfully");
}
@Override
public Stream<UserModel> searchForUserStream(RealmModel realm, Map<String, String> params, Integer firstResult, Integer maxResults) {
String search = params.get(UserModel.SEARCH);
String lower = search != null ? search.toLowerCase() : "";
logger.debug("Searching for users with search term: {}", lower);
Stream<UserModel> userStream = this.usersApiService.getAllUsers().stream()
.filter(userEntity -> userEntity.getUsername().toLowerCase().contains(lower) ||
userEntity.getEmail().toLowerCase().contains(lower))
.map(entity -> new UserAdapter(session, realm, model, entity));
// Применяем постраничный вывод
if (firstResult != null) {
userStream = userStream.skip(firstResult);
}
if (maxResults != null) {
userStream = userStream.limit(maxResults);
}
return userStream;
}
@Override
public Stream<UserModel> getGroupMembersStream(RealmModel realmModel, GroupModel groupModel, Integer integer, Integer integer1) {
return Stream.empty();
}
@Override
public Stream<UserModel> searchForUserByUserAttributeStream(RealmModel realmModel, String s, String s1) {
return Stream.empty();
}
}
Рис. 9 Класс ExternalUserStorageProvider
реализует интерфейсы Keycloak для управления пользователями, взаимодействуя с внешним сервисом
Однако стоит немного подробнее рассказать о UserModel. Что же это за «модель»?
UserModel — один из ключевых компонентов, описывающий модель пользователя в Keycloak. Это основной интерфейс, представляющий пользователя в системе. У внимательного читателя может возникнуть вопрос: как происходит преобразование UserEntity в UserModel, если UserApiService возвращает только UserEntity и ничего не знает о UserModel? И он будет прав.
Сначала необходимо преобразовать данные, для чего мы реализуем UserAdapter. Этот адаптер преобразует сущность UserEntity (из внешней системы) в модель Keycloak (UserModel).
package org.example.keycloakapiplugin.adapter;
import org.jboss.logging.Logger;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.storage.StorageId;
import org.keycloak.storage.adapter.AbstractUserAdapterFederatedStorage;
import org.example.keycloakapiplugin.entity.UserEntity;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
public class UserAdapter extends AbstractUserAdapterFederatedStorage {
private static final Logger logger = Logger.getLogger(UserAdapter.class);
protected UserEntity entity;
protected String keycloakId;
public UserAdapter(KeycloakSession session, RealmModel realm, ComponentModel model, UserEntity entity) {
super(session, realm, model);
this.entity = entity;
keycloakId = StorageId.keycloakId(model, entity.getId());
}
@Override
public String getUsername() {
return entity.getUsername();
}
@Override
public void setUsername(String username) {
entity.setUsername(username);
}
@Override
public void setEmail(String email) {
entity.setEmail(email);
}
@Override
public String getEmail() {
return entity.getEmail();
}
@Override
public String getId() {
return keycloakId;
}
@Override
public void setSingleAttribute(String name, String value) {
if (name.equals("phone")) {
entity.setPhone(value);
} else {
super.setSingleAttribute(name, value);
}
}
@Override
public void removeAttribute(String name) {
if (name.equals("phone")) {
entity.setPhone(null);
} else {
super.removeAttribute(name);
}
}
@Override
public void setAttribute(String name, List<String> values) {
if (name.equals("phone")) {
entity.setPhone(values.get(0));
} else {
super.setAttribute(name, values);
}
}
@Override
public String getFirstAttribute(String name) {
if (name.equals("phone")) {
return entity.getPhone();
} else {
return super.getFirstAttribute(name);
}
}
@Override
public Map<String, List<String>> getAttributes() {
Map<String, List<String>> attrs = super.getAttributes();
MultivaluedHashMap<String, String> all = new MultivaluedHashMap<>();
all.putAll(attrs);
all.add("phone", entity.getPhone());
return all;
}
@Override
public Stream<String> getAttributeStream(String name) {
if (name.equals("phone")) {
List<String> phone = new LinkedList<>();
phone.add(entity.getPhone());
return phone.stream();
} else {
return super.getAttributeStream(name);
}
}
}
Рис. 10 Класс UserAdapter
адаптирует сущность пользователя для Keycloak
Основная задача UserAdapter — интеграция внешних пользователей в Keycloak без изменения их исходного хранилища. Ключевой особенностью адаптера является то, что он наследуется от AbstractUserAdapterFederatedStorage, что предоставляет следующие преимущества:
• Базовая реализация UserModel.
• Хранение атрибутов в Federated Storage (если не переопределены).
• Поддержка многозначных атрибутов (MultivaluedHashMap).
• Обеспечивается автоматическая работа с атрибутами, которые не описаны явно.
Также я хочу обратить особое внимание на процесс генерации keycloakId:
keycloakId = StorageId.keycloakId(model, entity.getId());
Рис. 11 Создание идентификатор Keycloak для пользователя на основе модели
Почему это важно? Keycloak требует уникальный идентификатор в формате
f:{providerId}:{externalId},
Рис. 12 Формат идентификатора пользователя
который обеспечивает однозначную идентификацию пользователей из различных внешних систем. Например, идентификатор может выглядеть так:
f:my-provider:123.
Рис. 13 Идентификатор пользователя
Этот формат позволяет Keycloak эффективно управлять пользователями, интегрированными из разных источников, и гарантирует, что каждый пользователь будет уникально идентифицирован в системе. Использование такого подхода также упрощает интеграцию и синхронизацию данных между Keycloak и внешними сервисами, минимизируя риск конфликтов идентификаторов и обеспечивая целостность данных.
И финальный штрих, это реализация фабрики.
Фабрика ExternalUserStorageProviderFactory в данном коде выполняет следующие функции:
Создание провайдера: Реализует метод
create
, который создает экземплярExternalUserStorageProvider
для работы с внешней базой данных.Идентификация: Метод
getId
возвращает уникальный идентификатор провайдера, который используется для его регистрации в Keycloak.Описание: Метод
getHelpText
предоставляет текстовое описание провайдера, объясняющее его функциональность и возможности интеграции с внешней базой данных через HTTP-запросы для аутентификации пользователей.
Таким образом, фабрика обеспечивает интеграцию Keycloak с внешними системами хранения пользователей.
Теперь необходимо собрать наш плагин и добавить его в keycloak. Для сборки плагина выполним команду:
mvn clean install
Рис. 14 Команда для сборки плагина
После сборки, в корне проекта в каталоге target появится наш плагин keycloak_api_plugin-1.0-SNAPSHOT.jar
Я буду использовать keycloak на основе docker образа, поэтому набросаем небольшой compose файл и разместим его в корне проекта:
services:
keycloak_with_plugin:
image: quay.io/keycloak/keycloak:26.1.3
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
ports:
- "8080:8080"
volumes:
- ./target/keycloak_api_plugin-1.0-SNAPSHOT.jar:/opt/keycloak/providers/keycloak_api_plugin-1.0-SNAPSHOT.jar
command: start-dev
Рис. 15 docker-compose.yml файл
Обращаю ваше внимание, что **keycloak_api_plugin-1.0-SNAPSHOT.jar **должен быть собран заранее.
Теперь, выполняем команду
docker compose up —build
Рис. 16 Команда для запуска docker compose
и после того как проект будет собран, переходим по адресу http://0.0.0.0:8080
вводим логин и пароль admin admin
Затем нам необходимо зайти в раздел User Federation и добавить созданный плагин:

Рис. 17 Раздел User Federation
в панели администратора
Осталось проверить, добавлены ли пользователи, предоставляемые внешним сервисом. Для этого переходим в раздел Users

Рис. 18 Раздел Users
в панели администратора

Рис. 19 Обзор данных конкретного пользователя в панели администратора
Мы убедились, что все пользователи успешно добавлены.
Разлогинимся и зайдем под пользователем из внешней базы данных

Рис. 20 Кабинет пользователя в панели Keycloak
Поздравляю, вы успешно залогинились используя данные пользователя из стороннего сервиса.
Заключение
Это был долгий путь, однако только что мы написали и успешно протестировали плагин для Keycloak, который позволяет использовать внешнее хранилище пользователей через взаимодействие с удаленным сервисом, предоставляющим API для работы с этими пользователями. Это решение уже демонстрирует свою функциональность, но есть множество возможностей для его улучшения.
Вы можете рассмотреть добавление оптимизаций в класс ExternalUserStorageProvider, чтобы повысить его производительность и эффективность. Также стоит доработать логику UserAdapter, внедрив работу с атрибутами пользователей, что позволит расширить функционал и сделать его более гибким. Кроме того, вы можете реализовать дополнительные интерфейсы, предоставляемые Keycloak, что откроет новые горизонты для интеграции и настройки вашего плагина.
Таким образом, у вас есть отличная основа для дальнейшей работы и развития, я надеюсь, что этот проект вдохновит вас на создание еще более мощных и эффективных решений в экосистеме Keycloak.
Если вам понравилась эта статья, то вы знаете что нужно делать — подписывайтесь, ставьте лайки и делайте репосты. Это лучшая поддержка для автора. С вами был Дубовицкий Юрий, автор канала «Ваш код еще не готов, сэр».
А наш код уже готов. До связи!
Gabenskiy
Интересная статья, не так много видел про keycloak. Из того, что бросается в глаза - @Dataсодержит в себе @Getter и @Setter.
И еще не увидел ссылки на гит с исходниками, этого реально не хватает в объемных статьях
И если на работе у вас есть keycloak, то хочу узнать как устроена архитектура keycloak + back + front