Search code examples
spring-securitykeycloakroles

Secure method using roles in Spring


I am currently developing a demo prototype to secure a spring service through a Spring Gateway against a Keycloak. With my current configuration, everytime I access one of the mapped URLs in the gateway it redirects me to the keycloak' login screen and, after introducing my credentials, it redirects me to the service and the result is shown on screen. Now, I am trying to secure specific endpoints in the service using roles, so any user trying to access to them needs to have a specific role. In order to do so, I have added the "@EnableMethodSecurity" annotation to my configuration as follows:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfiguration {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()).oauth2ResourceServer((oauth2ResourceServer) -> oauth2ResourceServer.jwt((jwt) -> jwt.decoder(jwtDecoder())));
        //OLD http.authorizeRequests(authorize -> authorize.anyRequest().authenticated()).oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
        return http.build();
    }


    @Bean
    public JwtDecoder jwtDecoder() {
        return NimbusJwtDecoder.withJwkSetUri("http://[KEYCLOAK_IP]:8080/realms/my-realm/protocol/openid-connect/certs").build();
    } 
}

I have also added the "@PreAuthorize("hasRole('myrole')")" annotation to one of the endpoints of the service as follows:

@Controller
public class ExampleController {

    @PreAuthorize("hasRole('myrole')")
    @GetMapping("/securedexample")
    @ResponseBody
    public String getString(){
        return "secured string";
    }

    
    @GetMapping("/getToken")
    @ResponseBody
    public String getString2(){
        Jwt token =(Jwt)SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        return "Value of the token:*******" + token.getTokenValue() + "*******";
    }

Everytime I try to access to the service secured with the @PreAuthorize annotation, no matter the user I am logged in, I get a blank screen in the browser and I can see the following in the log:

Failed to authorize ReflectiveMethodInvocation: public java.util.String com.webdemo.controllers.ExampleController.getString(); target is of class [com.webdemo.controllers.ExampleController] with authorization manager org.springframework.security.config.annotation.method.configuration.DeferringObservationAuthorizationManager@7828b138 and decision ExpressionAuthorizationDecision [granted=false, expressionAttribute=hasRole('myrole')]

It seems that it does not find the role in the logged user. I have 2 users on keycloak, each one with a different role (myrole and myrole2) and when I check the content of the JWS token of the logged user (the one with the correct role) I can see that it has the role. Next I paste the relevant part of the token:

...
  "realm_access": {
    "roles": [
      "offline_access",
      "uma_authorization",
      "myrole",     <-- The role is here!
      "default-roles-my-realm"
    ]
  },
...

I have search over internet and found several code variations to secure and endpoint:

@PreAuthorize("hasRole('myrole')")
@PreAuthorize("hasRole('ROLE_myrole')") 
@PreAuthorize("hasAuthority('myrole')")
@PreAuthorize("hasAuthority('ROLE_myrole')")

I have tried all 4 variations with no luck. I have also searched and found this link PreAuthorize not working on Controller , but it seems to use the same code above. Any clue about what I am doing wrongly? Thanks in advance!


Solution

  • It seems that you have not configured anything for authorities mapping and the default Converter<Jwt, ? extends AbstractAuthenticationToken> is JwtAuthenticationConverter which maps Spring authorities from scope claim with SCOPE_ prefix.

    You should define your own Converter<Jwt, ? extends AbstractAuthenticationToken> when configuring the SecurityFilterChain bean. Something like that:

    spring:
      security:
        oauth2:
          resourceserver:
            Jwk-set-uri: http://[KEYCLOAK_IP]:8080/realms/my-realm/protocol/openid-connect/certs
    
    @Configuration
    @EnableWebSecurity
    @EnableMethodSecurity
    public class SecurityConfiguration {
        @Component
        static class KeycloakAuthoritiesConverter implements Converter<Jwt, List<SimpleGrantedAuthority>> {
            @Override
            @SuppressWarnings({ "rawtypes", "unchecked" })
            public List<SimpleGrantedAuthority> convert(Jwt jwt) {
                final var realmAccess = (Map<String, Object>) jwt.getClaims().getOrDefault("realm_access", Map.of());
                final var roles = (List<String>) realmAccess.getOrDefault("roles", List.of());
                // add some processing here like a "ROLE_" prefix if you prefer hasRole over hasAuthority and your Keycloak roles do not start with ROLE_ already
                return roles.stream().map(SimpleGrantedAuthority::new).toList(); 
            }
        }
        
        @Component
        @RequiredArgsContructor
        static class KeycloakAuthenticationConverter implements Converter<Jwt, JwtAuthenticationToken> {
            private final KeycloakAuthoritiesConverter authoritiesConverter;
    
            @Override
            public JwtAuthenticationToken convert(Jwt jwt) {
                return new JwtAuthenticationToken(jwt, authoritiesConverter.convert(jwt), jwt.getClaimAsString(StandardClaimNames.PREFERRED_USERNAME));
            }
            
        }
        
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http, KeycloakAuthenticationConverter authenticationConverter) throws Exception {
            http.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(authenticationConverter)));
    
            // Enable and configure CORS
            http.cors(cors -> cors.configurationSource(corsConfigurationSource()));
    
            // State-less session (state in access-token only)
            http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
    
            // Disable CSRF because of state-less session-management
            http.csrf(csrf -> csrf.disable());
    
            // Return 401 (unauthorized) instead of 302 (redirect to login) when
            // authorization is missing or invalid
            http.exceptionHandling(eh -> eh.authenticationEntryPoint((request, response, authException) -> {
                response.addHeader(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"Restricted Content\"");
                response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
            }));
    
            // @formatter:off
            http.authorizeHttpRequests(requests -> requests
                .requestMatchers(permitAll).permitAll()
                .anyRequest().authenticated());
            // @formatter:on
            
            return http.build();
        }
    }
    

    But you should probably have a look at my starter to get things working with no Java conf at all (just application properties) and my tutorials for more background, more use-cases and more flexible authorities converter (one than can use more than one claim as source, like the resource_access.{client-id}.roles Pier-Jean Malandrino refers to in his answer).

    With this dependencies:

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <!-- For a reactive application, use spring-boot-starter-webflux instead -->
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>
    
    <dependency>
        <groupId>com.c4-soft.springaddons</groupId>
        <artifactId>spring-addons-starter-oidc</artifactId>
        <version>7.0.0</version>
    </dependency>
    

    Those yaml properties:

    com:
      c4-soft:
        springaddons:
          oidc:
            ops:
            - iss: {value of iss claim in one of your tokens} 
              jwk-set-uri: http://[KEYCLOAK_IP]:8080/realms/my-realm/protocol/openid-connect/certs
              username-claim: preferred_username
              authorities:
              - path: $.realm_access.roles
              - path: $.resource_access.*.roles
            resourceserver:
              permit-all:
              - "/greet/public"
              cors:
              - path: /**
                allowed-origin-patterns: http://localhost:4200
    

    And just that as security conf:

    @Configuration
    @EnableMethodSecurity
    public class SecurityConfiguration {
    }