Список статей этой серии
Часть 1: Строим свой SSO сервер используя Spring Authorization Server
Часть 3: Строим свой SSO. Часть 3: Redis, Swagger, Vue.js
Вступление
Всем привет, мы продолжаем строить собственный SSO Server. В предыдущей статье мы разобрали:
Построение Resource Server для нашего SSO.
Подключили СУБД PostgreSQL к проекту и настроили механизмы раската схемы БД.
Создали объектно-реляционное отображение таблиц БД и сделали необходимые правки по коду.
Рассмотрели три типа ролевой модели и добавили ее в проект.
Создали роли и привилегии, протестировали механизмы разграничения доступа Spring Security.
Из запланированного (см. введение предыдущей статьи) нам осталось:
Подключить Redis
Настроить Swagger
Создать кастомизированный интерфейс для
j-sso
с использованиемVue.js
В этой статье мы разберем эти три пункта. Приступим!
Раздел 3.1: Добавляем Redis
Прежде чем мы перейдем к разбору подключения необходимо поговорить о том, зачем мы вообще добавили техническое требование о подключении Redis-а как кэш хранилища. Конечно, из названия этого требования и так понятно, что это нужно для хранения кэша. Но зачем нам нужен этот кэш в Redis-е и что конкретно там будет храниться? Для ответа на данный вопрос стоит вспомнить три принципа построения информационных систем:
Надежность - система должна продолжать работать корректно (осуществлять нужные функции на требуемом уровне производительности) даже при неблагоприятных обстоятельствах (в случае аппаратных или программных сбоев либо ошибок пользователя).
Масштабируемость - должны быть предусмотрены разумные способы решения возникающих при росте системы проблем (объемов данных, трафика или сложности).
Удобство сопровождения - необходимо обеспечить возможность эффективной работы с системой множеству различных людей (разработчикам и обслуживающему персоналу, занимающимся как поддержкой текущего функционирования, так и адаптацией системы к новым сценариям применения).
В понятие надежность также входит понятие устойчивость к аппаратным сбоям. Чтобы было понятно, примером этого могут быть фатальные сбои винчестеров, появление дефектов ОЗУ, отключение электропитания, выход из строя виртуалки, отключение кем-то не того сетевого кабеля и т.д. А в понятие масштабируемость входит понятие горизонтальная масштабируемость, как средство решения проблем при возросшей нагрузке. Для обоих случаев простейшим решением становиться избыточность экземпляров запущенных приложений. Это означает, что наш j-sso
в продуктивной среде будет запущен как минимум в двух, а то и более экземплярах (см. схему ниже).
Соответственно, когда мы это поняли, вспомним, что j-sso
, а именно Spring Security и ижи с ними, внутри себя хранит токены доступа и сессии авторизовавшихся пользователей. Если у нас несколько экземпляров j-sso
, то на каком из них буду храниться эти данные в определенный момент времени? И во вторых, на какой экземпляр мы попадем после балансировщика нагрузки?
Все правильно, на данный момент эти данные будут "размазаны" по всем экземплярам, а предсказать на какой экземпляр попадет запрос невозможно. Поэтому у нас и появилась задача сложить эти данные в какое-нибудь In Memory Data Grid хранилище, а именно в Redis. Конечно, мы могли бы использовать и другие IMDG хранилища, но так как я на большинстве проектов его использую, а Spring предоставляет всеобъемлющую поддержку работы с Redis, то я выбрал его.
Итак, давайте уже подключим этот Redis и заставим Spring Security работать с ним.
Первым делом добавим в наш docker-compose.yml
файл сервис j-redis
, чтобы было с чем тестироваться.
docker-compose.yml
version: '3.3'
services:
// .....
j-redis:
image: redis:7.2-rc2
restart: always
command: redis-server --save 20 1 --loglevel warning --requirepass qwerty12345678
ports:
- '6379:6379'
networks:
- j-network
// .....
Далее, нам понадобятся следующие зависимости:
spring-boot-starter-data-redis
- нужна для организации подключенияj-sso
к Redis.spring-session-data-redis
- нужна, чтобы заставить хранить данные Spring Session в Redis
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-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
</dependencies>
// .....
</project>
И наконец, достаточно добавить следующие настройки в наш application.yml
. После чего можно проверять:
application.yml
spring:
application:
name: j-sso
session:
timeout: 1800 # Время жизни сессии (в секундах)
redis:
flush-mode: on_save # Указываем, когда изменения сеанса записываются в хранилище (immediate или on_save)
namespace: j-sso:session # Пространство имен для ключей, используемых для хранения сессий.
save-mode: on_set_attribute # Определяет в какой момент происходит сохранение изменений сессии (on_set_attribute, on_get_attribute, always)
data:
redis: # Настраиваем подключение к Redis
client-type: lettuce # Указываем тип клиента Redis (jedis или lettuce)
database: 0 # Указываем номер БД Redis
host: localhost # Хост подключения
port: 6379 # Порт подключения
password: qwerty12345678 # Пароль подключения
lettuce: # Настраиваем пул подключений клиента lettuce
pool:
max-active: 16 # Максимальное количество подключений в пуле
max-idle: 8 # Минимальное количество "idle" подключений в пуле
Все настройки достаточно простые, для получения дополнительных сведений можно обратиться к документации. Параметры Spring Session смотрите здесь. Параметры Spring Data Redis смотрите здесь.
Единственное, можно обсудить почему lettuce
, а не jedis
. Во-первых, потому что он используется по умолчанию (данный клиент поставляется в составе зависимости spring-session-data-redis
). А во-вторых, в нашем приложении нет каких-то весомых причин не использовать или использовать lettuce
. На самом деле jedis
точно также мог быть использован, как и lettuce
.
Lettuce — это полностью неблокирующий Java-клиент Redis. Он поддерживает как синхронную, так и асинхронную связь. Его сложные абстракции позволяют легко масштабировать продукты.
Jedis — это клиентская библиотека внутри Redis, разработанная для повышения производительности и простоты использования. Он предлагает меньше функций, но по-прежнему может обрабатывать большие объемы памяти.
По факту, клиент lettuce
, более продвинутый за счет поддержки асинхронной связи и возможности его использования в реактивных приложениях. Но он точно также может быть использован в более простых приложениях с использованием синхронной связи с Redis.
Теперь запустим приложение (не забудьте запустить postgresql
и redis
). Далее, пройдем аутентификацию любым способом и заглянем в хранилище Redis.
Как видно, под ключом с идентификатором нашей сессии (это значение куки JSESSIONID
) находится ряд данных, среди которых есть Spring Security Context. Это означает, что все корректно сработало и теперь данные наших сессий хранятся в Redis. К тому же, если вы перезагрузите j-sso
, то данные не потеряются и все активные сессии останутся активными. Учитывайте это при дальнейшей работе с проектом. Из-за этого часто разработчики некорректно тестируют свои решения или ищут часами ошибки в коде, а надо всего лишь очистить Redis.
Теперь, мы можем запустить j-sso
в нескольких экземплярах и не беспокоиться о данных, так как на всех инстансах приложений они будут доступны. Плюсом ко всему вышесказанному, у нас появляется множество возможностей работы с данными сессий пользователей, которые нам пригодятся в дальнейшем.
На этом подключение Redis к проекту завершено.
Исходники данного раздела смотрите здесь.
Раздел 3.2: Поговорим о Swagger
Swagger — это набор инструментов для разработчиков API от SmartBear Software и бывшая спецификация, на которой основана спецификация OpenAPI.
По факту, у нас есть:
OpenAPI 3 - спецификация, на основе которой можно задокументировать наши контроллеры и их endpoint-ы. OpenAPI Specification
Swagger UI - это утилита предоставляющая интерфейс, генерируемый на основе OpenAPI. Он предоставляет возможности просмотра и взаимодействия с endpoint-ами сервисов.
Данные инструменты нам позволят легко и без лишних телодвижений документировать наш API, а также его тестировать. При этом не используя никакого больше стороннего ПО.
Для подключения Swagger в Spring Boot приложение имеется две библиотеки: Springfox и Springdoc. Springfox считается устаревшей, поэтому берем Springdoc.
Данная библиотека очень грамотно построена, она состоит из нескольких артефактов, каждый из которых отвечает за свою часть. Ниже представлена схема артефактов из их документации. Обратите внимание, что мы рассматриваем версию 2.x.x
. Версия 1.7.х
не будет работать со Spring Boot 3.
Как видно из данной схемы, springdoc имеет артефакты, которые предназначены для работы как в приложениях Spring MVC, так и артефакты для реактивных приложений с WebFlux. Нам нужны те что справа, так как мы не используем WebFlux. Также, springdoc разделяет свои артефакты на те, что предоставляют механизмы генерации документации, на основе наших контроллеров, по спецификации OpenAPI. А также артефакты, которые предоставляют вместе с этим ещё и Swagger UI, для интерактивного отображения этой документации. Соответственно, так как сейчас мы будем подключать все в j-sso
, то нам понадобиться добавить следующую зависимость в наш 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">
// ...
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.1.0</version>
</dependency>
// ...
</project>
Теперь необходимо создать бин класса OpenAPI
в нашем RootConfig
. В нем мы объявим имя, описание и версию нашего сервиса. Эта информация будет выведена на странице документации Swagger.
RootAppConfig.java
@Slf4j
@Configuration
@RequiredArgsConstructor
@EnableJpaRepositories(basePackages = "ru.dlabs.sas.example.jsso.dao.repository")
public class RootAppConfig {
// ....
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("Наш прекрасный SSO")
.description("Некое описание этого SSO")
.version("0.0.1")
);
}
}
Теперь, если запустить наш j-sso
то у нас есть два endpoint-а.
http://localhost:7777/swagger-ui/index.html
- по нему будет доступен Swagger UIhttp://localhost:7777/v3/api-docs
- по нему будет доступна спецификация OpenAPI нашего сервиса в json формате
Единственное, наш SSO попросит вначале пройти аутентификацию. Это не совсем удобно, так как Swagger UI это инструмент, который необходим при разработке или тестировании, и никакого отношения к пользователю SSO не имеет. Поэтому, вынесем его endpoint-ы из-под механизмов Security. Для этого, изменим SecurityConfig
и AuthorizationServerConfig
следующим образом:
SecurityConfig.java
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
@Configuration(proxyBeanMethods = false)
public class SecurityConfig {
// ...
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
// ...
http.authorizeHttpRequests(authorize ->
authorize
// endpoint-ы swagger вынесем из под security
.requestMatchers(
"/v3/api-docs",
"/swagger-ui/**",
"/v3/api-docs/swagger-config"
).permitAll()
.anyRequest().authenticated()
);
return http.formLogin(withDefaults()).build();
}
}
AuthorizationServerConfig.java
@RequiredArgsConstructor
@Configuration(proxyBeanMethods = false)
public class AuthorizationServerConfig {
// ....
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception {
// .....
RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();
http.securityMatcher(endpointsMatcher)
.authorizeHttpRequests(authorize ->
authorize
// endpoint-ы swagger вынесем из под security
.requestMatchers(
"/v3/api-docs",
"/swagger-ui/**",
"/v3/api-docs/swagger-config"
).permitAll()
.anyRequest().authenticated()
)
/*...*/;
return http.build();
}
}
После этого, можно открыть страницу Swagger UI без всякой аутентификации.
Несмотря на то, что с параметрами по умолчанию все работает, давайте на них посмотрим и укажем в application.yml
. Мы это сделаем чтобы было представление о возможностях кастомизации. Весь список параметров можно найти здесь.
application.yml
// ...
springdoc:
swagger-ui:
enabled: true
use-root-path: true
path: /swagger-ui
doc-expansion: none
config-url: /v3/api-docs/swagger-config
disable-swagger-default-url: true
operationsSorter: method
tagsSorter: alpha
url: /v3/api-docs
try-it-out-enabled: true
api-docs:
enabled: true
path: /v3/api-docs
// ...
swagger-ui.use-root-path
- выставим true, чтобы swagger-ui был доступен из корня приложенияswagger-ui.path
- кастомизируем путь до HTML страницы swagger-uiswagger-ui.doc-expansion
- этим параметром можно настроить раскрытие элементов в списках. Можно сразу раскрыть все или ничего не раскрывать.swagger-ui.config-url
- можно кастомизировать URL до конфигурации HTML страницы swagger-ui. Мы его выставим сейчас по умолчаниюswagger-ui.disable-swagger-default-url
- по умолчанию swagger-ui открывает страницу Pet Store (пример сервиса). Нам это не нужно, поэтому выключимswagger-ui.operationsSorter
- включим сортировку по HTTP методамswagger-ui.tagsSorter
- включим сортировку по алфавиту в наименованиях контроллеровswagger-ui.url
- указываем URL до спецификации OpenAPI нашего сервисаswagger-ui.try-it-out-enabled
- можно включить/выключить возможность сделать запрос из swagger-uiapi-docs.path
- можно кастомизировать путь до спецификации OpenAPI нашегоj-sso
В принципе, можно сказать, что мы подключили Swagger к проекту. Но это еще не все. Нам надо сделать так, чтобы была возможность пройти авторизацию и получить токен доступа. Это нужно для того чтобы было удобно делать запросы с формы swagger-ui. Во-вторых, у нас уже есть два сервиса (j-sso
и j-service
), а дальше будет больше. Получается, что на каждом сервисе нам надо настраивать и добавлять в сборку Swagger UI. Конечно, можно только на одном из сервисов добавить поддержку Swagger UI и в поле Explorer указывать полный путь до спецификации нужного нам сервиса. Но все это выглядит как-то не удобно.
Обычно, при разработке микросервисных решений, Swagger UI ставят только на один сервис, а именно на Gateway - это обратный прокси сервис, который можно реализовать с использованием Zuul или Spring Cloud Gateway. При этом Swagger UI можно настроить так, что на странице документации будет выводиться список доступных сервисов вместо поля "Explorer", выбирая которые будет подгружаться нужная спецификация. Хотелось бы, чтобы у нас было что-то аналогичное, но у нас нет gateway, у нас каждый сервис будет предназначен для конкретного pet-проекта и будет самостоятельным решением.
Поэтому, предлагаю сделать отдельный сервис, который будет предназначен только для запуска Swagger UI. Соответственно, создадим модуль j-swagger-ui
в нашем проекте и настроим в нем простейшее Spring Boot приложение. Далее, добавим зависимость springdoc-openapi-starter-webmvc-ui
. Далее, аналогично модулю j-sso
, настроим swagger-ui. Единственное отличие будет в том, что мы отключим поддержку генерации спецификации OpenAPI этого сервиса при помощи настройки springdoc.api-docs.enabled = false
, так как это просто не имеет смысла. Ниже показано как будет выглядеть файл application.yml
этого сервиса:
application.yml
server:
port: 7778
logging:
level:
root: INFO
org.springframework.security.web.FilterChainProxy: ERROR
logging.level.org.hibernate.SQL: INFO
com.zaxxer.hikari: ERROR
org.postgresql: ERROR
spring:
application:
name: j-swagger-ui
springdoc:
swagger-ui:
enabled: true
use-root-path: true
doc-expansion: none
disable-swagger-default-url: true
operationsSorter: method
tagsSorter: alpha
try-it-out-enabled: true
urls:
- name: SSO Server
url: http://localhost:7777/v3/api-docs
api-docs:
enabled: true
Для решения проблемы отображения нескольких сервисов в springdoc есть следующие настройки springdoc.swagger-ui.urls[0].*
. С их помощью мы можем настроить отображение документации нескольких сервисов. Теперь запустим и проверим как это работает.
После запуска мы можем наблюдать, что у нас сверху есть выпадающий список с сервисами, которые мы добавили в параметр urls
. А также наблюдаем ошибку запрета кросс-доменных запросов.
Cross-Origin Resource Sharing (CORS) — механизм, использующий дополнительные HTTP-заголовки, чтобы дать возможность агенту пользователя получать разрешения на доступ к выбранным ресурсам с сервера на источнике (домене), отличном от того, что сайт использует в данный момент. Говорят, что агент пользователя делает запрос с другого источника (cross-origin HTTP request), если источник текущего документа отличается от запрашиваемого ресурса доменом, протоколом или портом.
Для решения этой проблемы, мы должны на запрашиваемом ресурсе разрешить выполнение запросов с домена на котором расположен наш j-swagger-ui
. Для этого перейдем в j-sso
и найдем бин corsFilter
, в котором мы конфигурируем CORS. Нам достаточно добавить значение http://localhost:7778
в параметр AllowOrigins
и все заработает. Но, так как это уже вторая ситуация добавления нового ресурса в конфигурацию CORS, то думаю пора вынести настройки cors в application.yml
. Создадим класс AppProperties
в котором мы будем создавать вложенные классы с различными настройками сервиса. Например, как для CORS сейчас:
AppProperties.java
@Configuration
public class AppProperties {
@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "spring.mvc.cors")
public static class CorsProperties {
private List<CorsConfig> configs;
public static record CorsConfig(
String pattern,
String allowedOrigins,
String allowedOriginPatterns,
String allowedHeaders,
String exposedHeaders,
String allowedMethods,
Boolean allowCredentials,
Long maxAge
) {
}
}
}
Мы сейчас сделали специально именно список конфигураций, чтобы добавить возможность настраивать CORS для различных ресурсов по-разному. Для демонстрации этого, добавим соответствующие значения в application.yml
. При чем, для http://localhost:8080
и http://localhost:7778
будут отдельные конфигурации:
application.yml
// .....
spring:
application:
name: j-sso
mvc:
cors:
configs:
- pattern: /v3/api-docs
allowed-origins: "http://localhost:7778"
allowed-header: "*"
exposed-headers: "*"
allowed-methods: "*"
allow-credentials: true
- pattern: /**
allowed-origins: "http://127.0.0.1:8080,http://localhost:8080"
allowed-header: "*"
exposed-headers: "*"
allowed-methods: "*"
allow-credentials: true
// .....
Теперь изменим бин corsFilter
, так чтобы он поддерживал вышеописанные настройки:
RootAppConfig.java
@Slf4j
@Configuration
@RequiredArgsConstructor
@EnableJpaRepositories(basePackages = "ru.dlabs.sas.example.jsso.dao.repository")
public class RootAppConfig {
// .....
@Bean
public FilterRegistrationBean<CorsFilter> corsFilter() {
log.debug("CREATE CORS FILTER");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
corsProperties.getConfigs().forEach(configProps -> {
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(configProps.allowCredentials());
config.addAllowedOrigin(configProps.allowedOrigins());
config.addAllowedOriginPattern(configProps.allowedOriginPatterns());
config.addAllowedHeader(configProps.allowedHeaders());
config.addExposedHeader(configProps.exposedHeaders());
config.addAllowedMethod(configProps.allowedMethods());
source.registerCorsConfiguration(configProps.pattern(), config);
});
FilterRegistrationBean<CorsFilter> bean = new FilterRegistrationBean<>(new CorsFilter(source));
bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
return bean;
}
// ....
}
Запустим и проверим как это работает совместно с j-swagger-ui
.
Итак, все сработало как мы и хотели. Теперь давайте подключим j-service
.
Для этого, добавим зависимость springdoc-openapi-starter-webmvc-api
в его pom.xml
. Да, именно ее, так как нам больше не нужен swagger-ui в модулях j-sso
и j-service
. Поэтому, у них обоих указываем зависимость springdoc-openapi-starter-webmvc-api
вместо springdoc-openapi-starter-webmvc-ui
.
j-service/pom.xml
<?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">
// ....
<dependencies>
// ....
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
</dependency>
</dependencies>
// ....
</project>
Чуть-чуть реструктуризируем директорию config
в j-service
. Все что относится к security
вынесем в поддиректорию с одноименным названием. Добавим как и в j-sso
класс RootAppConfig
. Далее добавим CORS конфигурацию и бин OpenAPI openAPI()
с информацией о сервисе. Все то же самое мы уже делали в j-sso
, поэтому вы можете посмотреть RootAppConfig
и application.yml
в Github репозитории.
Вернемся к сервису j-swagger-ui
и добавим новый сервис в параметр urls
:
application.yml
// ....
springdoc:
swagger-ui:
enabled: true
use-root-path: true
doc-expansion: none
disable-swagger-default-url: true
operationsSorter: method
tagsSorter: alpha
try-it-out-enabled: true
urls:
- name: SSO Server
url: http://localhost:7777/v3/api-docs
- name: J Service
url: http://localhost:9001/v3/api-docs
// ....
Теперь запустим все три сервиса, откроем форму Swagger UI и видим, что в списке сервисов теперь их два и мы можем с легкостью переключаться между ними.
Итак, вроде бы теперь вообще все отлично и удобно. Но согласитесь, удобно было бы выводить информацию о времени и версии сборки из maven, а не руками менять это в java коде. Тем более в pom.xml
есть дескрипторы <name>
и <description>
, которые предназначены для указания наименования модуля и его описания. Было бы удобно все это использовать для конфигурирования бина openAPI
.
Оказывается в Spring Boot есть возможность указать в файле META-INF/build-info.properties
информацию о сборке и потом использовать ее при помощи бина BuildProperties buildProperties
. То есть мы можем при сборке создать файл build-info.properties
и поместить туда информацию из pom.xml
. Осталось решить, как это сделать. И тут разработчики Spring Boot уже за нас все придумали. В maven-плагине spring-boot-maven-plugin
, есть возможность настройки задачи build-info
, она как раз занимается созданием данного файла.
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">
<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>
<name>SSO Server</name>
<description>Единый сервис аутентификации пользователей</description>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<build-date>${maven.build.timestamp}</build-date>
<maven.build.timestamp.format>yyyy-MM-dd HH:mm</maven.build.timestamp.format>
</properties>
// ....
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<finalName>${project.name}</finalName>
</configuration>
<executions>
<!-- Подключаем задачу создания build-info.properties файла-->
<execution>
<goals>
<goal>build-info</goal>
</goals>
<configuration>
<!-- Указываем дополнительные параметры: описание и время сборки-->
<additionalProperties>
<description>${project.description}</description>
<build-date>${build-date}</build-date>
</additionalProperties>
</configuration>
</execution>
</executions>
</plugin>
// ....
</plugins>
</build>
</project>
После сборки, в директории j-sso/target/classes/META-INF
можно найти файл build-info.properties
со следующим содержимым:
build-info.properties
build.artifact=j-sso
build.build-date=2023-07-01 16\:57
build.description=Единый сервис аутентификации пользователей
build.group=ru.dlabs
build.name=SSO Server
build.time=2023-07-01T16\:57\:57.756Z
build.version=0.0.1
Теперь достаточно внедрить бин buildProperties
в наш RootAppConfig
и чуть изменить бин openAPI
.
RootAppConfig.java
@Slf4j
@Configuration
@RequiredArgsConstructor
@EnableJpaRepositories(basePackages = "ru.dlabs.sas.example.jsso.dao.repository")
public class RootAppConfig {
private final AppProperties.CorsProperties corsProperties;
private final BuildProperties buildProperties;
// .....
@Bean
public OpenAPI openAPI() {
String buildDate = buildProperties.get("build-date");
String buildInfo = "<h4>Build date: " + buildDate + "</h4>"
+ "<br>" + buildProperties.get("description");
return new OpenAPI()
.info(new Info()
.title(buildProperties.getName())
.description(buildInfo)
.version(buildProperties.getVersion())
);
}
}
Аналогично, сделаем и в j-service
. После чего, пересоберем проект и запустим все три наших сервиса. Результаты представлены на скриншотах ниже.
Вроде бы, все теперь просто прекрасно, но осталась последняя не маловажная деталь. Нам необходимо донастроить Swagger UI таким образом, чтобы была возможность тестирования наших endpoint-ов. Она конечно и сейчас есть, но нет возможности подставить заголовок Authorization
с токеном доступа. Для этого необходимо объявить бин с типом OperationCustomizer
и в нем уже настроить добавление дополнительного параметра к каждой спецификации endpoint-а. Также, сразу добавим возможность включения/отключения этого заголовка, посредством добавления параметра springdoc.auth.auth-header-enabled
и аннотации @ConditionalOnProperty
.
RootAppConfig.java
@Slf4j
@Configuration
@RequiredArgsConstructor
public class RootAppConfig {
// ....
@Bean
@ConditionalOnProperty(value = "springdoc.auth.auth-header-enabled", havingValue = "true")
public OperationCustomizer customGlobalHeaders() {
return (Operation operation, HandlerMethod handlerMethod) -> {
Parameter authorizationHeader = new Parameter()
.in(ParameterIn.HEADER.toString())
.schema(new StringSchema())
.name("Authorization")
.description("Authorization Header (Bearer or Basic)")
.required(false);
operation.addParametersItem(authorizationHeader);
return operation;
};
}
}
Запустим и посмотрим что получилось. После запуска, мы можем обнаружить следующую ошибку.
Ну конечно, мы же разрешили для swagger только получать спецификацию по пути /v3/api-docs
, а чтобы со страницы swagger можно было делать запросы придется разрешить все endpoint-ы, по средствам указания следующего шаблона /**
в CORS конфигурации. Сделав это запустим и посмотрим как оно работает.
Это конечно решило проблему с CORS, но почему у нас так и не отправляется заголовок Authorization, хотя он указан? Оказывается, swagger не разрешает в явную указывать заголовки: Content-Type
, Accept
, Authorization
. Информацию об этом можно найти в этой документации. Но стоит отметить, что сам механизм работает, и при помощи бина OperationCustomizer customGlobalHeaders()
можно установить любые глобальные параметры за исключением вышеописанных заголовков.
Так как теперь решить нашу задачу? Обратимся к документации Springdoc и посмотрим, что же там есть для авторизации. Оказывается, для решения нашей проблемы есть другой путь. Для его реализации необходимо изменить наш бин openAPI
следующим образом:
RootAppConfig.java
@Slf4j
@Configuration
@RequiredArgsConstructor
public class RootAppConfig {
@Value("${springdoc.auth.auth-header-enabled:false}")
private Boolean authHeaderEnabled;
private final AppProperties.CorsProperties corsProperties;
private final BuildProperties buildProperties;
// ...
@Bean
public OpenAPI openAPI() {
String buildDate = buildProperties.get("build-date");
String buildInfo = "<h4>Build date: " + buildDate + "</h4>"
+ "<br>" + buildProperties.get("description");
Components components = new Components();
List<SecurityRequirement> securityRequirements = new ArrayList<>();
// добавляем возможность указывать Authorization header
if (authHeaderEnabled) {
String securitySchemeName = "Authorization header (Bearer)";
components.addSecuritySchemes(securitySchemeName,
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
);
securityRequirements.add(new SecurityRequirement().addList(securitySchemeName));
}
return new OpenAPI()
.components(components)
.security(securityRequirements)
.info(new Info()
.title(buildProperties.getName())
.description(buildInfo)
.version(buildProperties.getVersion())
);
}
}
Теперь запустим и увидим, что у нас появилась кнопка Authorize
. При нажатии на нее у нас откроется модальное окно для ввода Bearer
токена.
Получим токен при помощи нашего test-client
и проверим как работает весь настроенный нами механизм.
Скриншот выше демонстрирует успешную работу Authorization
заголовка.
Ну на этот раз все просто прекрасно и жизнь удалась, но согласитесь, что это не совсем удобно, получать токен при помощи стороннего ПО, а потом добавлять его сюда. Хочется все настроить в одном месте, так чтобы по кнопке Authorize
можно было пройти полноценную аутентификацию и получить access токен. Копавшись в документации Swagger и Springdoc, я увидел, что и это возможно реализовать.
Для того чтобы добавить механизмы авторизации по протоколу OAuth 2 на форму Swagger UI, нам необходимо опять изменить наш бин openAPI
. В него мы добавим дополнительные объекты SecurityScheme
с типом SecurityScheme.Type.OAUTH2
. Да, мы сразу добавим несколько типов авторизации:
Authorization code flow
Client Credentials Flow
Ниже представлен обновленный бин openAPI
:
RootAppConfig.java
@Slf4j
@Configuration
@RequiredArgsConstructor
public class RootAppConfig {
private final AppProperties.CorsProperties corsProperties;
private final AppProperties.SwaggerProperties swaggerProperties;
private final BuildProperties buildProperties;
// ....
@Bean
public OpenAPI openAPI() {
String buildDate = buildProperties.get("build-date");
String buildInfo = "<h4>Build date: " + buildDate + "</h4>"
+ "<br>" + buildProperties.get("description");
Components components = new Components();
List<SecurityRequirement> securityRequirements = new ArrayList<>();
// добавляем возможность указывать Authorization header
if (swaggerProperties.getAuthTypes().authHeaderEnabled()) {
String securitySchemeName = "Authorization header";
components.addSecuritySchemes(securitySchemeName,
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
);
securityRequirements.add(new SecurityRequirement().addList(securitySchemeName));
}
// Добавим возможность OAuth2 Authorization code flow
if (swaggerProperties.getAuthTypes().authorizationCodeEnabled()) {
String securitySchemeName = "Authorization code flow";
components.addSecuritySchemes(securitySchemeName, new SecurityScheme()
.type(SecurityScheme.Type.OAUTH2)
.flows(new OAuthFlows().authorizationCode(
new OAuthFlow()
.tokenUrl(swaggerProperties.getAuthOauth().tokenUrl())
.authorizationUrl(swaggerProperties.getAuthOauth().authorizationUrl())
.refreshUrl(swaggerProperties.getAuthOauth().refreshUrl())
)));
securityRequirements.add(new SecurityRequirement().addList(securitySchemeName));
}
// Добавим возможность OAuth2 Client credentials flow
if (swaggerProperties.getAuthTypes().clientCredentialsEnabled()) {
String securitySchemeName = "Client credentials flow";
components.addSecuritySchemes(securitySchemeName, new SecurityScheme()
.type(SecurityScheme.Type.OAUTH2)
.flows(new OAuthFlows().clientCredentials(
new OAuthFlow().tokenUrl(swaggerProperties.getAuthOauth().tokenUrl())
)));
securityRequirements.add(new SecurityRequirement().addList(securitySchemeName));
}
return new OpenAPI()
.components(components)
.security(securityRequirements)
.info(new Info()
.title(buildProperties.getName())
.description(buildInfo)
.version(buildProperties.getVersion())
);
}
}
Изменения достаточно простые. Просто добавляем в компоненты новые security схемы с типом SecurityScheme.Type.OAUTH2
и настроенным объектом OAuthFlows
.
Как вы можете заметить, мы создали класс AppProperties.SwaggerProperties
по аналогии с AppProperties.CorsProperties
, чтобы все настройки нашего Swagger были в одном месте. Реализация этого класса очень простая и вы можете ее посмотреть в репозитории.
Ниже представлен обновленный, в части настроек swagger, application.yml
сервисов j-service
и j-sso
.
application.yml
// .....
springdoc:
auth-types:
auth-header-enabled: true
client-credentials-enabled: true
authorization-code-enabled: true
auth-oauth:
token-url: http://localhost:7777/oauth2/token
authorization_url: http://localhost:7777/oauth2/authorize
refresh-url: http://localhost:7777/oauth2/token
swagger-ui:
enabled: false
api-docs:
enabled: true
path: /v3/api-docs
// .....
Запустим и посмотрим что получилось. На скриншоте ниже представлено как будет выглядеть модальное окно авторизации.
В секции Authorization code flow введем client_id
и client_secret
от нашего test-client
(а именно test-client/test-client). При нажатии на кнопку Authorize
нас перенаправит на страницу логина j-sso
. Пройдем аутентификацию и получим очередную ошибку.
Данная ошибка означает, что наш клиент не поддерживает то значение параметра redirect_uri
, которое мы указали при запросе /oauth2/authorize
. Кстати, а какой мы redirect_uri
указали, и возможно ли его поменять? Обратимся к документации springdoc и найдем там параметр springdoc.swagger-ui.oauth2RedirectUrl
. В значении по умолчанию там указан путь /swagger-ui/oauth2-redirect.html
. Через этот параметр мы можем его изменить. Но менять мы его не будем, давайте просто пока в таблице БД sso.system_oauth2_clients
руками добавим значение http://localhost:7778/swagger-ui/oauth2-redirect.html
в колонке redirect_uris
и проверим.
И снова мы получаем ошибку, но другую.
Данная ошибка говорит о том, что метод аутентификации не поддерживается. Давайте заглянем в табличку sso.system_oauth2_clients
в колонку client_authentication_methods
. Сейчас там стоит значение client_secret_basic
. Это означает, что параметр client_secret
передается только в заголовке Authorization
с типом Basic
. Давайте теперь посмотрим как swagger строит запросы. Для этого в браузере откроем консоль разработчика и посмотрим на payload запроса к j-sso
.
Как видно, swagger все параметры передает через тело запроса POST. Я искал, как можно это изменить и заставить swagger передавать Authorization
заголовок, но не нашел решения. Поэтому, пойдем другим путем и добавим возможность передавать client_secret
в form-data
. Для этого опять перейдем в табличку sso.system_oauth2_clients
и добавим в колонке client_authentication_methods
значение client_secret_post
. Проверим как это будет работать.
На скриншоте выше показано выполнение запроса /test
к сервису j-service
после прохождения авторизации. В строке запроса можно видеть что Authorization
заголовок передается и также можно наблюдать 200-ый код ответа.
Итак, мы полностью настроили работу swagger, но остался маленький штришок. Сейчас у нас везде используется в качестве клиента test-client
. Давайте для swagger добавим отдельный клиент с его собственными настройками. Для этого, перейдем в файл j-sso/database/release-1.0.0/data/system-oauth2-clients-data.sql
и добавим новый changeSet.
system-oauth2-clients-data.sql
--liquibase formatted sql
// ......
--changeSet daivanov:system-oauth2-clients-data-02
-- client_secret = swagger-client
INSERT INTO sso.system_oauth2_clients(client_id, client_secret,
client_secret_expires_at,
client_name, client_authentication_methods,
authorization_grant_types, redirect_uris,
scopes, client_settings, token_settings)
VALUES ('swagger-client', '$2a$10$G2AzJ0wujkRRbyU0hNWoNewpAfSPhg.cmo7HMT8cX9Xe6vdwmTMTW',
to_timestamp('2072-01-01', 'YYYY-MM-DD'), 'Клиент для Swagger UI',
'client_secret_post', 'authorization_code,refresh_token,client_credentials',
'http://localhost:7778/swagger-ui/oauth2-redirect.html', 'read.scope,write.scope', null, null);
Таким образом мы добавили новый клиент для нашего swagger ui. На этом настройка Swagger заканчивается, теперь мы его можем использовать как полноценный инструмент вместо Postman.
Исходники данного раздела смотрите здесь.
Раздел 3.3: Настал час интерфейса SSO
Итак, настало время поговорить о кастомизации интерфейса нашего j-sso
. В техническом требовании мы указывали, что необходимо использовать VueJS. Вот с него и начнем.
Во-первых, нам необходимо построить само VueJS приложение. Его мы расположим в директории j-sso/client
. Создадим пока что простейшее приложение по средствам vue-cli
(в конфигурации я указал использование vue 2
, scss
, vue-router
и vuex
). После этого попробуем поместить его вместо текущей формы логина j-sso
, которая доступна по умолчанию от Spring Security.
Итак, после того как vue-cli
создал нам приложение у нас в package.json
файле доступны две задачи:
serve
- запустить используя dev serverbuild
- собрать приложение
Очевидно, что из этого нас сейчас интересует именно сборка, так как именно собранные файлы нам необходимо отдавать браузеру. Давайте более детально посмотрим что это за файлы:
После выполнения команды npm run build
мы можем наблюдать, что в корневой директории клиента создалась директория dist
- это и есть собранные файлы нашего приложения. Она состоит из 3 частей:
index.html
- это главный html файл, который будет загружаться первым.директория
js
- здесь находиться файлы со всем JavaScript-ом, который строит наш UI.директория
css
- здесь находятся все стилиcss
которые у нас используются в приложении.
Как же они между собой связаны? Давайте заглянем в index.html
, ведь именно там и скрыт ответ на этот вопрос.
index.html
<!doctype html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="/favicon.ico">
<title>client</title>
<script defer="defer" src="/js/chunk-vendors.6bb41578.js"></script>
<script defer="defer" src="/js/app.5c0e5e95.js"></script>
<link href="/css/app.bc18c568.css" rel="stylesheet">
</head>
<body>
<noscript><strong>We're sorry but client doesn't work properly without JavaScript enabled. Please enable it to
continue.</strong></noscript>
<div id="app"></div>
</body>
</html>
Как мы помним из базовых уроков по html, у него есть дескриптор <head>
, который хранит некую дополнительную информацию для браузера: стили, скрипты, шрифты и т.д.
Тег предназначен для хранения других элементов, цель которых — помочь браузеру в работе с данными. Также внутри контейнера находятся метатеги, которые используются для хранения информации предназначенной для браузеров и поисковых систем. Например, механизмы поисковых систем обращаются к метатегам для получения описания сайта, ключевых слов и других данных.
Внутри этого дескриптора нас интересуют три последние строчки. В данных строчках происходит подключение JavaScript-а и css стилей из тех директорий, что мы рассмотрели выше. То есть это означает, что нам достаточно по запросу из web-сервера (коим у нас является Embedded Tomcat) отдать этот html файл браузеру и далее он самостоятельно подгрузит необходимые скрипты и стили. После чего наше frontend приложение заработает. А это в свою очередь означает что нам необходимо решить три маленькие задачи:
Создать в нашем
j-sso
контроллер и endpoint, который будет возвращатьindex.html
Необходимо, чтобы наш
j-sso
умел возвращать ресурсы (js
,css
,images
и т.д.) по соответствующим запросам изindex.html
Скорее всего придется чуть-чуть видоизменить запросы до
js
иcss
, поэтому необходимо найти возможность кастомизации данных путей при сборке frontend приложения
Решаем первую задачу
Для ее решения нам необходимо вспомнить про Spring MVC и посмотреть как при помощи данного проекта реализовать нашу задачу. Оказывается, все очень просто. Достаточно создать простейший контроллер, с методом, который будет возвращать имя html файла. Данный html файл, в свою очередь, должен быть расположен в ресурсах приложения, в директории templates
. Но, все это не будет работать без добавления зависимости spring-boot-starter-thymeleaf
. Поэтому вначале добавляем зависимость в j-sso
модуль.
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-thymeleaf</artifactId>
</dependency>
</dependencies>
// ....
</project>
Далее, создадим контроллер и поместим наш index.html
из директории client/dist
в директорию resources/templates
. Не забудем выключить данный url из security конфигурации (чтобы не мешало экспериментам).
UIController.java
@Controller
public class UIController {
@GetMapping("/ui-test")
public String index() {
return "index";
}
}
Соберем, запустим j-sso
и перейдем на URL http://localhost:7777/ui-test
. Мы конечно увидим просто белую страницу, но нас сейчас не это интересует. Нас интересует следующее: подгрузился ли наш index.html
файл? Для этого откроем консоль разработчика в браузере и посмотрим на ответ GET запроса /ui-test
. Как видно из скриншота ниже, наш index.html
успешно подгрузился. Соответственно, теперь нам нужно решить вторую задачу.
Решаем вторую задачу
Нам необходимо заставить подгружаться js
и css
файлы. По своей сути они являются статическими ресурсами, как например картинки. Соответственно, давайте просто решим задачу обслуживания статических ресурсов для Embedded Tomcat, который вшит в наше приложение j-sso
.
Из данного руководства Spring мы можем найти, что достаточно поместить файлы в одну из следующих директорий и они будут доступны по соответствующим путям:
/META-INF/resources/
/resources/
/static/
/public/
Добавим все наши остальные файлы из директории client/dist
в директорию src/main/resources/static
. Отключим механизмы Security для следующих шаблонов: /favicon.ico
, /js/**
, /css/**
.
Так как у нас стало достаточно много шаблонов запросов, которые необходимо вынести из под security, а менять сразу в двух конфигурационных классах (SecurityConfig
и AuthorizationServerConfig
) не особо удобно, то в SecurityConfig
создадим для этого константу, а в AuthorizationServerConfig
ее используем:
SecurityConfig.java
public class SecurityConfig {
public static final String[] PERMIT_ALL_PATTERNS = {
"/v3/api-docs",
"/ui-test",
"/favicon.ico",
"/js/**",
"/css/**"
};
// ....
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
SocialConfigurer socialConfigurer = new SocialConfigurer()
.oAuth2UserService(customOAuth2UserService);
http.apply(socialConfigurer);
http.getSharedObject(AuthenticationManagerBuilder.class)
.userDetailsService(userDetailService)
.passwordEncoder(passwordEncoder);
http.authorizeHttpRequests(authorize ->
authorize
// endpoint-ы которые вынесем из под security
.requestMatchers(PERMIT_ALL_PATTERNS).permitAll()
.anyRequest().authenticated()
);
return http.formLogin(withDefaults()).build();
}
}
Теперь пересоберем проект и запустим. Далее перейдем на URL http://localhost:7777/ui-test
.
На скриншоте выше мы можем видеть, что все файлы корректно загрузились и наше frontend приложение заработало.
Но это еще не все. Мне не нравится, что все статические файлы имеют URL от корня приложения, это в дальнейшем будет трудно контролировать. А также есть вероятность, что оно создаст проблемы при вертикальном масштабировании приложения. Поэтому, хочу чтобы все ресурсы имели префикс /static/**
.
В Spring Boot реализуется данное требование очень просто: достаточно добавить параметр spring.mvc.static-path-pattern
. Кстати, в Spring Boot я также нашел параметр для явного указания директории со статическими ресурсами - spring.web.resources.static-locations
.
application.yml
// ....
spring:
application:
name: j-sso
mvc:
static-path-pattern: /static/**
cors:
configs:
- pattern: /**
allowed-origins: "http://127.0.0.1:8080,http://localhost:8080,http://localhost:7778"
allowed-headers: "*"
exposed-headers: "*"
allowed-methods: "*"
allow-credentials: true
web:
resources:
static-locations: classpath:static
// ....
Не забудем выключить механизмы security на шаблоне /static/**
, и перезапустим приложение.
Конечно, теперь front у нас сломан, потому что html пытается загрузить js
и css
по старым путям (от корня), а js
и css
теперь находятся по новому пути. Попробуем получить картинку favicon.ico
по пути http://localhost:7777/static/favicon.ico
.
Как видно из скриншота выше все сработало. И теперь мы переходим к третьей задаче.
Решаем третью задачу
Для решения данной задачи нам необходимо опять окунуться в мир frontend приложений и вспомнить, что сборщиком приложения у нас выступает Webpack
. В Webpack
для решения нашей задачи существует параметр output.publicPath
(ссылка на документацию). А как его можно указать в нашем vue приложении? Для этого существует vue.config.js
. Заглянем в документацию vue-cli и посмотрим, что там сказано про параметр publicPath
.
Базовый URL-адрес сборки вашего приложения, по которому оно будет опубликовано (именуемая как baseUrl до версии Vue CLI 3.3). Это аналог опции webpack output.publicPath, но Vue CLI также использует это значение в других целях, поэтому вы должны всегда использовать publicPath вместо изменения опции output.publicPath.
Соответственно, добавим данный параметр в vue.config.js
и укажем там значение /static
.
vue.config.js
const {defineConfig} = require('@vue/cli-service');
module.exports = defineConfig({
transpileDependencies: true,
publicPath: process.env.VUE_APP_NODE_ENV !== "development" ? "/static" : "",
});
Обратите внимание, мы не просто указали путь, а поставили тернарный оператор, который ставит данный путь если переменная окружения VUE_APP_NODE_ENV
не равна development
. Это сделано для того, чтобы можно было работать с vue приложением как обычно (запуская его на node сервере). Данную переменную необходимо указать в .env
файлах. Соответственно, создадим 4 файла:
.env.development
- параметры для среды разработки.env.production
- параметры для продуктивной среды.env.testing
- параметры для среды тестирования.env.dev-java
- параметры для среды разработки (для сборки и использования в java приложении)
В них укажем пока только одну переменную VUE_APP_NODE_ENV
со значением для соответствующего файла. Теперь, мы можем получить значение данной переменной следующим образом process.env.VUE_APP_NODE_ENV
. Также изменим список задач в package.json
.
package.json
{
"name": "client",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve --mode=development",
"build": "vue-cli-service build --mode=development",
"build-prod": "vue-cli-service build --mode=production",
"build-test": "vue-cli-service build --mode=testing",
"build-dev-java": "vue-cli-service build --mode=dev-java"
}
// .....
}
Соберем наше Vue.js приложение следующей командой npm run build-prod
и посмотрим на полученный index.html
.
index.html
<!doctype html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="/static/favicon.ico">
<title>client</title>
<script defer="defer" src="/static/js/chunk-vendors.efbb0edb.js"></script>
<script defer="defer" src="/static/js/app.41afe825.js"></script>
<link href="/static/css/app.bc18c568.css" rel="stylesheet">
</head>
<body>
<noscript><strong>We're sorry but client doesn't work properly without JavaScript enabled. Please enable it to
continue.</strong></noscript>
<div id="app"></div>
</body>
</html>
Как видно из примера выше, мы получили, что хотели. Также, при запуске через npm run serve
приложение тоже будет корректно работать.
Поместим сборку frontend приложения в ресурсы нашего j-sso
. После пересборки и запуска приложения, UI будет отображаться корректно.
Автоматизация сборки
Согласитесь, не особо удобно каждый раз удалять и заново копипастить файлы в ресурсы j-sso
. Давайте попробуем автоматизировать это. Есть много разных путей реализации этого: создать bash скрипты, проводить сборку в Docker и т.д. Но я хочу показать вариант с использованием только maven плагинов. Для этого нам понадобиться два плагина:
com.github.eirslett:frontend-maven-plugin
- он нужен для выполнения npm задач. Также с его помощью можно скачать и использовать нужную версию Node и NPM без дополнительной их установки. Документацию можно посмотреть здесьorg.apache.maven.plugins:maven-resources-plugin
- данным плагином мы будем переносить файлы изclient/dist
вtarget
. Документацию можно посмотреть здесь.
Итак, приступим. Подключим данные плагины и настроим следующие задачи:
Плагин frontend-maven-plugin
:
install node and npm
- установка Node и NPM для сборки фронтаnpm install
- выполнение задачи установки зависимостей для фронтаnpm build
- сборка фронта
Плагин maven-resources-plugin
:
copy-to-templates
- задача копированияindex.html
файла изclient/dist
вtarget/classes/templates
copy-to-static
- задача копирования остальных файлов изclient/dist
вtarget/classes/static
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">
<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>
<name>SSO Server</name>
<description>Единый сервис аутентификации пользователей</description>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<build-date>${maven.build.timestamp}</build-date>
<maven.build.timestamp.format>yyyy-MM-dd HH:mm</maven.build.timestamp.format>
</properties>
// ......
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<executions>
<!-- Установка Node и NPM для сборки фронта-->
<execution>
<id>install node and npm</id>
<goals>
<goal>install-node-and-npm</goal>
</goals>
<phase>generate-resources</phase>
</execution>
<!-- Выполнение задачи установки зависимостей для фронта-->
<execution>
<id>npm install</id>
<goals>
<goal>npm</goal>
</goals>
<phase>generate-resources</phase>
<configuration>
<workingDirectory>client</workingDirectory>
<arguments>install</arguments>
</configuration>
</execution>
<!-- Сборка фронта-->
<execution>
<id>npm build</id>
<goals>
<goal>npm</goal>
</goals>
<phase>generate-resources</phase>
<configuration>
<workingDirectory>client</workingDirectory>
<arguments>run build-dev-java</arguments>
</configuration>
</execution>
</executions>
<configuration>
<nodeVersion>v16.20.1</nodeVersion>
<npmVersion>9.7.2</npmVersion>
<nodeDownloadRoot>https://nodejs.org/dist/</nodeDownloadRoot>
<npmDownloadRoot>https://registry.npmjs.org/npm/-/</npmDownloadRoot>
<installDirectory>.node</installDirectory>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<!-- Копирование index.html файла из client/dist в target/classes/templates-->
<execution>
<id>copy-templates</id>
<phase>generate-resources</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${basedir}/target/classes/templates</outputDirectory>
<resources>
<resource>
<directory>${basedir}/client/dist</directory>
<includes>
<include>index.html</include>
</includes>
</resource>
</resources>
</configuration>
</execution>
<!-- Копирование js, css, images и т.д. из client/dist в target/classes/static-->
<execution>
<id>copy-static</id>
<phase>generate-resources</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${basedir}/target/classes/static</outputDirectory>
<resources>
<resource>
<directory>${basedir}/client/dist</directory>
<includes>
<include>favicon.ico</include>
<include>js/**</include>
<include>css/**</include>
</includes>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
В принципе, этого достаточно. Теперь при сборке будет полностью собираться frontend приложение и после помещаться в ресурсы сборки java приложения, которые в последующем будут использованы при работе приложения. Но есть несколько неудобств:
Скачивать Node и npm каждый раз как будет собираться приложение не нужно. Достаточно один раз скачать.
Устанавливать зависимости frontend приложения и производить его сборку, если оно не изменялось, бессмысленно каждый раз когда пересобирается java приложение
У frontend приложения есть как минимум 3 профиля сборки: development, test и production. В Java приложении, тоже скорее всего понадобятся профили сборки. Нужно как-то синхронизировать их.
Итак, чтобы решить первую и вторую задачу, нам необходимо понять как можно включить/выключить конкретную задачу. Мы помним, что Maven запускает задачу (execution) только тогда, когда у него есть привязка к параметру build phase. То есть простыми словами, когда есть дескриптор <phase>
и в нем установлено корректное значение. Поэтому, универсальным способом включить/выключить будет смена параметра phase
у execution
. Но как мы это можем реализовать?
И тут на сцену выходят профили сборки maven и <properties>
. Создадим следующие properties
:
build-client.phrase
- будет содержать значениеphase
для задачи сборки frontend приложения. Но сборка не возможна без установки Node и NPM, если до этого они не были установлены. Также, сборка не возможна, если не была выполнена задача установки зависимостей. Давайте объединим запуск установки Node и NPM, загрузки зависимостей и сборки под один параметр. Кстати, установка Node и NPM не будет выполняться, если они уже установленны. Здесь за нас поработали разработчики плагина.copy-client-build.phrase
- будет содержать значениеphase
для задачи копирования сборки frontend приложения вtarget
директорию.client-build-command.param
- будет содержать наименование скрипта сборки frontend приложения (build-prod
,build
,build-test
)
И далее, нам осталось создать профили сборки maven, которыми мы будем указывать, что необходимо включить, а что выключить. Поэтому, создадим следующие профили сборки:
dev
- профиль сборки для среды разработки. По умолчанию активенtest
- профиль сборки для среды тестированияprod
- профиль сборки для продуктиваclient-build-and-copy
- профиль сборки, при котором произойдет полная сборка и копирование файлов сборки фронта в ресурсы сборки java приложенияclient-only-copy
- профиль сборки, при котором будет выполнено только копирование файлов сборки фронта в ресурсы сборки java приложения
Ниже представлен файл pom.xml
для j-sso
.
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">
<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>
<name>SSO Server</name>
<description>Единый сервис аутентификации пользователей</description>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<build-date>${maven.build.timestamp}</build-date>
<maven.build.timestamp.format>yyyy-MM-dd HH:mm</maven.build.timestamp.format>
<build-client.phrase>disabled</build-client.phrase>
<copy-client-build.phrase>disabled</copy-client-build.phrase>
<client-build-command.param>build-dev-java</client-build-command.param>
</properties>
// ......
<profiles>
<!-- Профиль для сборки и копирования frontend приложения-->
<profile>
<id>client-build-and-copy</id>
<properties>
<build-client.phrase>generate-resources</build-client.phrase>
<copy-client-build.phrase>generate-resources</copy-client-build.phrase>
</properties>
</profile>
<!-- Профиль для только копирования frontend приложения-->
<profile>
<id>client-only-copy</id>
<properties>
<build-client.phrase>non</build-client.phrase>
<copy-client-build.phrase>generate-resources</copy-client-build.phrase>
</properties>
</profile>
<!-- Стандартный профиль сборки для dev среды, по умолчанию активен-->
<profile>
<id>dev</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<client-build-command.param>build-dev-java</client-build-command.param>
</properties>
</profile>
<!-- Стандартный профиль сборки для test среды-->
<profile>
<id>test</id>
<properties>
<client-build-command.param>build-test</client-build-command.param>
</properties>
</profile>
<!-- Стандартный профиль сборки для production среды-->
<profile>
<id>prod</id>
<properties>
<client-build-command.param>build-prod</client-build-command.param>
</properties>
</profile>
</profiles>
<build>
<finalName>${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<executions>
<!-- Установка Node и NPM для сборки фронта-->
<execution>
<id>install node and npm</id>
<goals>
<goal>install-node-and-npm</goal>
</goals>
<phase>${build-client.phrase}</phase>
</execution>
<!-- Выполнение задачи установки зависимостей для фронта-->
<execution>
<id>npm install</id>
<goals>
<goal>npm</goal>
</goals>
<phase>${build-client.phrase}</phase>
<configuration>
<workingDirectory>client</workingDirectory>
<arguments>install</arguments>
</configuration>
</execution>
<!-- Сборка фронта-->
<execution>
<id>npm build</id>
<goals>
<goal>npm</goal>
</goals>
<phase>${build-client.phrase}</phase>
<configuration>
<workingDirectory>client</workingDirectory>
<arguments>run ${client-build-command.param}</arguments>
</configuration>
</execution>
</executions>
<configuration>
<nodeVersion>v16.20.1</nodeVersion>
<npmVersion>9.7.2</npmVersion>
<nodeDownloadRoot>https://nodejs.org/dist/</nodeDownloadRoot>
<npmDownloadRoot>https://registry.npmjs.org/npm/-/</npmDownloadRoot>
<installDirectory>.node</installDirectory>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<!-- Копирование index.html файла из client/dist в target/classes/templates-->
<execution>
<id>copy-templates</id>
<phase>${copy-client-build.phrase}</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${basedir}/target/classes/templates</outputDirectory>
<resources>
<resource>
<directory>${basedir}/client/dist</directory>
<includes>
<include>index.html</include>
</includes>
</resource>
</resources>
</configuration>
</execution>
<!-- Копирование js, css, images и т.д. из client/dist в target/classes/static-->
<execution>
<id>copy-static</id>
<phase>${copy-client-build.phrase}</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${basedir}/target/classes/static</outputDirectory>
<resources>
<resource>
<directory>${basedir}/client/dist</directory>
<includes>
<include>favicon.ico</include>
<include>js/**</include>
<include>css/**</include>
</includes>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Теперь, выполнив команду clean install -DskipTests -P dev,client-build-and-copy
мы соберем как frontend приложение, так и backend приложение нашего SSO. А поменяв профиль clean install -DskipTests -P dev,client-only-copy
мы скопируем файлы сборки frontend приложения в target
директорию и соберем java приложение. При этом, из директории resources
можно удалить те файлы сборки, которые мы добавляли вручную.
Если вы используете IntelliJ IDEA, то не забудьте изменить конфигурацию запуска, указав корректную задачу сборки. Иначе, не будет работать фронт.
Создание собственной формы логина
Раз мы разобрались, как Vue приложение использовать через Spring Boot приложение. Давайте заменим форму логина, которую нам предоставляет Spring Security на наше Vue приложение. Для этого первым шагом мы должны кастомизировать DSL метод formLogin()
в SecurityConfig
и в SocialConfigurer
. При кастомизации мы укажем loginPage
и success/failure Handlers. Ниже представлен обновленный SecurityConfig
. successHandler
и failureHandler
пока создадим те же, что используются по умолчанию, в дальнейшем они нам пригодятся.
SecurityConfig.java
public class SecurityConfig {
public static final String LOGIN_PAGE = "/login";
public static final String[] PERMIT_ALL_PATTERNS = {
LOGIN_PAGE,
"/static/**"
};
private final CustomOAuth2UserService customOAuth2UserService;
private final CustomUserDetailsService userDetailService;
private final PasswordEncoder passwordEncoder;
private final AuthenticationSuccessHandler successHandler = new SimpleUrlAuthenticationSuccessHandler();
private final AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
SocialConfigurer socialConfigurer = new SocialConfigurer()
.oAuth2UserService(customOAuth2UserService)
.successHandler(successHandler)
.failureHandler(failureHandler)
.formLogin(LOGIN_PAGE);
http.apply(socialConfigurer);
http.getSharedObject(AuthenticationManagerBuilder.class)
.userDetailsService(userDetailService)
.passwordEncoder(passwordEncoder);
http.authorizeHttpRequests(authorize ->
authorize
.requestMatchers(PERMIT_ALL_PATTERNS).permitAll()
.anyRequest().authenticated()
);
return http.formLogin(configurer -> {
// кастомизируем форму логина
configurer.loginPage(LOGIN_PAGE)
.successHandler(successHandler)
.failureHandler(failureHandler);
}).build();
}
}
Вторым шагом, кастомизируем UIController
. Это необходимо чтобы на запрос /login
была возвращена html страница нашего frontend приложения.
UIController.java
@Controller
public class UIController {
@GetMapping("/login")
public String index() {
return "index";
}
}
В результате, когда мы пересоберем и запустим наш проект, то увидим на странице логина наше frontend приложение.
Теперь, когда все основные связующие механизмы мы создали, осталось создать frontend приложение, сделать нормальную форму логина и дальше смело проектировать новые функциональности. Ниже показан скриншот новой рабочей формы логина. Сейчас мы не будем погружаться в детали построения frontend приложения, сделаем это в следующей статье. Единственное, давайте разберем логику взаимодействия с API j-sso
.
Ниже представлен основной сервис для реализации этого взаимодействия - login-service.js
.
login-service.js
import axios from 'axios';
export class LoginAPI {
__LOGIN_URL = "/login";
__OAUTH_AUTHORIZATION_URL = "/oauth2/authorization/";
__LOCATION_HEADER = process.env.VUE_APP_SSO_LOCATION_HEADER;
/**
* Вход черед логин/пароль.
* При успешной аутентификации получает в заголовках ответа специальный
* заголовок {@see process.env.VUE_APP_SSO_LOCATION_HEADER} в котором содержится URL для дальнейшего перехода
* @param username - логин
* @param password - пароль
*/
login(username, password) {
let formData = new FormData();
formData.append("username", username);
formData.append("password", password);
return axios.post(this.__LOGIN_URL, formData).then(result => {
// проверяем есть ли спец. заголовок
if (result.headers.has(this.__LOCATION_HEADER)) {
// переходим на указанный в заголовке адрес
window.location = result.headers.get(this.__LOCATION_HEADER);
}
});
}
/**
* Метод запуска процесса авторизации через Yandex, Github или Google
* @param providerName - одно из следующих значений: google, github, yandex
*/
loginWith(providerName) {
window.location = this.__getOAuthAuthorizationUrl(providerName);
}
__getOAuthAuthorizationUrl(providerName) {
return this.__OAUTH_AUTHORIZATION_URL + providerName;
}
}
export default new LoginAPI();
Вроде бы все очевидно, но есть один нюанс. Он заключается в обработке успешной аутентификации по средствам логина и пароля. При чем, он затрагивает не только frontend приложение, но и backend тоже. Нюанс заключается в том, что axios
, в силу использования XMLHttpRequest
, не позволяет вручную обрабатывать редиректы (параметр maxRedirects
- работает только для приложений node.js). А как мы помним, Spring Security в j-sso
при успешной авторизации перенаправляет нас на корень приложения. При этом, в силу особенностей реализации механизма Social Login
, данное перенаправление не создает проблем. Соответственно, дабы не придумывать кучу "велосипедов", я решил поддержать реализацию с перенаправлением.
Смысл ее заключается в том, что при аутентификации через форму мы редирект будем выполнять на frontend приложении. А URL для этого перенаправления мы будем получать через специальный заголовок HTTP ответа. Поэтому, для реализации такого подхода необходимо при настройке Spring Security указать кастомизированный AuthenticationSuccessHandler
, что я и сделал.
Ниже представлен обновленный SecurityConfig
и новый обработчик CustomAuthenticationSuccessHandler
.
SecurityConfig.java
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
@Configuration(proxyBeanMethods = false)
public class SecurityConfig {
public static final String LOGIN_PAGE = "/login";
public static final String[] PERMIT_ALL_PATTERNS = {
LOGIN_PAGE,
"/static/**"
};
private final CustomOAuth2UserService customOAuth2UserService;
private final CustomUserDetailsService userDetailService;
private final PasswordEncoder passwordEncoder;
private final AuthorizationServerProperties authorizationServerProperties;
// handlers
private AuthenticationSuccessHandler oAuth2successHandler;
private AuthenticationSuccessHandler loginRequestSuccessHandler;
private AuthenticationFailureHandler failureHandler;
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
SocialConfigurer socialConfigurer = new SocialConfigurer()
.oAuth2UserService(customOAuth2UserService)
.successHandler(oAuth2successHandler)
.failureHandler(failureHandler)
.formLogin(LOGIN_PAGE);
http.apply(socialConfigurer);
http.csrf(AbstractHttpConfigurer::disable);
http.getSharedObject(AuthenticationManagerBuilder.class)
.userDetailsService(userDetailService)
.passwordEncoder(passwordEncoder);
http.authorizeHttpRequests(authorize ->
authorize
.requestMatchers(PERMIT_ALL_PATTERNS).permitAll()
.anyRequest().authenticated()
);
return http.formLogin(configurer -> {
configurer.loginPage(LOGIN_PAGE)
.loginProcessingUrl(LOGIN_PAGE)
.successHandler(loginRequestSuccessHandler)
.failureHandler(failureHandler);
}).build();
}
@PostConstruct
private void initializeHandlers() {
// создаем кастомный AuthenticationSuccessHandler для формы логина
this.loginRequestSuccessHandler = new CustomAuthenticationSuccessHandler(
authorizationServerProperties.getAuthenticationSuccessUrl(),
authorizationServerProperties.getCustomHandlerHeaderName()
);
// указываем стандартный AuthenticationSuccessHandler для OAuth2 Client
this.oAuth2successHandler = new SimpleUrlAuthenticationSuccessHandler(
authorizationServerProperties.getAuthenticationSuccessUrl()
);
this.failureHandler = new SimpleUrlAuthenticationFailureHandler();
}
}
CustomAuthenticationSuccessHandler.java
@Slf4j
@RequiredArgsConstructor
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final String locationUrl;
private final String headerName;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("Authentication success");
response.setHeader(headerName, locationUrl);
}
}
Таким образом после пересборки и запуска j-sso
, мы получаем следующий результат:
На этом все, нами выставленные, технические требования, мы выполнили.
Исходники данного раздела смотрите здесь.
Резюме
Итак, мы проделали очень большую работу и в плотную подошли к дальнейшей реализации функциональных требований. Мы полностью выполнили все технические требования. Ниже перечислим их для большей наглядности:
Использование непрозрачных токенов
Использование последних версий Spring Boot и Spring Authorization Server
Использование Java 17
Использование SPA Vue.JS приложения в качестве фронта SSO
Использование Redis в качестве кэш хранилища (хранение токенов и т.д.)
Использование PostgreSQL в качестве основного хранилища
Подключить Swagger и настроить там авторизацию
В следующей статье мы детально разберем построение frontend приложения модуля j-sso
. Поговорим о его стеке используемых библиотек. И приступим к реализации оставшихся функциональных требований:
Регистрация пользователей через отдельную форму регистрации на SSO
Реализация функции "Забыли пароль" (это новое требование)
Возможность управления выданными токенами (отзыв токена, просмотр активных сессий и т.д.)
Полезные ссылки
Исходники смотрите здесь
Redis image. Docker Hub
Документация Spring Data Redis
Документация Spring Session
Документация Springdoc
Документация Swagger OpenAPI
Документация Spring MVC (Serving Web Content with Spring MVC)
Tutorial Spring Boot - Thymeleaf
Репозиторий/документация frontend-maven-plugin
Документация maven-resources-plugin
Документация Webpack
GotoPhone
Респект за такой подробный разбор. Немного ковыряю Защиту весны, тут прям есть то что можно подглядеть. Хотя для этого в реальных проектах мне ещё долго не видать. Спасибо за статьи