Search code examples
spring-securityspring-bootspring-web

Why is the configured servlet path correctly used by the REST controllers, but ignored in Spring Security features?


I'm new to Spring and I try to create a secured rest application using Spring Boot and Spring Security. I'm searching for weeks for a solution now...

I'm using Spring Boots embedded web container (Tomcat) and the spring-boot-starter-parent 1.2.6.RELEASE in my pom.

My endpoints:

  • /login (to authenticate)
  • /application/{id} (some service which I want to secure)

I configured my servlet path in my application.properties like this:

server.servletPath: /embedded

so I expect my services e.g. on //localhost/embedded/login

Ok so now the problem: If I run the application without security everything is fine, I can call http//localhost/embedded/application and get an answer. If I now add my security configuration like this:

import javax.servlet.ServletContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.servlet.configuration.EnableWebMvcSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebMvcSecurity
@EnableScheduling
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private TokenAuthenticationService tokenAuthenticationService;

    @Value("${server.servletPath}")
    private String servletPath;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
         http.authorizeRequests().antMatchers("/hello/**", "/login").permitAll()
            .antMatchers("/application/**").authenticated().and()
            .addFilterBefore(new TokenAuthenticationFilter(tokenAuthenticationService), UsernamePasswordAuthenticationFilter.class);  
            http.csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                .httpBasic().disable();
    }

}

when running the application //localhost/application/{id} is secured instead of //localhost/embedded/application/{id} as I would have expected. For some reason the servlet path is ignored there. I tought "ok so I just add the servlet path manually" and make it look like this:

...antMatchers(servletPath+"/application/**").authenticated()...

This works in my application. However I also use MockMvc to test my services and for some reason there the servlet path is correctly added to the matchers. So if I start the tests the security filters are mapped to //localhost/embedded/embedded/application/{id} while the controllers themselves still are mapped to //localhost/embedded/application/{id} which is very annoying... I took a look at here http://spring.io/blog/2013/07/03/spring-security-java-config-preview-web-security/ and thought I could fix the issue by using AbstractSecurityWebApplicationInitializer instead of SpringBootServletInitializer but it changed nothing. This is my application class by the way:

com.sebn.gsd.springservertemplate.service.security.WebSecurityConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.context.web.SpringBootServletInitializer;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@ComponentScan
@EnableAutoConfiguration
public class Application extends SpringBootServletInitializer {

    public static void main(String[] args) {
        System.out.println("Run from main");
        SpringApplication.run(applicationClass, args);
    }

    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
        return application.sources(applicationClass, WebSecurityConfig.class);
    }

    private static Class<Application> applicationClass = Application.class;

}

The application.properties doesn't contain any more interesting information I think. To be complete this is my MockMvc testing class:

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sebn.gsd.springservertemplate.service.api.LoginData;
import com.sebn.gsd.springservertemplate.service.security.Session_model;
import com.sebn.gsd.springservertemplate.service.security.WebSecurityConfig;
import java.util.Arrays;
import org.hamcrest.Matchers;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.web.servlet.MockMvc;
import static org.hamcrest.Matchers.notNullValue;
import org.junit.Assert;
import org.junit.Before;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.test.web.servlet.ResultActions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup;
import org.springframework.web.context.WebApplicationContext;

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = {Application.class, WebSecurityConfig.class })
@WebAppConfiguration
@ActiveProfiles(profiles = "development")
public class SecurityTests {

    private MockMvc mockMvc;

    @Autowired
    private WebApplicationContext webApplicationContext;

    private HttpMessageConverter mappingJackson2HttpMessageConverter;
    private ObjectMapper o = new ObjectMapper();

    @Autowired
    private FilterChainProxy filterChainProxy;

    @Value("${server.servletPath}")
    private String servletPath;

    @Before
    public void setup() throws Exception {
        this.mockMvc = webAppContextSetup(webApplicationContext).addFilter(filterChainProxy).build();
    }

    @Test
    public void testLoginSecurity() throws Exception {
        int applicationId = 1;
        // Try to access secured api
        ResultActions actions = mockMvc.perform(get("/application/" + applicationId))
                .andDo(MockMvcResultHandlers.print())
                .andExpect(status().isForbidden());
        //login
        String username = "user";
        LoginData loginData = new LoginData();
        loginData.setPasswordBase64("23j4235jk26=");
        loginData.setUsername(username);
        actions = mockMvc.perform(post("/login").content(o.writeValueAsString(loginData)).contentType(MediaType.APPLICATION_JSON_VALUE))
                .andDo(MockMvcResultHandlers.print())
                .andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.login", Matchers.equalTo(username)))
                .andExpect(jsonPath("$.token", notNullValue()))
                .andExpect(jsonPath("$.expirationDate", notNullValue()));
         Session_model session = getResponseContentAsJavaObject(actions.andReturn().getResponse(), Session_model.class);
         Assert.assertNotNull(session);
        // Try to access secured api again 
        actions = mockMvc.perform(get("/application/" + applicationId).header("X-AUTH-TOKEN", session.getToken()))
                .andDo(MockMvcResultHandlers.print())
                .andExpect(status().isOk());
    }

    private <T> T getResponseContentAsJavaObject(MockHttpServletResponse response, Class<T> returnType) throws Exception{
        return o.readValue(response.getContentAsString(), returnType);
    }

    @Autowired
    void setConverters(HttpMessageConverter<?>[] converters) {

        this.mappingJackson2HttpMessageConverter = Arrays.asList(converters).stream().filter(
                hmc -> hmc instanceof MappingJackson2HttpMessageConverter).findAny().get();

        Assert.assertNotNull("the JSON message converter must not be null",
                this.mappingJackson2HttpMessageConverter);
    }
}

Maybe I misunderstood something. I hope you can tell me.


Solution

  • Summary

    In short you need to map Spring Security to use include the servlet path. Additionally, you need to include the servlet path in your MockMvc requests. To do so you can perform something like:

    @Before
    public void setup() throws Exception {
        this.mockMvc = webAppContextSetup(webApplicationContext)
               // ADD LINE BELOW!!!
               .defaultRequest(get("/").servletPath(servletPath))
               .addFilter(filterChainProxy)
               .build();
    }
    

    Detailed Response

    Spring Security Matches Based on Context Root

    Spring Security's matchers are relative to the application's context root. It is not relative to the servlet path. This is deliberate because it should protect all the servlets (not just Spring MVC). If it were relative to the servlet, consider the following:

    servlet1-path/abc -> Only users with role ROLE_ADMIN can access
    
    servlet2-path/abc -> Only users with role ROLE_USER can access
    

    How would you differentiate between these two mappings if Spring Security were relative to the servlet path?

    Working in Mock MVC

    The reason Spring Security is working in MockMvc is because when you are using MockMvc the servlet path is no longer considered. Your requests are being sent to Spring Security and Spring MVC as though the servlet path is "". To fix this you need to include the servlet path in the request.

    @Before
    public void setup() throws Exception {
        this.mockMvc = webAppContextSetup(webApplicationContext)
               // ADD LINE BELOW!!!
               .defaultRequest(get("/").servletPath(servletPath))
               .addFilter(filterChainProxy)
               .build();
    }