Вступление

На днях я решил сделать под все свои 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(). Это сделано только лишь в тестовых целях, так делать в реальных проектах категорически воспрещается ????

UserRepository.java


@Repository
public class UserRepository implements InitializingBean {

    private final Map<UUID, UserEntity> store = new HashMap<>();

    public UserEntity save(UserEntity entity) {
        if (entity.getId() == null) {
            entity.setId(UUID.randomUUID());
        }
        this.store.put(entity.getId(), entity);
        return entity;
    }

    public UserEntity findById(UUID id) {
        return this.store.getOrDefault(id, null);
    }

    public UserEntity findByEmail(String email) {
        return this.store.values().stream()
                .filter(item -> item.getEmail().equals(email))
                .findFirst()
                .orElse(null);
    }


    @Override
    public void afterPropertiesSet() throws Exception {
        this.save(UserEntity.builder()
                .email("admin@example.com")
                .passwordHash("{noop}admin@example.com")
                .active(true)
                .firstName("Admin")
                .secondName("Admin")
                .birthday(LocalDate.of(1998, 7, 14))
                .build());
    }
}

Теперь реализуем нашего AuthorizedUser, который является расширением стандартного User предоставляемого нам Spring Security. Он представляет собой достаточно простой класс, в него мы добавляем данные из UserEntity, а также поддерживаем необходимые атрибуты из User. Для удобства использования реализуем в нём builder. В статье не буду приводить весь скучный код AuthorizedUserBuilder, поэтому весь код этого класса вы можете посмотреть в Github репозитории.

AuthorizedUser.java


@Getter
@Setter
public class AuthorizedUser extends User {

    private UUID id;
    private String firstName;
    private String secondName;
    private String middleName;
    private LocalDate birthday;
    private String avatarUrl;

    public AuthorizedUser(String username, String password, Collection<? extends GrantedAuthority> authorities) {
        super(username, password, authorities);
    }

    public AuthorizedUser(
            String username,
            String password,
            boolean enabled,
            boolean accountNonExpired,
            boolean credentialsNonExpired,
            boolean accountNonLocked,
            Collection<? extends GrantedAuthority> authorities
    ) {
        super(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
    }

    public static AuthorizedUserBuilder builder(String username, String password, Collection<? extends GrantedAuthority> authorities) {
        return new AuthorizedUserBuilder(username, password, authorities);
    }

    public static AuthorizedUserBuilder builder(
            String username,
            String password,
            boolean enabled,
            boolean accountNonExpired,
            boolean credentialsNonExpired,
            boolean accountNonLocked,
            Collection<? extends GrantedAuthority> authorities
    ) {
        return new AuthorizedUserBuilder(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities);
    }

    public String getEmail() {
        return this.getUsername();
    }

    // .......
}

После этого, нам остаётся всего лишь реализовать кастомный UserDetailsServiceи убрать старый бин users. CustomUserDetailsService очень простой, в нём только один метод, это загрузка пользователя по его username, коим у нас является email. Для этой загрузки мы используем UserRepository. Подключать данный сервайс отдельно в конфигурацию Spring Security нет необходимости. Достаточно, чтобы он реализовывал интерфейс UserDetailsService и являлся бином.

CustomUserDetailsService.java


@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserEntity entity = userRepository.findByEmail(username);
        if (entity == null) {
            throw new UsernameNotFoundException("User with username = " + username + " not found");
        }
        return AuthorizedUserMapper.map(entity, null);
    }
}

Итак, теперь мы полностью контролируем данные пользователей у нас в сервисе в случае аутентификации через форму логина. Соответственно, объектом Principal в контексте Spring Security у нас будет теперь AuthorizedUser.

Теперь давайте настроим аутентификацию через social login. Для этого нам необходимо в конфигурации Spring Security настроить OAuth2LoginConfigurer, который доступен через метод DSL oauth2Login(...). Чтобы не громоздить всё в один метод конфигурации, давайте создадим свой SocialConfigurer класс и подключим его. Конечно, сейчас в SecurityConfig не так много кода конфигурации, но в дальнейшем этот конфиг будет разрастаться, поэтому смотря в будущее, мы сразу заложим удобную для масштабирования основу.

Приступим к созданию SocialConfigurer. Для этого нам достаточно создать класс и унаследовать его от AbstractHttpConfigurer. После чего переопределить в нём метод void init(HttpSecurity http). В этом методе можно произвести всю необходимую конфигурацию security. Давайте, здесь добавим сразу возможность указания AuthenticationSuccessHandler и AuthenticationFailureHandler. Код нашего SocialConfigurer показан ниже:

SocialConfigurer.java


@Setter
@Accessors(chain = true, fluent = true)
public class SocialConfigurer extends AbstractHttpConfigurer<SocialConfigurer, HttpSecurity> {

    private AuthenticationFailureHandler failureHandler;
    private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();

    @Override
    public void init(HttpSecurity http) throws Exception {
        http.oauth2Login(oauth2Login -> {
            if (this.successHandler != null) {
                oauth2Login.successHandler(this.successHandler);
            }
            if (this.failureHandler != null) {
                oauth2Login.failureHandler(this.failureHandler);
            }
        });
    }
}

Чтобы этот configurer добавить в конфигурацию SecurityConfig, нам необходимо создать экземпляр данного configurer и добавить его через метод http.apply(...).

SecurityConfig.java


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

    @Bean
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        SocialConfigurer socialConfigurer = new SocialConfigurer();
        http.apply(socialConfigurer);

        // .....
    }
}

Теперь, обратимся к документации Spring OAuth2 и посмотрим, что в ней говорится про поддержку СУБД для пользователей. И видим, что там говорится следующее:

Implement and expose OAuth2UserService to call the Authorization Server as well as your database.
Your implementation can delegate to the default implementation, which will do the heavy lifting of calling the Authorization Server.
Your implementation should return something that extends your custom User object and implements OAuth2User.

Это означает, что нам достаточно создать собственную реализацию интерфейса OAuth2UserService, а наш Principal объект должен быть унаследован от User, и также должен реализовывать интерфейс OAuth2User. Чуть выше мы уже реализовали AuthorizedUser, который унаследован от User. Соответственно, мы просто в этом же классе реализуем интерфейс OAuth2User. И код нашего AuthorizedUser будет выглядеть следующим образом:

AuthorizedUser.java


@Getter
@Setter
public class AuthorizedUser extends User implements OAuth2User {

    private UUID id;
    private String firstName;
    private String secondName;
    private String middleName;
    private LocalDate birthday;
    private String avatarUrl;

    private Map<String, Object> oauthAttributes;

    // .....

    @Override
    public Map<String, Object> getAttributes() {
        return oauthAttributes;
    }

    @Override
    public String getName() {
        return this.getUsername();
    }

    // ........


}

Нам осталось сделать реализацию OAuth2UserService. Мы не будем реализовывать полностью весь интерфейс, а возьмём реализацию по умолчанию - DefaultOAuth2UserService и переопределим метод OAuth2User loadUser(OAuth2UserRequest userRequest):

CustomOAuth2UserService.java


@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final UserService userService;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);                            // Загружаем пользователя, как это было до
        String clientRegId = userRequest.getClientRegistration().getRegistrationId();   // Получаем наименование провайдера (google, github и т.д.)
        AuthProvider provider = AuthProvider.fingByName(clientRegId);                   // Для удобства создадим enum AuthProvider и по наименованию провайдера получим значение
        return userService.saveAndMap(oAuth2User, provider);                            // Создадим дополнительный сервис UserService, в котором опишем сохранение пользователя при его отсутствии в БД, а также маппинг на AuthorizedUser
    }
}

Как вы могли заметить из кода выше, мы ещё добавляем следующие компоненты:

  1. enum AuthProvider - это простейший enum со списком поддерживаемых провайдеров (google, github и т.д.)

  2. UserService - это сервис, который создаёт или обновляет данные по пользователю. Эти данные он получает из объекта OAuth2User. Далее находит в БД пользователя по email. И после чего мапит нашего UserEntity на AuthorizedUser.

Полный код DefaultUserService, AuthProvider и сопутствующих компонентов смотрите в моём репозитории

DefaultUserService.java

@Service
@RequiredArgsConstructor
public class DefaultUserService implements UserService {

    private final UserRepository userRepository;

    /**
     * Создание или обновление пользователя
     */
    @Override
    public UserEntity save(OAuth2User userDto, AuthProvider provider) {
        return switch (provider) {
            case GITHUB -> this.saveUserFromGithab(userDto);
            case GOOGLE -> this.saveUserFromGoogle(userDto);
        };
    }

    /**
     * Создание или обновление пользователя с последующим маппингом в сущность AuthorizedUser
     */
    @Override
    public AuthorizedUser saveAndMap(OAuth2User userDto, AuthProvider provider) {
        UserEntity entity = this.save(userDto, provider);
        return AuthorizedUserMapper.map(entity, provider);
    }


    /**
     * Метод описывающий создание/обновление UserEntity на основе OAuth2User, полученного из провайдера Github
     */
    private UserEntity saveUserFromGithab(OAuth2User userDto) {
        String email = userDto.getAttribute("email");           // пытаемся получить атрибут email
        if (email == null) {                                          // если данного атрибута нет или он пустой, то генерируем исключение с указанием того, что нет email
            throw new AuthException(AuthErrorCode.EMAIL_IS_EMPTY);
        }
        UserEntity user = this.userRepository.findByEmail(email);     // пытаемся найти пользователя в нашем хранилище по email
        if (user == null) {                                           // если пользователя не существует у нас, то создаём новую сущность UserEntity
            user = new UserEntity();
            user.setEmail(email);
            user.setActive(true);                                     // пока пусть все созданные пользователи будут активными
        }

        if (userDto.getAttribute("name") != null) {             // получаем firstName, lastName и middleName
            String[] splitted = ((String) userDto.getAttribute("name")).split(" ");
            user.setFirstName(splitted[0]);
            if (splitted.length > 1) {
                user.setSecondName(splitted[1]);
            }
            if (splitted.length > 2) {
                user.setMiddleName(splitted[2]);
            }
        } else {                                                      // иначе устанавливаем в эти поля значение email
            user.setFirstName(userDto.getAttribute("login"));   // конечно в реальных проектах так делать не надо, здесь это сделано для упрощения логики
            user.setSecondName(userDto.getAttribute("login"));
        }

        if (userDto.getAttribute("avatar_url") != null) {       // если есть аватар, то устанавливаем значение в поле avatarUrl
            user.setAvatarUrl(userDto.getAttribute("avatar_url"));
        }
        return userRepository.save(user);                             // сохраняем сущность UserEntity
    }

    /**
     * Метод, описывающий создание/обновление UserEntity на основе OAuth2User, полученного из провайдера Google
     */
    private UserEntity saveUserFromGoogle(OAuth2User userDto) {
        // .....
    }
}

Итак, теперь у нас есть полный набор компонентов, и мы готовы запустить и проверить, как это всё работает. Давайте запустим наши j-sso и test-client. Пройдём аутентификацию через Github на j-sso. Выполним запрос на наш тестовый эндпоинт и посмотрим, что теперь у нас в качестве объекта Principal.

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

Как мы видим, всё, что мы сделали выше, сработало и в качестве объекта Principal у нас AuthorizedUser. Конечно хотелось бы посмотреть на данные, которые находятся в хранилище. Давайте в этом контроллере внедрим UserRepository и заглянем к нему внутрь.

Значения в хранилище
Значения в хранилище

Как мы можем видеть, там находятся два пользователя:

  • первый, это пользователь, который создался при аутентификации через Github

  • второй, это admin - добавляется по умолчанию в хуке afterPropertiesSet() класса DefaultUserRepository

Давайте подведём итог этого раздела. Мы добавили "Social Login" через Github и Google. Мы смогли унифицировать объект Principal в Security Context. Единственное, что мне не нравится в этой истории, так это то, что нет явной связи между конфигурацией Spring Security и кастомной реализацией User Services (CustomOAuth2UserService, CustomUserDetailsService). В больших проектах, это ,как правило, является критичным моментом, так как конфигурации в них очень много, она сложная,а излишние неявные связи, такие как эти, привносят только большую сложность модуля.

Давайте явно укажем в конфигурации Spring Security использование этих services. Для этого, в наш SocialConfigurer добавим новое поле private OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService;. Далее, этот сервайс надо указать в UserInfoEndpointConfig следующим образом oauth2Login.userInfoEndpoint().userService(this.oAuth2UserService);. Код, обновлённого SocialConfigurer показан ниже:

SocialConfigurer.java


@Setter
@Accessors(chain = true, fluent = true)
public class SocialConfigurer extends AbstractHttpConfigurer<SocialConfigurer, HttpSecurity> {

    private OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService;
    private AuthenticationFailureHandler failureHandler;
    private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();

    @Override
    public void init(HttpSecurity http) throws Exception {
        http.oauth2Login(oauth2Login -> {
            if (this.oAuth2UserService != null) {
                oauth2Login.userInfoEndpoint().userService(this.oAuth2UserService);
            }
            if (this.successHandler != null) {
                oauth2Login.successHandler(this.successHandler);
            }
            if (this.failureHandler != null) {
                oauth2Login.failureHandler(this.failureHandler);
            }
        });
    }
}

Перейдём к нашему SecurityConfig, сразу внедрим наши CustomOAuth2UserService и CustomUserDetailsService. С первым всё очевидно, мы просто добавляем его в socialConfigurer, а его мы уже настроили. Как же теперь добавить CustomUserDetailsService? Ведь его надо как-то указать в AuthenticationProvider. Создавая заново бин AuthenticationProvider и AuthenticationManager, мы привносим много излишней конфигурации, хочется добавить наш CustomUserDetailsService в дефолтный flow конфигурации Spring Security. Это можно сделать следующим образом:

SecurityConfig.java


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

    private final CustomOAuth2UserService customOAuth2UserService;
    private final CustomUserDetailsService userDetailService;

    @Bean
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        SocialConfigurer socialConfigurer = new SocialConfigurer()
                .oAuth2UserService(customOAuth2UserService);
        http.apply(socialConfigurer);

        http.getSharedObject(AuthenticationManagerBuilder.class)
                .userDetailsService(userDetailService);

        http.authorizeHttpRequests(authorize ->
                authorize.anyRequest().authenticated()
        );
        return http.formLogin(withDefaults()).build();
    }
}

Мы можем получить объект AuthenticationManagerBuilder при помощи http.getSharedObject(AuthenticationManagerBuilder.class) и в него добавить наш CustomUserDetailsService.

Запустив j-sso и test-client, мы пройдём аутентификацию и увидим тот же результат, что и на гифке выше. На этом этот раздел мы завершаем.

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

Раздел 4: Подключаем авторизацию через Yandex и дальнейшая кастомизация

В предыдущей главе мы подключили "Social Login" через Github и Google. Выглядело это довольно просто, мы просто указали соответствующие clientId и clientSecret. Но как Spring понял, куда отправлять запросы на авторизацию, и откуда получать информацию о пользователе? Предположим, что магия существует, и добавим аналогичную конфигурацию для аутентификации через Yandex. В этой документации рассказывается, как зарегистрировать OAuth клиент в Yandex (на мой взгляд у них самый удобный и красивый конструктор регистрации клиента, за это респект Yandex-у). После регистрации клиента, у нас есть clientId и clientSecret. Добавим это всё в 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>

                    yandex:
                        clientId: <your_yandex_client_id>
                        clientSecret: <your_yandex_client_secret>

Запустим наш j-sso и .... Мы получаем ошибку при запуске следующего вида:

Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate [org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository]:
Factory method 'clientRegistrationRepository' threw exception with message:
Provider ID must be specified for client registration 'yandex'

Посмотрим на неё внимательно и видим доказательство того, что магия не существует, по крайней мере не в Spring Security OAuth 2.0 Client. Данная ошибка нам говорит, что для клиента с идентификатором 'yandex' необходим специальный провайдер. Как же так получается? Для Google и Github ничего не надо было, а для Yandex надо? Давайте разбираться. Для этого заглянем в исходники конфигурации клиентов. В OAuth2ClientProperties находим класс Registration, именно он предназначен для описания полной конфигурации клиентов и является отображением параметров из application.yml.

Класс Registration
Класс Registration
Класс Registration

В самом верху красуется параметр provider, а над ним есть документация, в которой и говорится, что данная библиотека по умолчанию предоставляет преднастроенные провайдеры для следующих сервисов:

  • google

  • github

  • facebook

  • okta

Яндекса в этом списке нет, поэтому для него необходимо самостоятельно настроить провайдер. Делается это достаточно просто, на уровне с параметром registration, также есть параметр provider, его то нам и надо настроить. Все параметры достаточно очевидны, исходя из их названия, поэтому детально их разбирать не будем, сразу покажу как теперь у нас будет выглядеть application.yml

application.yml

  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>

                  yandex:
                      provider: yandex
                      clientId: <your_yandex_client_id>
                      clientSecret: <your_yandex_client_secret>
                      redirect-uri: http://localhost:7777/login/oauth2/code/yandex
                      authorizationGrantType: authorization_code
                      clientName: Yandex

              provider:
                  yandex:
                      authorization-uri: https://oauth.yandex.ru/authorize
                      token-uri: https://oauth.yandex.ru/token
                      user-name-attribute: default_email
                      userInfoUri: https://login.yandex.ru/info

Помимо самого провайдера для Yandex, мы также добавили параметры в настройки клиента. Самый главный из них, это конечно же provider, через него осуществляется связь между зарегистрированным клиентом и его провайдером.

Запустим наш j-sso, перейдём на страницу логина и попробуем пройти аутентификацию через Yandex. Если мы всё правильно настроили, то у нас откроется форма логина Yandex, а после нас обратно перенаправит в наш j-sso, и мы получим очередную ошибку. Почему? Да всё потому, что мы забыли добавить значение в enum AuthProvider, а также не сделали обработку информации для провайдера Yandex в сервисе DefaultUserService. Я не буду здесь показывать код, вы можете посмотреть его в Github репозитории. После реализации этих мелочей, заново запустим наш сервис, также запустим test-client и проверим, как работает аутентификация через Yandex.

Демонстрация входа через Yandex
Демонстрация входа через Yandex

Как мы видим, всё сработало успешно. На этом настройка аутентификации через Yandex закончена.

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

Сразу назревает запрос, а что это на эндпоинт должен быть и где? По хорошему, у нас должен быть отдельный Resource Server для работы с аккаунтом пользователя, из которого по access token мы получили бы данные по пользователю. Этот вариант самый правильный, но требование у нас стоит другое. Давайте попробуем не меняя требования, реализовать это на SSO. Предположим, мы создадим специальный эндпоинт в нашем j-sso, например /user/current. Если мы на него будем делать запрос только лишь с access_token, то мы будем получать ошибку 401 или перенаправление на форму логина из-за отсутствия авторизованной сессии. Это происходит потому, что наш j-sso перекрыт настроенной конфигурацией безопасности в SecurityConfig, которая в свою очередь работает на JSESSIONID куке. Поэтому, чтобы эндпоинты на j-sso работали, нам необходимо её иметь. Но это никак не ложится в концепцию OAuth2. Что же тогда делать? Сразу на ум приходит реализовать в j-sso Resource Server с отдельной конфигурацией security. Но это уже будет третья конфигурация в нашем SSO сервисе, как-то громоздко смотрится. Далее на ум приходит кастомизировать introspection endpoint, и путь он возвращает:

  1. флаг active - говорит, что токен активен или нет. Если true, то отображаются следующие параметры

  2. объект Principal - объект авторизованного пользователя, который мы и хотим получить

  3. список authorities - список привилегий доступных пользователю

И на этом варианте я бы и остановился. У нас есть access_token. Нам в любом случае надо его проверить. Для этого существует introspection endpoint. На данный момент он возвращает следующую информацию если токен активен:

{
    "active": true,
    "sub": "admin@example.com",
    "aud": [
        "test-client"
    ],
    "nbf": 1684683990,
    "scope": "write.scope read.scope",
    "iss": "http://localhost:7777",
    "exp": 1684685790,
    "iat": 1684683990,
    "jti": "4e9759cc-b80a-4cd2-840c-e5f666f1c499",
    "client_id": "test-client",
    "token_type": "Bearer"
}

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

TokenInfoDto.java


@Getter
@Setter
@Builder
public class TokenInfoDto {

    private Boolean active;
    private String sub;
    private List<String> aud;
    private Instant nbf;
    private List<String> scopes;
    private URL iss;
    private Instant exp;
    private Instant iat;
    private String jti;
    private String clientId;
    private String tokenType;

    private Object principal;
    private Collection<? extends GrantedAuthority> authorities;

}

А теперь кастомизируем обработку introspection endpoint. Для этого в конфигурации Authorization Server OAuth2AuthorizationServerConfigurer настроим DSL метод tokenIntrospectionEndpoint(...), в котором добавим собственный introspectionResponseHandler. Конечно, чтобы это всё настроить, необходимо изменить дефолтную конфигурацию OAuth2AuthorizationServerConfigurer. Ниже показан изменённый Security Filter Chain в классе AuthorizationServerConfig:

AuthorizationServerConfig.java


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

    // .....

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer();
        authorizationServerConfigurer.tokenIntrospectionEndpoint((config) -> {
            config.introspectionResponseHandler(this::introspectionResponse);
        });

        RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();
        http.securityMatcher(endpointsMatcher)
                .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated())
                .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
                .exceptionHandling(exceptions -> exceptions.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")))
                .apply(authorizationServerConfigurer);
        return http.build();
    }

    // .....

}

Теперь посмотрите внимательно на метод-обработчик introspectionResponse, который показан ниже:

AuthorizationServerConfig.java


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

    private final static String principalAttributeKey = "java.security.Principal";

    private final MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter;
    private final AuthorizationServerProperties authorizationServerProperties;
    private final OAuth2AuthorizationService oAuth2AuthorizationService;

    // .....

    private void introspectionResponse(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
        OAuth2TokenIntrospectionAuthenticationToken introspectionAuthenticationToken = (OAuth2TokenIntrospectionAuthenticationToken) authentication;
        TokenInfoDto.TokenInfoDtoBuilder tokenInfoDtoBuilder = TokenInfoDto.builder().active(false);                        // создаём билдер объекта ответа
        if (introspectionAuthenticationToken.getTokenClaims().isActive()) {                                                 // если токен активен, то заполняем все параметры информации о токене и далее пытаемся получить информацию о пользователе
            OAuth2TokenIntrospection claims = introspectionAuthenticationToken.getTokenClaims();
            tokenInfoDtoBuilder.active(true)
                    .sub(claims.getSubject())
                    .aud(claims.getAudience())
                    .nbf(claims.getNotBefore())
                    .scopes(claims.getScopes())
                    .iss(claims.getIssuer())
                    .exp(claims.getExpiresAt())
                    .iat(claims.getIssuedAt())
                    .jti(claims.getId())
                    .clientId(claims.getClientId())
                    .tokenType(claims.getTokenType());


            String token = introspectionAuthenticationToken.getToken();                                                     // получаем значение токена, который проверяется
            OAuth2Authorization tokenAuth = oAuth2AuthorizationService.findByToken(token, OAuth2TokenType.ACCESS_TOKEN);    // предполагая что это ACCESS TOKEN, пытаемся получить объект OAuth2Authorization из OAuth2AuthorizationService
            if (tokenAuth != null) {
                Authentication attributeAuth = tokenAuth.getAttribute(principalAttributeKey);                               // Если найден этот объект OAuth2Authorization, то получаем из него объект Authentication следующим образом
                if (attributeAuth != null) {
                    tokenInfoDtoBuilder                                                                                     // Если полученный объект Authentication не пуст, то заполняем данные в TokenInfoDto
                            .principal(attributeAuth.getPrincipal())
                            .authorities(authentication.getAuthorities());
                }
            }
        }

        ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);
        mappingJackson2HttpMessageConverter.write(tokenInfoDtoBuilder.build(), null, httpResponse);                         // Предращаем наш TokenInfoDto в json строку и отправляем её через ServletServerHttpResponse
    }

    // .....
}

Конечно, нас Spring попросит объявить бин OAuth2AuthorizationService. Для этого создадим класс ConfigUtilities и в нём объявим его. Возмем в качестве реализации InMemoryOAuth2AuthorizationService.

ConfigUtilities.java


@Configuration(proxyBeanMethods = false)
public class ConfigUtilities {

    @Bean
    public OAuth2AuthorizationService oAuth2AuthorizationService() {
        return new InMemoryOAuth2AuthorizationService();
    }

}

Запустим и проверим. После прохождения успешной аутентификации, наш test-client покажет следующую информацию об access token:

Информация о токене и пользователе
Информация о токене и пользователе

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

Резюме

Итак, в этой статье мы разобрались, как можно построить простенький SSO сервис используя Spring Security и Spring Authorization Server. Создали простенький клиент и протестировали вход при помощи authorization code flow. Начали кастомизировать наш SSO-сервис, подключили "Social login" через Google, Github и Yandex. Увидели как можно настраивать собственные провайдеры. Выполнили следующие требования, которые ставили в самом начале этой статьи:

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

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

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

  3. Java 17

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

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

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

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

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

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

В следующих статьях мы разберём, как вкрутить SPA VueJS приложение в качестве формы аутентификации на SSO. Разработаем процесс регистрации пользователей. Подключим PostgreSQL и Redis. Посмотрим, как можно реализовать менеджер токенов доступа, настроим springdoc (Swagger) и многое другое.

Полезные ссылки

  1. Исходники смотрите здесь

  2. Спецификация The OAuth 2.1 Authorization Framework

  3. Документация Spring Authorization Server

  4. Руководство по созданию сервера OAuth2.0 OAuth 2.0 Simplified

  5. Гайд по VueJS

  6. Гайд по Vue Cli

  7. Гайд по Spring Security OAuth 2.0 Client

  8. Регистрация OAuth2 приложения Github

  9. Регистрация OAuth2 приложения Google

  10. Регистрация OAuth2 приложения Yandex

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


  1. Filex
    26.05.2023 06:29
    +1

    А почему предпочитаете использовать log4j2 вместо стандартного logback ?


    1. dlabs71 Автор
      26.05.2023 06:29

      Apache Log4j2 - самый молодой из Logback, Log4j и Log4j2. Его цель - улучшить более старые реализации (Logback, Log4j), используя преимущества как одного так и второго и одновременно избегая и их проблем. Он также является самым быстрым и продвинутым из них. Однако не забывайте использовать последнюю версию этой библиотеки. Logback по-прежнему является хорошим вариантом, если производительность не является вашим главным приоритетом.