Перейти к основному содержимому

Использование пользовательских провайдеров с Keycloak

· 14 мин. чтения

1. Введение

В этом руководстве мы покажем, как добавить собственного поставщика в Keycloak , популярное решение для управления идентификацией с открытым исходным кодом, чтобы мы могли использовать его с существующими и/или нестандартными хранилищами пользователей.

2. Обзор пользовательских провайдеров с Keycloak

По умолчанию Keycloak предоставляет ряд стандартных интеграций на основе таких протоколов, как SAML, OpenID Connect и OAuth2 . Хотя эта встроенная функциональность достаточно мощная, иногда ее недостаточно . Общим требованием, особенно когда задействованы устаревшие системы, является интеграция пользователей из этих систем в Keycloak. Чтобы приспособиться к этому и подобным сценариям интеграции, Keycloak поддерживает концепцию пользовательских поставщиков.

Пользовательские поставщики играют ключевую роль в архитектуре Keycloak. Для каждой основной функции, такой как процесс входа в систему, аутентификация, авторизация, существует соответствующий интерфейс поставщика услуг. Этот подход позволяет нам подключать пользовательские реализации для любой из этих служб, которые Keycloak затем будет использовать как свои собственные.

2.1. Развертывание и обнаружение настраиваемого поставщика

В своей простейшей форме пользовательский поставщик — это просто стандартный файл jar, содержащий одну или несколько реализаций службы. При запуске Keycloak просканирует свой classpath и выберет всех доступных провайдеров, используя стандартный механизм java.util.ServiceLoader . Это означает, что все, что нам нужно сделать, это создать файл с именем конкретного интерфейса службы, который мы хотим предоставить, в папке META-INF/services нашего jar-файла и поместить в него полное имя нашей реализации.

Но какие услуги мы можем добавить в Keycloak? Если мы перейдем на страницу информации о сервере , доступную в консоли управления Keycloak, мы увидим их довольно много:

./4ceb7510f7c65a7e8b09faecb5bf787a.png

На этом рисунке левый столбец соответствует данному интерфейсу поставщика услуг (сокращенно SPI), а правый столбец показывает доступных поставщиков для этого конкретного SPI.

2.2. Доступные SPI

В основной документации Keycloak перечислены следующие SPI:

  • org.keycloak.authentication.AuthenticatorFactory : определяет действия и потоки взаимодействия, необходимые для аутентификации пользователя или клиентского приложения.
  • org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory : позволяет нам создавать настраиваемые действия, которые Keycloak будет выполнять при достижении конечной точки /auth/realms/master/login-actions/action-token . Например, этот механизм стоит за стандартным процессом сброса пароля. Ссылка, включенная в электронное письмо, включает такой токен действия
  • org.keycloak.events.EventListenerProviderFactory : создает поставщика, который прослушивает события Keycloak. Страница EventType Javadoc содержит список доступных настраиваемых событий, которые может обрабатывать поставщик. Типичным применением этого SPI является создание базы данных аудита.
  • org.keycloak.adapters.saml.RoleMappingsProvider : сопоставляет роли SAML, полученные от внешнего поставщика удостоверений, с ролями Keycloak. Это сопоставление очень гибкое, что позволяет нам переименовывать, удалять и/или добавлять роли в контексте данного Царства.
  • org.keycloak.storage.UserStorageProviderFactory : позволяет Keycloak получать доступ к пользовательским хранилищам.
  • org.keycloak.vault.VaultProviderFactory : позволяет нам использовать собственное хранилище для хранения секретов, специфичных для Realm. Они могут включать такую информацию, как ключи шифрования, учетные данные базы данных и т. д.

Этот список ни в коем случае не охватывает все доступные SPI: они просто наиболее хорошо задокументированы и на практике, скорее всего, потребуют настройки.

3. Реализация пользовательского поставщика

Как мы упоминали во введении к этой статье, пример нашего провайдера позволит нам использовать Keycloak с пользовательским репозиторием, доступным только для чтения. Например, в нашем случае этот пользовательский репозиторий представляет собой обычную таблицу SQL с несколькими атрибутами:

create table if not exists users(
username varchar(64) not null primary key,
password varchar(64) not null,
email varchar(128),
firstName varchar(128) not null,
lastName varchar(128) not null,
birthDate DATE not null
);

Для поддержки этого пользовательского хранилища пользователей мы должны реализовать SPI UserStorageProviderFactory и развернуть его в существующем экземпляре Keycloak.

Ключевым моментом здесь является часть только для чтения. Под этим мы подразумеваем, что пользователи смогут использовать свои учетные данные для входа в Keycloak, но не для изменения какой-либо информации в пользовательском хранилище, включая свои пароли. Однако это не ограничение Keycloak, так как он фактически поддерживает двунаправленные обновления. Встроенный поставщик LDAP — хороший пример поставщика, поддерживающего эту функцию.

3.1. Настройка проекта

Наш пользовательский проект поставщика — это обычный проект Maven, который создает файл jar. Чтобы избежать трудоемкого цикла компиляции-развертывания-перезапуска нашего провайдера в обычный экземпляр Keycloak, мы воспользуемся хорошим приемом: встроим Keycloak в наш проект как тестовую зависимость.

Мы уже рассмотрели , как встроить Keycloack в приложение SpringBoot , поэтому не будем здесь вдаваться в подробности того, как это делается . Приняв этот метод, мы обеспечим более быстрое время запуска и возможность горячей перезагрузки, что обеспечит более плавный процесс разработки. Здесь мы будем повторно использовать пример приложения SpringBoot для запуска наших тестов непосредственно из нашего пользовательского поставщика, поэтому мы добавим его в качестве тестовой зависимости:

<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<version>12.0.2</version>
</dependency>

<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>12.0.2</version>
</dependency>

<dependency>
<groupId>com.foreach</groupId>
<artifactId>oauth-authorization-server</artifactId>
<version>0.1.0-SNAPSHOT</version>
<scope>test</scope>
</dependency>

Мы используем последнюю версию 11-й серии для зависимостей keycloak-core и keycloak-server-spi Keycloak.

Однако зависимость oauth-authorization-server должна быть собрана локально из репозитория ForEach Spring Security OAuth .

3.2. Реализация UserStorageProviderFactory

Давайте начнем с нашего провайдера, создав реализацию UserStorageProviderFactory и сделав ее доступной для обнаружения с помощью Keycloak.

Этот интерфейс содержит одиннадцать методов, но нам нужно реализовать только два из них:

  • getId() : возвращает уникальный идентификатор для этого провайдера, который Keycloak покажет на своей странице администрирования.
  • create() : возвращает фактическую реализацию провайдера.

Keycloak вызывает метод create() для каждой транзакции, передавая KeycloakSession и ComponentModel в качестве аргументов . Здесь транзакция означает любое действие, требующее доступа к пользовательскому хранилищу. Ярким примером является процесс входа в систему: в какой-то момент Keycloak вызовет каждое настроенное пользовательское хранилище для данного Realm для проверки учетных данных. Поэтому нам следует избегать выполнения каких-либо дорогостоящих действий по инициализации на этом этапе, так как метод create() вызывается все время.

Тем не менее, реализация довольно тривиальна:

public class CustomUserStorageProviderFactory
implements UserStorageProviderFactory<CustomUserStorageProvider> {
@Override
public String getId() {
return "custom-user-provider";
}

@Override
public CustomUserStorageProvider create(KeycloakSession ksession, ComponentModel model) {
return new CustomUserStorageProvider(ksession,model);
}
}

Мы выбрали «custom-user-provider» в качестве идентификатора нашего провайдера, и наша реализация create() просто возвращает новый экземпляр нашей реализации UserStorageProvider . Теперь мы не должны забыть создать файл определения службы и добавить его в наш проект. Этот файл должен называться org.keycloak.storage.UserStorageProviderFactory и помещаться в папку META-INF/services нашей последней банки.

Поскольку мы используем стандартный проект Maven, это означает, что мы добавим его в папку src/main/resources/META-INF/services :

./ca4c27e081937b4f05278ce91e247d3c.png

Содержимое этого файла — это просто полное имя реализации SPI:

# SPI class implementation
com.foreach.auth.provider.user.CustomUserStorageProviderFactory

3.3. Реализация UserStorageProvider

На первый взгляд реализация UserStorageProvider выглядит не так, как мы ожидали. Он содержит всего несколько методов обратного вызова, ни один из которых не относится к реальным пользователям. Причина этого в том, что Keycloak ожидает, что наш провайдер также реализует другие смешанные интерфейсы, которые поддерживают определенные аспекты управления пользователями.

Полный список доступных интерфейсов доступен в документации Keycloak , где они называются Provider Capabilities. Для простого поставщика, доступного только для чтения, единственный интерфейс, который нам нужно реализовать, — это UserLookupProvider . Он предоставляет только возможности поиска, а это означает, что Keycloak автоматически импортирует пользователя во внутреннюю базу данных, когда это необходимо. Однако исходный пароль пользователя не будет использоваться для аутентификации. Для этого нам также необходимо реализовать CredentialInputValidator .

Наконец, общим требованием является возможность отображать существующих пользователей в нашем пользовательском магазине в интерфейсе администратора Keycloak. Это требует, чтобы мы реализовали еще один интерфейс: UserQueryProvider . Этот добавляет несколько методов запросов и действует как DAO для нашего магазина.

Итак, учитывая эти требования, наша реализация должна выглядеть так:

public class CustomUserStorageProvider implements UserStorageProvider, 
UserLookupProvider,
CredentialInputValidator,
UserQueryProvider {

// ... private members omitted

public CustomUserStorageProvider(KeycloakSession ksession, ComponentModel model) {
this.ksession = ksession;
this.model = model;
}

// ... implementation methods for each supported capability
}

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

3.4. Реализация UserLookupProvider

Keycloak использует методы этого интерфейса для восстановления экземпляра UserModel с учетом его идентификатора , имени пользователя или электронной почты. Идентификатор в этом случае является уникальным идентификатором для этого пользователя в следующем формате: 'f:' unique_id ':' external_id

  • «f:» — это просто фиксированный префикс, указывающий, что это федеративный пользователь.
  • unique_id — это идентификатор Keycloak для пользователя.
  • external_id — это идентификатор пользователя, используемый данным хранилищем пользователей. В нашем случае это будет значение столбца имени пользователя .

Давайте продолжим и реализуем методы этого интерфейса, начиная с getUserByUsername() :

@Override
public UserModel getUserByUsername(String username, RealmModel realm) {
try ( Connection c = DbUtil.getConnection(this.model)) {
PreparedStatement st = c.prepareStatement(
"select " +
" username, firstName, lastName, email, birthDate " +
"from users " +
"where username = ?");
st.setString(1, username);
st.execute();
ResultSet rs = st.getResultSet();
if ( rs.next()) {
return mapUser(realm,rs);
}
else {
return null;
}
}
catch(SQLException ex) {
throw new RuntimeException("Database error:" + ex.getMessage(),ex);
}
}

Как и ожидалось, это простой запрос к базе данных с использованием предоставленного имени пользователя для поиска информации. Есть два интересных момента, требующих пояснений: DbUtil.getConnection() и mapUser() .

DbUtil — это вспомогательный класс, который каким-то образом возвращает соединение JDBC из информации, содержащейся в ComponentModel , которую мы получили в конструкторе. Мы рассмотрим его детали позже.

Что касается mapUser() , его работа заключается в сопоставлении записей базы данных, содержащих пользовательские данные, с экземпляром UserModel . UserModel представляет сущность пользователя, видимую Keycloak, и имеет методы для чтения ее атрибутов. Наша реализация этого интерфейса, доступная здесь, расширяет класс AbstractUserAdapter , предоставляемый Keycloak. Мы также добавили в нашу реализацию внутренний класс Builder , поэтому mapUser() может легко создавать экземпляры UserModel :

private UserModel mapUser(RealmModel realm, ResultSet rs) throws SQLException {
CustomUser user = new CustomUser.Builder(ksession, realm, model, rs.getString("username"))
.email(rs.getString("email"))
.firstName(rs.getString("firstName"))
.lastName(rs.getString("lastName"))
.birthDate(rs.getDate("birthDate"))
.build();
return user;
}

Точно так же другие методы в основном следуют той же схеме, описанной выше, поэтому мы не будем подробно рассматривать их. Пожалуйста, обратитесь к коду провайдера и проверьте все методы getUserByXXX и searchForUser .

3.5. Получение соединения

Теперь давайте взглянем на метод DbUtil.getConnection() :

public class DbUtil {

public static Connection getConnection(ComponentModel config) throws SQLException{
String driverClass = config.get(CONFIG_KEY_JDBC_DRIVER);
try {
Class.forName(driverClass);
}
catch(ClassNotFoundException nfe) {
// ... error handling omitted
}

return DriverManager.getConnection(
config.get(CONFIG_KEY_JDBC_URL),
config.get(CONFIG_KEY_DB_USERNAME),
config.get(CONFIG_KEY_DB_PASSWORD));
}
}

Мы видим, что ComponentModel — это место, где находятся все необходимые параметры для создания. Но как Keycloak узнает, какие параметры требуются нашему пользовательскому провайдеру? Чтобы ответить на этот вопрос, нам нужно вернуться к CustomUserStorageProviderFactory.

3.6. Метаданные конфигурации

Базовый контракт для CustomUserStorageProviderFactory , UserStorageProviderFactory , содержит методы, которые позволяют Keycloak запрашивать метаданные свойств конфигурации и, что также важно, проверять присвоенные значения . В нашем случае мы определим несколько параметров конфигурации, необходимых для установления соединения JDBC. Поскольку эти метаданные являются статическими, мы создадим их в конструкторе, а getConfigProperties() просто вернет их.

public class CustomUserStorageProviderFactory
implements UserStorageProviderFactory<CustomUserStorageProvider> {
protected final List<ProviderConfigProperty> configMetadata;

public CustomUserStorageProviderFactory() {
configMetadata = ProviderConfigurationBuilder.create()
.property()
.name(CONFIG_KEY_JDBC_DRIVER)
.label("JDBC Driver Class")
.type(ProviderConfigProperty.STRING_TYPE)
.defaultValue("org.h2.Driver")
.helpText("Fully qualified class name of the JDBC driver")
.add()
// ... repeat this for every property (omitted)
.build();
}
// ... other methods omitted

@Override
public List<ProviderConfigProperty> getConfigProperties() {
return configMetadata;
}

@Override
public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config)
throws ComponentValidationException {
try (Connection c = DbUtil.getConnection(config)) {
c.createStatement().execute(config.get(CONFIG_KEY_VALIDATION_QUERY));
}
catch(Exception ex) {
throw new ComponentValidationException("Unable to validate database connection",ex);
}
}
}

В validateConfiguration() мы получим все необходимое для проверки параметров, переданных при добавлении предоставленного нами в Realm . В нашем случае мы используем эту информацию для установления соединения с базой данных и выполнения запроса проверки. Если что-то пойдет не так, мы просто выкинем ComponentValidationException , сигнализируя Keycloak, что параметры недействительны.

Более того, хотя это и не показано здесь, мы также можем использовать метод onCreated() для присоединения логики, которая будет выполняться каждый раз, когда администратор добавляет нашего провайдера в Realm . Это позволяет нам выполнять одноразовую логику времени инициализации, чтобы подготовить наше хранилище к использованию, что может быть необходимо для определенных сценариев. Например, мы могли бы использовать этот метод для изменения нашей базы данных и добавления столбца для записи того, использовал ли уже данный пользователь Keycloak.

3.7. Реализация CredentialInputValidator

Этот интерфейс содержит методы, проверяющие учетные данные пользователя . Поскольку Keycloak поддерживает различные типы учетных данных (пароль, токены OTP, сертификаты X.509 и т. д.), наш провайдер должен сообщить, поддерживает ли он данный тип в supportsCredentialType() и настроен для него в контексте данного Realm в isConfiguredFor. () .

В нашем случае у нас просто есть поддержка паролей, и, поскольку они не требуют дополнительной настройки, мы можем делегировать более поздний метод первому:

@Override
public boolean supportsCredentialType(String credentialType) {
return PasswordCredentialModel.TYPE.endsWith(credentialType);
}

@Override
public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
return supportsCredentialType(credentialType);
}

Фактическая проверка пароля происходит в методе isValid() :

@Override
public boolean isValid(RealmModel realm, UserModel user, CredentialInput credentialInput) {
if(!this.supportsCredentialType(credentialInput.getType())) {
return false;
}
StorageId sid = new StorageId(user.getId());
String username = sid.getExternalId();

try (Connection c = DbUtil.getConnection(this.model)) {
PreparedStatement st = c.prepareStatement("select password from users where username = ?");
st.setString(1, username);
st.execute();
ResultSet rs = st.getResultSet();
if ( rs.next()) {
String pwd = rs.getString(1);
return pwd.equals(credentialInput.getChallengeResponse());
}
else {
return false;
}
}
catch(SQLException ex) {
throw new RuntimeException("Database error:" + ex.getMessage(),ex);
}
}

Здесь стоит обсудить пару моментов. Во-первых, обратите внимание, как мы извлекаем внешний идентификатор из UserModel, используя объект StorageId , инициализированный из идентификатора Keycloak. Мы могли бы использовать тот факт, что этот идентификатор имеет общеизвестный формат, и извлечь оттуда имя пользователя, но здесь лучше перестраховаться и позволить этим знаниям инкапсулироваться в классе, предоставленном Keycloak.

Далее идет фактическая проверка пароля. Для нашей упрощенной и, конечно, очень небезопасной базы данных проверка пароля тривиальна: просто сравните значение базы данных со значением, предоставленным пользователем, доступным через getChallengeResponse() , и все готово. Конечно, реальному провайдеру потребуется еще несколько шагов, таких как генерация хэш-информированного пароля и солт-значения из базы данных и сравнение хэшей.

Наконец, пользовательские хранилища обычно имеют некоторый жизненный цикл, связанный с паролями: максимальный возраст, заблокированный и/или неактивный статус и так далее. Несмотря на это, при реализации поставщика метод isValid() является местом для добавления этой логики.

3.8. Реализация UserQueryProvider

Интерфейс возможностей UserQueryProvider сообщает Keycloak, что наш провайдер может искать пользователей в своем магазине. Это удобно, поскольку, поддерживая эту возможность, мы сможем видеть пользователей в консоли администратора.

Методы этого интерфейса включают getUsersCount() для получения общего количества пользователей в магазине и несколько методов getXXX() и searchXXX() . Этот интерфейс запросов поддерживает поиск не только пользователей, но и групп, которые мы не будем рассматривать в этот раз.

Поскольку реализация этих методов очень похожа, давайте рассмотрим только один из них, searchForUser() :

@Override
public List<UserModel> searchForUser(String search, RealmModel realm, int firstResult, int maxResults) {
try (Connection c = DbUtil.getConnection(this.model)) {
PreparedStatement st = c.prepareStatement(
"select " +
" username, firstName, lastName, email, birthDate " +
"from users " +
"where username like ? +
"order by username limit ? offset ?");
st.setString(1, search);
st.setInt(2, maxResults);
st.setInt(3, firstResult);
st.execute();
ResultSet rs = st.getResultSet();
List<UserModel> users = new ArrayList<>();
while(rs.next()) {
users.add(mapUser(realm,rs));
}
return users;
}
catch(SQLException ex) {
throw new RuntimeException("Database error:" + ex.getMessage(),ex);
}
}

Как видим, здесь нет ничего особенного: обычный JDBC-код. Замечание по реализации, о котором стоит упомянуть: методы UserQueryProvider обычно поставляются в версиях с выгружаемыми и не выгружаемыми страницами. Поскольку пользовательское хранилище потенциально может иметь большое количество записей, невыгружаемые версии должны просто делегироваться выгружаемым версиям, используя разумное значение по умолчанию. Еще лучше, мы можем добавить параметр конфигурации, который определяет, что такое «разумное значение по умолчанию».

4. Тестирование

Теперь, когда мы внедрили нашего провайдера, пришло время протестировать его локально с помощью встроенного экземпляра Keycloak. Код проекта содержит живой тестовый класс, который мы использовали для начальной загрузки Keycloak и пользовательской базы данных пользователей, а затем просто распечатывали URL-адреса доступа на консоли перед тем, как спать в течение одного часа.

Используя эту настройку, мы можем убедиться, что наш пользовательский провайдер работает должным образом, просто открыв распечатанный URL-адрес в браузере:

./869e24d6eb66687e4e5570677095454b.png

Чтобы получить доступ к консоли администрирования, мы будем использовать учетные данные администратора, которые мы можем получить, просмотрев файл application-test.yml . После входа в систему давайте перейдем на страницу «Информация о сервере»:

./50892bf50fc27ecda31d5e754ba5018a.png

На вкладке «Поставщики» мы видим, что наш пользовательский поставщик отображается рядом с другими встроенными поставщиками хранилища:

./de86c5c2ae2101b758a71d0652a9834b.png

Мы также можем проверить, что область ForEach уже использует этого провайдера. Для этого мы можем выбрать его в раскрывающемся меню в верхнем левом углу, а затем перейти на страницу User Federation :

./c4ce67ac7a214d075a7ebf81799ec9c7.png

Далее, давайте проверим реальный вход в эту область. Мы будем использовать страницу управления учетной записью области, где пользователь может управлять своими данными. Наш Live Test распечатает этот URL-адрес перед переходом в спящий режим, поэтому мы можем просто скопировать его из консоли и вставить в адресную строку браузера.

Тестовые данные содержат трех пользователей: user1, user2 и user3. Пароль для всех один и тот же: «changeit». После успешного входа мы увидим страницу управления учетной записью, отображающую данные импортированного пользователя:

./e317a297574632caf4755ff94f42ca0c.png

Однако, если мы попытаемся изменить какие-либо данные, мы получим ошибку. Это ожидаемо, так как наш провайдер доступен только для чтения, поэтому Keycloak не позволяет его модифицировать. Пока оставим все как есть, так как поддержка двунаправленной синхронизации выходит за рамки этой статьи.

5. Вывод

В этой статье мы показали, как создать собственный провайдер для Keycloak, используя в качестве конкретного примера провайдера хранилища пользователей. Полный исходный код примеров можно найти на GitHub .