Контекст

Это статья-туториал. Рассмотрим в ней, как сделать компонент, который поможет забыть о необходимости дублировать механизмы аутентификации и авторизации. Цель статьи - реализовать starter, который можно будет легко и удобно подключить к Spring Boot проекту. Предлагаемая цель актуальна?

Используемые технологии

Для экспресс-оценки, подходит ли вам предлагаемый контент - технологический стек, который будет использоваться:

  • Java 17;

  • Spring boot;

  • Maven:

    • В явном виде в статье он не используется, но в коде проекта все настроено под использование этого сборщика. Можно использовать любой;

  • Spring Cloud OpenFeign;

  • KeyCloak:

    • Альтернатива поддерживающая OpenID+OAuth 2.0;

  • Nexus:

    • Использование starter предполагает, что у Вас есть репозиторий хранения исходных файлов;

  • Lombok

    • Будут использоваться аннотации lombok, чтобы не отвлекаться от темы изложения материала;

Кому будет полезна статья

В статье приведен способ получения доступа к ресурсам по варианту OpenID+OAuth 2.0, с низкой нагрузкой на сервер аутентификации.

Если:

  • в ваших проектах используется похожий технологический стек;

  • возникает задача реализации доступа к ресурсам по OAuth 2.0;

  • нагрузка на серверы аутентификации/авторизации высокая;

Перед вами видение, как можно решить эти задачи.

В статье:

  • расскажу почему важно уделять внимание информационной безопасности;

  • кратко опишу теоретическую часть;

  • опишу компонент, который реализует получение доступов и удовлетворяет требованиям безопасности и надежности;

  • помечтаю о том, как наш компонент можно развивать;

  • предложу исходный код в проекте на git;

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

Зачем это понадобилось?

Я разрабатываю микросервисы, которые обмениваются информацией с поставщиками данных в информационном контуре компании и за его пределами. Некоторые из них не требуют аутентификации и авторизации, так как располагаются в закрытых сегментах внутренней сети, а некоторые требуют. Из внешних поставщиков - это БКИАвитоКонтурФокус. В последнее время и с внешними, и с внутренними поставщиками все чаще передо мной возникает протокол OpenID+OAuth 2.0. О варианте реализации компонента для этого протокола дальше пойдет речь.

Вводная информация

Перед тем как начать погружение в реализацию, обсудим, в чем суть OpenID+OAuth 2.0, что он требует и почему так известен.

Что такое OpenID и OAuth 2.0?

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

Про OAuth 2.0

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

Последовательность действий для выдачи разрешения или согласия часто называют авторизацией или даже делегированной авторизацией.

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

Про OpenId

OpenID Connect (OIDC) — это тонкий слой поверх OAuth 2.0, добавляющий сведения о логине и профиле пользователя, который вошел в учетную запись.

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

Выделим роли:

  • Приложение - защищенный ресурс;

  • Сервер авторизации - обладает данными пользователей, реализует логику выдачи токенов, проверяет их подлинность при обращении к защищенным ресурсам:

    • Выдача токенов и проверка подлинности - ответственность разных программных компонентах. Традиционно, для снижения рисков безопасности, эти действия объединяются;

  • Клиент - сервис, которому сервер авторизации передает права доступа на приложение;

  • Пользователь - наделяет клиента данными для доступа к приложению;

В описании мы выделили аутентификацию и авторизацию:

  • OpenID служит для аутентификации, то есть подтверждения клиента, что именно он является конкретным пользователем;

  • Вслед за этим вступает OAuth 2.0, который предоставляет авторизацию, то есть предоставление прав клиенту на выполнение определенных действий с защищенными ресурсами:

    • Для аутентификации по OpenID используется ID учетной записи;

    • Для OAuth 2.0 используются токены с сроком действия и правами доступа;

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

OAuth 2.0 - современный, развивающийся протокол авторизации. В качестве плюсов этого протокола:

  • Удобство:

    • Не нужно запоминать и вводить логин, пароль. Используются данные, которые хранятся в отдельном защищенном ресурсе;

  • Безопасность:

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

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

    • OAuth 2.0 поддерживает разные типы авторизаций - на основе доступа, на основе разрешений. Это делает его гибким и масштабируемым;

  • Открытость:

    • OAuth 2.0 - открытый стандарт c большим сообществом. Это облегчает интеграцию различных приложений, сервисов, приводит к созданию стандартных компонентов в различных фреймворках;

Основные недостатки этого протокола:

  • Относительная сложность по сравнению с моделью доступа по логину и паролю:

    • Появляются дополнительные участники процесса, которые берут на себя функциональность по аутентификации и авторизации. Это увеличивает количество точек отказа и усложняет процесс;

  • Потенциальные уязвимости:

  • Необходимость дополнительной настройки и дополнительных компонентов:

    • Для использования OAuth 2.0 требуется настроить серверы API. Это требует дополнительных ресурсов и времени или даже отдельных систем класса IAM;

OAuth 2.0 - шаги

Стандарт OAuth 2.0 предполагает 4 возможных варианта реализации. Предлагаемая реализации идет по пути Authorization Code Grant. Версия OAuth 2.1, появившаяся в 2021 году уже содержит не 4, а 2 способа авторизации. Предлагаемый в этой статье способ остался актуальным. Договоримся о терминах:

Use case обработки OAuth 2.0 следующий (Рис.1):

  • Клиент, которому надо авторизоваться, получает данные пользователя;

  • Клиент запрашивает у сервиса авторизации данные для авторизации;

  • Сервер авторизации проверяет данные:

    • Успех → Сервер авторизации предоставляет токен клиенту;

    • Провал → Заканчиваем процесс;

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

  • Приложения, отправляет токен на проверку серверу ресурсов;

  • Сервер ресурсов проверяет токен и передает результат приложению: 

    • Токен валидный → Доступ клиенту в приложение предоставлен;

    • Токен не валидный → В Доступе отказано;

Рис.1
Рис.1

Используемые технологии

Для нашей компании проблема разрозненного управления доступами к ресурсам и поддержки разных реализаций стала крайне критичной несколько лет назад. Не было централизованного хранения и общих механизмов управления доступами. Пароли выдавались по запросу, клиенты хранили их по своему усмотрению. Клиент-серверное взаимодействие к защищенным ресурсам определялось для каждого конкретного случая ситуативно. В целом, ситуация нормальная, но при увеличении объема взаимодействий между системами, каждый раз "по-своему" решать задачи аутентификации и авторизации становится сложно, дорого. Развитие микросервисного подхода тоже внесло свою лепту. Число различных конфигурация для подключений к внешним источникам, базам данных увеличивалось вместе с количеством микросервисов. Требовался единый и надежный механизм, удовлетворяющий все системы, развиваемый и поддерживаемый профессиональным сообществом. Тогда и появились KeyCloak и надежный репозиторий Vault. 

Keycloak

Keycloak - IAM система для управления авторизацией и аутентификацией пользователей. Она получила широкое распространение по нескольким причинам:

  1. Open Source:

    1. Keycloak является открытым программным обеспечением - нет затрат на лицензии, его можно дополнять своими компонентами;

    2. Написан на Java;

  2. Поддержка различных протоколов:

    1. Keycloak поддерживает стандартные протоколы аутентификации, сам использует SSO;

  3. Простая интеграция:

    1. Keycloak предлагает клиентские библиотеки для популярных языков программирования и платформ, облегчая разработчикам интеграцию в различные приложения;

  4. Масштабируемость, надежность:

    1. Keycloak спроектирован с учетом потребностей крупномасштабных систем;

  5. Функции управления пользователями:

    1. В Keycloak есть встроенные функции для управления пользователями, такие как регистрация, восстановление, управление ролями;

  6. Сообщество:

    1. Keycloak имеет активное сообщество;

Keycloak - отдельная тема для обсуждений. Подробнее тут. В этой статье только то, что критично для раскрытия темы.

HashiCorp Vault

Vault - популярное решение управления секретами и конфиденциальной информацией:

  • Open Source:

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

  • Поддержка различных протоколов:

    • Предлагает высокоразвитые методы шифрования для защиты данных;

  • Централизованное управление:

    • API-ключи, пароли, сертификаты и др.

  • Включает функции аудита и управления доступом:

    • Позволяет отслеживать, кто и когда имеет доступ к секретам, устанавливать требуемые политики;

  • Простая интеграция:

    • Легко интегрируется с различными системами управления конфигурациями, оркестрации контейнеров (Kubernetes), облачными сервисами, CI/CD инструментами.

  • Масштабируемость, надежность:

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

Кроме Vault в нашей компании используются gitlab-секреты, maven-профили и многое другое, но я использую именно эту систему. Появятся вопросы - пишите, обсудим.

Итого

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

Контекст

Микросервисы, которые должны авторизовываться, создаются на Spring boot и его экосистеме. В качестве клиента используется Feign. Еще понадобится cache, буду использовать caffeine. Для caffeine - предпочтительный вариант. Для себя выбирайте. Цель понятна, стек сформулирован. Приступаем.

Как происходит взаимодействие

Переложим ранее определенные роли на конкретные системы (Рис.2):

  • Клиент - сервис, которому необходимо авторизоваться к приложению; 

    • Приложение использует данные, которые помогут подключиться к пользователю, или к системе, представляющую пользователя, для получения данных:

      • В моем случае это решается за счет использования стартера "spring-cloud-vault-config", который позволяет получать из vault секретные данные при старте приложения;

  • Пользователь - в нашем случае, хранилище пользовательских данных. На диаграмме это Vault. Провайдер пользовательских данных;

    • В нем будут:

      • grant_type - тип полномочий пользователя. Используется для авторизации прав;

      • client_id - идентификатор уполномоченного пользователя

      • client_secret - пароль уполномоченного пользователя;

    • Эти данные используются в специализированном realm Keycloak, который создается для обслуживания авторизации конкретного приложения:

      • Realm имеет свой адрес для аутентификации клиента с данными  - https://[адрес keycloak]/realms/[приложение]/protocol/openid-connect/[Тип механизма подключения. Далее для нас - token]

      • О realm будет далее;

  • Сервер авторизации - для меня это будет Keycloak;

    • На сервере будет находиться realm, который будет обслуживать механизмы аутентификации и авторизации. Realm - это изолированная область в Keycloak для управления безопасностью:

      • Каждый realm может содержать собственную базу данных пользователей, роли, политики, настройки аутентификации;

      • Keycloak содержит множество изолированных друг от друга realm. Это позволяет использовать один экземпляр Keycloak для множества приложений;

      • Каждый realm может иметь свои собственные методы аутентификации и интеграции с внешними системами;

  • Приложение - сервис, к которому необходимо аутентифицироваться/авторизоваться;

    • Приложение использует token от клиента. Из этого токена понятно, кто с чем пришел и какими правами он обладает. Токены разберем далее.

Рис.2
Рис.2

Token

Токен - закодированная удостоверяющим центром информация, содержащая способ кодирования, информацию о клиенте, данные о кодировании. Он используется в контексте аутентификации и авторизации для представления информации о клиенте, его правах. Keycloak поддерживает несколько типов авторизации и аутентификации по токенам - JWT, Opaque Tokens, SAML Tokens, OIDC: 

  • JWT, Opaque Tokens - технические стандарты для представления информации;

  • SAML Tokens, OIDC - спецификации, для решения задач аутентификации и авторизации через протокол OAuth 2.0;

В моем случае используется JWT поверх спецификации OIDC.

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

OIDC помогает проверять пользователей на основе аутентификации, которая выполняется сервером авторизации. В результате клиент получает информацию о пользователе в токене. При выполнении аутентификации с помощью OIDC возвращается один или несколько токенов. Один из них в формате JWT. Токен содержит информацию о времени действия. OIDC и JWT работают вместе в процессе аутентификации: OIDC использует JWT. 

Компонент - Spring boot starter

Целевой вид, к которому нужно прийти - starter, добавленный к клиенту. Давайте  разбираться с тем, что должно происходить. Входное условие - у нас есть токен. Результат - мы авторизованы на работу с бизнес логикой (Рис.3).

Рис.3
Рис.3

Целевой процесс получается такой:

  • Starter получает токен;

  • Токен помещается в заголовок для отправки данных в приложение;

  • Приложение получает сообщение:

    • Успех - выполняем запланированное;

    • Провал:

      • Провал может быть по разным причинам - токен не тот, проблемы с сетью и т.д.;

      • Используя best practise разработки распределенных систем, попробуем повторить с токеном;

        • Получилось - идем дальше;

        • Не получилось - заканчиваем и формируем сообщение с валидным описанием;

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

Решение

Клиент при отправке сообщения приложению должен иметь токен. Для этого используем Feign в целом и RequestInterceptor в частности. С помощью этих компонентов реализуем необходимую логику. Код будет выглядеть так:

@RequiredArgsConstructor
public class SomeServiceFeignClientConfiguration {
 
    private final KeycloakRequestInterceptor KeycloakRequestInterceptor;
 
     @Bean
    public RequestInterceptor requestInterceptor() {
        return new KeycloakRequestInterceptor();
    }
 
}

Итак:

  • SomeServiceFeignClientConfiguration - целевой сервис, которому необходимо пройти аутентификацию; 

  • KeycloakRequestInterceptor  - наш компонент, который будет реализовывать целевой процесс:

    • Результат его работы - валидный токен, который подставляется в заголовок, при отправке сообщения целевому сервису;

Именно компонент KeycloakRequestInterceptor и вся реализуемая в нем логика - наш starter. Его подключение, это добавление в pom целевого сервиса нужной зависимости. Результат понятен.

Здесь и далее по блокам кода я буду использовать аннотации lombok, которые нужны для компонента:

 - @AllArgsConstructor, @NoArgsConstructor, @Getter, @Setter

и которые я предпочитаю для использования в работе

- @Builder

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

Следующий шаг - получение токена из Keycloak. На вход сервиса сигнатура данных, которую поддерживает Keycloak. Важно - общение с Keycloak должно поддерживать x-www-form-urlencoded. Эта особенность описана в спецификации Keycloak - OAuth 2.0. Она привязана к потоку работы в браузере с несколькими запросами/ответами и перенаправлениями вызова. Для этого понадобится @FormProperty над полями объекта. Модель вызова будет такой:

@ConfigurationProperties(value = "Keycloak-request")
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
@Builder
public class RequestKeycloak {
    @FormProperty("client_id")
    private String clientId;
 
    @FormProperty("grant_type")
    private String grantType;
 
    @FormProperty("client_secret")
    private String clientSecret;
 
}

Сразу будем использовать @ConfigurationProperties, чтобы получать данные из настроек. Если вам по каким-то причинам это не подходит, то можно без ущерба функциональности отказаться от этого и работать с атрибутами компонента. Используется класс, и есть необходимость в Setter, потому что поток перенаправлений предполагает возможность изменения состояния этого объекта. Добавим объект для ответа:

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
public class ResponseKeycloak {
 
        @JsonProperty("access_token")
        private String accessToken;
 
        @JsonProperty("expires_in")
        private Integer expiresIn;
 
        @JsonProperty("refresh_expires_in")
        private Integer refreshExpiresIn;
 
        @JsonProperty("token_type")
        private String tokenType;
 
        @JsonProperty("not-before-policy")
        private Integer notBeforePolicy;
 
        private String scope;
}

Объект ответа конфигурируется в зависимости от того, какого вида параметры нужно получать и как настроен Keycloak. Модель ответа не предполагает изменений, поэтому нам уже не требуется Setter. Я оставил class, но тут может быть использован record без потери функциональности. У меня она типовая. Углубляться в значение каждого поля не буду. Для нас важен именно токен (access_token) и время его жизни (expires_in). Все остальное - тип токена, параметра обновления, область использования обсуждать не будем. Если Вам требуется углубиться - добро пожаловать в приведенные ссылки.

Наш клиент к Keycloak:

@FeignClient(
        name = Keycloak_NAME_FEIGN_CLIENT,
        qualifiers = Keycloak_QUALIFIER_FEIGN_CLIENT,
        url = "${Keycloak-connection.url}",
        configuration = KeycloakFeignClientConfiguration.class
)
public interface KeycloakFeignClient {
 
    @PostMapping(
            consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE
    )
    ResponseKeycloak processingAuthData(
          RequestPropertiesKeycloak requestPropertiesKeycloak);
}

В этом клиенте мы указали необходимые параметры для использования Feign (name, qualifiers, url, конфигурацию, REST метод и contentType). Традиционное заполнение декларативного Feign. В конфигурации будут находиться обработка ошибок Keycloak и логика обработки проблем. Подробнее посмотрим на каждый класс. Конфигурация:

@Configurable
public class KeycloakFeignClientConfiguration {
 
    @Bean
    public ErrorDecoder KeycloakErrorDecoder() {
        return new KeycloakErrorDecoder();
    }
 
    @Bean
    public Retryer retryer() {
        return new KeycloakRetryer();
    }
 
}

Она предполагает использование 2 обычных классов. Для этого будем использовать @Configurable. @Configurable является своеобразным прокси над объектом, в результате которого в контекст приложения помещаются классы созданные с помощью new. В моем случае это помогает поместить компоненты Retryer и RequestInterceptor из Feign, которые не являются bean, но которые нужны для логики starter. Теперь пристальнее посмотрим на каждый объект. Начнем с Retryer:

@Getter
@Slf4j
public class KeycloakRetryer implements Retryer {
 
    private final int maxAttempts;
    private int currentAttempt;
    private final Retryer defaultRetryer;
 
    public KeycloakRetryer() {
        this.maxAttempts = 2;
        this.currentAttempt = 1;
        this.defaultRetryer = 
          new Retryer.Default(1000, TimeUnit.SECONDS.toMillis(1), 3);
    }
 
    @Override
    public void continueOrPropagate(RetryableException exception) {
        if (RETRYER_STATUS_LIST.contains(exception.status())) {
            if (currentAttempt == maxAttempts) {
                throw new KeycloakProcessingException(
                        String.format(
                                "Keycloak. Ошибка при попытке получить данные. Выполнено %d попыток. Статус: %d, содержание: %s",
                                maxAttempts,
                                exception.status(),
                                exception.getMessage()
                        )
                );
            }
            currentAttempt++;
        } else {
            defaultRetryer.continueOrPropagate(exception);
        }
    }
 
    @Override
    public Retryer clone() {
        return new KeycloakRetryer();
    }
 
}

Наш KeycloakRetryer имплементирует интерфейс Retryer, предоставляемый OpenFeign. В нем переопределены 2 метода. Первый - continueOrPropagate. Это метод используется для обработки запросов или передачи исключения. Он управляет логикой повторных попыток в случае, если первый запрос завершился ошибкой. СontinueOrPropagate на вход принимает произошедшую ошибку и определяет, повторяем ли мы запрос или же "пропагируем", то есть распространяем исключение. Этот метод полезен для создания нашей фиксированной логики "перезапросов" (Рис.3).  Для этого метода, через конструктор задается количество перезапросов. В сумме пытаемся 3 раза вместе с первым ошибочным запросом. Если пробуем больше 3 раз - завершаем логику ошибкой. Число 3 выбрано в демонстрационных целях. Будем считать его магическим. В качестве продуктивного кода можно задать любое подходящее. У нас именно 3. Переопределяем метод clone(), в котором задаем, что в каждый следующий раз для обработки retry будем использовать наш же класс. В этом описании осталась одна недосказанность. А откуда нам пробрасывать RetryableException, которое должно обрабатываться? Переходим к KeycloakErrorDecoder:

public class KeycloakErrorDecoder implements ErrorDecoder {
 
    @Override
    public Exception decode(String methodKey, Response response) {
        String message;
        try {
            message =
                    IOUtils.toString(
                        response.body().asInputStream(), 
                        StandardCharsets.UTF_8
                        );
        } catch (IOException e) {
            return new KeycloakProcessingException(
                    String.format(
                            "Keycloak. Ошибка при попытке получить данные. Статус: %d, содержание: %s",
                            response.status(),
                            methodKey
                    )
            );
        }
 
        if (RETRYER_STATUS_LIST.contains(response.status())) {
            return new KeycloakConnectException(
                    response.status(),
                    message,
                    response.request().httpMethod(),
                    PAUSE_FOR_RETRY,
                    response.request());
        } else {
            return new KeycloakProcessingException(
                    String.format(
                            "Keycloak. Ошибка при попытке получить данные. Статус: %d, содержание: %s",
                            response.status(),
                            message
                    )
            );
        }
    }
 
}

В этом компоненте обрабатываются ошибки Keycloak. В случае, если ошибки попадают в заданный список RETRYER_STATUS_LIST, то пробрасывается исключение KeycloakConnectException, которое будет обработано в continueOrPropogate. Список обрабатываемых статусов раскрывать не буду. Ниже ссылка на проект. Там детали. А вот о KeycloakConnectException поговорим:

public class KeycloakConnectException extends RetryableException {
 
    public KeycloakConnectException(
                          int status, 
                          String message, 
                          Request.HttpMethod httpMethod, 
                          Long retryAfter, 
                          Request request) {
        super(status, message, httpMethod, retryAfter, request);
    }
}

Этот exception должен наследоваться от feign RetryableException, чтобы логика продолжения обработки ошибочных подключений сработала. В таком случае каждый раз при запросе данных получаем валидный токен от Keycloak. C ним можно авторизовываться. Вполне? Чего-то не хватает. Каждый раз при выполнении запроса идет обращение к Keycloak. У нас есть параметр expires_in, который явно говорит о том, сколько токен будет жить. Есть четкое представление о том, сколько токен будет валиден для подключения. Можно какое-то время не ходить в Keycloak, а авторизовываться с имеющимся параметром. Сache?! Реализуем сервис подключения к Keycloak в котором поместим токен в cache, на какое-то время:

@Service
@RequiredArgsConstructor
@EnableConfigurationProperties(RequestPropertiesKeycloak.class)
@Slf4j
public class KeycloakFeignClientService {
 
    private final KeycloakFeignClient KeycloakFeignClient;
 
    private final RequestPropertiesKeycloak requestPropertiesKeycloak;
 
    @Cacheable(cacheNames = CACHE_NAME_TOKEN_Keycloak)
    public String getBearerToken() {
        return getPreparedBearerToken();
    }
 
    private String getPreparedBearerToken() {
        return String.join(
                SPACE,
                BEARER,
                getAuthDataFromKeycloak().getAccessToken()
        );
    }
 
    private ResponseKeycloak getAuthDataFromKeycloak() {
        log.info("get Keycloak token at: {}", new Date());
        return KeycloakFeignClient
                  .processingAuthData(requestPropertiesKeycloak);
    }
 
}

В этом классе используем feign client, набор атрибутов для подключения. Класс реализует формирование токена, который будет добавлен в приложение, попутно поместив его в cache. Есть метод для формирования конечного вида токена, есть метод для получения токена из Keycloak и единственный публичный, он же cache метод с токеном. Но мы используем cache. Значит надо предусмотреть кейс в котором токен должен удаляться. Это очевидно. Это должно происходить, если подключение к Keycloak не увенчалось успехом. Для этого в feign retryer требуется добавить логику по которой токен должен удаляться. Полный Retryer c логикой удаления cache будет такой:

@Getter
@Slf4j
public class KeycloakRetryer implements Retryer {
 
    private final int maxAttempts;
    private int currentAttempt;
    private final Retryer defaultRetryer;
 
    public KeycloakRetryer() {
        this.maxAttempts = 2;
        this.currentAttempt = 1;
        this.defaultRetryer = 
                new Retryer.Default(1000, TimeUnit.SECONDS.toMillis(1), 3);
    }
 
    @Override
    public void continueOrPropagate(RetryableException exception) {
        if (RETRYER_STATUS_LIST.contains(exception.status())) {
            clearTokenCacheData();
            if (currentAttempt == maxAttempts) {
                throw new KeycloakProcessingException(
                        String.format(
                                "Keycloak. Ошибка при попытке получить данные. Выполнено %d попыток. Статус: %d, содержание: %s",
                                maxAttempts,
                                exception.status(),
                                exception.getMessage()
                        )
                );
            }
            currentAttempt++;
        } else {
            defaultRetryer.continueOrPropagate(exception);
        }
    }
 
    @Override
    public Retryer clone() {
        return new KeycloakRetryer();
    }
 
    @CacheEvict(value = CACHE_NAME_TOKEN_Keycloak, allEntries = true)
    public void clearTokenCacheData() {
        log.debug("clear token data after get 4xx status");
    }
 
}

Немного про cache. Содержательно описано тут. Если требуется - изучайте. Его конфигурация:

@Configuration
@EnableCaching
@EnableConfigurationProperties(
  {
    KeycloakConnectionSettings.class, 
    RequestPropertiesKeycloak.class
  }
)
@RequiredArgsConstructor
public class CacheTokenConfig {
 
    private final KeycloakFeignClient KeycloakFeignClient;
 
    private final RequestPropertiesKeycloak requestPropertiesKeycloak;
 
    @Bean
    public Caffeine caffeineConfig() {
        return Caffeine.newBuilder()
                .expireAfterWrite(
                        KeycloakFeignClient
                              .processingAuthData(requestPropertiesKeycloak)
                              .getExpiresIn() * 8 /10 ,
                        TimeUnit.SECONDS
                ).maximumSize(CACHE_SIZE)
                ;
    }
 
    @Bean
    public CacheManager cacheManager(Caffeine caffeine) {
        CaffeineCacheManager caffeineCacheManager = 
            new CaffeineCacheManager(CACHE_NAME_TOKEN_Keycloak);
        caffeineCacheManager.setCaffeine(caffeine);
        return caffeineCacheManager;
    }
 
}

В конфигурации я определяю CacheManager, который должен использовать cache реализацию. После сравнений выбор пал на Caffeine. Он не требует дополнительных хранилищ, рассчитывает на доступную память приложения. То, что нужно. При определении параметров Caffeine достаточно задать время жизни токена и размер cache. С размером просто - одной ячейки достаточно. Время жизни можно получить, обратившись к Keycloak в первый раз и получив от него параметр expires_in. Логично. Keycloak знает для каждого конкретного случая, сколько времени будет валидным токен. У него эти "знания" и получим. Но не будем заниматься экстремизмом и обновим токен немного заранее. Осталось только добавить фасад за которым будет наша реализованная логика. В целевой картине это "перехватчик", который добавит токен к запросу клиента. Предполагается, что клиент будет использовать feign RequestInterceptor:

@RequiredArgsConstructor
public class KeycloakRequestInterceptor implements RequestInterceptor {
 
    private final KeycloakFeignClientService KeycloakFeignClientService;
 
    private void apply(RequestTemplate requestTemplate) {
        requestTemplate
                .headers()
                .getOrDefault(HttpHeaders.AUTHORIZATION, List.of())
                .stream()
                .findFirst()
                .ifPresentOrElse(
                        token -> {
                            final Map<String, Collection<String>> headers = 
                                    new HashMap<>(requestTemplate.headers());
                            headers.put(
                                    HttpHeaders.AUTHORIZATION, 
                                    List.of(
                                        KeycloakFeignClientService
                                              .getBearerToken()
                                            )
                                    );
                            requestTemplate.headers(headers);
                        },
                        () -> requestTemplate.header(
                                  HttpHeaders.AUTHORIZATION, 
                                  KeycloakFeignClientService.getBearerToken()
                                  )
                );
    }

}

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

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

В приложенном коде есть тестовый класс на каждый класс кода. Подробно описывать их не буду. Пройдусь по важным деталям. Где используются интеграционные тесты и требуется @SpringBootTest нужно добавить конфигурацию cache для сбора тестового контекста. У меня это выглядит так:

@EnableCaching
@Configuration
public static class TestConfig {
 
    @Bean
    public CacheManager cacheManager() {
        return new CaffeineCacheManager("token");
    }
 
}

Подробно о том, как нужно тестировать cache описано в этой статье. Для меня более предпочтительный вариант тестирования, в котором будет использоваться Mockito для проверки cache. Тестовый класс получился таким:

@ExtendWith(SpringExtension.class)
@ContextConfiguration
class KeycloakFeignClientServiceCacheTest {
 
    private KeycloakFeignClientService mock;
 
    @Autowired
    private KeycloakFeignClientService KeycloakFeignClientService;
 
    @BeforeEach
    void setUp() {
        mock = AopTestUtils.getTargetObject(KeycloakFeignClientService);
        reset(mock);
        when(mock.getBearerToken()).thenReturn(TEST_STRING);
    }
 
    @Test
    void testCache() {
        assertEquals(TEST_STRING, KeycloakFeignClientService.getBearerToken());
        verify(mock, times(1)).getBearerToken();
 
        assertEquals(TEST_STRING, KeycloakFeignClientService.getBearerToken());
        assertEquals(TEST_STRING, KeycloakFeignClientService.getBearerToken());
        verifyNoMoreInteractions(mock);
 
    }
 
    @EnableCaching
    @Configuration
    public static class TestConfig {
 
        @Bean
        public KeycloakFeignClientService KeycloakFeignClientServiceImplementation() {
            return Mockito.mock(KeycloakFeignClientService.class);
        }
 
        @Bean
        public CacheManager cacheManager() {
            return new CaffeineCacheManager(CACHE_NAME_TOKEN_Keycloak);
        }
 
    }
 
}

В конфигурацию к классу кроме CacheManager требуется добавить фиктивную реализацию KeycloakFeignClientService. Чтобы использовать проверки Mockito нужно получить фактический mock через AopTestUtils.getTargetObject. Важно - для использования cache, перед выполнением каждого теста нужно сбросить настройки mock, так как конфигурация cache загружается единожды. Эту задачи выполняет метод reset в блоке @BeforeEach. Реализация настроена и можно проверить, было ли взаимодействие моck с cache и сколько раз. Это мы и сделали.

А что дальше?

Приложение работоспособно. Как его использовать описано в README проекта. Предлагаемый starter используется в нескольких сервисах и приносит результат. Использование cache в нем практически не создает трафика и задержек в общем процессе. Есть несколько моментов, которые хочется обсудить и которые нужно обдумать перед тем, как приступать к использованию предлагаемой функциональности.

О чем нужно продолжить думать?

Автоматическое получение настроек для cache

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

Конфигурация

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

Тестирование сервисов, использующих starter

Использование starter в сервисах предполагает, что для тестов сервиса, где требуется @SpringBootTest Вам потребуется добавлять KeycloakRequestInterceptor в контекст приложения или особым образом конфигурировать тестовый контекст. Тоже не очень удобно. Это тоже требуется учитывать и как-то научится с этим жить.

Что нужно продолжить развивать?

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

Итоги

  • Разобрались в OpenID+OAuth 2.0;

  • Выбрали конкретный вариант реализации;

  • Создали starter;

Если появятся предложения, рекомендации, критика, то пишите любым удобным для Вас способом - обсудим.

 Благодарности

Спасибо огромное всем коллегам, которые помогали мне в процессе создания starter и при написании этой статьи. Спасибо команде - Саша, Валя, Юра, Женя, Женя, Денис, Кирилл, Алсу, Даша, Ксюша. Вместе мы делаем рутину незаметной для процессов разработки. Отдельное спасибо Никите - твои идеи и работа по исследованию feign, обсуждению идей о развитии помогают двигаться дальше. Львиная часть сделанного - твоя. Спасибо ревьюерам - ребята Вы супер.

Ссылка на репозиторий

Ссылка на репозиторий

Комментарии (3)


  1. Ratikonkik
    08.11.2024 08:08

    Отличная статья! Понятный и практичный разбор реализации стартер-компонента с Keycloak и Spring Boot. Кэширование токенов и обработка ошибок впечатляют. Спасибо за примеры и тесты — очень полезно!


  1. ma1uta
    08.11.2024 08:08

    У Spring Boot есть starter для работы с OAuth2 spring-boot-starter-oauth2-resource-server. Так как Keycloak реализует стандарты OAuth2 и OpenID Connect, этот стартер работает и с keycloak (как и с любой другой реализацией сервера авторизации OAuth2), то чем ваш стартер лучше стандартного?


    1. in86 Автор
      08.11.2024 08:08

      Здраствуйте,

      Из того, ссылку на что Вы прислали

      Spring Security supports protecting endpoints by using two forms of OAuth 2.0 Bearer Tokens

      Что в переводе

      Spring Security поддерживает защиту конечных точек с помощью двух форм токенов OAuth 2.0 Bearer Tokens

      То есть стандартный starter реализует защиту, а в компоненте/статье я показываю способ преодолеть защиту )

      Стартером, ссылку на который Вы дали, мы пользуемся.