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

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


Что такое Authentication объекты?

Когда пользователь вводит свое имя пользователя и пароль или использует Google для логина, что при этом делает Spring Security? Она создает объект типа Authentication. Объект типа Authentication — это интерфейс, использующийся для аутентификации и авторизации. Аутентификация — это процесс определения личности пользователя, его имя, дата рождения и т.д. Авторизация — это процесс определения того, что данному пользователю разрешено, например, можно ли ему входить в панель админа или удалять заказы.

В этой части мы в основном фокусируемся на первой составляющей, однако не забываем, что Authentication объект отвечает за оба процесса.

Это представлено в интерфейсе Authentication через наследование и работу с Authentication, как с  Principal-like дата объектом. Principal — это пользователь, сущность, направляющая запрос, информация по его идентификации. Кроме того, этот интерфейс содержит GrantedAuthorities, представляющий набор скоупов/ролей/разрешений и т.д., который впоследствии используется для авторизации.. Если посмотреть на такого рода Authentication объект, мы увидим, что в нем есть метод getPrincipal():

Он возвращает объект. Мы знаем, что Spring Security — не самая типобезопасная библиотека в мире. Вам придется часто делать преобразования типов. Да, да, мы в курсе, увы и ах, но так сложилось исторически, начиная с 2004-го года, так что нам остается лишь принять эту ситуацию.

Пожалуйста, не перепутайте getPrincipal() с вот этим Principal вот отсюда:

В данном случае интерфейс Authentication расширяет интерфейс Principal. Интерфейс Principal — это способ представления пользователя в Java из пакета java.security. Однако мы рекомендуем не углубляться в изучение этого вопроса: лучше оставаться в рамках мира Spring, где данный функционал реализован гораздо удобнее.

Помимо Principal в интерфейсе Authentication есть много других полезных вещей, в частности, метод isAuthenticated(). Когда вы конфигурируете свою цепочку фильтров таким образом, выполнялся только для тех пользователей, кто аутентифицирован, isAuthenticated() всегда будет возвращать значение true в процессе этой проверки для пользователей с не Anonymous-аутентификацией.

Кроме того, в данном интерфейсе присутствует переменная details — она делает следующее: 

  • Хранит дополнительные детали запроса на аутентификацию. Это может быть IP адрес, серийный номер сертификата и т.д. 

  • Возвращает дополнительные детали запроса на аутентификацию или null, если не используется. 

Также существует переменная credentials. Когда Authentication объект попадает в ваш контроллер, credentials всегда будут равны null. Это потому, что credentials являются сферой ответственности безопасности, и не должны быть видимы в тех частях приложения, где идет работа с бизнес-логикой. Мы не хотим, чтобы User объект оказался доступен приложению и впоследствии появился в логах консоли с токеном, видимым в простом текстовом формате.

Authentication объекты хранятся в SecurityContext, поэтому существует класс SecurityContextHolder, у которого есть метод getContext(). Это статический метод, который возвращает SecurityContext, содержащий объект Authentication.

По сути, в нашем контроллере мы заинжектировали Authentication объект:

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

Но вы можете получить его следующим образом:

var auth = SecurityContextHolder.getContext().getAuthentication();

Объект auth должен быть точно равен authentication:

@GetMapping("/private")
public String privatePage(Model model, Authentication authentication) {

	var auth = SecurityContextHolder.getContext().getAuthentication();

	auth == authentication;

	model.addAttribute("name", getName(authentication));
	return "private";
}

Этот подход может быть полезен для вас, когда у вас есть дерево зависимостей с очень глубоким вложением, у вас есть сервис, который вызывает сервис, который вызывает сервис, и затем вы хотите провести проверку на безопасность в самом низу, не инжектируя Authentication на каждом уровне, вы можете получить authentication при помощи SecurityContext, как показано выше.

Кроме того, Spring Security может сделать @PreAuthorize.

@GetMapping("/private")
@PreAuthorize("hasRole('admin')")
public String privatePage(Model model, Authentication authentication) {

	var auth = SecurityContextHolder.getContext().getAuthentication();

	auth == authentication;

	model.addAttribute("name", getName(authentication));
	return "private";
}

Данный код получит Authentication из контекста и затем сравнит актуальную роль со значением выражения, являющегося аргументом аннотации @PreAuthorize.

Мы учили в школе, не используйте статические выражения, это плохо, потому что это Global State, но в нашем случае это не настоящий Global State, он валиден только для текущего запроса, это глобальная переменная, локальная для потока. Если у меня есть два параллельных запроса, каждый из них имеет свой собственный SecurityContext, они изолированы друг от друга, и это безопасно.

Если вы посылаете работу другим потокам, вы потеряете вот это:

var auth = SecurityContextHolder.getContext().getAuthentication();

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

Следующий актуальный вопрос:: что является наиболее распространенной реализацией Authentication, о которой все знают? Ответ следующий: UsernamePasswordAuthenticationToken, и в связи с этим автор доклада советует следующее: не используйте UsernamePasswordAuthenticationToken, если у вас нет имени пользователя и пароля. 

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

Гораздо лучше использовать специализированные реализации, например, для Oauth2, в каждом конкретном случае. Они все существуют и работают.

Как поменяется метод doFilter()?

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

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

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

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

Как мы помним из первой части, так работает doFilter(), когда мы принимаем решение по безопасности. Когда наша задача состоит в том, чтобы аутентифицировать запрос, код выглядит несколько иначе:

public void doFilter(
	HttpServletRequest request,
	HttpServletResponse response,
	FilterChain chain
) {
	// 1. Decide whether the filter should be applied

	// 2. Apply filter: authenticate or reject request

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

	// 4. No cleanup
}

Итак, в первую очередь вы решаете, должны ли вы применять фильтр или нет. Во вторую очередь вы получаете credentials и валидируете их. Если они валидны, вы создаете Authentication объект, если они не валидны, вы отклоняете запрос. Затем вы вызываете остаток цепочки и не делаете никакой зачистки, потому что зачистка — это очень узкий кейс. Мы сделаем это во фреймворке, но вам это, как правило, не требуется.

Пример фильтра

Давайте реализуем пример такого сценария. У нас замечательное приложение, у него есть публичная страница или приватная страница, но тут приходит SRE команда и говорит, “мы хотим проверять содержимое приватной страницы каждую ночь”. Можно было бы сделать следующее: написать скрипт на Python, который идет к форме, парсит форму, чтобы получить Csrf токен, делает POST запрос, получает сессию и так далее, но все это очень громоздко и не очень рационально. Давайте разработаем что-то попроще. 

Начнем, как всегда, с фильтра. Назовем его RobotAuthenticationFilter. Он расширяет OncePerRequestFilter

Мы всегда говорили, что первое — это решить, хотим ли мы применить фильтр. Второе — проверить credentials и на их основе аутентифицировать либо отклонить запрос. И третье — вызвать следующий фильтр.

class RobotAuthenticationFilter extends OncePerRequestFilter {

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
    		// 1. Decide whether we want to apply the filter?

    		// 2. Check credentials and [authenticate | reject]

    		// 3. Call next!
    	
	}
}

Вызов следующего — это самая простая часть.

// 3. Call next!
filterChain.doFilter(request, response);

Зарегистрируем новый фильтр в нашем конфиге безопасности, он называется RobotAuthenticationFilter:

class SecurityConfig {
	SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    			.formLogin(l -> l.defaultSuccessUrl("/private"))
    			.logout(l -> l.logoutSuccessUrl("/"))
    			.oauth2Login(withDefaults())
    			.addFilterBefore(new ProhibidoFilter(), AuthorizationFilter.class)
    			.addFilterBefore(new RobotAuthenticationFilter(), AuthorizationFilter.class)
    			.build();
	}
}

Мы пока что отложим первый пункт  в сторонку и сделаем второй. Для credentials опять-таки мы сделаем заголовки (headers), потому что их легко продемонстрировать, новый заголовок называется x-robot-secret, и мы хотим, чтобы он был равен “beep-boop”, поэтому если он этому не равен, отклоняем запрос. В противном случае мы аутентифицируем запрос. Для сравнения заголовков воспользуемся Object.equals:

// 2. Check credentials and [authenticate | reject]
if (!Objects.equals(request.getHeader("x-robot-secret"), "beep-boop")) {
	// reject the request
}
// authenticate

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

// 2. Check credentials and [authenticate | reject]
if (!Objects.equals(request.getHeader("x-robot-secret"), "beep-boop")) {
	response.setStatus(HttpStatus.FORBIDDEN.value());
	response.setCharacterEncoding("UTF-8");
	response.setHeader("Content-Type", "text/plain;charset=UTF-8");
	response.getWriter().write("⛔⛔?? You are not Ms Robot");
	return;
}

Если вы посылаете x-robot-secret, но он не равен beep-boop, то запрос отклоняется и пользователь получает сообщение, “Вы не миссис Робот”. В противном случае мы аутентифицируем запрос. То есть нам нужен Authentication объект, соответственно, мы создаем класс RobotAuthenticationToken. Согласно принятой в Spring Security конвенции, реализации Authentication называются AuthenticationToken-ами, но на самом деле вы можете называть их, как хотите. Главное, чтобы они реализовывали интерфейс Authentication.

import org.springframework.security.core.Authentication;

class RobotAuthenticationToken implements Authentication {
	//...
}

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

class RobotAuthenticationToken extends AbstractAuthenticationToken {

	@Override
	public Object getCredentials() {
    		return null;
	}

	@Override
	public Object getPrincipal() {
    		return null;
	}
}

Такой подход значительно уменьшит количество методов под реализацию. Но нам все еще нужен конструктор, потому что мне надо знать разрешения (permissions) пользователя или сущности. Так что здесь в этом случае мы напишем:

public RobotAuthenticationToken() {
	super(AuthorityUtils.createAuthorityList("ROLE_robot"));
}

Итак, getCredentials() и setCredentials() будет null, а getPrincipal() — это детали запроса, и здесь мы вернем простую строку.

@Override
public Object getPrincipal() {
	return "Ms Robot ?";
}

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

И последнее, что необходимо сделать, это убедиться в том, что isAuthenticated равно true. Одна из вещей, которую я могу сделать, это установить  isAuthenticated в true. Это можно было бы сделать следующим образом:

setAuthenticated(true);

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

@Override
public boolean isAuthenticated() {
	return true;
}

@Override
public void setAuthenticated(boolean authenticated) {
	throw new RuntimeException("Can’t touch this!");
}

Итак, у нас есть аутентификация, и я хочу использовать ее в фильтре. У нас есть auth, и мы хотим установить ее в SecurityContext, и это делается через создание нового контекста. И в новом контексте мы устанавливаем аутентификацию в auth, а в SecurityContext мы устанавливаем контекст в newContext. Код второго пункта в методе doFilterInternal() приобретает следующий вид:

// 2. Check credentials and [authenticate | reject]
if (!Objects.equals(request.getHeader("x-robot-secret"), "beep-boop")) {
	response.setStatus(HttpStatus.FORBIDDEN.value());
	response.setCharacterEncoding("UTF-8");
	response.setHeader("Content-Type", "text/plain;charset=UTF-8");
	response.getWriter().write("⛔⛔?? You are not Ms Robot");
	return;
}

var auth = new RobotAuthenticationToken();
var newContext = SecurityContextHolder.createEmptyContext();
newContext.setAuthentication(auth);
SecurityContextHolder.setContext(newContext);

Готово. Теперь попробуем попасть на закрытую страницу, отправив следующий запрос:

http :8080/private x-robot-secret:beep-boop

Ответом является код закрытой страницы:

<head>
  <meta charset="UTF-8">
  <title>? Private 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>VIP section ???</h1>
  <p>Hello, ~[Ms Robot ?]~ !</p>
  <p>You are on the very exclusive private page.</p>
  <p></p>
  <form method="post" action="/logout">
	<input name="_csrf" type="hidden" value="a3hPJ8McJRsI9e2Z6l9x4wCmBQTdWVCxCaasW9F1X-EVEtZ6Dxx_EPt9RCMIx9_63HJF0zKWKDy5PzGcOJ0bbbRAadkgIOYY">
    <button class="btn" type="submit">Log out</button>
  </form>
</body>
</html>

Если же SRE команда введет неверный пароль, в доступе будет отказано:

http :8080/private x-robot-secret:beep-beep

Секрет неправильный, мы ввели beep-beep, мы не миссис Робот, и мы видим в консоли следующее:

HTTP/1.1 403
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Connection: keep-alive
Content-Length: 41
Content-Type: text/plain;charset=UTF-8
Date: Thu, 30 May 2024 10:34:53 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

⛔⛔?? You are not Ms Robot

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

Это происходит потому, что браузер не посылает заголовок. И именно в этом месте нам необходимо реализовать первый пункт метода, там где необходимо принять решение, хотите ли мы применить фильтр. Мы аутентифицируем запрос от робота только тогда, когда присутствует заголовок x-robot-secret, поэтому пишем:

if (!Collections.list(request.getHeaderNames()).contains("x-robot-secret")) {
    //...
}

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

// 1. Decide whether we want to apply the filter?
if (!Collections.list(request.getHeaderNames()).contains("x-robot-secret")) {
	filterChain.doFilter(request, response);
	return;
}

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

Одна из вещей, которая поменялась в Spring Security 6 для людей, которые использовали пятый, в прошлом, когда создавали новый контекст, он сохранялся, и вы получали cookie от сессии, чтобы пользователь оставался залогиненным. Этого больше не происходит по умолчанию по причинам тайминга и race condition. Так что если вы хотите сохранить эту сессию, эту Security, эту Authentication, в вашей сессии, вам понадобится репозиторий для сессии: HttpSessionSecurityContextRepository. По факту,  SecurityContextRepository.

SecurityContextRepository scr;
scr.saveContext(newContext, request, response);

По умолчанию существует HttpSessionRequestRepository, который вы можете использовать, если хотите, чтобы логин сохранялся по всем запросам. 

Соберем получившийся код воедино:

class RobotAuthenticationFilter extends OncePerRequestFilter {

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
    		// 1. Decide whether we want to apply the filter?

if (!Collections.list(request.getHeaderNames()).contains("x-robot-secret")) {
		filterChain.doFilter(request, response);
		Return;
}

// 2. Check credentials and [authenticate | reject]
if (!Objects.equals(request.getHeader("x-robot-secret"), "beep-boop")) {
		response.setStatus(HttpStatus.FORBIDDEN.value());
		response.setCharacterEncoding("UTF-8");
		response.setHeader("Content-Type", "text/plain;charset=UTF-8");
		response.getWriter().write("⛔⛔?? You are not Ms Robot");
		return;
}

var auth = new RobotAuthenticationToken();
var newContext = SecurityContextHolder.createEmptyContext();
newContext.setAuthentication(auth);
SecurityContextHolder.setContext(newContext);
SecurityContextRepository scr;
scr.saveContext(newContext, request, response);

    		// 3. Call next!
    		filterChain.doFilter(request, response);

	}
}

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

Давайте кратко просуммируем все сказанное выше. 

  • Некоторые фильтры защищают от эксплойтов, некоторые создают объекты типа Authentication

  • Они считывают запрос, проверяют креденшелы, если креденшелы валидны, они создают Authentication объект, сохраняют его в SecurityContext, а если креденшелы неправильные, тогда они отклоняют запрос и возвращают ответ 401, 403 или что-то наподобие.

  • Не используйте UsernamePasswordAuthenticationToken, если хотите сами попробовать воспроизвести приведенный выше сценарий. Используйте специализированные реализации.


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

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