Строим свой SSO сервер используя Spring Authorization Server
Вступление
На днях я решил сделать под все свои pet-проекты собственный SSO сервис, дабы не заморачиваться каждый раз с авторизацией и аутентификацией.
Единый вход в систему (Single sign-on, SSO) — это решение для аутентификации,
которое дает пользователям возможность входить в несколько приложений и на
несколько веб-сайтов с использованием единовременной аутентификации пользователя.
Возиться с этим особо долго мне не хотелось. Все таки это для pet-проектов. Поэтому выбор изначально пал на Keycloak, как самое популярное решение SSO сервера.
Keycloak продукт с открытым кодом для реализации single sign-on с возможностью
управления доступом, нацелен на современные применения и сервисы.
Запустив и чуть-чуть поковырявшись с ним, я понял, что мне он не подходит. Я люблю в своих проектах иметь возможность быстро и легко кастомизировать решение под свои цели (особенно в pet-проектах бывают разные эксперименты). Я пишу на Java и в основном использую проекты Spring для решения своих задач. Поэтому после экспериментов с Keycloak выбор пал на Spring Security. На работе я уже несколько раз создавал сервер SSO, но всегда с использованием Spring Boot 2 и Spring OAuth2, и конечно же мне было интересно посмотреть в действии как на Spring Boot 3, так и новый Spring Authorization Server. Поэтому, почитав пару статей на хабре и вооружившись самыми последними версиями данных фреймворков (на момент написания статьи Spring Authorization Server 1.0.2
, Spring Boot 3.0.6
), я приступил к настройке собственного SSO сервера. К сожалению, я быстро столкнулся с проблемой, что в интернете очень мало информации о возможностях кастомизации готовых конфигураций Spring Authorization Server, поэтому и решил написать данную статью. Итак, перейдем от слов к делу!
Цели
При разработке своего SSO я поставил себе следующие требования:
Технические требования:
Использование непрозрачных токенов
Использование последних версий 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
4.0.0
ru.dlabs
spring-authorization-server-example
pom
0.0.1
spring-authorization-server-example
17
17
17
UTF-8
1.0.2
org.springframework.boot
spring-boot-starter-parent
3.0.5
j-sso
org.springframework.security
spring-security-oauth2-authorization-server
${security-oauth2-server.version}
Для реализации нашего j-sso нам понадобится следующие стартеры Spring Boot:
Не забудем подключить сам Spring Authorization Server. Также нам нужна какая-нибудь зависимость для логирования. Я люблю во всех своих проектах использовать log4j2. Поэтому, отключим логгер по умолчанию и подключим log4j2. Для этого исключим из spring-boot-starter
spring-boot-starter-logging
и подключим spring-boot-starter-log4j2
. Ну и конечно для удобства работы подключим lombok
, куда же мы без него)) Ниже приведена полная конфигурация pom.xml
для модуля j-sso.
pom.xml модуля j-sso
spring-authorization-server-example
ru.dlabs
0.0.1
4.0.0
j-sso
17
17
org.springframework.boot
spring-boot-starter
org.springframework.boot
spring-boot-starter-logging
org.springframework.boot
spring-boot-starter-log4j2
org.springframework.boot
spring-boot-starter-web
org.springframework.boot
spring-boot-starter-security
org.springframework.security
spring-security-oauth2-authorization-server
org.projectlombok
lombok
${lombok.version}
provided
${project.artifactId}
org.springframework.boot
spring-boot-maven-plugin
${project.name}
Все необходимые инструменты мы подключили к нашему проекту, теперь приступим к реализации SSO сервера. Создадим стандартный стартовый класс (точку входа) для запуска нашего Spring Boot приложения, а затем создадим два класса конфигурации:
SecurityConfig.java
— в нем мы будем описывать собственную конфигурацию безопасности модуля j-sso.AuthorizationServerConfig.java
— здесь мы будем описывать конфигурацию безопасности с точки зрения сервера авторизации
SecurityConfig.java
import static org.springframework.security.config.Customizer.withDefaults;
@EnableWebSecurity
@RequiredArgsConstructor
@Configuration(proxyBeanMethods = false)
public class SecurityConfig {
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(authorize ->
authorize.anyRequest().authenticated()
);
return http.formLogin(withDefaults()).build();
}
@Bean
public UserDetailsService users() {
UserDetails user = User.builder()
.username("admin")
.password("{noop}password")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
}
Здесь создадим самую простую конфигурацию безопасности. Создадим бин SecurityFilterChain
в нем укажем, что все эндпоинты заведены под секурити, и добавим конфигурацию страницы входа, поставляемую по умолчанию, указав Customizer.withDefaults()
в качестве параметра DSL метода formLogin(...)
. Также создадим бин UserDetailsService
и укажем в нем in memory реализацию этого интерфейса. Он у нас будет отвечать за хранение и получение данных по логину в процессе аутентификации пользователя.
Настраиваем класс описывающий конфигурацию Authorization Server.
Создадим класс AuthorizationServerConfig
. В нём создадим бин SecurityFilterChain
, в котором добавим конфигурацию, предоставляемую по умолчанию зависимостью spring-security-oauth2-authorization-server
. Для этого достаточно добавить OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
. После чего, не забываем настроить переход на форму логина, если у нас отсутствует аутентифицированная сессия j-sso. Создадим бин registeredClientRepository
, реализующий интерфейс RegisteredClientRepository
. Этот бин необходим для работы с хранилищем клиентов системы. Для простоты данного примера возьмем InMemoryRegisteredClientRepository
, но не забываем, что в реальном проекте лучше всего создать собственную реализацию интерфейса RegisteredClientRepository
. Так, мы будем иметь больше возможностей масштабирования при изменяющихся требованиях.
AuthorizationServerConfig.java
@RequiredArgsConstructor
@Configuration(proxyBeanMethods = false)
public class AuthorizationServerConfig {
private final AuthorizationServerProperties authorizationServerProperties;
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.exceptionHandling(exceptions ->
exceptions.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))
);
return http.build();
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
return new InMemoryRegisteredClientRepository(
RegisteredClient.withId("test-client-id")
.clientName("Test Client")
.clientId("test-client")
.clientSecret("{noop}test-client")
.redirectUri("http://localhost:5000/code")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.build()
);
}
@Bean
public JWKSource jwkSource() {
RSAKey rsaKey = JwkUtils.generateRsa();
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder()
.issuer(authorizationServerProperties.getIssuerUrl())
.build();
}
}
В нашем бине registeredClientRepository
сразу зарегистрируем тестового клиента. Укажем ему client_id test-client
и такой же client_secret. PasswordEncoder указывать не будем. Укажем все доступные grant types. В методе аутентификации установим Basic Authentication — это значит, чтобы пройти аутентификацию клиента, нам необходимо указать Authorization хедер с типом Basic. Обратите внимание на параметр redirectUri
, он необходим для типа аутентификации authorization code flow, то есть для grant_type AUTHORIZATION_CODE
. В этом параметре мы указываем, на какой URL разрешен редирект после успешной аутентификации пользователя.
По умолчанию тип токена у нас JWT, поэтому от нас также требуется настройка бина jwkSource
, в котором мы описываем конфигурацию хранилища RSA ключей. Чтобы не громоздить описание правил генерации RSA ключа в классе с общей конфигурацией сервера авторизаций, вынесем это в отдельный Utility класс с названием JwkUtils
.
JwkUtils.java
public class JwkUtils {
public static RSAKey generateRsa() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
return new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
}
public static KeyPair generateRsaKey() {
KeyPair keyPair;
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
keyPair = keyPairGenerator.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
return keyPair;
}
}
В основном вся необходимая конфигурация у нас есть, но зависимость spring-security-oauth2-authorization-server
также в обязательном порядке требует бин описания конфигурации самого OAuth2 сервера. Для этого мы объявим бин authorizationServerSettings
и укажем в нем пока единственный параметр issuer
— это корневой URL адрес нашего SSO сервера. Я не особо люблю такие параметры оставлять в коде, поэтому вынесем этот URL в application.yml
и укажем его через проперти класс AuthorizationServerProperties
. AuthorizationServerProperties
— это банальный класс аннотированный при помощи аннотации @ConfigurationProperties
, и описывающий параметры с определенным префиксом из application.yml
файла.
AuthorizationServerProperties.class
@Setter
@Getter
@Configuration
@ConfigurationProperties(prefix = "spring.security.oauth2.authorizationserver")
public class AuthorizationServerProperties {
private String issuerUrl;
private String introspectionEndpoint;
}
application.yml
server:
port: 7777
logging:
level:
root: DEBUG
org.apache.tomcat.util.net.NioEndpoint: ERROR
sun.rmi: ERROR
java.io: ERROR
javax.management: ERROR
spring:
application:
name: j-sso
security:
oauth2:
authorizationserver:
issuer-url: http://localhost:7777
На этом самая простая конфигурация сервера авторизации закончена. Можно собирать и запускать наш j-sso. После успешного запуска у нас доступна форма логина в нашем j-sso при переходе на /login
. А также доступны эндпоинты OAuth2 Authorization Server и соответственно все 3 типа получения OAuth2 токенов, описанные в спецификации The OAuth 2.1 Authorization Framework. Да, вы не ошиблись, spring-security-oauth2-authorization-server
версии 1.x.x
поддерживает именно OAuth2.1, а не OAuth2.0. Поэтому, не ищите в SSO password grant type, его не существует по умолчанию. Думаю, в дальнейших статьях мы посмотрим, как можно создать собственную реализацию password grant type и внедрить её в наш j-sso, но в этой статье этого делать не будем, ограничимся тем что есть. Ниже приведены все доступные примеры методов авторизации через наш j-sso.
Получение токенов методом authorization code flow:
Выполняем запрос /authorization
:
curl --location --request GET 'http://localhost:7777/oauth2/authorize?response_type=code&client_id=test-client&redirect_uri=http://localhost:5000/code'
Вот так он будет выглядеть, если вы его выполните в браузере
Запрос authorization
Далее нас перенаправит на страницу логина, в которой мы введем логин/пароль и нажмём Sign In
.
Стандартная форма аутентификации Spring Security
После этого выполнится POST запрос на эндпоинт /login
, и нас опять перенаправит на первый запрос. Как работает эта магия, будет описано во втором разделе этой статьи.
POST запрос аутентификации
Повторное выполнение запроса authorization, и в заголовке ответа Location
можно увидеть код авторизации.
Повторное выполнение запроса authorization
Как можно увидеть на скриншоте, последний запрос нас отправляет на страницу клиента с кодом авторизации. Берём этот код и выполняем запрос на получение токенов с параметром grant_type
равным authorization_code
и параметром code
, в который и помещаем полученное значение кода авторизации. После чего у нас есть access
и refresh
токены.
curl --location --request POST 'http://localhost:7777/oauth2/token?grant_type=authorization_code&code=M6MsgrcmEa6eKlslkgDoS3mEOSuNoN827eLFUu6-k2Vi1v-xW17it7ojPC6QXbnjVsvCVCvfkIWNRq8kmMZBcPcre2R2N9AvNSxwLCMIiO0q4SRjWcoYrOFztvputvxS&redirect_uri=http://localhost:5000/code' \
--header 'Authorization: Basic dGVzdC1jbGllbnQ6dGVzdC1jbGllbnQ='
Пример запроса/ответа из Postman
Получение токена доступа по коду авторизации
Обратите внимание, что у нас обязательно должен быть заголовок Authorization
с типом Basic
, в котором находится base64 строка следующего вида: test-client: test-client. Это наши clientId и clientSecret, которые мы указывали при создании RegisteredClient
.
Обновление токена:
Мы также можем обновить токен, выполнив следующий запрос:
curl --location --request POST 'http://localhost:7777/oauth2/token?grant_type=refresh_token&refresh_token=W8jsk970AG8p9oYjJ_mlT0Fgf-VWjEemcmXW9hvvcvgj_D3Rc_yfrDu5Dxm4C6ccUP5sZQY6eAjQOSTOuSPln0dNkf-9nXC7UcAN084T1bfBsUHO05ICszNAy2Az4sai' \
--header 'Authorization: Basic dGVzdC1jbGllbnQ6dGVzdC1jbGllbnQ='
Также используется заголовок Authorization
, как и в запросе выше.
Пример запроса/ответа из Postman
Обновление токена доступа
Client Credentials авторизация:
Для получения токена доступа с grant_type равным client_credentials, выполните запрос ниже.
curl --location --request POST 'http://localhost:7777/oauth2/token?grant_type=client_credentials' \
--header 'Authorization: Basic dGVzdC1jbGllbnQ6dGVzdC1jbGllbnQ='
Пример запроса/ответа из Postman
Получение токена Client Credentials
На этом простейшая конфигурация нашего 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 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
LOGIN PAGE
home.vue
HOME PAGE
{{ tokenInfoString }}
Также создадим файл login-service.js
, в нем опишем всю необходимую логику авторизации и получения информации о токене.
login-service.js
import axios from "axios";
const serverUrl = process.env.VUE_APP_OAUTH_URL;
axios.defaults.baseURL = serverUrl;
const clientId = process.env.VUE_APP_OAUTH_CLIENT_ID;
const authHeaderValue = process.env.VUE_APP_OAUTH_AUTH_HEADER;
const redirectUri = process.env.VUE_APP_OAUTH_REDIRECT_URI;
const ACCESS_TOKEN_KEY = "access_token";
export default {
// делаем первичный запрос на авторизацию через j-sso
login() {
let requestParams = new URLSearchParams({
response_type: "code",
client_id: clientId,
redirect_uri: redirectUri,
scope: 'read.scope write.scope'
});
window.location = serverUrl + "/oauth2/authorize?" + requestParams;
},
// После успешного получения кода авторизации, делаем запрос на получение access и refresh токенов
getTokens(code) {
let payload = new FormData()
payload.append('grant_type', 'authorization_code')
payload.append('code', code)
payload.append('redirect_uri', redirectUri)
payload.append('client_id', clientId)
return axios.post('/oauth2/token', payload, {
headers: {
'Content-type': 'application/url-form-encoded',
'Authorization': authHeaderValue
}
}
).then(response => {
// получаем токены, кладем access token в LocalStorage
console.log("Result getting tokens: " + response.data)
window.sessionStorage.setItem(ACCESS_TOKEN_KEY, response.data[ACCESS_TOKEN_KEY]);
})
},
// получение информации о токене
getTokenInfo() {
let payload = new FormData();
// достаем из LocalStorage наш access token и помещаем его в параметр `token`
payload.append('token', window.sessionStorage.getItem(ACCESS_TOKEN_KEY));
return axios.post('/oauth2/token-info', payload, {
headers: {
'Authorization': authHeaderValue
}
});
}
}
Как вы можете заметить, я вынес все необходимые константы в .env файл, а именно в .env.development
.
.env.development
VUE_APP_OAUTH_REDIRECT_URI=http://127.0.0.1:8080/code
VUE_APP_OAUTH_CLIENT_ID=test-client
VUE_APP_OAUTH_AUTH_HEADER=Basic dGVzdC1jbGllbnQ6dGVzdC1jbGllbnQ=
VUE_APP_OAUTH_URL=http://localhost:7777
Стоит обратить внимание на параметр redirect_uri
. Он обязательно должен совпадать с одноимённым параметром и в нашем бине registeredClientRepository()
.
Как вы могли заметить, домашнюю страницу и страницу логина мы создали, но в redirect_uri
мы указываем путь /code
, для которой у нас нет страницы. Да, все верно, мы не будем для этого сейчас делать страницу, нам достаточно достать код авторизации из запроса и сделать запрос на получение токенов. Поэтому, я просто сделаю эту обработку в beforeEach
хуке нашего роутера.
router/index.js
import {createRouter, createWebHistory} from 'vue-router'
import Home from '../views/home.vue'
import Login from '../views/login.vue'
import LoginService from "@/services/login-service";
const routes = [
{
path: '/',
name: 'home',
component: Home
},
{
path: '/login',
name: 'login',
component: Login
}
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
});
router.beforeEach((to, from, next) => {
// если путь равен /code, то пытаемся достать параметр code из запроса, запросить токены, и после их получения
// сделать переход на домашнюю страницу
if (to.path === '/code' && to.query.code != null) {
LoginService.getTokens(to.query.code).then(() => {
next({name: 'home'});
});
} else {
next()
}
});
export default router
Итак, наш клиент готов. Запускаем и проверяем. При переходе на http://localhost:8080
мы сразу попадаем на страницу логина, так как у нас нет access token. Нажимаем на кнопку login, у нас открывается форма логина j-sso. Вводим данные, и нас перенаправляет на наш test-client, он получает код авторизации иии… у нас ошибка.
Access to XMLHttpRequest at 'http://localhost:7777/oauth2/token' from origin 'http://127.0.0.1:8080' has been blocked by CORS policy: Response to preflight request doesn’t pass access control check: Redirect is not allowed for a preflight request.
Нас не пускают на j-sso, так как он находится на другом домене, а мы пытаемся выполнить кросс-доменный запрос. Значит нам надо настроить CORS.
Cross-origin resource sharing — технология современных браузеров, которая позволяет предоставить веб-страницам доступ к ресурсам другого домена.
Для этого создадим на нашем j-sso отдельный класс CORSConfig
и объявим в нем бин corsFilter
.
CORSConfig.java
@Slf4j
@Configuration
public class CORSConfig {
@Bean
public FilterRegistrationBean corsFilter() {
log.debug("CREATE CORS FILTER");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
// Указываем список адресов для которых разрешены кросс-доменные запросы
config.addAllowedOrigin("http://127.0.0.1:8080,http://localhost:8080");
config.addAllowedHeader(CorsConfiguration.ALL);
config.addExposedHeader(CorsConfiguration.ALL);
config.addAllowedMethod(CorsConfiguration.ALL);
source.registerCorsConfiguration("/**", config);
FilterRegistrationBean bean = new FilterRegistrationBean<>(new CorsFilter(source));
bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
return bean;
}
}
Перезапустим наш j-sso и заново инициируем авторизацию на test-client. Теперь нас перебрасывает на страницу авторизации нашего j-sso. Вводим логин и пароль, авторизуемся. Если логин и пароль мы ввели правильные, то нас перенаправит на http://localhost:8080/code
, и в параметрах запроса будет находиться наш authorization code. Далее, мы делаем сразу запрос на получение токенов, получаем access token, и после этого мы переходим на http://localhost:8080/
, где отображается информация о нашем access token. Ниже показано, как будет выглядеть успешный результат авторизации:
Успешный результат авторизации
Теперь, когда у нас есть полное demo приложение с клиентом и сервером, давайте окунемся во вселенную Spring и посмотрим на весь процесс изнутри.
Все действо начинается с того что клиент посылает GET запрос следующего вида на наш j-sso. В ответ мы получаем статус 302 и в заголовке Location
видим, что нас перенаправляет на форму логина j-sso.
Запрос начала авторизации
В этот момент, j-sso приняв данный запрос видит, что у него нет авторизованной сессии и нас перенаправляет на страницу логина. Но это не все, давайте посмотрим на наш Security Filter Chain и разберемся, что же там происходит.
Список фильтров Spring Security участвующих в запросе
Security filter chain: [
DisableEncodeUrlFilter
WebAsyncManagerIntegrationFilter
SecurityContextHolderFilter
HeaderWriterFilter
CsrfFilter
LogoutFilter
OAuth2AuthorizationRequestRedirectFilter
OAuth2LoginAuthenticationFilter
UsernamePasswordAuthenticationFilter
DefaultLoginPageGeneratingFilter
DefaultLogoutPageGeneratingFilter
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
AnonymousAuthenticationFilter
ExceptionTranslationFilter
AuthorizationFilter
]
Тут конечно очень много всяких фильтров, детально по каждому проходить сейчас мы не будем, нас интересуют 3 последние фильтра. Оказывается, там происходит очень важная Spring магия.
Ниже показан класс AnonymousAuthenticationFilter в моменте обработки запроса:
Класс AnonymousAuthenticationFilter
Класс AnonymousAuthenticationFilter
AnonymousAuthenticationFilter
добавляет создание AnonymousAuthenticationToken
через установку Supplier в securityContextHolderStrategy
, как метод получения security контекста. Если мы внимательно посмотрим на метод defaultWithAnonymous
, то увидим, что в нем происходит проверка на существование объекта аутентификации, и если он отсутствует, то создается AnonymousAuthenticationToken
и устанавливается в security context, как текущий объект аутентификации.
Далее ExceptionTranslationFilter
пока просто пропускает дальше запрос по цепочке фильтров, но стоит заметить, что это он делает в блоке try catch.
Класс ExceptionTranslationFilter
Класс ExceptionTranslationFilter
Далее в игру вступает AuthorizationFilter
, который проверяет объект аутентификации.
Класс AuthorizationFilter
Класс AuthorizationFilter
Для этого он берет текущий securityContextHolderStrategy
и получает из него контекст, в этот момент начинает выполняться наш Supplier, который был установлен в AnonymousAuthenticationFilter
. Соответственно, в качестве объекта аутентификации мы получаем AnonymousAuthenticationToken
. Далее при помощи authorizationManager он проверяет данный объект аутентификации и выносит решение AuthorizationDecision
, как мы видим, он конечно же не проходит проверку и генерируется AccessDeniedException
.
И тут мы возвращаемся к нашему ExceptionTranslationFilter
в тот самый try catch, на который я просил обратить внимание.
Класс ExceptionTranslationFilter (блок catch)
Класс ExceptionTranslationFilter
И вот тут происходит очень важный момент при обработке этого исключения. Он проходит до метода handleAccessDeniedException
, в котором мы можем видеть, что если объект аутентификации является anonymous, то выполняется метод говорящий сам за себя sendStartAuthentication
. То есть, если мы посмотрим в реализацию AuthenticationTrustResolverImpl
, то увидим банальную проверку объекта аутентификации, что он является реализацией класса AnonymousAuthenticationToken
.
Метод handleAccessDeniedException класса ExceptionTranslationFilter
Далее, в sendStartAuthentication
мы видим следующую строчку this.requestCache.saveRequest(request, response);
. Это значит, что пришедший к нам запрос сохранился в кэше. То есть простыми словами, Spring Security видит, что у него нет авторизованной сессии, приостанавливает выполнение текущего запроса, сохраняя его в кэш, и запускает процесс аутентификации. Выполняя метод commence()
, он запускает выполнение AuthenticationEntryPoint
по умолчанию, а это LoginUrlAuthenticationEntryPoint
, в котором и прописан редирект на страницу логина.
Метод sendStartAuthentication класса ExceptionTranslationFilter
Таким «незамысловатым» путем, у нас в браузере отображается страница логина. В добавок к этому у нас выставлена JSESSIONID кука, и сохранен изначальный запрос в request cache.
Теперь вводим логин и пароль, нажимаем кнопку Sign In
. Посмотрим в консоль браузера и увидим, что там выполняется POST запрос на endpoint /login
, а в ответ мы получаем ответ с кодом 302, в хедере Location
мы видим тот самый наш первый запрос.
POST запрос аутентификации
Чтобы понять, как из request cache наш запрос «перекочевал» в хедер Location
, посмотрим на SavedRequestAwareAuthenticationSuccessHandler
.
Класс SavedRequestAwareAuthenticationSuccessHandler