Search code examples
javaspringspring-bootspring-securityleast-privilege

How do I make all of my @RequestMapping as @PreAuthorize("isAuthenticated()") by default?


I’d like to apply the Principle of Least Privilege in my endpoints by requiring all of them to be authenticated, unless stated otherwise at the method-level (so I don’t need to duplicate pathes from my controllers in a SecurityFilterChain).

Ideally, I’d want all of them to behave as if they were annotated by @PreAuthorize("isAuthenticated()") by default, while still being able to override this by explicitly specifying a @PreAuthorize (or other annotations like @PermitAll). Of course, it can be achieved by annotating the @Controllers themselves like the following, but you can still forget to annotate one, which is the reason why I’d like a more global solution:

@RestController
@PreAuthorize("isAuthenticated()")
public class TestController {
    @GetMapping("/test")
    @PreAuthorize("permitAll()")
    public void test() {}
}

I tried following the documentation that says:

It’s important to remember that when you use annotation-based Method Security, then unannotated methods are not secured. To protect against this, declare a catch-all authorization rule in your HttpSecurity instance.

So I kept Spring Boot’s default SecurityFilterChain which does http.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) like in the doc:

@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated());
    http.formLogin(withDefaults());
    http.httpBasic(withDefaults());
    return http.build();
}

Then I tried using @PreAuthorize("permitAll()") on some endpoints to override it, with no success: as described in my issue on the subject, SecurityFilterChain rules are evaluated before @PreAuthorize, so they can’t be overridden by it.

I tried using custom method security (with a custom SecurityFilterChain identical to Spring Boot's but with the catch-all line http.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) commented out) as advised in the issue, like this:

@Configuration
@EnableMethodSecurity
public class SecurityConfiguration {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        //http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated());
        http.formLogin(withDefaults());
        http.httpBasic(withDefaults());
        return http.build();
    }

    @Bean
    @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
    Advisor preAuthorize() {
        return AuthorizationManagerBeforeMethodInterceptor.preAuthorize(AuthenticatedAuthorizationManager.authenticated());
    }
}

with no success, since AuthorizationManagerBeforeMethodInterceptor.preAuthorize only applies to @PreAuthorize-annotated methods:

public static AuthorizationManagerBeforeMethodInterceptor preAuthorize(
        PreAuthorizeAuthorizationManager authorizationManager) {
    AuthorizationManagerBeforeMethodInterceptor interceptor = new AuthorizationManagerBeforeMethodInterceptor(
            AuthorizationMethodPointcuts.forAnnotations(PreAuthorize.class), authorizationManager);
    interceptor.setOrder(AuthorizationInterceptorsOrder.PRE_AUTHORIZE.getOrder());
    return interceptor;
}

(and indeed it does work if I annotate the @Controller with @PreAuthorize(""))

I tried creating my own AuthorizationManagerBeforeMethodInterceptor, but to no avail either for now, since I don't really understand how that internal machinery works.


Solution

  • With your additional explanation and code snippets (which were useful for clarity), I can share one possible way to achieve your goal.


    WARNING: Before doing so, I will once again reiterate that multiple layers of defense are always better than implementing something like this, because we can be assured that when one layer fails (due to a variety of reasons including misconfiguration), another layer is in place for baseline protection.


    To achieve a "fallback" with only method-security, we can target all methods in the application that have the RequestMapping annotation, including as a meta-annotation (e.g. @GetMapping, @PostMapping, etc.). We can rely on the fact that the existing PreAuthorizeAuthorizationManager abstains (returns null) when no PreAuthorize annotation is found on the method/class.

    NOTE: The major downside of this approach is that endpoints available through (for example) servlet mappings are not protected, so it is still possible to have endpoints that are not protected. A robust set of functional tests for security will be required to ensure you can have confidence here.

    The following is an example security configuration:

    @Configuration
    @EnableWebSecurity
    @EnableMethodSecurity(prePostEnabled = false)
    public class SecurityConfiguration {
    
        @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
            http.formLogin(withDefaults());
            http.httpBasic(withDefaults());
    
            return http.build();
        }
    
        @Bean
        public UserDetailsService userDetailsService() {
            // ...
        }
    
        @Bean
        @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
        static Advisor preAuthorize() {
            var pointcut = forAnnotation(RequestMapping.class);
            var authorizationManager = new CatchAllPreAuthorizeAuthorizationManager();
            var interceptor = new AuthorizationManagerBeforeMethodInterceptor(pointcut,
                authorizationManager);
            interceptor.setOrder(AuthorizationInterceptorsOrder.PRE_AUTHORIZE.getOrder());
    
            return interceptor;
        }
    
        private static Pointcut forAnnotation(Class<? extends Annotation> annotation) {
            return Pointcuts.union(new AnnotationMatchingPointcut(null, annotation, true),
                new AnnotationMatchingPointcut(annotation, true));
        }
    
        static class CatchAllPreAuthorizeAuthorizationManager
                implements AuthorizationManager<MethodInvocation> {
    
            private final AuthorizationManager<MethodInvocation> delegate =
                new PreAuthorizeAuthorizationManager();
    
            private final AuthorizationManager<MethodInvocation> fallback =
                AuthenticatedAuthorizationManager.authenticated();
    
            @Override
            public AuthorizationDecision check(Supplier<Authentication> authentication,
                    MethodInvocation object) {
    
                var authorizationDecision = this.delegate.check(authentication, object);
                if (authorizationDecision == null) {
                    // PreAuthorizeAuthorizationManager abstained due to no PreAuthorize
                    // annotation, so apply fallback.
                    authorizationDecision = this.fallback.check(authentication, object);
                }
    
                return authorizationDecision;
            }
    
        }
    
    }
    

    Another minor note, in testing with this setup there are other gotchas and caveats that pop up, for example the default Spring Boot /error endpoint provided by BasicErrorController and issues with favicon.ico requests that return a 404. More configuration may be required to get things fully working with your setup.