Wednesday, July 18, 2018

Caching in Spring Boot with Spring Security

In this post, I’d like to share a lesson learned by one of the teams at O&B. They were using Spring Boot with Spring Security.

By default, anything that is protected by Spring Security is sent to the browser with the following HTTP header:

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

Essentially, the response will never be cached by the browser. While this may seem inefficient, there is actually a good reason for this default behavior. When one user logs out, we don’t want the next logged in user to be able to see the previous user’s resources (and this is possible if they’re cached).

It makes sense to not cache anything by default, and leave caching to be explicitly enabled. But it’s not good if nothing is cached, as it will lead to high bandwidth usage and slow page loads.

Good thing it is very easy to enable caching of static content in Spring Boot (even with Spring Security). Simply configure a cache period. And that’s it!

# Boot 2.x
spring.resources.cache.cachecontrol.max-age=14400

# Boot 1.x
spring.resources.cache-period=14400

But there are some gotchas! With some versions, it ain’t that simple! Let me explain further.

There are several ways content can be returned:

  1. Static content through Spring Boot auto-configured static resource request handler
  2. Controller method returning view name (e.g. resolves to a JSP)
  3. Controller method returning HttpEntity (or ResponseEntity)

Enable Caching of Static Content

The first (serving static content) is handled by configuring the said property (usually in application.properties as shown above).

Set via HttpServletResponse

In the second case, the controller handler method may choose to set "Cache-Control" headers through a HttpServletResponse method parameter.

@Controller
... class ... {
    @RequestMapping(...)
    public String ...(..., HttpServletResponse response) {
        response.setHeader("Cache-Control", "max-age=14400");
        return ...; // view name
    }
}

This works, as long as Spring Security does not overwrite it.

Set via HttpEntity/ResponseEntity

In the third case, the controller handler method may choose to set "Cache-Control" headers of the returned HTTP entity.

@Controller
... class ... {
    @RequestMapping(...)
    public ResponseEntity<...> ...(...) {
        return ResponseEntity.ok().cacheControl(...).body(...);
    }
}

This works, as long as Spring Security has not written its own "Cache-Control" headers yet.

Under the Hood

To understand when and why it works, here are the relevant sequences.

With Spring Security Web 4.0.x, 4.2.0 up to 4.2.4 and above, the following sequence occurs:

  1. The HeaderWriterFilter delegates to CacheControlHeadersWriter to write the "Cache-Control" headers (including "Pragma" and "Expires"), if no cache headers exist.
  2. Controller handler method (if matched) is invoked. The method can:
    • Explicitly set a header in HttpServletResponse.
    • Or, set a header in the returned HttpEntity or ResponseEntity (refer to the handleReturnValue() method of HttpEntityMethodProcessor).
      • Note that HttpEntityMethodProcessor only writes the headers (from HttpEntity) to the actual response if they do not exist yet. This becomes a problem, since back in #1, the headers have already been set.
  3. If no controller handles the request, then the Spring Boot auto-configured static resource request handler gets its chance. It tries to serve static content, and if configured to cache, it overwrites the "Cache-Control" headers (and clears the values of "Pragma" and "Expires" headers, if any). The static resource handler is a ResourceHttpRequestHandler object (refer to the applyCacheControl() method in its WebContentGenerator base class).
    • However, in Spring Web MVC 4.2.5, the WebContentGenerator only writes the "Cache-Control" headers only if it does not exist!. This becomes a problem, since back in #1, the headers have already been set.
    • In Spring Web MVC 4.2.6 and above, it adds the "Cache-Control" headers even if it already exists. So, no problem even if the headers have been set in #1.

With Spring Security Web 4.1.x, 4.2.5, and above (version 4.2.5 is used in Spring Boot 1.5.11), the sequence has changed. It goes something like this:

  1. Controller handler method (if matched) is invoked. The method can:
    • Explicitly set a header in HttpServletResponse.
    • Or, set a header in the returned HttpEntity or ResponseEntity (refer to the handleReturnValue() method of HttpEntityMethodProcessor).
      • Note that HttpEntityMethodProcessor only writes the headers (from HttpEntity) to the actual response if they do not exist yet. No problem, since no headers have been set yet.
  2. If no controller handles the request, then the Spring Boot auto-configured static resource request handler gets its chance. It tries to serve static content, and if configured to cache, it overwrites the "Cache-Control" headers (and clears the values of "Pragma" and "Expires" headers, if any).
  3. The HeaderWriterFilter delegates to CacheControlHeadersWriter to write the "Cache-Control" headers (including "Pragma" and "Expires"), if no cache headers exist.
    • No problem, since it will not overwrite if cache headers have already been set.

Working Versions

The above three cases of controlling caching all work in Spring Boot 1.5.11 and Spring Boot 2.x. But in case upgrading to those versions is not possible, please see the following classes and check if it has your desired behavior (using the above sequences):

  • HeaderWriterFilter (see doFilterInternal method)
  • CacheControlHeadersWriter (see writeHeaders() method)
  • WebContentGenerator (see applyCacheControl() method)
  • HttpEntityMethodProcessor (see handleReturnValue() method)

Also, be aware that Spring Security Web 4.2.5 and above will write the following HTTP headers (overwrite it, even if they are already set, like in a controller for example):

  • X-Content-Type-Options via XContentTypeOptionsHeaderWriter
  • Strict-Transport-Security via HstsHeaderWriter
  • X-Frame-Options via XFrameOptionsHeaderWriter
  • X-XSS-Protection via XXssProtectionHeaderWriter

This is because, unlike CacheControlHeadersWriter, the header writers for the above do not check if the headers already exist. They simply set their respective HTTP headers. Please refer to their respective header writer classes and issue #5193.

Another option is to have Spring Security ignore static resource requests. That way, the configured cache period will not be overwritten.

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/css/**", "/js/**");
        // If the above paths are served by the
        // Spring Boot auto-configured
        // static resource request handler,
        // and a cache period is specified,
        // then it will have a "Cache-Control"
        // HTTP header in its response.
        // And it would NOT get overwritten by Spring Security.
    }
}

That's all for now. Hope this clears things up.

7 comments:

  1. This comment has been removed by the author.

    ReplyDelete
  2. You have made some really good points there. I checked on the web for more information about the issue and found most people will study go along with your views on this web site.

    ReplyDelete
  3. Found this content great and sharable with other resources. If you are willing to participate and win grand lottery prizes in KBC, visit https://kbcofficialwinner.com website to get detailed information.

    ReplyDelete
  4. I have read your blog its very attractive and impressive. I like it your blog.
    devops training in hyderabad

    ReplyDelete