Вступление
На днях я решил сделать под все свои 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 я поставил себе следующие требования:
Технические требования:
Использование непрозрачных токенов
Использование последних версий Spring Boot и Spring Authorization Server
Java 17
Использование SPA Vue.JS приложения в качестве фронта SSO
Использование Redis в качестве кэш хранилища (хранение токенов и т.д.)
Использование PostgreSQL в качестве основного хранилища
Подключить Swagger и настроить там авторизацию
Функциональные требования:
Аутентификация пользователей на SSO через форму логина/пароля
Аутентификация пользователей на SSO через Google, Github и Yandex
Авторизация по протоколу OAuth2.1 для моих pet-проектов
Получение информации о пользователе по токену доступа из SSO
Регистрация пользователей через Google, Github и Yandex
Регистрация пользователей через отдельную форму регистрации на SSO
Возможность управления выданными токенами (отзыв токена, просмотр активных сессий и т.д.)
Раздел 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'
Вот так он будет выглядеть, если вы его выполните в браузере
Далее нас перенаправит на страницу логина, в которой мы введем логин/пароль и нажмём Sign In
.
После этого выполнится POST запрос на эндпоинт /login
, и нас опять перенаправит на первый запрос. Как работает эта магия, будет описано во втором разделе этой статьи.
Повторное выполнение запроса authorization, и в заголовке ответа Location
можно увидеть код авторизации.
Как можно увидеть на скриншоте, последний запрос нас отправляет на страницу клиента с кодом авторизации. Берём этот код и выполняем запрос на получение токенов с параметром 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
На этом простейшая конфигурация нашего SSO сервиса закончена, переходим к кастомизациям.
Исходники данного раздела смотрите здесь.
Раздел 2: Переходим на Opaque token и тестируем с реальным клиентом
Теперь у нас есть простейший, но рабочий вариант Authorization Server, отталкиваясь от него, мы будем превращать его в тот Authorization Server, который нам нужен. Вспомним какие технические требования мы ставили. Первым пунктом шло использование непрозрачных (opaque) токенов вместо JWT.
Основное отличие Opaque token от JWT заключается в том, что незашифрованный JWT может быть интерпретирован кем угодно, а Opaque token нет. Кроме того, предполагается, что JWT не имеет состояния и является автономным. Он содержит всю информацию необходимую серверу, кроме ключей подписи, поэтому серверу не нужно хранить эту информацию на стороне сервера. Это означает, что пользователи могут получить токен с вашего сервера авторизации и использовать его на другом без необходимости обращения этих серверов к центральной службе.
Итак, минутка теории окончена, давайте реализуем это. Обратимся к документации и посмотрим, что она нам говорит сделать, чтобы наш сервер авторизации стал выдавать непрозрачные токены доступа. В документации сказано, что существует enum OAuth2TokenFormat
, в котором находится два формата
OAuth2TokenFormat.SELF_CONTAINED
- JWT формат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);
// }
//...............
}
Давайте детальнее разберем какие параметры у нас имеются:
accessTokenFormat()
- указываем формат access_token (JWT или Opaque)accessTokenTimeToLive()
- указываем время жизни нашего access_tokenrefreshTokenTimeToLive()
- указываем время жизни refresh_tokenreuseRefreshTokens()
- указываем, разрешено ли переиспользовать refresh_token повторно, если его срок действия еще не истекauthorizationCodeTimeToLive()
- указываем время жизни authorization code который используется при Authorization Code FlowidTokenSignatureAlgorithm()
- алгоритм подписи для генерации идентификационного токена в 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"
]
}
Удалим автоматически сгенерированные страницы и компоненты. Создадим следующие простейшие страницы:
login.vue
- страница логина. Добавим на нее только одну кнопку Login, которая будет запускать процесс авторизации через j-ssohome.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
добавляет создание AnonymousAuthenticationToken
через установку Supplier в securityContextHolderStrategy
, как метод получения security контекста. Если мы внимательно посмотрим на метод defaultWithAnonymous
, то увидим, что в нем происходит проверка на существование объекта аутентификации, и если он отсутствует, то создается AnonymousAuthenticationToken
и устанавливается в security context, как текущий объект аутентификации.
Далее ExceptionTranslationFilter
пока просто пропускает дальше запрос по цепочке фильтров, но стоит заметить, что это он делает в блоке try catch.
Класс ExceptionTranslationFilter
Далее в игру вступает AuthorizationFilter
, который проверяет объект аутентификации.
Класс AuthorizationFilter
Для этого он берет текущий securityContextHolderStrategy
и получает из него контекст, в этот момент начинает выполняться наш Supplier, который был установлен в AnonymousAuthenticationFilter
. Соответственно, в качестве объекта аутентификации мы получаем AnonymousAuthenticationToken
. Далее при помощи authorizationManager он проверяет данный объект аутентификации и выносит решение AuthorizationDecision
, как мы видим, он конечно же не проходит проверку и генерируется AccessDeniedException
.
И тут мы возвращаемся к нашему ExceptionTranslationFilter
в тот самый try catch, на который я просил обратить внимание.
Класс ExceptionTranslationFilter (блок catch)
И вот тут происходит очень важный момент при обработке этого исключения. Он проходит до метода handleAccessDeniedException
, в котором мы можем видеть, что если объект аутентификации является anonymous, то выполняется метод говорящий сам за себя sendStartAuthentication
. То есть, если мы посмотрим в реализацию AuthenticationTrustResolverImpl
, то увидим банальную проверку объекта аутентификации, что он является реализацией класса AnonymousAuthenticationToken
.
Далее, в sendStartAuthentication
мы видим следующую строчку this.requestCache.saveRequest(request, response);
. Это значит, что пришедший к нам запрос сохранился в кэше. То есть простыми словами, Spring Security видит, что у него нет авторизованной сессии, приостанавливает выполнение текущего запроса, сохраняя его в кэш, и запускает процесс аутентификации. Выполняя метод commence()
, он запускает выполнение AuthenticationEntryPoint
по умолчанию, а это LoginUrlAuthenticationEntryPoint
, в котором и прописан редирект на страницу логина.
Таким "незамысловатым" путем, у нас в браузере отображается страница логина. В добавок к этому у нас выставлена JSESSIONID кука, и сохранен изначальный запрос в request cache.
Теперь вводим логин и пароль, нажимаем кнопку Sign In
. Посмотрим в консоль браузера и увидим, что там выполняется POST запрос на endpoint /login
, а в ответ мы получаем ответ с кодом 302, в хедере Location
мы видим тот самый наш первый запрос.
Чтобы понять, как из request cache наш запрос "перекочевал" в хедер Location
, посмотрим на 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.
Вот страница, на которой описано как зарегистрировать 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, добавим аналогичную конфигурацию для 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()
.
Как видно из скриншота выше, объектом Principal у нас является DefaultOAuth2User
, который конструируется на основе информации, полученной от Github. Далее, давайте чуть по другому проведём тест. Теперь аутентифицируемся при помощи формы логина и посмотрим как изменится объект Authentication
.
Смотрим на результат и видим уже другой объект принципала. Здесь мы получаем объект 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)
- получить пользователя по IDUserEntity 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
}
}
Как вы могли заметить из кода выше, мы ещё добавляем следующие компоненты:
enum AuthProvider
- это простейший enum со списком поддерживаемых провайдеров (google, github и т.д.)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
.
Как мы видим, всё, что мы сделали выше, сработало и в качестве объекта 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
В самом верху красуется параметр 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 закончена.
Давайте взглянем на те функциональные требования, которые мы выставили в самом начале. Как мы видим первое и второе требования теперь выполнены. Давайте реализуем следующее: получение информации о пользователе по токену доступа из 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, и путь он возвращает:
флаг active - говорит, что токен активен или нет. Если true, то отображаются следующие параметры
объект Principal - объект авторизованного пользователя, который мы и хотим получить
список 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. Увидели как можно настраивать собственные провайдеры. Выполнили следующие требования, которые ставили в самом начале этой статьи:
Технические требования:
Использование непрозрачных токенов
Использование последних версий Spring Boot и Spring Authorization Server
Java 17
Функциональные требования:
Аутентификация пользователей на SSO через форму логина/пароля
Аутентификация пользователей на SSO через Google, Github и Yandex
Авторизация по протоколу OAuth2.1 для моих pet-проектов
Получение информации о пользователе по токену доступа
Регистрация пользователей через Google, Github и Yandex
В следующих статьях мы разберём, как вкрутить SPA VueJS приложение в качестве формы аутентификации на SSO. Разработаем процесс регистрации пользователей. Подключим PostgreSQL и Redis. Посмотрим, как можно реализовать менеджер токенов доступа, настроим springdoc (Swagger) и многое другое.
Полезные ссылки
Исходники смотрите здесь
Спецификация The OAuth 2.1 Authorization Framework
Документация Spring Authorization Server
Руководство по созданию сервера OAuth2.0 OAuth 2.0 Simplified
Гайд по VueJS
Гайд по Vue Cli
Гайд по Spring Security OAuth 2.0 Client
Регистрация OAuth2 приложения Github
Регистрация OAuth2 приложения Google
Регистрация OAuth2 приложения Yandex
Filex
А почему предпочитаете использовать log4j2 вместо стандартного logback ?
dlabs71 Автор
Apache Log4j2 - самый молодой из Logback, Log4j и Log4j2. Его цель - улучшить более старые реализации (Logback, Log4j), используя преимущества как одного так и второго и одновременно избегая и их проблем. Он также является самым быстрым и продвинутым из них. Однако не забывайте использовать последнюю версию этой библиотеки. Logback по-прежнему является хорошим вариантом, если производительность не является вашим главным приоритетом.