Pull to refresh
0
Arenadata
Разработчик платформы данных на базе open source

Единая точка входа с Keycloak и Яндекс в условиях большого переезда

Reading time20 min
Views15K

Так же, как и многие другие компании, мы долго и счастливо использовали целый стек популярных коммерческих облачных сервисов: Github для кода, Slack для общения, Jira для ведения задач, Confluence для внутренней документации, Artifactory и Allure для разработки — и связывал это всё воедино Google Workspace, который не только решал офисные и почтовые задачи, но и выступал в качестве SSO для всех используемых сервисов.

Последние события всю эту идиллию разрушили, и пришлось достаточно быстро искать и реализовывать альтернативу из отечественных сервисов и open source продуктов (хуже ремонта, как известно, только переезд). С учётом того, что альтернатив не так много и вся наша инфраструктура развернута в облаке Яндекс, в итоге мы получили следующий стек:

  • Yandex 360 вместо Google Workspace, офисные приложения и почта;

  • on-premises Gitlab вместо Github и Jira;

  • on-premises Mattermost вместо Slack;

  • BookStack вместо Confluence.

При этом — возможно, в силу привычки к Google — хотелось держать базу пользователей в Яндекс: офисные задачи никто не отменял, и время тоже поджимало.

Open source продукты в большинстве случаев поддерживают стандартные протоколы аутентификации OpenID Connect и SAML. Поскольку протокол аутентификации Google соответствует спецификации OpenID Connect, при организации SSО-средствами Google проблем с поддержкой в on-premises, а тем более в облачных решениях не возникает.

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

Таким образом, необходимо некое дополнительное связующее звено, выступающее в качестве адаптера между стандартными протоколами и специфическим протоколом Яндекса.

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

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

Задача-минимум звучала так: связать через единую точку аутентификации Яндекс, Gitlab, Mattermost, Bookstack и хотя бы часть сервисов разработки в некие разумные сроки.

Собственно, Keycloak

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

На данный момент Keycloak предусматривает два варианта сборки: устаревший, на базе сервера приложений Wildfly (он же JBoss), и новый, на базе фреймворка Quarkus. Большинство материалов про Keycloak написано про WildFly, и мы начали с него, просто использовав готовый Dockerfile. Лучше сразу использовать Quarkus, потому что:

  • Widlfly-вариант прекратит существование осенью этого года (Keycloak release plans for 2022);

  • итоговый контейнер меньше, потребляет меньше ресурсов и работает быстрее.

Переменные окружения контейнеров и некоторые параметры запуска для Wildfly и Quarkus отличаются, поэтому, читая различные статьи, нужно обращать внимание, о каком варианте сборки идет речь (скорее всего, это будет WildFly). Удобное полное описание конфигурации Quarkus с разделением на параметры сборки и параметры запуска приведено в документации. Ключевой момент здесь — параметры сборки отдельно, параметры запуска отдельно (хотя и есть режим auto-build, позволяющий их сочетать, например, в процессе разработки). Различия между сборками необходимо учитывать также и при размещении Keycloak за reverse-прокси: в Quarkus отказались от использования префиксов auth/ в адресах HTTP-ресурсов, при настройке reverse-прокси имеет смысл сразу ориентироваться на актуальные пути, описанные в документации. В итоге мы стали использовать Quarkus-версию, разместив keycloak за reverse-прокси и используя отдельный домен для административного доступа.

Расширяемость Keycloak

На самом деле killer feature Keycloak даже не в том, что он очень многое умеет, а в том, что его возможности можно расширять без необходимости внесения изменений непосредственно в код Keycloak. Реализуется это за счёт стандартной технологии Java SPI (Service Provider Interface), и физически выглядит как динамическая загрузка приложением специальным образом подготовленного jar-файла, содержащего, помимо собственно классов Java, текстовые файлы, определяющие связь интерфейса (сервиса) и реализующего его класса (провайдера сервиса в терминологии SPI): Service Provider Interfaces.

Используя механизм расширений, вы можете реализовывать:

  • поддержку внешних сервисов авторизации (помимо достаточно большого количества, которое уже поддерживается в стандартной поставке, в том числе Google, Github, OpenShift и т. д.);

  • дополнительные шаги процесса аутентификации, в том числе настраиваемые;

  • собственные способы хранения информации о пользователях;

  • собственные хранилища секретов;

  • обработчики событий Keycloak.

В большинстве случаев расширения пишутся на Java, однако в некоторых случаях (например, для дополнительных шагов аутентификации) можно (с некоторыми ограничениями) использовать JavaScript, который будет выполняться встроенным интерпретатором.

Интеграция с Яндекс

Как уже говорилось, Яндекс использует собственный протокол на базе OAuth2, и соответствующего провайдера в стандартной сборке Keycloak, конечно же, нет.

Поскольку Keycloak — это расширяемая система, поддержку особенностей протокола аутентификации можно реализовать путём создания собственного расширения, реализующего SPI-интерфейсы identity-провайдера. Немного погуглив, можно найти готовую реализацию набора расширений для отечественных облачных сервисов (Яндекс, Mail.ru и т. д.): https://github.com/playa-ru/keycloak-russian-providers от компании playa.ru, и мы начали с того, что собрали Keycloak с этим набором расширений. Он действительно работает, и в определённых обстоятельствах его, наверное, можно использовать, но нам он не подошёл:

  • нет поддержки ограничений на домен учётной записи пользователя: нам необходимо, чтобы могли авторизовываться только пользователи в корпоративном почтовом домене, а в случае этого набора плагинов авторизоваться может любой с валидной учётной записью в Яндекс;

  • хотелось минимизировать зависимости и иметь возможность добавить какую-либо дополнительную функциональность в плагин.

Поэтому, несмотря на то что у нас админская команда и мы не пишем на Java, было решено реализовать собственный провайдер, взяв за основу проект playa.ru и посмотрев на простые провайдеры, реализованные в самом Keyclock, например identity-провайдер Github.

Основная логика OAuth2-авторизации уже реализована в Keycloak в виде абстрактного класса AbstractOAuth2IdentityProvider, от которого мы и наследуем класс провайдера. В унаследованном классе мы определяем набор констант, описывающих ресурсы OAuth2-авторизации на стороне Яндекса, которые будут использованы в конфигурации провайдера:

public class YandexIdentityProvider extends
AbstractOAuth2IdentityProvider<YandexIdentityProviderConfig>
		implements SocialIdentityProvider<YandexIdentityProviderConfig> {
  public static final String AUTH_URL = "https://oauth.yandex.ru/authorize";
  public static final String TOKEN_URL = "https://oauth.yandex.ru/token";
  public static final String PROFILE_URL = "https://login.yandex.ru/info";
  
  public YandexIdentityProvider(KeycloakSession session, YandexIdentityProviderConfig config) {
  	super(session, config);
    config.setAuthorizationUrl(AUTH_URL);
    config.setTokenUrl(TOKEN_URL);
    config.setUserInfoUrl(PROFILE_URL);
  }
  
  @Override
  protected String getProfileEndpointForValidation(EventBuilder event) {
  	return PROFILE_URL;
  }

  // ...
}

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

public static final String PROFILE_URL = "https://login.yandex.ru/info";

@Overrideprotected BrokeredIdentityContext doGetFederatedIdentity(String accessToken) {
  log.debug("doGetFederatedIdentity()");
  JsonNode profile = null;
  try {
    profile = SimpleHttp.doGet(PROFILE_URL, session)
      .header("Authorization", "OAuth " + accessToken)
      .asJson();
    return extractIdentityFromProfile(null, profile);
  } catch (Exception e) {
    throw new IdentityBrokerException("Could not obtain user profile from Yandex: " + e.getMessage(), e);
  }
}

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

@Overrideprotected BrokeredIdentityContext extractIdentityFromProfile(EventBuilder event, JsonNode profile) {
  BrokeredIdentityContext user = new BrokeredIdentityContext(getJsonProperty(profile, "id"));
  String domain = ((YandexIdentityProviderConfig) getConfig()).getHostedDomain();
  String email = getJsonProperty(profile, "default_email");
  if (email == null || email.trim().isEmpty()) {
    throw new IllegalArgumentException("Can not get user's email.");
  }
  if (domain != null && !domain.isEmpty() && !email.endsWith("@" + domain)) {
    throw new IllegalArgumentException("Hosted domain does not match.");
  }
  String login = getJsonProperty(profile, "login");
  if (login == null || login.trim().isEmpty()) {
    user.setUsername(email);
  } else {
    user.setUsername(login);
  }
  user.setEmail(email);
  user.setFirstName(getJsonProperty(profile, "first_name"));
  user.setLastName(getJsonProperty(profile, "last_name"));
  user.setIdpConfig(getConfig());
  user.setIdp(this);
  AbstractJsonUserAttributeMapper.storeUserProfileForMapper(user, profile, getConfig().getAlias());
  return user;
}

Помимо собственно класса провайдера, нам понадобится класс описания конфигурации провайдера, в котором мы определим параметр конфигурации hostedDomain для проверки домена:

public class YandexIdentityProviderConfig extends OAuth2IdentityProviderConfig {
  public YandexIdentityProviderConfig(IdentityProviderModel model) {
    super(model);
  }
  public YandexIdentityProviderConfig() {
  }
  public String getHostedDomain() {
    String hostedDomain = getConfig().get("hostedDomain");
    return hostedDomain == null || hostedDomain.isEmpty() ? null : hostedDomain;
  }
  public void setHostedDomain(final String hostedDomain) {
    getConfig().put("hostedDomain", hostedDomain);
  }
}

После этого останется только создать пару классов YandexIdentityProviderFactory и YandexUserAttributeMapper, которые позволяет использовать наш провайдер в Keycloak, по аналогии с любым другим провайдером.

Для того, чтобы наш класс мог быть погружён механизмом SPI, в создаваемом jar необходимо разместить также текстовые файлы, описывающие связь между SPI-интерфейсами и созданными нами классами:

resources/
  META.INF/
    services/
      org.keycloak.broker.provider.IdentityProviderMapper
      org.keycloak.broker.social.SocialIdentityProviderFactory

Имя файла при этом соответствует интерфейсу, а содержимое — реализации: (io.arenadata.keycloak.providers.yandex.YandexUserAttributeMapper и io.arenadata.keycloak.providers.yandex.YandexIdentityProviderFactory).

Сборка выполняется с помощью Maven, со следующими зависимостями:

<dependencies>
  <dependency>
    <groupId>org.keycloak</groupId>
    <artifactId>keycloak-core</artifactId>
    <scope>provided</scope>
    <version>${version.keycloak}</version>
  </dependency>
  <dependency>
    <groupId>org.keycloak</groupId>
    <artifactId>keycloak-server-spi</artifactId>
    <scope>provided</scope>
    <version>${version.keycloak}</version>
  </dependency>
  <dependency>
    <groupId>org.keycloak</groupId>
    <artifactId>keycloak-server-spi-private</artifactId>
    <scope>provided</scope>
    <version>${version.keycloak}</version>
  </dependency>
  <dependency>
    <groupId>org.keycloak</groupId>
    <artifactId>keycloak-services</artifactId>
    <scope>provided</scope>
    <version>${version.keycloak}</version>
  </dependency>
</dependencies>

Итак, мы реализовали провайдер, добились с его помощью связки с Яндексом и можем переходить, собственно, к интересующим нас сервисам. Тут, казалось бы, всё сразу должно было получиться, но на практике всё оказалось не так просто, как мы рассчитывали.

Gitlab

Начали мы с Gitlab. Он у нас уже был, поскольку постепенную миграцию на него с Github мы рассматривали уже давно, и в нём уже работала аутентификация Google Workspace, настроенная с использованием протокола SAML. Keycloak тоже поддерживает SAML, поэтому мы, недолго думая, просто перенастроили SAML-аутентификацию с Google на Keycloak, и всё сразу получилось.

Тут надо отметить одно очень полезное качество Gitlab: одна учётная запись Gitlab может быть привязана сразу к нескольким identity-провайдерам, уникальным идентификатором в этом случае является адрес электронной почты. Поэтому, например, можно создать часть пользователей локально и потом связать их с внешним провайдером, можно использовать несколько провайдеров одновременно и т. д. На самом деле, такая гибкость нетипична, гораздо распространённее ситуация, когда учётная запись привязана к какому-то одному провайдеру, и мы столкнулись с этим позже при подключении других сервисов.

Вдохновившись успехом с Gitlab, мы перешли к Mattermost, и вот с ним всё оказалось значительно сложнее.

Mattermost

Mattermost блокирует сайт для российских IP, так что речь сразу шла об Open Source Team Edition. Возможности этой версии по организации SSO ограничены единственным провайдером, и этот провайдер, как ни странно, — Gitlab (на самом деле никакой странности нет: Mattermost используется в качестве средства общения в дистрибутивах Gitlab).

Gitlab у нас на тот момент уже был, и технически можно было бы использовать его для аутентификации, но процесс получался слишком сложный (аутентификация в Mattermost через аутентифкацию в Gitlab через аутентификацию в Яндекс). Хотелось просто использовать Keycloak в качестве SSO. Опять же, погуглив, мы нашли примеры настройки протокола аутентификации Gitlab в Keycloak, например: Mattermost Teams Edition— Replacing Gitlab SSO with Keycloak.

И вот тут нас поджидала проблема.

Специфика авторизации Gitlab в Mattermost подразумевает получение от Gitlab уникального числового идентификатора пользователя. Для локального пользователя Keycloak или при использовании LDAP его легко добавить в

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

Задачу нужно было решать быстро, ковыряться в Java не хотелось, и мы решили срезать углы и использовать хак для генерации уникальных числовых идентификаторов, которые могли бы использоваться в качестве идентификаторов пользователей Gitlab в Mattermost. Мы написали триггер для базы данных Keycloak примерно такого содержания:

CREATE SEQUENCE IF NOT EXISTS mattermostid AS BIGINT INCREMENT BY 1 MINVALUE 1 NO CYCLE;
CREATE OR REPLACE FUNCTION add_mattermostid()
	RETURNS TRIGGER
  LANGUAGE PLPGSQL
AS
$$
BEGIN
	INSERT INTO user_attribute(id, name,value,user_id) VALUES(
    md5(random()::text || clock_timestamp()::text)::uuid::text,
    'mattermostid',
    nextval('mattermostid'),
    NEW.id
  );
  RETURN NEW;
END;
$$
CREATE TRIGGER user_added AFTER INSERT ON user_entity FOR EACH ROW EXECUTE
PROCEDURE add_mattermostid();

При создании пользователя соответствующая запись добавляется в таблицу пользователей, срабатывает триггер, уникальный последовательный идентификатор добавляется в таблицу атрибутов, у пользователя чудесным образом появляется атрибут mattermostid, используемый в настройках клиента Keycloak для Mattermost, и аутентификация в Mattermost работает. Решение простое, в каком-то смысле красивое, позволило включить в работу Mattermost и в итоге оказалось неправильным, но об этом чуть позже. Отметим, что решение тем не менее имеет право на существование, если, например, нужна аутентификация Mattermost Team Edition каким-то внешним провайдером, а Gitlab при этом вообще не используется.

Импорт сообщений из Slack

Попутно мы решали проблему импорта сообщений из Slack в Mattermost.

Mattermost поддерживает импорт из Slack, при этом он не только копирует каналы и сообщения, но и создаёт пользовательские учётные записи, соответствующие авторам импортируемых сообщений (жаль только, что импортировать полную длительную историю нам не позволили ограничения на стороне сервиса Slack). При этом у пользователя Mattermost может быть только один источник аутентификации, и, как несложно догадаться, в результате импорта мы получили множество локальных пользователей без идентификатора пользователя Gitlab. Радует в этом только то, что импорт — всё-таки разовая операция, и для новых пользователей, не появляющихся в результате импорта, каких-либо проблем не возникает.

К счастью, и у Keycloak, и у Mattermost есть REST API, причём у Mattermost предусмотрен специальный вызов, позволяющий изменить сервис аутентификации учётной записи.

Мы написали несложный скрипт, проверяющий, существует ли импортируемый пользователь в Keycloak, создающий его при необходимости и устанавливающий значение идентификатора пользователя Gitlab в значение атрибута, полученного от Keycloak (при добавлении пользователя оно автоматически сгенерируется триггером). Единственный минус — создаваемый в Keycloak пользователь по умолчанию локальный и может быть привязан к конкретному identity-провайдеру либо отдельным вызовом REST API, либо явной процедурой подтверждения пользователем своего внешнего аккаунта.

Вызов REST API в нашем случае оказался бесполезным, потому что в скрипте у нас не было возможности получить уникальный идентификатор пользователя Яндекс (кстати, на момент миграции API пользователей организации Яндекс ещё не существовал, да и он вряд ли бы нам помог). Поэтому мы решили пойти по пути модификации последовательности действий, выполняемых при первом логине пользователя (First Login Flow).

Keycloak позволяет редактировать существующую последовательность действий или создавать собственные из расширяемого набора предопределённых действий с помощью простого визуального редактора.

Последовательность по умолчанию (First Broker Login) позволяет пользователю привязать свою учётную запись к провайдеру, но требует дополнительных действий по подтверждению аккаунта. На этапе миграции с учётом большого количества пользователей такой сценарий вызывал слишком много организационных проблем. С учётом этого, а также того, что identity-провайдер у нас всего один, на период миграции мы упростили сценарий, сведя его к созданию нового пользователя (Create User If Unique) и автоматической привязке пользователя к провайдеру (Automatically Set Existing User).

Установив новый сценарий в качестве First Login Flow для провайдера Яндекса, мы получили автоматическую привязку локального пользователя с почтовым адресом в домене организации к провайдеру, и таким образом, казалось бы, вопрос аутентификации в ходе миграции на Mattermost был решён.

Но тут мы обнаружили, что проглядели один очень важный момент, и нам опять пришлось вернуться к программированию на Java.

Интеграция Gitlab и Mattermost

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

Тут мы, конечно, приуныли, потому что, во-первых, реализованное решение оказалось неправильным, а во-вторых, интеграция нужна.

Пришлось пересмотреть подход к генерации mattermostid в Keycloak.

Изначально мы не считали необходимым наличие для каждого пользователя учётной записи в Gitlab, потому что не все сотрудники программисты. С другой стороны, с учётом отказа от Jira и переезда задач в Gitlab, становится понятно, что для подавляющего большинства учётная запись в Gitlab рано или поздно понадобится, так что мы решили, что учётная запись в Gitlab вне зависимости от вида деятельности сотрудника нас не напрягает.

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

  • запросить Gitlab через REST API, существует ли учётная запись с почтовым адресом пользователя, для которого выполняется аутентификация;

  • если такой учётной записи нет, создать её через API;

  • получить идентификатор пользователя Gitlab;

  • создать пользовательский атрибут mattermostid с полученным значением, который будет использоваться при аутентифкации в Mattermost.

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

В Keycloak логику можно было бы реализовать в двух местах:

  • непосредственно в identity-провайдере;

  • в дополнительном собственном шаге authentication flow (authenticator).

Первый вариант нам не понравился: у нас есть провайдер для Яндекса, он решает свою задачу, и грузить его какой-то мутной логикой для Gitlab не хотелось.

Второй вариант выглядел более логично: мы создаём дополнительный шаг для процесса аутентификации и можем при необходимости его использовать независимо от Яндекса или иного провайдера.

Собственную логику для authentication flow в Keycloak можно реализовать одним из двух способов:

  • на JavaScript, использовав встроенный script authenticator;

  • честно написав собственный аутентификатор на Java.

Первый способ наиболее простой, и в ряде случаев он может здорово выручить, если нужно здесь и сейчас быстро получить работающее решение или прототип. Код аутентификатора пишется на встроенной реализации JavaScript, при этом набор необходимых объектов (например, контекст аутентифкации) пробрасывается в скрипт в виде глобальных переменных.

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

  • по умолчанию поддержка скриптования в Keycloak отключена, необходимо явно включить поддержку с помощью флага --features при сборке или запуске в режиме разработки;

  • возможность закачки скриптов администратором также требует дополнительных настроек и не будет поддерживаться в следующих версиях Keycloak;

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

Тем не менее вариант с JavaScript вполне рабочий, и с его помощью можно решать реальные задачи. Сам JavaScript в общем-то вполне обыкновенный, единственная специфика — связь с Java API и вызов Java-методов, например, при вызове переопределённого метода необходимо обращаться к конкретной реализации, явно указывая его сигнатуру следующим образом:

SimpleHttp = Java.type("org.keycloak.broker.provider.util.SimpleHttp");
String response = SimpleHttp["doGet(String,KeycloakSession)"](url, session).asString();

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

Для реализации собственного шага аутентификации необходимо реализовать два класса — собственно аутентификатор и фабричный класс, реализовав необходимые интерфейсы (Authenticator, AuthenticatoryFactory и DisplayTypeAuthenticatorFactory).

Основной содержательный метод аутентификатора в нашем случае — authenticate, который и выполняет нужные нам действия:

public class MattermostIntegrationAuthenticator implements Authenticator {
  
  private static final Logger LOG = Logger.getLogger(MattermostIntegrationAuthenticator.class);
  
  @Override
  public void authenticate(AuthenticationFlowContext context) {
    UserModel user = context.getUser();
    String email = user.getEmail();
    Map<String, String> config = context.getAuthenticatorConfig().getConfig();
    int mattermostId = 0;
    // Local user without email, e.g. admin, do nothing.
    if (email == null || email.trim().isEmpty()) {
      context.success();
      return;
    }
    String gitlabToken = config.get(MattermostIntegrationAuthenticatorFactory.GITLAB_TOKEN);
    String gitlabURI = config.get(MattermostIntegrationAuthenticatorFactory.GITLAB_URI);
    try {
      JsonNode searchUserResponse = SimpleHttp.doGet(
        gitlabURI + "/api/v4/users?search=" + email, context.getSession())
        	.header("PRIVATE-TOKEN", gitlabToken)
        	.asJson();
      if (searchUserResponse.size() > 0) {
        mattermostId = searchUserResponse.get(0)
          .get("id").asInt(0);
      } else {
        GitlabCreateUserRequest createUserRequest = new GitlabCreateUserRequest();
        createUserRequest.setEmail(email);
        createUserRequest.setName(user.getFirstName() + " " + user.getLastName());
        createUserRequest.setUsername(user.getUsername());
        JsonNode createUserResponse = SimpleHttp.doPost(gitlabURI + "/api/v4/users", context.getSession())
          .header("PRIVATE-TOKEN", gitlabToken)
          .json(createUserRequest)
          .asJson();
        if (createUserResponse != null) {
          mattermostId = createUserResponse.get("id").asInt(0);
        }
      }
      if (mattermostId > 0) {
        user.setSingleAttribute(
          "mattermostid", Integer.toString(mattermostId));
      }
    } catch (IOException e) {
      LOG.error(e.getMessage());
    }
    context.success();
  }
  // ...
}

Аутентификация всегда завершается успехом: если по каким-то причинам Gitlab в момент аутентификации недоступен, это не лишает пользователя возможности выполнить аутентификацию, привязка к Mattermost в этом случае может быть выполнена при повторной аутентификации.

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

public class MattermostIntegrationAuthenticatorFactory implements AuthenticatorFactory, DisplayTypeAuthenticatorFactory {
  public static final String PROVIDER_ID  = "mattermost-integration";
  public static final String GITLAB_TOKEN = "gitlabToken";
  public static final String GITLAB_URI   = "gitlabURI";
 
  @Override
  public List<ProviderConfigProperty> getConfigProperties() {
    ProviderConfigProperty gitlab_uri = new ProviderConfigProperty(
      GITLAB_URI,
      "Gitlab URI",
      "Gitlab instance URI (https://gitlab.com)",
      STRING_TYPE,
      null
    );
    ProviderConfigProperty gitlab_token = new ProviderConfigProperty(
      GITLAB_TOKEN,
      "Gitlab Token",
      "Gitlab private token",
      STRING_TYPE,
      null
    );
    return asList(gitlab_uri, gitlab_token);
  }
  // ...
}

Прочие сервисы

К счастью, с остальными сервисами всё пока прошло без приключений, и больше ничего изобретать не понадобилось.

Для Jenkins (да, мы переходим на Gitlab, но на Jenkins пока ещё завязано очень много процессов) мы использовали плагин авторизации в Keycloak, и каких-либо существенных проблем с ним не возникло. Существуют и альтернативные плагины, использующие OpenID Connect, скорее всего, с ними тоже бы всё получилось, в данном случае нас устроил первый полученный результат.

C Bookstack проблем тоже не возникло, достаточно было сделать всё по инструкции.

Было желание на период миграции с Jira так же подключить её к общему SSO, но это оказалось невыполнимо: внешняя авторизация в собственном SSO требует покупки дополнительного продукта, а с учётом перехода это просто не имеет смысла.

Визуальная тема

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

  • за основу имеет смысл брать встроенную тему Keycloak;

  • если в команде есть люди, профессионально занимающиеся frontend, и есть возможность выделить определённое время, можно писать целиком собственную тему, используя base; мы не разработчики и позволить себе такое не могли;

  • доступные на Github упрощённые темы (например, Alfresco) имеют массу проблем и ограничений, если хочется чего-то в качестве основы, лучше смотреть на темы, базирующиеся на встроенных, например на тему Linaro.

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

Сборка контейнера

Использование Quarkus всегда предполагает использование multistage-контейнера, включающее по крайней мере два шага: конфигурирование типового образа Keycloak в отдельном контейнере (build) и формирование образа для запуска путём копирования оптимизированного приложения в итоговый контейнер. В нашем случае добавилась ещё сборка собственных расширений и визуальной темы, в итоге наш Dockerfile выглядит примерно так:

ARG VERSION
ARG KEYCLOAK_VERSION=17.0.1
# Build extensions.
FROM maven:3-openjdk-17 as builder
ARG VERSION
ARG KEYCLOAK_VERSION
COPY extensions /src/extensions
RUN cd /src/extensions && mvn package
# Configure Keycloak.
FROM quay.io/keycloak/keycloak:${KEYCLOAK_VERSION} as keycloak
ARG VERSION
ARG KEYCLOAK_VERSION
COPY --from=builder \
	/src/extensions/target/keycloak-arenadata-${VERSION}.jar \
  /opt/keycloak/providers/
COPY themes/arenadata/ /opt/keycloak/themes/arenadata/
RUN /opt/keycloak/bin/kc.sh build \
	--db=postgres \
  --metrics-enabled=true \
  --features=scripts
# Build Keycloak.
FROM quay.io/keycloak/keycloak:${KEYCLOAK_VERSION}
ARG VERSIONARG KEYCLOAK_VERSION
COPY --from=keycloak /opt/keycloak/lib/quarkus/ /opt/keycloak/lib/quarkus/
COPY --from=keycloak /opt/keycloak/providers/ /opt/keycloak/providers/
COPY --from=keycloak /opt/keycloak/themes/ /opt/keycloak/themes/
WORKDIR /opt/keycloak
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]

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

Итого

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

Самым проблемным сервисом оказался Mattermost, в первую очередь из-за ограничений Team Edition.

Что мы хотим сделать дальше:

  • провести дополнительный аудит безопасности;

  • написать дополнительный скрипт аудита, который периодически проверял бы соответствие учётных записей в разных сервисах друг другу;

  • отработать протокол увольнения сотрудника;

  • реализовать дополнительную логику включения нового пользователя в группы на стороне Gitlab.

Получившееся у нас решение мы выложили в виде репозитория на Github.

Хочу поблагодарить за помощь в подготовке этого материала моего коллегу, Сергея Ротару (@Maunty) который принимал участие в обсуждении ряда технических вопросов и провел рецензирование статьи.

Tags:
Hubs:
Total votes 14: ↑14 and ↓0+14
Comments12

Articles

Information

Website
arenadata.tech
Registered
Founded
2016
Employees
101–200 employees
Representative
Arenadata