1. Introduction
In our tutorial on Spring method security, we saw how we can use the @PreAuthorize and @PostAuthorize annotations.
In this tutorial, we'll see how to deny access to methods that lack authorization annotations.
2. Security by Default
After all, we are only human, so we might forget to protect one of our endpoints. Unfortunately, there's no easy way to deny access to non-annotated endpoints.
Luckily, Spring Security requires authentication for all endpoints by default. However, it will not require a specific role. Also, it will not deny access when we did not add security annotations.
3. Setup
First, let's take a look at the application for this example. We have a simple Spring Boot application:
@SpringBootApplication public class DenyApplication { public static void main(String[] args) { SpringApplication.run(DenyApplication.class, args); } }
Secondly, we have a security configuration. We set up two users and enable the pre/post annotations:
@Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class DenyMethodSecurityConfig extends GlobalMethodSecurityConfiguration { @Bean public UserDetailsService userDetailsService() { return new InMemoryUserDetailsManager( User.withUsername("user").password("{noop}password").roles("USER").build(), User.withUsername("guest").password("{noop}password").roles().build() ); } }
Finally, we have a rest controller with two methods. However, we “forgot” to protect the /bye endpoint:
@RestController public class DenyOnMissingController { @GetMapping(path = "hello") @PreAuthorize("hasRole('USER')") public String hello() { return "Hello world!"; } @GetMapping(path = "bye") // whoops! public String bye() { return "Bye bye world!"; } }
When running the example, we can sign in with user/password. Then, we access the /hello endpoint. We can also sign in with guest/guest. In that case, we cannot access the /hello endpoint.
However, any authenticated user can access the /bye endpoint. In the next section, we write a test to prove that.
4. Testing the Solution
Using MockMvc we can set up a test. We check that our non-annotated method is still accessible:
@RunWith(SpringJUnit4ClassRunner.class) @SpringBootTest(classes = DenyApplication.class) public class DenyOnMissingControllerIntegrationTest { @Rule public ExpectedException expectedException = ExpectedException.none(); @Autowired private WebApplicationContext context; private MockMvc mockMvc; @Before public void setUp() { mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); } @Test @WithMockUser(username = "user") public void givenANormalUser_whenCallingHello_thenAccessDenied() throws Exception { mockMvc.perform(get("/hello")) .andExpect(status().isOk()) .andExpect(content().string("Hello world!")); } @Test @WithMockUser(username = "user") // This will fail without the changes from the next section public void givenANormalUser_whenCallingBye_thenAccessDenied() throws Exception { expectedException.expectCause(isA(AccessDeniedException.class)); mockMvc.perform(get("/bye")); } }
The second test fails because the /bye endpoint is accessible. In the next section, we update our configuration to deny access to unannotated endpoints.
5. Solution: Deny by Default
Let's extend our MethodSecurityConfig class and set up a MethodSecurityMetadataSource:
@Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true) public class DenyMethodSecurityConfig extends GlobalMethodSecurityConfiguration { @Override protected MethodSecurityMetadataSource customMethodSecurityMetadataSource() { return new CustomPermissionAllowedMethodSecurityMetadataSource(); } // setting up in memory users not repeated ... }
Now let's implement the MethodSecurityMetadataSource interface:
public class CustomPermissionAllowedMethodSecurityMetadataSource extends AbstractFallbackMethodSecurityMetadataSource { @Override protected Collection findAttributes(Class<?> clazz) { return null; } @Override protected Collection findAttributes(Method method, Class<?> targetClass) { Annotation[] annotations = AnnotationUtils.getAnnotations(method); List attributes = new ArrayList<>(); // if the class is annotated as @Controller we should by default deny access to all methods if (AnnotationUtils.findAnnotation(targetClass, Controller.class) != null) { attributes.add(DENY_ALL_ATTRIBUTE); } if (annotations != null) { for (Annotation a : annotations) { // but not if the method has at least a PreAuthorize or PostAuthorize annotation if (a instanceof PreAuthorize || a instanceof PostAuthorize) { return null; } } } return attributes; } @Override public Collection getAllConfigAttributes() { return null; } }
We'll add the DENY_ALL_ATTRIBUTE to all methods of @Controller classes.
But, we don't add them if a @PreAuthorize/@PostAuthorize annotation is found. We do this by returning null, indicating that no metadata applies.
With the updated code, our /bye endpoint is protected and the tests succeed.
6. Conclusion
In this short tutorial, we've shown how to protect endpoints lacking @PreAuthorize / @PostAuthorize annotations.
Also, we show that non-annotated methods are now indeed protected.
As always, the full source code of the article is available over on GitHub.