Путь запроса по внутренностям Spring Security

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

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

image


Подготовка проекта


Для начала подготовим проект, зайдем на 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 filters = getFilters(fwRequest); ... VirtualFilterChain vfc = new VirtualFilterChain(fwRequest, chain, filters); vfc.doFilter(fwRequest, fwResponse); 

Внутри метода getFilters берем первый SecurityFilterChain который совпадет с запросом.

private List 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='https://habrahabr.ru/logout', POST] - по умолчанию
и запускает процедуру логаута

handler = {CompositeLogoutHandler} logoutHandlers = {ArrayList} size = 2 0 = {CsrfLogoutHandler} 1 = {SecurityContextLogoutHandler} 

по дефолту происходит следующие:

  1. Удаляется Csrf токен.
  2. Завершается сессия
  3. Чистится 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); — отчищает SecurityContextHolder
requestCache.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 с WebAsyncManager
SecurityContextPersistenceFilter — Ищет SecurityContext в сессии и заполняет SecurityContextHolder если находит
HeaderWriterFilter — Добавляет «security» заголовки в ответ
CsrfFilter — Проверяет на наличие сsrf токена
LogoutFilter — Выполняет logout
BasicAuthenticationFilter — Производит basic аутентификацию
RequestCacheAwareFilter — Восстанавливает сохраненный до аутентификации запрос, если такой есть
SecurityContextHolderAwareRequestFilter — Оборачивает существущий запрос в SecurityContextHolderAwareRequestWrapper
AnonymousAuthenticationFilter — Заполняет SecurityContext ананонимной аутентификацией
SessionManagementFilter — Выполняет действия связанные с сессией
ExceptionTranslationFilter — Обрабатывает AuthenticationException\AccessDeniedException которые происходят ниже по стеку.
FilterSecurityInterceptor — Проверяет имеет ли текущей пользователь доступ к текущему url.

FilterComparator — здесь можно посмотреть список фильтров и их возможный порядок.

AuthenticationManager — интерфейс, ответственнен за аутентификацию
ProviderManager — реализация AuthenticationManager, которая использует внутри использует AuthenticationProvider
AuthenticationProvider — интерфейс, отвественнен за аутентификаци конкретной реализации 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 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 { @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.
Данные о правообладателе фото и видеоматериалов взяты с сайта «Хабрахабр», подробнее в Условиях использования
Анализ
×