В современных приложениях централизованная аутентификация и авторизация играют ключевую роль в обеспечении безопасности и удобства пользователей. Именно с такой задачей мы столкнулись в компании NAUKA при создании "Платформы" — экосистемы, предназначенной для функционирования наших решений и разработки собственных прикладных приложений. В качестве основного компонента системы аутентификации и авторизации был выбран Spring Authorization Server.

Spring Authorization Server как мощный инструмент для реализации SSO (Single Sign-On) предоставляет гибкие возможности для настройки OAuth2 и OpenID Connect. Однако при масштабировании и интеграции с реальными системами возникают сложности, требующие глубокого понимания архитектуры и возможностей фреймворка, да и в целом считается, что все, связанное со Spring Security, имеет слабый DX (developer experience), и этим никто не любит заниматься.

Настоящая статья - это небольшой практический обзор реализации SSO-сервера на основе технологии Spring Authorization Server с акцентом на решении типовых проблем, которые возникают при её использовании в реальной системе. Мы рассмотрим как технические детали, так и архитектурные решения, которые помогут создать надежный и масштабируемый сервер авторизации.

1. Простая реализация по документации

Приступать к работе можно как с чистого листа, так и при помощи конфигуратора Spring Initializr. В любом случае у вас на выходе должен получиться проект с таким описанием структуры (авторы выбирают maven, однако предпочтения в этом случае не влияют на конечный результат: если используется gradle, то в build-файле обязательно должна быть зависимость spring-boot-starter-oauth2-authorization-server):

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.4</version>
        <relativePath/>
    </parent>
    <groupId>ru.ntik</groupId>
    <artifactId>oauth2-authorization-server</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>oauth2-authorization-server</name>
    <description>Demo project for Spring Authorization server</description>
    <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

В итоге в зависимости нашего проекта попадают (на момент написания) Spring Boot 3.4.4, Spring Authorization Server 1.4.2 и Lombok.

Удостоверяемся, что есть запускающий класс:

@SpringBootApplication
public class AuthorizationServerApplication {

  public static void main(String[] args) {
    SpringApplication.run(AuthorizationServerApplication.class, args);
  }
}

Приступим к настройке SSO сервера, для этого создадим два класса конфигурации:

  • SecurityConfig.java - описание конфигурации собственной безопасности сервера авторизации как веб-приложения;

  • AuthorizationServerConfig.java - здесь мы будем описывать конфигурацию безопасности системы с точки зрения сервера авторизации.

Сперва создаем класс SecurityConfig и конфигурируем:

  • в бине SecurityFilterChain указывается, что на всех эндпоинтах будет проверяться авторизация, а в конфигурации страницы входа - Customizer.withDefaults() в качестве параметра DSL метода formLogin(...);

  • в бине UserDetailsService укажем in memory реализацию этого интерфейса: он будет отвечать за хранение и получение данных по логину в процессе аутентификации пользователя. Сразу же в него вносится один пользователь.

SecurityConfig.java

import static org.springframework.security.config.Customizer.withDefaults;

@EnableWebSecurity // включает конфигурации Spring Security в проекте
@Configuration
public class SecurityConfig {

    @Bean // обязательный бин SecurityFilterChain, описывающий поведение Spring Security при вызовах
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authorize ->
                authorize.anyRequest().authenticated() // любой запрос должен быть авторизованным
        );
        return http.formLogin(withDefaults()).build(); // для авторизации использовать страницу с логином
    }

    @Bean
    public UserDetailsService users() {
        UserDetails user = User.builder()
                .username("admin")
                .password("{noop}password") // незашифрованный пароль
                .roles("ADMIN")
                .build();
        return new InMemoryUserDetailsManager(user);
    }
}

Далее создадим класс AuthorizationServerConfig:

  • в нём создадим второй в проекте бин SecurityFilterChain. В этом бине добавим конфигурацию, предоставляемую по умолчанию зависимостью spring-security-oauth2-authorization-server. Для этого достаточно добавить OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);. После чего не забываем настроить переход на форму логина, если у нас отсутствует аутентифицированная сессия j-sso.

  • Создадим бин registeredClientRepository, реализующий интерфейс RegisteredClientRepository. Он будет представлять в нашем проекте хранилище клиентов системы. Так же как и в случае с UserDetailsService, сделаем хранилище клиентов максимально простым (in-memory реализация InMemoryRegisteredClientRepository) и сразу запишем в него единственного клиента.

Поскольку проект тестовый, поставим самый простой способ аутентификации Basic Authentication. Это значит, что у клиента должен быть заголовок Authorization с типом Basic. Обратите внимание на параметр redirectUri - он необходим для аутентификации по типу AUTHORIZATION_CODE (аутентификация при обмене кода на токен). В этом параметре мы указываем, на какой URL разрешен редирект после успешной аутентификации пользователя.

AuthorizationServerConfig.java

@RequiredArgsConstructor
@Configuration
public class AuthorizationServerConfig {

    private final AuthorizationServerProperties authorizationServerProperties;

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE) // в цепочке из двух бинов типа SecurityFilterChain этот будет обрабатываться первым
    public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); // включает обязательные фильтры и эндпоинты для работы сервера по OAUTH2 /oauth2/authorize и т.д

        http.exceptionHandling(exceptions ->
                exceptions.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))
        ); // редирект неаутентифицированного пользователя на /login
        return http.build();
    }

    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        return new InMemoryRegisteredClientRepository(
                RegisteredClient.withId("test-client-id")
                        .clientName("Test Client")
                        .clientId("test-client")
                        .clientSecret("{noop}test-client") // незашифрованный секрет клиента
                        .redirectUri("http://localhost:8080/code")
                        .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                        .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                        .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                        .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) // все доступные grant types
                        .build()
        );
    }

    @Bean // динамическая генерация ключей для подписи токенов
    public JWKSource<SecurityContext> jwkSource() {
        RSAKey rsaKey = JwkUtils.generateRsa();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
    }

    @Bean // добавляет в токен поле iss для проверки валидности сервиса, выдавшего токен
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder()
                .issuer(authorizationServerProperties.getIssuerUrl())
                .build();
    }
}

По умолчанию тип токена в проекте JWT, поэтому понадобится настройка бина jwkSource, в котором описывается конфигурация хранилища RSA ключей. Правила генерации RSA ключа опишем в классе JwkUtils:

public class JwkUtils {
    // Генерирует пару ключей по алгоритму RSA с длиной 2048 бит
    // Приватный ключ используется для подписи токенов сервером авторизации, публичный ключ используется ресурсным 
    // сервером для проверки валидности токенов, которые отправляет клиент

    public static RSAKey generateRsa() {
        KeyPair keyPair = generateRsaKey();
        RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
        RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
        return new RSAKey.Builder(publicKey)
                .privateKey(privateKey)
                .keyID(UUID.randomUUID().toString())
                .build();
    }

    
    public static KeyPair generateRsaKey() {
        KeyPair keyPair;
        try {
            KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
            keyPairGenerator.initialize(2048);
            keyPair = keyPairGenerator.generateKeyPair();
        } catch (Exception ex) {
            throw new IllegalStateException(ex);
        }
        return keyPair;
    }
}

В основном, вся необходимая конфигурация у нас есть, но зависимость spring-security-oauth2-authorization-server также в обязательном порядке требует бин описания конфигурации самого OAuth2 сервера. Для этого мы объявим бин authorizationServerSettings и укажем в нем пока единственный параметр issuer - это корневой URL адрес нашего SSO сервера. Чтобы не оставлять такие параметры в коде, вынесем этот URL в application.properties и укажем его через класс конфигураций AuthorizationServerProperties.
AuthorizationServerProperties - это банальный класс, аннотированный при помощи аннотации @ConfigurationProperties и описывающий параметры с определенным префиксом из application.properties файла.

AuthorizationServerProperties.class

@Setter
@Getter
@Configuration
@ConfigurationProperties(prefix = "spring.security.oauth2.authorizationserver")
public class AuthorizationServerProperties {
    private String issuerUrl;
}

application.properties

spring.application.name=authorization-server
server.port=9091
logging.level.root=DEBUG
spring.security.oauth2.authorizationserver.issuer-url=http://localhost:9091

На этом конфигурация простейшего сервера авторизации закончена, приложение oauth2-authorization-server должно корректно собраться и запуститься, а после этого станет доступна форма логина при переходе на /login. Также будут доступны эндпоинты OAuth2 Authorization Server и все 3 типа получения OAuth2 токенов, описанные в спецификации The OAuth 2.1 Authorization Framework.
Ниже приведены примеры методов авторизации через наш сервер авторизации.

Получение токенов методом authorization code flow прямыми запросами

  1. Выполняем запрос /authorize:

     curl --location --request GET 'http://localhost:9091/oauth2/authorize?response_type=code&client_id=test-client&redirect_uri=http://localhost:8080/code'
    
  2. Далее нас перенаправит на страницу логина, в которой мы введем логин/пароль и нажмём Sign In.

  3. После успешного логина нас отправляет на страницу клиента с кодом авторизации. Берём этот код и выполняем запрос на получение токенов с параметром grant_type равным authorization_code и параметром code, в который и помещаем полученное значение кода авторизации:

    curl --location --request POST 'http://localhost:9091/oauth2/token?grant_type=authorization_code&code=M6MsgrcmEa6eKlslkgDoS3mEOSuNoN827eLFUu6-k2Vi1v-xW17it7ojPC6QXbnjVsvCVCvfkIWNRq8kmMZBcPcre2R2N9AvNSxwLCMIiO0q4SRjWcoYrOFztvputvxS&redirect_uri=http://localhost:8080/code' \
    --header 'Authorization: Basic dGVzdC1jbGllbnQ6dGVzdC1jbGllbnQ='
    
  4. Последний запрос вернёт необходимые нам access и refresh токены.

Обратите внимание, что в последнем запросе у нас обязательно должен быть заголовок Authorization с типом Basic, в котором находится base64 строка следующего вида: test-client:test-client. Это наши clientId и clientSecret, которые мы указывали при создании RegisteredClient.

Подключение фронтового клиента

Но наиболее удобный вариант тестирования сервера авторизации - это подключение реального клиента, например, написанного на Vue.js

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

  1. При переходе по адресу http://localhost:8080 нас перенаправит на страницу со ссылкой на форму логина.

    login_8080
    login_8080
  2. Если мы перейдём по этой ссылке, нам откроется форма логина сервера авторизации.

    login_9091
    login_9091
  3. После успешного логина сервер авторизации спросит у нас, какие скоупы (разрешения) мы хотим предоставить клиенту. Выбираем единственный доступный в данном примере и нажимаем Submit.

    login_scope
    login_scope
  4. После чего нам откроется страница http://localhost:8080 с информацией о выданном токене.

    home
    home

Ссылки на источники.

2. Проблемы простейшей реализации и их решения

Безусловно, реализация, описанная выше, подойдёт только для pet- или демо-проекта. Какие изменения необходимо в ней произвести, чтобы проект минимально соответствовал требованиям продакшен-среды? Давайте посмотрим на основные проблемы такой реализации и попробуем их решить.

2.1 Cross-origin resource sharing

Как вы могли обратить внимание, мы не настраивали CORS. Проблемы у нас начнутся сразу, как мы попробуем подключить клиент на VueJs.
При первом же запросе JWT-токена клиент кинет ошибку CORS header 'Access-Control-Allow-Origin' missing. С одной стороны, для решения этой проблемы можно было бы разрешить запросы к серверу с любых хостов, однако ради общей безопасности давайте явно укажем нашему серверу авторизации, откуда разрешены запросы.

Определим бин CorsConfigurationSource, где мы настроим CORS-политики, а также включим CORS в основной конфигурации. Заодно перепишем нашу конфигурацию SecurityFilterChain по рекомендации новой версии Spring Authorization Server:

AuthorizationServerConfig.java

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception {
    // вместо устаревшего вызова OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)
        OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = authorizationServer();
        http
                .securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
                .with(authorizationServerConfigurer, Customizer.withDefaults())
                .authorizeHttpRequests(authorize -> 
                    authorize.anyRequest().authenticated())
                .exceptionHandling(exceptions ->
                    exceptions.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")))
                .cors(Customizer.withDefaults());
        return http.build();
    }
    
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        // настройка CORS позволит запросы с любыми заголовками и методами, но только с перечисленных хостов
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        authorizationServerProperties.getAllowedOrigins().forEach(config::addAllowedOrigin);
        config.setAllowCredentials(true);
        source.registerCorsConfiguration("/**", config);
        return source;
    }

Также для удобства последующей настройки вынесем разрешенные хосты в виде списка в property файл:

AuthorizationServerProperties.java

@Setter
@Getter
@Configuration
@ConfigurationProperties(prefix = "spring.security.oauth2.authorizationserver")
public class AuthorizationServerProperties {
    private String issuerUrl;
    private List<String> allowedOrigins =new ArrayList<>();
}

application.properties

spring.security.oauth2.authorizationserver.allowedOrigins[0]=http://localhost:8080
spring.security.oauth2.authorizationserver.allowedOrigins[1]=http://localhost:8081

2.2 Провайдер пользователей

InMemoryUserDetailsManager совсем не годится для серьезного применения, поэтому давайте напишем свою реализацию UserDetailsService и UserDetails.

В дополнение к UserDetails будем использовать еще и кастомный GrantedAuthority, который представляет собой простую реализацию ABAC. Таким образом, наш CustomGrantedAuthority будет состоять не просто из строковых значений, а иметь вид "ключ-значение":

CustomUserDetails.java

@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails, Serializable {
    private final List<CustomGrantedAuthority> authorities;
    private final String username;
    private final String password;
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }
}

CustomGrantedAuthority.java

public class CustomGrantedAuthority implements GrantedAuthority {
    private String key;
    private String value;

    public CustomGrantedAuthority(String key, String value) {
        this.key = key;
        this.value = value;
    }

    @Override
    public String getAuthority() {
        return key + ":" + value;
    }
}

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

CustomUserDetailsService.java

@Service
@Slf4j
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    private static final List<CustomGrantedAuthority> bigGrantedAuthorityList;
    private final JdbcTemplate jdbcTemplate;
    private String usersByUsernameQuery = "select username,password,enabled from users where username = ?";

    static {
        bigGrantedAuthorityList = IntStream.range(0, 1000)
                .mapToObj(String::valueOf)
                .map(i ->
                        new CustomGrantedAuthority(i, UUID.randomUUID().toString()))
                .collect(Collectors.toList());
    }

    public CustomUserDetails getUserDetails(String username, String password) {
        CustomUserDetails customUserDetails = (CustomUserDetails) loadUserByUsername(username);
        if (!customUserDetails.getPassword().equals(password)) {
            throw new AuthenticationCredentialsNotFoundException(username);
        }
        return customUserDetails;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        RowMapper<CustomUserDetails> mapper = (rs, rowNum) -> {
            String usernameFromDb = rs.getString(1);
            String password = rs.getString(2);
            return new CustomUserDetails(bigGrantedAuthorityList, usernameFromDb, password);
        };
        List<CustomUserDetails> users = jdbcTemplate.query(usersByUsernameQuery, mapper, username);
        if (users.isEmpty()) {
            throw new UsernameNotFoundException("Username %s not found".formatted(username));
        } else {
            return users.get(0);
        }
    }
}

В конфигурацию AuthorizationServerConfig добавим определение бина AuthenticationProvider, а в качестве параметра при создании AuthenticationProviderImpl передадим наш кастомный UserDetailsService:

AuthorizationServerConfig.java

    private final CustomUserDetailsService userDetailsService;

    @Bean
    public AuthenticationProvider authenticationProvider() {
        return new AuthenticationProviderImpl(userDetailsService);
    }

2.3 Хранилище клиентов

В исходном проекте не только пользователи, но и клиенты хранились в памяти (InMemoryRegisteredClientRepository) - следует заменить эту реализацию на что-то более серьезное.

Как и с провайдером пользователей, мы можем написать собственный RegisteredClientRepository. Но Spring предоставляет неплохую реализацию с сохранением настроек клиентов в БД - давайте ею воспользуемся.

Сперва добавим зависимость spring-jdbc в проект:

pom.xml

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

Заменим InMemoryRegisteredClientRepository на JdbcRegisteredClientRepository в конфигурации сервера авторизации:

AuthorizationServerConfig.java

    @Bean
    public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
        return new JdbcRegisteredClientRepository(jdbcTemplate);
    }

Настроим подключение к БД:

application.properties

spring.datasource.url=jdbc:postgresql://127.0.0.1:5432/authorization_server
spring.datasource.password=postgres
spring.datasource.username=postgres

Скрипт создания таблиц Spring любезно оставил в исходниках:

CREATE TABLE oauth2_registered_client
(
    id                            varchar(100)                            NOT NULL,
    client_id                     varchar(100)                            NOT NULL,
    client_id_issued_at           timestamp     DEFAULT CURRENT_TIMESTAMP NOT NULL,
    client_secret                 varchar(200)  DEFAULT NULL,
    client_secret_expires_at      timestamp     DEFAULT NULL,
    client_name                   varchar(200)                            NOT NULL,
    client_authentication_methods varchar(1000)                           NOT NULL,
    authorization_grant_types     varchar(1000)                           NOT NULL,
    redirect_uris                 varchar(1000) DEFAULT NULL,
    scopes                        varchar(1000)                           NOT NULL,
    client_settings               varchar(2000)                           NOT NULL,
    token_settings                varchar(2000)                           NOT NULL,
    PRIMARY KEY (id)
);

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

pom.xml

<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
</dependency>

Создаём директорию src/main/resources/db/migration и в ней файл миграции с нашим скриптом.

2.4 Неочевидные проблемы

Казалось бы, всё: теперь наш сервер авторизации ничего не хранит in-memory, а, значит, мы можем не переживать за потребление памяти. Но давайте на всякий случай протестируем его под нагрузкой.

Для тестирования нагрузки использовался Apache JMeter с groovy скриптом, который в точности повторяет всю логику авторизации с запросом JWT-токена, как это делает наш клиент на Vue.js.

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

out_off_memory
out_off_memory

А в логах нашего SSO сервера в скором времени появятся знаменитые ошибки java.lang.OutOfMemoryError: Java heap space.

Что же занимает память сервера? Если посмотреть дамп памяти приложения, мы увидим, что не от всех in-memory реализаций мы избавились.

inmemory_oauth_service
inmemory_oauth_service

InMemoryOAuth2AuthorizationService - это реализация сервиса OAuth2AuthorizationService, который отвечает за хранение и управление авторизационными данными в контексте OAuth 2.0. Он используется для управления состоянием авторизации (например, хранение токенов, проверку их валидности и удаление устаревших данных).

Сам факт хранения авторизаций в памяти не является чем-то запрещённым, но, исследовав исходный код класса InMemoryOAuth2AuthorizationService, вы обнаружите, что эти авторизации никогда не удаляются и копятся до OutOfMemoryError.
Более того, javadoc прямо заявляет об этом: "NOTE: This implementation should ONLY be used during development/testing."

В качестве реализации OAuth2AuthorizationService для продуктивной среды Spring предлагает использовать JdbcOAuth2AuthorizationService ("NOTE: This OAuth2AuthorizationService is a simplified JDBC implementation that MAY be used in a production environment."). В этом случае авторизации будут храниться в базе данных, что нам позволит избежать утечки памяти.
Неплохо, но в ней также отсутствует очистка истекших авторизаций. Чтобы это исправить, нам нужно расширить предложенную реализацию.

Создадим свой класс CustomJdbcOAuth2AuthorizationService и наследуем его от JdbcOAuth2AuthorizationService. В конструкторе создадим экземпляр Timer и с помощью него запустим CleanReminder.
CleanReminder - это внутренний класс, который будет выполнять запрос к БД по очистке авторизаций с протухшим токеном, а также зависших авторизаций, когда пользователь запросил authorization code, но так им и не воспользовался.

CustomJdbcOAuth2AuthorizationService.java

public class CustomJdbcOAuth2AuthorizationService extends JdbcOAuth2AuthorizationService {
    private static final String REMOVE_EXPIRED_AUTHORIZATION_SQL =
            "DELETE FROM oauth2_authorization  WHERE (access_token_expires_at < ? and (refresh_token_expires_at < ? or refresh_token_value is null)) " +
                    "or (access_token_value is null and refresh_token_value is null and (authorization_code_expires_at < ? or authorization_code_value is null))";

    public CustomJdbcOAuth2AuthorizationService(
            JdbcOperations jdbcOperations, RegisteredClientRepository registeredClientRepository) {
        super(jdbcOperations, registeredClientRepository);
        Timer timer = new Timer();
        timer.schedule(new CleanReminder(), 0, 3600000);
    }

    class CleanReminder extends TimerTask {
        public void run() {
            Object[] parameters =
                IntStream.range(0, 3)
                    .mapToObj(i -> new SqlParameterValue(Types.TIMESTAMP, Timestamp.from(Instant.now())))
                    .toArray();
            PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters);
            int deleted = getJdbcOperations().update(REMOVE_EXPIRED_AUTHORIZATION_SQL, pss);
            log.debug("----------- Expired Authorizations Deleted: {}", deleted);
        }
    }
}

Добавим создание бина нашего CustomJdbcOAuth2AuthorizationService в конфигурацию сервера авторизации:

AuthorizationServerConfig.java

    @Bean
    OAuth2AuthorizationService jdbcOAuth2AuthorizationService(JdbcOperations jdbcOperations,
                                                          RegisteredClientRepository registeredClientRepository) {
    return new CustomJdbcOAuth2AuthorizationService(jdbcOperations, registeredClientRepository);
}

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

CREATE TABLE IF NOT EXISTS oauth2_authorization
(
    id                            varchar(100) NOT NULL,
    registered_client_id          varchar(100) NOT NULL,
    principal_name                varchar(200) NOT NULL,
    authorization_grant_type      varchar(100) NOT NULL,
    authorized_scopes             varchar(1000) DEFAULT NULL,
    attributes                    text          DEFAULT NULL,
    state                         varchar(500)  DEFAULT NULL,
    authorization_code_value      text          DEFAULT NULL,
    authorization_code_issued_at  timestamp     DEFAULT NULL,
    authorization_code_expires_at timestamp     DEFAULT NULL,
    authorization_code_metadata   text          DEFAULT NULL,
    access_token_value            text          DEFAULT NULL,
    access_token_issued_at        timestamp     DEFAULT NULL,
    access_token_expires_at       timestamp     DEFAULT NULL,
    access_token_metadata         text          DEFAULT NULL,
    access_token_type             varchar(100)  DEFAULT NULL,
    access_token_scopes           varchar(1000) DEFAULT NULL,
    oidc_id_token_value           text          DEFAULT NULL,
    oidc_id_token_issued_at       timestamp     DEFAULT NULL,
    oidc_id_token_expires_at      timestamp     DEFAULT NULL,
    oidc_id_token_metadata        text          DEFAULT NULL,
    refresh_token_value           text          DEFAULT NULL,
    refresh_token_issued_at       timestamp     DEFAULT NULL,
    refresh_token_expires_at      timestamp     DEFAULT NULL,
    refresh_token_metadata        text          DEFAULT NULL,
    user_code_value               text          DEFAULT NULL,
    user_code_issued_at           timestamp     DEFAULT NULL,
    user_code_expires_at          timestamp     DEFAULT NULL,
    user_code_metadata            text          DEFAULT NULL,
    device_code_value             text          DEFAULT NULL,
    device_code_issued_at         timestamp     DEFAULT NULL,
    device_code_expires_at        timestamp     DEFAULT NULL,
    device_code_metadata          text          DEFAULT NULL,
    PRIMARY KEY (id)
    );

2.5 Jackson

Если мы попытаемся запустить приложение сразу после внесения правок по OAuth2AuthorizationService, то при аутентификации мы увидим ошибку The class with ru.ntik.authorization_server.security.impl.CustomUserDetails and name of ru.ntik.authorization_server.security.impl.CustomUserDetails is not in the allowlist. If you believe this class is safe to deserialize, please provide an explicit mapping using Jackson annotations or by providing a Mixin. If the serialization is only done by a trusted source, you can also enable default typing. See https://github.com/spring-projects/spring-security/issues/4370 for details.

Данная ошибка связана с ограничением безопасности в Spring Security, начиная с версии 5.7.0. Spring Security по умолчанию запрещает десериализацию классов, не входящих в "белый список" (allowlist), чтобы предотвратить риски, связанные с десериализацией произвольных объектов. В нашем случае класс CustomUserDetails не входит в этот список, и Spring не может его десериализовать. А нам это необходимо, так как теперь авторизации хранятся в БД и, соответственно, проходят сериализацию и десериализацию.

Для исправления этой ошибки нам нужно указать Spring Security, по каким правилам должна осуществляться сериализация/десериализация нашего кастомного пользователя.
Как нам и предлагает Spring, добавим Mixin на все классы, которые мы используем в CustomUserDetails. И создадим на их основе модуль, который позже зарегистрируем в ObjectMapper в нашем CustomJdbcOAuth2AuthorizationService.

CustomUserDetailsMixin.java

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME)
@JsonAutoDetect(
        fieldVisibility = JsonAutoDetect.Visibility.ANY,
        getterVisibility = JsonAutoDetect.Visibility.NONE,
        isGetterVisibility = JsonAutoDetect.Visibility.NONE)
@JsonIgnoreProperties(ignoreUnknown = true)
public abstract class CustomUserDetailsMixin {}

CustomGrantedAuthorityMixin.java

@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
@JsonAutoDetect(
        fieldVisibility = JsonAutoDetect.Visibility.ANY,
        getterVisibility = JsonAutoDetect.Visibility.NONE,
        isGetterVisibility = JsonAutoDetect.Visibility.NONE)
@JsonIgnoreProperties(ignoreUnknown = true)
public abstract class CustomGrantedAuthorityMixin {
    @JsonCreator
    public CustomGrantedAuthorityMixin(@JsonProperty("authority") String authority) {
    }
}

CustomUserDetailsJackson2Module.java

public class CustomUserDetailsJackson2Module extends SimpleModule {
    public CustomUserDetailsJackson2Module() {
        super(CustomUserDetailsJackson2Module.class.getName(), new Version(1, 0, 0, null, null, null));
    }

    @Override
    public void setupModule(SetupContext context) {
        SecurityJackson2Modules.enableDefaultTyping(context.getOwner());
        context.setMixInAnnotations(CustomUserDetails.class, CustomUserDetailsMixin.class);
        context.setMixInAnnotations(CustomGrantedAuthority.class, CustomGrantedAuthorityMixin.class);
    }
}

CustomJdbcOAuth2AuthorizationService.java

public CustomJdbcOAuth2AuthorizationService(
        JdbcOperations jdbcOperations, RegisteredClientRepository registeredClientRepository) {
    super(jdbcOperations, registeredClientRepository);
    JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper rowMapper =
            new JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper(registeredClientRepository);
    ClassLoader classLoader = JdbcOAuth2AuthorizationService.class.getClassLoader();
    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.registerModules(new CoreJackson2Module());
    objectMapper.registerModules(SecurityJackson2Modules.getModules(classLoader));
    objectMapper.registerModule(new OAuth2AuthorizationServerJackson2Module());
    objectMapper.registerModule(new CustomUserDetailsJackson2Module());
    rowMapper.setObjectMapper(objectMapper);
    this.setAuthorizationRowMapper(rowMapper);
    Timer timer = new Timer();
    timer.schedule(new CleanReminder(), 0, 3600000);
}

Так как Spring сериализует GrantedAuthority через getAuthority(), нам нужно настроить корректную сериализацию/десериализацию GrantedAuthority с использованием этого метода и поля authority.
Для этого добавим пару аннотаций и конструктор в класс CustomGrantedAuthority.

CustomGrantedAuthority.java

    @JsonCreator
    public CustomGrantedAuthority(String authority) {
        String[] split = authority.split(":");
        this.key = split[0];
        this.value = split[1];
    }

    @Override
    @JsonProperty("authority")
    public String getAuthority() {
        return key + ":" + value;
    }

2.6 SessionRegistry

В данной статье мы не рассматривали ещё одну особенность Spring Authorization Server, а именно поддержку протокола OpenID Connect.

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

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

SessionRegistryImpl — это реализация интерфейса SessionRegistry в библиотеке Spring Security, которая используется для отслеживания и управления активными сессиями пользователей в веб-приложениях. Этот класс помогает контролировать одновременные сессии, ограничивать количество активных сессий для одного пользователя и обрабатывать события сессий (например, выход пользователя или истечение срока действия сессии).

Spring Authorization Server создает SessionRegistryImpl автоматически, если вы самостоятельно не определяете реализацию SessionRegistry.

SessionRegistryImpl для контроля сессий реализует интерфейс ApplicationListener<AbstractSessionEvent> - механизм в Spring Framework, который позволяет обрабатывать события, связанные с жизненным циклом HTTP-сессий (например, создание, уничтожение, изменение сессии). Он используется в сочетании с Spring Security для отслеживания действий, происходящих с сессиями пользователей, и реализации кастомной логики (логирование, уведомления, мониторинг и т.д.).

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

Чтобы исправить эту проблему, нам нужно определить бин ServletListenerRegistrationBean<HttpSessionEventPublisher> в конфигурации сервера авторизации.

AuthorizationServerConfig.java

    @Bean
    public ServletListenerRegistrationBean<HttpSessionEventPublisher> httpSessionEventPublisher() {
        return new ServletListenerRegistrationBean<>(new HttpSessionEventPublisher());
    }

2.7 Авторизация для одностраничных приложений

В первой части статьи уже упоминалось создание клиентского приложения, работающего в браузере (Vue.js), и приводился пример простой реализации, в которой секрет клиента прописывался в исходниках. Такой клиент регистрируется в сервере авторизации и запрашивает у него доступ к серверу ресурсов от лица пользователя. Подобно паролю пользователя, клиент должен в процессе авторизации передавать серверу авторизации свой секрет, который сравнивается с его хешом из регистрационной записи. Однако специфика современных браузерных приложений, созданных на популярных фреймворках, такова, что весь код, включая секрет, хоть и в минифицированном виде, загружается в компьютер пользователя и полностью доступен для разбора и анализа. Такой компрометирующий подход может позволить злоумышленникам при помощи оригинального секрета получить access_token и доступ к защищенным ресурсам пользователя.

По этой причине в OAuth 2.1 все браузерные и мобильные клиенты считаются небезопасными и для них разработан специальный протокол авторизации, который устраняет описанную проблему. Он называется PKCE (Proof Key for Code Exchange) и является надстройкой над авторизационным грантом "authorization_code". Решение проблемы компрометации клиентского секрета простое: в регистрационных данных клиента секрет отсутствует, вместо него в базе данных записан NULL.

Авторизация при подходе "PKCE + authorization_code" усложняется:

Было:

было
было

Стало:

стало
стало

Основное отличие заключается в том, что вместо постоянного секрета клиента применяется динамическая пара code_verifier + code_challenge. Первый элемент - это некоторая сгенерированная строка, а второе - ее хеш. В таком случае злоумышленник, даже если и сможет перехватить code_challenge, не сможет обменять его токен - у него не будет второй половины "ключа", т.е. code_verifier, который клиент не отправляет при первом запросе. Сервер авторизации при первоначальном запросе /authorize принимает в параметрах запроса code_challenge и code_challenge_method, описывающий алгоритм хеширования (в примере выше это SHA-256). На запросе /token сервер авторизации сравнивает исходную строку и ее хеш и на этом основании возвращает либо токен, либо ошибку.

Пример реализации

Требование PKCE в регистрации клиента:

В разделе client_settings должно быть свойство requireProofKey=true ({"settings.client.require-proof-key":true})

Реализация на стороне клиента (TypeScript)


import { sha256 } from 'js-sha256';
import { nanoid } from '@reduxjs/toolkit';


export const encodeSHA256 = (str: string) => {
    const hash = sha256.create();
    hash.update(str);
    const buffer = hash.arrayBuffer();

    const array = Array.from(new Uint8Array(buffer));
    const binaryString = String.fromCharCode(...array);
    return btoa(binaryString).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
};

export const getAuthorizeRequestParams = (code: string, clientId: string, redirectURL: string) =>
    new URLSearchParams({
        response_type: 'code',
        client_id: clientId, /* зарегистрированный ID клиента */
        redirect_uri: redirectURL, /* зарегистрированный редирект после авторизации */
        code_challenge: encodeSHA256(code),
        code_challenge_method: 'S256'
    });

export const handleAuthorize = (authUrl: string, clientId: string, redirectUrl) => {
    const codeVerifier = nanoid(); /* или любая другая случайная строка */
    // перед хешированием случайная строка должна быть сохранена в памяти, например в local storage
    localStorage.setItem("code_verifier", codeVerifier);
    location.href = `${authUrl}?${decodeURIComponent(getAuthorizeRequestParams(codeVerifier, clientId, redirectUrl))}`
        /* код выше запрашивает страницу /authorize сервера авторизации и передает идентификатор клиента,
        закодированный в base64 хеш случайной строки, вид хеширования и желаемый адрес для редиректа, который выполнит
        сервер авторизации в случае успеха */
}

export const handlePostRedirect = (tokenUrl: string, redirectUrl: string, clientId: string) => {
    /* Код авторизации, выдаваемый сервером авторизации, выглядит как параметр 'code', который он добавляет к адресу,
     указанному в redirectUrl */
    const authCode = new URLSearchParams(window.location.search).get('code');
    
    fetch(tokenUrl, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded'
        },
        body: new URLSearchParams({
            grant_type: 'authorization_code',
            code: authCode,
            redirect_uri: redirectUrl,
            client_id: clientId,
            code_verifier: localStorage.getItem("code_verifier") // незахешированная исходная строка
        })
    })
        .then(response => response.json())
        .then(data => {
            data.access_token // в ответе от сервера будет access_token
        })
}

Приведенный вариант авторизации с использованием динамического разделяемого секрета клиента хоть и надежно защищает взаимодействие между SPA-приложением и сервером авторизации, но не рассматривает проблему хранения токенов. Разработчик клиентского приложения может выбрать между хранением в памяти или хранением в браузере (local/session storage, indexDB). По мнению авторов, наиболее безопасный вариант - возвращать токены не в теле ответа, а в виде HttpOnly cookie, однако эта тема выходит за рамки настоящей статьи.

Заключение

Реализация SSO-сервера на Spring Authorization Server достаточно нетривиальная задача и требует не только технических знаний, но и понимания архитектурных решений, обеспечивающих масштабируемость и безопасность. В этой статье мы рассмотрели некоторые аспекты реализации сервера авторизации минимально пригодного для использования в реальных проектах.

Однако это лишь начало. Spring Authorization Server предоставляет гибкие возможности, и с правильным подходом вы можете создать надежный и производительный сервер авторизации, готовый к использованию в действительно серьёзных проектах.
В будущем, если будет особенный интерес к этой статье, мы напишем дополнение, где подробнее рассмотрим безопасность, OpenID Connect и другие полезные фичи, которые можно привнести в наш SSO сервер.

Спасибо!

Исходный код можно найти здесь

Aвторы: @devolek, @lvchgntsv

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


  1. Filex
    21.08.2025 06:22

    Мощный разбор. Я правильно понимаю, что это основано на личном опыте в проде?


    1. devolek Автор
      21.08.2025 06:22

      Да, основано на личном опыте в проде. Но, естественно, это лишь часть, которая относится непосредственно к Spring Authorization Server