Spring Security Inside Request Request Path

Most developers have only an approximate idea of ​​what is happening inside Spring Security, which is dangerous and can lead to vulnerabilities.

In this article, we will go step by step along the path of the http request, which will help with understanding to configure and solve Spring Security problems.

image


Project preparation


First, prepare the project, go to https://start.spring.io/ , check the boxes opposite 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 grooves:

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's in the logs?

Inquiry:

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 any additional settings, has already begun to protect method calls, since the configuration - SpringBootWebSecurityConfigurationsupplied by the spring boot has worked . Inside this class lies ApplicationNoWebSecurityConfigurerAdapterwhich sets defaults.

Some of them can be influenced 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

Donot configure spring boot configuration for completeness:

@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.
We’ll try to make a deal, but first we’ll 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);
}

Put the break and run the test.


fig. 1 - get method

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


pic. 2 - call stack

Let's see what lies in the variable filterChain


fig. 3 - application filter chain

An interesting filter here is springSecurityFilterChainthat it does all the work of SS in the web part.

Itself is DelegatingFilterProxyRegistrationBeannot very interesting, let's see to whom he delegates his work


fig. 4 - filter chain proxy

He delegates his work to the class FilterChainProxy. Inside it, several interesting things happen.

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

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

Inside the getFilters method, take the first one SecurityFilterChainthat matches the request.

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

Let's go to the debugger and see which list iterates.


fig. 5 - security filter chains

What does this list tell us?

In both sheets lies OrRequestMatcherthat will try to match the current url with at least one pattern from the list.

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

Check in practice.
Any url that matches these patterns 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 will match 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 dependencies added.
For example, with this configuration:


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

Filters would be added to this list:

UsernamePasswordAuthenticationFilter
DefaultLoginPageGeneratingFilter

In what order the filters go by default can be seen here: FilterComparator

0 = {WebAsyncManagerIntegrationFilter}


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

1 = {SecurityContextPersistenceFilter}


Searches for the SecurityContext in the session and populates the SecurityContextHolder if it finds.
By default, ThreadLocalSecurityContextHolderStrategy is used which stores the SecurityContext in the ThreadLocal variable.

2 = {HeaderWriterFilter}


Just adds headers to 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

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 getting acquainted with SS, would not encounter the error "lack of csrf token."

Why haven't we seen this error before? It's simple, we ran methods on which there is no csrf protection.

Let's try to add a 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 returned a 403 error, csrf protection 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. The Csrf token is removed.
  2. The session ends
  3. Brushed SecurityContextHolder

5 = {BasicAuthenticationFilter}


Now we get directly to authentication. What is going on inside?
The filter checks to see if there is an Authorization header with a value starting in Basic.
If it finds it, it extracts the username \ password and passes them to AuthenticationManager

Inside 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 implementation of Authentication is UsernamePasswordAuthenticationToken.
It would be possible to implement AuthenticationManager itself, but it makes little sense, there is a default implementation - ProviderManager.

The ProviderManager delegates authorization to another interface:


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

When we pass an object Authenticationto ProviderManager, it AuthenticationProvideriterates over the existing -rys and checks if the
AuthenticationProvider supports this Authentication implementation


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

As a result, inside AuthenticationProvider.authenticatewe can already cast the passed Authentication into the desired implementation without execution casts.

Next, from the concrete implementation we get the credit cards.

If authentication fails, it AuthenticationProvidershould quit the execution, ProviderManagercatch it and try the next AuthenticationProvider from the list, if no AuthenticationProvider returns successful authentication, the ProviderManager will forward the last caught execution.

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

Next, it BasicAuthenticationFiltersaves the received Authentication in SecurityContextHolder
SecurityContextHolder.getContext (). SetAuthentication (authResult);
The authentication process is now complete.

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


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

The task of AuthenticationEntryPoint is to record in response 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 displays the basic authorization window.

6 = {RequestCacheAwareFilter}


What is this filter for? Imagine a scenario:

1. A user logs on to a secure url.
2. He throws it to the login page.
3. After successful authorization, the user is redirected to the page that 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 request, if it is, the current request is substituted for it.
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, the header that we passed in the first request returned to us. 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 of servlet 2.5 / 3

8 = {AnonymousAuthenticationFilter}


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

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

9 = {SessionManagementFilter}


At this stage, actions associated with the session are performed.

This can be:

- changing the session identifier
- limiting the number of simultaneous sessions
- saving the SecurityContext in securityContextRepository

In our case, the following happens:
SecurityContextRepositorywith the default implementation, HttpSessionSecurityContextRepository saves the SecurityContext into the session.
Called sessionAuthenticationStrategy.onAuthentication

Inside sessionAuthenticationStrategy lies:

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

2 things happen:

1. By default, protection from session fixation attack is enabled, that is, after authentication, the session id changes.
2. If a csrf token was transferred, 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}


К этому моменту 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. A user with anonymous authentication, or with authentication using the rememberMe token
is called sendStartAuthentication

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

11 = {FilterSecurityInterceptor}


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

There is another implementation of MethodSecurityInterceptor which is responsible for the admission to the method call when using @Secured \ @PreAuthorize annotations.

AccessDecisionManager

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

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 in favor, skip it, if at least 1 vote against we do not let it go, if no one votes we do not let it go.

To summarize:

springSecurityFilterChain- a set of spring security filters.

An example of a set of filters for basic authorization:

WebAsyncManagerIntegrationFilter- Integrates SecurityContext with WebAsyncManager
SecurityContextPersistenceFilter- Looks for SecurityContext in the session and fills in SecurityContextHolder if it finds
HeaderWriterFilter- Adds “security” headers in response
CsrfFilter- Checks for csrf token
LogoutFilter- Performs logout
BasicAuthenticationFilter- Performs basic authentication
RequestCacheAwareFilter- Restores the request saved before authentication, if there is one
SecurityContextHolderAwareRequestFilter- Wraps an existing request in SecurityContextHolderAwareRequestWrapper
AnonymousAuthenticationFilter- Fills the SecurityContext with anonymous authentication
SessionManagementFilter- Performs session-related actions
ExceptionTranslationFilter- Handles the AuthenticationException \ AccessDeniedException that occurs down 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- the interface responsible for authentication
ProviderManager- the AuthenticationManager implementation that uses internally uses AuthenticationProvider
AuthenticationProvider- the interface is responsible for authenticating the specific implementation Authentication.
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.)

AccessDecisionManagerdecides whether it has Authenticationaccess to some resource.
AffirmativeBased- 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 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 (); to get the current user


Authentication is not a very usable object per se. Almost all methods return Object, and to get the necessary information you need to cast it to a specific implementation.

Better get an interface, make an implementation depending on your needs, write 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();
}

Extend existing implementations


Spring security contains many interfaces that you can implement, but most likely there is an abstract class that does 99% what you need.

For example, for the Authentication interface, exists AbstractAuthenticationToken, and it is reasonable to inherit the authentication filter fromAbstractAuthenticationProcessingFilter

Use SecurityConfigurerAdapter to Configure Your Authentication


If 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 Authentication implementation
3. Add a filter that started the authentication process.

It makes sense to combine them all in one place. Look at HttpBasicConfigurer, OpenIDLoginConfigurerthey do the same thing.


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

To limit the method call by role, use @Secured \ @PreAuthorize


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

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

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

For example if there is a controller with two methods GET: "url/methodOne", "url/methodTwo",
It is not necessary to do so:


 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 between 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, an empty filter will be used.

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

References


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

Also popular now: