Imagine we have two home pages, that should be accessible under following paths: /regular/home and /special/home. We would like to have them secured with corresponding login forms: /regular/login, and /special/login. By deafault, Thymeleaf templates are supposed to be located in /templates directory:
├───src │ ├───main │ │ ├───java │ │ │ └───com │ │ │ └───bslota │ │ │ │ MultiLoginApplication.java │ │ │ │ │ │ │ └───config │ │ │ MvcConfig.java │ │ │ SecurityConfig.java │ │ │ │ │ └───resources │ │ │ application.yml │ │ │ │ │ ├───static │ │ │ └───css │ │ │ styles.css │ │ │ │ │ └───templates │ │ ├───regular │ │ │ home.html │ │ │ login.html │ │ │ │ │ └───special │ │ home.html │ │ login.html │ │ │ └───test │ └───java │ └───com │ └───bslota │ WebSecurityTest.java │ │ .gitignore │ mvnw │ mvnw.cmd │ pom.xmlNow, apart from html templates we need to configure our application, so that it resolves all views properly. To keep it simple, this should be enough for our example:
@Configuration public class MvcConfig extends WebMvcConfigurerAdapter { @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/").setViewName("regular/home"); registry.addViewController("/regular/home").setViewName("regular/home"); registry.addViewController("/special/home").setViewName("special/home"); registry.addViewController("/regular/login").setViewName("regular/login"); registry.addViewController("/special/login").setViewName("special/login"); } }In order to introduce security into the application, we need to declare a following dependency:
Hey, hold on, we are not going to do anything without testing (I hope you already have a habit of writing tests, don't you?). In order to test our security configuration, we need this library:org.springframework.boot spring-boot-starter-security
Right now, what we have out of the box due to having spring-boot-starter-security dependency on classpath are:org.springframework.boot spring-boot-starter-test test
- HTTP-Basic security setup for all endpoints,
- randomly generated password logged in console during startup for a user named user.
@RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) @AutoConfigureMockMvc public class WebSecurityTest { @Autowired private MockMvc mockMvc; @Test public void testIfRegularHomePageIsSecured() throws Exception { final ResultActions resultActions = mockMvc.perform(get("/regular/home")); resultActions .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("http://localhost/regular/login")); } @Test @WithMockUser public void testIfLoggedUserHasAccessToRegularHomePage() throws Exception { final ResultActions resultActions = mockMvc.perform(get("/regular/home")); resultActions .andExpect(status().isOk()) .andExpect(view().name("regular/home")); } @Test @WithMockUser public void testIfLoggedUserHasAccessToSpecialHomePage() throws Exception { final ResultActions resultActions = mockMvc.perform(get("/special/home")); resultActions .andExpect(status().isOk()) .andExpect(view().name("special/home")); } }
What you should know from this code is that we are creating a mock servlet environment (webEnvironment = SpringBootTest.WebEnvironment.MOCK) and auto-configuring MockMvc (@AutoConfigureMockMvc) - a neat and powerful tool for web controller testing. With this setup you don't need to build and configure MockMvc on your own - you can simply inject it as a regular bean dependency. Spring Boot never stops fascinating me.
We have three tests written here. The first one says:
given:
anonymous user
when:
trying to access /regular/home URL
then:
I get 302 HTTP response
and:
I'm redirected to /regular/login page
And the second one:
given:
a principal with username "user" and password "password"
when:
trying to access /regular/home URL
then:
I get 200 HTTP response
and:
I access regular/home view
The third one is almost the same as the second (the only difference is the url that is being accessed with MockMvc). As long as when-then parts should be rather clear, the given part might be a bit confusing. In the first test we have not defined any security constraints, so the MockMvc call will be be considered as one from anonymous user. In the second test, though, we are using
username: user password: password roles: USER
- Define AuthenticationManager (in-memory one will be enough for this example) and create appropriate user,
- Configure HttpSecurity so that all resources are secured with form-based login under /regular/login.
@Configuration public class SecurityConfig { @Configuration public static class RegularSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { //@formatter:off http .authorizeRequests() .antMatchers("/css/**").permitAll() .anyRequest().authenticated() .and() .formLogin() .loginPage("/regular/login") .defaultSuccessUrl("/regular/home") .permitAll() .and() .logout() .logoutUrl("/regular/logout") .permitAll(); //@formatter:on } } @Autowired public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication() .withUser("user") .password("password") .roles("USER"); } }Good, now within configureGlobal method we configured an in-memory AuthenticationManager and created a user, as desired. We also declared a RegularSecurityConfig class that extends WebSecurityConfigurerAdapter and overrides configure(HttpSecurity) method - this gives us the ability to change default pre-configured security behaviour. Within the configure(HttpSecurity) method, we:
- decided to authenticate any request (apart from accessing css files - nevermind that part),
- set login page URL to /regular/login,
- set default success login URL to /regular/home - the place where users will be directed after authenticating, not having visited a secured page,
- set logout URL to /regular/logout.
@Test public void testIfSpecialHomePageIsSecured() throws Exception { final ResultActions resultActions = mockMvc.perform(get("/special/home")); resultActions .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("http://localhost/special/login")); }This tests is very similar to the previously written one. We are checking whether anonymous user trying to access /special/home page gets redirected to /special/login. Surely this test won't pass. The remedy is to add a following code into SecurityConfig class:
@Configuration @Order(1) public static class SpecialSecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { //@formatter:off http .antMatcher("/special/**") .authorizeRequests() .anyRequest().authenticated() .and() .formLogin() .loginPage("/special/login") .defaultSuccessUrl("/special/home") .permitAll() .and() .logout() .logoutUrl("/special/logout") .permitAll(); //@formatter:on } }
We just created yet another WebSecurityConfigurerAdapter extension. When you look at the body of configure method, then you will see that it is pretty similar to the one that we had defined previously. The difference is that we have an antMatcher, that enables us to filter and apply this settings only for URLs that begin with /special/ prefix. And how does Spring know which configure method (from which WebSecurityConfigurerAdapter extension) should be invoked first? It is because the @Order(1) annotation.
Yep, we got it! We have implemented and tested our Spring MVC configuration with two separate login pages for different URLs being accessed. This example should show you how many possibilitis Spring Security gives you. Do not think that this kind of configuration is limited to form-based login - you could declare HTTP-Basic security for all requests that concerns /special/** URLs as well or set any security constraints you like for whatever path templates.