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 @Controller
s 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.
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.