В современных приложениях централизованная аутентификация и авторизация играют ключевую роль в обеспечении безопасности и удобства пользователей. Именно с такой задачей мы столкнулись в компании NAUKA при создании "Платформы" — экосистемы, предназначенной для функционирования наших решений и разработки собственных прикладных приложений. В качестве основного компонента системы аутентификации и авторизации был выбран Spring Authorization Server.
Spring Authorization Server как мощный инструмент для реализации SSO (Single Sign-On) предоставляет гибкие возможности для настройки OAuth2 и OpenID Connect. Однако при масштабировании и интеграции с реальными системами возникают сложности, требующие глубокого понимания архитектуры и возможностей фреймворка, да и в целом считается, что все, связанное со Spring Security, имеет слабый DX (developer experience), и этим никто не любит заниматься.
Настоящая статья - это небольшой практический обзор реализации SSO-сервера на основе технологии Spring Authorization Server с акцентом на решении типовых проблем, которые возникают при её использовании в реальной системе. Мы рассмотрим как технические детали, так и архитектурные решения, которые помогут создать надежный и масштабируемый сервер авторизации.
1. Простая реализация по документации
Приступать к работе можно как с чистого листа, так и при помощи конфигуратора Spring Initializr. В любом случае у вас на выходе должен получиться проект с таким описанием структуры (авторы выбирают maven, однако предпочтения в этом случае не влияют на конечный результат: если используется gradle, то в build-файле обязательно должна быть зависимость spring-boot-starter-oauth2-authorization-server
):
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.4</version>
<relativePath/>
</parent>
<groupId>ru.ntik</groupId>
<artifactId>oauth2-authorization-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>oauth2-authorization-server</name>
<description>Demo project for Spring Authorization server</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
В итоге в зависимости нашего проекта попадают (на момент написания) Spring Boot 3.4.4, Spring Authorization Server 1.4.2 и Lombok.
Удостоверяемся, что есть запускающий класс:
@SpringBootApplication
public class AuthorizationServerApplication {
public static void main(String[] args) {
SpringApplication.run(AuthorizationServerApplication.class, args);
}
}
Приступим к настройке SSO сервера, для этого создадим два класса конфигурации:
SecurityConfig.java
- описание конфигурации собственной безопасности сервера авторизации как веб-приложения;AuthorizationServerConfig.java
- здесь мы будем описывать конфигурацию безопасности системы с точки зрения сервера авторизации.
Сперва создаем класс SecurityConfig
и конфигурируем:
в бине
SecurityFilterChain
указывается, что на всех эндпоинтах будет проверяться авторизация, а в конфигурации страницы входа -Customizer.withDefaults()
в качестве параметра DSL методаformLogin(...)
;в бине
UserDetailsService
укажем in memory реализацию этого интерфейса: он будет отвечать за хранение и получение данных по логину в процессе аутентификации пользователя. Сразу же в него вносится один пользователь.
SecurityConfig.java
import static org.springframework.security.config.Customizer.withDefaults;
@EnableWebSecurity // включает конфигурации Spring Security в проекте
@Configuration
public class SecurityConfig {
@Bean // обязательный бин SecurityFilterChain, описывающий поведение Spring Security при вызовах
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(authorize ->
authorize.anyRequest().authenticated() // любой запрос должен быть авторизованным
);
return http.formLogin(withDefaults()).build(); // для авторизации использовать страницу с логином
}
@Bean
public UserDetailsService users() {
UserDetails user = User.builder()
.username("admin")
.password("{noop}password") // незашифрованный пароль
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user);
}
}
Далее создадим класс AuthorizationServerConfig
:
в нём создадим второй в проекте бин
SecurityFilterChain
. В этом бине добавим конфигурацию, предоставляемую по умолчанию зависимостьюspring-security-oauth2-authorization-server
. Для этого достаточно добавитьOAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
. После чего не забываем настроить переход на форму логина, если у нас отсутствует аутентифицированная сессия j-sso.Создадим бин
registeredClientRepository
, реализующий интерфейсRegisteredClientRepository
. Он будет представлять в нашем проекте хранилище клиентов системы. Так же как и в случае сUserDetailsService
, сделаем хранилище клиентов максимально простым (in-memory реализацияInMemoryRegisteredClientRepository
) и сразу запишем в него единственного клиента.
Поскольку проект тестовый, поставим самый простой способ аутентификации Basic Authentication. Это значит, что у клиента должен быть заголовок Authorization
с типом Basic
. Обратите внимание на параметр redirectUri
- он необходим для аутентификации по типу AUTHORIZATION_CODE
(аутентификация при обмене кода на токен). В этом параметре мы указываем, на какой URL разрешен редирект после успешной аутентификации пользователя.
AuthorizationServerConfig.java
@RequiredArgsConstructor
@Configuration
public class AuthorizationServerConfig {
private final AuthorizationServerProperties authorizationServerProperties;
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE) // в цепочке из двух бинов типа SecurityFilterChain этот будет обрабатываться первым
public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); // включает обязательные фильтры и эндпоинты для работы сервера по OAUTH2 /oauth2/authorize и т.д
http.exceptionHandling(exceptions ->
exceptions.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))
); // редирект неаутентифицированного пользователя на /login
return http.build();
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
return new InMemoryRegisteredClientRepository(
RegisteredClient.withId("test-client-id")
.clientName("Test Client")
.clientId("test-client")
.clientSecret("{noop}test-client") // незашифрованный секрет клиента
.redirectUri("http://localhost:8080/code")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) // все доступные grant types
.build()
);
}
@Bean // динамическая генерация ключей для подписи токенов
public JWKSource<SecurityContext> jwkSource() {
RSAKey rsaKey = JwkUtils.generateRsa();
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}
@Bean // добавляет в токен поле iss для проверки валидности сервиса, выдавшего токен
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder()
.issuer(authorizationServerProperties.getIssuerUrl())
.build();
}
}
По умолчанию тип токена в проекте JWT, поэтому понадобится настройка бина jwkSource
, в котором описывается конфигурация хранилища RSA ключей. Правила генерации RSA ключа опишем в классе JwkUtils
:
public class JwkUtils {
// Генерирует пару ключей по алгоритму RSA с длиной 2048 бит
// Приватный ключ используется для подписи токенов сервером авторизации, публичный ключ используется ресурсным
// сервером для проверки валидности токенов, которые отправляет клиент
public static RSAKey generateRsa() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
return new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
}
public static KeyPair generateRsaKey() {
KeyPair keyPair;
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
keyPair = keyPairGenerator.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
return keyPair;
}
}
В основном, вся необходимая конфигурация у нас есть, но зависимость spring-security-oauth2-authorization-server также в обязательном порядке требует бин описания конфигурации самого OAuth2 сервера. Для этого мы объявим бин authorizationServerSettings и укажем в нем пока единственный параметр issuer - это корневой URL адрес нашего SSO сервера. Чтобы не оставлять такие параметры в коде, вынесем этот URL в application.properties и укажем его через класс конфигураций AuthorizationServerProperties.
AuthorizationServerProperties - это банальный класс, аннотированный при помощи аннотации @ConfigurationProperties
и описывающий параметры с определенным префиксом из application.properties файла.
AuthorizationServerProperties.class
@Setter
@Getter
@Configuration
@ConfigurationProperties(prefix = "spring.security.oauth2.authorizationserver")
public class AuthorizationServerProperties {
private String issuerUrl;
}
application.properties
spring.application.name=authorization-server
server.port=9091
logging.level.root=DEBUG
spring.security.oauth2.authorizationserver.issuer-url=http://localhost:9091
На этом конфигурация простейшего сервера авторизации закончена, приложение oauth2-authorization-server
должно корректно собраться и запуститься, а после этого станет доступна форма логина при переходе на /login
. Также будут доступны эндпоинты OAuth2 Authorization Server и все 3 типа получения OAuth2 токенов, описанные в спецификации The OAuth 2.1 Authorization Framework.
Ниже приведены примеры методов авторизации через наш сервер авторизации.
Получение токенов методом authorization code flow прямыми запросами
-
Выполняем запрос /authorize:
curl --location --request GET 'http://localhost:9091/oauth2/authorize?response_type=code&client_id=test-client&redirect_uri=http://localhost:8080/code'
Далее нас перенаправит на страницу логина, в которой мы введем логин/пароль и нажмём Sign In.
-
После успешного логина нас отправляет на страницу клиента с кодом авторизации. Берём этот код и выполняем запрос на получение токенов с параметром grant_type равным authorization_code и параметром code, в который и помещаем полученное значение кода авторизации:
curl --location --request POST 'http://localhost:9091/oauth2/token?grant_type=authorization_code&code=M6MsgrcmEa6eKlslkgDoS3mEOSuNoN827eLFUu6-k2Vi1v-xW17it7ojPC6QXbnjVsvCVCvfkIWNRq8kmMZBcPcre2R2N9AvNSxwLCMIiO0q4SRjWcoYrOFztvputvxS&redirect_uri=http://localhost:8080/code' \ --header 'Authorization: Basic dGVzdC1jbGllbnQ6dGVzdC1jbGllbnQ='
Последний запрос вернёт необходимые нам access и refresh токены.
Обратите внимание, что в последнем запросе у нас обязательно должен быть заголовок Authorization с типом Basic, в котором находится base64 строка следующего вида: test-client:test-client. Это наши clientId и clientSecret, которые мы указывали при создании RegisteredClient.
Подключение фронтового клиента
Но наиболее удобный вариант тестирования сервера авторизации - это подключение реального клиента, например, написанного на Vue.js
Не будем углубляться в детали реализации такого клиента. Вы можете с этим ознакомится в этой статье или посмотреть исходники клиента, который будет использоваться в дальнейшем в статье.
-
При переходе по адресу http://localhost:8080 нас перенаправит на страницу со ссылкой на форму логина.
login_8080 -
Если мы перейдём по этой ссылке, нам откроется форма логина сервера авторизации.
login_9091 -
После успешного логина сервер авторизации спросит у нас, какие скоупы (разрешения) мы хотим предоставить клиенту. Выбираем единственный доступный в данном примере и нажимаем Submit.
login_scope -
После чего нам откроется страница http://localhost:8080 с информацией о выданном токене.
home
Ссылки на источники.
2. Проблемы простейшей реализации и их решения
Безусловно, реализация, описанная выше, подойдёт только для pet- или демо-проекта. Какие изменения необходимо в ней произвести, чтобы проект минимально соответствовал требованиям продакшен-среды? Давайте посмотрим на основные проблемы такой реализации и попробуем их решить.
2.1 Cross-origin resource sharing
Как вы могли обратить внимание, мы не настраивали CORS. Проблемы у нас начнутся сразу, как мы попробуем подключить клиент на VueJs.
При первом же запросе JWT-токена клиент кинет ошибку CORS header 'Access-Control-Allow-Origin' missing
. С одной стороны, для решения этой проблемы можно было бы разрешить запросы к серверу с любых хостов, однако ради общей безопасности давайте явно укажем нашему серверу авторизации, откуда разрешены запросы.
Определим бин CorsConfigurationSource
, где мы настроим CORS-политики, а также включим CORS в основной конфигурации. Заодно перепишем нашу конфигурацию SecurityFilterChain
по рекомендации новой версии Spring Authorization Server:
AuthorizationServerConfig.java
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception {
// вместо устаревшего вызова OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = authorizationServer();
http
.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
.with(authorizationServerConfigurer, Customizer.withDefaults())
.authorizeHttpRequests(authorize ->
authorize.anyRequest().authenticated())
.exceptionHandling(exceptions ->
exceptions.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")))
.cors(Customizer.withDefaults());
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
// настройка CORS позволит запросы с любыми заголовками и методами, но только с перечисленных хостов
config.addAllowedHeader("*");
config.addAllowedMethod("*");
authorizationServerProperties.getAllowedOrigins().forEach(config::addAllowedOrigin);
config.setAllowCredentials(true);
source.registerCorsConfiguration("/**", config);
return source;
}
Также для удобства последующей настройки вынесем разрешенные хосты в виде списка в property файл:
AuthorizationServerProperties.java
@Setter
@Getter
@Configuration
@ConfigurationProperties(prefix = "spring.security.oauth2.authorizationserver")
public class AuthorizationServerProperties {
private String issuerUrl;
private List<String> allowedOrigins =new ArrayList<>();
}
application.properties
spring.security.oauth2.authorizationserver.allowedOrigins[0]=http://localhost:8080
spring.security.oauth2.authorizationserver.allowedOrigins[1]=http://localhost:8081
2.2 Провайдер пользователей
InMemoryUserDetailsManager
совсем не годится для серьезного применения, поэтому давайте напишем свою реализацию UserDetailsService
и UserDetails
.
В дополнение к UserDetails
будем использовать еще и кастомный GrantedAuthority
, который представляет собой простую реализацию ABAC. Таким образом, наш CustomGrantedAuthority
будет состоять не просто из строковых значений, а иметь вид "ключ-значение":
CustomUserDetails.java
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails, Serializable {
private final List<CustomGrantedAuthority> authorities;
private final String username;
private final String password;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
}
CustomGrantedAuthority.java
public class CustomGrantedAuthority implements GrantedAuthority {
private String key;
private String value;
public CustomGrantedAuthority(String key, String value) {
this.key = key;
this.value = value;
}
@Override
public String getAuthority() {
return key + ":" + value;
}
}
Получение пользователей может быть реализовано любым способом: из базы данных либо из какой-то другой системы.
А так как это целиком зависит от целевой системы и не является темой данной статьи, мы возьмём для примера вариант хранения примитивного пользователя в базе данных с искусственным заполнением Authorities:
CustomUserDetailsService.java
@Service
@Slf4j
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private static final List<CustomGrantedAuthority> bigGrantedAuthorityList;
private final JdbcTemplate jdbcTemplate;
private String usersByUsernameQuery = "select username,password,enabled from users where username = ?";
static {
bigGrantedAuthorityList = IntStream.range(0, 1000)
.mapToObj(String::valueOf)
.map(i ->
new CustomGrantedAuthority(i, UUID.randomUUID().toString()))
.collect(Collectors.toList());
}
public CustomUserDetails getUserDetails(String username, String password) {
CustomUserDetails customUserDetails = (CustomUserDetails) loadUserByUsername(username);
if (!customUserDetails.getPassword().equals(password)) {
throw new AuthenticationCredentialsNotFoundException(username);
}
return customUserDetails;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
RowMapper<CustomUserDetails> mapper = (rs, rowNum) -> {
String usernameFromDb = rs.getString(1);
String password = rs.getString(2);
return new CustomUserDetails(bigGrantedAuthorityList, usernameFromDb, password);
};
List<CustomUserDetails> users = jdbcTemplate.query(usersByUsernameQuery, mapper, username);
if (users.isEmpty()) {
throw new UsernameNotFoundException("Username %s not found".formatted(username));
} else {
return users.get(0);
}
}
}
В конфигурацию AuthorizationServerConfig
добавим определение бина AuthenticationProvider
, а в качестве параметра при создании AuthenticationProviderImpl
передадим наш кастомный UserDetailsService
:
AuthorizationServerConfig.java
private final CustomUserDetailsService userDetailsService;
@Bean
public AuthenticationProvider authenticationProvider() {
return new AuthenticationProviderImpl(userDetailsService);
}
2.3 Хранилище клиентов
В исходном проекте не только пользователи, но и клиенты хранились в памяти (InMemoryRegisteredClientRepository
) - следует заменить эту реализацию на что-то более серьезное.
Как и с провайдером пользователей, мы можем написать собственный RegisteredClientRepository
. Но Spring предоставляет неплохую реализацию с сохранением настроек клиентов в БД - давайте ею воспользуемся.
Сперва добавим зависимость spring-jdbc
в проект:
pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
Заменим InMemoryRegisteredClientRepository
на JdbcRegisteredClientRepository
в конфигурации сервера авторизации:
AuthorizationServerConfig.java
@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
return new JdbcRegisteredClientRepository(jdbcTemplate);
}
Настроим подключение к БД:
application.properties
spring.datasource.url=jdbc:postgresql://127.0.0.1:5432/authorization_server
spring.datasource.password=postgres
spring.datasource.username=postgres
Скрипт создания таблиц Spring любезно оставил в исходниках:
CREATE TABLE oauth2_registered_client
(
id varchar(100) NOT NULL,
client_id varchar(100) NOT NULL,
client_id_issued_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
client_secret varchar(200) DEFAULT NULL,
client_secret_expires_at timestamp DEFAULT NULL,
client_name varchar(200) NOT NULL,
client_authentication_methods varchar(1000) NOT NULL,
authorization_grant_types varchar(1000) NOT NULL,
redirect_uris varchar(1000) DEFAULT NULL,
scopes varchar(1000) NOT NULL,
client_settings varchar(2000) NOT NULL,
token_settings varchar(2000) NOT NULL,
PRIMARY KEY (id)
);
Пусть этот скрипт выполняется как миграция, а механизм их выполнения пусть будет Flyway.
pom.xml
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
Создаём директорию src/main/resources/db/migration
и в ней файл миграции с нашим скриптом.
2.4 Неочевидные проблемы
Казалось бы, всё: теперь наш сервер авторизации ничего не хранит in-memory, а, значит, мы можем не переживать за потребление памяти. Но давайте на всякий случай протестируем его под нагрузкой.
Для тестирования нагрузки использовался Apache JMeter с groovy скриптом, который в точности повторяет всю логику авторизации с запросом JWT-токена, как это делает наш клиент на Vue.js.
Если мы запустим тест на небольшой нагрузке, то увидим в профайлере:

А в логах нашего SSO сервера в скором времени появятся знаменитые ошибки java.lang.OutOfMemoryError: Java heap space
.
Что же занимает память сервера? Если посмотреть дамп памяти приложения, мы увидим, что не от всех in-memory реализаций мы избавились.

InMemoryOAuth2AuthorizationService
- это реализация сервиса OAuth2AuthorizationService, который отвечает за хранение и управление авторизационными данными в контексте OAuth 2.0. Он используется для управления состоянием авторизации (например, хранение токенов, проверку их валидности и удаление устаревших данных).
Сам факт хранения авторизаций в памяти не является чем-то запрещённым, но, исследовав исходный код класса InMemoryOAuth2AuthorizationService
, вы обнаружите, что эти авторизации никогда не удаляются и копятся до OutOfMemoryError.
Более того, javadoc прямо заявляет об этом: "NOTE: This implementation should ONLY be used during development/testing."
В качестве реализации OAuth2AuthorizationService
для продуктивной среды Spring предлагает использовать JdbcOAuth2AuthorizationService
("NOTE: This OAuth2AuthorizationService is a simplified JDBC implementation that MAY be used in a production environment."). В этом случае авторизации будут храниться в базе данных, что нам позволит избежать утечки памяти.
Неплохо, но в ней также отсутствует очистка истекших авторизаций. Чтобы это исправить, нам нужно расширить предложенную реализацию.
Создадим свой класс CustomJdbcOAuth2AuthorizationService
и наследуем его от JdbcOAuth2AuthorizationService
. В конструкторе создадим экземпляр Timer
и с помощью него запустим CleanReminder
.
CleanReminder
- это внутренний класс, который будет выполнять запрос к БД по очистке авторизаций с протухшим токеном, а также зависших авторизаций, когда пользователь запросил authorization code, но так им и не воспользовался.
CustomJdbcOAuth2AuthorizationService.java
public class CustomJdbcOAuth2AuthorizationService extends JdbcOAuth2AuthorizationService {
private static final String REMOVE_EXPIRED_AUTHORIZATION_SQL =
"DELETE FROM oauth2_authorization WHERE (access_token_expires_at < ? and (refresh_token_expires_at < ? or refresh_token_value is null)) " +
"or (access_token_value is null and refresh_token_value is null and (authorization_code_expires_at < ? or authorization_code_value is null))";
public CustomJdbcOAuth2AuthorizationService(
JdbcOperations jdbcOperations, RegisteredClientRepository registeredClientRepository) {
super(jdbcOperations, registeredClientRepository);
Timer timer = new Timer();
timer.schedule(new CleanReminder(), 0, 3600000);
}
class CleanReminder extends TimerTask {
public void run() {
Object[] parameters =
IntStream.range(0, 3)
.mapToObj(i -> new SqlParameterValue(Types.TIMESTAMP, Timestamp.from(Instant.now())))
.toArray();
PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters);
int deleted = getJdbcOperations().update(REMOVE_EXPIRED_AUTHORIZATION_SQL, pss);
log.debug("----------- Expired Authorizations Deleted: {}", deleted);
}
}
}
Добавим создание бина нашего CustomJdbcOAuth2AuthorizationService
в конфигурацию сервера авторизации:
AuthorizationServerConfig.java
@Bean
OAuth2AuthorizationService jdbcOAuth2AuthorizationService(JdbcOperations jdbcOperations,
RegisteredClientRepository registeredClientRepository) {
return new CustomJdbcOAuth2AuthorizationService(jdbcOperations, registeredClientRepository);
}
Возьмем готовый скрипт создания таблицы для хранения авторизаций
CREATE TABLE IF NOT EXISTS oauth2_authorization
(
id varchar(100) NOT NULL,
registered_client_id varchar(100) NOT NULL,
principal_name varchar(200) NOT NULL,
authorization_grant_type varchar(100) NOT NULL,
authorized_scopes varchar(1000) DEFAULT NULL,
attributes text DEFAULT NULL,
state varchar(500) DEFAULT NULL,
authorization_code_value text DEFAULT NULL,
authorization_code_issued_at timestamp DEFAULT NULL,
authorization_code_expires_at timestamp DEFAULT NULL,
authorization_code_metadata text DEFAULT NULL,
access_token_value text DEFAULT NULL,
access_token_issued_at timestamp DEFAULT NULL,
access_token_expires_at timestamp DEFAULT NULL,
access_token_metadata text DEFAULT NULL,
access_token_type varchar(100) DEFAULT NULL,
access_token_scopes varchar(1000) DEFAULT NULL,
oidc_id_token_value text DEFAULT NULL,
oidc_id_token_issued_at timestamp DEFAULT NULL,
oidc_id_token_expires_at timestamp DEFAULT NULL,
oidc_id_token_metadata text DEFAULT NULL,
refresh_token_value text DEFAULT NULL,
refresh_token_issued_at timestamp DEFAULT NULL,
refresh_token_expires_at timestamp DEFAULT NULL,
refresh_token_metadata text DEFAULT NULL,
user_code_value text DEFAULT NULL,
user_code_issued_at timestamp DEFAULT NULL,
user_code_expires_at timestamp DEFAULT NULL,
user_code_metadata text DEFAULT NULL,
device_code_value text DEFAULT NULL,
device_code_issued_at timestamp DEFAULT NULL,
device_code_expires_at timestamp DEFAULT NULL,
device_code_metadata text DEFAULT NULL,
PRIMARY KEY (id)
);
2.5 Jackson
Если мы попытаемся запустить приложение сразу после внесения правок по OAuth2AuthorizationService, то при аутентификации мы увидим ошибку The class with ru.ntik.authorization_server.security.impl.CustomUserDetails and name of ru.ntik.authorization_server.security.impl.CustomUserDetails is not in the allowlist. If you believe this class is safe to deserialize, please provide an explicit mapping using Jackson annotations or by providing a Mixin. If the serialization is only done by a trusted source, you can also enable default typing. See https://github.com/spring-projects/spring-security/issues/4370 for details
.
Данная ошибка связана с ограничением безопасности в Spring Security, начиная с версии 5.7.0. Spring Security по умолчанию запрещает десериализацию классов, не входящих в "белый список" (allowlist), чтобы предотвратить риски, связанные с десериализацией произвольных объектов. В нашем случае класс CustomUserDetails
не входит в этот список, и Spring не может его десериализовать. А нам это необходимо, так как теперь авторизации хранятся в БД и, соответственно, проходят сериализацию и десериализацию.
Для исправления этой ошибки нам нужно указать Spring Security, по каким правилам должна осуществляться сериализация/десериализация нашего кастомного пользователя.
Как нам и предлагает Spring, добавим Mixin на все классы, которые мы используем в CustomUserDetails
. И создадим на их основе модуль, который позже зарегистрируем в ObjectMapper
в нашем CustomJdbcOAuth2AuthorizationService
.
CustomUserDetailsMixin.java
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME)
@JsonAutoDetect(
fieldVisibility = JsonAutoDetect.Visibility.ANY,
getterVisibility = JsonAutoDetect.Visibility.NONE,
isGetterVisibility = JsonAutoDetect.Visibility.NONE)
@JsonIgnoreProperties(ignoreUnknown = true)
public abstract class CustomUserDetailsMixin {}
CustomGrantedAuthorityMixin.java
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
@JsonAutoDetect(
fieldVisibility = JsonAutoDetect.Visibility.ANY,
getterVisibility = JsonAutoDetect.Visibility.NONE,
isGetterVisibility = JsonAutoDetect.Visibility.NONE)
@JsonIgnoreProperties(ignoreUnknown = true)
public abstract class CustomGrantedAuthorityMixin {
@JsonCreator
public CustomGrantedAuthorityMixin(@JsonProperty("authority") String authority) {
}
}
CustomUserDetailsJackson2Module.java
public class CustomUserDetailsJackson2Module extends SimpleModule {
public CustomUserDetailsJackson2Module() {
super(CustomUserDetailsJackson2Module.class.getName(), new Version(1, 0, 0, null, null, null));
}
@Override
public void setupModule(SetupContext context) {
SecurityJackson2Modules.enableDefaultTyping(context.getOwner());
context.setMixInAnnotations(CustomUserDetails.class, CustomUserDetailsMixin.class);
context.setMixInAnnotations(CustomGrantedAuthority.class, CustomGrantedAuthorityMixin.class);
}
}
CustomJdbcOAuth2AuthorizationService.java
public CustomJdbcOAuth2AuthorizationService(
JdbcOperations jdbcOperations, RegisteredClientRepository registeredClientRepository) {
super(jdbcOperations, registeredClientRepository);
JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper rowMapper =
new JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper(registeredClientRepository);
ClassLoader classLoader = JdbcOAuth2AuthorizationService.class.getClassLoader();
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModules(new CoreJackson2Module());
objectMapper.registerModules(SecurityJackson2Modules.getModules(classLoader));
objectMapper.registerModule(new OAuth2AuthorizationServerJackson2Module());
objectMapper.registerModule(new CustomUserDetailsJackson2Module());
rowMapper.setObjectMapper(objectMapper);
this.setAuthorizationRowMapper(rowMapper);
Timer timer = new Timer();
timer.schedule(new CleanReminder(), 0, 3600000);
}
Так как Spring сериализует GrantedAuthority через getAuthority(), нам нужно настроить корректную сериализацию/десериализацию GrantedAuthority с использованием этого метода и поля authority.
Для этого добавим пару аннотаций и конструктор в класс CustomGrantedAuthority.
CustomGrantedAuthority.java
@JsonCreator
public CustomGrantedAuthority(String authority) {
String[] split = authority.split(":");
this.key = split[0];
this.value = split[1];
}
@Override
@JsonProperty("authority")
public String getAuthority() {
return key + ":" + value;
}
2.6 SessionRegistry
В данной статье мы не рассматривали ещё одну особенность Spring Authorization Server, а именно поддержку протокола OpenID Connect.
OpenID Connect — это мощный протокол, который дополняет OAuth 2.0, добавляя возможность приложениям получать информацию о пользователе. Если вам нужно не просто разрешить доступ к ресурсам, но и подтвердить, кто именно использует приложение, OpenID Connect — это правильный выбор.
Рано или поздно вы захотите воспользоваться этим механизмом, например, для отображения аватара пользователя или его имейл-адрес.
В таком случае авторизации со скоупом openid
опять начнут копиться в памяти, но уже в SessionRegistryImpl
, и никогда не будут удаляться, даже после истечения времени жизни сессии.
SessionRegistryImpl
— это реализация интерфейса SessionRegistry
в библиотеке Spring Security, которая используется для отслеживания и управления активными сессиями пользователей в веб-приложениях. Этот класс помогает контролировать одновременные сессии, ограничивать количество активных сессий для одного пользователя и обрабатывать события сессий (например, выход пользователя или истечение срока действия сессии).
Spring Authorization Server создает SessionRegistryImpl
автоматически, если вы самостоятельно не определяете реализацию SessionRegistry
.
SessionRegistryImpl
для контроля сессий реализует интерфейс ApplicationListener<AbstractSessionEvent>
- механизм в Spring Framework, который позволяет обрабатывать события, связанные с жизненным циклом HTTP-сессий (например, создание, уничтожение, изменение сессии). Он используется в сочетании с Spring Security для отслеживания действий, происходящих с сессиями пользователей, и реализации кастомной логики (логирование, уведомления, мониторинг и т.д.).
Для работы этого механизма, помимо прочего, необходимо определить бин HttpSessionEventPublisher
, что по умолчанию Spring не делает. Поэтому у вас будут копиться сессии в памяти и никогда не удаляться.
Чтобы исправить эту проблему, нам нужно определить бин ServletListenerRegistrationBean<HttpSessionEventPublisher>
в конфигурации сервера авторизации.
AuthorizationServerConfig.java
@Bean
public ServletListenerRegistrationBean<HttpSessionEventPublisher> httpSessionEventPublisher() {
return new ServletListenerRegistrationBean<>(new HttpSessionEventPublisher());
}
2.7 Авторизация для одностраничных приложений
В первой части статьи уже упоминалось создание клиентского приложения, работающего в браузере (Vue.js), и приводился пример простой реализации, в которой секрет клиента прописывался в исходниках. Такой клиент регистрируется в сервере авторизации и запрашивает у него доступ к серверу ресурсов от лица пользователя. Подобно паролю пользователя, клиент должен в процессе авторизации передавать серверу авторизации свой секрет, который сравнивается с его хешом из регистрационной записи. Однако специфика современных браузерных приложений, созданных на популярных фреймворках, такова, что весь код, включая секрет, хоть и в минифицированном виде, загружается в компьютер пользователя и полностью доступен для разбора и анализа. Такой компрометирующий подход может позволить злоумышленникам при помощи оригинального секрета получить access_token
и доступ к защищенным ресурсам пользователя.
По этой причине в OAuth 2.1 все браузерные и мобильные клиенты считаются небезопасными и для них разработан специальный протокол авторизации, который устраняет описанную проблему. Он называется PKCE (Proof Key for Code Exchange) и является надстройкой над авторизационным грантом "authorization_code". Решение проблемы компрометации клиентского секрета простое: в регистрационных данных клиента секрет отсутствует, вместо него в базе данных записан NULL.
Авторизация при подходе "PKCE + authorization_code" усложняется:
Было:

Стало:

Основное отличие заключается в том, что вместо постоянного секрета клиента применяется динамическая пара code_verifier
+ code_challenge
. Первый элемент - это некоторая сгенерированная строка, а второе - ее хеш. В таком случае злоумышленник, даже если и сможет перехватить code_challenge
, не сможет обменять его токен - у него не будет второй половины "ключа", т.е. code_verifier
, который клиент не отправляет при первом запросе. Сервер авторизации при первоначальном запросе /authorize
принимает в параметрах запроса code_challenge
и code_challenge_method
, описывающий алгоритм хеширования (в примере выше это SHA-256). На запросе /token
сервер авторизации сравнивает исходную строку и ее хеш и на этом основании возвращает либо токен, либо ошибку.
Пример реализации
Требование PKCE в регистрации клиента:
В разделе client_settings должно быть свойство requireProofKey=true ({"settings.client.require-proof-key":true})
Реализация на стороне клиента (TypeScript)
import { sha256 } from 'js-sha256';
import { nanoid } from '@reduxjs/toolkit';
export const encodeSHA256 = (str: string) => {
const hash = sha256.create();
hash.update(str);
const buffer = hash.arrayBuffer();
const array = Array.from(new Uint8Array(buffer));
const binaryString = String.fromCharCode(...array);
return btoa(binaryString).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
};
export const getAuthorizeRequestParams = (code: string, clientId: string, redirectURL: string) =>
new URLSearchParams({
response_type: 'code',
client_id: clientId, /* зарегистрированный ID клиента */
redirect_uri: redirectURL, /* зарегистрированный редирект после авторизации */
code_challenge: encodeSHA256(code),
code_challenge_method: 'S256'
});
export const handleAuthorize = (authUrl: string, clientId: string, redirectUrl) => {
const codeVerifier = nanoid(); /* или любая другая случайная строка */
// перед хешированием случайная строка должна быть сохранена в памяти, например в local storage
localStorage.setItem("code_verifier", codeVerifier);
location.href = `${authUrl}?${decodeURIComponent(getAuthorizeRequestParams(codeVerifier, clientId, redirectUrl))}`
/* код выше запрашивает страницу /authorize сервера авторизации и передает идентификатор клиента,
закодированный в base64 хеш случайной строки, вид хеширования и желаемый адрес для редиректа, который выполнит
сервер авторизации в случае успеха */
}
export const handlePostRedirect = (tokenUrl: string, redirectUrl: string, clientId: string) => {
/* Код авторизации, выдаваемый сервером авторизации, выглядит как параметр 'code', который он добавляет к адресу,
указанному в redirectUrl */
const authCode = new URLSearchParams(window.location.search).get('code');
fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
redirect_uri: redirectUrl,
client_id: clientId,
code_verifier: localStorage.getItem("code_verifier") // незахешированная исходная строка
})
})
.then(response => response.json())
.then(data => {
data.access_token // в ответе от сервера будет access_token
})
}
Приведенный вариант авторизации с использованием динамического разделяемого секрета клиента хоть и надежно защищает взаимодействие между SPA-приложением и сервером авторизации, но не рассматривает проблему хранения токенов. Разработчик клиентского приложения может выбрать между хранением в памяти или хранением в браузере (local/session storage, indexDB). По мнению авторов, наиболее безопасный вариант - возвращать токены не в теле ответа, а в виде HttpOnly cookie, однако эта тема выходит за рамки настоящей статьи.
Заключение
Реализация SSO-сервера на Spring Authorization Server достаточно нетривиальная задача и требует не только технических знаний, но и понимания архитектурных решений, обеспечивающих масштабируемость и безопасность. В этой статье мы рассмотрели некоторые аспекты реализации сервера авторизации минимально пригодного для использования в реальных проектах.
Однако это лишь начало. Spring Authorization Server предоставляет гибкие возможности, и с правильным подходом вы можете создать надежный и производительный сервер авторизации, готовый к использованию в действительно серьёзных проектах.
В будущем, если будет особенный интерес к этой статье, мы напишем дополнение, где подробнее рассмотрим безопасность, OpenID Connect и другие полезные фичи, которые можно привнести в наш SSO сервер.
Спасибо!
Исходный код можно найти здесь
Aвторы: @devolek, @lvchgntsv
Filex
Мощный разбор. Я правильно понимаю, что это основано на личном опыте в проде?
devolek Автор
Да, основано на личном опыте в проде. Но, естественно, это лишь часть, которая относится непосредственно к Spring Authorization Server