Вступление

Всем привет, и мы продолжаем нашу серию статей по созданию собственного SSO. В предыдущей статье мы создали замечательную основу приложения и проработали множество нюансов связанных как с Spring Authorization Server, так и с Spring Security, Spring OAuth2 Client. Подключили "Social login" через Google, Github и Yandex. Теперь настало время превращения этого SSO в реальный проект.

Но, прежде чем мы окунёмся в мир Spring, давайте вспомним, какие требования у нас были и посмотрим, что же нам осталось сделать:

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

Требование

Статус

Комментарий

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

Выполнено

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

Выполнено

На текущий момент они слегка поднялись: Spring Boot 3.1.1 и Spring Authorization Server 1.1.1. Мы их поднимем в конце данной статьи.

Использование Java 17

Выполнено

Использование SPA Vue.JS приложения в качестве фронта SSO

Нет

Использование Redis в качестве кэш хранилища (хранение токенов и т.д.)

Нет

Использование PostgreSQL в качестве основного хранилища

Нет

Подключить Swagger и настроить там авторизацию

Нет

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

Требование

Статус

Комментарий

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

Выполнено

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

Выполнено

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

Выполнено частично

Сам механизм авторизации есть на SSO. Мы его демонстрировали при помощи vuejs клиента (test-client). Но необходимо также показать его работу с использованием OAuth2 Resource Server

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

Выполнено

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

Выполнено

Регистрация пользователей через отдельную форму регистрации на SSO

Нет

Возможность управления выданными токенами (отзыв токена, просмотр активных сессий и т.д.)

Нет

В этой статье мы сосредоточимся на выполнении оставшихся технических требований, так как для реализации функциональных требований необходимо превратить наше демо приложение в полноценное, удобное в эксплуатации, масштабируемое решение.

Раздел 2.1: Скрестим наш SSO с сервисом на основе OAuth2 Resource Server

В предыдущей статье мы построили SSO сервер на основе Spring Authorization Server, в котором introspection endpoint помимо информации о токене также возвращает информацию о пользователе. В Spring Security есть подпроект, который позволяет быстро создать приложение делегирующее управление своими привилегиями (authorities) серверу авторизаций, такому каким сейчас у нас является наш SSO. Давайте создадим такое приложение и подружим его с нашим сервером авторизаций.

Создадим новый модуль в нашем проекте: j-service. Он и будет являться нашим Resource Server. По аналогии с j-sso добавим основные зависимости для создания web приложения. Далее, добавим зависимость OAuth2 Resource Server:

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.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
            <version>3.0.6</version>
        </dependency>
    </dependencies>
</project>

Весь конфигурационный файл j-service вы можете посмотреть в репозитории

Теперь обратимся к документации OAuth 2.0 Resource Server. Сразу вспомним, что в нашем j-sso настроены opaque tokens. Соответственно, перейдём в раздел Opaque Token и посмотрим, как можно настроить наш j-service. В документации всё просто: добавляем в наш application.yml настройки spring.security.oauth2.resourceserver.opaquetoken.* и должно всё заработать. Добавим их, и запустим наш сервис.

application.yml

// .....

spring:
  security:
    oauth2:
      resourceserver:
        opaquetoken:
          client-id: test-client
          client-secret: test-client
          introspection-uri: http://localhost:7777/oauth2/token-info

Не забудем также добавить общую конфигурацию Spring Security (она представлена ниже). Обратите внимание на настройку sessionManagement. Так как security механизм нашего сервиса должен основываться только на Authorization заголовке с типом Bearer, то мы должны выключить поддержку HttpSession, которая используется Spring Security по умолчанию. Поэтому, мы специально указываем политику создания сессии STATELESS, чтобы Spring Security не создавал HttpSession и не использовал его для получения SecurityContext. Это также означает, что при запросах сервис не будет создавать JSESSIONID Cookie и будет пропускать часть цепочки Security фильтров, таких как HttpSessionSecurityContextRepository, SessionManagementFilter и RequestCacheFilter.

ResourceServerConfig


@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class ResourceServerConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // выключаем поддержку сессий
        http.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(customizer -> {
                    customizer.anyRequest().authenticated();
                });

        // подключаем поддержку OAuth2 Resource Server с Opaque Token
        http.oauth2ResourceServer(OAuth2ResourceServerConfigurer::opaqueToken);
        return http.build();
    }
}

Конечно, прежде чем запускать наш сервис мы создали main класс - ResourceApplication и тестовый контроллер со всего одним endpoint-ом - /test. Код их достаточно банален, поэтому их вы можете посмотреть в репозитории.

Соберём и запустим наш j-sso и j-service. При помощи нижеследующего запроса получим Access Token. Нам сейчас не важно какой будет параметр grant type, так как нам нужно протестировать только взаимодействие этих сервисов. Поэтому, пойдём по пути наименьшего сопротивления и получим токен с grant type равным client_credentials.

curl --location --request POST 'http://localhost:7777/oauth2/token?grant_type=client_credentials' \
--header 'Authorization: Basic dGVzdC1jbGllbnQ6dGVzdC1jbGllbnQ='
Пример запроса/ответа из Postman
Получение токена доступа
Получение токена доступа

Не забываем, что у нас обязательно должен быть заголовок Authorization с типом Basic, в котором находится base64 строка следующего вида: test-client:test-client. Это наши clientId и clientSecret, которые мы указывали при создании клиента в j-sso.

Далее, делаем запрос на тестовый endpoint и смотрим в логи.

Логи j-service
Логи j-service

Видим, что наш j-service, получил запрос. Далее он делает запрос HTTP POST http://localhost:7777/oauth2/token-info. В него он помещает тот access токен, который мы передали. Получает ответ с кодом 200 OK. И далее мы получаем ошибку java.lang.ClassCastException. Итак, можно сделать вывод, что сервисы между собой корректно общаются, но у нас есть ошибка при разборе ответа Introspection Endpoint нашего j-sso. Давайте разберёмся почему.

Для того чтобы понять, что произошло, нам надо обратиться к прошлой статье в главу Подключаем авторизацию через Yandex и дальнейшая кастомизация. В конце этой главы, мы кастомизировали стандартный вывод информации introspection endpoint, предоставляемый Spring. Мы создали собственный DTO класс, описывающий информацию о токене и пользователе. Обратите внимание на конец метода introspectionResponse() в AuthorizationServerConfig. Мы при помощи конвертера Jackson2 превратили наш TokenInfoDto в строку и передали его в HttpResponse.

Метод introspectionResponse() класса AuthorizationServerConfig
Метод introspectionResponse() класса AuthorizationServerConfig

А теперь давайте взглянем на то, что происходит в классе SpringOpaqueTokenIntrospector, который обрабатывает ответ от Introspection endpoint в сервисе j-service.

Обработка claims в SpringOpaqueTokenIntrospector
Обработка claims в SpringOpaqueTokenIntrospector

Мы видим, что стандартный SpringOpaqueTokenIntrospector получает на вход Map<String, Object> claims, которая является отображением конвертированного в JSON формат TokenInfoDto. И тут видно, что при конвертации, Jackson конвертировал тип Instant не в число, а в строку. Сразу обратим внимание на то, что в полях nbf, exp, iat этот обработчик ожидает именно количество секунд от начала времён. Это создаёт нам некоторые трудности, так как при помощи аннотации @JsonFormat мы можем указать Jackson-у конвертировать Instant в число, но мы не можем указать конвертировать именно в секунды. Максимум мы можем конвертировать в миллисекунды как показано ниже:

TokenInfoDto.java

public class TokenInfoDto {
    // ....

    @JsonFormat(
            without = {JsonFormat.Feature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS},
            shape = JsonFormat.Shape.NUMBER_INT
    )
    private Instant nbf;

    // .....
}

Но этот вариант нам не подходит, поэтому на данный момент я не вижу другого способа, как создать собственный JsonSerializer для типа Instant и при помощи аннотации @JsonSerialize указать его над нашими полями.

Итак, наш сериализатор выглядит следующим образом:

JsonInstantSerializer.java

public class JsonInstantSerializer extends JsonSerializer<Instant> {

    @Override
    public void serialize(Instant value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        if (value != null) {
            gen.writeNumber(value.getEpochSecond());
        } else {
            gen.writeNull();
        }
    }
}

Применим этот сериализатор над полями с типом Instant в TokenInfoDto:

TokenInfoDto.java

@Getter
@Setter
@Builder
public class TokenInfoDto {

    private Boolean active;
    private String sub;
    private List<String> aud;
    @JsonSerialize(using = JsonInstantSerializer.class)
    private Instant nbf;
    private List<String> scopes;
    private URL iss;
    @JsonSerialize(using = JsonInstantSerializer.class)
    private Instant exp;
    @JsonSerialize(using = JsonInstantSerializer.class)
    private Instant iat;
    private String jti;
    private String clientId;
    private String tokenType;

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

Теперь соберём и запустим наши сервисы. Выполним ещё раз тестовый запрос j-service и вуаля, всё сработало корректно. В ответе мы можем наблюдать, что данные корректно конвертировались.

Успешное выполнение тестового запроса j-service
Успешное выполнение тестового запроса j-service

Но на этом, мы не останавливаемся. Вспомним, что у нас ещё информация о пользователе приходит вместе с информацией о токене, поэтому нам необходимо кастомизировать наш j-service таким образом, чтобы объект principal из данного ответа оказался в SecurityContext. Вот тут и начинается самое веселье.

Класс достаточно большой, целиком в статье его разместить не получиться, поэтому добавлю ссылку на Github репозиторий Spring OAuth2 Resource Server класс SpringOpaqueTokenIntrospector.

Очевидно, что стоит начинать с какого-нибудь AuthenticationProvider. Если мы посмотрим на доступные нам реализации этого интерфейса мы обнаружим очень интересный экземпляр - OpaqueTokenAuthenticationProvider, похоже это то, что нам нужно.

OpaqueTokenAuthenticationProvider метод authenticate()
OpaqueTokenAuthenticationProvider метод authenticate()

Взглянем на его метод authenticate() и увидим, что при помощи OpaqueTokenIntrospector мы получаем OAuth2AuthenticatedPrincipal, который является principal-ом у объекта Authentication, коим у нас является BearerTokenAuthentication. Это можно увидеть из метода convert() данного класса.

OpaqueTokenAuthenticationProvider метод convert()
OpaqueTokenAuthenticationProvider метод convert()

Соответственно, можно сделать следующее предположение: создав собственную реализацию introspector и поместив её через конструктор, можно реализовать любой механизм получения Principal объекта и любой его реализации. Он также должен реализовывать интерфейс OAuth2AuthenticatedPrincipal.

Давайте убедимся, что так реально можно сделать. Для этого посмотрим на возможности DSL oauth2ResourceServer. Там найдём метод opaqueToken и посмотрим на возможности OpaqueTokenConfigurer.

Класс OpaqueTokenConfigurer
Класс OpaqueTokenConfigurer

Видим, что тут можно указать свой кастомный OpaqueTokenIntrospector. А это то, что нам нужно.

Итак, мы определились, что нам необходимо сделать собственную реализацию интерфейса OpaqueTokenIntrospector, в котором мы сделаем запрос в j-sso на introspection endpoint. Далее, поймём, что токен валидный и корректно смапим данные. Ещё нам понадобиться создать кастомную реализацию интерфейса OAuth2AuthenticatedPrincipal, добавив туда наши данные по пользователю.

Конечно, с нуля выписывать всю реализацию OpaqueTokenIntrospector достаточно трудоёмко, да и если повнимательней взглянуть на дефолтную реализацию (SpringOpaqueTokenIntrospector) то можно заметить, что в ней уже всё есть. Только необходимо чуть изменить методы: introspect() и makeRequest(). К сожалению, данная реализация полностью закрыта и разработчики OAuth2 Resource Server не предусмотрели никакой абстрактной реализации, чтобы нам сейчас упростить жизнь. Поэтому, нам ничего не остаётся, как скопировать реализацию SpringOpaqueTokenIntrospector и начать её изменять.

Создадим класс CustomSpringTokenIntrospection, в него копируем всю реализацию SpringOpaqueTokenIntrospector. Далее, нам необходимо сделать следующие изменения:

  1. Добавить в RestTemplate конвертер MappingJackson2HttpMessageConverter.

CustomSpringTokenIntrospection.java конструктор класса


public class CustomSpringTokenIntrospection implements OpaqueTokenIntrospector {

    private final RestOperations restOperations;
    private Converter<String, RequestEntity<?>> requestEntityConverter;

    public CustomSpringTokenIntrospection(String introspectionUri, String clientId, String clientSecret, MappingJackson2HttpMessageConverter jackson2HttpMessageConverter) {
        Assert.notNull(introspectionUri, "introspectionUri cannot be null");
        Assert.notNull(clientId, "clientId cannot be null");
        Assert.notNull(clientSecret, "clientSecret cannot be null");
        this.requestEntityConverter = this.defaultRequestEntityConverter(URI.create(introspectionUri));
        RestTemplate restTemplate = new RestTemplate();
        // Добавляем jackson конвертер запросов
        restTemplate.getMessageConverters().add(jackson2HttpMessageConverter);
        restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(clientId, clientSecret));
        this.restOperations = restTemplate;
    }

    // .......
}

  1. Добавим в j-service TokenInfoDto из j-sso. Переделаем метод makeRequest, так чтобы он возвращал сразу TokenInfoDto.

CustomSpringTokenIntrospection.java метод makeRequest

public class CustomSpringTokenIntrospection implements OpaqueTokenIntrospector {
    // ......

    private TokenInfoDto makeRequest(RequestEntity<?> requestEntity) {
        try {
            return this.restOperations.postForObject(requestEntity.getUrl(), requestEntity, TokenInfoDto.class);
        } catch (Exception ex) {
            throw new OAuth2IntrospectionException(ex.getMessage(), ex);
        }
    }

    // ......
}
  1. Прежде чем переделывать метод introspect необходимо создать кастомную реализацию интерфейса OAuth2AuthenticatedPrincipal. Так как данный интерфейс требует реализации методов работы с аттрибутами токена в виде Map<String, Object>. Поэтому, чтобы не городить городушек и в то же время не упустить реализации важных механизмов, мы, воспользовавшись интерфейсом OAuth2TokenIntrospectionClaimAccessor, создадим абстрактный класс TokenInfoOAuth2ClaimAccessor и переопределим в нём все геттеры.

TokenInfoOAuth2ClaimAccessor.java
public abstract class TokenInfoOAuth2ClaimAccessor implements OAuth2TokenIntrospectionClaimAccessor {

    @Override
    public Map<String, Object> getClaims() {
        return null;
    }

    // добавим абстрактный метод, для получения объекта TokenInfoDto
    abstract TokenInfoDto getTokenInfo();

    @Override
    public boolean isActive() {
        return this.getTokenInfo().getActive();
    }

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

    @Override
    public String getClientId() {
        return this.getTokenInfo().getClientId();
    }

    @Override
    public List<String> getScopes() {
        return this.getTokenInfo().getScopes();
    }

    @Override
    public String getTokenType() {
        return this.getTokenInfo().getTokenType();
    }

    @Override
    public Instant getExpiresAt() {
        return this.getTokenInfo().getExp();
    }

    @Override
    public Instant getIssuedAt() {
        return this.getTokenInfo().getIat();
    }

    @Override
    public Instant getNotBefore() {
        return this.getTokenInfo().getNbf();
    }

    @Override
    public String getSubject() {
        return this.getTokenInfo().getSub();
    }

    @Override
    public List<String> getAudience() {
        return this.getTokenInfo().getAud();
    }

    @Override
    public URL getIssuer() {
        return this.getTokenInfo().getIss();
    }

    @Override
    public String getId() {
        return this.getTokenInfo().getJti();
    }
}

После этого, создадим CustomOAuth2AuthenticatedPrincipal (он представлен ниже). Обратите внимание на поле delegate, оно имеет тип AuthorizedUser - это тот же AuthorizedUser, который используется в j-sso и который находиться в поле principal TokenInfoDto.

CustomOAuth2AuthenticatedPrincipal.java

public class CustomOAuth2AuthenticatedPrincipal extends TokenInfoOAuth2ClaimAccessor implements OAuth2AuthenticatedPrincipal, Serializable {

    private final AuthorizedUser delegate;
    private final TokenInfoDto tokenInfo;

    public CustomOAuth2AuthenticatedPrincipal(TokenInfoDto tokenInfo) {
        // tokenInfo.getPrincipal() - может быть пустым, например, когда access токен получен путем grant_type=client_credentials
        this.delegate = AuthorizedUser.build(tokenInfo.getPrincipal());
        this.tokenInfo = tokenInfo;
    }

    public Map<String, Object> getAttributes() {
        return Collections.emptyMap();
    }

    public Collection<? extends GrantedAuthority> getAuthorities() {
        if (this.delegate == null) {
            return Collections.emptyList();
        }
        return this.delegate.getAuthorities();
    }

    /**
     * Если пришедший токен вне контекста пользователя (Client Credential Grant Type), то возвращаем client_id
     */
    public String getName() {
        if (this.delegate == null) {
            return this.tokenInfo.getClientId();
        }
        return this.delegate.getName();
    }

    @Override
    TokenInfoDto getTokenInfo() {
        return this.tokenInfo;
    }
}
  1. Переделаем метод introspect. Учтём, что мы поменяли метод makeRequest и что он должен возвращать CustomOAuth2AuthenticatedPrincipal.

CustomSpringTokenIntrospection.java метод introspect


@Slf4j
public class CustomSpringTokenIntrospection implements OpaqueTokenIntrospector {

    private final RestOperations restOperations;
    private Converter<String, RequestEntity<?>> requestEntityConverter;

    // .....

    public OAuth2AuthenticatedPrincipal introspect(String token) {
        RequestEntity<?> requestEntity = this.requestEntityConverter.convert(token);
        if (requestEntity == null) {
            throw new OAuth2IntrospectionException("requestEntityConverter returned a null entity");
        } else {
            TokenInfoDto tokenInfo = this.makeRequest(requestEntity);
            if (!tokenInfo.getActive()) {
                log.trace("Did not validate token since it is inactive");
                throw new BadOpaqueTokenException("Provided token isn't active");
            }
            return new CustomOAuth2AuthenticatedPrincipal(tokenInfo);
        }
    }

    // ......

    private TokenInfoDto makeRequest(RequestEntity<?> requestEntity) {
        try {
            return this.restOperations.postForObject(requestEntity.getUrl(), requestEntity, TokenInfoDto.class);
        } catch (Exception ex) {
            throw new OAuth2IntrospectionException(ex.getMessage(), ex);
        }
    }
}

Весь код, класса CustomSpringTokenIntrospection вы можете посмотреть в репозитории

Теперь, настало время подключения нашей кастомизированной реализации introspector в конфигурацию сервиса. Для этого изменим нам ResourceConfig следующим образом.

ResourceServerConfig.java


@Configuration
@EnableWebSecurity(debug = true)
@RequiredArgsConstructor
public class ResourceServerConfig {

    private final OAuth2ResourceOpaqueProperties resourceProperties;
    private final MappingJackson2HttpMessageConverter messageConverter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // выключаем поддержку сессий
        http.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(customizer -> {
                    customizer.anyRequest().authenticated();
                });

        // Подключаем поддержку OAuth2 Resource Server с Opaque Token.
        // Добавляем в качестве introspector нашу реализацию
        http.oauth2ResourceServer(configurer -> {
            configurer.opaqueToken(customizer -> {
                customizer.introspector(new CustomSpringTokenIntrospection(
                        resourceProperties.getIntrospectionUri(),
                        resourceProperties.getClientId(),
                        resourceProperties.getClientSecret(),
                        messageConverter
                ));
            });
        });
        return http.build();
    }
}

Мне также пришлось реализовать класс OAuth2ResourceOpaqueProperties, который предназначен для получения основных настроек resource server. К сожалению, удобного способа получения их из OpaqueTokenConfigurer я не нашёл, поэтому пошёл этим путём.

Итак, мы почти готовы к тестированию, но прежде чем мы это сделаем, давайте взглянем на TokenInfoDto, а именно на поля principal и authorities.

TokenInfoDto.java


@Getter
@Setter
@Builder
public class TokenInfoDto {

    // ....

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

Во-первых, передавать информацию о пользователе используя тип Object не совсем корректно и может повлечь за собой большое количество багов в дальнейшем. Также, в объекте principal итак уже есть authorities, незачем тогда нам их передавать дважды. Поэтому, я предлагаю, создать класс IntrospectionPrincipal и пусть он будет описывать всю необходимую информацию о пользователе, которая передаётся через introspection endpoint. Стоит понимать, что этот класс не то же самое, что AuthorizedUser. Это специальный класс-DTO, который предназначен для передачи необходимой информации о пользователе. И в дальнейшем для построения класса AuthorizedUser.

IntrospectionPrincipal.java


@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class IntrospectionPrincipal {

    private UUID id;
    private String firstName;
    private String secondName;
    private String middleName;
    private LocalDate birthday;
    private String avatarUrl;
    private String username;
    private String email;
    private Collection<? extends GrantedAuthority> authorities;
}

Соответственно, мы можем убрать поле authorities из TokenInfoDto и заменить тип у поля principal.

TokenInfoDto.java

@Getter
@Setter
@NoArgsConstructor
public class TokenInfoDto {

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

    private IntrospectionPrincipal principal;
}

Эти изменения необходимо сделать как в j-sso, так и в j-services. В дальнейшем можно будет сделать какой-нибудь модуль introspection-api, в котором разместим данные DTO и подключим к обоим сервисам. Далее, не забудем изменить метод introspectionResponse в AuthorizationServerConfig модуля j-sso.

AuthorizationServerConfig.java метод introspectionResponse

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

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

            // Здесь начинаются изменения
            String token = introspectionAuthenticationToken.getToken();                                                      // получаем значение токена, который проверяется
            OAuth2Authorization tokenAuth = oAuth2AuthorizationService.findByToken(token, OAuth2TokenType.ACCESS_TOKEN);     // предполагая что это ACCESS TOKEN, пытаемся получить объект OAuth2Authorization из OAuth2AuthorizationService
            if (tokenAuth != null) {
                Authentication attributeAuth = tokenAuth.getAttribute(principalAttributeKey);                                // Если найден этот объект OAuth2Authorization, то получаем из него объект Authentication следующим образом
                if (attributeAuth != null) {
                    if (attributeAuth.getPrincipal() instanceof AuthorizedUser authorizedUser) {                             // Если полученный объект Authentication не пуст, то проверяем является ли его principal экземпляром класса AuthorizedUser
                        tokenInfoDtoBuilder.principal(IntrospectionPrincipal.build(authorizedUser));                         // Создаём IntrospectionPrincipal на его основе
                    } else {                                                                                                 // Иначе выбрасываем исключение, что другие типы principal мы не поддерживаем
                        throw new RuntimeException("Principal class = " + attributeAuth.getPrincipal().getClass().getSimpleName() + " is not supported");
                    }
                }
            }
        }

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

Всё, теперь мы точно готовы к запуску, давайте соберём и посмотрим, как будет выглядеть SecurityContext в j-service. Конечно, в этот раз нет смысла использовать access токен полученный вне контекста пользователя (т.е. Client Credential Grant Type). Поэтому, при помощи нашего тестового Vue/JS клиента (модуль test-client), мы авторизуемся под пользователем admin@example.com и получим access токен. Далее, мы используем его в запросе к j-service.

Тестовый запрос к j-service
Тестовый запрос к j-service

Поставив точку останова в тестовом контроллере j-service, смотрим что находится в SecurityContext. Видно, что объект Authentication имеет реализацию BearerTokenAuthentication, внутри которой есть объект principal, имеющий реализацию CustomOAuth2AuthenticatedPrincipal. А внутри него уже есть объект delegate реализующий класс AuthorizedUser. Я специально не стал делать объект principal в BearerTokenAuthentication делать типом AuthorizedUser, так как у нас, помимо информации о пользователе, также есть некоторая информация о токене и клиенте, получившим этот токен. Следовательно, из-за большого количества составляющих информации об объекте principal, я сделал отдельный класс CustomOAuth2AuthenticatedPrincipal, из которого мы можем получить любую информацию. При необходимости, можно расширить возможности CustomOAuth2AuthenticatedPrincipal для более удобной работы с ним.

Итак, в этой части статьи я показал каким образом можно подружить наш кастомный ответ introspection endpoint с реализацией OAuth2 Resource Server в Spring.

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

Раздел 2.2: Подключаем PostgreSQL и раскатываем схему БД

Прежде чем мы начнём говорить о регистрации пользователей, работе с данными пользователей и так далее, нам необходимо "прокачать" наш слой DAO (слой работы с данными) модуля j-sso. Для этого мы должны определиться с тем где мы будем хранить все данные, и как мы будем работать с этим хранилищем. На первый вопрос в нашем случае ответ очевиден, мы в самом начале серии статей поставили техническое требование использования PostgreSQL. А на второй вопрос нам поможет ответить документация Spring.

Начнём с того, что нам необходимо где-то развернуть PostgreSQL. Для окружения среды разработки нам отлично подойдёт docker контейнер с СУБД. Но чтобы нам было вообще удобно, мы будет использовать docker-compose. Поэтому, создадим файл в корне проекта docker-compose.yml и добавим в него сервис j-postgres, который и будет являться нашей СУБД. Документацию по параметрам данного image можно найти на Docker Hub

docker-compose.yml

version: '3.3'
services:
  j-postgres:
    image: postgres:15.3
    container_name: j-postgres
    ports:
      - '5435:5432'
    environment:
      POSTGRES_PASSWORD: "user"
      POSTGRES_USER: "user"
      POSTGRES_DB: "j-sso"
    restart: always
    volumes:
      - ./docker/postgres/data:/var/lib/postgresql/data
    networks:
      - j-network

networks:
  j-network:
    ipam:
      driver: default
      config:
        - subnet: "14.205.210.0/24"

А для того чтобы у нас не было проблем с недоступностью каких либо интернет ресурсов, я всегда добавляю конфигурацию docker networks. В ней указываю подсеть работы моих докер контейнеров. Вы её смело можете менять под свои нужды. На этом всё, теперь у нас есть PostgreSQL к которому можно подключаться. Далее, переходим к части Spring.

Подключаем приложение к СУБД

Spring предоставляет нам под-проект Spring Data, который как раз и предназначен для подключения и работы с хранилищами данных.

Из документации Spring Data

Spring Data’s mission is to provide a familiar and consistent, Spring-based programming model for data access while still retaining the special traits of the underlying data store.

Соответственно, там имеется всем знакомый и любимый Spring Data JPA. У них также есть ссылка на их гайд, в котором рассказано как подключить его к проекту. Итак, давайте подключим.

Во-первых, подключим следующие зависимости в pom.xml нашего j-sso.

  1. spring-boot-starter-data-jpa - добавляет в наш проект spring-data-jpa, hibernate, HikariCP и остальные зависимости для комфортной работы

  2. postgresql - JDBC драйвер для подключения к PostgreSQL.

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-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
        </dependency>

        // ....
    </dependencies>
</project>

Далее, нам достаточно объявить нижеследующие настройки в наш application.yml и на этом всё. Подключение к PostgreSQL настроено.

application.yml

// .....

spring:
  application:
    name: j-sso
  datasource:
    url: "jdbc:postgresql://localhost:5435/j-sso" // URL подключения к БД
    username: "user"                              // Имя пользователя СУБД
    password: "user"                              // Пароль пользователя СУБД
    driver-class-name: "org.postgresql.Driver"    // путь до класса JDBC драйвера (для этого мы выше добавили вторую зависимость)
    hikari:                                       // Настройки пула соединений (HikariCP)
      connection-timeout: 30000
      maximum-pool-size: 10
      minimum-idle: 10
      initialization-fail-timeout: 1
      leak-detection-threshold: 0
      pool-name: "HikariPool-j-sso"
      register-mbeans: true

  // .....

Не сморя на то, что у нас не высоко-нагруженный проект и "тюнить" параметры пула соединений особо нет смысла, я всегда советую проставлять данные параметры. Это позволяет в дальнейшем (когда проект вырастает в реальный продукт) избежать некоторых проблем и быстро изменять параметры пула подключений при проблемах с ним. Также, это полезно в понимании того, как работает пул соединений и какие параметры можно изменить при проблемах с производительностью. Давайте быстренько пробежимся по данным параметрам:

  1. connection-timeout - максимальное количество миллисекунд, в течение которых драйвер будет ожидать подключения из пула.

  2. maximum-pool-size - максимальный размер пула соединений

  3. minimum-idle - минимальное количество простаивающих соединений, которые HikariCP пытается поддерживать в пуле. Но для максимальной производительности и реагирования на возросшую нагрузку разработчики советуют ставить значение равное maximum-pool-size, тем самым делая пул фиксированного размера. По умолчанию этот параметр равен maximum-pool-size.

  4. initialization-fail-timeout - количество миллисекунд, необходимое для попытки установить начальное соединение. Этот таймаут применяется после connection-timeout.

  5. leak-detection-threshold - количество миллисекунд, в течение которого соединение может быть вне пула, прежде чем будет зарегистрировано сообщение, указывающее на возможную утечку соединения.

  6. pool-name - имя пула. Удобно потом логи смотреть

  7. register-mbeans - определяет, зарегистрированы ли компоненты управления JMX (MBeans). Это необходимо для мониторинга и сбора статистики, для оптимизаций при нагрузках. Прочитать можете здесь

Это основные настройки пула, но HikariCP имеет ещё кучу настроек, о которых вы можете прочитать в их репозитории на Github.

По документации, нам говорят, что надо ещё добавить аннотацию @EnableJpaRepositories. Пока не будем вдаваться в подробности, поговорим о ней чуть позже. Запустим наш j-sso и увидим в логах подтверждение того что подключение создалось.

Логи запуска j-sso
Логи запуска j-sso

Итак, БД у нас есть, подключение к ней тоже настроено. Теперь, настало время поговорить о том, где и как хранить скрипты создания схемы реляционной базы данных и как ими эффективно пользоваться.

Управление DDL скриптами

Существуют такие библиотеки, которые позволяют отслеживать, управлять и применять изменений схемы базы данных. Одна из таких - Liquibase. Я знаком с этой библиотекой очень давно, во всех проектах её использую. Она удовлетворяет все мои требования к работе со скриптами БД, а также поддерживается автоконфигурациями Spring Boot. Поэтому, я буду использовать её.

Для этого добавляем следующую зависимость:

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.liquibase</groupId>
            <artifactId>liquibase-core</artifactId>
        </dependency>
        // ....
    </dependencies>
</project>

Теперь мы слегка отвлечёмся от кода и поговорим о структуре хранения скриптов миграции схемы базы данных. Данные скрипты мы расположим в директории database, которая будет находиться в корне модуля j-sso. В ней мы будем хранить всё, что связанно с liquibase. Структура этой директории будет следующей:

Структура директории database
Структура директории database

В корне будет располагаться 3 файла:

  1. extensions.sql - в этом файле будут находиться change-set-ы подключения различных расширений СУБД

  2. schemas.sql - в этом файле будут находиться change-set-ы создания схем БД

  3. db.changelog.yaml - корневой файл changelog для liquibase. В нём будут описаны подключение файла schemas.sql и корневых файлов директорий релизов. Ниже представлено содержимое данного файла

  4. директории release-<версия приложения> - в этих директориях будут находиться все change-set-ы относящиеся к конкретной версии приложения. Они тоже имеют определённую структуру.

db.changelog.yaml

databaseChangeLog:
  - include:
      file: extensions.sql
      relativeToChangelogFile: true
  - include:
      file: schemas.sql
      relativeToChangelogFile: true
  - include:
      file: release-1.0.0/release.changelog.yaml
      relativeToChangelogFile: true

Структура директорий release-* состоит из следующих компонентов:

  1. Файл release.changelog.yaml - он агрегирует в себе подключения всех .sql файлов из нижеописанных директорий. Именно он подключается в корневой changelog файл. Содержимое данного файла представлено ниже.

release.changelog.yaml

databaseChangeLog:
  #  Objects
  - include:
      file: object/users.sql
      relativeToChangelogFile: true
      contextFilter: prod
  - include:
      file: object/system-oauth2-clients.sql
      relativeToChangelogFile: true
      contextFilter: prod

  #  Data
  - include:
      file: data/system-oauth2-clients-data.sql
      relativeToChangelogFile: true
      contextFilter: prod

  # Data dev
  - include:
      file: data/dev/users-data.sql
      relativeToChangelogFile: true
      contextFilter: dev
  1. Директория object - в ней находятся все .sql файлы только с DDL скриптами. Например, создание таблицы пользователей, создание индексов, изменение наименования колонки и т.д.

  2. Директория data - в ней находятся все .sql файлы с DML скриптами, необходимыми для работы приложения. Это как правило скрипты заполнения справочников или других данных, без которых приложение не может корректно запуститься или работать.

  3. В директории data есть под-директория dev - здесь находятся скрипты, которые позволяют создать данные для тестирования или разработки приложения, только в этих окружениях. Например, это может быть накат тестовых пользователей, или создание бизнес сущностей и т.д. Такую же директорию можно создать и в object, но обычно это очень редкие случаи, поэтому мы не стали сейчас добавлять её.

Стоит обратить внимание на параметр contextFilter в changelog файлах. Именно им мы устанавливаем разграничение между скриптами, предназначенными для продакшена и данными предназначенными для разработки и тестирования.

У вас, наверное, сразу возникнет желание создать changelog файлы в директориях object и data, так делать не надо. Потому, что иной раз приходиться в начале произвести операции с данными, а потом со схемой. В первом релизе это маловероятная история, но в последующих релизах, когда необходимо изменять таблицы, это встречается чаще. Также, некоторые делают сначала директории data и object, а внутри уже директории релизов (release-*), так тоже делать не стоит. Всё потому, что когда вы начнёте удалять ненужные колонки в таблице, к которой также имеются скрипты наката данных, вы столкнётесь с проблемой, что у вас не будут проходить DML скрипты. Это происходит из-за того, что вы сначала раскатываете DDL скрипты, в которых создаётся ваша таблица. Затем из неё удаляется колонка. А после этого, начинаете выполнять DML скрипты, в которых выполняется скрипт наката данных в полноценную таблицу. Из-за этого и возникает ошибка. Конечно, можно просто подправить скрипты, но это плохой тон и зачастую становиться проблемой при раскате на проде. Эти проблемы в основном возникают из-за того, что в Liquibase есть понятие hashSum changeSet-а, которая хранит в себе hash сумму sql запроса. И если вы его поменяете, то Liquibase будет "ругаться".

Итак, со структурой разобрались. Добавим скрипты создания таблиц sso.users и sso.system_oauth2_clients, также создадим DML скрипты наката test-client и в dev-скрипты добавим INSERT создания пользователя с email example@example.ru. Все скрипты очень простые и вы можете посмотреть их в Github репозитории. Обратите внимание, что пароли мы сразу указываем как результат хэш функции, а именно BCrypt с параметром round = 10 (значение по умолчанию). Для хеширования паролей, как правило, рекомендуется применять алгоритм Blowfish, а bcrypt как раз основан на шифре Blowfish. Ниже мы разберём как подключить PasswordEncoder в нашем j-sso.

bcrypt — адаптивная криптографическая хеш-функция формирования ключа, используемая для защищенного хранения паролей. Разработчики: Нильс Провос и David Mazières. Функция основана на шифре Blowfish, впервые представлена на USENIX в 1999 году.

Запуск наката скриптов

Теперь, когда мы создали всё необходимое для работы Liquibase, настало время поговорить о запуске процесса наката. Есть два сценария и мы разберём оба:

  1. Накат скриптов происходит при запуске приложения

  2. Накат скриптов можно запустить отдельным процессом используя liquibase-maven-plugin

Накат скриптов при запуске приложения

Для того чтобы запустить накат скриптов при запуске приложения, вам необходимо сначала добавить директорию database в директорию ресурсов сборки (т.е. в target/classes). Для этого достаточно указать директорию database как ресурс в 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">

    // .....

    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <finalName>${project.name}</finalName>
                </configuration>
            </plugin>
        </plugins>

        <resources>
            <!-- добавляем нашу директорию database в сборку-->
            <resource>
                <directory>${project.basedir}/database</directory>
                <targetPath>${project.build.directory}/classes/database</targetPath>
            </resource>
            <resource>
                <directory>${project.basedir}/src/main/resources</directory>
            </resource>
        </resources>
    </build>

</project>

А далее необходимо добавить настройки Liquibase в application.yml. Мы указываем два контекста dev и prod, для того чтобы накатить все скрипты. При необходимости можно указать только prod для раската, без данных для тестирования и разработки. Как вариант, можно использовать профайлы Spring, чтобы настроить какие контексты Liquibase использовать с тем или иным профайлом.

application.yml пример с профайлами Spring

// ....

spring:
  application:
    name: j-sso
  liquibase:
    enabled: true
    change-log: classpath:database/db.changelog.yaml
    contexts: dev, prod

  // ....

---
spring:
  config:
    activate:
      on-profile: production
  liquibase:
    contexts: prod

---
spring:
  config:
    activate:
      on-profile: development
  liquibase:
    contexts: prod, dev

Теперь, когда мы запустим приложение у нас накатятся скрипты БД (не забудьте также запустить сам PostgreSQL).

Накат скриптов используя maven плагин

Второй сценарий, как я уже говорил, подразумевает использование специального плагина maven: liquibase-maven-plugin. В этом сценарии нам не надо добавлять директорию database в ресурсы сборки, поэтому секцию <resources>...</resounrces> можно убрать. В конфигурацию 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">

    // .....

    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <finalName>${project.name}</finalName>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.liquibase</groupId>
                <artifactId>liquibase-maven-plugin</artifactId>
                <configuration>
                    <propertyFile>${project.basedir}/database/liquibase.properties</propertyFile>
                    <promptOnNonLocalDatabase>false</promptOnNonLocalDatabase>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

Также мы добавим в директорию database новый файл - liquibase.properties, в котором будут находиться настройки подключения к БД и путь до главного changelog файла.

liquibase.properties

changeLogFile=database/db.changelog.yaml
driver=org.postgresql.Driver
url=jdbc:postgresql://localhost:5435/j-sso
username=user
password=user
contexts=prod, dev

Для наката скриптов, удовлетворяющих только определённым контекстам, можно использовать maven профайлы и несколько файлов liquibase.properties.

А сейчас, чтобы запустить накат скриптов БД, можно выполнить следующую команду в модуле j-sso:

mvn liquibase:update -Dliquibase.searchPath=./

Можно запускать накат скриптов БД в процессе сборки. Для этого необходимо настроить дескриптор <executions> следующим образом. В дескрипторе <phase> указывается задача после которой будет запущено обновление.

j-sso/pom.xml настройка liquibase плагина

<?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">

    // .....

    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <finalName>${project.name}</finalName>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.liquibase</groupId>
                <artifactId>liquibase-maven-plugin</artifactId>
                <configuration>
                    <propertyFile>${project.basedir}/database/liquibase.properties</propertyFile>
                    <promptOnNonLocalDatabase>false</promptOnNonLocalDatabase>
                </configuration>
                <executions>
                    <execution>
                        <id>liquibase-update</id>
                        <phase>process-resources</phase>
                        <goals>
                            <goal>update</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

Параметр -Dliquibase.searchPath используется только потому, что на данный момент в репозитории настроено сразу два варианта. Из-за этого главный changelog файл Liquibase находит дважды. Чтобы указать конкретный путь поиска можно использовать параметр liquibase.searchPath.

После наката всех скриптов у нас в БД есть таблица с клиентами системы и таблица с пользователями системы. Давайте теперь создадим соответствующее объектно-реляционное отображение этих сущностей и заставим наше приложение с ним работать.

Создание объектно-реляционного отображения

В директории dao у нас есть уже UserEntity, поменяем её под наши новые реалии и создадим вторую сущность SystemOauth2Client. Эти две сущности и будут являться объектно реляционным отображением таблиц sso.users и sso.system_oauth2_clients. Если SystemOauth2Client очень простая entity, которую вы можете посмотреть в репозитории, то про UserEntity необходимо чуть-чуть пояснить.

UserEntity.java


@Getter
@Setter
@Entity
@Table(schema = "sso", name = "users")
public class UserEntity extends VersionedBusinessEntity<UUID> {

    @Id
    @Column(name = "user_id", nullable = false)
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;
    @Column(name = "email", nullable = false)
    private String email;
    @Column(name = "password_hash")
    private String passwordHash;
    @Column(name = "first_name", nullable = false)
    private String firstName;
    @Column(name = "second_name", nullable = false)
    private String secondName;
    @Column(name = "middle_name")
    private String middleName;
    @Column(name = "birthday")
    private LocalDate birthday;
    @Column(name = "avatar_url")
    private String avatarUrl;
    @Column(name = "active", nullable = false)
    private Boolean active;

    @Override
    public UUID getId() {
        return this.id;
    }

    @Override
    public void setId(UUID id) {
        this.id = id;
    }

}

Обратите внимание на то, что она наследуется от VersionedBusinessEntity и в ней нет служебных полей (created_by, created_date и др.). И вот мы подошли к тому моменту, когда надо рассказать про то, что такое служебные поля и зачем они нужны.

Служебные поля - это поля в таблице в которой храниться следующая информация:

  1. дата/время создания записи и кто создал эту запись (как правило логин пользователя)

  2. дата/время обновления записи и кто обновил эту запись

  3. Версия записи

Пункты 1 и 2 необходимы в основном для некоторой статистики, для анализа данных при разборе багов или проблемных мест, для будущей миграции данных (например, когда система устаревает и на замену ей приходит новый проект) и других кейсов. А вот 3-ий пункт необходим для предотвращения проблем с одновременным обновлением одной и то же записи или решения проблем с "потерянными обновлениями". Для этого, при сохранении проверяется версия записи в БД и если она больше чем текущая, то возникает ошибка OptimisticLockException и транзакция откатывается, мы же в свою очередь можем отловить этот exception и попросить пользователя, например, обновить страницу.

Так как служебные поля необходимо указывать на всех бизнес-сущностях, то удобнее всего их вынести в абстрактные классы, что и сделано. Но, мы не просто вынесли все поля, а добавили возможность подключать только те группы полей, которые нужны. Как правило, поля: created_by, created_date, last_updated_by, last_updated_date - указываются единой группой. А поле object_version_number может быть указанно, как с вышеперечисленными полями, так и без. Поэтому, первым делом, мы добавим интерфейс CoreEntity, в нём будут объявлены только геттер и сеттер для идентификатора сущности. Данный интерфейс всегда пригодиться в работе, не стоит им пренебрегать.

CoreEntity.java

public interface CoreEntity<Id extends Serializable> extends Serializable {

    Id getId();

    void setId(Id id);
}

Далее, создадим абстрактный класс BusinessEntity, в нём будут основные служебные поля, а также объявлены два метода prePersist() и preUpdate(). В этих методах будет происходить заполнение соответствующих полей при сохранении или при обновлении:

BusinessEntity.java


@Getter
@Setter
@MappedSuperclass
public abstract class BusinessEntity<Id extends Serializable> implements CoreEntity<Id> {

    public final static String DEFAULT_USER = "system";

    @Column(name = "created_by", nullable = false)
    private String createdBy;

    @Column(name = "created_date", nullable = false)
    private LocalDateTime creationDate;

    @Column(name = "last_updated_by", nullable = false)
    private String lastUpdatedBy;

    @Column(name = "last_updated_date", nullable = false)
    private LocalDateTime lastUpdateDate;

    @PrePersist
    public void prePersist() {
        this.creationDate = LocalDateTime.now(TimeZone.getDefault().toZoneId());
        this.lastUpdateDate = this.creationDate;

        if (StringUtils.isEmpty(this.createdBy)) {
            this.createdBy = findCurrentUser();
        }
        this.lastUpdatedBy = this.createdBy;
    }

    @PreUpdate
    public void preUpdate() {
        this.lastUpdateDate = LocalDateTime.now(TimeZone.getDefault().toZoneId());
        this.lastUpdatedBy = findCurrentUser();
    }

    private String findCurrentUser() {
        String user = null;
        if (SecurityContextHolder.getContext().getAuthentication() != null) {
            UserDetails userDetails = null;
            if (SecurityContextHolder.getContext().getAuthentication().getPrincipal() instanceof UserDetails) {
                userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
            }
            user = userDetails != null ? userDetails.getUsername() : null;
        }
        return user == null ? DEFAULT_USER : user;
    }
}

И наконец, создадим два абстрактных класса VersionedBusinessEntity и VersionedEntity. Каждый из них будет реализовывать механизм версионирования записи, по средствам аннотации @Version. Только первый будет наследоваться от BusinessEntity, а второй будет реализовывать интерфейс CoreEntity. Таким образом при наследовании нашей сущности от VersionedBusinessEntity мы автоматически добавим все 5 служебных полей. При наследовании от BusinessEntity мы добавим все поля кроме версии записи. А при наследовании от VersionedEntity мы добавим только версию записи. Таким образом, нам больше не нужно каждый раз в сущности описывать эти служебные поля и не нужно думать об их заполнении.

VersionedBusinessEntity.java


@Getter
@Setter
@MappedSuperclass
public abstract class VersionedBusinessEntity<Id extends Serializable> extends BusinessEntity<Id> {

    @Version
    @Column(name = "object_version_number", nullable = false)
    private Long version;

}

VersionedEntity.java


@Getter
@Setter
@MappedSuperclass
public abstract class VersionedEntity<Id extends Serializable> implements CoreEntity<Id> {

    @Version
    @Column(name = "object_version_number", nullable = false)
    private Long version;

}

Итак, давайте подытожим.

  1. Мы подключили Liquibase

  2. Создали структуру хранения скриптов "раската" БД

  3. Создали скрипты создания 2-х таблиц и наката данных в них

  4. Создали подключение к СУБД PostgreSQL в j-sso

  5. Создали объектно реляционное отображение

  6. Создали вспомогательные абстрактные классы для создания новых сущностей и упрощение жизни для нас будущих

Для полноценной работы нашего приложения нам осталось:

  1. добавить JPA репозитории соответствующих сущностей, для проведения CRUD операций

  2. Переделать поиск и сохранение пользователя в UserService

  3. Заставить бин RegisteredClientRepository работать с нашей таблицей в БД sso.system_oauth2_clients

  4. Подключить PasswordEncoder с использованием BCrypt хеш-функции.

Последние приготовления

Для создания JPA репозиториев, перейдём в директорию dao/repository. Удалим старый UserRepository и создадим два интерфейса SystemOauth2ClientRepository и UserRepository, которые будут наследованы от JpaRepository. Далее, добавим новый конфигурационный класс RootAppConfig. Данный конфиг будет предназначен для общих конфигураций не касающихся security. Сразу перенесём в него содержимое класса CORSConfig, чтобы не плодить конфигурационные классы. И тут мы возвращаемся к аннотации @EnableJpaRepositories. Данная аннотация включает механизм репозиториев JPA. Укажем в ней пакет с нашими репозиториями в параметре basePackages.

Теперь чуть-чуть остановимся, и сделаем следующие действия. Все конфигурационные классы перенесём в директорию config/security. Приложение стало расти и соответственно его конфигурация тоже, поэтому чтобы не путаться в конфигах, мы помещаем конфиги security в отдельную директорию.

Далее, давайте подключим PasswordEncoder и изменим реализацию бина RegisteredClientRepository. Первое очень легко сделать, в конфигурационном классе SecurityConfigUtilities добавим следующий бин:

SecurityConfigUtilities.java


@Configuration(proxyBeanMethods = false)
public class SecurityConfigUtilities {

    // ...

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(10);
    }

}

Далее осталось указать его у AuthenticationManager. Для этого поменяем SecurityConfig.

SecurityConfig.java


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

    private final CustomOAuth2UserService customOAuth2UserService;
    private final CustomUserDetailsService userDetailService;

    // inject-им наш password encoder бин
    private final PasswordEncoder passwordEncoder;

    @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.anyRequest().authenticated()
        );
        return http.formLogin(withhttps://github.com/dlabs71/spring-authorization-server-example/tree/chapter-2.2Defaults()).build();
    }
}

А как же его добавить в AuthenticationProvider для client authentication? Вот тут одновременно всё просто и всё сложно. Чтобы указать в явную, нам придётся пройти через "семь кругов ада spring" и создать кучу классов, которые и так есть по дефолту и они нас устраивают. Поэтому, давайте взглянем на исходники OAuth2AuthorizationServerConfigurer.

Найдём там DSL clientAuthentication(). Перейдём в OAuth2ClientAuthenticationConfigurer и посмотрим, как создаётся AuthenticationProvider по умолчанию.

OAuth2ClientAuthenticationConfigurer, метод createDefaultAuthenticationProviders
OAuth2ClientAuthenticationConfigurer, метод createDefaultAuthenticationProviders

В методе createDefaultAuthenticationProviders видим, что на 143 строке он пытается получить бин PasswordEncoder из HttpSecurity. А если мы взглянем ещё чуть глубже, в метод getOptionalBean, то увидим, что там он пытается достать бин по имени из ApplicationContext.

OAuth2ConfigurerUtils, метод getOptionalBean
OAuth2ConfigurerUtils, метод getOptionalBean

Это означает, что нам достаточно объявить бин PasswordEncoder с именем passwordEncoder и он будет использоваться в механизмах client authentication. То есть получается, что мы создав бин passwordEncoder везде его автоматически подключили.

Далее, нам надо создать свою реализацию RegisteredClientRepository, которая будет использовать SystemOauth2ClientRepository для доступа к данным. Для этого мв просто создадим DaoRegisteredClientRepository, которая будет реализовывать методы интерфейса RegisteredClientRepository. Реализация очень простая, вы можете посмотреть её в репозитории. Старый бин registeredClientRepository в AuthorizationServerConfig можно теперь удалить.

Так как в прошлой статье мы создали UserRepository с именами методов соответствующих спецификациям JPA, то в сервисах нам уже ничего изменять не надо. Достаточно только добавить метод findByEmail. Единственное, надо не забыть добавить аннотацию @Transactional над методами сервисов.

UserRepository.java


@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {

    UserEntity findByEmail(String email);
}

Теперь соберём и запустим. Не забудьте запустить СУБД и при помощи Liquibase создать схему БД. Если вы всё правильно сделали, то приложение успешно соберётся и запуститься. После того как вы пройдёте аутентификацию через Yandex, в таблице sso.users можно увидеть новую запись с данными полученными от Yandex.

Таблица `sso.users` после аутентификации через Yandex
Таблица `sso.users` после аутентификации через Yandex

Также, если вы проставите пароль, данной учётной записи (конечно, не просто пароль, а хеш пароля), то можно, используя данный email, проходить аутентификацию по логину и паролю. Здорово, не правда ли? ????

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

Раздел 2.3: Простым языком о ролевой модели

Всё время до этого мы аккуратно обходили стороной роли и привилегии, которые также должны управляться нашим SSO. Настало время про них поговорить и создать ролевую модель. На практике, мне доводилось работать с множеством разных ролевых моделей, но есть 3 модели, которые чаще всего встречаются. Их мы и разберём.

Первый вариант

Это простейший случай, обычно обитающий в маленьких или демо приложениях. У пользователя есть только роль, в зависимости от которой мы можем настроить тот или иной доступ до ресурсов нашего приложения. Ресурсами приложения обычно выступают его endpoint-ы. Например, есть у нас система электронной библиотеки университета. Хранятся в ней конечно же книги и есть 3 типа пользователей:

  1. Студент - имеет доступ только к просмотру библиотеки, поиску книги, чтению книги

  2. Администратор библиотеки - имеет доступ, что и студент. Но также имеет доступ к добавлению/удалению книги, статистике прочтения книги, управлению новостями библиотеки, управлению пользователями.

  3. Преподаватель - имеет доступ, что и студент. Но также имеет доступ к публикации собственных книг.

Таким образом достаточно создать 3 роли: STUDENT, ADMIN, TEACHER. Далее назначить конкретному пользователю конкретную роль для разрешения или запрета доступа к тем или иным ресурсам приложения.

Реализовать это можно очень просто:

  1. добавив дополнительную колонку role в таблицу users

  2. или можно создать отдельную таблицу roles и создать связь один ко многим между таблицами roles и users

Данная ролевая модель очень плохо подвержена масштабированию и если ваше приложение в дальнейшем будет развиваться, то её выбирать не стоит. Но если вам необходимо быстро построить маленькое приложение, возможно даже просто демо или тестовое приложение, то данная ролевая модель очень хорошо подойдёт. Она достаточно проста в реализации и не привносит лишних сложностей в код.

Ниже представлены две физические схемы БД рассматриваемого варианта:

Добавили дополнительную колонку role в таблицу users
Добавили дополнительную колонку role в таблицу users
Роли вынесли в отдельную таблицу
Роли вынесли в отдельную таблицу

Второй вариант

Следующий вариант будет чуть посложнее, но будет базироваться на первом варианте ролевой модели. Предположим, что нам приходит ряд требований на развитие сервиса библиотеки. И заказчик захотел самостоятельно создавать роли, давая той или иной роли доступ до конкретных ресурсов (как правило это endpoint-ы). В этом случае просто набором ролей, открывающих доступ до ряда ресурсов, мы не обойдёмся. Поэтому в начале нам необходимо создать справочник всех ресурсов. Далее на основе этого списка можно построить справочник привилегий - некая "мини роль" которая открывает доступ только до определённого ресурса приложения. Как правило, на каждый endpoint своя привилегия. А далее, из этого справочника мы можем создать группы привилегий - это и будут роли, которые мы можем назначить пользователю.

При реализации мы создадим табличку authorities (привилегии), создадим табличку roles (роли) и сделаем между ними связь многие ко многим. Связь между пользователем и ролями может быть как многие ко многим, так и многие к одному. Чаще всего встречается вариант со связью многие ко многим.

Ниже представлены две физические схемы БД рассматриваемого варианта:

Второй вариант, связь многие к одному
Второй вариант, связь многие к одному
Второй вариант, связь многие ко многим
Второй вариант, связь многие ко многим

Третий вариант

Следующий вариант, это ветвь развития предыдущей модели. Обычно встречается в очень крупных системах. Эта модель состоит также из ролей и привилегий, только теперь, помимо возможности назначить роль пользователю, добавляется возможность назначить определённую привилегию напрямую пользователю. Таким образом открывается возможность для администратора системы очень гибко настраивать доступ пользователям до конкретных ресурсов.

Третий вариант ролевой модели
Третий вариант ролевой модели

Выбираем вариант и реализуем его

Сейчас мы не можем в точности предсказать какие конкретно привилегии и роли нам будут нужны. Но мы знаем что:

  1. Мы используем Spring Security

  2. Разграничение доступа у нас будет на уровне endpoint-ов

  3. У нас будут системы (pet-проекты, для которых мы стоим наш SSO), которые делегируют управление пользователями и ролями нашему SSO

Это означает, что первый вариант точно не подойдёт. Так как нам удобнее всего работать с привилегиями, а потом их группировать в роли. Тем более учитывая что Spring Security позволяет это легко реализовать.

Есть ли смысл делать сейчас третий вариант? Думаю что нет, так как у нас не было конкретных требований к этому. А наша система не такая большая и пока не предполагает огромного разнообразия типов пользователей. Поэтому, мы выбираем второй вариант. Тем более, перейти на третий вариант, будет не сложно, даже когда система станет большой. Всё потому, что достаточно будет всего лишь добавить связать многие ко многим между пользователями и привилегиями, а остальное остаётся, как и было.

Соответственно, физическая схема БД нашей ролевой модели будет выглядеть следующим образом.

Физическая схема ролевой модели
Физическая схема ролевой модели

Скрипты создания данных таблиц очень простые, поэтому вы можете посмотреть их в репозитории. Аналогично и объектно реляционное отображение этих таблиц не представляет собой ничего сложного. Единственное, хочу сказать пару слов о полях role_code, authority_code и system_code.

У вас может сразу возникнуть вопрос, а зачем нам эти поля когда и так есть role_id и authority_id? Начнём с того, что с привилегиями мы будем работать в коде, и там удобнее всего иметь некий код, который своим наименованием будет отражать то для чего он создан. К примеру, если у нас есть привилегия на создание аккаунта, то её код может быть следующим ACCESS_CREATE_ACCOUNT. Аналогично и с ролями.

system_code - хранит в себе код системы (проекта), для которой роль или привилегия предназначена. Я подразумеваю, что привилегии для моих pet-проектов будут храниться и управляться j-sso. Соответственно, необходимо тогда понимать какая роль или привилегия предназначена для приложения делающего запрос на получение этих данных. Поэтому, мы сразу заложили основу для этого.

Чтобы нам было интереснее проверять работу нашего приложения с ролевой моделью, давайте добавим роль с кодом USER_SSO - она будет группировать в себе все необходимы привилегии обычного пользователя нашего j-sso. Соответственно, тогда создадим несколько привилегий:

  1. GET_OWN_DATA - Привилегия позволяет получить данные текущего пользователя

  2. CHANGE_OWN_DATA - Привилегия позволяет изменять данные текущего пользователя

  3. CHANGE_OWN_PASSWORD - Привилегия позволяет изменять пароль текущего пользователя

  4. DELETE_OWN_ACCOUNT - Привилегия позволяет удалять аккаунт текущего пользователя

Так как любой пользователь, прошедший регистрацию сразу становиться пользователем SSO и без вышеперечисленных привилегий не имеет возможности работы с базовыми функциями SSO, то пусть эта роль сразу добавляется при создании пользователя. Это означает, что нам необходимо изменить DefaultUserService. Напомню, там находиться методы создания пользователя при входе через Google, Github и Yandex.

DefaultUserService.java


@Service
@RequiredArgsConstructor
public class DefaultUserService implements UserService {

    // ......

    /**
     * Метод описывающий создание/обновление UserEntity на основе OAuth2User полученного из провайдера Github
     */
    private UserEntity saveUserFromGithab(OAuth2User userDto) {
        String email = userDto.getAttribute("email");
        UserEntity user = this.getEntityByEmail(email); // добавим дополнительный метод получения сущности UserEntity по email
        // ........
    }

    // ......

    /**
     * Метод получения сущности UserEntity по email
     * Если пользователь с данным email не найден в БД, то создаём новую сущность
     */
    private UserEntity getEntityByEmail(String email) {
        if (email == null) {
            throw new AuthException(AuthErrorCode.EMAIL_IS_EMPTY);
        }
        UserEntity user = this.userRepository.findByEmail(email);
        if (user == null) {
            user = new UserEntity();
            user.setEmail(email);
            user.setActive(true);
            // добавляем роль по умолчанию
            user.setRoles(List.of(roleRepository.getDefaultRole()));
        }
        return user;
    }
}

Теперь настало время чуть-чуть поменять наш AuthorizedUserMapper и далее можно проверять как это всё работает.

AuthorizedUserMapper.java


@UtilityClass
public class AuthorizedUserMapper {

    public AuthorizedUser map(UserEntity entity, AuthProvider provider) {
        // получаем список привилегий из сущности и помещаем его в параметр builder
        List<GrantedAuthority> authorities = getUserAuthorities(entity);
        return AuthorizedUser.builder(entity.getEmail(), entity.getPasswordHash(), authorities)
                .id(entity.getId())
                .firstName(entity.getFirstName())
                .secondName(entity.getSecondName())
                .middleName(entity.getMiddleName())
                .birthday(entity.getBirthday())
                .avatarUrl(entity.getAvatarUrl())
                .build();
    }

    // получаем список привилегий из сущности и преобразовываем каждый код привилегии в объект SimpleGrantedAuthority
    public List<GrantedAuthority> getUserAuthorities(UserEntity entity) {
        return entity.getRoles().stream()
                .filter(RoleEntity::getActive)
                .flatMap(role -> role.getAuthorities().stream())
                .filter(AuthorityEntity::getActive)
                .map(authority -> new SimpleGrantedAuthority(authority.getCode()))
                .collect(Collectors.toList());
    }
}

Обновите БД с использованием Liquibase, запустите j-sso и test-client. После аутентификации через GitHub мы увидим следующий результат.

Результат тестирования после добавления authorities
Результат тестирования после добавления authorities

Как видно, всё что мы сделали успешно сработало и у объекта авторизованного пользователя теперь есть привилегии. Это с вою очередь означает, что мы можем в дальнейшем защитить endpoint-ы соответствующими привилегиями при помощи аннотации @PreAuthorize. В добавок ко всему этому, данный механизм также будет работать и в j-service, так как мы полностью соблюли требования Spring Security при создании контекста на основе данных introspection endpoint.

Ниже представлен пример контроллера использующего аннотацию @PreAuthorize. Но чтобы данная аннотация заработала, необходимо сказать Spring Security включить поддержку этих механизмов. Для этого в SecurityConfig добавьте аннотацию @EnableMethodSecurity.

TestController.java


@RestController
public class TestController {

    @GetMapping("/test")
    @PreAuthorize("hasAnyAuthority('GET_OWN_DATA')")
    public String test() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        return authentication.getName();
    }
}

Теперь если вы запустите приложение, и пройдёте аутентификацию, то данный endpoint вернёт вам email адрес авторизованного пользователя. Если же поменять на другую привилегию, которой нет у пользователя, например @PreAuthorize("hasAnyAuthority('NOT_EXISTING_AUTHORITY')"), аутентифицироваться и также сделать запрос на данный endpoint, то в ответ уже придёт 403 ошибка - доступ запрещён.

Давайте теперь проверим как это будет работать с j-service. Для этого также поставим аннотацию @PreAuthorize("hasAnyAuthority('GET_OWN_DATA')") над тестовым endpoint. И не забудем в ResourceServerConfig добавить аннотацию @EnableMethodSecurity. При помощи test-client получим access токен и сделаем запрос на тестовый endpoint приложения j-service. И тут мы получаем... Ошибку ????

Stacktrace ошибки
Stacktrace ошибки

Здесь мы наткнулись на часто встречаемую ошибку, когда jackson не может создать экземпляр класса, из-за отсутствия конструктора по умолчанию. Да и вообще, давайте взглянем на DTO которую jackson пытается создать:

IntrospectionPrincipal.java


@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class IntrospectionPrincipal {

    private UUID id;
    private String firstName;
    private String secondName;
    private String middleName;
    private LocalDate birthday;
    private String avatarUrl;
    private String username;
    private String email;
    private Collection<? extends GrantedAuthority> authorities;
}

Как видно в качестве типа authorities, указан очень неоднозначный тип. Стоит запомнить, что такое jackson "не прощает" и выбрасывает подобные исключения. Поэтому, предлагаю сейчас переделать и передавать привилегии как список строк. В текущей реализации мы сознательно опускаем механизмы разграничения привилегий в зависимости от запрашиваемого приложения. Эту реализацию мы будем рассматривать в следующих статьях. А сейчас поменяем класс IntrospectionPrincipal в j-sso следующим образом:

j-sso/IntrospectionPrincipal.java


@Getter
@Builder
@AllArgsConstructor
public class IntrospectionPrincipal {

    private UUID id;
    private String firstName;
    private String secondName;
    private String middleName;
    private LocalDate birthday;
    private String avatarUrl;
    private String username;
    private String email;
    private List<String> authorities;

    public static IntrospectionPrincipal build(AuthorizedUser authorizedUser) {
        if (authorizedUser == null) {
            return null;
        }

        // создаём список строк из authorities в AuthorizedUser
        List<String> authorities = Collections.emptyList();
        if (authorizedUser.getAuthorities() != null) {
            authorities = authorizedUser.getAuthorities()
                    .stream()
                    .map(GrantedAuthority::getAuthority)
                    .collect(Collectors.toList());
        }

        return IntrospectionPrincipal.builder()
                .id(authorizedUser.getId())
                .firstName(authorizedUser.getFirstName())
                .secondName(authorizedUser.getSecondName())
                .middleName(authorizedUser.getMiddleName())
                .birthday(authorizedUser.getBirthday())
                .avatarUrl(authorizedUser.getAvatarUrl())
                .username(authorizedUser.getUsername())
                .email(authorizedUser.getEmail())
                // сетим выше-созданный список привилегий
                .authorities(authorities)
                .build();
    }
}

В аналогичном классе j-service достаточно только поменять тип у поля authorities. А методе build класса AuthorizedUser добавить создание списка классов SimpleGrantedAuthority из списка строк (код класса).

Теперь всё готово к запуску и тестированию. Проведите тестирование данного механизма, и вы убедитесь что аннотация @PreAuthorize в j-service также прекрасно работает, как и в j-sso.

Запрос к j-service с использованием GET_OWN_DATA
Запрос к j-service с использованием GET_OWN_DATA
Запрос к j-service с использованием NOT_EXISTING_AUTHORITY
Запрос к j-service с использованием NOT_EXISTING_AUTHORITY

Поднимем версии Spring

С прошлой статьи прошло достаточно времени, поэтому Spring Boot и Spring Authorization Server успели получить обновление. На момент написания текущей статьи Spring Boot 3.1.1, Spring Authorization Server 1.1.1. Для нашего проекта в новых версиях нет существенных изменений. Поэтому мы просто поднимем версии и всё.

Единственное, устарели DSL методы по типу builder. Теперь необходимо использовать DSL с параметром. Это нововведение затронуло только один класс - это SocialConfigurer. Здесь мы использовали добавление User Service устаревшим способом, поэтому просто чуть изменим его.

SocialConfigurer.java


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

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

    @Override
    public void init(HttpSecurity http) throws Exception {
        http.oauth2Login(oauth2Login -> {
            if (this.oAuth2UserService != null) {
                // было
                // oauth2Login.userInfoEndpoint().userService(this.oAuth2UserService);

                // стало
                oauth2Login.userInfoEndpoint(customizer -> {
                    customizer.userService(this.oAuth2UserService);
                });
            }
            if (this.successHandler != null) {
                oauth2Login.successHandler(this.successHandler);
            }
            if (this.failureHandler != null) {
                oauth2Login.failureHandler(this.failureHandler);
            }
        });
    }
}

Резюме

В данной статье мы рассмотрели построение Resource Server для нашего SSO. Подключили СУБД PostgreSQL к проекту и настроили механизмы раската схемы БД. Создали объектно-реляционное отображение таблиц БД и сделали необходимые правки по коду, чтобы j-sso корректно обрабатывал запросы. Рассмотрели три типа ролевой модели и добавили её в проект. Создали роли и привилегии, протестировали механизмы разграничения доступа Spring Security.

В следующей статье мы сосредоточимся на выполнении оставшихся технических требований и после чего перейдём к реализации регистрации пользователя и функции восстановления пароля пользователя.

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

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

  2. Предыдущая статья здесь

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

  4. Github репозиторий Spring OAuth2 Resource Server

  5. PostgreSQL image. Docker Hub

  6. Документация Spring Data

  7. Документация Spring Data JPA

  8. Гайд по подключению Spring Data JPA здесь

  9. Документация HikariCP

  10. HikariCP MBean (JMX) Monitoring and Management

  11. Документация liquibase-maven-plugin

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


  1. aol-nnov
    10.07.2023 08:52

    Скажите, а чем ваш фундаментальный труд будет принципиально отличаться, скажем, от KeyCloak? Или проект преследует чисто академические цели?