Search code examples
spring-bootspring-securitykeycloak

Access based on REALM ROLES not working, Spring Security 6 & Keycloak


I want to give access based on the realm roles. I am using Keycloak and Spring Boot 3 (Spring Security 6).

I tried hasRole()/hasAnyRole/hasAuthority/hasAnyAuthority with the desired role name. The letter case is the same.

By default use-resource-role-mappings is false("If false, it will look at the realm level for user role mappings").

I'd like to set this in java configuration file, as I would find it very time consuming to add on each controller @PreAuthorize annotation.

I keep getting 403 as a response. What am I doing wrong? Error:

Retrieved SecurityContextImpl [Authentication=OAuth2AuthenticationToken [Principal=Name: [firstname.lastname], Granted Authorities: [[OIDC_USER, SCOPE_ProjectNameClientScope, SCOPE_email, SCOPE_openid, SCOPE_profile]], 
User Attributes: [{at_hash=hash-hash-hash, sub=sub-sub-sub-sub-sub, email_verified=true, iss=https://keycloak.domain.ac.at:port/realms/ProjectNameRealm, groups=[/pn_administratoren], 
Roles=[cafeteria, obw, ROLE_obw, courseplanning], typ=ID, preferred_username=firstname.lastname, given_name=firstname, nonce=nonce-y-nonce-nonce, sid=sid-sid-sid-sid-sid, aud=[po-ms], acr=1, persId=number, azp=po-ms, auth_time=2023-01-18T13:36:38Z, name=firstname lastname, exp=2023-01-18T13:41:38Z, 
session_state=sss-sss-sss-ss-sss, family_name=lastname, iat=2023-01-18T13:36:38Z, [email protected], jti=jti-jti-jti-jti-jti}], 
Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=xxx.xxx.xx.xx, SessionId=sesId], 
Granted Authorities=[OIDC_USER, SCOPE_ProjectNameClientScope, SCOPE_email, SCOPE_openid, SCOPE_profile]]]

Here is my code:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;

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

@Configuration
@EnableWebSecurity
public class SpringSecurity2 {
    
    public interface Jwt2AuthoritiesConverter extends Converter<Jwt, Collection<? extends GrantedAuthority>> {
    }

    @SuppressWarnings("unchecked")
    @Bean
    public Jwt2AuthoritiesConverter authoritiesConverter() {
        // This is a converter for roles as embedded in the JWT by a Keycloak server
        // Roles are taken from both realm_access.roles & resource_access.{client}.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());

            final var resourceAccess = (Map<String, Object>) jwt.getClaims().getOrDefault("resource_access", Map.of());
            // We assume here you have "spring-addons-confidential" and "spring-addons-public" clients configured with "client roles" mapper in Keycloak
            final var confidentialClientAccess = (Map<String, Object>) resourceAccess.getOrDefault("spring-addons-confidential", Map.of());
            final var confidentialClientRoles = (Collection<String>) confidentialClientAccess.getOrDefault("roles", List.of());
            final var publicClientAccess = (Map<String, Object>) resourceAccess.getOrDefault("spring-addons-public", Map.of());
            final var publicClientRoles = (Collection<String>) publicClientAccess.getOrDefault("roles", List.of());

            return Stream.concat(realmRoles.stream(), Stream.concat(confidentialClientRoles.stream(), publicClientRoles.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));
    }

    @Bean
    protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
        return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl());
    }


    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, Converter<Jwt, AbstractAuthenticationToken> authenticationConverter, KeycloakLogoutHandler keycloakLogoutHandler) throws Exception {

        CookieCsrfTokenRepository tokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse();
        XorCsrfTokenRequestAttributeHandler delegate = new XorCsrfTokenRequestAttributeHandler();
        delegate.setCsrfRequestAttributeName("_csrf");

        CsrfTokenRequestHandler requestHandler = delegate::handle;

        http.authorizeHttpRequests(auth -> {
            auth.requestMatchers("/firstpath/**", "/secondpath/**").permitAll();
            auth.requestMatchers("/thirdpath/**").hasAnyRole("obw");
            auth.anyRequest().authenticated();
        });

        http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(authenticationConverter);
        http.oauth2Login()
                .and()
                .logout()
                .addLogoutHandler(keycloakLogoutHandler)
                .logoutSuccessUrl("/");

        http.csrf(csrf -> csrf
                .csrfTokenRepository(tokenRepository)
                .csrfTokenRequestHandler(requestHandler));

        return http.build();
    }
    
}

enter image description here

The logged in user's roles The logged in user's roles

The same with ROLE_obw...

And this is part of the access token: enter image description here


Solution

  • Login (and logout) are handled by OAuth2 clients, not resource-servers (REST APIs). Requests to resource-server protected resources should have an access-token.

    Remove login and logout conf from your resource-server security filter-chain (you might also make it session-less and disable CSRF) and send requests with Bearer access-token in Authorization header.

    If your app also serves server-side rendered UI, define a second SecurityFilterChain bean for client configuration (with login, logout, sessions and CSRF protection enabled).

    Details in