OpenID Connect Authorization Code Flow

Наш Gateway Service может выступать в терминологии OAuth 2.0 как в роли клиента Keycloak, так и в роли сервера ресурсов (resource server).

Первым рассмотрим сценарий:
  • Gateway Service выступает в качестве OAuth 2.0 клиента Keycloak
  • Микросервисы являются OAuth 2.0 серверами ресурсов.
  • Keycloak - сервер авторизации

Диаграммы последовательности Gateway Service как клиента Keycloak

В этом случае серверами ресурсов будут выступать остальные микросервисы. Например Orders Service будет контролировать доступ к эндпоинту /v1/menu-orders как для метода POST(создание заказа), так и для метода GET(получение списка заказов пользователя). GET-запрос к эндпоинту /v1/menu-orders на получение информации о заказах от неавторизованного пользователя может выглядеть следующим образом (используется рекомендуемый подход с помощью кода авторизации + PKCE).

PS: для увеличения изображение кликните на него или на сайте https://sequencediagram.org введите следующий текст в поле, где описывается последовательность вызовов, чтобы получить более наглядный вид (Ctrl+M - Presentation Mode):
title GET /menu-orders

entryspacing 0.9
User->Browser: Предоставь список заказов.
Browser->Gateway: GET /menu-orders
Gateway->Browser: Redirect to auth endpoint. Создаю сессионную куку.
Browser->Gateway: Follow Redirect to auth endpoint.
Gateway->Browser: 302 Redirect to Keycloak. Требуется аутентификация + авторизация.\nПередаю code_challenge и code_challenge_method параметры для Keycloak.
Browser->Keycloak: Аутентифицируй пользователя. Передаю code_challenge и\ncode_challenge_method параметры от Gateway.
Keycloak->Keycloak:Сохраняю code_challenge и code_challenge_method.
Keycloak->Browser: Предоставь логин и пароль.
User->Browser: Логин и пароль.
Browser->Keycloak: Аутентифицируй пользователя по логину и паролю.
Keycloak->Keycloak: Проверяю логин и пароль. Генерирую код авторизации.
Keycloak->Browser: 302 Redirect to Gateway +\nКод авторизации.
Browser->Gateway: Код авторизации.
Gateway->Keycloak: Меняю код авторизации на Id Token, Access Token, Refresh Token. Вот параметр code_verifier, чтобы проверить,\nчто это я отправлял запрос на получение кода авторизации и злоумышленники не перехватили его.
Keycloak->Keycloak: Проверяю code_challenge и code_verifier.
Keycloak->Gateway: Вот Id Token, Access Token, Refresh Token.
Gateway->Gateway: Ассоциирую авторизованного пользователя с сессией. Сохраняю токены в сессии.
Gateway->Browser: 302 Redirect на первоначальный эндпоинт
Browser->Gateway: GET /menu-orders + сессионная кука.
Gateway->Orders Service: GET /menu-orders + Access Token.
Orders Service->Keycloak: Предоставь публичный ключ для проверки Access Token (JWT).
Keycloak->Orders Service: Публичный ключ.
Orders Service->Orders Service: Кеширую публичный ключ (на 5 минут). Проверяю валидность токена\nПроверяю разрешение на доступ к ресурсам.
Orders Service->Gateway: Список заказов.
Gateway->Browser: Список заказов.

GET /menu-orders
  1. Неаутентифицированный пользователь пытается получить доступ к странице своих заказов в браузере.
  2. Браузер отправляет запрос GET на получение списка заказов в Gateway Service.
  3. Gateway Service понимает, что пользователь не аутентифицирован, поэтому перенаправляет Браузер на URL авторизации /oauth2/authorization/keycloak. При этом создается сессионная кука, по которой пользователь не может войти до тех пор, пока не введет логин и пароль.
  4. Браузер переходит по URL из редиректа и получает новый редирект, но уже в Keycloak. При этом в новом редиректе передаются параметры code_challenge и code_challenge_method:
    • code_challenge - результат хеширования рандомной строки с помощью метода code_challenge_method и последующего кодирования хеша в base64
    • code_challenge_method - способ хеширования строки, может принимать два значения:
      plain (не рекомендуется) - означает, что строка не хеширована;
      S256 - означает, что строка захеширована с помощью алгоритма SHA-256
    Эти параметры являются дополнительной защитой от перехвата кода авторизации (PKCE или Proof Key Code Exchange). На текущий момент использование PKCE рекомендуется для публичных клиентов OAuth2.0, однако в версии OAuth2.1 это станет обязательным для всех клиентов, поэтому логично использовать PKCE и в случае конфиденциального клиента. Помимо code_challenge и code_challenge_method, передаются следующие параметры:
    • response_type=code - означает что инициирован процесс аутентификации и авторизации с помощью кода авторизации.
    • client_id - идентификатор клиента.
    • scope - список запрашиваемых клиентом областей видимости. В нашем случае мы будем запрашивать openid, так как используется протокол аутентификации OpenID Connect и roles, так как нам требуются роли пользователя в токене доступа.
    • redirect_uri - адрес редиректа, который был настроен в Keycloak для клиента.
    • state - строка, которая используется для защиты от CSRF-атак во время получения кода авторизации.
    • nonce - строка, которая используется в протоколе OpenID Connect для ассоциации сессии клиента с токеном идентификации. В дальнейшем токен идентификации должен содержать это значение в claim с названием nonce.
  5. Keycloak сохраняет code_challenge и code_challenge_method, затем отправляет браузеру страницу, на которой необходимо ввести логин и пароль.
  6. Пользователь вводит логин и пароль.
  7. Браузер отправляет запрос в Keycloak на аутентификацию пользователя по логину и паролю.
  8. Keycloak проверяет логин и пароль, после чего генерирует код авторизации.
  9. Keycloak отправляет браузеру ответ с редиректом на Gateway Service, в ответе содержится код авторизации.
  10. Gateway Service отправляет запрос в Keycloak на обмен кода авторизации на токены идентификации, доступа и обновления. Одним из параметров запроса является code_verifier - строка, на основе которой был создан code_challenge с помощью code_challenge_method.
  11. Keycloak хеширует code_verifer с помощью метода code_challenge_method, кодирует хеш в base64 и проверяет полученный результат на совпадение с code_challenge. Если совпадения нет или вообще нет параметра code_verifier, это значит, что запрос на получение токенов пришел от злоумышленника, который перехватил код авторизации - токены не будут сгенерированы. В случае совпадения Keycloak генерирует токены и возвращает их Gateway Service.
  12. Gateway Service инициализирует пользовательскую сессию (так как у нас используется распределенная система, в которой одновременно может работать несколько экземпляров Gateway Service, данные о сессии необходимо хранить во внешней системе, например, в Redis). В сессии сохраняется информация о токенах. Теперь браузер может использовать ранее созданную куку, чтобы пользователю не приходилось при каждом запросе вводить логин и пароль. Gateway Service отправляет редирект на первоначальный эндпоинт.
  13. Браузер отправляет запрос в Gateway Service на получение списка заказов, в запросе содержится сессионная кука.
  14. Gateway Service видит куку, понимает, что пользователь аутентифицирован, получает из сессии токен доступа и отправляет запрос в Orders Service на получение заказа, в запросе содержится заголовок Authorization: Bearer + Access Token.
  15. Orders Service отправляет запрос в Keycloak на получение публичного ключа для проверки подписи JWT токена.
  16. Keycloak отправляет публичный ключ.
  17. Ключ кешируется на стороне Orders Service, чтобы в дальнейшем не требовалось каждый раз запрашивать его у Keycloak. По умолчанию ключ кешируется на 5 минут. Если требуется изменить эту настройку, придется создать кастомный бин JwtDecoder. Orders Service проверяет валидность токена, затем проверяет, разрешен ли пользователю с этим токеном доступ к ресурсам, которые он запрашивает. Проверка доступа осуществляется на основе ролей, которые были определены ранее в Keycloak и передаются в токене.
  18. Если пользователю доступ разрешен, в ответ возвращается список заказов.
  19. Gateway Service возвращает результат браузеру.
Как видно из диаграммы последовательности, в этом сценарии необходим браузер и участие пользователя. Как было сказано ранее, в разрабатываемом проекте не предусмотрен пользовательский интерфейс, поэтому данный подход будет крайне проблематично протестировать на практике - через браузер мы сможем отправлять только GET-запросы.

Несмотря на отсутствие UI, мы подробно разберем конфигурацию Gateway Service, Menu Service, Orders Service и Review Service для обеспечения аутентификации и авторизации с помощью OpenID Connect Authorization Code Flow в отдельной ветке security_client.

Настройка Gateway Service как клиента Keycloak

Создайте в локальном репозитории Gateway Service ветку security_client и продолжите работу в ней
Примените в ветке security_client патч  security_client.patch
  • Добавляем в build.gradle зависимости на Spring Boot Starter Oauth2 Client и Spring Spring Session Data Redis:
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    implementation 'org.springframework.session:spring-session-data-redis'
    Первая потребуется для настройки необходимых бинов в контексте безопасности Spring, вторая - для хранения сессий в Redis.
  • В application.yml настраиваем Gateway Service в качестве клиента в терминологии OAuth 2.0.
    Нам потребуется способ связи с сервером авторизации, а также необходимые для этого параметры: client-id, client-secret, scope. При поднятии Spring-контекста данные параметры считываются из application.yml и на их основе создается класс ClientRegistration, который сохранится в ReactiveClientRegistrationRepository (Spring предоставляет единственную реализацию данного интерфейса - InMemoryReactiveClientRegistrationRepository).
    spring:
      security:
        oauth2:
          client:
            registration:
              keycloak: # произвольное название. Проставляем название сервера авторизации
                client-id: cloud-java-gateway
                client-secret: ${external.keycloak-client-secret}
                scope: openid,roles
            provider:
              keycloak:
                issuer-uri: ${external.keycloak-url}
    
    external:
      ...
      keycloak-url: ${KEYCLOAK_URL:http://keycloak:8080/realms/cloud-java}
      keycloak-client-secret: 5eVPs8Am7if8P6KMUi4nSFpZJhIh1uE1
    • client-id - id, который был указан при создании клиента в Keycloak
    • client-secret - секрет, который сгенерировал Keycloak при создании клиента.
      keycloak-client-secret должен совпадать с тем, что у вас в настройках cloud-java-realm.json
    • scope - для начала процесса аутентификации по протоколу OpenID Connect один из параметров обязательно должен быть openid. Также указываем roles, чтобы запрашивать роли пользователя из Keycloak.
    • issuer-uri - URL по которому можно получить доступ к Realm нашего клиента. Используя этот URL, Spring также получит доступ к Discovery Endpoint, о котором говорилось в теоретической части - .well-known/openid-configuration. Зная его, Spring сможет запросить у Keycloak адреса эндпоинтов для аутентификации, получения токенов и информации о пользователе.
  • Кастомизируем процесс сохранения сессии в Redis: добавим таймаут (время хранения сессии) и неймспейс для ключей, по которым будут хранится данные в Redis:
    spring:
      session:
        timeout: 10m
        redis:
          namespace: cloudjava:gateway
  • Позаботимся о том, чтобы Gateway передавал полученный от сервера авторизации токен доступа в остальные микросервисы в заголовке Authorization: Bearer и сохранял сессии. Для этого в качестве дефолтных фильтров необходимо добавить TokenRelay и SaveSession.
    spring:
      cloud:
        gateway:
          default-filters:
            - TokenRelay
            - SaveSession
  • В ветке security_client в config-repository добавлены эти настройки Gateway Service.
    Указываем, что настройки config-repository мы будем получать из этой ветки:
    spring:
      cloud.config.label: security_client
  • Включаем логирование уровня TRACE для компонентов Spring Security и web - это позволит вам увидеть более полную картину происходящего во время отправки запросов:
    logging:
      level:
        org.springframework.security: TRACE
        web: TRACE
Остальная работа будет вестись в классе SecurityConfig.

Доступ к эндпоинтам

В SecurityConfig мы определим бин SecurityWebFilterChain, отвечающий за формирование цепочки фильтров безопасности. Фильтры будут обрабатывать запросы согласно тем настройкам, которые мы укажем в цепочке. Помимо этого, будут применены сконфигурированные спрингом фильтры по умолчанию, если мы их не переопределим.

Чтобы создать цепочку фильтров безопасности, отвечающих за обработку запросов в Spring Boot приложении, использующем WebFlux (стоит напомнить, что наш Spring Cloud Gateway основан на реактивном стеке со Spring WebFlux), нам необходимо определить бин SecurityWebFilterChain в классе, помеченном аннотациями @Configuration и @EnableWebFluxSecurity:
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
                                                         ReactiveClientRegistrationRepository clientRegistrationRepository) {
        return http
                .authorizeExchange(exchange ->
                        exchange
                                .pathMatchers("/actuator/**").permitAll()
                                .pathMatchers(HttpMethod.GET, "/v1/menu-items/**").permitAll()
                                .pathMatchers(HttpMethod.GET, "/v1/reviews/menu-item/**").permitAll()
                                .pathMatchers(HttpMethod.GET, "/v1/reviews/{id}").permitAll()
                                .pathMatchers(HttpMethod.GET, "/v1/menu-aggregate/**").permitAll()
                                .pathMatchers(HttpMethod.POST, "/v1/reviews/ratings/**").permitAll()
                                .anyExchange().authenticated())
                ...
                .build();
    }
С помощью заинжекченного ServerHttpSecurity мы настроим саму цепочку фильтров безопасности, а ReactiveClientRegistrationRepository потребуется для кастомизации логики выхода из нашего приложения (logout).

В приложении со Spring WebFlux взаимодействие по HTTP определяется с помощью сущности ServerWebExchange, предоставляющей доступ к HTTP-запросу и ответу. Поэтому при определении цепочки фильтров, отвечающей за доступ к тому или иному эндпоинту, используется метод ServerHttpSecurity#authorizeExchange (в коде это http.authorizeExchange), принимающий в качестве параметра Customizer<AuthorizeExchangeSpec>, который предоставляет удобный DSL для настройки прав доступа к эндпонитам.

Согласно полученной от архитекторов спецификации, предоставим открытый доступ всем пользователям к следующим эндпоинтам:
  • GET /v1/menu-items/** - все эндпоинты Menu Service для метода GET:
    • получение блюда по id
    • получение списка блюд из категории
  • GET /v1/reviews/menu-item/** - получение отзывов к блюду.
  • GET /v1/reviews/{id} - получение отзыва по его идентификатору.
  • GET /v1/menu-aggregate/** - все эндпоинты Menu Aggregate Service для метода GET:
    • получение полной информации о блюде, его рейтинге и отзывах по идентификатору блюда
    • получение списка блюд и их рейтингов из конкретной категории
  • POST /v1/reviews/ratings/** - получение информации о рейтингах и средних оценках блюд.
Также откроем доступ к эндпоинтам Spring Boot Actuator - /actuator/**.

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

Запускаем аутентификацию

Когда пользователь проходит аутентификацию и получает доступ к ресурсам
  1. в контекст безопасности спринга добавляется сущность Authentication, которая наследуется от Principal - эта сущность представляет собой зарегистрированного пользователя.
  2. создается сущность OAuth2AuthorizedClient, представляющая собой клиента (наше приложение), авторизованного на осуществление каких-либо действий с защищенным ресурсом. Основной задачей OAuth2AuthorizedClient является ассоциирование токена доступа с клиентом (нашим приложением) и владельцем защищенного ресурса (Resource Owner), который также является Principal.
  3. OAuth2AuthorizedClient сохраняется в репозитории ServerOAuth2AuthorizedClientRepository. Для хранения сведений об авторизованных клиентах в сессии используется реализация этого интерфейса WebSessionServerOAuth2AuthorizedClientRepository.

Чтобы запустить процесс аутентификации и авторизации по протоколам OpenID Connect и OAuth 2.0, необходимо сконфигурировать сущность OAuth2LoginSpec. По умолчанию будет использоваться вариант аутентификации и авторизации Authorization Code Flow, для чего в стартере сконфигурирован бин OAuth2AuthorizationCodeReactiveAuthenticationManager. Чтобы добавить PKCE в этот процесс, кастомизируем интерфейс ServerOAuth2AuthorizationRequestResolver:
    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
                                                         ReactiveClientRegistrationRepository clientRegistrationRepository) {
        return http
                ...
                .oauth2Login(oauth -> oauth.authorizationRequestResolver(pkceResolver(clientRegistrationRepository)))
                .build();
    }

    /**
     * Резолвер запросов на авторизацию в сервер авторизации.
     * Позволяет кастомизировать запрос на авторизацию. В данном случае мы добавляем PKCE защиту:
     * на стороне клиента будет сформирован code_verifier, на основе
     * code_verifier будет подготовлен code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier))).
     * Далее в запрос на авторизацию будут добавлены параметры: code_challenge и code_challenge_method.
     */
    private ServerOAuth2AuthorizationRequestResolver pkceResolver(ReactiveClientRegistrationRepository clientRegistrationRepository) {
        var resolver = new DefaultServerOAuth2AuthorizationRequestResolver(clientRegistrationRepository);
        resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());
        return resolver;
    }

Добавляем сессии

Для сохранения данных об авторизованных клиентах в сессии создадим бин WebSessionServerOAuth2AuthorizedClientRepository:
    /**
     * Добавляем в контекст Спринга реализацию [ServerOAuth2AuthorizedClientRepository],
     * которая хранит данные об авторизованных клиентах OAuth 2.0 [OAuth2AuthorizedClient] в сессии.
     * Клиент считается авторизованным, когда пользователь (владелец ресурсов) предоставил
     * ему доступ к защищенным ресурсам. Фактически [OAuth2AuthorizedClient] связывает
     * токен доступа с клиентом и владельцем ресурсов (пользователем).
     */
    @Bean
    public ServerOAuth2AuthorizedClientRepository serverOAuth2AuthorizedClientRepository() {
        return new WebSessionServerOAuth2AuthorizedClientRepository();
    }
По умолчанию данные о клиенте хранятся в памяти приложения, однако наш Gateway Service может быть развернут в нескольких экземплярах, поэтому такой вариант нам не подходит. Для хранения сессий у нас используется Redis, таким образом каждый экземпляр Gateway Service будет иметь актуальную информацию об авторизованных клиентах.

В application.yml изменяем название сессионной куки:
server:
  reactive:
    session:
      cookie:
        name: CLOUD_JAVA_SESSION

Конфигурируем выход из учетной записи

Представьте ситуацию, когда авторизованный на нашем сайте пользователь решил выйти из своей учетной записи. Он нажимает кнопку Выйти (Logout), и наше приложение прекращает авторизованную сессию с данным пользователем. Однако по умолчанию наше приложение не уведомляет сервер авторизации о том, что пользователь вышел из учетки. Для этого необходимо добавить ServerLogoutSuccessHandler , который также инициирует процесс выхода из учетной записи на сервере авторизации. В Spring такой класс уже реализован - OidcClientInitiatedServerLogoutSuccessHandler. В качестве postLogoutRedirectUri нам необходимо указать Valid post logout redirect URIs, который прописан в конфигурации Keycloak (http://localhost:9099). На различных стендах развертывания приложения этот URI может отличаться, поэтому разработчики Spring предоставили возможность воспользоваться плейсхолдером {baseUrl}, который в рантайме будет заменен на требуемый URI.
    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
                                                         ReactiveClientRegistrationRepository clientRegistrationRepository) {
        return http
                ...
                .logout(logout -> logout.logoutSuccessHandler(serverLogoutSuccessHandler(clientRegistrationRepository)))
                .build();
    }

    /**
     * При нажатии на кнопку Logout на сайте, пользователь выходит из своей учетной записи на
     * самом сайте, однако на сервере авторизации (в нашем случае Keycloak) он по умолчанию
     * остается авторизованным. Согласно лучшим практикам, нам необходимо разлогинить
     * пользователя на сервере авторизации. Для этого мы создаем реализацию интерфейса
     * [ServerLogoutSuccessHandler], которая отвечает за этот процесс - [OidcClientInitiatedServerLogoutSuccessHandler].
     * Реализации необходимо знать информацию о клиенте OAuth 2.0, для этого в конструкторе
     * мы передаем репозиторий [ReactiveClientRegistrationRepository], хранящий данные сведения.
     * По умолчанию в Spring данные сведения хранятся в памяти приложения.
     */
    private ServerLogoutSuccessHandler serverLogoutSuccessHandler(ReactiveClientRegistrationRepository clientRegistrationRepository) {
        var logoutSuccessHandler = new OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository);
        logoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}");
        return logoutSuccessHandler;
    }

Обновляем RequestRateLimiter

В самом начале разработки Gateway Service мы добавили функционал по ограничению количества входящих запросов от клиента в секунду с помощью RequestRateLimiter и алгоритма Token Bucket. В RequestRateLimiterConfig мы определили один общий bucket для всех запросов и договорились провести рефакторинг, когда будет реализована аутентификация и авторизация. Теперь мы можем для каждого пользователя сделать собственный bucket, получив его имя из объекта Principal, который доступен в ServerWebExchange. Если же запрос приходит на эндпоинт, который не требует аутентификации, мы создаем bucket на основе IP-адреса, с которого пришел запрос:
@Configuration
public class RequestRateLimiterConfig {

    private static final String DEFAULT_BUCKET = "anyUser";

    @Bean
    public KeyResolver keyResolver() {
        return exchange -> exchange.getPrincipal()
                .map(Principal::getName)
                .defaultIfEmpty(getIpAsStringOrDefault(exchange));
    }

    private String getIpAsStringOrDefault(ServerWebExchange exchange) {
        return Optional.ofNullable(exchange.getRequest().getRemoteAddress())
                .map(InetSocketAddress::getAddress)
                .map(InetAddress::getHostAddress)
                .orElse(DEFAULT_BUCKET);
    }
}
Стоит отметить, что такой подход к получению IP-адреса будет работать в том случае, если перед нашим Gateway Service не будет стоять Proxy-сервер. При наличии Proxy-сервера придется вычитывать заголовок X-Forwarded-For для идентификации исходного IP-адреса.

Защита от CSRF-атак

CSRF (Cross-Site Request Forgery) атака, также известная как "атака подделки межсайтовых запросов", происходит, когда злоумышленник обманом заставляет пользователя выполнить непреднамеренное действие на сайте, на котором он аутентифицирован. Это действие может быть выполнено без ведома пользователя и может иметь серьезные последствия, такие как изменение данных пользователя, проведение транзакций и т.д.
Kак работает CSRF-атака?
  • Аутентификация: Пользователь аутентифицируется на сайте и получает сессионный токен.
  • Перенаправление: Злоумышленник создает вредоносный сайт или ссылку, которая содержит запрос на целевой сайт.
  • Выполнение запроса: Пользователь, будучи аутентифицированным, посещает вредоносный сайт и невольно выполняет запрос, который использует его сессионный токен для выполнения действий на целевом сайте.
Пример CSRF-атаки
Сценарий
  1. Целевой сайт: пользователь аутентифицирован на сайте банка bank.com. На сайте есть форма для перевода денег, URL-адрес для перевода денег: https://bank.com/transfer.
  2. Форма на сайте банка:
    <form action="https://bank.com/transfer" method="POST">
        <input type="hidden" name="toAccount">
        <input type="hidden" name="amount">
        <button type="submit">Transfer</button>
    </form>
  3. Злоумышленник: создает вредоносный сайт attacker.com. На этом сайте размещена скрытая форма, которая автоматически отправляется при загрузке страницы:
    <body onload="document.getElementById('csrfForm').submit()">
       <form id="csrfForm" action="https://bank.com/transfer" method="POST">
           <input type="hidden" name="toAccount" value="9876543210">
           <input type="hidden" name="amount" value="1000">
       </form>
    </body>
Последовательность атаки:
  1. Аутентификация: Пользователь входит в свой аккаунт на bank.com и его сессия остается активной.
  2. Визит на вредоносный сайт: Пользователь случайно заходит на сайт attacker.com (например, по ссылке в электронной почте).
  3. Автоматическая отправка формы: При загрузке страницы на attacker.com, JavaScript код автоматически отправляет форму.
  4. Запрос к bank.com: Вредоносный запрос отправляется на bank.com с использованием активной сессии пользователя.
  5. Действие выполнено: Банк обрабатывает запрос как легитимный, так как он пришел с авторизованной сессии пользователя, и переводит деньги на счет злоумышленника.
Способы защиты от CSRF-атаки
  1. Использование CSRF-токенов
    Наиболее распространенный метод защиты от CSRF-атак — использование CSRF-токенов. Эти токены генерируются сервером и добавляются в формы или запросы, которые отправляются клиентом. Каждая форма содержит уникальный CSRF_TOKEN токен, который сервер проверяет при получении запроса.
    
    <form action="https://bank.com/transfer" method="POST">
        <input type="hidden" name="_csrf" value="CSRF_TOKEN">
        <input type="hidden" name="toAccount">
        <input type="hidden" name="amount">
        <button type="submit">Transfer</button>
    </form>
    Злоумышленник не имеет доступа к токену, поэтому при отправке запроса со зловредного сайта, сервер не будет выполнять перевод денег, так как запрос не пройдет проверку.
  2. Проверка Referer и Origin заголовков
    Сервер может проверять заголовки Referer и Origin, чтобы убедиться, что запросы приходят с доверенного источника. Это менее надежный метод, так как заголовки могут быть подделаны, но все же добавляет дополнительный уровень защиты.
  3. Использование SameSite атрибутов для cookies
    Атрибут SameSite для cookies позволяет ограничить использование cookies только запросами с того же сайта. Например:
    @Bean
    public WebFilter cookieWebFilter() {
        return (exchange, chain) -> {
            exchange.getResponse()
                    .addCookie(ResponseCookie.from("XSRF-TOKEN", csrfToken)
                            .httpOnly(true)
                            .sameSite("Strict")
                            .build());
            return chain.filter(exchange);
        };
    }
Еще про защиту от CSRF можно прочитать тут.
Spring Security и CSRF
По умолчанию в Spring Security включена защита от CSRF-атак с помощью токена CSRF. Сервер генерирует уникальный токен для каждого пользователя и предоставляет его сайту, который встраивает этот токен в качестве невидимого поля в форму отправки и при каждом запросе будет его посылать на сервер, чтобы доказать, что запрос приходит от корректного сайта. Однако если мы используем SPA (single-page application), написанное, например, на Angular, которое отправляет AJAX запросы, такой вариант не подойдет. В этом случае требуется наличие особой куки от сервера с названием XSRF-TOKEN. Мы предполагаем, что UI для нашего онлайн-кафе будет написан с использованием JavaScript, поэтому заранее позаботимся о том, чтобы токены CSRF хранились в куках, для чего определим CookieServerCsrfTokenRepository. По умолчанию куки будут хранить не сам токен, а его маскированное значение - маска накладывается с помощью XOR в классе XorServerCsrfTokenRequestAttributeHandler.

Однако это не все - ведь мы используем реактивный подход, который имеет свои нюансы. Токен CsrfToken будет доступен в качестве атрибута в ServerWebExchange в виде Mono<CsrfToken>, однако он не будет сохранен в нашем CookieServerCsrfTokenRepository, пока на него кто-нибудь не подпишется - ведь, как мы знаем, реактивные стримы начинают свою работу только после подписки на них. Эта проблема известна давно, и в Spring Security открыто issue по данному вопросу. Подходящий для нас вариант решения - зарегистрировать в контексте спринга бин типа WebFilter, единственной целью которого будет подписываться на данный стрим Mono<CsrfToken>, если он есть:
    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http,
                                                         ReactiveClientRegistrationRepository clientRegistrationRepository) {
        return http
                ...
                .csrf(csrf -> csrf
                        // Используем куки для хранения токенов CSRF. Для того, чтобы Angular
                        // и другие приложения на JS поддерживали такой механизм, необходимо
                        // установить атрибут куки httpOnly = false.
                        // По умолчанию токены CSRF хранятся в веб-сессии.
                        .csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse())
                        // Токены CSRF доступны в качестве атрибута ServerWebExchange, это достигается
                        // за счет наличия в контексте спринга реализации интерфейса ServerCsrfTokenRequestHandler,
                        // по умолчанию используется XorServerCsrfTokenRequestAttributeHandler, который
                        // умеет маскировать токены (c помощью XOR операции) и получать их значение обратно. В данном случае,
                        // конфигурация полностью совпадает с дефолтной и приведена здесь в качестве примера.
                        .csrfTokenRequestHandler(new XorServerCsrfTokenRequestAttributeHandler()))
                .build();
    }

    /**
     * Этот бин извлекает из ServerWebExchange атрибут CsrfToken, и
     * подписывается на него. Такое поведение необходимо, так как без подписки
     * реактивные стримы не будут выполняться, и сохранения CSRF токена в
     * CookieServerCsrfTokenRepository не произойдет.
     * На эту тему открыто issue https://github.com/spring-projects/spring-security/issues/5766
     */
    @Bean
    public WebFilter csrfWebFilter() {
        return (exchange, chain) -> {
            exchange.getResponse().beforeCommit(() -> Mono.defer(() -> {
                Mono<CsrfToken> csrfToken = exchange.getAttribute(CsrfToken.class.getName());
                return csrfToken != null ? csrfToken.then() : Mono.empty();
            }));
            return chain.filter(exchange);
        };
    }
Соберите проект ./gradlew clean build и образ микросервиса: ./gradlew bootBuildImage.

На этом конфигурация Gateway Service в качестве клиента OAuth 2.0 завершена. Приступим к конфигурации микросервисов в качестве серверов ресурсов.

Настройка микросервисов как серверов ресурсов

Настраиваем Menu Service

Откройте репозиторий menu-service.
От последнего коммита в ветке spring_cloud создайте ветку security_client и продолжите работу в ней
Примените в ветке security_client патч  security_client.patch
Патч находится в каталоге /patch/spring_cloud/
  • В build.gradle добавляем зависимости на Spring Boot Starter OAuth2 Resource Server и TestContainers Keycloak.
    Последняя потребуется для того, чтобы можно было протестировать интеграцию контроллера с сервером авторизации:
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
    testImplementation "com.github.dasniko:testcontainers-keycloak:${keyCloakTestContainerVersion}"
  • В application.yml добавлена конфигурация для OAuth 2.0 Resource Server, в которой указан URL для связи с Keycloak (issuer-uri), а также URL, по которому можно получить публичный ключ для проверки подписи JWT токена (jwk-set-uri):
    spring:
      security:
        oauth2:
          resourceserver:
            jwt:
              issuer-uri: ${external.keycloak-url}
              jwk-set-uri: ${external.jwk-set-url}
    
    external:
      ...
      keycloak-url: ${KEYCLOAK_URL:http://keycloak:8080/realms/cloud-java}
      jwk-set-url: ${JWK_SET_URL:http://keycloak:8080/realms/cloud-java/protocol/openid-connect/certs}
                            
  • В ветке security_client в config-repository добавлены настройки OAuth 2.0 Resource Server.
    Указываем, что настройки config-repository мы будем получать из этой ветки:
    spring:
      cloud.config.label: security_client
    
  • Создаем класс SecurityConfig и помечаем его аннотациями @Configuration и @EnableWebSecurity. В нем:
    • Настраиваем цепочку фильтров безопасности SecurityFilterChain, которая используется в Spring MVC приложениях. В этих фильтрах мы разрешаем доступ к эндпоинтам создания, удаления и обновления блюд только пользователям с ролью ADMIN. Напомним - при рефакторинге Menu Service мы добавили запрос на информацию по списку блюд через POST /v1/menu-items/menu-info, чтобы список имен передавать в теле запроса, а не в URL. Роль мы будем получать из токена доступа JWT, который передается в каждом запросе от Gateway Service.
    • Настраиваем наш микросервис в качестве сервера ресурсов, указывая ему, что работа будет вестись с токенами типа JWT - oauth2ResourceServer(customizer -> customizer.jwt(..)
    • Каждый запрос будет сопровождаться токеном, поэтому нам нет нужды хранить сессию, поэтому отключаем этот механизм - SessionCreationPolicy.STATELESS
    • Отключаем защиту от CSRF-атак - ведь эндпоинты Menu Service будут доступны только Gateway Service, а не сайтам - csrf(AbstractHttpConfigurer::disable).
    • По умолчанию Spring ожидает, что каждая роль, передаваемая в токене доступа, будет иметь префикс ROLE_. На текущий момент наши роли представлены так: USER, ADMIN и хранятся в JWT claim с названием roles. Чтобы роли поступали на обработку в нужном виде, нам потребуется зарегистрировать в контексте кастомный JwtAuthenticationConverter.
    @Configuration
    @EnableWebSecurity
    public class SecurityConfig {
    
        @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
            return http
                    .authorizeHttpRequests(customizer -> customizer
                            .requestMatchers(HttpMethod.GET, "/v1/menu-items/**").permitAll()
                            .requestMatchers(HttpMethod.POST, "/v1/menu-items/menu-info").permitAll()
                            .requestMatchers("/actuator/**").permitAll()
                            .requestMatchers(HttpMethod.POST, "/v1/menu-items/**").hasRole("ADMIN")
                            .requestMatchers(HttpMethod.DELETE, "/v1/menu-items/**").hasRole("ADMIN")
                            .requestMatchers(HttpMethod.PATCH, "/v1/menu-items/**").hasRole("ADMIN")
                            .anyRequest().authenticated()
                    )
                    .oauth2ResourceServer(customizer -> customizer.jwt(Customizer.withDefaults()))
                    .sessionManagement(customizer -> customizer
                            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                    )
                    .csrf(AbstractHttpConfigurer::disable)
                    .build();
        }
    
        @Bean
        public JwtAuthenticationConverter jwtAuthenticationConverter() {
            var grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
            grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
            grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
    
            var authConverter = new JwtAuthenticationConverter();
            authConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
            return authConverter;
        }
    }
Эти настройки обеспечивают сценарий, при котором сервер ресурсов, обрабатывая входящий запрос с токеном доступа, получает от сервера авторизации (Keycloak) публичный ключ для расшифровки токена (запрос на получение ключа уходит по адресу, указанному в spring.security.oauth2.resourceserver.jwt.jwk-set-uri), кеширует ключ на 5 минут, проверяет валидность токена, проверяет возможность доступа к ресурсу на основе ролей, переданных в токене доступа, и лишь после этого формирует ответ на запрос. Если необходимо изменить время кеширования получаемого от Keycloak публичного ключа, то потребуется настроить свой JwtDecoder и прописать его в конфигурации.
Тестирование
Позаботимся о тестовом окружении - ведь теперь запросы могут сопровождаться токеном, который необходимо проверить, а для этого требуется работающий сервер авторизации. На помощь приходит реализация тестового контейнера Keycloak.

  • В MenuItemControllerTest добавим контейнер KeycloakContainer, который будет импортировать json c пользователями, ролями и клиентами из файла cloud-java-realm.json. Запрос токена будет происходить по username/password, поэтому в настройках клиента (запись с "clientId" : "cloud-java-gateway") должно быть "directAccessGrantsEnabled" : true.
    public class MenuItemControllerTest extends BaseIntegrationTest {
    
        private static final KeycloakContainer KEYCLOAK = new KeycloakContainer("quay.io/keycloak/keycloak:24.0")
                .withRealmImportFile("/cloud-java-realm.json");
    
        static {
            KEYCLOAK.start();
        }
    
        @DynamicPropertySource
        static void registerProperties(DynamicPropertyRegistry registry) {
            registry.add("spring.security.oauth2.resourceserver.jwt.issuer-uri", () -> KEYCLOAK.getAuthServerUrl() + "/realms/cloud-java");
            registry.add("spring.security.oauth2.resourceserver.jwt.jwk-set-uri", () -> KEYCLOAK.getAuthServerUrl() + "/realms/cloud-java/protocol/openid-connect/certs");
        }
  • При обработке эндпоинтов создания, удаления и модификации блюда наш контроллер ожидает, что в запросе будет заголовок Authorization: Bearer, содержащий JWT-токен доступа. Нам необходимо программно добавить такой токен в соответствующие запросы, который мы отправляем в тестах с помощью WebTestClient. При этом токены не могут быть случайными, так как Menu Service настроен в качестве сервера ресурсов, он будет проверять токены с помощью публичного ключа, полученного от Keycloak. Другими словами, нам необходимо получать эти токены в Keycloak и подставлять их в заголовки. Для этого мы воспользуемся обычным спринговским WebClient, с помощью которого отправим запрос в Keycloak на получение токена доступа по логину и паролю пользователя (наш тестовый клиент позволяет такой flow).

    Для наглядности инкапсулируем токен доступа в классе AuthToken:
    public class AuthToken {
    
        private final String accessToken;
    
        @JsonCreator
        public AuthToken(@JsonProperty("access_token") String accessToken) {
            this.accessToken = accessToken;
        }
    
        public String getAccessToken() {
            return accessToken;
        }
    }
    Перед запуском тестов получаем два токена: один для админа, другой для простого пользователя.
    В методе для createToken для отправки запроса в Keycloak на получение токена доступа отправляем POST-запрос, содержащий следующие параметры:
    • grant_type - указываем password
    • client_id - указываем идентификатор клиента (cloud-java-gateway)
    • username - имя пользователя (админ - alex, простой пользователь - max)
    • password - пароль (для всех пользователей это password)
    • client_secret - секрет клиента
    Секрет можно использовать сохраненный или в cloud-java-realm.json поменять в конфигурации
    {
        "clientId" : "cloud-java-gateway",
        "secret" : "iaDMVOKEGssvW5XRaaqZN4EO3lkvdRu6" <-- "secret"
    }
    public class MenuItemControllerTest extends BaseIntegrationTest {
        ...
        private static AuthToken admin;
        private static AuthToken user;
    
        @BeforeAll
        static void setup() {
            WebClient webClient = WebClient.builder()
                    .baseUrl(KEYCLOAK.getAuthServerUrl() + "/realms/cloud-java/protocol/openid-connect/token")
                    .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
                    .build();
            admin = createToken(webClient, "alex", "password");
            user = createToken(webClient, "max", "password");
        }
        ...
    
        private static AuthToken createToken(WebClient webClient, String username, String password) {
            return webClient.post()
                    .body(fromFormData("grant_type", "password")
                            .with("client_id", "cloud-java-gateway")
                            .with("username", username)
                            .with("password", password)
                            .with("client_secret", "iaDMVOKEGssvW5XRaaqZN4EO3lkvdRu6")
                    )
                    .retrieve()
                    .bodyToMono(AuthToken.class)
                    .block();
        }
    }

    Теперь мы можем подставлять созданные токены в заголовки нужных запросов.
    При создании блюда вернется статус:
    • 201 - isCreated, если в запросе присутствует валидный токен доступа и пользователь admin
    • 401 - isUnauthorized, если токен отсутствует
    • 403 - isForbidden, если у пользователя нет прав на обновление (пользоватеь user)
        @Test
        void createMenuItem_createsItem() {
            var dto = createMenuRequest();
            var now = LocalDateTime.now();
    
            webTestClient.post()
                    .uri(BASE_URL)
                    .headers(h -> h.setBearerAuth(admin.getAccessToken()))
                    .accept(MediaType.APPLICATION_JSON)
                    .bodyValue(dto)
                    .exchange()
                    .expectStatus().isCreated()
                    ...
        }
    
        @Test
        void createMenuItem_returnsUnauthorized_whenNoAccessToken() {
            var dto = createMenuRequest();
    
            webTestClient.post()
                    .uri(BASE_URL)
                    .accept(MediaType.APPLICATION_JSON)
                    .bodyValue(dto)
                    .exchange()
                    .expectStatus().isUnauthorized();
        }
    
        @Test
        void createMenuItem_returnsForbidden_forSimpleUser() {
            var dto = createMenuRequest();
    
            webTestClient.post()
                    .uri(BASE_URL)
                    .headers(h -> h.setBearerAuth(user.getAccessToken()))
                    .accept(MediaType.APPLICATION_JSON)
                    .bodyValue(dto)
                    .exchange()
                    .expectStatus().isForbidden();
        }
    Аналогичным образом протестированы методы обновления и удаления блюда.
Соберите проект ./gradlew clean build и образ микросервиса: ./gradlew bootBuildImage.

Настраиваем Review Service

Откройте репозиторий review-service.
От последнего коммита в ветке spring_cloud создайте ветку security_client и продолжите работу в ней
Примените в ветке security_client патч  security_client.patch
Патч находится в каталоге /patch/spring_cloud/
  • В build.gradle и application.yml внесены аналогичные изменения.
  • Настройка SecurityFilterChain отличается от Menu Service только разрешениями на доступ к эндпоинтам, настройка JwtAuthenticationConverter и HttpSecurity идентичная.
    @Configuration
    @EnableWebSecurity
    public class SecurityConfig {
    
        @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
            return http
                    .authorizeHttpRequests(customizer -> customizer
                            .requestMatchers(HttpMethod.POST, "/v1/reviews/ratings").permitAll()
                            .requestMatchers(HttpMethod.POST, "/v1/reviews/**").hasRole("USER")
                            .requestMatchers(HttpMethod.GET, "/v1/reviews/my/**").hasRole("USER")
                            .requestMatchers(HttpMethod.GET, "/v1/reviews/menu-item/**").permitAll()
                            .requestMatchers(HttpMethod.GET, "/v1/reviews/{id}").permitAll()
                            .requestMatchers("/actuator/**").permitAll()
                    ...
  • Отличия присутствуют в методах MenuOrderController, где мы получали имя пользователя, создающего отзыв (в createReview) или пытающегося получить список своих отзывов (getReviewsOfUser), в заголовке X-User-Name. Cейчас мы получаем его из токена доступа, который может передаваться в качестве параметра org.springframework.security.oauth2.jwt.Jwt, помеченного аннотацией @AuthenticationPrincipal, в любой из методов контроллера. Так как мы настроили Review Service в качестве сервера ресурсов, работающего с JWT-токенами, с помощью этой аннотации Spring будет автоматически получать токен доступа из объекта Authentication (конкретная реализация данного интерфейса в данном случае будет JwtAuthenticationToken) и конвертировать его в сущность Jwt, из которой мы уже можем получить имя пользователя с помощью метода Jwt.getClaimAsString(String claimName). При этом название claim для имени пользователя - preferred_username. Например, эндпоинт создания отзыва теперь будет выглядеть следующим образом:
    @PostMapping
        @ResponseStatus(HttpStatus.CREATED)
        public ReviewResponse createReview(@RequestBody
                                           @Valid
                                           CreateReviewRequest request,
                                           @AuthenticationPrincipal Jwt jwt) {
            var username = jwt.getClaimAsString(USERNAME_CLAIM);
            ...
                            
  • Тестирование контроллера также осуществляется с помощью библиотеки TestContainers и получения токенов доступа в тестовом Keycloak.
  • В TestConstants и в insert-data.sql имена получаемых в токене пользователей заменили теми, что созданы в Keycloak.
Соберите проект ./gradlew clean build и образ микросервиса: ./gradlew bootBuildImage.

Настраиваем Orders Service

Откройте репозиторий orders-service.
От последнего коммита в ветке spring_cloud создайте ветку security_client и продолжите работу в ней
Примените в ветке security_client патч  security_client.patch
Патч находится в каталоге /patch/spring_cloud/
  • В build.gradle и application.yml внесены аналогичные изменения.
  • Открываем доступ к эндпоинтам Spring Boot Actuator, разрешаем доступ к эндпоинтам контроллера только пользователям с ролью USER. Настраиваем Orders Service как сервер ресурсов (oauth2ResourceServer), а также отключим сессии и защиту от CSRF-атак. Для отключения сессий в Spring WebFlux необходимо отключить сохранение контекста безопасности между вызовами с помощью NoOpServerSecurityContextRepository:
    @Configuration
    @EnableWebFluxSecurity
    public class SecurityConfig {
        @Bean
        public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
            return http
                    .authorizeExchange(exchange ->
                            exchange
                                    .pathMatchers("/actuator/**").permitAll()
                                    .pathMatchers("/v1/menu-orders/**").hasRole("USER")
                                    .anyExchange().authenticated())
                    .oauth2ResourceServer(customizer -> customizer.jwt(Customizer.withDefaults()))
                    .securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
                    .csrf(ServerHttpSecurity.CsrfSpec::disable)
                    .build();
        }
    }
  • Если в Spring MVC приложении за конвертацию JWT claims в GrantedAuthority отвечают классы JwtAuthenticationConverter и JwtGrantedAuthoritiesConverter, то в Spring WebFlux приложении нам требуется реализовать интерфейс org.springframework.core.convert.converter.Converter<Jwt, Flux<GrantedAuthority>>, после чего настроить ReactiveJwtAuthenticationConverter. Кастомный конвертер нужен для того, чтобы корректно получать роли пользователя из токена - на текущий момент роли представлены в виде строк USER и ADMIN, однако Spring-у требуется префикс ROLE_ перед каждой ролью. После этого мы сможем настраивать стратегию авторизации эндпоинтов с помощью метода ServerHttpSecurity.hasRole(String roleName).

    Реализуем этот конвертер и пометим его аннотацией @Component, чтобы он попал в контекст Spring-а, а в классе SecurityConfig определяем бин ReactiveJwtAuthenticationConverter:
    @Component
    public class KeycloakClientAuthoritiesConverter implements Converter<Jwt, Flux<GrantedAuthority>> {
    
        @Override
        public Flux<GrantedAuthority> convert(Jwt source) {
            final var roles = source.getClaimAsStringList("roles");
            return Flux.fromStream(roles.stream())
                    .map("ROLE_%s"::formatted)
                    .map(SimpleGrantedAuthority::new)
                    .map(GrantedAuthority.class::cast);
        }
    }
    
    @Configuration
    @EnableWebFluxSecurity
    public class SecurityConfig {
        ...
    
        @Bean
        public ReactiveJwtAuthenticationConverter authenticationConverter(Converter<Jwt, Flux<GrantedAuthority>> authoritiesConverter) {
            final var authenticationConverter = new ReactiveJwtAuthenticationConverter();
            authenticationConverter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
            authenticationConverter.setPrincipalClaimName(StandardClaimNames.PREFERRED_USERNAME);
            return authenticationConverter;
        }
    }
  • В MenuOrderController имя пользователя получаем из JWT-токена аналогично тому, как было сделано в Review Service
  • Тестирование контроллера осуществляется также с помощью тестового Docker-контейнера Keycloak, в котором мы получаем токены доступа и используем их в заголовках перед отправкой запросов, анологично Review Service. И анологично в TestConstants и в insert-data.sql имена получаемых в токене пользователей заменили теми, что созданы в Keycloak.
Соберите проект ./gradlew clean build и образ микросервиса: ./gradlew bootBuildImage.

Menu Aggregate Service не требует настройки

Все запросы к этому сервису разрешены для неавторизованных пользователей, настройка не требуется.
Security, Authorization и Authentication Обновление Docker Deployment всех микросервисов >