使用Spring Boot 3与Keycloak Spring适配器

24

我在使用 Keycloak Spring 适配器的项目中升级到了 Spring Boot 3。不幸的是,由于 KeycloakWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter 已经被 Spring Security 废弃并移除,导致项目无法启动。目前是否有其他的方法来实现与 Keycloak 的安全集成呢?换句话说,我该如何在 Spring Boot 3 中使用 Keycloak 适配器呢?

我在网上搜索了一下,但没有找到任何其他版本的适配器。


1
关于 Keycloak 适配器弃用的最新更新可以在这里找到:https://www.keycloak.org/2023/03/adapter-deprecation-update.html - Danylo Zatorsky
6个回答

60

你不能在spring-boot 3中使用Keycloak适配器,原因你已经找到了,还有一些与传递依赖关系相关的其他原因。由于大多数Keycloak适配器在2022年初已被弃用,很可能不会发布更新来修复这个问题。

相反,使用spring-security 6库进行OAuth2不要慌张,使用spring-boot很容易实现

接下来,我假设你对OAuth2的概念有很好的理解,并且清楚为什么需要配置OAuth2客户端或OAuth2资源服务器。 如果有疑问,请参考我的教程中的OAuth2基础知识部分

我只会在这里详细介绍将servlet应用程序配置为资源服务器的方法,然后再介绍将其配置为客户端的方法,针对一个单独的Keycloak领域,以及使用和不使用我的Spring Boot starter spring-addons-starter-oidc。如果您不想使用"我的" starter,可以直接浏览您感兴趣的部分(但请准备编写更多的代码)。
另外,请参考我的教程,了解不同的用例,例如:
  • 接受由多个领域或实例(事先已知或在受信任的域中动态创建)发行的令牌
  • 响应式应用程序(WebFlux),例如spring-cloud-gateway
  • 公开提供REST API和用于消费API的服务器端渲染UI的应用程序
  • 高级访问控制规则
  • BFF模式
  • ...

1. OAuth2资源服务器

应用程序通过访问令牌保护了一个REST API,它被OAuth2 REST客户端所使用。以下是一些此类客户端的示例:

  • 另一个配置为OAuth2客户端并使用WebClient@FeignClientRestTemplate或类似工具的Spring应用程序
  • 像Postman这样的开发工具,能够获取OAuth2令牌并发出REST请求
  • 基于Javascript的应用程序,配置为“public” OAuth2客户端,使用类似angular-auth-oidc-client的库

1.1. 使用spring-addons-starter-oidc

<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.1.3</version>
</dependency>

origins: http://localhost:4200
issuer: http://localhost:8442/realms/master

com:
  c4-soft:
    springaddons:
      oidc:
        ops:
        - iss: ${issuer}
          username-claim: preferred_username
          authorities:
          - path: $.realm_access.roles
            prefix: ROLE_
          - path: $.resource_access.*.roles
        resourceserver:
          cors:
          - path: /**
            allowed-origin-patterns: ${origins}
          permit-all: 
          - "/actuator/health/readiness"
          - "/actuator/health/liveness"
          - "/v3/api-docs/**"

上面的配置文件中的领域角色前缀仅用于说明目的,您可以将其删除。CORS配置也需要进行一些改进。

@Configuration
@EnableMethodSecurity
public static class WebSecurityConfig { }

没有更多需要配置资源服务器的细粒度CORS策略和权限映射了。多么美妙啊!
从`ops`属性是一个数组可以猜到,这个解决方案实际上与“静态”多租户兼容:您可以声明尽可能多的受信任的发行者,并且可以是异构的(使用不同的声明来表示用户名和权限)。
此外,这个解决方案与响应式应用程序兼容:`spring-addons-starter-oidc`将根据类路径上的内容检测到它,并自适应其安全自动配置。
1.2. 只需`spring-boot-starter-oauth2-resource-server`
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
    <!-- used when converting Keycloak roles to Spring authorities -->
    <groupId>com.jayway.jsonpath</groupId>
    <artifactId>json-path</artifactId>
</dependency>

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8442/realms/master

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public static class WebSecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http, Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter) throws Exception {

        http.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter)));

        // Enable and configure CORS
        http.cors(cors -> cors.configurationSource(corsConfigurationSource("http://localhost:4200")));

        // 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, "Bearer realm=\"Restricted Content\"");
            response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
        }));

        // @formatter:off
        http.authorizeHttpRequests(accessManagement -> accessManagement
            .requestMatchers("/actuator/health/readiness", "/actuator/health/liveness", "/v3/api-docs/**").permitAll()
            .anyRequest().authenticated()
        );
        // @formatter:on

        return http.build();
    }

    private UrlBasedCorsConfigurationSource corsConfigurationSource(String... origins) {
        final var configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList(origins));
        configuration.setAllowedMethods(List.of("*"));
        configuration.setAllowedHeaders(List.of("*"));
        configuration.setExposedHeaders(List.of("*"));

        final var source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

    @RequiredArgsConstructor
    static class JwtGrantedAuthoritiesConverter implements Converter<Jwt, Collection<? extends GrantedAuthority>> {

        @Override
        @SuppressWarnings({ "rawtypes", "unchecked" })
        public Collection<? extends GrantedAuthority> convert(Jwt jwt) {
            return Stream.of("$.realm_access.roles", "$.resource_access.*.roles").flatMap(claimPaths -> {
                Object claim;
                try {
                    claim = JsonPath.read(jwt.getClaims(), claimPaths);
                } catch (PathNotFoundException e) {
                    claim = null;
                }
                if (claim == null) {
                    return Stream.empty();
                }
                if (claim instanceof String claimStr) {
                    return Stream.of(claimStr.split(","));
                }
                if (claim instanceof String[] claimArr) {
                    return Stream.of(claimArr);
                }
                if (Collection.class.isAssignableFrom(claim.getClass())) {
                    final var iter = ((Collection) claim).iterator();
                    if (!iter.hasNext()) {
                        return Stream.empty();
                    }
                    final var firstItem = iter.next();
                    if (firstItem instanceof String) {
                        return (Stream<String>) ((Collection) claim).stream();
                    }
                    if (Collection.class.isAssignableFrom(firstItem.getClass())) {
                        return (Stream<String>) ((Collection) claim).stream().flatMap(colItem -> ((Collection) colItem).stream()).map(String.class::cast);
                    }
                }
                return Stream.empty();
            })
            /* Insert some transformation here if you want to add a prefix like "ROLE_" or force upper-case authorities */
            .map(SimpleGrantedAuthority::new)
            .map(GrantedAuthority.class::cast).toList();
        }
    }

    @Component
    @RequiredArgsConstructor
    static class SpringAddonsJwtAuthenticationConverter implements Converter<Jwt, JwtAuthenticationToken> {

        @Override
        public JwtAuthenticationToken convert(Jwt jwt) {
            final var authorities = new JwtGrantedAuthoritiesConverter().convert(jwt);
            final String username = JsonPath.read(jwt.getClaims(), "preferred_username");
            return new JwtAuthenticationToken(jwt, authorities, username);
        }
    }
}

除了比前面的解决方案冗长之外,这个解决方案也不够灵活:
- 不适用于多租户(多个Keycloak领域或实例) - 硬编码的允许来源 - 硬编码的声明名称以获取权限 - 硬编码的"permitAll"路径匹配器
2. OAuth2客户端
应用程序公开了通过会话进行安全保护的任何类型的资源(而不是访问令牌)。它直接由浏览器(或任何其他能够维护会话的用户代理)消费,无需脚本语言或OAuth2客户端库(Spring在服务器上处理授权码流、注销和令牌存储)。常见用例包括:
- 使用服务器端渲染的UI的应用程序(使用Thymeleaf、JSF或其他工具) - 作为OAuth2客户端配置的"spring-cloud-gateway",使用"TokenRelay"过滤器(将OAuth2令牌从浏览器隐藏,并在将请求转发到下游资源服务器之前,用访问令牌替换会话cookie)
请注意,Back-Channel Logout目前尚未由Spring实现。如果您需要它,请使用“my”启动器(或从中复制)。

2.1. 使用spring-addons-starter-oidc

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-client</artifactId>
</dependency>
<dependency>
    <groupId>com.c4-soft.springaddons</groupId>
    <artifactId>spring-addons-starter-oidc</artifactId>
    <version>7.1.3</version>
</dependency>

issuer: http://localhost:8442/realms/master
client-id: spring-addons-confidential
client-secret: change-me
client-uri: http://localhost:8080

spring:
  security:
    oauth2:
      client:
        provider:
          keycloak:
            issuer-uri: ${issuer}
        registration:
          keycloak-login:
            authorization-grant-type: authorization_code
            client-name: My Keycloak instance
            client-id: ${client-id}
            client-secret: ${client-secret}
            provider: keycloak
            scope: openid,profile,email,offline_access

com:
  c4-soft:
    springaddons:
      oidc:
        ops:
        - iss: ${issuer}
          username-claim: preferred_username
          authorities:
          - path: $.realm_access.roles
          - path: $.resource_access.*.roles
        client:
          client-uri: ${client-uri}
          security-matchers: /**
          permit-all:
          - /
          - /login/**
          - /oauth2/**
          csrf: cookie-accessible-from-js
          post-login-redirect-path: /home
          post-logout-redirect-path: /
          back-channel-logout-enabled: true

@Configuration
@EnableMethodSecurity
public class WebSecurityConfig {
}

关于资源服务器,这个解决方案也适用于响应式应用。
客户端还可以选择性地支持多租户:允许用户同时登录多个OpenID提供者,在这些提供者上可能有不同的用户名(默认情况下是subject,在Keycloak中是一个UUID,并且每个领域都会发生变化)。
2.2. 只使用spring-boot-starter-oauth2-client
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
    <!-- used when converting Keycloak roles to Spring authorities -->
    <groupId>com.jayway.jsonpath</groupId>
    <artifactId>json-path</artifactId>
</dependency>

issuer: http://localhost:8442/realms/master
client-id: spring-addons-confidential
client-secret: change-me

spring:
  security:
    oauth2:
      client:
        provider:
          keycloak:
            issuer-uri: ${issuer}
        registration:
          keycloak-login:
            authorization-grant-type: authorization_code
            client-name: My Keycloak instance
            client-id: ${client-id}
            client-secret: ${client-secret}
            provider: keycloak
            scope: openid,profile,email,offline_access

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class WebSecurityConfig {

    @Bean
    SecurityFilterChain
            clientSecurityFilterChain(HttpSecurity http, InMemoryClientRegistrationRepository clientRegistrationRepository)
                    throws Exception {
        http.oauth2Login(withDefaults());
        http.logout(logout -> {
            logout.logoutSuccessHandler(new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository));
        });
        // @formatter:off
        http.authorizeHttpRequests(ex -> ex
                .requestMatchers("/", "/login/**", "/oauth2/**").permitAll()
                .requestMatchers("/nice.html").hasAuthority("NICE")
                .anyRequest().authenticated());
        // @formatter:on
        return http.build();
    }

    @Component
    @RequiredArgsConstructor
    static class GrantedAuthoritiesMapperImpl implements GrantedAuthoritiesMapper {

        @Override
        public Collection<? extends GrantedAuthority> mapAuthorities(Collection<? extends GrantedAuthority> authorities) {
            Set<GrantedAuthority> mappedAuthorities = new HashSet<>();

            authorities.forEach(authority -> {
                if (OidcUserAuthority.class.isInstance(authority)) {
                    final var oidcUserAuthority = (OidcUserAuthority) authority;
                    final var issuer = oidcUserAuthority.getIdToken().getClaimAsURL(JwtClaimNames.ISS);
                    mappedAuthorities.addAll(extractAuthorities(oidcUserAuthority.getIdToken().getClaims()));

                } else if (OAuth2UserAuthority.class.isInstance(authority)) {
                    try {
                        final var oauth2UserAuthority = (OAuth2UserAuthority) authority;
                        final var userAttributes = oauth2UserAuthority.getAttributes();
                        final var issuer = new URL(userAttributes.get(JwtClaimNames.ISS).toString());
                        mappedAuthorities.addAll(extractAuthorities(userAttributes));

                    } catch (MalformedURLException e) {
                        throw new RuntimeException(e);
                    }
                }
            });

            return mappedAuthorities;
        };

        @SuppressWarnings({ "rawtypes", "unchecked" })
        private static Collection<GrantedAuthority> extractAuthorities(Map<String, Object> claims) {
            /* See resource server solution above for authorities mapping */
        }
    }
}

3. {{code>spring-addons-starter-oidc}}是什么,为什么要使用它

这个starter是一个标准的Spring Boot starter,它附带了额外的应用程序属性,用于自动配置默认的beans并提供给Spring Security。 需要注意的是,自动配置的@Beans几乎都是@ConditionalOnMissingBean,这使得您可以在您的配置中覆盖它

它是开源的,您可以更改它为您预配置的所有内容(请参阅Javadoc、starter的README或者众多示例)。 在决定不信任它之前,您应该阅读starter的源代码,它并不是很大。从imports资源开始,它定义了Spring Boot用于自动配置的加载内容。

在我看来(并且如上所示),Spring Boot对于OAuth2的自动配置可以进一步推进,以实现以下目标:
- 使OAuth2配置更具可移植性:通过可配置的权限转换器,从一个OIDC提供者切换到另一个只需要编辑属性即可(例如Keycloak、Auth0、Cognito、Azure AD等)。 - 简化在不同环境中的应用部署:CORS配置可以通过属性文件进行控制。 - 大幅减少Java代码的量(如果处于多租户场景中,情况会更加复杂)。 - 默认支持多个发行者。 - 减少配置错误的机会(例如,经常看到在客户端上禁用CSRF保护,或者在使用访问令牌保护的端点上浪费资源的示例配置)。

2
谢谢。看起来非常漂亮,而且易于迁移。我会尝试这种方法。 - Samuel
如果我们有一个OAuth2客户端,是否可以通过Postman访问我们的端点? 当我在浏览器中访问端点时,我没有任何问题,它会重定向到登录Keycloak页面。但是即使我在Authorization头中发送访问令牌,在Postman中也只会收到登录页面作为响应。 - Octavia
2
太棒了。感谢您对Spring Boot 3.1/Security 6.1的跟进。 - Samuel
对于你的"2.2.",你能否也加上导入部分?我已经添加了给定的依赖,但仍然找不到大部分这些类。我正在使用Springboot 3.1.5。所以不确定为什么你没有包含这段代码的导入部分。 - undefined
在我的代码库中,有一些包含导入和完整pom.xml的示例和教程项目。在这个回答中,与2.2最接近的教程是名为servlet-client的教程。你可以在这里找到这些项目和教程。 - undefined
显示剩余36条评论

5

使用标准的Spring Security OAuth2客户端代替特定的Keycloak适配器,使用SecurityFilterChain代替WebSecurityAdapter

像这样:

@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()
}

然后在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

感谢您发布您的配置。我会尝试那种方式。Keycloak已经宣布放弃他们自己的适配器。 - Samuel
角色映射缺失,这将成为一个问题,一旦需要任何基于角色的访问控制(@PreAuthorise表达式或安全配置中的authoriseRequests)。此外,这仅涵盖部分情况:资源服务器(REST API)未被覆盖。 - ch4mp
您在代码中提到了一个读取CustomBearerJwtAuthenticationConverter()的方法。我从哪里获取该方法? - Samuel
只需实现Spring的接口即可。你可以从我为资源服务器提供的身份验证转换器答案中获得灵感。 - ch4mp

2
使用Keycloak适配器不可能,因为KeycloakWebSecurityConfigurerAdapter继承自WebSecurityConfigurerAdapter类,在Spring Security中被弃用并随后在新版本中被删除。
我在Medium上发布了一篇详细的文章,介绍了如何在Spring Boot 3.0中集成Keycloak,并提供了逐步指南。这篇指南特别适合那些刚开始将Keycloak与Spring Boot 3.0集成或从旧版本迁移到Spring Boot 3.0的人。
您可以查看该文章(https://medium.com/geekculture/using-keycloak-with-spring-boot-3-0-376fa9f60e0b)以获得对整个集成过程的全面解释。 enter image description here 希望这有所帮助!如果您有任何问题、进一步澄清或建议,请随时留下评论。

1
你意识到你的文章只涵盖了资源服务器和“realm”角色,对吧?(而上面的回答则涵盖了客户端和客户端角色...) - ch4mp
你好Yasas,感谢你提供的有用教程。我已经按照你在教程中提到的代码进行了实现,但是当我想要访问用户URL时仍然出现未经授权的访问错误。我已经仔细检查过,确保将用户角色分配给了该用户。 - Ghassen Jemaî
更新,我的问题已经通过配置application.properties文件中的keycloak领域设置得到解决。 - Ghassen Jemaî
嗨@GhassenJemaî,感谢您的反馈,很高兴您能解决这个问题。 - Yasas Sandeepa
我也遵循了Yasa的代码。除了将执行器端点与角色匹配之外,其他都正常工作。 - undefined
显示剩余2条评论

0

Keycloak 适配器已被弃用,并且不会有任何未来的更新或修复,正如 Keycloak 团队 所宣布的。

建议使用 Spring Security 提供的 OAuth2 和 OpenID Connect 支持。


-1

Keycloak 21.0.0 推出了一些新的变化来支持 Spring Security 6.x.x 和 Spring Boot 3.x.x。这里是一个 参考链接


2
文档已过时,因为它扩展自WebSecurityConfigurerAdapter,您仍然可以使用Spring Security的oauth2客户端依赖项。 - Ghassen Jemaî
这还不是同样的吗? - Samuel
是的,文档基本上已经过时了,因为它们实现了WebSecurityConfigurationAdapter。 - Ghassen Jemaî

-1

根据不同的资源和整个周末用于解决这个新问题,我成功找到了完美运行的解决方案。

我在客户端级别(而非领域)定义了两个角色:用户和管理员,并分配给不同的用户。

  • JDK 17
  • Keycloak 22.0.0.
  • Spring Boot 3.1.1

以下是可工作的解决方案:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class OAuth2ResourceServerSecurityConfiguration {
    @Value("${keycloak.resource}")
    private String keycloakClientName;
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
        .authorizeHttpRequests((authorize) -> {
            authorize
            .anyRequest().authenticated();
        })
        .oauth2ResourceServer(httpSecurityOAuth2ResourceServerConfigurer -> httpSecurityOAuth2ResourceServerConfigurer
            .jwt(jwtConfigurer -> {
                jwtConfigurer.jwtAuthenticationConverter(jwtAuthenticationConverter());
            })
        );

        return httpSecurity.build();
    }
    private Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter() {
        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(new KeycloakRealmRoleConverter());

        return jwtAuthenticationConverter;
    }

    private class KeycloakRealmRoleConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
        private Collection<GrantedAuthority> grantedAuthorities = new ArrayList<>();
        @Override
        public Collection<GrantedAuthority> convert(Jwt jwt) {
            final Map<String, Object> resourceAccess = (Map<String, Object>) jwt.getClaims().get("resource_access");

            if (resourceAccess != null) {
                final Map<String, Object> clientAccess = (Map<String, Object>) resourceAccess.get(OAuth2ResourceServerSecurityConfiguration.this.keycloakClientName);

                if (clientAccess != null) {
                    grantedAuthorities = ((List<String>) clientAccess.get("roles")).stream()
                            .map(roleName -> "ROLE_" + roleName) // Prefix to map to a Spring Security "role"
                            .map(SimpleGrantedAuthority::new)
                            .collect(Collectors.toList());
                }
            }

            return grantedAuthorities;
        }
    }
}

在属性中的Keycloak配置:

keycloak:
  authServerUrl: http://<your_keycloak_host>:8989
  realm: <your_realm>
  resource: <your_client>
  useResourceRoleMappings: true
  cors: true
  corsMaxAge: 1000
  corsAllowedMethods: POST, PUT, DELETE, GET
  sslRequired: none
  bearerOnly: true
  publicClient: true
  principalAttribute: preferred_username
  credentials:
    secret: '{cipher}<your_encrypted_secret>'

测试控制器:

@RestController
@RequestMapping("/api/v1/test")
public class TestController {
    @GetMapping("/")
    public String allAccess() {
        return "Public content";
    }

    @GetMapping("/endpoint1")
    @PreAuthorize("hasRole('user')")
    public String endpoint1() {
        return "User board";
    }

    @GetMapping("/endpoint2")
    @PreAuthorize("hasRole('administrator')")
    public String endpoint2() {
        return "Administrator board";
    }
}

2
这基本上只是接受答案的一部分(资源服务器部分),大多数keycloak属性都没有被使用(只有keycloak.resource被使用),而且你的代码似乎需要spring.security.oauth2.resourceserver,但在你的代码片段中缺失了。此外,在资源服务器的过滤器链中,你应该禁用会话(和CSRF保护),并确保未经授权的请求返回401状态码。简而言之,如果你读了答案(也许还跟着链接走了一遭),你就能节省一个周末。 - ch4mp

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接