1

I am trying to implement RBAC on my Spring Boot resource server. Previously I used Keycloak adapters, but now that they are deprecated I am having issues. I have followed the solution proposed here: https://stackoverflow.com/a/74572732/16489856, but it seems like that my configuration is wrong somehow.

Here is my Spring Security configuration:

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
            .cors().disable()
            .csrf().disable()
            .authorizeHttpRequests()
                .requestMatchers("/workstations").hasRole("USER")
                .anyRequest().authenticated();

        http
            .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
            .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        return http.build();
    }

    public interface Jwt2AuthoritiesConverter extends Converter<Jwt, Collection<? extends GrantedAuthority>> {
    }

    @SuppressWarnings("unchecked")
    @Bean
    public Jwt2AuthoritiesConverter authoritiesConverter() {
        // Roles are taken from realm_access.roles
        return jwt -> {
            final var realmAccess = (Map<String, Object>) jwt.getClaims().getOrDefault("realm_access", Map.of());
            final var realmRoles = (Collection<String>) realmAccess.getOrDefault("roles", List.of());

            return realmRoles.stream().map(SimpleGrantedAuthority::new).toList();
        };
    }

    public interface Jwt2AuthenticationConverter extends Converter<Jwt, AbstractAuthenticationToken> {
    }

    @Bean
    public Jwt2AuthenticationConverter authenticationConverter(Jwt2AuthoritiesConverter authoritiesConverter) {
        return jwt -> new JwtAuthenticationToken(jwt, authoritiesConverter.convert(jwt));
    }

}

Roles are properly set, if I print them like so:

Jwt jwt = (Jwt) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Map<String, Object> resourceAccess = jwt.getClaim("realm_access");
Collection<String> resourceRoles = (Collection<String>) resourceAccess.get("roles");

System.out.println((Collection<String>) resourceAccess.get("roles"));

I get:

[default-roles-spring_reservations_realm_0, offline_access, uma_authorization, USER]

which is correct.

However, when I access /workstation endpoint, with a user that has the USER role, it fails with 403 forbidden status, how can I fix this?

dur
  • 15,689
  • 25
  • 79
  • 125
Centuri0n
  • 59
  • 1
  • 6
  • 2
    Why did you disable CORS? Is your client running in your Spring Boot application on the same port? – dur Feb 11 '23 at 10:52
  • No you shouldn't change the Spring Security configration, you have to change your role name in your `authoritiesConverter` method. – dur Feb 11 '23 at 11:14
  • 1
    Why dont you just enable debug logs and look in the server logs for the exact reason? – Toerktumlare Feb 11 '23 at 11:15
  • I changed the `authoritiesConverter` like so: `return realmRoles.stream() .map(role -> new SimpleGrantedAuthority("SCOPE_"+role)) .collect(Collectors.toSet());` It still fails. – Centuri0n Feb 11 '23 at 11:30
  • 1
    Just try the prefix `ROLE_`. – dur Feb 11 '23 at 13:14
  • I will edit the answer you link so that none else falls in the trap you stepped onto. Details in may answer below. – ch4mp Feb 11 '23 at 20:04

3 Answers3

4

Your authentication convert bean is not picked by spring-boot auto-configuration because boot expects a Converter<Jwt, ? extends AbstractAuthenticationToken> when you provide a Converter<Jwt, AbstractAuthenticationToken>.

Two different soultions:

  • apply the solution exposed in my answer you linked: provide the authentication converter explicitly. For that, replace .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt) with .oauth2ResourceServer().jwt().jwtAuthenticationConver(authenticationConverter(authoritiesConverter()) (can be shorter if you use authenticationConverter as filterChain argument: authenticationConverter is bean in your conf):
    @Bean
    public SecurityFilterChain filterChain(
            HttpSecurity http,
            /* next is the exact type of your current authentication converter bean */ 
            /* not Converter<Jwt, ? extends AbstractAuthenticationToken> like provided by default (and scanned for overrides) by boot */
            Converter<Jwt, AbstractAuthenticationToken> authenticationConverter) throws Exception {

        http.oauth2ResourceServer().jwt().jwtAuthenticationConver(authenticationConverter);

        http.cors().disable(); // Are you really sure about this one?

        http.authorizeHttpRequests()
                // The following requires that either 
                // - the user is granted with "ROLE_USER" in Keycloak (with that case)
                // - the user is granted with "user" in Keycloak and the authorities mapper adds "ROLE_" prefix and forces roles to uppercase
                .requestMatchers("/workstations").hasRole("USER")
                .anyRequest().authenticated();

        http.sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .csrf().disable(); // This is safe because of disabled sessions. Put it together maybe?

        return http.build();
    }

// Keep the rest of your conf as it is
  • change the type of your authenticationConverter @Bean to be the one that spring-boot looks for: Converter<Jwt, ? extends AbstractAuthenticationToken> (and not Converter<Jwt, AbstractAuthenticationToken> like in your current conf). This means changing a bit your Jwt2AuthenticationConverter definition so that the configurer destination type is a sub-type of AbstractAuthenticationToken (JwtAuthenticationToken in your case) and not AbstractAuthenticationToken itself. Yes, I know, this Java generics can be tricky sometimes):
    public interface Jwt2AuthenticationConverter extends Converter<Jwt, JwtAuthenticationToken> {
    }

// keep .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
// or change Converter<Jwt, AbstractAuthenticationToken> to Converter<Jwt, ? extends AbstractAuthenticationToken>
// for the type of bean injected in filterChain if you prefer explicit
// authenticationConverter override in fiterChain config like in the other solution

PS regarding comments

@dur is right regarding CORS: it doesn't look like a great idea to disable it, specially if your API is intended to be consumed by a web app.

For spring, a role is is nothing more than an authority prefixed with ROLE_. As a consequence:

  • hasRole("USER") is equivalent to hasAuthority("ROLE_USER") (but not hasAuthority("ROLE_user") as case is important)
  • neither hasAuthority("user") nor hasAuthority("SCOPE_USER") have hasRole equivalent (JWT decoder or not)
  • hasRole("SCOPE_USER") is equivalent to hasAuthority("ROLE_SCOPE_USER")
  • etc.

The SCOPE_ prefix is a choice from the default authorities converter because it maps authorities from scope claim (there is no OAuth2 nor OpenID standard claim for roles and it must pick it from somewhere standard). Using this same prefix when you pick authorities from somewhere else than the scope claim (like you do) would be very misleading...

Just use as prefix:

  • nothing (like in your question) if you are ok with hasAuthority or if roles in the access token are already prefixed with ROLE_
  • ROLE_ if you prefer reading hasRole over hasAuthority in your code (and roles in access tokens are not prefixed with ROLE_ already)
ch4mp
  • 6,622
  • 6
  • 29
  • 49
  • Thank you for the advices, it is working now. I am not enabling cors because the resource server is in my case a microservice behind Spring Cloud Gateway, I have cors enabled there. – Centuri0n Feb 11 '23 at 20:28
  • If it is working, maybe can you accept the answer? This would save some time to those trying answer questions without solutions and give an indication to those with a similar need that this thread contains a valid answer – ch4mp Feb 11 '23 at 20:48
1

Here is how to configure Keycloak RBAC for a Spring Boot Resource Server, thanks to @ch4mp and @dur:

Spring Security configuration:

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;



@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

    private final JwtAuthConverter jwtAuthConverter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        
        http.cors().disable().csrf().disable();

        http.authorizeHttpRequests()
                .requestMatchers("/workstations").hasRole("USER")
                .anyRequest().authenticated();

        http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(jwtAuthConverter);

        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        return http.build();
    }


}

JwtAuthConverter:

import lombok.NonNull;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimNames;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.stereotype.Component;

import java.util.Collection;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

@Component
public class JwtAuthConverter implements Converter<Jwt, AbstractAuthenticationToken> {

    private final JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();

    private final JwtAuthConverterProperties properties;

    public JwtAuthConverter(JwtAuthConverterProperties properties) {
        this.properties = properties;
    }

    @Override
    public AbstractAuthenticationToken convert(@NonNull Jwt jwt) {
        Collection<GrantedAuthority> authorities = Stream.concat(
                jwtGrantedAuthoritiesConverter.convert(jwt).stream(),
                extractResourceRoles(jwt).stream()).collect(Collectors.toSet());
        return new JwtAuthenticationToken(jwt, authorities, getPrincipalClaimName(jwt));
    }

    private String getPrincipalClaimName(Jwt jwt) {
        String claimName = JwtClaimNames.SUB;
        if (properties.getPrincipalAttribute() != null) {
            claimName = properties.getPrincipalAttribute();
        }
        return jwt.getClaim(claimName);
    }

    @SuppressWarnings("unchecked")
    private Collection<? extends GrantedAuthority> extractResourceRoles(Jwt jwt) {

        Map<String, Object> resourceAccess = jwt.getClaim("realm_access");
        Collection<String> resourceRoles = (Collection<String>) resourceAccess.get("roles");

        return resourceRoles.stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                .collect(Collectors.toSet());
    }
}

JwtAuthConverterProperties:

import lombok.Data;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.annotation.Validated;

@Data
@Validated
@Configuration
public class JwtAuthConverterProperties {

    private String resourceId;
    private String principalAttribute;
}
Centuri0n
  • 59
  • 1
  • 6
1

The following ResourceServerConfig was working before migration to spring 3.0.5. The roles in the token already have ROLE_ prefix. Is there anything else that needs to be added?

@Configuration
public class ResouceServerConfig {

    @Autowired
     private JwtAuthConverter jwtAuthConverter;
    

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        
        http.authorizeHttpRequests(authorizeRequests -> {
            authorizeRequests.requestMatchers(HttpMethod.GET, "/couponapi/coupons/{code:^[A-Z]*$}")
                    .hasAnyRole("USER", "ADMIN").requestMatchers(HttpMethod.POST, "/couponapi/coupons").hasRole("ADMIN");
        }).oauth2ResourceServer().jwt().jwtAuthenticationConverter(jwtAuthConverter);
        http.csrf().disable().cors().disable();
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        return http.build();

    }
}



@Component
public class JwtAuthConverter implements Converter<Jwt, AbstractAuthenticationToken> {

    private final JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();

    private static final String ROLES_CLAIM = "roles";

    @Override
    public AbstractAuthenticationToken convert(Jwt jwt) {
        Collection<GrantedAuthority> authorities = Stream
                .concat(jwtGrantedAuthoritiesConverter.convert(jwt).stream(), extractResourceRoles(jwt).stream())
                .collect(Collectors.toSet());
        return new JwtAuthenticationToken(jwt, authorities, ROLES_CLAIM);
    }

    private Collection<? extends GrantedAuthority> extractResourceRoles(Jwt jwt) {

        List<String> roles = jwt.getClaimAsStringList(ROLES_CLAIM);
        if (roles != null) {
            return roles.stream().map(eachRole -> new SimpleGrantedAuthority(eachRole))
                    .collect(Collectors.toList());
        }
        return Collections.emptyList();
    }
}
Suraj Rao
  • 29,388
  • 11
  • 94
  • 103
bharath
  • 11
  • 2