Строим свой SSO сервер используя Spring Authorization Server

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Вступление

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

Единый вход в систему (Single sign-on, SSO) – это решение для аутентификации,
которое дает пользователям возможность входить в несколько приложений и на
несколько веб-сайтов с использованием единовременной аутентификации пользователя.

Возиться с этим особо долго мне не хотелось. Все таки это для pet-проектов. Поэтому выбор изначально пал на Keycloak, как самое популярное решение SSO сервера.

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

Запустив и чуть-чуть поковырявшись с ним, я понял, что мне он не подходит. Я люблю в своих проектах иметь возможность быстро и легко кастомизировать решение под свои цели (особенно в pet-проектах бывают разные эксперименты). Я пишу на Java и в основном использую проекты Spring для решения своих задач. Поэтому после экспериментов с Keycloak выбор пал на Spring Security. На работе я уже несколько раз создавал сервер SSO, но всегда с использованием Spring Boot 2 и Spring OAuth2, и конечно же мне было интересно посмотреть в действии как на Spring Boot 3, так и новый Spring Authorization Server. Поэтому, почитав пару статей на хабре и вооружившись самыми последними версиями данных фреймворков (на момент написания статьи Spring Authorization Server 1.0.2, Spring Boot 3.0.6), я приступил к настройке собственного SSO сервера. К сожалению, я быстро столкнулся с проблемой, что в интернете очень мало информации о возможностях кастомизации готовых конфигураций Spring Authorization Server, поэтому и решил написать данную статью. Итак, перейдем от слов к делу!

Цели

При разработке своего SSO я поставил себе следующие требования:

Технические требования:

  1. Использование непрозрачных токенов

  2. Использование последних версий Spring Boot и Spring Authorization Server

  3. Java 17

  4. Использование SPA Vue.JS приложения в качестве фронта SSO

  5. Использование Redis в качестве кэш хранилища (хранение токенов и т.д.)

  6. Использование PostgreSQL в качестве основного хранилища

  7. Подключить Swagger и настроить там авторизацию

Функциональные требования:

  1. Аутентификация пользователей на SSO через форму логина/пароля

  2. Аутентификация пользователей на SSO через Google, Github и Yandex

  3. Авторизация по протоколу OAuth2.1 для моих pet-проектов

  4. Получение информации о пользователе по токену доступа из SSO

  5. Регистрация пользователей через Google, Github и Yandex

  6. Регистрация пользователей через отдельную форму регистрации на SSO

  7. Возможность управления выданными токенами (отзыв токена, просмотр активных сессий и т.д.)

Раздел 1: Строим простейший Spring Authorization Server

При погружении в Spring Authorization Server я был поражен, на сколько разработчики упростили процесс конфигурации, и насколько теперь структурированы и понятны исходники фреймворка. Поэтому, если вы сталкиваетесь с проблемами его настройки, можете смело смотреть в исходники, там с вероятностью 80% найдете решение. Создадим Maven проект и добавим модуль нашего sso server, назовем его j-sso. Я сразу создам многомодульную конфигурацию Maven, чтобы в дальнейшем было проще расширять наш demo-проект. После создания базовой конфигурации Maven проекта, добавим в проект зависимости Spring Boot и Spring Authorization Server. На момент написания статьи последняя версия Spring Boot 3.0.6, а Spring Authorization Server 1.0.2. Ниже приведен пример корневого pom.xml файла.

Корневой pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://maven.apache.org/POM/4.0.0"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>ru.dlabs</groupId>
    <artifactId>spring-authorization-server-example</artifactId>
    <packaging>pom</packaging>
    <version>0.0.1</version>
    <name>spring-authorization-server-example</name>

    <properties>
        <java.version>17</java.version>
        <maven.compiler.target>17</maven.compiler.target>
        <maven.compiler.source>17</maven.compiler.source>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

        <security-oauth2-server.version>1.0.2</security-oauth2-server.version>
    </properties>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.0.5</version>
        <relativePath/>
    </parent>

    <modules>
        <module>j-sso</module>
    </modules>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.security</groupId>
                <artifactId>spring-security-oauth2-authorization-server
                </artifactId>
                <version>${security-oauth2-server.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>
</project>

Для реализации нашего j-sso нам понадобится следующие стартеры Spring Boot:

  • spring-boot-starter-security

  • spring-boot-starter-web

  • spring-boot-starter

Не забудем подключить сам Spring Authorization Server. Также нам нужна какая-нибудь зависимость для логирования. Я люблю во всех своих проектах использовать log4j2. Поэтому, отключим логгер по умолчанию и подключим log4j2. Для этого исключим из spring-boot-starter spring-boot-starter-logging и подключим spring-boot-starter-log4j2. Ну и конечно для удобства работы подключим lombok, куда же мы без него)) Ниже приведена полная конфигурация pom.xml для модуля j-sso.

pom.xml модуля j-sso
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>spring-authorization-server-example</artifactId>
        <groupId>ru.dlabs</groupId>
        <version>0.0.1</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>j-sso</artifactId>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-logging</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-log4j2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-authorization-server</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${lombok.version}</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>

    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <finalName>${project.name}</finalName>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

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

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

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

SecurityConfig.java

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

@EnableWebSecurity
@RequiredArgsConstructor
@Configuration(proxyBeanMethods = false)
public class SecurityConfig {

    @Bean
    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("USER")
                .build();
        return new InMemoryUserDetailsManager(user);
    }
}

Здесь создадим самую простую конфигурацию безопасности. Создадим бин SecurityFilterChain в нем укажем, что все эндпоинты заведены под секурити, и добавим конфигурацию страницы входа, поставляемую по умолчанию, указав Customizer.withDefaults() в качестве параметра DSL метода formLogin(...). Также создадим бин UserDetailsService и укажем в нем in memory реализацию этого интерфейса. Он у нас будет отвечать за хранение и получение данных по логину в процессе аутентификации пользователя.

Настраиваем класс описывающий конфигурацию Authorization Server.

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

AuthorizationServerConfig.java


@RequiredArgsConstructor
@Configuration(proxyBeanMethods = false)
public class AuthorizationServerConfig {

    private final AuthorizationServerProperties authorizationServerProperties;

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        http.exceptionHandling(exceptions ->
                exceptions.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/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:5000/code")
                        .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                        .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                        .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                        .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                        .build()
        );
    }

    @Bean
    public JWKSource<SecurityContext> jwkSource() {
        RSAKey rsaKey = JwkUtils.generateRsa();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
    }

    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder()
                .issuer(authorizationServerProperties.getIssuerUrl())
                .build();
    }
}

В нашем бине registeredClientRepository сразу зарегистрируем тестового клиента. Укажем ему client_id test-client и такой же client_secret. PasswordEncoder указывать не будем. Укажем все доступные grant types. В методе аутентификации установим Basic Authentication - это значит, чтобы пройти аутентификацию клиента, нам необходимо указать Authorization хедер с типом Basic. Обратите внимание на параметр redirectUri, он необходим для типа аутентификации authorization code flow, то есть для grant_type AUTHORIZATION_CODE. В этом параметре мы указываем, на какой URL разрешен редирект после успешной аутентификации пользователя.

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

JwkUtils.java
public class JwkUtils {

    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.yml и укажем его через проперти класс AuthorizationServerProperties. AuthorizationServerProperties - это банальный класс аннотированный при помощи аннотации @ConfigurationProperties, и описывающий параметры с определенным префиксом из application.yml файла.

AuthorizationServerProperties.class


@Setter
@Getter
@Configuration
@ConfigurationProperties(prefix = "spring.security.oauth2.authorizationserver")
public class AuthorizationServerProperties {

    private String issuerUrl;
    private String introspectionEndpoint;
}

application.yml

server:
    port: 7777

logging:
    level:
        root: DEBUG
        org.apache.tomcat.util.net.NioEndpoint: ERROR
        sun.rmi: ERROR
        java.io: ERROR
        javax.management: ERROR

spring:
    application:
        name: j-sso
    security:
        oauth2:
            authorizationserver:
                issuer-url: http://localhost:7777

На этом самая простая конфигурация сервера авторизации закончена. Можно собирать и запускать наш j-sso. После успешного запуска у нас доступна форма логина в нашем j-sso при переходе на /login. А также доступны эндпоинты OAuth2 Authorization Server и соответственно все 3 типа получения OAuth2 токенов, описанные в спецификации The OAuth 2.1 Authorization Framework. Да, вы не ошиблись, spring-security-oauth2-authorization-server версии 1.x.x поддерживает именно OAuth2.1, а не OAuth2.0. Поэтому, не ищите в SSO password grant type, его не существует по умолчанию. Думаю, в дальнейших статьях мы посмотрим, как можно создать собственную реализацию password grant type и внедрить её в наш j-sso, но в этой статье этого делать не будем, ограничимся тем что есть. Ниже приведены все доступные примеры методов авторизации через наш j-sso.

Получение токенов методом authorization code flow:

Выполняем запрос /authorization:

curl --location --request GET 'http://localhost:7777/oauth2/authorize?response_type=code&client_id=test-client&redirect_uri=http://localhost:5000/code'
Вот так он будет выглядеть, если вы его выполните в браузере
Запрос authorization
Запрос authorization

Далее нас перенаправит на страницу логина, в которой мы введем логин/пароль и нажмём Sign In.

Стандартная форма аутентификации Spring Security
Стандартная форма аутентификации Spring Security

После этого выполнится POST запрос на эндпоинт /login, и нас опять перенаправит на первый запрос. Как работает эта магия, будет описано во втором разделе этой статьи.

POST запрос аутентификации
POST запрос аутентификации

Повторное выполнение запроса authorization, и в заголовке ответа Locationможно увидеть код авторизации.

Повторное выполнение запроса authorization
Повторное выполнение запроса authorization

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


curl --location --request POST 'http://localhost:7777/oauth2/token?grant_type=authorization_code&code=M6MsgrcmEa6eKlslkgDoS3mEOSuNoN827eLFUu6-k2Vi1v-xW17it7ojPC6QXbnjVsvCVCvfkIWNRq8kmMZBcPcre2R2N9AvNSxwLCMIiO0q4SRjWcoYrOFztvputvxS&redirect_uri=http://localhost:5000/code' \
--header 'Authorization: Basic dGVzdC1jbGllbnQ6dGVzdC1jbGllbnQ='

Пример запроса/ответа из Postman
Получение токена доступа по коду авторизации
Получение токена доступа по коду авторизации

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

Обновление токена:

Мы также можем обновить токен, выполнив следующий запрос:


curl --location --request POST 'http://localhost:7777/oauth2/token?grant_type=refresh_token&refresh_token=W8jsk970AG8p9oYjJ_mlT0Fgf-VWjEemcmXW9hvvcvgj_D3Rc_yfrDu5Dxm4C6ccUP5sZQY6eAjQOSTOuSPln0dNkf-9nXC7UcAN084T1bfBsUHO05ICszNAy2Az4sai' \
--header 'Authorization: Basic dGVzdC1jbGllbnQ6dGVzdC1jbGllbnQ='

Также используется заголовок Authorization, как и в запросе выше.

Пример запроса/ответа из Postman
Обновление токена доступа
Обновление токена доступа

Client Credentials авторизация:

Для получения токена доступа с grant_type равным client_credentials, выполните запрос ниже.


curl --location --request POST 'http://localhost:7777/oauth2/token?grant_type=client_credentials' \
--header 'Authorization: Basic dGVzdC1jbGllbnQ6dGVzdC1jbGllbnQ='

Пример запроса/ответа из Postman
Получение токена Client Credentials
Получение токена Client Credentials

На этом простейшая конфигурация нашего SSO сервиса закончена, переходим к кастомизациям.

Исходники данного раздела смотрите здесь.

Раздел 2: Переходим на Opaque token и тестируем с реальным клиентом

Теперь у нас есть простейший, но рабочий вариант Authorization Server, отталкиваясь от него, мы будем превращать его в тот Authorization Server, который нам нужен. Вспомним какие технические требования мы ставили. Первым пунктом шло использование непрозрачных (opaque) токенов вместо JWT.

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

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

  1. OAuth2TokenFormat.SELF_CONTAINED - JWT формат

  2. OAuth2TokenFormat.REFERENCE- Opaque формат

Этот формат указывается при загрузке/создании самого объекта клиента RegisteredClient через специальное поле называемое tokenSettings. Это поле имеет тип TokenSettings, через которое настраиваются токены этого клиента. По классу, конечно, сразу понять, какие настройки есть, трудно, но у этого класса есть builder() а там уже более-менее все понятно. Конечно, в этом месте не помешала бы документация, так как в документации про это поле есть только одна строчка

tokenSettings: The custom settings for the OAuth2 tokens issued to the client – for example, access/refresh token time-to-live, reuse refresh tokens, and others.

Добавим настройки токенов нашего test-client.

AuthorizationServerConfig.java


@RequiredArgsConstructor
@Configuration(proxyBeanMethods = false)
public class AuthorizationServerConfig {

    // .........

    @Bean
    public RegisteredClientRepository registeredClientRepository() {
        return new InMemoryRegisteredClientRepository(
                RegisteredClient.withId("test-client-id")
                        .clientName("Test Client")
                        .clientId("test-client")
                        .clientSecret("{noop}test-client")
                        .redirectUri("http://127.0.0.1:8080/code")
                        .scope("read.scope")
                        .scope("write.scope")
                        .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                        .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                        .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                        .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                        .tokenSettings(TokenSettings.builder()
                                .accessTokenFormat(OAuth2TokenFormat.REFERENCE)
                                .accessTokenTimeToLive(Duration.of(30, ChronoUnit.MINUTES))
                                .refreshTokenTimeToLive(Duration.of(120, ChronoUnit.MINUTES))
                                .reuseRefreshTokens(false)
                                .authorizationCodeTimeToLive(Duration.of(30, ChronoUnit.SECONDS))
                                .build())
                        .build()
        );
    }

    // TODO это больше не нужно после перехода на использование OPAQUE токенов
    //    @Bean
    //    public JWKSource<SecurityContext> jwkSource() {
    //        RSAKey rsaKey = JwkUtils.generateRsa();
    //        JWKSet jwkSet = new JWKSet(rsaKey);
    //        return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
    //    }

    //...............

}

Давайте детальнее разберем какие параметры у нас имеются:

  1. accessTokenFormat() - указываем формат access_token (JWT или Opaque)

  2. accessTokenTimeToLive() - указываем время жизни нашего access_token

  3. refreshTokenTimeToLive() - указываем время жизни refresh_token

  4. reuseRefreshTokens() - указываем, разрешено ли переиспользовать refresh_token повторно, если его срок действия еще не истек

  5. authorizationCodeTimeToLive() - указываем время жизни authorization code который используется при Authorization Code Flow

  6. idTokenSignatureAlgorithm() - алгоритм подписи для генерации идентификационного токена в OpenID Connect (OIDC)

Итак, мы настроили использование нашим test-client Opaque token вместо JWT. Также указали время жизни access token равное 30-и минутам, а время жизни refresh token равное 120 минут. Запретили переиспользовать refresh token повторно и указали время жизни authorization code равное 30 секунд. Бин jwkSource нам больше не нужен как и класс JwkUtils, мы можем их смело убрать.

Прежде чем мы перейдем к тестированию, нам необходимо еще настроить Introspection Endpoint. Мы настроили использование непрозрачных токенов, а значит нам необходим механизм для валидации и получения информации этих токенов. Для этого в спецификации OAuth2 имеется раздел под названием [Token Introspection Endpoint (https://www.oauth.com/oauth2-servers/token-introspection-endpoint/). Там описан протокол конечной точки, который возвращает информацию о токене доступа, предназначенном для использования серверами ресурсов или другими внутренними серверами. Обратимся к документации Spring Authorization Server и найдем там раздел, который называется OAuth2 Token Introspection Endpoint. В этом разделе описаны параметры конфигурации обработки этих запросов. Пока здесь мы все оставим по умолчанию, но в дальнейшем нам это пригодится. Изменим лишь только сам URL данной конечной точки. Для этого в бине authorizationServerSettings укажем нужный нам URL tokenIntrospectionEndpoint(...). Так как issue url мы вынесли в файл application.yml, то давайте с нашим introspection endpoint поступим также.

AuthorizationServerConfig.java


@RequiredArgsConstructor
@Configuration(proxyBeanMethods = false)
public class AuthorizationServerConfig {

    // ......

    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder()
                .issuer(authorizationServerProperties.getIssuerUrl())
                .tokenIntrospectionEndpoint(authorizationServerProperties.getIntrospectionEndpoint())
                .build();
    }
}

application.yml

server:
    port: 7777

spring:
    application:
        name: j-sso
    security:
        oauth2:
            authorizationserver:
                issuer-url: http://localhost:7777
                introspection-endpoint: /oauth2/token-info

Соберем и запустим наш сервер авторизации OAuth2. Теперь при выполнении запросов из первого раздела мы получаем не JWT токены, а непрозрачные токены. Первый технический пункт, который мы ставили в самом начале статьи, выполнен.

Построим тестовый клиент для j-sso

Нам предстоит еще очень много чего настроить, да и хочется уже "руками потрогать" рабочий процесс с использованием authorization code. Поэтому, в конце этого раздела добавим простейший VueJS клиент, который будет авторизовываться через наш j-sso и выводить информацию о токене. Думаю, этого будет пока достаточно.

Приступим!
Про клиент расскажу вкратце, не будем вдаваться в подробности построения приложений на VueJS, про это очень много есть статей на Хабре.

Добавим в корень директорию test-client - в ней будет находиться само VueJS приложение. При помощи vue-cli создадим простейший шаблон приложения. Вот документация, где описано как это делается. Node.js я взял версии 16.17.0.

package.json
{
    "name": "test-client",
    "version": "0.0.1",
    "scripts": {
        "serve": "vue-cli-service serve",
        "build": "vue-cli-service build"
    },
    "dependencies": {
        "core-js": "^3.8.3",
        "vue": "^3.2.13",
        "vue-router": "^4.0.3",
        "vuex": "^4.0.0",
        "axios": "^0.27.2"
    },
    "devDependencies": {
        "@vue/cli-plugin-babel": "~5.0.0",
        "@vue/cli-plugin-router": "~5.0.0",
        "@vue/cli-plugin-vuex": "~5.0.0",
        "@vue/cli-service": "~5.0.0"
    },
    "engines": {
        "npm": ">=8.0.0",
        "node": ">=16.0.0"
    },
    "browserslist": [
        "> 1%",
        "last 2 versions",
        "not dead",
        "not ie 11"
    ]
}

Удалим автоматически сгенерированные страницы и компоненты. Создадим следующие простейшие страницы:

  1. login.vue - страница логина. Добавим на нее только одну кнопку Login, которая будет запускать процесс авторизации через j-sso

  2. home.vue - домашняя страница, которая будет доступна только после успешной авторизации. На ней будет отображена информация о токене.

Ниже вы можете посмотреть эти страницы:

login.vue

<template>
    <h1>
        LOGIN PAGE
    </h1>
    <button @click="login">
        LOGIN
    </button>
</template>

<script>
import LoginService from "@/services/login-service";

export default {
    name: "login",
    methods: {
        login() {
            return LoginService.login();
        }
    }
}
</script>

<style scoped>

</style>
home.vue

<template>
    <h1>HOME PAGE</h1>
    <p aria-multiline="true" aria-rowcount="20">
        {{ tokenInfoString }}
    </p>
</template>

<script>

import LoginService from "@/services/login-service";

export default {
    name: "home",
    data: () => {
        return {
            tokenInfo: {}
        }
    },
    methods: {
        getCurrentPrincipal() {
            LoginService.getTokenInfo()
                    .then(result => {
                        console.log("Result getting token info: ", result);
                        if (!result.data.active) {
                            this.$router.replace({name: "login"});
                            return;
                        }
                        this.tokenInfo = result.data;
                    })
                    .catch((err) => {
                        console.log("Error getting token info: ", err);
                        this.$router.replace({name: "login"});
                    })
        }
    },
    computed: {
        tokenInfoString() {
            if (!this.tokenInfo) {
                return null;
            }
            return JSON.stringify(this.tokenInfo, null, 8);
        }
    },
    mounted() {
        this.getCurrentPrincipal();
    }
}
</script>

<style scoped>
    p {
        white-space: pre-wrap;
        text-align: left;
        margin-left: 20px;
        font-size: 1.5em;
    }
</style>

Также создадим файл login-service.js, в нем опишем всю необходимую логику авторизации и получения информации о токене.

login-service.js

import axios from "axios";

const serverUrl = process.env.VUE_APP_OAUTH_URL;
axios.defaults.baseURL = serverUrl;

const clientId = process.env.VUE_APP_OAUTH_CLIENT_ID;
const authHeaderValue = process.env.VUE_APP_OAUTH_AUTH_HEADER;
const redirectUri = process.env.VUE_APP_OAUTH_REDIRECT_URI;

const ACCESS_TOKEN_KEY = "access_token";

export default {

    // делаем первичный запрос на авторизацию через j-sso
    login() {
        let requestParams = new URLSearchParams({
            response_type: "code",
            client_id: clientId,
            redirect_uri: redirectUri,
            scope: 'read.scope write.scope'
        });
        window.location = serverUrl + "/oauth2/authorize?" + requestParams;
    },

    // После успешного получения кода авторизации, делаем запрос на получение access и refresh токенов
    getTokens(code) {
        let payload = new FormData()
        payload.append('grant_type', 'authorization_code')
        payload.append('code', code)
        payload.append('redirect_uri', redirectUri)
        payload.append('client_id', clientId)

        return axios.post('/oauth2/token', payload, {
                    headers: {
                        'Content-type': 'application/url-form-encoded',
                        'Authorization': authHeaderValue
                    }
                }
        ).then(response => {

            // получаем токены, кладем access token в LocalStorage
            console.log("Result getting tokens: " + response.data)
            window.sessionStorage.setItem(ACCESS_TOKEN_KEY, response.data[ACCESS_TOKEN_KEY]);
        })
    },

    // получение информации о токене
    getTokenInfo() {
        let payload = new FormData();
        // достаем из LocalStorage наш access token и помещаем его в параметр `token`
        payload.append('token', window.sessionStorage.getItem(ACCESS_TOKEN_KEY));

        return axios.post('/oauth2/token-info', payload, {
            headers: {
                'Authorization': authHeaderValue
            }
        });
    }
}

Как вы можете заметить, я вынес все необходимые константы в .env файл, а именно в .env.development.

.env.development

VUE_APP_OAUTH_REDIRECT_URI=http://127.0.0.1:8080/code
VUE_APP_OAUTH_CLIENT_ID=test-client
VUE_APP_OAUTH_AUTH_HEADER=Basic dGVzdC1jbGllbnQ6dGVzdC1jbGllbnQ=
VUE_APP_OAUTH_URL=http://localhost:7777

Стоит обратить внимание на параметр redirect_uri. Он обязательно должен совпадать с одноимённым параметром и в нашем бине registeredClientRepository().

Как вы могли заметить, домашнюю страницу и страницу логина мы создали, но в redirect_uri мы указываем путь /code, для которой у нас нет страницы. Да, все верно, мы не будем для этого сейчас делать страницу, нам достаточно достать код авторизации из запроса и сделать запрос на получение токенов. Поэтому, я просто сделаю эту обработку в beforeEach хуке нашего роутера.

router/index.js

import {createRouter, createWebHistory} from 'vue-router'
import Home from '../views/home.vue'
import Login from '../views/login.vue'
import LoginService from "@/services/login-service";

const routes = [
    {
        path: '/',
        name: 'home',
        component: Home
    },
    {
        path: '/login',
        name: 'login',
        component: Login
    }
]

const router = createRouter({
    history: createWebHistory(process.env.BASE_URL),
    routes
});


router.beforeEach((to, from, next) => {

    // если путь равен /code, то пытаемся достать параметр code из запроса, запросить токены, и после их получения
    // сделать переход на домашнюю страницу
    if (to.path === '/code' && to.query.code != null) {
        LoginService.getTokens(to.query.code).then(() => {
            next({name: 'home'});
        });
    } else {
        next()
    }
});

export default router

Итак, наш клиент готов. Запускаем и проверяем. При переходе на http://localhost:8080 мы сразу попадаем на страницу логина, так как у нас нет access token. Нажимаем на кнопку login, у нас открывается форма логина j-sso. Вводим данные, и нас перенаправляет на наш test-client, он получает код авторизации иии... у нас ошибка.

Access to XMLHttpRequest at 'http://localhost:7777/oauth2/token' from origin 'http://127.0.0.1:8080' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: Redirect is not allowed for a preflight request.

Нас не пускают на j-sso, так как он находится на другом домене, а мы пытаемся выполнить кросс-доменный запрос. Значит нам надо настроить CORS.

Cross-origin resource sharing — технология современных браузеров, которая позволяет предоставить веб-страницам доступ к ресурсам другого домена.

Для этого создадим на нашем j-sso отдельный класс CORSConfig и объявим в нем бин corsFilter.

CORSConfig.java


@Slf4j
@Configuration
public class CORSConfig {

    @Bean
    public FilterRegistrationBean<CorsFilter> corsFilter() {
        log.debug("CREATE CORS FILTER");
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();

        config.setAllowCredentials(true);

        // Указываем список адресов для которых разрешены кросс-доменные запросы
        config.addAllowedOrigin("http://127.0.0.1:8080,http://localhost:8080");
        config.addAllowedHeader(CorsConfiguration.ALL);
        config.addExposedHeader(CorsConfiguration.ALL);
        config.addAllowedMethod(CorsConfiguration.ALL);

        source.registerCorsConfiguration("/**", config);
        FilterRegistrationBean<CorsFilter> bean = new FilterRegistrationBean<>(new CorsFilter(source));
        bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
        return bean;
    }
}

Перезапустим наш j-sso и заново инициируем авторизацию на test-client. Теперь нас перебрасывает на страницу авторизации нашего j-sso. Вводим логин и пароль, авторизуемся. Если логин и пароль мы ввели правильные, то нас перенаправит на http://localhost:8080/code, и в параметрах запроса будет находиться наш authorization code. Далее, мы делаем сразу запрос на получение токенов, получаем access token, и после этого мы переходим на http://localhost:8080/, где отображается информация о нашем access token. Ниже показано, как будет выглядеть успешный результат авторизации:

Успешный результат авторизации
Успешный результат авторизации

Теперь, когда у нас есть полное demo приложение с клиентом и сервером, давайте окунемся во вселенную Spring и посмотрим на весь процесс изнутри.

Все действо начинается с того что клиент посылает GET запрос следующего вида на наш j-sso. В ответ мы получаем статус 302 и в заголовке Location видим, что нас перенаправляет на форму логина j-sso.

Запрос начала авторизации
Запрос начала авторизации

В этот момент, j-sso приняв данный запрос видит, что у него нет авторизованной сессии и нас перенаправляет на страницу логина. Но это не все, давайте посмотрим на наш Security Filter Chain и разберемся, что же там происходит.

Список фильтров Spring Security участвующих в запросе

Security filter chain: [
        DisableEncodeUrlFilter
        WebAsyncManagerIntegrationFilter
        SecurityContextHolderFilter
        HeaderWriterFilter
        CsrfFilter
        LogoutFilter
        OAuth2AuthorizationRequestRedirectFilter
        OAuth2LoginAuthenticationFilter
        UsernamePasswordAuthenticationFilter
        DefaultLoginPageGeneratingFilter
        DefaultLogoutPageGeneratingFilter
        RequestCacheAwareFilter
        SecurityContextHolderAwareRequestFilter
        AnonymousAuthenticationFilter
        ExceptionTranslationFilter
        AuthorizationFilter
]

Тут конечно очень много всяких фильтров, детально по каждому проходить сейчас мы не будем, нас интересуют 3 последние фильтра. Оказывается, там происходит очень важная Spring магия.

Ниже показан класс AnonymousAuthenticationFilter в моменте обработки запроса:

Класс AnonymousAuthenticationFilter
Класс AnonymousAuthenticationFilter
Класс AnonymousAuthenticationFilter

AnonymousAuthenticationFilter добавляет создание AnonymousAuthenticationToken через установку Supplier в securityContextHolderStrategy, как метод получения security контекста. Если мы внимательно посмотрим на метод defaultWithAnonymous, то увидим, что в нем происходит проверка на существование объекта аутентификации, и если он отсутствует, то создается AnonymousAuthenticationToken и устанавливается в security context, как текущий объект аутентификации.

Далее ExceptionTranslationFilter пока просто пропускает дальше запрос по цепочке фильтров, но стоит заметить, что это он делает в блоке try catch.

Класс ExceptionTranslationFilter
Класс ExceptionTranslationFilter
Класс ExceptionTranslationFilter

Далее в игру вступает AuthorizationFilter, который проверяет объект аутентификации.

Класс AuthorizationFilter
Класс AuthorizationFilter
Класс AuthorizationFilter

Для этого он берет текущий securityContextHolderStrategy и получает из него контекст, в этот момент начинает выполняться наш Supplier, который был установлен в AnonymousAuthenticationFilter. Соответственно, в качестве объекта аутентификации мы получаем AnonymousAuthenticationToken. Далее при помощи authorizationManager он проверяет данный объект аутентификации и выносит решение AuthorizationDecision, как мы видим, он конечно же не проходит проверку и генерируется AccessDeniedException.

И тут мы возвращаемся к нашему ExceptionTranslationFilter в тот самый try catch, на который я просил обратить внимание.

Класс ExceptionTranslationFilter (блок catch)
Класс ExceptionTranslationFilter
Класс ExceptionTranslationFilter

И вот тут происходит очень важный момент при обработке этого исключения. Он проходит до метода handleAccessDeniedException, в котором мы можем видеть, что если объект аутентификации является anonymous, то выполняется метод говорящий сам за себя sendStartAuthentication. То есть, если мы посмотрим в реализацию AuthenticationTrustResolverImpl, то увидим банальную проверку объекта аутентификации, что он является реализацией класса AnonymousAuthenticationToken.

Метод handleAccessDeniedException класса ExceptionTranslationFilter
Метод handleAccessDeniedException класса ExceptionTranslationFilter

Далее, в sendStartAuthentication мы видим следующую строчку this.requestCache.saveRequest(request, response);. Это значит, что пришедший к нам запрос сохранился в кэше. То есть простыми словами, Spring Security видит, что у него нет авторизованной сессии, приостанавливает выполнение текущего запроса, сохраняя его в кэш, и запускает процесс аутентификации. Выполняя метод commence(), он запускает выполнение AuthenticationEntryPoint по умолчанию, а это LoginUrlAuthenticationEntryPoint, в котором и прописан редирект на страницу логина.

Метод sendStartAuthentication класса ExceptionTranslationFilter
Метод sendStartAuthentication класса ExceptionTranslationFilter

Таким "незамысловатым" путем, у нас в браузере отображается страница логина. В добавок к этому у нас выставлена JSESSIONID кука, и сохранен изначальный запрос в request cache.

Теперь вводим логин и пароль, нажимаем кнопку Sign In. Посмотрим в консоль браузера и увидим, что там выполняется POST запрос на endpoint /login, а в ответ мы получаем ответ с кодом 302, в хедере Location мы видим тот самый наш первый запрос.

POST запрос аутентификации
POST запрос аутентификации

Чтобы понять, как из request cache наш запрос "перекочевал" в хедер Location, посмотрим на SavedRequestAwareAuthenticationSuccessHandler.

Класс SavedRequestAwareAuthenticationSuccessHandler
Класс SavedRequestAwareAuthenticationSuccessHandler
Класс SavedRequestAwareAuthenticationSuccessHandler

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

Получение кода авторизации
Получение кода авторизации
Получение кода авторизации

На этом создание простейшего тестового клиента завершим и приступим к дальнейшим настройкам нашего сервера авторизаций.

Исходники данного раздела смотрите здесь.

Раздел 3: Подключаем авторизацию через Google и Github

Теперь давайте подключим аутентификацию на нашем j-sso через Google и Github, так называемый "Social Login". Для реализации этой функции существует готовый spring boot starter - spring-boot-starter-oauth2-client. Давайте подключим его в наш проект и настроим.

Добавляем зависимость в наш pom.xml файл

j-sso/pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xmlns="http://maven.apache.org/POM/4.0.0"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

    //.......

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-client</artifactId>
        </dependency>
    </dependencies>

    //.......

</project>

Теперь следуя документации настроим вход через Github. Для этого нам необходимо всего лишь добавить пару параметров в наш application.yml.

application.yml

// ...........

spring:
    application:
        name: j-sso
    security:
        oauth2:
            authorizationserver:
                issuer-url: http://localhost:7777
                introspection-endpoint: /oauth2/token-info
            client:
                registration:
                    github:
                        clientId: <you_client_id>
                        clientSecret: <you_client_secret>

Выше мы добавили конфигурацию oauth2 клиента для Github и указали у него clientId и clientSecret. Данные параметры мы получили при регистрации нашего j-sso в Github.

Регистрация клиента в Github
Регистрация клиента в Github

Вот страница, на которой описано как зарегистрировать OAuth приложение в Github. Обратите внимание на параметр Authorization callback URL, его необходимо указать как на скриншоте ниже, этот URL Spring нам предоставляет по умолчанию. В документации про это сказано следующее:

The default redirect URI template is {baseUrl}/login/oauth2/code/{registrationId}. The registrationId is a unique
identifier for the ClientRegistration.

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

Соберём проект и проверим, как это работает. Ниже показано, как это работает у меня.

Пример авторизации через Github
Пример авторизации через Github

После настройки аутентификации через Github, добавим аналогичную конфигурацию для Google. Как зарегистрировать клиент OAuth в Google рассказывается в этой документации. После успешных настроек и регистрации клиента, у вас на форме логина в секции Login with OAuth 2.0 появится кнопка Google. А раздел spring вашего application.yml будет выглядеть так:

application.yml

// .......
spring:
    application:
        name: j-sso
    security:
        oauth2:
            authorizationserver:
                issuer-url: http://localhost:7777
                introspection-endpoint: /oauth2/token-info
            client:
                registration:
                    github:
                        clientId: <your_github_client_id>
                        clientSecret: <your_github_client_secret>

                    google:
                        clientId: <your_google_client_id>
                        clientSecret: <your_google_client_secret>

Итак, таким простым путём мы подключили аутентификацию на j-sso через Github и Google. Но, вообще не очевидно, что за объект авторизованного пользователя (Principal) находится у нас в контексте security. Давайте заглянем в Security Context и посмотрим, что там лежит в качестве реализации Authentication, и какой у него Principal. Для этого нам необходимо сделать какой-нибудь тестовый эндпоинт. Объект Authentication можно получить следующим образом - SecurityContextHolder.getContext().getAuthentication().

Значение Authorization после аутентификации черед Github
Значение Authorization после аутентификации черед Github

Как видно из скриншота выше, объектом Principal у нас является DefaultOAuth2User, который конструируется на основе информации, полученной от Github. Далее, давайте чуть по другому проведём тест. Теперь аутентифицируемся при помощи формы логина и посмотрим как изменится объект Authentication.

Значение Authorization после аутентификации черед форму
Значение Authorization после аутентификации черед форму

Смотрим на результат и видим уже другой объект принципала. Здесь мы получаем объект User, который мы сконструировали в бине users (ниже он продублирован).

Конечно, возможно для простейших тестовых проектов это и нормально, но меня это не утраивает. Тем более, в самом начале я ставил техническое требование хранения данных в PostgreSQL. Соответственно, хотелось бы как-то контролировать эту информацию. И конечно же, хочется иметь возможность создать пользователя при аутентификации через "Social Login" у нас в хранилище, если он отсутствует. Или загрузить пользователя, если он уже существует, из хранилища. Параметром для определения существования пользователя будет его email. Вспомним, что сейчас у нас пользователи хранятся в in memory хранилище, которое мы настроили в бине users.

SecurityConfig.java


@EnableWebSecurity
@RequiredArgsConstructor
@Configuration(proxyBeanMethods = false)
public class SecurityConfig {
    // .....

    @Bean
    public UserDetailsService users() {
        UserDetails user = User.builder()
                .username("admin")
                .password("{noop}password")
                .roles("USER")
                .build();
        return new InMemoryUserDetailsManager(user);
    }

    // .....
}


Соответственно, нам надо как-то масштабировать эту реализацию на нашу новую аутентификацию через Google и Github. Я сразу скажу, что я не буду сейчас оставлять и пытаться переиспользовать эту реализацию, несмотря на то, что InMemoryUserDetailsManager достаточно удобная реализация хранилища для таких тестовых проектов, как наш. Мы сразу заложим на этом этапе основу для дальнейшего хранения пользователей в СУБД PostgreSQL. Поэтому, нам надо реализовать следующее:

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

  • AuthorizedUser - класс, который будет наследовать класс User предоставляемый Spring Security, в качестве объекта авторизованного пользователя. То есть мы просто расширим стандартную реализацию User.

  • UserRepository - класс, который будет отвечать за управление данными пользователей, а также на текущий момент будет являться in memory хранилищем

  • CustomUserDetailsService - реализация интерфейса UserDetailsService, которую Spring Security будет использовать для получения объекта авторизованного пользователя, по его username. Параметр username - у нас будет email.

Итак, приступим. Реализуем UserEntity:

UserEntity.java


@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserEntity {

    private UUID id;
    private String email;
    private String passwordHash;
    private String firstName;
    private String secondName;
    private String middleName;
    private LocalDate birthday;
    private String avatarUrl;
    private Boolean active;
}

В этом классе всё очевидно, не будем на нём останавливаться. Далее сразу реализуем UserRepository. В качестве хранилища будем использовать простой Map<UUID, UserEntity>. Также реализуем три метода:

  • UserEntity save(UserEntity entity) - сохранение пользователя

  • UserEntity findById(UUID id) - получить пользователя по ID

  • UserEntity findByEmail(String email) - получить пользователя по email

Обратите внимание, что мы добавили создание пользователя c email admin@example.com и аналогичным паролем в хук afterPropertiesSet(). Это сделано только лишь в тестовых целях, так делать в реальных проектах категорически воспрещается

Источник: https://habr.com/ru/articles/737548/


Интересные статьи

Интересные статьи

Kubernetes состоит из нескольких компонентов, где значительная часть взаимодействий итогового пользователя с системой осуществляется при помощи API-сервера. Он представляет собой отправную точку для м...
Всем привет! Меня зовут Артем, я старший разработчик в ИНТЕРВОЛГЕ. Наконец дошли руки рассказать про «обмен с 1С с нуля». Типовой интернет-магазин состоит из двух частей: сайт и учетная система. ...
Автор статьи, перевод которой мы публикуем сегодня, хочет рассказать о нескольких JavaScript-паттернах, направленных на отложенную инициализацию свойств объектов, для выполнения которой т...
Обычно посты об оптимизации запросов рассказывают о том, как делать правильные вещи, чтобы помочь оптимизатору запросов выбрать оптимальный план выполнения: использовать SARGable-выра...
Привет хабр! На днях мне в очередной раз на глаза попал код вида if(someParameter.Volatilities.IsEmpty()) { // We have to report about the broken channels, however we could not differ it f...