Строим свой 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



    4.0.0

    ru.dlabs
    spring-authorization-server-example
    pom
    0.0.1
    spring-authorization-server-example

    
        17
        17
        17
        UTF-8

        1.0.2
    

    
        org.springframework.boot
        spring-boot-starter-parent
        3.0.5
        
    

    
        j-sso
    

    
        
            
                org.springframework.security
                spring-security-oauth2-authorization-server
                
                ${security-oauth2-server.version}
            
        
    

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

Не забудем подключить сам 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



    
        spring-authorization-server-example
        ru.dlabs
        0.0.1
    
    4.0.0

    j-sso

    
        17
        17
    

    
        
            org.springframework.boot
            spring-boot-starter
            
                
                    org.springframework.boot
                    spring-boot-starter-logging
                
            
        
        
            org.springframework.boot
            spring-boot-starter-log4j2
        
        
            org.springframework.boot
            spring-boot-starter-web
        
        
            org.springframework.boot
            spring-boot-starter-security
        
        
            org.springframework.security
            spring-security-oauth2-authorization-server
        

        
            org.projectlombok
            lombok
            ${lombok.version}
            provided
        
    

    
        ${project.artifactId}
        
            
                org.springframework.boot
                spring-boot-maven-plugin
                
                    ${project.name}
                
            
        
    

Все необходимые инструменты мы подключили к нашему проекту, теперь приступим к реализации 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 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 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







home.vue







Также создадим файл 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() {
        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 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

© Habrahabr.ru