Часть первая

Команда Spring АйО перевела и адаптировала доклад Даниэля Гарнье-Муару “Spring Security Architecture Principles”, в котором на наглядных примерах рассказывается, как пользоваться возможностями Spring Security, не запутываясь на каждом шагу и не зарабатывая себе головную боль. 

Доклад будет опубликован тремя частями. В первой части будет рассказано об основных подходах к созданию цепочек фильтров, а также разработан простейший фильтр с красивым названием “Es prohibido” (“Это запрещено” в переводе с испанского).


Перед началом доклада Даниэль по традиции провел голосование в аудитории. На вопрос “Кто использует Spring Boot?” поднялось примерно 99,99% рук присутствующих. На вопрос “Кому нравится Spring Boot?” — почти столько же рук. Однако когда аналогичные вопросы были заданы по поводу Spring Security, использующих эту библиотеку оказалось примерно 80%, но тех кому она нравится — не более 15%, и это, со слов Даниэля, был довольно неплохой результат. Более того, создатели Spring Security привыкли к такой ситуации.

Когда на конференциях людям в кулуарах задают вопросы о Spring Security, они обычно дают примерно такую обратную связь:

“Spring Security - это так сложно, я не понимаю, как она работает.” 

“В один из дней мне надо было что-то сделать, я пошел на Stack Overflow, скопировал там что-то, это даже не скомпилировалось, а когда скомпилировалось, я получил какие-то странные сообщения об ошибках, так что я выгорел из-за всего этого.” 

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

В итоге в документации по данному продукту даже появился раздел под названием “У меня сложный сценарий, что может пойти не так?”. 

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

Поэтому основной целью доклада было не показать все самые крутые возможности Spring Security, а дать людям точку входа, начав с которой, они смогли бы строить какие-то свои сценарии, чтобы ситуация перестала выглядеть вот так:   

и превратилась вот в такую:

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

Spring Security, говоря обобщенно, выполняет три основные задачи: защиту от эксплойтов, аутентификацию и авторизацию. Если на этапе аутентификации приложение просто идентифицирует пользователя (это Даниэль, с таким-то адресом электронном почты и таким-то днём рождения), то на этапе авторизации приложение должно определить, какие именно разрешения должны предоставляться данному пользователю. 

Базовое защищенное приложение

Для демо используется максимально простое приложение, публичная страница которого выглядит вот так:

При нажатии на “GO TO PRIVATE” оно пытается перейти на /private, и появляется привычный экран логина по умолчанию, через который можно залогиниться с логином и паролем.

Вторая опция — залогиниться с помощью Google. Поэтому в приложении реализован Openid Connect Login:

После успешного логина тем или иным способом пользователь попадает на закрытую страницу — VIP раздел — которая защищена и не будет доступна без логина.

Эта часть реализуется очень просто, достаточно лишь следовать туториалам и гайдам, которые есть в интернете.

Давайте узнаем как это работает и что для этого необходимо. Прежде всего, в зависимостях у присутствует spring-boot-starter-security, что позволяет нам создавать форму логина, а также oauth2-client, который позволит нам осуществлять логин с помощью SSO и Google.

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
	implementation("org.springframework.boot:spring-boot-starter-web")
	developmentOnly("org.springframework.boot:spring-boot-devtools")
	implementation("org.springframework.boot:spring-boot-starter-security")
	implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
	testImplementation("org.springframework.boot:spring-boot-starter-test")
}

Давайте посмотрим на креденшелы для логина через Google:

client-id: 851941661103-vikpbjuvohh9oe16pnngeh051kvrgjna.apps.googleusercontent.com
client-secret: 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

Кроме того, у нас имеется стандартный класс для конфигурации цепочек фильтров.

@Configuration
@EnableWebSecurity
class SecurityConfig {

	SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    	return http
        	.authorizeHttpRequests(
            	authorizeHttp -> {
                	authorizeHttp.requestMatchers("/").permitAll();
                	authorizeHttp.requestMatchers("/favicon.svg").permitAll();
                	authorizeHttp.requestMatchers("/css/*").permitAll();
                	authorizeHttp.requestMatchers("/error").permitAll();
                	authorizeHttp.anyRequest().authenticated();
            	}
        	)
        	.formLogin(l -> l.defaultSuccessUrl("/private"))
        	.logout(l -> l.logoutSuccessUrl("/"))
        	.oauth2Login(withDefaults())
        	.build();
	}
}

В прошлом для конфигурации Spring Security использовался вот такой класс:

class SecurityConfig extends WebSecurityConfigurerAdapter {

Но так было в Spring Security 5 и в новых версиях больше не работает.

Теперь вы должны предоставлять бин типа securityFilterChain, который создастся тем же билдером, который был в прошлом. С помощью этого класса можно решать различные задачи, в том числе указывать, что именно вы хотите защитить и как. В нашем случае класс сконфигурирован таким образом, что запрос должен быть аутентифицирован и пользователь должен быть залогинен, чтобы получить доступ к каким-то ресурсам за исключением тех, которые помечены как .permitAll();

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

Если завтра добавить эндпоинт /springio, он будет закрыт по умолчанию. И будет понятно, что необходимо сделать его публичным, но просто начинать сливать через него информацию, не сделав его в явном виде публичным, не получится.

Для обеспечения работы функционала логина необходимо подключить formLogin()  и oauth2Login(). В прошлом это можно было сделать с помощью fluent API:

.authorizeHttpRequests()
  	.requestMatchers("/springio")
    .permitAll()
	.anyRequest()
    .authenticated()
    .and()

Однако, такой подход сильно запутывал разработчиков, так что люди от отчаяния начинали писать что-то вроде:

.and().and().and().helpme()

Поэтому авторы Spring Security решили покончить с таким подходом, и метод authorizeHttpRequests() был помечен как deprecated. Новый подход позволяет разработчикам иметь лучшее представление о scope своих наработок в области Spring Security.

Иногда разработчиков запутывает (withDefaults()), который мы можем квалифицировать как .oath2Login(Customizer.withDefaults()). Но это всего лишь лямбда, которая по сути ничего не делает, своего рода “синтаксический сахар”.

Итак, мы сконфигурировали Spring Security. Также в этом демо-проекте присутствуют публичная и закрытая страница.

@Controller
class GreetingsController {

	@GetMapping("/")
	public String publicPage() {
    	return "public";
	}

	@GetMapping("/private")
	public String privatePage(Model model, Authentication authentication) {
    	model.addAttribute("name", getName(authentication));
    	return "private";
	}
}

Инжектируем объект класса Authentication, из которого можно будет получить либо имя, либо адрес электронной почты, в зависимости от выбранного типа логина.

Теперь у нас есть основа, на базе которой можно построить какие-то примеры и понять, что именно использует Spring Security, чтобы предоставить разработчикам заявленные возможности. 

Фильтр в Spring Security

Первый и самый важный строительный блок — это фильтр. С помощью фильтров можно  взаимодействовать с запросами и ответами HTTP.

Фильтр — это интерфейс, имеющий ровно один метод. 

public void doFilter(
	HttpServletRequest request,
	HttpServletResponse response,
	FilterChain chain
) {
	// 1. Before the request proceeds further (e.g. authentication or rejection)
	// ...

	// 2. Invoke the "rest" of the chain
	chain.doFilter(request, response);

	// 3. Once the request has been fully processed (e.g. cleanup)
	// ...
}

Метод называется doFilter(). Он принимает объект запроса, объект ответа и цепочку фильтров. И они все работают по следующему алгоритму: сначала они инспектируют объект запроса и принимают решение по безопасности: “Это выглядит как атака, и я собираюсь ее заблокировать”, “Это выглядит нормально, пропускаем”. Затем они вызывают остаток цепочки и иногда после вызова цепочки они делают небольшую зачистку.

Эта концепция была рождена не в Spring Security, она пришла из сервлетов. Например, в Tomcat также имеется цепочка фильтров. Но в Spring Security существует специализированная цепочка фильтров, предназначенная исключительно для безопасности, куда попадают все относящиеся к безопасности фильтры.

Цепочка фильтров Spring Security выглядит примерно как на этом рисунке. Входящие запросы проходят через фильтры в том порядке, в котором фильтры зарегистрированы в классе конфигурации. После этого запрос добирается до сервлета или контроллера, в котором выполняется бизнес-логика. 

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

Фрагменты кода, отображенные на рисунке на зеленом фоне, отражают процесс прихода запроса, который попадает в цепочку фильтров от Spring Security, после чего вызывается метод doFilter(), внутри которого doFilter() значение переменной position инкрементируется. Отрабатывает фильтр под номером 1, после его выполнения совершается вызов обратно в цепочку, позиция снова увеличивается на единицу, вызывается фильтр номер 2 и т.д. И все эти вызовы будут отображены в stacktrace.

Так образуются немного пугающие на вид паттерны stacktrace с многочисленными упоминаниями  doFilter(), по которым легко опознать работу цепочки фильтров. 

Пугаться этого на самом деле не стоит, потому что вас интересует только тот фильтр, который находится на самом верху в stacktrace, и именно на нем следует поставить breakpoint.

Окей, теперь мы знаем, как работают фильтры. Давайте отработаем сценарий, в котором мы принимаем решения по безопасности на основании HTTP запроса.

Для этого мы добавляем новый класс. Поскольку доклад состоялся в Испании, новый фильтр получил название ProhibidoFilter (от испанского слова prohibido, означающего “запрещено”). Данный класс должен реализовывать интерфейс Filter. Если начать печатать название интерфейса, IDE сама подскажет список существующих интерфейсов с таким названием.

Нам нужен тот Filter, который принадлежит библиотеке jakarta.servlet. Если пойти этим путем, появятся три метода, которые необходимо как-то реализовать.

Spring дает более приятную альтернативу, интерфейс OncePerRequestFilter, у которого есть только один метод. Здесь нет методов init() и destroy(), так что разработчику нет необходимости  заботиться о жизненном цикле. Единственный оставшийся метод называется doFilterInternal(), но по сути это то же самое, что и doFilter().

Помимо запроса, ответа и цепочки фильтров нам необходимо логирование, фильтр должен печатать в лог фразу “Es prohibido”, что значит “Это запрещено”.

class ProhibidoFilter extends OncePerRequestFilter {

	@Override
	protected void doFilterInternal(
    	HttpServletRequest request,
    	HttpServletResponse response,
    	FilterChain filterChain) throws ServletException, IOException {
   	 
    	System.out.println("⛔⛔⛔⛔⛔ Es prohibido");
	}
}

Далее необходимо зарегистрировать этот фильтр в цепочке. Мы идем обратно в конфигурацию безопасности, пишем addFilter, и IDE покажет список доступных опций:

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

Воспользуемся опцией addFilterBefore(). Вы можете положить свой кастомизированный фильтр в любое место в цепочке, но главное, чтобы он находился до AuthorizationFilter.class.

class SecurityConfig {

	SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    	return http
        	.authorizeHttpRequests(
            	authorizeHttp -> {
                	authorizeHttp.requestMatchers("/").permitAll();
                	authorizeHttp.requestMatchers("/favicon.svg").permitAll();
                	authorizeHttp.requestMatchers("/css/*").permitAll();
                	authorizeHttp.requestMatchers("/error").permitAll();
                	authorizeHttp.anyRequest().authenticated();
            	}
        	)
        	.formLogin(l -> l.defaultSuccessUrl("/private"))
        	.logout(l -> l.logoutSuccessUrl("/"))
        	.oauth2Login(withDefaults())
        	.addFilterBefore(new ProhibidoFilter(), AuthorizationFilter.class)
        	.build();
	}
}

AuthorizationFilter — это фильтр, который выполняет уже знакомый нам код:

.authorizeHttpRequests(
	authorizeHttp -> {
    	authorizeHttp.requestMatchers("/").permitAll();
    	authorizeHttp.requestMatchers("/favicon.svg").permitAll();
    	authorizeHttp.requestMatchers("/css/*").permitAll();
    	authorizeHttp.requestMatchers("/error").permitAll();
    	authorizeHttp.anyRequest().authenticated();
	}
)

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

Далее делаем HTTP запрос и смотрим на логи:

Здесь присутствует сообщение “Es prohibido”. Значит, наш фильтр был вызван. Но есть проблема. Если сейчас пойти на страницу логина, она превратится в пустой экран, она сломана. Проблема состоит в том, что не был сделан вызов обратно в цепочку фильтров иначе мы не попадем в контроллер, который рендерит темплейт:

class ProhibidoFilter extends OncePerRequestFilter {

	@Override
	protected void doFilterInternal(
    			HttpServletRequest request,
    			HttpServletResponse response,
    			FilterChain filterChain) throws ServletException, IOException {
   	 
    		System.out.println("⛔⛔⛔⛔⛔ Es prohibido");
    		filterChain.doFilter(request, response);
	}
}

Теперь всё заработает.

Давайте примем решение по безопасности по входящему запросу. Чтобы принять решение по безопасности, можно использовать кастомизированный заголовок. Буква X означает, что он кастомизирован, и в нашем случае это значение будет “x-prohibido”, и когда оно равно “si”, мы отклоняем запрос. Воспользуемся Objects.equals для сравнения значений.

class ProhibidoFilter extends OncePerRequestFilter {

	@Override
	protected void doFilterInternal(
    			HttpServletRequest request,
    			HttpServletResponse response,
    			FilterChain filterChain) throws ServletException, IOException {

    		if (Objects.equals(request.getHeader("x-prohibido"), "si")) 	{
        		// reject
    		}

    		filterChain.doFilter(request, response);
	}
}

Как мы отклоняем запрос? Мы берем объект ответа и устанавливаем  HTTP статус forbidden, и затем мы можем что-то вписать в ответ, какое-то стандартное сообщение:

response.getWriter().write("⛔⛔⛔⛔Es prohibido");

После этого мы пишем return, потому что не хотим выполнять остаток цепочки:

Для поддержки emoji необходимо добавить код, устанавливающий режим кодирования символов в UTF-8. Получаем следующий код:

class ProhibidoFilter extends OncePerRequestFilter {

	@Override
	protected void doFilterInternal(
    			HttpServletRequest request,
    			HttpServletResponse response,
    			FilterChain filterChain) throws ServletException, IOException {

    		if (Objects.equals(request.getHeader("x-prohibido"), "si")) {
        			response.setStatus(HttpStatus.FORBIDDEN.value());
        			response.setCharacterEncoding("UTF-8");
        		response.setHeader("Content-Type", "text/plain;charset=UTF-8");
        		response.getWriter().write("⛔⛔⛔⛔ Es prohibido");
        		return;
    	}

    	filterChain.doFilter(request, response);
	}
}

Итак, теперь, если сделать HTTP запрос http: 8080 x-prohibido:si, мы получим “es prohibido”.

HTTP/1.1 403
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Connection: keep-alive
Content-Length: 37
Content-Type: text/plain;charset=UTF-8
Date: Thu, 30 May 2024 10:18:31 GMT
Expires: 0
Keep-Alive: timeout=60
Pragma: no-cache
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0

⛔⛔⛔⛔ Es prohibido

Если вписать в запрос в качестве параметра значение “no” (“не запрещено”) или вообще ничего не вписывать, тогда мы увидим код публичной страницы:

<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>✅ Public Page [Spring Sec: The Good Parts]</title>
	<link rel="stylesheet" href="css/style.css">
	<link rel="icon" href="/favicon.svg" type="image/svg+xml">
</head>
<body>

	<h1>Hello world!</h1>
	<p>This page is totally public.</p>
	<p></p>
	<a href="/private" class="btn">Go to private</a>

</body>
</html>

Вся работа со Spring Security происходит по тому же принципу. Точкой входа для обработки HTTP запроса является фильтр.

Детализированный пример 

В качестве примера посмотрим на CsrFilter.java. Csrf расшифровывается как Cross Site Request Forgery. Это такой тип атаки, который заставляет вас делать запросы, при этом не осознавая, что вы их делаете:

Происходит нечто похожее на фишинговую атаку, когда вы кликаете на картинку, и она постит форму. Для защиты вы по сути блокируете такой тип запроса при помощи токена, который вы вставляете в вашу форму, он называется Csrf токен. И он сравнивается с тем токеном, который находится в вашей сессии. 

А как это работает в Spring Security? Давайте посмотрим на CsrfFilter

Расширяем интерфейс OncePerRequestFilter, который содержит метод doFilterInternal(). Внутри этого метода выполняются определенные настройки.

DeferredCsrfToken deferredCsrfToken = this.tokenRepository.loadDeferredToken(request, response);
request.setAttribute(DeferredCsrfToken.class.getName(), deferredCsrfToken);
this.requestHandler.handle(request, response, deferredCsrfToken);

Затем необходимо ответить на вопрос: “Требует ли этот запрос Csrf защиты? Это запрос POST,  PUT или DELETE?” Если это запрос типа GET, TRACE или HEAD, значит, Csrf защита не нужна, ничего делать не надо, и мы просто вызываем остаток цепочки фильтров и делаем return.

if (!this.requireCsrfProtectionMatcher.matches(request)) {
	if (this.logger.isTraceEnabled()) {
    		this.logger.trace("Did not protect against CSRF since request did not match "
        				+ this.requireCsrfProtectionMatcher);
	}
	filterChain.doFilter(request, response);
	return;
}

Если выяснилось, что запросу нужна защита, потому что это форма, получаем один токен из сессии, а второй из запроса, из тела формы или из заголовка. Сравниваем эти два токена, добавляем дополнительную защиту против атак по таймингу и других возможных атак. Если они не равны, тогда оно создает исключение типа AccessDeniedException, MissingCsrfTokenException или может быть InvalidCsrfTokenException, и затем вызывается заголовок “Access Denied”, который, по сути, делает то, что делали мы. 

Если токены не совпали, получаем “HTTP status forbidden”, 403. Если же токены равны, то это хороший запрос, и алгоритм его пропускает. 

Ничего сложного здесь нет, и любой разработчик может написать такой код самостоятельно, как это сделали мы. Однако, механизм безопасности, который используется здесь под капотом, несколько сложноват. Если мы захотим пойти в Csrf фильтр и поменять заголовок запроса, нам необходимо четко понимать, как работает Csrf. Что такое Csrf атака? Какие используются защиты? Как весь этот механизм вписывается в глобальную схему защиты?

Чтобы найти все Spring Security фильтры, которые зарегистрированы и защищают ваше приложение, то первое, что вы можете сделать — это посмотреть на логи, и, когда ваше Spring Boot приложение запускается, вы ищете самый большой лог, тот, который содержит много чёрного текста и выглядит несколько пугающе. Например, вот так:

Здесь перечисляются все используемые фильтры, начиная с DefaultSecurityFilterChain, дальше вы видите WebAsyncManagerIntegrationFilter, в списке присутствует также CsrfFilter.

Если промотать этот список до самого низа, мы найдем там наш ProhibidoFilter и следом за ним AuthorizationFilter, который является последним в цепочке. 

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

server:
  address: 127.0.0.1 # faster shutdown on multi-interface setups
  servlet:
    session:
  	persistent: false

logging:
  level:
    org.springframework.security: TRACE

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

В качестве примера посмотрим на то, как обрабатывался запрос типа GET, а именно http: 8080 x-prohibido:si:

Как видно из этого лога трассировки, первым был вызван DisableEncoderFilter. Затем еще несколько фильтров, и все они пронумерованы в логах. В какой-то момент (шестым по счёту) был вызван CsrfFilter. И после его вызова мы получаем сообщение о том, что Csrf защита применена не была, поскольку она не применяется к  GET-запросам. 

Проматываем вниз до ProhibidoFilter, который был вызван 17-м по счету, а  AuthorizationFilter, вызван не был, мы не получили 18/18 поскольку запрос был отклонен предыдущим фильтром.

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

Подведем итоги

Давайте теперь обобщим то, что мы сделали. 

  • Мы посмотрели на базовый интерфейс Filter, и в особенности на тот его вариант, который будет вам нужен в 99% случаев, а именно OncePerRequestFilter

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

  • Затем он вписывает свое решение в ответ, либо не делает ничего, либо записывает что-то в лог или в контекст. И если запрос безопасен с точки зрения фильтра, то дальше вызывается остаток цепочки фильтров и применяется следующий фильтр.

  • Все, что относится к безопасности, будет находиться в отдельной цепочке фильтров, предназначенной именно для безопасности. Не забудьте, что все созданные вами фильтры должны помещаться в конфигурации до AuthorizationFilter.class


Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

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