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 на получение списка заказов в Gateway Service.
- Gateway Service понимает, что пользователь не аутентифицирован, поэтому перенаправляет Браузер на URL авторизации /oauth2/authorization/keycloak. При этом создается сессионная кука, по которой пользователь не может войти до тех пор, пока не введет логин и пароль.
- Браузер переходит по URL из редиректа и получает новый редирект, но уже в Keycloak. При этом в новом редиректе передаются параметры code_challenge и
code_challenge_method:
- code_challenge - результат хеширования рандомной строки с помощью метода code_challenge_method и последующего кодирования хеша в base64
- code_challenge_method - способ хеширования строки, может принимать два значения:
plain (не рекомендуется) - означает, что строка не хеширована;
S256 - означает, что строка захеширована с помощью алгоритма SHA-256
- response_type=code - означает что инициирован процесс аутентификации и авторизации с помощью кода авторизации.
- client_id - идентификатор клиента.
- scope - список запрашиваемых клиентом областей видимости. В нашем случае мы будем запрашивать openid, так как используется протокол аутентификации OpenID Connect и roles, так как нам требуются роли пользователя в токене доступа.
- redirect_uri - адрес редиректа, который был настроен в Keycloak для клиента.
- state - строка, которая используется для защиты от CSRF-атак во время получения кода авторизации.
- nonce - строка, которая используется в протоколе OpenID Connect для ассоциации сессии клиента с токеном идентификации. В дальнейшем токен идентификации должен содержать это значение в claim с названием nonce.
- Keycloak сохраняет code_challenge и code_challenge_method, затем отправляет браузеру страницу, на которой необходимо ввести логин и пароль.
- Пользователь вводит логин и пароль.
- Браузер отправляет запрос в Keycloak на аутентификацию пользователя по логину и паролю.
- Keycloak проверяет логин и пароль, после чего генерирует код авторизации.
- Keycloak отправляет браузеру ответ с редиректом на Gateway Service, в ответе содержится код авторизации.
- Gateway Service отправляет запрос в Keycloak на обмен кода авторизации на токены идентификации, доступа и обновления. Одним из параметров запроса является code_verifier - строка, на основе которой был создан code_challenge с помощью code_challenge_method.
- Keycloak хеширует code_verifer с помощью метода code_challenge_method, кодирует хеш в base64 и проверяет полученный результат на совпадение с code_challenge. Если совпадения нет или вообще нет параметра code_verifier, это значит, что запрос на получение токенов пришел от злоумышленника, который перехватил код авторизации - токены не будут сгенерированы. В случае совпадения Keycloak генерирует токены и возвращает их Gateway Service.
- Gateway Service инициализирует пользовательскую сессию (так как у нас используется распределенная система, в которой одновременно может работать несколько экземпляров Gateway Service, данные о сессии необходимо хранить во внешней системе, например, в Redis). В сессии сохраняется информация о токенах. Теперь браузер может использовать ранее созданную куку, чтобы пользователю не приходилось при каждом запросе вводить логин и пароль. Gateway Service отправляет редирект на первоначальный эндпоинт.
- Браузер отправляет запрос в Gateway Service на получение списка заказов, в запросе содержится сессионная кука.
- Gateway Service видит куку, понимает, что пользователь аутентифицирован, получает из сессии токен доступа и отправляет запрос в Orders Service на получение заказа, в запросе содержится заголовок Authorization: Bearer + Access Token.
- Orders Service отправляет запрос в Keycloak на получение публичного ключа для проверки подписи JWT токена.
- Keycloak отправляет публичный ключ.
- Ключ кешируется на стороне Orders Service, чтобы в дальнейшем не требовалось каждый раз запрашивать его у Keycloak. По умолчанию ключ кешируется на 5 минут. Если требуется изменить эту настройку, придется создать кастомный бин JwtDecoder. Orders Service проверяет валидность токена, затем проверяет, разрешен ли пользователю с этим токеном доступ к ресурсам, которые он запрашивает. Проверка доступа осуществляется на основе ролей, которые были определены ранее в Keycloak и передаются в токене.
- Если пользователю доступ разрешен, в ответ возвращается список заказов.
- Gateway Service возвращает результат браузеру.
Настройка Gateway Service как клиента Keycloak
Создайте в локальном репозитории Gateway Service ветку security_client и продолжите работу в ней
Примените в ветке security_client патч security_client.patch
-
Добавляем в
build.gradle
зависимости на Spring Boot Starter Oauth2 Client и Spring Spring Session Data Redis:
Первая потребуется для настройки необходимых бинов в контексте безопасности Spring, вторая - для хранения сессий в Redis.implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.session:spring-session-data-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/**
- получение информации о рейтингах и средних оценках блюд.
/actuator/**
.
PS: в реальном проекте прежде чем открыть какой-либо
эндпоинт актуатора, потребуется провести тщательный анализ информации, которую он раскрывает, на предмет возможных утечек и нарушения принципов
безопасности организации. Как правило, такими проверками занимается подразделение безопасности компании, и, зачастую, они сильно усложняют жизнь
разработчикам, когда дело касается чувствительной информации: персональных данных, финансовой информации и т.п., однако это критически важный аспект,
который необходимо учитывать, чтобы избежать юридических претензий со стороны пользователей вашего приложения.
Запускаем аутентификацию
Когда пользователь проходит аутентификацию и получает доступ к ресурсам- в контекст безопасности спринга добавляется сущность
Authentication
, которая наследуется отPrincipal
- эта сущность представляет собой зарегистрированного пользователя. - создается сущность
OAuth2AuthorizedClient
, представляющая собой клиента (наше приложение), авторизованного на осуществление каких-либо действий с защищенным ресурсом. Основной задачейOAuth2AuthorizedClient
является ассоциирование токена доступа с клиентом (нашим приложением) и владельцем защищенного ресурса (Resource Owner), который также являетсяPrincipal
. OAuth2AuthorizedClient
сохраняется в репозиторииServerOAuth2AuthorizedClientRepository
. Для хранения сведений об авторизованных клиентах в сессии используется реализация этого интерфейсаWebSessionServerOAuth2AuthorizedClientRepository
.
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-атаки
Сценарий- Целевой сайт: пользователь аутентифицирован на сайте банка bank.com. На сайте есть форма для перевода денег, URL-адрес для перевода денег: https://bank.com/transfer.
- Форма на сайте банка:
<form action="https://bank.com/transfer" method="POST"> <input type="hidden" name="toAccount"> <input type="hidden" name="amount"> <button type="submit">Transfer</button> </form>
- Злоумышленник: создает вредоносный сайт 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>
- Аутентификация: Пользователь входит в свой аккаунт на bank.com и его сессия остается активной.
- Визит на вредоносный сайт: Пользователь случайно заходит на сайт attacker.com (например, по ссылке в электронной почте).
- Автоматическая отправка формы: При загрузке страницы на attacker.com, JavaScript код автоматически отправляет форму.
- Запрос к bank.com: Вредоносный запрос отправляется на bank.com с использованием активной сессии пользователя.
- Действие выполнено: Банк обрабатывает запрос как легитимный, так как он пришел с авторизованной сессии пользователя, и переводит деньги на счет злоумышленника.
Способы защиты от CSRF-атаки
Использование 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>
Проверка Referer и Origin заголовков
Сервер может проверять заголовки Referer и Origin, чтобы убедиться, что запросы приходят с доверенного источника. Это менее надежный метод, так как заголовки могут быть подделаны, но все же добавляет дополнительный уровень защиты.Использование 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); }; }
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; } }
-
Настраиваем цепочку фильтров безопасности
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
.