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

190 Views Asked by At

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.

1

There are 1 best solutions below

2
Steve Riesenberg On BEST ANSWER

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.