📜 ⬆️ ⬇️

Spring Security interrogation query path

Most developers have only a rough idea of ​​what is happening inside Spring Security, which is dangerous and can lead to the appearance of vulnerabilities.

In this article, step by step, we will follow the path of the http request, which will help you understand and solve Spring Security problems with understanding.

image


Project preparation


To begin with, we will prepare a project, go to https://start.spring.io/ , tick the boxes Web> web, and Core> Security.
')
Add a controller:

@RestController public class Controller { @GetMapping public String get() { return String.valueOf(System.currentTimeMillis()); } } 

Add rest-assured:

 testCompile('io.rest-assured:rest-assured:3.0.2') 

Add a bulk:

 apply plugin: 'groovy' 

Let's write a test:

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); } } 

Run the test. What is in the logs?

Request:

 Request method: GET Request URI: http://localhost:51213/ 

Answer:

 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 without additional settings has already begun to protect method calls, since the configuration has worked - SpringBootWebSecurityConfiguration supplied by spring boot. Inside this class is the ApplicationNoWebSecurityConfigurerAdapter which sets defaults.

Some of them can be affected through the settings:
docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html
look for "# SECURITY PROPERTIES", you can also look at the code: SecurityProperties

Tune up the spring boot configuration to complete the story:

 @TestPropertySource(properties = [ "security.user.password=pass", "security.enable-csrf=true", "security.sessions=if_required" ]) 

Filters


Spring Security in a web application starts with a servlet filter.
Let's try, but first we will add a test with successful authorization.

 @Test void 'api call with authentication must succeed'() { given() .auth().preemptive().basic("user", "pass") .when() .get("/") .then() .statusCode(HttpStatus.SC_OK); } 

We put bryak and run the test.


rice 1 - get method

let's go down the huge call stack (new Exception().getStackTrace().length == 91) and find the first mention of the spring


rice 2 - call stack

Let's see what is in the variable filterChain


rice 3 - application filter chain

The springSecurityFilterChain filter is interesting here springSecurityFilterChain it is he who does all the SS work in the web part.

DelegatingFilterProxyRegistrationBean itself is not very interesting, let's see to whom it delegates its work.


rice 4 - filter chain proxy

He delegates his work to the FilterChainProxy class. Inside it happens a few interesting things.

First of all, let's look at the FilterChainProxy#doFilterInternal . What's going on here? We VirtualFilterChain filters, create VirtualFilterChain and run the request and response on them.

 List<Filter> filters = getFilters(fwRequest); ... VirtualFilterChain vfc = new VirtualFilterChain(fwRequest, chain, filters); vfc.doFilter(fwRequest, fwResponse); 

Inside the getFilters method, we take the first SecurityFilterChain that matches the request.

 private List<Filter> getFilters(HttpServletRequest request) { for (SecurityFilterChain chain : filterChains) { if (chain.matches(request)) { return chain.getFilters(); } } return null; } 

Let's go to the debbager and see which list is being iterated.


rice 5 - security filter chains

What does this list tell us?

In both sheets is OrRequestMatcher , which will try to match the current url with at least one pattern from the list.

The first element of the list has an empty list of filters, respectively, there will be no additional filtering, and as a result there will be no protection.

Check in practice.
Any url that matches this pattern will not be protected by SS by default.
"/css/**", "/js/**", "/images/**", "/webjars/**", "/**/favicon.ico", "/error"
Add a method:

 @GetMapping("css/hello") public String cssHello() { return "Hello I'm secret data"; } 

Let's write a test:

 @Test void 'get css/hello must succeed'() { when() .get("css/hello") .then() .statusCode(HttpStatus.SC_OK); } 

Much more interesting is the second SecurityFilterChain which matches any url "/ **"

In our case there is the following list of filters.

 0 = {WebAsyncManagerIntegrationFilter} 1 = {SecurityContextPersistenceFilter} 2 = {HeaderWriterFilter} 3 = {CsrfFilter} 4 = {LogoutFilter} 5 = {BasicAuthenticationFilter} 6 = {RequestCacheAwareFilter} 7 = {SecurityContextHolderAwareRequestFilter} 8 = {AnonymousAuthenticationFilter} 9 = {SessionManagementFilter} 10 = {ExceptionTranslationFilter} 11 = {FilterSecurityInterceptor} 

This list may vary depending on the settings and added dependencies.
For example with this configuration:

 http .authorizeRequests().anyRequest().authenticated() .and() .formLogin() .and() .httpBasic(); 

Filters would be added to this list:

 UsernamePasswordAuthenticationFilter DefaultLoginPageGeneratingFilter 

In which order the filters go by default can be viewed here: FilterComparator

0 = {WebAsyncManagerIntegrationFilter}


We are not very interesting, according to the documentation, it “integrates” the SecurityContext with the WebAsyncManager which is responsible for asynchronous requests.

1 = {SecurityContextPersistenceFilter}


Searches for the SecurityContext in the session and populates the SecurityContextHolder if found.
The default is ThreadLocalSecurityContextHolderStrategy which stores the SecurityContext in a ThreadLocal variable.

2 = {HeaderWriterFilter}


Just add the headers in response.

Disable cache:

- Cache-Control: no-cache, no-store, max-age = 0, must-revalidate
- Pragma: no-cache
- Expires: 0

We do not allow browsers to automatically determine the type of content:

- X-Content-Type-Options: nosnif

Do not allow iframe

- X-Frame-Options: DENY

We enable built-in protection in the browser from cross-site scripting (XSS)

- X-XSS-Protection: 1; mode = block

3 = {CsrfFilter}


Perhaps there is not a single developer who, when meeting with SS, would not encounter the error “lack of csrf token”.

Why we did not meet this error earlier? It's simple, we run methods on which there is no csrf protection.

Let's try to add POST method

 @PostMapping("post") public String testPost() { return "Hello it is post request"; } 

Test:

 @Test void 'POST without CSRF token must return 403'() { given() .auth().preemptive().basic("user", "pass") .when() .post("/post") .then() .statusCode(HttpStatus.SC_FORBIDDEN); } 

The test was successful, we were returned a 403 error, the csrf protection is in place.

4 = {LogoutFilter}


Next comes the logout filter, it checks if the url matches the pattern.
Ant [pattern='/logout', POST] -
and starts the logout procedure

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

By default, the following occurs:

  1. Csrf token removed.
  2. Session ends
  3. Cleaned SecurityContextHolder

5 = {BasicAuthenticationFilter}


Now we got directly to authentication. What happens inside?
The filter checks if there is an Authorization header with a value starting with Basic.
If found, retrieves the login \ password and sends them to the AuthenticationManager

Inside there is something like this:

 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 is an interface that accepts Authentication and returns Authentication too.

In our case, the Authentication implementation will be the UsernamePasswordAuthenticationToken.
It would be possible to implement the AuthenticationManager itself, but there is little point in this, there is a default implementation - ProviderManager.

ProviderManager authorizes delegates to another interface:

 public interface AuthenticationProvider { Authentication authenticate(Authentication authentication) throws AuthenticationException; boolean supports(Class<?> authentication); } 

When we pass the Authentication object to the ProviderManager , it iterates through the existing AuthenticationProvider and checks whether
AuthenticationProvider this implementation Authentication

 public boolean supports(Class<?> authentication) { return (UsernamePasswordAuthenticationToken.class .isAssignableFrom(authentication)); } 

As a result, inside AuthenticationProvider.authenticate we can already transfer the passed Authentication to the desired implementation without caste exection.

Next, from the specific implementation we take out the creditshenals.

If authentication fails, AuthenticationProvider should throw an exception, the ProviderManager will catch it and try the next AuthenticationProvider from the list, if none of the AuthenticationProvider returns successful authentication, the ProviderManager will forward the last caught event.

In more detail and with pictures the process is described here:
https://spring.io/guides/topicals/spring-security-architecture/

Next, the BasicAuthenticationFilter saves the resulting Authentication to the SecurityContextHolder
SecurityContextHolder.getContext (). SetAuthentication (authResult);
The authentication process is now complete.

If AuthenticationException is thrown, SecurityContextHolder.clearContext(); will be reset SecurityContextHolder.clearContext(); context and will be called AuthenticationEntryPoint.

 public interface AuthenticationEntryPoint { void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException; } 

The task of AuthenticationEntryPoint is to write in response to the information that authentication failed.

In the case of basic authentication, this will be:

 response.addHeader("WWW-Authenticate", "Basic realm=\"" + realmName + "\""); response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()); 

As a result, the browser will show the basic authorization window.

6 = {RequestCacheAwareFilter}


What is this filter for? Imagine the script:

1. The user logs on to the protected url.
2. It throws on the login page.
3. After successful authorization, the user transfers to the page he requested at the beginning.

It is for the restoration of the original request that this filter exists.
Inside it is checked if there is a saved query, if there is, it replaces the current query.
The request is saved in the session, at what stage it is saved will be written below.

Let's try to reproduce.

Add a method:

 @GetMapping("customHeader") public String customHeader(@RequestHeader("x-custom-header") String customHeader) { return customHeader; } 

Add a test:

 @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")); } 

As we see in the second request, we returned the header that we passed in the first request. The filter is working.

7 = {SecurityContextHolderAwareRequestFilter}


Wraps an existing request in SecurityContextHolderAwareRequestWrapper

 chain.doFilter(this.requestFactory.create((HttpServletRequest) req, (HttpServletResponse) res), res); 

Implementation may vary depending on servlet api version servlet 2.5 / 3

8 = {AnonymousAuthenticationFilter}


If by the time this filter is executed, SecurityContextHolder is empty, i.e. failed authentication The filter populates the SecurityContextHolder object with anonymous authentication - AnonymousAuthenticationToken with the role "ROLE_ANONYMOUS".

This guarantees that the SecurityContextHolder will have an object, this allows not to be afraid of NP, as well as a more flexible approach to setting up access for unauthorized users.

9 = {SessionManagementFilter}


At this stage, actions related to the session are performed.

It may be:

- change session ID
- limiting the number of simultaneous sessions
- saving SecurityContext in securityContextRepository

In our case, the following happens:
SecurityContextRepository with the default implementation of the HttpSessionSecurityContextRepository saves the SecurityContext to the session.
Called sessionAuthenticationStrategy.onAuthentication

Inside sessionAuthenticationStrategy is:

 sessionAuthenticationStrategy = {CompositeSessionAuthenticationStrategy} delegateStrategies 0 = {ChangeSessionIdAuthenticationStrategy} 1 = {CsrfAuthenticationStrategy} 

2 things happen:

1. By default, protection against session fixation attack is enabled, i.e. after authentication, session id changes.
2. If a csrf token was transmitted, a new csrf token is generated

Let's try to check the first item:

 @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}


At this point, the SecurityContext should contain anonymous or normal authentication.

ExceptionTranslationFilter passes request and response on the filter chain and handles possible authorization errors.

SS distinguishes 2 cases:

1. AuthenticationException
It sendStartAuthentication , inside of which the following occurs:

SecurityContextHolder.getContext().setAuthentication(null); - clears SecurityContextHolder
requestCache.saveRequest(request, response); - saves the current request to requestCache so that RequestCacheAwareFilter has something to recover.
authenticationEntryPoint.commence(request, response, reason); - calls authenticationEntryPoint - which records in response a signal that it is necessary to perform authentication (headers \ redirect)

2. AccessDeniedException

Here again 2 cases are possible:

 if (authenticationTrustResolver.isAnonymous(authentication) || authenticationTrustResolver.isRememberMe(authentication)) { ... } else { ... } 

1. User with anonymous authentication, or with authentication by rememberMe token
sendStartAuthentication is called

2. A user with full, non-anonymous authentication is invoked:
accessDeniedHandler.handle (request, response, (AccessDeniedException) exception)
which defaults the answer forbidden 403

11 = {FilterSecurityInterceptor}


At the last stage, the authorization is based on the url of the request.
FilterSecurityInterceptor is inherited from AbstractSecurityInterceptor and decides whether the current user has access to the current url.

There is another implementation of the MethodSecurityInterceptor which is responsible for admitting a method call using @Secured \ @PreAuthorize annotations.

AccessDecisionManager is called inside.

There are several strategies for deciding whether to allow or not, the default is used: AffirmativeBased

The code inside is very simple:

 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(); 

In other words, if someone votes for, we skip, if at least 1 vote against is not allowed, if no one has voted, we will not let it go.

Let's summarize a little:

springSecurityFilterChain is a set of spring security filters.

Example filter set for basic authorization:

WebAsyncManagerIntegrationFilter - Integrates SecurityContext with WebAsyncManager
SecurityContextPersistenceFilter - Looks for the SecurityContext in a session and fills in the SecurityContextHolder if it finds
HeaderWriterFilter - Adds “security” headers to the response
CsrfFilter - Checks for the presence of csrf token
LogoutFilter - Performs logout
BasicAuthenticationFilter - Performs basic authentication.
RequestCacheAwareFilter - Restores the request saved prior to authentication, if any
SecurityContextHolderAwareRequestFilter - Wraps an existing request in SecurityContextHolderAwareRequestWrapper
AnonymousAuthenticationFilter - Fills SecurityContext with Anonymous Authentication
SessionManagementFilter - Performs session related actions
ExceptionTranslationFilter - Handles AuthenticationException \ AccessDeniedException that occur below the stack.
FilterSecurityInterceptor - Checks if the current user has access to the current url.

FilterComparator - here you can see the list of filters and their possible order.

AuthenticationManager - interface responsible for authentication
ProviderManager - An implementation of an AuthenticationManager that uses an internally uses AuthenticationProvider
AuthenticationProvider is an interface responsible for authenticating a specific Authentication implementation.
SecurityContextHolder - stores authentication usually in a ThreadLocal variable.
AuthenticationEntryPoint - modifies the response to make it clear to the client that authentication is required (headers, redirect to login page, etc.)

AccessDecisionManager decides whether Authentication access to some resource.
AffirmativeBased is the default strategy used by AccessDecisionManager.

Recommendations


Write simple tests that test the order of filters and their settings.


This will avoid unpleasant surprises.
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); } } 

Do not call SecurityContextHolder.getContext (). GetAuthentication (); for current user


Authentication - in itself is not a very convenient object to use. Almost all methods return an Object, and to get the necessary information you need to cast into a specific implementation.

Better get the interface, do the implementation depending on your needs, write a HandlerMethodArgumentResolver.

Code with this approach is better to read, test, maintain.

 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(); } 

Expand Existing Implementations


Spring security contains a lot of interfaces that can be implemented, but most likely there is an abract class that is 99% doing what you need.

For example, for the Authentication interface, there is an AbstractAuthenticationToken , and an authentication filter is reasonable to inherit from AbstractAuthenticationProcessingFilter

Use SecurityConfigurerAdapter to configure your authentication.


In the event that you have fully custom authentication, most likely you had to do the following:

1. Create an Authentication implementation
2. Create an AuthenticationProvider that supports your implementation of Authentication
3. Add a filter that started the authentication process.

It is reasonable to unite them all in one place. Look at HttpBasicConfigurer, OpenIDLoginConfigurer they do the same.

 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()) } } 

To restrict method invocation by role, use @Secured \ @PreAuthorize


Write a test that will pass through all the methods of the controllers and check for the presence of @Secured \ @PreAuthorize annotations.

When configuring WebSecurityConfigurerAdapter, require authorization for all url. Add exceptions if necessary. Exceptions should be as stringent as possible.
Explicitly specify the type of the http method, and the url should be as complete as possible.

It is better to explicitly specify the full path to the method, even if there were no other api with such an endpoint at the time of writing.

For example, if there is a controller with two GET methods: "url/methodOne", "url/methodTwo" ,
Do not do this:

 authorizeRequests().antMatchers(HttpMethod.GET, "url/**").permitAll(). 

Better write:

 authorizeRequests().antMatchers(HttpMethod.GET, "url/methodOne", "url/methodTwo").permitAll(). 

In case of problems, enable org.springframework.security: debug


Spring Security has quite detailed debug logs, often they are enough to understand the essence of the problem.

Distinguish antMatchers ("permit_all_url"). PermitAll () and 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"); } 

In the case of “ignored_url” it will be checked at the stage of selecting the security filter chain and if the url matches, then an empty filter will be used.

In the case of "permit_all_url", the check will take place at the AccessDecisionManager stage.

Links


  1. https://github.com/VladDm93/spring-security-request-journey.git - code.
  2. https://spring.io/guides/topicals/spring-security-architecture - An overview of the architecture of Spring security.

Source: https://habr.com/ru/post/346628/


All Articles