Как стать автором
Обновить

Путешествие к центру Spring Security

Уровень сложностиСредний
Время на прочтение10 мин
Количество просмотров14K

Введение

Я вас категорически приветствую, дорогие хабравчане! В этой статье я хотел бы поглубже разобрать такую штуку как Spring Security, а в частности Security фильтры, как они работают в целом и как объединяются в цепочку ApplicationFilterChain.

Скажу сразу, эта статья является скорее финальной точкой моего ночного дебагинга кишочков Spring Security, а также одной из основных целей этой статьи является закрепление знаний, которые я получил. Но это не отменяет тот факт, что статья кому-то (и я уверен что многим) будет полезна. Поехали!

Проблема

Для начала я обозначу проблему, с появлением которой я и начал столь интересный путь по стектрейсу в дебаггере. На проекте, над которым сейчас мы с командой работаем понадобилось прикрутить сервис авторизации. Не долго думая и собрав в кучу все требования, было принято решение поднять сервис, поддерживающий OAuth2 стандарт, а именно Keycloak. Этот сервис довольно популярен и по его настройке скопилось немало экспертизы. А также у коллег из других команд был опыт по развертыванию и настройке Keycloak. В общем, остановились на нем. Как гласит документация, в Keycloak поддерживается интеграция со Spring Security, поэтому проблем возникнуть не должно, НО…

После поднятия самого Keycloak сервера и конфигурирования Realm’a, Client’a юзеров и тд, мы начали прикручивать авторизацию в наши микросервисы. Исходя из мануала, настройка Keycloak довольно проста (не входит в тему этой статьи). Но что же мы за программисты такие, если не хотим это дело как-то кастомизировать. Добавляем зависимости.

Зависимости Kyecloak
Зависимости Kyecloak
Зависимости Keycloak
Зависимости Keycloak

На нашем проекте основная потребность в Keycloak заключалась в том, чтобы обновлять истекшие access токены прямо в микросервисах. Например у нас есть запрос который выполняется довольно долго (+- 30 сек). Этот запрос дергает еще кучу других мксов, которым нужен access токен. И в процессе выполнения этого долгого метода токен иногда истекал и процесс прерывался в середине с досадной 401. Кажется выход очевиден, обновить токен прямо на месте, в микросервисе. Вот эту идею я и начал реализовывать.

Для обновления access токена необходим refresh токен (см RFC по OAuth2). Соответственно встает вопрос, откуда мкс должен взять этот refresh токен, ведь он известен пока только фронту (возвращается при авторизации, после того как юзер авторизуется по логину и паролю). Не долго думаю, приняли решение передавать refresh токен в отельном заголовке Refresh-Token также как в заголовке Authorization access токен во все запросы к другим мксам. Такое решение не конечное, возможно в будущем переделаем по другому, но пока так.

Передать то передали, но как внутри мкса нам его принять. Ну очевидно что нужно применить фильтр для перехвата запроса, и из заголовка вытянуть токен. Вот отсюда и начинается самое интересное.

Последовательность фильтров

Начну с описания @Configuration класса, описывающего конфигурацию Spring Security.

Конфигурация Spring Security
Конфигурация Spring Security

Во-первых, над классом вешаем аннотацию @KeycloakConfiguration. В ней особо ничего интересного, она просто объединяет @Configuration и @EnableWebSecurity.

@KeycloakConfiguration
@KeycloakConfiguration

Во-вторых, наследуемся от абстрактного класса KeycloakWebSecurityConfigurerAdapter. В нем определены 4 основных Keycloak фильтра, выполняющие валидацию access токена, и устанавливающие контекст авторизации SecurityContextHolder.setContext().

Инициализация Keycloak фильтров
Инициализация Keycloak фильтров
Инициализация Keycloak фильтров
Инициализация Keycloak фильтров

Важная особенность, объект Authentication в случае использования Keycloak имеет реализацию KeycloakAuthenticationToken, у которой есть поле account.context типа RefreshableKeycloakSecurityContext, у которого имеется удобный метод RefreshableKeycloakSecurityContext.refreshExpiredToken. То есть нам не нужно самим реализовывать метод, отправляющий пост запрос для обновления access токена, нам всего лишь нужно сообщить refresh токен нашему RefreshableKeycloakSecurityContext.

Соберем все вместе. RefreshableKeycloakSecurityContext context имеется у объекта principal, который мы можем получить из контекста безопасности SecurityContextHolder.getContext().getAuthentication(). То есть чтобы добраться то метода обновления токена нужно сделать примерно следующее.

Получение токена из контекста безопасности
Получение токена из контекста безопасности

Другими словами, на момент вызова метода ctx.refreshExpiredToken, контекст безопасности ДОЛЖЕН БЫТЬ установлен и иметь в себе access токен и refresh токен сразу. Для этого должен отработать KeycloakAuthenticationProcessingFilter (т.к. именно он устанавливает контекст безопасности).

Далее, в RefreshableKeycloakSecurityContext нам необходимо передать refresh токен из хедера входящего к нам запроса. По итогу выходит, что нам нужно создать фильтр, извлекающий refresh токен, и, ГЛАВНОЕ, поставить этот фильтр в цепочку ПОСЛЕ KeycloakAuthenticationProcessingFilter, т.к. нам необходимо иметь уже установленный контекст безопасности, чтобы сообщить ему перехваченный refresh токен. Вот тут то и начинается свистопляска.

Конфигурирование Spring Security

Создадим класс фильтра, перехватывающий рефреш токен.

Фильтр для перехвата refresh токена
Фильтр для перехвата refresh токена

Заранее отмечу, тут пришлось использовать обходной путь и устанавливать refresh токен через рефлексию. Теперь нужно поставить наш фильтр в конец цепочки ApplicationFilterChain. Поизучав интернеты и порывшись в памяти вспомнил, что управлять последовательностью фильтров можно в методе WebSecurityConfigurerAdapter.configure(HttpSecurity http) с помощью методов http.addFilter, http.addFilterBefore, http.addFilterAfter.Ну давайте пробовать. Накидали следующую конфигурацию.

Конфигурация Spring Security
Конфигурация Spring Security

KeycloakAuthenticatedActionsFilter последний из фильтров Keycloak, после которого нам и нужно поставить свой фильтр. Давайте поднимем контекст и посмотрим как выглядит наша ApplicationFilterChain.

ApplicationFilterChain
ApplicationFilterChain

Как мы видим наш refreshTokenFilter находится в цепочке не после, а до Keycloak фильтров, а нам нужен совсем обратный эффект. Но почему же не сработал вызов addFilterAfter в методе WebSecurityConfigurerAdapter.configure?

Погружаемся глубже

Вся суть такого поведения заключается в одном конкретном фильтре в цепочке, а именно springSecurityFilterChain.

springSecurityFilterChain
springSecurityFilterChain

Что же это за фильтр такой. Обратимся к его BeanDefinition.

Инициализация springSecurityFilterChain
Инициализация springSecurityFilterChain

Как ясно из определения, с точки зрения терминологии Spring это обычный фильтр, а именно объект FilterChainProxy имплементирующий GenericFilterBean.

FilterChainProxy
FilterChainProxy

Если идти по стектрейсу поднятия контекста спринга (его я приводить не буду, т.к. это не столь важно и отвлечет внимания читателя не важными техническими деталями), то во время инициализации этого фильтра, как раз и вызывается метод WebSecurityConfigurerAdapter.configure, определяющий порядок фильтров (но не совсем так, как мы ожидаем).

Давайте теперь рассмотрим этот springSecurityFilterChain с точки зрения всей цепочки ApplicationFilterChain и его положения в ней. Как я уже упомянул, это такой же фильтр, как и все остальные, соответственно заглянем в метод doFIlter(req, res, chain).

FilterChainProxy.doFilter
FilterChainProxy.doFilter

Тут нас интересует метод doFilterInternal.

FilterChainProxy.doFilterInternal
FilterChainProxy.doFilterInternal

Самый сок заключается в последних двух строках.

VirtualFilterChain vfc = new VirtualFilterChain(fwRequest, chain, filters);
vfc.doFilter(fwRequest, fwResponse);

Итого, что у нас есть на данный момент. В главной цепочке фильтров ApplicationFilterChain есть фильтр springSecurityFIlterChain, который в своем методе doFilter порождает еще одну цепочку фильтров VirtualFilterChain и передает обработку запроса ей. Теперь лезем внутрь VirtualFilterChain, что же там? А там у нас происходят следующие вещи.

Во первых VirtualFilterChain это технически такая же цепочка, как и основная ApplicationFilterChain, т.е. этот класс реализовывает FilterChain.

VirtualFilterChain
VirtualFilterChain

А дальше нас интересуют два поля 

private final FilterChain originalChain;
private final List<Filter> additionalFilters;

Поле originalChain это оригинальная, главная цепочка ApplicationFilterChain, а список additionalFilters это как раз таки список фильтров, которые мы конфигурируем методами addFIlterBefore, addFilterAfter, addFilter в методе WebSecurityConfigurerAdapter.configure. Раз VirtualFilterChain реализовывает FilterChain, то заглянем в реализацию метода doFIlter(req, res).

VirtualFilterChain.doFilter
VirtualFilterChain.doFilter

Вот что происходит внутри. Здесь есть состояние, а именно поле currentPosition, которое инкрементируется при каждом вызове doFilter. Оно нужно для того,чтобы взять из списка additionalFilters следующий фильтр, то есть это своего рода итератор. Как мы помним, список additionalFilters это список фильтров, устанавливающихся в методе WebSecurityConfigurerAdapter.configure. То есть, возвращаясь к нашей проблеме, правильный порядок фильтров присутствует именно в этом списке additionalFilters, а не в общей цепочке ApplicationFilterChain.И, соответственно, когда все additionalFilters проитерируются до конца, то управление передается дальше главной цепочке ApplicationFilterChain и вызывается следующий фильтр из списка основных, то есть следующий за springSecurityFilterChain. Строка originalChain.doFilter(request, response);

Давайте посмотрим на эту картину в дебаггере.

FilterChainProxy
FilterChainProxy

Сейчас мы находимся внутри springSecurityFilterChain фильтра и видим, что originalChain это наша основная цепочка фильтров ApplicationFilterChain, а список additionalFilter содержит все конфигурируемые нами фильтры В НУЖНОМ ПОРЯДКЕ. То есть в additionalFilter наш refreshTokenFilter стоит именно ПОСЛЕ keycloakAuthenticatedActionsFilter, как мы и сконфигурировали в WebSecurityConfigurerAdapter.configure.

Предлагаю на этом моменте перейти от разбора внутренней реализации и кишков SpringSecurity к решению обозначенной в начале статьи проблемы. Как вы помните, пока что мы не добились, чтобы в главной цепочке refreshTokenFilter находился после keycloakAuthenticatedActionsFilter, а для нас это необходимо, чтобы на момент работы фильтра был установлен контекст безопасности.

Еще стоит прояснить следующий момент. Даже несмотря на то, что в additionalFilter присутствует правильный порядок фильтров, это не решает нашу проблему., т.к. после вызова keycloakAuthentificationProcessingFilter изнутри springSecurityFilterChain, фильтр keycloakAuthentificationProcessingFilter вызывается еще раз, т.к. находится в основной цепочке ApplicationFilterChain, и находится после нашего refreshTokenFilter. Соответственно, этот фильтр установит наш контекст безопасности заново без refresh токена и поле refreshToken снова будет null.

Варианты решения проблемы

На данный момент я нашел 3 решения проблемы.

Решение 1

Было бы логично выпилить Keycloak фильтры и свой refreshTokenFilter из главной цепочки ApplicationFilterChain. На первый взгляд ничего сломаться не должно, эти фильтры вызываются из springSecurityFilterChain из additionalFilters. И самое главное, в правильном порядке, то есть на refreshTokenFilter стоит после фильтров Keycloak . Другими словами контекст безопасности также установится, но фильтры будут вызываться единожды, в springSecurityFilterChain. Если оставить как есть, то фильтры вызываются дважды, в springSecurityFilterChain и в конце основной цепочки. Как это реализовать, если фильтры уже инжектятся в @Configuration классе KeycloakWebSecurityConfigurerAdapter, поставляемом из keycloak-spring-boot-starter, от которого наследуется наш @Configuration класс. Сделать это можно следующим образом.

Инициализация GenericFilterBean с Keycloak фильтрами и refreshTokenFilter
Инициализация GenericFilterBean с Keycloak фильтрами и refreshTokenFilter
Инициализация GenericFilterBean с Keycloak фильтрами и refreshTokenFilter

То есть мы инициализируем бины типа FilterRegistrationBean и отключаем фильтры в них.

frb.setEnabled(false);

Проверим нашу цепочку.

ApplicationFilterChain
ApplicationFilterChain

Как видите, из основной цепочки наши фильтры пропали, но в additionalFilters они до сих пор есть (не забываем про метод WebSecurityConfigurerAdapter.configure). Проверим наличие токена где-нибудь в сервисе.

Наличие refresh токена в контексте безопасности
Наличие refresh токена в контексте безопасности

Итак, мы видим, что токен присутствует в контексте безопасности. Решением можно считать рабочим.

Решение 2

Также мы можем определить порядок Keycloak фильтров на один приоритетнее, чем наш refreshTokenFilter. По умолчанию у Keycloak фильтров и refreshTokenFilter самый низкий приоритет Oredered.LOWEST_PRECEDENCE = Integer.MAX_VALUE. Определяем порядок также в FilterRegistrationBean.

Инициализация GenericFilterBean с Keycloak фильтрами и refreshTokenFilter
Инициализация GenericFilterBean с Keycloak фильтрами и refreshTokenFilter
Инициализация GenericFilterBean с Keycloak фильтрами и refreshTokenFilter
Инициализация GenericFilterBean с Keycloak фильтрами и refreshTokenFilter

Посмотрим на нашу цепочку.

ApplicationFilterChain
ApplicationFilterChain

Как мы видим, наш refreshTokenFilter в самом конце цепочки. Проверим контекст безопасности в сервисе.

Наличие refresh токена
Наличие refresh токена

Как мы видим, refreshToken присутствует, решение также можно считать рабочим.

Решение 3

Пожалуй, самое неочевидное, самое, в каком-то смысле, изящное, но самое практически не применимое, на мой взгляд, решение. Объявим refreshTokenFilter ниже Keycloak фильтров.

Инициализация Keycloak фильтров
Инициализация Keycloak фильтров

Посмотрим цепочку.

ApplicationFilterChain
ApplicationFilterChain

Наш refreshTokenFilter в снова в самом конце, то есть решение сработало, скрин из сервиса даже приводить не буду.

Остановимся поподробнее, почему же фильтр встал в самом конце. Если снова пойти по стектрейсу поднятия контекста, то дойдем до метода, где обрабатываются все фильтры, а именно.ServletContextInitializerBeans.getOrderedBeansOfType. В этом методе происходит сортировка фильтров по указанному в них порядку (решение 2) через компаратор AnnotationAwareOrderComparator.INSTANCE.

Сортировка фильтров по порядку
Сортировка фильтров по порядку

Мы выяснили, что порядок у Keycloak фильтров и refreshTokenFilter одинаковый, а алгоритм сортировки, если два сравниваемых объекта равны, не будет выполнять перестановку в списке и оставит эту пару как есть. Смысл тут в том, что в момент сканирования нашего @Configuration класса Keycloak фильтры объявляются раньше чем refreshTokenFilter, соответственно и в компаратор приходят раньше, и из-за одинакового порядка остаются как есть. Для эксперимента уберем переопределение методов, возвращающих бины для Keycloak фильтров и посмотрим на результат.

Убираем инициализацию Keycloak фильтров
Убираем инициализацию Keycloak фильтров

Посмотрим на цепочку теперь.

ApplicationFilterChain
ApplicationFilterChain

Как мы видим, refreshTokenFilter теперь снова стоит перед Keycloak фильтрами, то есть в неправильном порядке. Это происходит из-за того, что объявление Keycloak фильтров происходит в абстрактном классе KeycloakWebSecurityConfigurerAdapter от которого наследуется наш @Configuration класс. То есть Spring сначала сканирует бины из конечного класса, а следом уже из классов, от которых мы наследуемся, и так по цепочке вверх. Глянем теперь наличие refresh токена в контексте безопасности в сервисном методе.

Refresh токен в контексте безопасности
Refresh токен в контексте безопасности

Refresh токен снова null. Вот такая вот магия :)

Вместо заключения

Надеюсь эта статья была кому то полезной. Также хочу отметить, данный материал не про то как правильно поднимать и конфигурировать Keycloak, и не про то как интегрировать Keycloak со Spring. Эта статья про то, как работают фильтры Spring Security и как их настраивать. Основной упор в этом изложении был именно на эти концепции. Keycloak в этой статье используется лишь для примера, вся логика описанная тут работает и для любых других фильтров. Потребность в интеграции с Keycloak привела к необходимости разобраться в теме фильтров Spring Security. В решение нашей практической проблемы на проекте, мы скорее всего применим решение 2. На мой взгляд оно самое читаемое для других коллег и не ломает дефолтное поведение KeycloakWebSecurityConfigurerAdapter. С радость почитаю ваши комментарии, советы и замечания по данной теме. Также постараюсь развернуто ответить на вопросы. Спасибо за внимание!!!

Теги:
Хабы:
Всего голосов 6: ↑3 и ↓30
Комментарии7

Публикации