Аутентификация через OpenID Connect

опубликовано: ⏰ 5

Небольшой пример аутентификации через OpenID Connect для Spring Boot приложения с UI. Готового и полного примера (frontend + backend) как-то не получилось найти, поэтому и решил добавить эту статью и себе как заметку. Возможно, позже дополню статью.

Вводная

В чем суть? Нужно создать приложение, которое будет использовать OpenID Connect для аутентификации пользователей. При этом в приложении есть UI (ReactJS), и нужно сделать так, чтобы пользователь мог авторизоваться через OIDC провайдера. Провайдер любой, без привязки к определённому вендору.

Из чего будет состоять пример:

  • Spring Boot приложение с Web MVC.
  • Библиотека для работы OIDC на клиентской стороне.
  • OAuth2 авторизация через OIDC с использованием Spring Security.
  • Keycloak в качестве OIDC провайдера, но можно использовать и любой другой.

Серверная часть

Серверная часть – это Spring Boot приложение, которое будет использовать Spring Security для аутентификации пользователей через OIDC.

С серверной частью всё довольно просто. Проще всего создать нужное Spring Boot приложение с помощью Spring Initializr. В качестве зависимостей выбрать:

  • org.springframework.boot:spring-boot-starter-web.
  • org.springframework.boot:spring-boot-starter-security.
  • org.springframework.boot:spring-boot-starter-oauth2-resource-server – для работы с JWT токенами.

Далее необходимо настроить Spring Security.

Java
public class SecurityConfig implements WebMvcConfigurer {

    private final OidcJwtConverter jwtConverter;

    @Bean
    SecurityFilterChain demoSecurityFilterChain(final HttpSecurity http) throws Exception {
        return http.oauth2ResourceServer(o -> o.jwt(jwt
                        -> jwt.jwtAuthenticationConverter(jwtConverter)))
                .build();
    }
}

Класс OidcJwtConverter отвечает за преобразование JWT токена в токен аутентификации Spring Security – org.springframework.security.core.Authentication.

Java

@Component
public class OidcJwtConverter implements Converter<Jwt, AbstractAuthenticationToken> {

    private final JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter 
            = new JwtGrantedAuthoritiesConverter();

    @Override
    public AbstractAuthenticationToken convert(final Jwt jwt) {
        final var expiresAt = jwt.getExpiresAt() == null ? 0 : jwt.getExpiresAt().toEpochMilli();
        if (System.currentTimeMillis() >= expiresAt) {
            SecurityContextHolder.getContext().setAuthentication(null);
            return null;
        }

        final var authorities = jwtGrantedAuthoritiesConverter.convert(jwt);
        final var identity = (String) jwt.getClaim(JwtClaimNames.SUB);

        // {extra logic}

        return new JwtAuthenticationToken(jwt, authorities, identity);
    }
}

Здесь {extra logic} – это дополнительная логика, которая может быть реализована в рамках конкретного проекта:

  • Проверка на блокировку пользователя.
  • Автоматическая регистрация пользователя или его профиля. Например, для случая, когда роли и разрешения назначаются не на стороне IdP, а в приложении.
  • Получение списка ролей и разрешений пользователя, например, из БД.

Дополнительно стоит сделать кеширование токенов аутентификации, так как в случае stateless сервиса они будут проверяться и заполняться при каждом запросе. Что при выгрузке из БД может быть довольно накладно. Можно кешировать либо в памяти (если развёртывание без кластера), либо во внешнем кеше (Hazelcast, Redis, Infinispan и прочее).

Клиентская часть

Со стороны клиентской части есть несколько готовых реализаций OIDC клиентов (тут только часть из них):

К определённому провайдеру не хотелось привязываться, поэтому взят oidc-client-ts. На основе библиотеки сделана обёртка react-oidc-context под ReactJS. Можно использовать её, если нет времени и желания разбираться с основной библиотекой.

Основной класс управления авторизацией - UserManager. Он отвечает за:

  • Получение токенов (access, refresh).
  • Обновление токенов.
  • Получение информации об авторизованном пользователе.
  • Сброс авторизации (logout).

Пример конфигурации ниже:

TypeScript
import {UserManager} from 'oidc-client-ts';

const settings = {
    authority: 'http://localhost:9080/realms/my-realm',
    client_id: 'my-client-id',
    redirect_uri: 'http://localhost:5173/callback',
    response_type: 'code',
    scope: 'openid profile email api',
    post_logout_redirect_uri: 'http://localhost:5173/login',
};

const userManager = new UserManager(settings);
  • authority – URL сервера авторизации (тут это Keycloak).
  • client_id – идентификатор клиента.
  • redirect_uri – URI, на который будет перенаправлен пользователь после успешной авторизации.
  • response_type – тип ответа от сервера авторизации. В данном случае используется code (Authorization Code Flow).
  • scope – список запрашиваемых областей доступа.
  • post_logout_redirect_uri – URI, на который будет перенаправлен пользователь после выхода из системы.

Необходимые методы:

  • userManager.getUser() – получение информации о текущем пользователе.
  • userManager.signinRedirect() – перенаправление пользователя на страницу авторизации.
  • userManager.signinCallback() – обработка результата авторизации. Возвращает информацию о пользователе.
  • userManager.signoutRedirect() – завершение сеанса пользователя и перенаправление на страницу выхода.

URI для возврата от сервера авторизации можно сделать в виде отдельной страницы (callback), чтобы вызвать на ней метод signinCallback и сохранить полученного пользователя локально. Этот метод можно вызвать только один раз после возврата, так как он удаляет информацию о запросе из localStorage. После успешной авторизации, пользователя можно будет перенаправить с неё на главную страницу.

Использование токенов доступа

Для использования токенов при обращении к серверному API (REST), необходимо добавлять заголовок Authorization в формате Bearer <token> в каждом запросе.

Если используется axios, то можно добавить в стандартные заголовки axios.defaults.headers.common['Authorization'] = 'Bearer ' + token;.

Как альтернатива, можно использовать interceptors для динамического добавления заголовка к каждому запросу с учётом определённой логики.

TypeScript
axios.create({...}).interceptors.request.use(
    (config) => {
        if (oidc.isAuthenticated()) {
            config.headers.Authorization = `Bearer ${oidcProtocol.getToken()}`;
        }
        return config;
    },
    (error: AxiosError) => {
        return Promise.reject(error);
    },
);

Identity Provider (IdP)

В примере используется Keycloak. Настройку приводить не будут, так как там всё относительно просто. Необходимо:

  1. Создать клиент с Standard flow. Все настройки по умолчанию, только в полях Valid * указать корректные URI.
  2. Создать пользователя.

Заключение

Понятно, что это только базовая реализация, но с ней можно пройти процесс аутентификации через OpenID Connect и получить токен для дальнейшего использования в запросах к API.

PS: Вместо oidc-client-ts можно взять react-oidc-context, чтобы не писать заново логику под ReactJS. Так как мне нужна своя реализация под tekBlueprint.frame, то использовал oidc-client-ts.

ℹ️ Примечание

Примера проекта пока нет, возможно будет попозже.

Java Security Oidc Oauth2 Jwt Spring