0

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!

Richter
  • 13
  • 5

3 Answers3

0

Did you check [the difference between realm and client roles][1] ? Maybe your spring config make you look through wrong type of roles.

[1]: https://stackoverflow.com/questions/47837613/how-are-keycloak-roles-managed#:~:text=Keycloak%20has%20two%20categories%20of,%2C%20represented%20by%20the%20realm).

drino
  • 46
  • 4
  • I have checked your link and some others which state that the parameter "keycloak.use-resource-role-mappings=true" is the one determining which roles are used. I have tested such parameter to true and false with no luck... same result... – Richter Jul 13 '23 at 07:11
0

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 {
} 
ch4mp
  • 6,622
  • 6
  • 29
  • 49
  • Could you please let me know the full path in GitHub to the example you are talking about (the origin of your code sample)? Thanks! – Richter Jul 13 '23 at 08:22
  • I used your comment about JwtAuthenticationConverter to perform a test. I modified my code to authorize one of the default authorities that the spring security log shows in screen `Granted Authorities=[OIDC_USER, SCOPE_email, SCOPE_openid, SCOPE_profile]` using `@PreAuthorize("hasAuthority('SCOPE_email')")` and it worked. Now I have to make the users available in the JWT... – Richter Jul 13 '23 at 08:55
  • Ok, I have a doubt. Your class "KeycloakAuthenticationConverter" only returns a NEW token, but I already have such token coming from Keycloak. I might recover the token using `Jwt token =(Jwt)SecurityContextHolder.getContext().getAuthentication().getPrincipal();`. Is there any way to put such token in the Spring session through the "SecurityFilterChain" ?? – Richter Jul 13 '23 at 09:52
  • It does not return a new token. It turns the result of successfully decoded and validated token into a JwtAuthenticationToken which is the default Authentication for Spring resource server with JWT decoder. Authentication is the internal representation for an identity in Spring Security. This sample is a simplified version of https://github.com/ch4mpy/spring-addons/tree/master/samples/tutorials/servlet-resource-server – ch4mp Jul 13 '23 at 10:51
  • Ok, thanks for your comments and your patience! I'll take a look at your code in order to implement your proposed solution. – Richter Jul 13 '23 at 11:02
  • Don't add my code to yours, just replace your code. Also, I added a sample with "my" starter to the answer above. – ch4mp Jul 13 '23 at 11:06
0

The code provided by ch4mp (with some minor modifications) worked like a charm! Thanks a lot for your help, you deserve a monument!

Richter
  • 13
  • 5