Команда 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 и всего, что с ним связано.
ссылка на оригинал статьи https://habr.com/ru/articles/911862/
Добавить комментарий