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

Для начала подготовим проект, зайдем на https://start.spring.io/, поставим галочки напротив Web > web, и Core > Security.
Добавим контролер:
Добавим rest-assured:
Добавим груви:
Напишем тест:
Запустим тест. Что в логах?
Запрос:
Ответ:
SS без дополнительных настроек уже начал защищать вызовы методов, так как заработала конфигурация —
На некоторые из них можно повлиять через настройки:
docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html
искать "# SECURITY PROPERTIES", также можно заглянуть в код:
Донастроим spring boot конфигурацию для полноты истории:
Работа Spring Security в веб приложении начинается с servlet фильтра.
Попробуем подебажить, но для начала добавим тест с успешной авторизацией.
Поставим бряк и запустим тест.

рис. 1 — get метод
спустимся по огромному стеку вызовов

рис. 2 — стэк вызовов
Посмотрим что лежит в переменной

рис. 3 — application filter chain
Здесь интересен фильтр
Сам

рис. 4 — filter chain proxy
Свою работу он делегирует классу
Прежде всего посмотрим на метод
Внутри метода getFilters берем первый
Зайдем в деббагер и посмотрим какой список итерируется.

рис. 5 — security filter chains
Что нам говорит этот список?
В обоих листах лежит
Первый элемет списка имеет пустой список фильтров, соответсвенно никакой дополнительной фильтрации не будет, и как следствие не будет и защиты.
Проверим на практике.
Напишем тест:
Гораздо интереснее второй SecurityFilterChain который совпадет с любым url "/**"
В нашем случае имеется следующий список фильтров.
Этот список может изменяться в зависимости от настроек и добавленных зависимостей.
Например с такой конфигурацией:
В этот список добавились бы фильтры:
В каком порядке фильтры идут по дефолту можно посмотреть здесь: FilterComparator
Нам не очень интересен, согласно документации он «интегрирует» SecurityContext с WebAsyncManager который отвественнен за асинхронные запросы.
Ищет SecurityContext в сессии и заполняет SecurityContextHolder если находит.
По умолчанию используется ThreadLocalSecurityContextHolderStrategy которая хранит SecurityContext в ThreadLocal переменной.
Просто добавляет заголовки в response.
Отключаем кэш:
– Cache-Control: no-cache, no-store, max-age=0, must-revalidate
– Pragma: no-cache
– Expires: 0
Не разрешаем браузерам автоматически определять тип контента:
– X-Content-Type-Options: nosnif
Не разрешаем iframe
– X-Frame-Options: DENY
Включаем встроенную зашиту в браузер от cross-site scripting (XSS)
– X-XSS-Protection: 1; mode=block
Пожалуй нет ни одного разработчика который при знакомстве с SS не столкнулся бы с ошибкой «отсутсвия csrf токена».
Почему мы не встречали эту ошибку ранее? Все просто, мы запускали методы на которых нет csrf защиты.
Попробуем добавить POST метод
Тест:
Тест выполнился успешно, нам вернули 403 ошибку, csrf защита на месте.
Далее идет logout фильтр, он проверяет совпадает ли url c паттерном
и запускает процедуру логаута
по дефолту происходит следующие:
Теперь мы добрались непосредственно до аутентификации. Что происходит внутри?
Фильтр проверяет, есть ли заголовок Authorization со значением начинающийся на Basic
Если находит, извлекает логин\пароль и передает их в
Внутри примерно такой код:
AuthenticationManager представляет из себя интрефейс, который принимает Authentication и возвращает тоже Authentication.
В нашем случае в имплементацией Authentication будет UsernamePasswordAuthenticationToken.
Можно было бы реализовать AuthenticationManager самому, но смысла в этом мало, существует дефолтная реализация — ProviderManager.
ProviderManager авторизацию делегирует другому интерфейсу:
Когда мы передаем объект
AuthenticationProvider эту имплементацию Authentication
В результате внутри
Далее из конкретной реализации вытаскиваем креденшеналы.
Если аутентификация не удалась
Более подробно и с картинками процесс описан здесь:
https://spring.io/guides/topicals/spring-security-architecture/
Далее
SecurityContextHolder.getContext().setAuthentication(authResult);
Процесс аутентификации на этом завершен.
Если выбросится AuthenticationException то будет сброшен
Задачей AuthenticationEntryPoint явялется записать в ответ информацию о том что аутентификация не удалась.
В случае бейсик аутентификации это будет:
В результате браузер покажет окошко basic авторизации.
Для чего нужен этот фильтр? Представим сценарий:
1. Пользователь заходит на защишенный url.
2. Его перекидывает на страницу логина.
3. После успешной авторизации пользователя перекидывает на страницу которую он запрашивал в начале.
Именно для для восстановления оригинального запроса существует этот фильтр.
Внутри проверяется есть ли сохраненный запрос, если есть им подменяется текущий запрос.
Запрос сохраняется в сессии, на каком этапе он сохраняется будет написанно ниже.
Попробуем вопроизвести.
Добавим метод:
Добавим тест:
Как видим во втором запросе нам вернулся заголовок который мы передали в первом запросе. Фильтр работает.
Оборачивает существущий запрос в SecurityContextHolderAwareRequestWrapper
Имплементация может отличаться в зависимости от servlet api версии servlet 2.5/3
Если к моменту выполнения этого фильтра SecurityContextHolder пуст, т.е. не произошло аутентификации фильтр заполняет объект SecurityContextHolder анонимной аутентификацией — AnonymousAuthenticationToken с ролью «ROLE_ANONYMOUS».
Это гарарантирует что в SecurityContextHolder будет объект, это позволяет не бояться NP, а также более гибко подходить к настройке доступа для неавторизованных пользователей.
На это этапе производятся действия связанные с сессией.
Это может быть:
— смена идентификатора сессии
— ограничени количества одновременных сессий
— сохранение SecurityContext в securityContextRepository
В нашем случае происходит следующе:
Вызывается
Внутри sessionAuthenticationStrategy лежит:
Происходят 2 вещи:
1. По умолчанию включенна защита от session fixation attack, т.е. после аутенцификации меняется id сессии.
2. Если был передан csrf токен, генерируется новый csrf токен
Попробуем проверить первый пункт:
К этому моменту SecurityContext должен содеражть анонимную, либо нормальную аутентификацию.
ExceptionTranslationFilter прокидывает запрос и ответ по filter chain и обрабатывает возможные ошибки авторизации.
SS различает 2 случая:
1. AuthenticationException
Вызывается
2. AccessDeniedException
Тут опять возможны 2 случая:
1. Пользователь с анонимной аутентификацией, или с аутентификацией по rememberMe токену
вызывается sendStartAuthentication
2. Пользователь с полной, не анонимной аутентификацией вызывается:
accessDeniedHandler.handle(request, response, (AccessDeniedException) exception)
который по дефолту проставляет ответ forbidden 403
На последнем этапе происходит авторизация на основе url запроса.
FilterSecurityInterceptor наследуется от AbstractSecurityInterceptor и решает, имеет ли текущий пользователь доступ до текущего url.
Существует другая реализация MethodSecurityInterceptor который отвественнен за допуск до вызова метода, при использовании аннотаций @Secured\@PreAuthorize.
Внутри вызывается AccessDecisionManager
Есть несколько стратегий принятия решения о том давать ли допуск или нет, по умолчанию используется: AffirmativeBased
код внутри очень простой:
Иными словами если кто-то голосует за, пропускаем, если хоть 1 голосует против не пускаем, если никто не проголосовал не пускаем.
Подведем небольшой итог:
Пример набора фильтров для basic авторизации:
это позволит избежать неприятных неожиданностей.
Authentication — сам по себе не очень удобный для использования объект. Почти все методы возвращают Object, а чтобы получить нужную информацию нужно кастить в конкретную реализацию.
Лучше заведите интерфейс, сделайте реализацию в зависимости от потребностей, напишите HandlerMethodArgumentResolver.
Код с таким подходом лучше читать, тестировать, поддерживать.
Spring security содержит множество интрефейсов которые можно имплементировать, но скорее всего существует абрактный класс который на 99% делает то что вам нужно.
Например для интерфейса Authentication, существует
В том случае если у вас полностью кастомная аутентификация, скорее всего вам пришлось сделать следующее:
1. Создать реализацию Authentication
2. Создать AuthenticationProvider который поддерживает вашу реализацию Authentication
3. Добавить фильтр который начинал процесс аутентификации.
Разумно объеденить их всех в одном месте. Посмотрите на
Напишите тест который пройдет по всем методам конроллеров и проверит наличие @Secured\@PreAuthorize аннотаций.
При настройке WebSecurityConfigurerAdapter требуйте наличия авторизации для всех url. При необходимости добавляйте исключения. Исключения должны быть как можно более строгие.
Явно указывайте тип http метода, а url должен быть как можно более полным.
Лучше явно указать полный путь до метода, даже если на момент написания других api с таким endpoint-ом не было.
Например если есть контроллер с двумя GET методами:
Не стоит делать так:
Лучше напишите:
Spring Security имеет достаточно подробные debug логи, зачастую одних их достаточно чтобы понять суть проблемы.
В случае «ignored_url» будет проверяться на этапе выбора security filter chain и если url совпадет то будет использован пустой фильтр.
В случае «permit_all_url» проверка будет проходить на этапе AccessDecisionManager.
В этой статье шаг за шагом пройдемся по пути http запроса, что поможет с пониманием настраивать и решать проблемы Spring Security.

Подготовка проекта
Для начала подготовим проект, зайдем на https://start.spring.io/, поставим галочки напротив Web > web, и Core > Security.
Добавим контролер:
@RestController public class Controller { @GetMapping public String get() { return String.valueOf(System.currentTimeMillis()); } }
Добавим rest-assured:
testCompile('io.rest-assured:rest-assured:3.0.2')
Добавим груви:
apply plugin: 'groovy'
Напишем тест:
ControllerIT.groovy@RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @TestPropertySource(properties = "security.user.password=pass") class ControllerIT { @LocalServerPort private int serverPort; @Before void initRestAssured() { RestAssured.port = serverPort; RestAssured.filters(new ResponseLoggingFilter()); RestAssured.filters(new RequestLoggingFilter()); } @Test void 'api call without authentication must fail'() { when() .get("/") .then() .statusCode(HttpStatus.SC_UNAUTHORIZED); } }
Запустим тест. Что в логах?
Запрос:
Request method: GET Request URI: http://localhost:51213/
Ответ:
HTTP/1.1 401 X-Content-Type-Options: nosniff X-XSS-Protection: 1; mode=block Cache-Control: no-cache, no-store, max-age=0, must-revalidate Pragma: no-cache Expires: 0 X-Frame-Options: DENY Strict-Transport-Security: max-age=31536000 ; includeSubDomains WWW-Authenticate: Basic realm="Spring" Content-Type: application/json;charset=UTF-8 Transfer-Encoding: chunked Date: Sun, 22 Oct 2017 11:53:00 GMT { "timestamp": 1508673180745, "status": 401, "error": "Unauthorized", "message": "Full authentication is required to access this resource", "path": "/" }
SS без дополнительных настроек уже начал защищать вызовы методов, так как заработала конфигурация —
SpringBootWebSecurityConfiguration поставляемая spring boot-ом. Внутри этого класса лежит ApplicationNoWebSecurityConfigurerAdapter который устанавливает дефолты.На некоторые из них можно повлиять через настройки:
docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html
искать "# SECURITY PROPERTIES", также можно заглянуть в код:
SecurityPropertiesДонастроим spring boot конфигурацию для полноты истории:
@TestPropertySource(properties = [ "security.user.password=pass", "security.enable-csrf=true", "security.sessions=if_required" ])
Фильтры
Работа Spring Security в веб приложении начинается с servlet фильтра.
Попробуем подебажить, но для начала добавим тест с успешной авторизацией.
@Test void 'api call with authentication must succeed'() { given() .auth().preemptive().basic("user", "pass") .when() .get("/") .then() .statusCode(HttpStatus.SC_OK); }
Поставим бряк и запустим тест.

рис. 1 — get метод
спустимся по огромному стеку вызовов
(new Exception().getStackTrace().length == 91) и найдем первое упоминание спринга
рис. 2 — стэк вызовов
Посмотрим что лежит в переменной
filterChain
рис. 3 — application filter chain
Здесь интересен фильтр
springSecurityFilterChain именно он делает всю работу SS в веб части.Сам
DelegatingFilterProxyRegistrationBean не очень интересен, посмотрим кому он делегирует свою работу
рис. 4 — filter chain proxy
Свою работу он делегирует классу
FilterChainProxy. Внутри него происходит несколько интересных вещей.Прежде всего посмотрим на метод
FilterChainProxy#doFilterInternal. Что здесь происходит? Получаем фильтры, создаем VirtualFilterChain и запускаем по ним запрос и ответ.List<Filter> filters = getFilters(fwRequest); ... VirtualFilterChain vfc = new VirtualFilterChain(fwRequest, chain, filters); vfc.doFilter(fwRequest, fwResponse);
Внутри метода getFilters берем первый
SecurityFilterChain который совпадет с запросом.private List<Filter> getFilters(HttpServletRequest request) { for (SecurityFilterChain chain : filterChains) { if (chain.matches(request)) { return chain.getFilters(); } } return null; }
Зайдем в деббагер и посмотрим какой список итерируется.

рис. 5 — security filter chains
Что нам говорит этот список?
В обоих листах лежит
OrRequestMatcher, который попытается сматчить текущий url с хотя бы с одним паттерном из списка.Первый элемет списка имеет пустой список фильтров, соответсвенно никакой дополнительной фильтрации не будет, и как следствие не будет и защиты.
Проверим на практике.
Любой url который совпадет с этим паттернами, по умолчанию не будет защищен SS.Добавим метод:
"/css/**", "/js/**", "/images/**", "/webjars/**", "/**/favicon.ico", "/error"
@GetMapping("css/hello") public String cssHello() { return "Hello I'm secret data"; }
Напишем тест:
@Test void 'get css/hello must succeed'() { when() .get("css/hello") .then() .statusCode(HttpStatus.SC_OK); }
Гораздо интереснее второй SecurityFilterChain который совпадет с любым url "/**"
В нашем случае имеется следующий список фильтров.
0 = {WebAsyncManagerIntegrationFilter} 1 = {SecurityContextPersistenceFilter} 2 = {HeaderWriterFilter} 3 = {CsrfFilter} 4 = {LogoutFilter} 5 = {BasicAuthenticationFilter} 6 = {RequestCacheAwareFilter} 7 = {SecurityContextHolderAwareRequestFilter} 8 = {AnonymousAuthenticationFilter} 9 = {SessionManagementFilter} 10 = {ExceptionTranslationFilter} 11 = {FilterSecurityInterceptor}
Этот список может изменяться в зависимости от настроек и добавленных зависимостей.
Например с такой конфигурацией:
http .authorizeRequests().anyRequest().authenticated() .and() .formLogin() .and() .httpBasic();
В этот список добавились бы фильтры:
UsernamePasswordAuthenticationFilter DefaultLoginPageGeneratingFilter
В каком порядке фильтры идут по дефолту можно посмотреть здесь: FilterComparator
0 = {WebAsyncManagerIntegrationFilter}
Нам не очень интересен, согласно документации он «интегрирует» SecurityContext с WebAsyncManager который отвественнен за асинхронные запросы.
1 = {SecurityContextPersistenceFilter}
Ищет SecurityContext в сессии и заполняет SecurityContextHolder если находит.
По умолчанию используется ThreadLocalSecurityContextHolderStrategy которая хранит SecurityContext в ThreadLocal переменной.
2 = {HeaderWriterFilter}
Просто добавляет заголовки в response.
Отключаем кэш:
– Cache-Control: no-cache, no-store, max-age=0, must-revalidate
– Pragma: no-cache
– Expires: 0
Не разрешаем браузерам автоматически определять тип контента:
– X-Content-Type-Options: nosnif
Не разрешаем iframe
– X-Frame-Options: DENY
Включаем встроенную зашиту в браузер от cross-site scripting (XSS)
– X-XSS-Protection: 1; mode=block
3 = {CsrfFilter}
Пожалуй нет ни одного разработчика который при знакомстве с SS не столкнулся бы с ошибкой «отсутсвия csrf токена».
Почему мы не встречали эту ошибку ранее? Все просто, мы запускали методы на которых нет csrf защиты.
Попробуем добавить POST метод
@PostMapping("post") public String testPost() { return "Hello it is post request"; }
Тест:
@Test void 'POST without CSRF token must return 403'() { given() .auth().preemptive().basic("user", "pass") .when() .post("/post") .then() .statusCode(HttpStatus.SC_FORBIDDEN); }
Тест выполнился успешно, нам вернули 403 ошибку, csrf защита на месте.
4 = {LogoutFilter}
Далее идет logout фильтр, он проверяет совпадает ли url c паттерном
Ant [pattern='/logout', POST] - по умолчаниюи запускает процедуру логаута
handler = {CompositeLogoutHandler} logoutHandlers = {ArrayList} size = 2 0 = {CsrfLogoutHandler} 1 = {SecurityContextLogoutHandler}
по дефолту происходит следующие:
- Удаляется Csrf токен.
- Завершается сессия
- Чистится SecurityContextHolder
5 = {BasicAuthenticationFilter}
Теперь мы добрались непосредственно до аутентификации. Что происходит внутри?
Фильтр проверяет, есть ли заголовок Authorization со значением начинающийся на Basic
Если находит, извлекает логин\пароль и передает их в
AuthenticationManagerВнутри примерно такой код:
if (headers.get("Authorization").startsWith("Basic")) { try { UsernamePasswordAuthenticationToken token = extract(header); Authentication authResult = authenticationManager.authenticate(token); } catch (AuthenticationException failed) { SecurityContextHolder.clearContext(); this.authenticationEntryPoint.commence(request, response, failed); return; } } else { chain.doFilter(request, response); }
AuthenticationManager
public interface AuthenticationManager { Authentication authenticate(Authentication authentication) throws AuthenticationException; }
AuthenticationManager представляет из себя интрефейс, который принимает Authentication и возвращает тоже Authentication.
В нашем случае в имплементацией Authentication будет UsernamePasswordAuthenticationToken.
Можно было бы реализовать AuthenticationManager самому, но смысла в этом мало, существует дефолтная реализация — ProviderManager.
ProviderManager авторизацию делегирует другому интерфейсу:
public interface AuthenticationProvider { Authentication authenticate(Authentication authentication) throws AuthenticationException; boolean supports(Class<?> authentication); }
Когда мы передаем объект
Authentication в ProviderManager, он перебирает существующие AuthenticationProvider-ры и проверяет суппортит лиAuthenticationProvider эту имплементацию Authentication
public boolean supports(Class<?> authentication) { return (UsernamePasswordAuthenticationToken.class .isAssignableFrom(authentication)); }
В результате внутри
AuthenticationProvider.authenticate мы уже можем скастить переданный Authentication в нужную реализацию без каст эксепшена. Далее из конкретной реализации вытаскиваем креденшеналы.
Если аутентификация не удалась
AuthenticationProvider должен бросить эксепшен, ProviderManager поймает его и попробует следующий AuthenticationProvider из списка, если ни один AuthenticationProvider не вернет успешную аутентификацию, то ProviderManager пробросит последний пойманный эксепшен.Более подробно и с картинками процесс описан здесь:
https://spring.io/guides/topicals/spring-security-architecture/
Далее
BasicAuthenticationFilter сохраняет полученный Authentication в SecurityContextHolder SecurityContextHolder.getContext().setAuthentication(authResult);
Процесс аутентификации на этом завершен.
Если выбросится AuthenticationException то будет сброшен
SecurityContextHolder.clearContext(); контекст и вызовится AuthenticationEntryPoint.public interface AuthenticationEntryPoint { void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException; }
Задачей AuthenticationEntryPoint явялется записать в ответ информацию о том что аутентификация не удалась.
В случае бейсик аутентификации это будет:
response.addHeader("WWW-Authenticate", "Basic realm=\"" + realmName + "\""); response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
В результате браузер покажет окошко basic авторизации.
6 = {RequestCacheAwareFilter}
Для чего нужен этот фильтр? Представим сценарий:
1. Пользователь заходит на защишенный url.
2. Его перекидывает на страницу логина.
3. После успешной авторизации пользователя перекидывает на страницу которую он запрашивал в начале.
Именно для для восстановления оригинального запроса существует этот фильтр.
Внутри проверяется есть ли сохраненный запрос, если есть им подменяется текущий запрос.
Запрос сохраняется в сессии, на каком этапе он сохраняется будет написанно ниже.
Попробуем вопроизвести.
Добавим метод:
@GetMapping("customHeader") public String customHeader(@RequestHeader("x-custom-header") String customHeader) { return customHeader; }
Добавим тест:
@Test void 'passed x-custom-header must be returned'() { def sessionCookie = given() .header("x-custom-header", "hello") .when() .get("customHeader") .then() .statusCode(HttpStatus.SC_UNAUTHORIZED) .extract().cookie("JSESSIONID") given() .auth().basic("user", "pass") .cookie("JSESSIONID", sessionCookie) .when() .get("customHeader") .then() .statusCode(HttpStatus.SC_OK) .body(equalTo("hello")); }
Как видим во втором запросе нам вернулся заголовок который мы передали в первом запросе. Фильтр работает.
7 = {SecurityContextHolderAwareRequestFilter}
Оборачивает существущий запрос в SecurityContextHolderAwareRequestWrapper
chain.doFilter(this.requestFactory.create((HttpServletRequest) req, (HttpServletResponse) res), res);
Имплементация может отличаться в зависимости от servlet api версии servlet 2.5/3
8 = {AnonymousAuthenticationFilter}
Если к моменту выполнения этого фильтра SecurityContextHolder пуст, т.е. не произошло аутентификации фильтр заполняет объект SecurityContextHolder анонимной аутентификацией — AnonymousAuthenticationToken с ролью «ROLE_ANONYMOUS».
Это гарарантирует что в SecurityContextHolder будет объект, это позволяет не бояться NP, а также более гибко подходить к настройке доступа для неавторизованных пользователей.
9 = {SessionManagementFilter}
На это этапе производятся действия связанные с сессией.
Это может быть:
— смена идентификатора сессии
— ограничени количества одновременных сессий
— сохранение SecurityContext в securityContextRepository
В нашем случае происходит следующе:
SecurityContextRepository с дефолтной реализацией HttpSessionSecurityContextRepository сохраняет SecurityContext в сессию.Вызывается
sessionAuthenticationStrategy.onAuthentication Внутри sessionAuthenticationStrategy лежит:
sessionAuthenticationStrategy = {CompositeSessionAuthenticationStrategy} delegateStrategies 0 = {ChangeSessionIdAuthenticationStrategy} 1 = {CsrfAuthenticationStrategy}
Происходят 2 вещи:
1. По умолчанию включенна защита от session fixation attack, т.е. после аутенцификации меняется id сессии.
2. Если был передан csrf токен, генерируется новый csrf токен
Попробуем проверить первый пункт:
@Test void 'JSESSIONID must be changed after login'() { def sessionCookie = when() .get("/") .then() .statusCode(HttpStatus.SC_UNAUTHORIZED) .extract().cookie("JSESSIONID") def newCookie = given() .auth().basic("user", "pass") .cookie("JSESSIONID", sessionCookie) .when() .get("/") .then() .statusCode(HttpStatus.SC_OK) .extract().cookie("JSESSIONID") Assert.assertNotEquals(sessionCookie, newCookie) }
10 = {ExceptionTranslationFilter}
К этому моменту SecurityContext должен содеражть анонимную, либо нормальную аутентификацию.
ExceptionTranslationFilter прокидывает запрос и ответ по filter chain и обрабатывает возможные ошибки авторизации.
SS различает 2 случая:
1. AuthenticationException
Вызывается
sendStartAuthentication, внутри которого происходит следующиее:SecurityContextHolder.getContext().setAuthentication(null); — отчищает SecurityContextHolderrequestCache.saveRequest(request, response); — сохраняет в requestCache текущий запрос, чтобы RequestCacheAwareFilter было что восстанавливать.authenticationEntryPoint.commence(request, response, reason); — вызывает authenticationEntryPoint — который записывает в ответ сигнал о том что необходимо произвести аутентификацию (заголовки \ редирект) 2. AccessDeniedException
Тут опять возможны 2 случая:
if (authenticationTrustResolver.isAnonymous(authentication) || authenticationTrustResolver.isRememberMe(authentication)) { ... } else { ... }
1. Пользователь с анонимной аутентификацией, или с аутентификацией по rememberMe токену
вызывается sendStartAuthentication
2. Пользователь с полной, не анонимной аутентификацией вызывается:
accessDeniedHandler.handle(request, response, (AccessDeniedException) exception)
который по дефолту проставляет ответ forbidden 403
11 = {FilterSecurityInterceptor}
На последнем этапе происходит авторизация на основе url запроса.
FilterSecurityInterceptor наследуется от AbstractSecurityInterceptor и решает, имеет ли текущий пользователь доступ до текущего url.
Существует другая реализация MethodSecurityInterceptor который отвественнен за допуск до вызова метода, при использовании аннотаций @Secured\@PreAuthorize.
Внутри вызывается AccessDecisionManager
Есть несколько стратегий принятия решения о том давать ли допуск или нет, по умолчанию используется: AffirmativeBased
код внутри очень простой:
for (AccessDecisionVoter voter : getDecisionVoters()) { int result = voter.vote(authentication, object, configAttributes); switch (result) { case AccessDecisionVoter.ACCESS_GRANTED: return; case AccessDecisionVoter.ACCESS_DENIED: deny++; break; default: break; } } if (deny > 0) { throw new AccessDeniedException(); } checkAllowIfAllAbstainDecisions();
Иными словами если кто-то голосует за, пропускаем, если хоть 1 голосует против не пускаем, если никто не проголосовал не пускаем.
Подведем небольшой итог:
springSecurityFilterChain — набор фильтров spring security.Пример набора фильтров для basic авторизации:
WebAsyncManagerIntegrationFilter — Интегрирует SecurityContext с WebAsyncManagerSecurityContextPersistenceFilter — Ищет SecurityContext в сессии и заполняет SecurityContextHolder если находитHeaderWriterFilter — Добавляет «security» заголовки в ответCsrfFilter — Проверяет на наличие сsrf токенаLogoutFilter — Выполняет logoutBasicAuthenticationFilter — Производит basic аутентификациюRequestCacheAwareFilter — Восстанавливает сохраненный до аутентификации запрос, если такой естьSecurityContextHolderAwareRequestFilter — Оборачивает существущий запрос в SecurityContextHolderAwareRequestWrapperAnonymousAuthenticationFilter — Заполняет SecurityContext ананонимной аутентификациейSessionManagementFilter — Выполняет действия связанные с сессиейExceptionTranslationFilter — Обрабатывает AuthenticationException\AccessDeniedException которые происходят ниже по стеку.FilterSecurityInterceptor — Проверяет имеет ли текущей пользователь доступ к текущему url.FilterComparator — здесь можно посмотреть список фильтров и их возможный порядок.AuthenticationManager — интерфейс, ответственнен за аутентификациюProviderManager — реализация AuthenticationManager, которая использует внутри использует AuthenticationProviderAuthenticationProvider — интерфейс, отвественнен за аутентификаци конкретной реализации Authentication.SecurityContextHolder — хранит в себе аутентификацию обычно в ThreadLocal переменной.AuthenticationEntryPoint — модифицирует ответ, чтобы дать понять клиенту что необходима аутентификация (заголовки, редирект на страницу логина, т.п.)AccessDecisionManager решает имеет ли Authentication доступ к какому-то ресурсу.AffirmativeBased — стратегия используемая AccessDecisionManager по умолчанию.Рекомендации
Напишите простые тесты, которые протестируют порядок фильтров и их настройки
это позволит избежать неприятных неожиданностей.
FilterChainIT.groovy@RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class FilterChainIT { @Autowired FilterChainProxy filterChainProxy; @Autowired List<Filter> filters; @Test void 'test main filter chain'() { assertEquals(5, filters.size()); assertEquals(OrderedCharacterEncodingFilter, filters[0].getClass()) assertEquals(OrderedHiddenHttpMethodFilter, filters[1].getClass()) assertEquals(OrderedHttpPutFormContentFilter, filters[2].getClass()) assertEquals(OrderedRequestContextFilter, filters[3].getClass()) assertEquals("springSecurityFilterChain", filters[4].filterName) } @Test void 'test security filter chain order'() { assertEquals(2, filterChainProxy.getFilterChains().size()); def chain = filterChainProxy.getFilterChains().get(1); assertEquals(chain.filters.size(), 11) assertEquals(WebAsyncManagerIntegrationFilter, chain.filters[0].getClass()) assertEquals(SecurityContextPersistenceFilter, chain.filters[1].getClass()) } @Test void 'test ignored patterns'() { def chain = filterChainProxy.getFilterChains().get(0); assertEquals("/css/**", chain.requestMatcher.requestMatchers[0].pattern); assertEquals("/js/**", chain.requestMatcher.requestMatchers[1].pattern); assertEquals("/images/**", chain.requestMatcher.requestMatchers[2].pattern); } }
Не вызывайте SecurityContextHolder.getContext().getAuthentication(); для получения текущего юзера
Authentication — сам по себе не очень удобный для использования объект. Почти все методы возвращают Object, а чтобы получить нужную информацию нужно кастить в конкретную реализацию.
Лучше заведите интерфейс, сделайте реализацию в зависимости от потребностей, напишите HandlerMethodArgumentResolver.
Код с таким подходом лучше читать, тестировать, поддерживать.
interface Auth { ... } public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.getParameterType().equals(Auth.class); } @Override public Auth resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); return toAuth(principal) } } @GetMapping public String get(Auth auth) { return "hello " + auth.getId(); }
Расширяйте существующие реализации
Spring security содержит множество интрефейсов которые можно имплементировать, но скорее всего существует абрактный класс который на 99% делает то что вам нужно.
Например для интерфейса Authentication, существует
AbstractAuthenticationToken, а аутенфикационный фильтр разумно отнаследовать от AbstractAuthenticationProcessingFilterИспользуйте SecurityConfigurerAdapter чтобы сконфигурировать вашу аутентификацию
В том случае если у вас полностью кастомная аутентификация, скорее всего вам пришлось сделать следующее:
1. Создать реализацию Authentication
2. Создать AuthenticationProvider который поддерживает вашу реализацию Authentication
3. Добавить фильтр который начинал процесс аутентификации.
Разумно объеденить их всех в одном месте. Посмотрите на
HttpBasicConfigurer, OpenIDLoginConfigurer они делают тоже самое.class MyConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> { @Override public void configure(HttpSecurity http) throws Exception { AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class); MyAuthenticationProvider myAuthenticationProvider = http.getSharedObject(MyAuthenticationProvider.class); MyAuthenticationFilter filter = new MyAuthenticationFilter(authenticationManager); http .authenticationProvider(myAuthenticationProvider) .addFilterBefore(postProcess(filter), AbstractPreAuthenticatedProcessingFilter.class); } } public class SecurityConfig extends WebSecurityConfigurerAdapter { protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests().anyRequest().authenticated() .and() .apply(new MyConfigurer()) } }
Для ограничения вызова методов по ролям используйте @Secured\@PreAuthorize
Напишите тест который пройдет по всем методам конроллеров и проверит наличие @Secured\@PreAuthorize аннотаций.
При настройке WebSecurityConfigurerAdapter требуйте наличия авторизации для всех url. При необходимости добавляйте исключения. Исключения должны быть как можно более строгие.
Явно указывайте тип http метода, а url должен быть как можно более полным.
Лучше явно указать полный путь до метода, даже если на момент написания других api с таким endpoint-ом не было.
Например если есть контроллер с двумя GET методами:
"url/methodOne", "url/methodTwo", Не стоит делать так:
authorizeRequests().antMatchers(HttpMethod.GET, "url/**").permitAll().
Лучше напишите:
authorizeRequests().antMatchers(HttpMethod.GET, "url/methodOne", "url/methodTwo").permitAll().
В случае проблем включите org.springframework.security: debug
Spring Security имеет достаточно подробные debug логи, зачастую одних их достаточно чтобы понять суть проблемы.
Различайте antMatchers(«permit_all_url»).permitAll() и web.ignoring().antMatchers(«ignored_url»)
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests(). anyRequest() .authenticated() .antMatchers("permit_all_url") .permitAll(); } @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("ignored_url"); }
В случае «ignored_url» будет проверяться на этапе выбора security filter chain и если url совпадет то будет использован пустой фильтр.
В случае «permit_all_url» проверка будет проходить на этапе AccessDecisionManager.
