简体   繁体   中英

Use Keycloak Spring Adapter with Spring Boot 3

I updated to Spring Boot 3 in a project that uses the Keycloak Spring Adapter. Unfortunately it doesn't start because the KeycloakWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter which was first deprecated in Spring Security and then removed. Is there currently another way to implement security with Keycloak? Or to put it in other words: How can I use Spring Boot 3 in combination with the keycloak adapter?

I searched the inte.net but couldn't find any other version of the adapter.

You can't use Keycloak adapters with spring-boot 3 for the reason you found, plus a few others related to transitive dependencies. As most Keycloak adapters were deprecated , it is very likely that no update will be published to fix that.

Directly use spring-security OAuth2 instead. Don't panic, it's an easy task with spring-boot .

"Official" staters

There are 2 spring-boot starters to ease the creation of all the necessary beans:

  • spring-boot-starter-oauth2-client if your app serves UI with Thymeleaf or alike. Client configuration can also be used to setup a REST client with client-credentials ( WebClient , @FeignClient , RestTemplate ).
  • spring-boot-starter-oauth2-resource-server if app is a REST API (serves resources, not the UI to manipulate it).

Here is how to configure a resource-server with a unique Keycloak realm as authorization-server:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class WebSecurityConfig {

    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
    public SecurityFilterChain apiFilterChain(HttpSecurity http, Converter<JWT, AbstractAuthenticationToken> authenticationConverter, ServerProperties serverProperties)
            throws Exception {

        // Enable OAuth2 with custom authorities mapping
        http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(authenticationConverter);

        // Enable anonymous
        http.anonymous();

        // Enable and configure CORS
        http.cors().configurationSource(corsConfigurationSource());

        // State-less session (state in access-token only)
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        // Disable CSRF because of disabled sessions
        http.csrf().disable();

        // Return 401 (unauthorized) instead of 302 (redirect to login) when authorization is missing or invalid
        http.exceptionHandling().authenticationEntryPoint((request, response, authException) -> {
            response.addHeader(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"Restricted Content\"");
            response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
        });

        // If SSL enabled, disable http (https only)
        if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) {
            http.requiresChannel().anyRequest().requiresSecure();
        } else {
            http.requiresChannel().anyRequest().requiresInsecure();
        }

        // Route security: authenticated to all routes but actuator and Swagger-UI
        // @formatter:off
        http.authorizeRequests()
            .antMatchers("/actuator/health/readiness", "/actuator/health/liveness", "/v3/api-docs/**").permitAll()
            .anyRequest().authenticated();
        // @formatter:on

        return http.build();
    }

    private CorsConfigurationSource corsConfigurationSource() {
        // Very permissive CORS config...
        final var configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("*"));
        configuration.setAllowedMethods(Arrays.asList("*"));
        configuration.setAllowedHeaders(Arrays.asList("*"));
        configuration.setExposedHeaders(Arrays.asList("*"));

        // Limited to API routes (neither actuator nor Swagger-UI)
        final var source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/greet/**", configuration);

        return source;
    }
}
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://localhost:8443/realms/master
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://localhost:8443/realms/master/protocol/openid-connect/certs

spring-addons starters

As above configuration is quite verbose (things get even more complicated if you are in multi-tenancy scenario), error prone (easy to de-synchronise CSRF protection and sessions configuration for instance) and invasive (would be easier to maintain if all of that conf was controlled from properties file), I wrote wrappers arround "official" starter . It is very thin (each is composed of three files only) and greatly simplifies resource-servers configuration:

<dependency>
    <groupId>com.c4-soft.springaddons</groupId>
    <!-- replace "webmvc" with "weblux" if your app is reactive -->
    <!-- replace "jwt" with "introspecting" to use token introspection instead of JWT decoding -->
    <artifactId>spring-addons-webmvc-jwt-resource-server</artifactId>
    <!-- this version is to be used with spring-boot 3.0.0, use 5.x for spring-boot 2.6.x or before -->
    <version>6.0.7</version>
</dependency>
@Configuration
@EnableMethodSecurity
public static class WebSecurityConfig { }
com.c4-soft.springaddons.security.issuers[0].location=https://localhost:8443/realms/realm1
com.c4-soft.springaddons.security.issuers[0].authorities.claims=realm_access.roles,ressource_access.some-client.roles,ressource_access.other-client.roles


com.c4-soft.springaddons.security.cors[0].path=/some-api

And, as you can guess from this issuers property being an array, you can configure as many OIDC authorization-server instances as you like (multiple realms or instances, even not Keycloak). Bootiful, isn't it?

Edit: add client configuration

If your Spring application also exposes secured UI elements you want to be accessible with a browser (with OAuth2 login), you'll need to add a FilterChain with "client" configuration.

Add this to pom.xml

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-client</artifactId>
        </dependency>

that to java conf (this is an additional SecurityFilterChain applying only to the securityMatcher list below, keep the resource-server SecurityFilterChain already defined above for REST endpoints):

    @Order(Ordered.HIGHEST_PRECEDENCE)
    @Bean
    SecurityFilterChain uiFilterChain(HttpSecurity http, ServerProperties serverProperties) throws Exception {

        // @formatter:off
        http.securityMatcher(new OrRequestMatcher(
                // add path to your UI elements instead
                new AntPathRequestMatcher("/ui/**"),
                // those two are required to access Spring generated login page
                // and OAuth2 client callback endpoints
                new AntPathRequestMatcher("/login/**"),
                new AntPathRequestMatcher("/oauth2/**")));

        http.oauth2Login();
        http.authorizeHttpRequests()
                .requestMatchers("/ui/index.html").permitAll()
                .requestMatchers("/login/**").permitAll()
                .requestMatchers("/oauth2/**").permitAll()
                .anyRequest().authenticated();
        // @formatter:on

        // If SSL enabled, disable http (https only)
        if (serverProperties.getSsl() != null && serverProperties.getSsl().isEnabled()) {
            http.requiresChannel().anyRequest().requiresSecure();
        } else {
            http.requiresChannel().anyRequest().requiresInsecure();
        }

        // Many defaults are kept compared to API filter-chain:
        // - sessions (and CSRF protection) are enabled
        // - unauthorized requests to secured resources will be redirected to login (302 to login is Spring's default response when access is denied)

        return http.build();
    }

and last client properties:

spring.security.oauth2.client.provider.keycloak.issuer-uri=https://localhost:8443/realms/master

spring.security.oauth2.client.registration.spring-addons-public.provider=keycloak
spring.security.oauth2.client.registration.spring-addons-public.client-name=spring-addons-public
spring.security.oauth2.client.registration.spring-addons-public.client-id=spring-addons-public
spring.security.oauth2.client.registration.spring-addons-public.scope=openid,offline_access,profile
spring.security.oauth2.client.registration.spring-addons-public.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.spring-addons-public.redirect-uri=http://bravo-ch4mp:8080/login/oauth2/code/spring-addons-public

Use the standard Spring Security OAuth2 client instead of a specific Keycloak adapter and SecurityFilterChain instead of WebSecurityAdapter .

Something like this:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(jsr250Enabled = true, prePostEnabled = true)
class OAuth2SecurityConfig {

@Bean
fun customOauth2FilterChain(http: HttpSecurity): SecurityFilterChain {
    log.info("Configure HttpSecurity with OAuth2")

    http {
        oauth2ResourceServer {
            jwt { jwtAuthenticationConverter = CustomBearerJwtAuthenticationConverter() }
        }
        oauth2Login {}

        csrf { disable() }

        authorizeRequests {
            // Kubernetes
            authorize("/readiness", permitAll)
            authorize("/liveness", permitAll)
            authorize("/actuator/health/**", permitAll)
            // ...
            // everything else needs at least a valid login, roles are checked at method level
            authorize(anyRequest, authenticated)
        }
    }

    return http.build()
}

And then in application.yml :

spring:
  security:
    oauth2:
      client:
        provider:
          abc:
            issuer-uri: https://keycloak.../auth/realms/foo
        registration:
          abc:
            client-secret: ...
            provider: abc
            client-id: foo
            scope: [ openid, profile, email ]
      resourceserver:
        jwt:
          issuer-uri: https://keycloak.../auth/realms/foo

I was following the guide by ch4mp and it is working quite well. On startup Spring Security is complaining about no authenticationProivder or parentAuthenticationManager being set.

Is this the desired behaviour or do we have to add some extra beans?

DEBUG 32744 --- [  restartedMain] swordEncoderAuthenticationManagerBuilder : No authenticationProviders and no parentAuthenticationManager defined. Returning null.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM