Search code examples
javaspringspring-bootsecurity

Implementing Custom Expression Handling with Spring Security 6


In my Java Spring Boot 3 application, I want to use Spring Security with @PreAuthorize / @PostAuthorize. For some reason, the Keycloak-generated tokens I receive do not have Roles under Authorities as Spring expects them, but under a "realm_access" attribute.
So, I decided to go ahead and implement a custom Expession handler, following pretty much what I found in https://docs.spring.io/spring-security/reference/servlet/authorization/method-security.html#customizing-expression-handling

So, some code
Expression Root

public class CustomMethodSecurityExpressionRoot
        extends SecurityExpressionRoot implements MethodSecurityExpressionOperations {

    public CustomMethodSecurityExpressionRoot(Authentication authentication) {
        super(authentication);
    }

    public boolean hasRealmRole(String role) {
        var principal = ((OAuth2AuthenticatedPrincipal)this.getPrincipal());
        if (principal == null) {
            return false;
        }

        var realmAccess = (JSONObject)principal.getAttribute("realm_access");
        if (realmAccess == null) {
            return false;
        }

        var roles = (JSONArray)realmAccess.get("roles");
        if (roles == null || roles.isEmpty()) {
            return false;
        }

        return roles.stream().anyMatch(x -> x.equals(role));
    }

    @Override
    public void setFilterObject(Object filterObject) {

    }

    @Override
    public Object getFilterObject() {
        return null;
    }

    @Override
    public void setReturnObject(Object returnObject) {

    }

    @Override
    public Object getReturnObject() {
        return null;
    }

    @Override
    public Object getThis() {
        return null;
    }
}

Expression Handler

public class CustomMethodSecurityExpressionHandler extends DefaultMethodSecurityExpressionHandler {
    private AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl();

    @Override
    protected MethodSecurityExpressionOperations createSecurityExpressionRoot(
            Authentication authentication, MethodInvocation invocation) {
        CustomMethodSecurityExpressionRoot root =
                new CustomMethodSecurityExpressionRoot(authentication);
        root.setPermissionEvaluator(getPermissionEvaluator());
        root.setTrustResolver(this.trustResolver);
        root.setRoleHierarchy(getRoleHierarchy());
        return root;
    }
}

Security Config

@Configuration
@EnableMethodSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf().disable()
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/health/**").permitAll()
                    .requestMatchers("/swagger-ui/**").permitAll()
                    .requestMatchers("/swagger/**").permitAll()
                    .requestMatchers("/v3/api-docs/**").permitAll()
                    // Cloud config related endpoint
                    .requestMatchers("/configuration/**").permitAll()
                    .anyRequest().authenticated()
            )
            .oauth2ResourceServer(OAuth2ResourceServerConfigurer::opaqueToken);
        return http.build();
    }
}

Controller

    @PreAuthorize("hasRealmRole('FOOBAR')")
    @GetMapping("/{id}")
    public ResponseEntity<Asset> getAsset(@UUIDConstraint @PathVariable("id") String id) {
    ...

and finally, the Beans based on the Spring reference added above

@Configuration
public class BaseConfig {
    @Bean
    public RoleHierarchy roleHierarchy() {
        return new RoleHierarchyImpl();
    }

    @Bean
    static MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) {
        var handler = new CustomMethodSecurityExpressionHandler();
        handler.setRoleHierarchy(roleHierarchy);
        return handler;
    }
}

Now, when I am sending a request to the /GET endpoint, I get the following error:

java.lang.IllegalArgumentException: Failed to evaluate expression 'hasRealmRole('FOOBAR')'  
...  
Caused by: org.springframework.expression.spel.SpelEvaluationException: EL1004E: Method call: Method hasRealmRole(java.lang.String) cannot be found on type org.springframework.security.access.expression.method.MethodSecurityExpressionRoot

Any idea why my CustomMethodSecurityExpressionRoot is not properly registered?


Solution

  • First, what you did is not quite adapted to authorities mapping: there are authentication (and authorities) converters for that. I have written a tutorial for configuring a quite universal authorities mapper there. It is compatible with Keycloak roles defined at realm level (those you found in realm_access.roles claim) as well as client level (resource-access.{client-id}.roles claims).

    Enriching the security SpEL DSL is actually a bit tricky (I had to copy a Spring protected class to get it working) and should be used when needing more than Role Based Access Control. I have another tutorial for that, but it is quite adherent to my libs and starters (the previous one I linked is not).

    Here is a sample expression handler configuration:

    @Bean
    static MethodSecurityExpressionHandler methodSecurityExpressionHandler() {
        return new SpringAddonsMethodSecurityExpressionHandler(ProxiesMethodSecurityExpressionRoot::new);
    }
    

    With this expression root:

    static final class ProxiesMethodSecurityExpressionRoot extends SpringAddonsMethodSecurityExpressionRoot {
    
        public boolean is(String preferredUsername) {
            return Objects.equals(preferredUsername, getAuthentication().getName());
        }
    
        public Proxy onBehalfOf(String proxiedUsername) {
            return get(ProxiesAuthentication.class).map(a -> a.getProxyFor(proxiedUsername))
                    .orElse(new Proxy(proxiedUsername, getAuthentication().getName(), List.of()));
        }
    
        public boolean isNice() {
            return hasAnyAuthority("NICE", "SUPER_COOL");
        }
    }
    

    And here is the code I had to copy from Spring Security:

    @RequiredArgsConstructor
    public class SpringAddonsMethodSecurityExpressionHandler extends DefaultMethodSecurityExpressionHandler {
        private final Supplier<SpringAddonsMethodSecurityExpressionRoot> expressionRootSupplier;
    
        /**
         * Creates the root object for expression evaluation.
         */
        @Override
        protected MethodSecurityExpressionOperations createSecurityExpressionRoot(Authentication authentication,
                MethodInvocation invocation) {
            return createSecurityExpressionRoot(() -> authentication, invocation);
        }
    
        @Override
        public EvaluationContext createEvaluationContext(Supplier<Authentication> authentication, MethodInvocation mi) {
            var root = createSecurityExpressionRoot(authentication, mi);
            var ctx = new SpringAddonsMethodSecurityEvaluationContext(root, mi, getParameterNameDiscoverer());
            ctx.setBeanResolver(getBeanResolver());
            return ctx;
        }
    
        private MethodSecurityExpressionOperations createSecurityExpressionRoot(Supplier<Authentication> authentication,
                MethodInvocation invocation) {
            final var root = expressionRootSupplier.get();
            root.setThis(invocation.getThis());
            root.setPermissionEvaluator(getPermissionEvaluator());
            root.setTrustResolver(getTrustResolver());
            root.setRoleHierarchy(getRoleHierarchy());
            root.setDefaultRolePrefix(getDefaultRolePrefix());
            return root;
        }
    
        static class SpringAddonsMethodSecurityEvaluationContext extends MethodBasedEvaluationContext {
    
            SpringAddonsMethodSecurityEvaluationContext(MethodSecurityExpressionOperations root, MethodInvocation mi,
                    ParameterNameDiscoverer parameterNameDiscoverer) {
                super(root, getSpecificMethod(mi), mi.getArguments(), parameterNameDiscoverer);
            }
    
            private static Method getSpecificMethod(MethodInvocation mi) {
                return AopUtils.getMostSpecificMethod(mi.getMethod(), AopProxyUtils.ultimateTargetClass(mi.getThis()));
            }
    
        }
    
    }
    
    public class SpringAddonsMethodSecurityExpressionRoot extends SecurityExpressionRoot implements MethodSecurityExpressionOperations {
    
        private Object filterObject;
        private Object returnObject;
        private Object target;
    
        public SpringAddonsMethodSecurityExpressionRoot() {
            super(SecurityContextHolder.getContext().getAuthentication());
        }
    
        @SuppressWarnings("unchecked")
        protected <T extends Authentication> Optional<T> get(Class<T> expectedAuthType) {
            return Optional.ofNullable(getAuthentication()).map(a -> a.getClass().isAssignableFrom(expectedAuthType) ? (T) a : null).flatMap(Optional::ofNullable);
        }
    
        @Override
        public void setFilterObject(Object filterObject) {
            this.filterObject = filterObject;
        }
    
        @Override
        public Object getFilterObject() {
            return filterObject;
        }
    
        @Override
        public void setReturnObject(Object returnObject) {
            this.returnObject = returnObject;
        }
    
        @Override
        public Object getReturnObject() {
            return returnObject;
        }
    
        public void setThis(Object target) {
            this.target = target;
        }
    
        @Override
        public Object getThis() {
            return target;
        }
    
    }