将 Keycloak Spring 适配器与 Spring Boot 3 结合使用

Sam*_*uel 28 spring-boot keycloak

我在一个使用 Keycloak Spring Adapter 的项目中更新到了 Spring Boot 3。不幸的是,它没有启动,因为它 KeycloakWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter首先在 Spring Security 中被弃用,然后被删除。目前是否有另一种方法可以使用 Keycloak 实现安全性?或者换句话说:如何将 Spring Boot 3 与 Keycloak 适配器结合使用?

我在互联网上搜索,但找不到任何其他版本的适配器。

ch4*_*4mp 64

由于您发现的原因以及其他一些与传递依赖项相关的原因,您无法将 Keycloak 适配器与 spring-boot 3 一起使用。由于大多数 Keycloak 适配器已于 2022 年初被弃用,因此很可能不会发布任何更新来解决该问题。

相反,对 OAuth2 使用 spring-security 6 库不要惊慌,使用 spring-boot 这很容易完成

在下文中,我将认为您对 OAuth2 概念有很好的理解,并且确切地知道为什么需要配置 OAuth2 客户端或 OAuth2 资源服务器。如有疑问,请参阅我的教程的OAuth2 要点部分

我在这里只会详细介绍servlet应用程序作为资源服务器的配置,然后作为客户端,对于单个 Keycloak 领域,使用和不使用spring-addons-starter-oidc我的 Spring Boot 启动器。直接浏览到您感兴趣的部分(但如果您不想使用“我的”启动器,请准备好编写更多代码)。

另请参阅我的教程以了解不同的用例,例如:

  • 接受多个领域或实例颁发的令牌(提前已知或在受信任域中动态创建)
  • 反应式应用程序(webflux),spring-cloud-gateway例如
  • 应用程序公开提供 REST API 和服务器端呈现的 UI 来使用它
  • 高级访问控制规则
  • 最好的朋友模式
  • ...

1.OAuth2资源服务器

应用程序公开使用访问令牌保护的 REST API。它由 OAuth2 REST 客户端使用。此类客户的一些示例:

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.5.3</version>
</dependency>
Run Code Online (Sandbox Code Playgroud)
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: /my-resources/**
            allowed-origin-patterns: ${origins}
          permit-all: 
          - "/actuator/health/readiness"
          - "/actuator/health/liveness"
          - "/v3/api-docs/**"
Run Code Online (Sandbox Code Playgroud)

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

@Configuration
@EnableMethodSecurity
public static class WebSecurityConfig { }
Run Code Online (Sandbox Code Playgroud)

无需再配置具有微调的 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>
Run Code Online (Sandbox Code Playgroud)
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8442/realms/master
Run Code Online (Sandbox Code Playgroud)
@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("/my-resources/**", 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);
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

除了比前一种解决方案更加冗长之外,该解决方案也不太灵活:

  • 不适应多租户(多个Keycloak领域或实例)
  • 硬编码允许的来源
  • 硬编码的声明名称以从中获取权限
  • 硬编码的“permitAll”路径匹配器

2.OAuth2客户端

应用程序公开使用会话(不是访问令牌)保护的任何类型的资源。它由浏览器(或任何其他能够维护会话的用户代理)直接使用,无需脚本语言或 OAuth2 客户端库(授权代码流、注销和令牌存储由服务器上的 Spring 处理)。常见用例有:

  • 具有服务器端渲染 UI 的应用程序(使用 Thymeleaf、JSF 或其他)
  • spring-cloud-gateway用作后端F前端:配置oauth2Login有过滤器(在将请求转发到下游资源服务器之前,从浏览器隐藏 OAuth2 令牌并用TokenRelay访问令牌替换会话 cookie)。

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.5.3</version>
</dependency>
Run Code Online (Sandbox Code Playgroud)
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:
            provider: keycloak
            authorization-grant-type: authorization_code
            client-id: ${client-id}
            client-secret: ${client-secret}
            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: /
Run Code Online (Sandbox Code Playgroud)
@Configuration
@EnableMethodSecurity
public class WebSecurityConfig {
}
Run Code Online (Sandbox Code Playgroud)

至于资源服务器,该解决方案也适用于反应式应用程序。

客户端上还有一个可选的多租户支持:允许用户同时登录多个 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>
Run Code Online (Sandbox Code Playgroud)
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:
            provider: keycloak
            authorization-grant-type: authorization_code
            client-id: ${client-id}
            client-secret: ${client-secret}
            scope: openid,profile,email,offline_access
Run Code Online (Sandbox Code Playgroud)
@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 */
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

3. 是什么spring-addons-starter-oidc以及为什么使用它

该启动器是一个标准的Spring Boot 启动器,具有附加的应用程序属性,用于自动配置默认 bean 并将其提供给 Spring Security。需要注意的是,@Beans几乎所有自动配置都@ConditionalOnMissingBean使您能够在 conf 中覆盖它

它是开源的,您可以更改它为您预先配置的所有内容(请参阅 Javadoc、入门自述文件或许多示例)。在决定不信任它之前,您应该阅读初学者的源代码,它并没有那么大。从importsresource开始,它定义了Spring Boot加载的内容以进行自动配置。

在我看来(正如上面所演示的),OAuth2 的 Spring Boot 自动配置可以进一步推进到:

  • 使 OAuth2 配置更加便携:使用可配置的权限转换器,从 OIDC 提供程序切换到另一个提供程序只需编辑属性(Keycloak、Auth0、Cognito、Azure AD 等)
  • 简化不同环境中的应用程序部署:CORS 配置由属性文件控制
  • 大幅减少 Java 代码量(如果处于多租户场景,事情会变得更加复杂)
  • 默认支持多个发行人
  • 减少错误配置的可能性(例如,经常会看到客户端上禁用 CSRF 保护的示例配置,或者在使用访问令牌保护的端点上浪费资源)

  • 谢谢。这看起来相当丰富并且易于迁移。将尝试这种方法。 (2认同)
  • 惊人的。感谢您对 Spring Boot 3.1/Security 6.1 的跟进。 (2认同)

lat*_*ell 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()
}
Run Code Online (Sandbox Code Playgroud)

然后在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
Run Code Online (Sandbox Code Playgroud)