Команда 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 и всего, что с ним связано.