I have an application with a fairly complex authorization model. In the database, I can have entries that specify authorities required for particular endpoints and I load them at the application startup.
Examples (method, pattern, authority):
GET
- /api/service1/someEntity
- GET_SERVICE1_SOME_ENTITY_AUTHORITY
GET
- /api/service2/someEntity
- GET_SERVICE2_SOME_ENTITY_AUTHORITY
GET
- /api/service1/**
- GET_SERVICE1_ANYTHING_AUTHORITY
null
- /**
- DO_ANYTHING
(admin)Before I load these rules, I sort them from most specific to most general, so they end up in exactly the same order as above. I load them like that (for each in the sorted order):
http.authorizeExchange()
.pathMatchers(permission.getMethod(), permission.getPattern())
.hasAuthority(permission.getAuthority());
Now the problem is that when I have user: ADMIN
with authority DO_ANYTHING
, it will actually not be able to do anything, because of the way how spring security works.
Let's say the admin
wants to access /api/service1/someEntity
.
Spring will match rule no.1 and check if admin
has authority GET_SERVICE1_SOME_ENTITY_AUTHORITY
and it will turn out that he doesn't. At this point, Spring will deny access. But what I want it to do is to check other matching rules (numbers 3 and 4).
I found the code responsible for this:
public final class DelegatingReactiveAuthorizationManager implements ReactiveAuthorizationManager<ServerWebExchange> {
// ...
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> authentication, ServerWebExchange exchange) {
return Flux.fromIterable(this.mappings).concatMap((mapping) -> mapping.getMatcher().matches(exchange)
.filter(MatchResult::isMatch).map(MatchResult::getVariables).flatMap((variables) -> {
logger.debug(LogMessage.of(() -> "Checking authorization on '"
+ exchange.getRequest().getPath().pathWithinApplication() + "' using "
+ mapping.getEntry()));
return mapping.getEntry().check(authentication, new AuthorizationContext(exchange, variables));
})).next().defaultIfEmpty(new AuthorizationDecision(false));
}
// ...
}
Unfortunately I don't know how to replace this implementation with mine.
tldr: I want spring security to verify all security rules matching the request path, instead of denying access on the first one that matches.
Ok, I figured out a solution, a little bit hacky, but it does what I need.
I've created my own implementation of ReactiveAuthorizationManager<ServerWebExchange>
, which is almost the same as Spring's DelegatingReactiveAuthorizationManager
, but has one additional filter, which does the job:
public class MyAuthorizationManager implements ReactiveAuthorizationManager<ServerWebExchange> {
private final List<ServerWebExchangeMatcherEntry<ReactiveAuthorizationManager<AuthorizationContext>>> mappings;
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> authentication, ServerWebExchange exchange) {
return Flux.fromIterable(this.mappings)
.concatMap((mapping) -> mapping.getMatcher()
.matches(exchange)
.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
.map(ServerWebExchangeMatcher.MatchResult::getVariables)
.flatMap((variables) -> {
log.debug(LogMessage.of(() -> "Checking authorization on '"
+ exchange.getRequest()
.getPath()
.pathWithinApplication() + "' using "
+ mapping.getEntry()).toString());
return mapping.getEntry()
.check(authentication, new AuthorizationContext(exchange, variables));
}).filter(AuthorizationDecision::isGranted) /* <- this is what I needed */)
.next()
.defaultIfEmpty(new AuthorizationDecision(false));
}
}
and this is how I registered it:
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
List<Permission> permissions = repo.getPermissions()
MyAuthorizationManager.Builder authBuilder = MyAuthorizationManager.builder();
permissions.forEach(permission -> {
// I create the mappings here, so matcher + auth manager for that matcher
authBuilder.add(
ServerWebExchangeMatchers.pathMatchers(permission.getMethod(), permission.getPattern()),
AuthorityReactiveAuthorizationManager.hasAuthority(permission.getAuthority())
);
});
http.authorizeExchange().anyExchange().permitAll()
.and()
.httpBasic()
.and()
.formLogin().disable()
.anonymous().disable();
// it will be before the default authorization filter, bu actually it doesn't really matter
http.addFilterBefore(new AuthorizationWebFilter(authBuilder.build()), SecurityWebFiltersOrder.AUTHORIZATION);
return http.build();
}
I have left .authorizeExchange().anyExchange().permitAll()
because it sets some default things like entry point, exception web filter and I didn't want to copy more code and totally give up the default manager.