Интегрируя Keycloak в уже существующую систему, высока вероятность столкнуться с необходимостью во время аутентификации загружать пользователей из древней базы данных, где информация о них может храниться в довольно причудливом виде. Такая задача решается созданием собственного провайдера пользователей (User Federation Provider в терминологии Keycloak). Ниже будет представлено краткое руководство по написанию такого провайдера.


Если вдруг вы не знакомы с Keycloak, то вот вам цитата из Wikipedia:

Keycloak продукт с открытым кодом для реализации single sign-on с возможностью управления доступом, нацелен на современные применения и сервисы.

В современном микросервисном мире Keycloak интересен прежде всего как провайдер OAuth 2.0, с помощью которого можно выдавать клиентам токены для доступа к тем или иным сервисам.

В техническом плане Keycloak - это web-приложение внутри сервера WildFly, что у кого-то может вызвать мурашки по телу от воспоминаний о кровавом энтерпрайзе. Впрочем, довольно теории, пора засучить рукава!

Наш плагин для Keycloak будет представлять собой небольшое приложение, упакованное в WAR. Для его сборки будет достаточно Java 8. В качестве инструмента сборки возьмём Gradle, а в зависимостях укажем следующие модули:

compileOnly "org.keycloak:keycloak-core:12.0.3"
compileOnly "org.keycloak:keycloak-server-spi:12.0.3"
compileOnly "org.jboss.logging:jboss-logging:3.4.1.Final"

implementation "org.springframework:spring-core:5.3.3"
implementation "org.springframework:spring-jdbc:5.3.3"
implementation "org.springframework.security:spring-security-core:5.4.4"

testImplementation platform('org.junit:junit-bom:5.7.1')
testImplementation 'org.junit.jupiter:junit-jupiter'
testImplementation 'org.mockito:mockito-core:3.7.7'

testRuntimeOnly 'javax.ws.rs:javax.ws.rs-api:2.1.1'
testRuntimeOnly 'com.h2database:h2:1.4.200'

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

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

Для логирования действий нашего провайдера был выбран JBoss Logging, как "родное" решение в случае сервера WildFly. Допускаю, что можно писать в лог, пользуясь чем-то более привычным, вроде SLF4J.

Также мы воспользуемся плагином io.freefair.lombok.

Провайдеры пользователей в Keycloak представлены интерфейсом org.keycloak.storage.UserStorageProvider, который тем не менее, не содержит особенно интересных методов. Те методы, которые мы будем реализовывать, содержатся в таких интерфейсах, как org.keycloak.storage.user.UserLookupProvider и org.keycloak.credential.CredentialInputValidator. Первый интерфейс предоставляет методы поиска пользователей по имени, идентификатору или адресу электронной почты. Второй интерфейс - метод валидации пароля. Начнём с методов поиска.

Для начала создадим реализацию интерфейса org.keycloak.models.UserModel (именно её нужно будет возвращать из методов поиска пользователей). Для этого удобно будет наследоваться от org.keycloak.storage.adapter.AbstractUserAdapter, который предоставляет реализации по умолчанию для многих методов интерфейса org.keycloak.models.UserModel:

public class LegacyDatabaseUserModel extends AbstractUserAdapter {
    public static final String ATTRIBUTE_PASSWORD = "password";
  
    private final MultivaluedHashMap<String, String> attributes = new MultivaluedHashMap<>();
    private final Set<RoleModel> roles;

    private LegacyDatabaseUserModel(Builder builder) {
        super(builder.session, builder.realm, builder.storageProviderModel);
        this.attributes.putSingle(UserModel.USERNAME, builder.username);
        this.attributes.putSingle(UserModel.FIRST_NAME, builder.firstName);
        this.attributes.putSingle(UserModel.LAST_NAME, builder.lastName);
        this.attributes.putSingle(ATTRIBUTE_PASSWORD, builder.password);
        this.roles = Collections.unmodifiableSet(builder.roles);
    }

    public static Builder builder() {
        return new Builder();
    }
  
    @Override
    public String getUsername() {
        return getFirstAttribute(UserModel.USERNAME);
    }

    @Override
    public String getFirstName() {
        return getFirstAttribute(UserModel.FIRST_NAME);
    }

    @Override
    public String getLastName() {
        return getFirstAttribute(UserModel.LAST_NAME);
    }
  
    @Override
    public Map<String, List<String>> getAttributes() {
        return new MultivaluedHashMap<>(attributes);
    }

    @Override
    public String getFirstAttribute(String name) {
        return attributes.getFirst(name);
    }

    @Override
    public List<String> getAttribute(String name) {
        return attributes.get(name);
    }

    @Override
    protected Set<RoleModel> getRoleMappingsInternal() {
        return roles;
    }

    public static class Builder {
        ...
    }
}

Набор атрибутов пользователя не велик, а сами атрибуты неизменяемы, что также поддерживается родительским классом, в котором методы-мутаторы по умолчанию бросают исключения. Почему атрибуты собраны в Map, а не представлены отдельными полями класса, станет понятно чуть позже - в момент валидации пароля.

Далее нам понадобится реализация интерфейса org.keycloak.models.RoleModel для представления ролей пользователя. Для неё я не обнаружил удобного адаптера, поэтому придётся предоставить реализации всех необходимых методов самостоятельно:

@AllArgsConstructor
public class LegacyDatabaseRoleModel implements RoleModel {
    @Getter
    private final RoleContainerModel container;
    @Getter
    private final String name;

    @Override
    public String getId() {
        return getName();
    }

    @Override
    public void setName(String name) {
        throw new ReadOnlyException("Role is read only for this update");
    }

    @Override
    public String getDescription() {
        return null;
    }

    @Override
    public void setDescription(String description) {
        throw new ReadOnlyException("Role is read only for this update");
    }

    @Override
    public boolean isComposite() {
        return false;
    }

    @Override
    public void addCompositeRole(RoleModel role) {
        throw new ReadOnlyException("Role is read only for this update");
    }

    @Override
    public void removeCompositeRole(RoleModel role) {
        throw new ReadOnlyException("Role is read only for this update");
    }

    @Override
    public Stream<RoleModel> getCompositesStream() {
        return Stream.empty();
    }

    @Override
    public boolean isClientRole() {
        return false;
    }

    @Override
    public String getContainerId() {
        return container.getId();
    }

    @Override
    public boolean hasRole(RoleModel role) {
        return false;
    }

    @Override
    public Map<String, List<String>> getAttributes() {
        return Collections.emptyMap();
    }

    @Override
    public void setSingleAttribute(String name, String value) {
        throw new ReadOnlyException("Role is read only for this update");
    }

    @Override
    public void setAttribute(String name, List<String> values) {
        throw new ReadOnlyException("Role is read only for this update");
    }

    @Override
    public void removeAttribute(String name) {
        throw new ReadOnlyException("Role is read only for this update");
    }

    @Override
    public Stream<String> getAttributeStream(String name) {
        return Stream.empty();
    }
}

Модель роли фактически состоит лишь из названия роли, которое также выбрано в качестве её идентификатора. По аналогии с адаптером модели пользователя, методы-мутаторы бросают исключения.

Теперь переходим непосредственно к провайдеру пользователей. Загружать информацию о пользователях будем только по имени пользователя или по его идентификатору. Адрес электронной почты проигнорируем. Метод загрузки по имени выглядит следующим образом:

private final ConcurrentMap<UserModelKey, LegacyDatabaseUserModel> loadedUsers = new ConcurrentHashMap<>();

@Override
public LegacyDatabaseUserModel getUserByUsername(String username, RealmModel realm) {
    UserModelKey userKey = new UserModelKey(username, realm.getId());
    return loadedUsers.computeIfAbsent(userKey, k -> {
        LegacyDatabaseUserModel user = findUserByName(username, realm);
        if (user != null) {
            log.debugv("User is loaded by name \"{0}\"", username);
        }
        return user;
    });
}

Полезно кэшировать ранее загруженных пользователей, поскольку Keycloak в процессе аутентификации может не один раз запросить модель пользователя у нашего провайдера. Здесь применён примитивный подход с java.util.concurrent.ConcurrentMap. Метод findUserByName обращается к базе данных, используя org.springframework.jdbc.core.JdbcTemplate и собственный org.springframework.jdbc.core.ResultSetExtractor, который раскладывает все атрибуты пользователя по полочкам, попутно формируя набор его ролей.

private LegacyDatabaseUserModel findUserByName(String username, RealmModel realm) {
	return jdbcTemplate.query(SQL_FIND_USER_BY_NAME, new Object[]{username}, new int[]{Types.VARCHAR},
				new LegacyDatabaseUserModelResultSetExtractor(realm));
}
@RequiredArgsConstructor
private class LegacyDatabaseUserModelResultSetExtractor implements ResultSetExtractor<LegacyDatabaseUserModel> {
    final RealmModel realm;

    @Override
    public LegacyDatabaseUserModel extractData(ResultSet rs) throws SQLException, DataAccessException {
        if (!rs.next()) {
            return null;
        }

        LegacyDatabaseUserModel.Builder userModelBuilder = LegacyDatabaseUserModel.builder()
                .session(session)
                .realm(realm)
                .storageProviderModel(storageProviderModel)
                .username(rs.getString(1))
                .password(rs.getString(2))
                .firstName(rs.getString(3))
                .lastName(rs.getString(4))
                .withRole(new LegacyDatabaseRoleModel(realm, rs.getString(5)));

        while (rs.next()) {
            userModelBuilder.withRole(new LegacyDatabaseRoleModel(realm, rs.getString(5)));
        }

        return userModelBuilder.build();
    }
}

Загрузка информации о пользователе по его идентификатору не намного сложнее. Потребуется лишь извлечь имя пользователя из этого идентификатора:

@Override
public LegacyDatabaseUserModel getUserById(String id, RealmModel realm) {
	StorageId storageId = new StorageId(id);
	String username = storageId.getExternalId();
	return getUserByUsername(username, realm);
}

По умолчанию идентификатор имеет следующий вид: f:<storageProvideId>:<username>. Для извлечения имени пользователя удобно воспользоваться классом org.keycloak.storage.StorageId.

Наконец, метод валидации пароля:

@Override
public boolean isValid(RealmModel realm, UserModel userModel, CredentialInput credentialInput) {
    if (!supportsCredentialType(credentialInput.getType())) {
        log.debugv("Credential type \"{0}\" is not supported", credentialInput.getType());
        return false;
    }

    String password = user.getFirstAttribute(LegacyDatabaseUserModel.ATTRIBUTE_PASSWORD);
    return passwordEncoder.matches(credentialInput.getChallengeResponse(), password);
}

В нём мы сначала проверяем, что текущий способ аутентификации нам подходит, а готовы откликнуться мы только на аутентификацию по паролю (среди альтернативных вариантов может быть аутентификация по одноразовому коду). Затем извлекаем пароль из атрибутов пользователя. Вот тут-то нам и пригодилось хранение атрибутов пользователя в Map. Интерфейс org.keycloak.models.UserModel не содержит метода получения пароля пользователя, кроме того, не стоит надеяться на возможность "скастить" аргумент метода isValid к нашему классу com.habr.keycloak.model.LegacyDatabaseUserModel - за интерфейсом может скрываться совершенно иная реализация. Сам процесс валидации пароля делегирован реализации интерфейса org.springframework.security.crypto.password.PasswordEncoder.

Провайдеры в Keycloak не создаются сами собой. Им требуются фабрики. В нашем случае это должна быть реализация интерфейса org.keycloak.storage.UserStorageProviderFactory. Во время инициализации нашей фабрики мы сделаем следующее:

  1. создадим источник данных для соединения с базой данных;

  2. создадим подходящий кодировщик паролей.

@Override
public void init(Config.Scope config) {
    initDataSource();
    initPasswordEncoder();
}

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

private PropertySource<Map<String, Object>> getPropertySource() {
    if (propertySource == null) {
        propertySource = getDefaultPropertySource();
    }
    return propertySource;
}

private PropertySource<Map<String, Object>> getDefaultPropertySource() {
    return new PropertiesPropertySource("default", System.getProperties());
}

Вообще, можно пойти по Keycloak-way и загрузить параметры из файла *.properties, путь к которому задан в standalone.xml:

@Override
public void init(Config.Scope config) {
    String propertyFilePath = config.get("property-file-path");
    ...

Но это на мой взгляд уже вкусовщина.

Источник данных у нас будет крайне простой, без пула соединений:

private void initDataSource() {
    String driverClassName = getDataSourceDriverClassName();
    String url = getDataSourceUrl();

    SimpleDriverDataSource dataSource = new SimpleDriverDataSource();
    try {
        dataSource.setDriverClass((Class<? extends Driver>) Class.forName(driverClassName));
        dataSource.setUrl(url);
        dataSource.setUsername(getDataSourceUsername());
        dataSource.setPassword(getDataSourcePassword());
        this.dataSource = dataSource;
        log.debugv("Data source to connect with database \"{0}\" is created", url);
    } catch (ClassNotFoundException e) {
        throw new IllegalStateException("JDBC driver class \"" + driverClassName + "\" is not found", e);
    }
}

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

В качестве кодировщика паролей я взял org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder.

Финальный шаг - регистрация фабрики по стандартам Factory Finder'а, что подразумевает создание в каталоге META-INF/services файла org.keycloak.storage.UserStorageProviderFactory, содержащего имя класса нашей фабрики провайдеров.

Как было указано ранее, плагин загружает драйвер базы данных из определённого модуля Keycloak. Для того, чтобы сказать Keycloak, что мы зависим от этого модуля, потребуется дополнительно создать файл jboss-deployment-structure.xml в каталоге META-INF:

<?xml version="1.0" encoding="UTF-8"?>
<jboss-deployment-structure>
    <deployment>
        <dependencies>
            <module name="org.postgresql"/>
        </dependencies>
    </deployment>
</jboss-deployment-structure>

Чтобы Keycloak подхватил наш плагин, его (плагин) следует положить в каталог $KEYCLOAK_HOME/standalone/deployments. В случае успеха с развёртыванием плагина в админке Keycloak в разделе User Federation появится возможность добавить провайдер с идентификатором habr.legacy-database, после чего можно приступать к выдаче токенов.

Исходный код плагина доступен на GitHub.

На этом всё. Спасибо за внимание!