在 Spring Boot 中嵌入 Keycloak 服务器

1、概览

Keycloak 是由 Red Hat 管理和在 Java 中由 JBoss 开发的开源身份和访问管理解决方案。

本文将带你了解如何在 在 Spring Boot 中嵌入 Keycloak 服务器,这样就能轻松启动预配置的 Keycloak 服务器。

Keycloak 也可以作为 独立服务器 运行,但需要下载并通过管理控制台进行设置。

2、Keycloak 预配置

服务器包含一组 Realm,每个 Realm 都是用户管理的独立单元。要对其进行预配置,我们需要指定一个 JSON 格式的 Realm 定义文件。

使用 Keycloak Admin 控制台 配置的所有内容都以 JSON 格式进行持久化。

我们的授权服务器将使用名为 baeldung-realm.json 的 JSON 文件进行预配置。文件中的几个相关配置如下:

  • users:默认用户是 john@test.commike@other.com;对应的凭证也在这里。
  • clients:定义一个 ID 为 newClient 的客户端
  • standardFlowEnabled:设置为 true,激活 newClient 的授权码(Authorization Code)授权模式。
  • redirectUrisnewClient 在成功验证后将重定向到的服务器 URL
  • webOrigins:置为 +,为所有 redirectUris 的 URL 提供 CORS 支持

Keycloak 服务器会默认签发 JWT Token,因此无需为此进行单独配置。接下来看看 Maven 的配置。

3、Maven 配置

由于在 Spring Boot 中使用的是嵌入式 Keycloak,因此无需单独下载它。

添加如下依赖:

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

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

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

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>
       

注意,本例使用的是 Spring Boot 3.1.1 版本。添加了 spring-boot-starter-data-jpaH2 持久层依赖。其他 springframework.boot 依赖用于 Web 支持,因为还需要将 Keycloak 授权服务器和管理控制台作为 Web 服务运行。

还要为 KeycloakRESTEasy 添加以下依赖:

<dependency>
    <groupId>org.jboss.resteasy</groupId>
    <artifactId>resteasy-jackson2-provider</artifactId>
    <version>6.2.4.Final</version>
</dependency>

<dependency>
    <groupId>org.keycloak</groupId>
    <artifactId>keycloak-dependencies-server-all</artifactId>
    <version>22.0.0</version>
    <type>pom</type>
</dependency> 

<dependency>
    <groupId>org.keycloak</groupId>
    <artifactId>keycloak-crypto-default</artifactId>
    <version>22.0.0</version>
</dependency>

<dependency>
    <groupId>org.keycloak</groupId>
    <artifactId>keycloak-admin-ui</artifactId>
    <version>22.0.0</version>
</dependency>

<dependency>
    <groupId>org.keycloak</groupId>
    <artifactId>keycloak-services</artifactId>
    <version>22.0.0</version>
</dependency>
       
<dependency>
    <groupId>org.keycloak</groupId>
    <artifactId>keycloak-rest-admin-ui-ext</artifactId>
    <version>22.0.0</version>
</dependency>

你可以从 Maven 仓库获取最新版本的 KeycloakRESTEasy

4、嵌入式 Keycloak 配置

为授权服务器定义 Spring 配置:

@Configuration
public class EmbeddedKeycloakConfig {

    @Bean
    ServletRegistrationBean keycloakJaxRsApplication(
      KeycloakServerProperties keycloakServerProperties, DataSource dataSource) throws Exception {
        
        mockJndiEnvironment(dataSource);
        EmbeddedKeycloakApplication.keycloakServerProperties = keycloakServerProperties;
        ServletRegistrationBean servlet = new ServletRegistrationBean<>(
          new HttpServlet30Dispatcher());
        servlet.addInitParameter("jakarta.ws.rs.Application", 
          EmbeddedKeycloakApplication.class.getName());
        servlet.addInitParameter(ResteasyContextParameters.RESTEASY_SERVLET_MAPPING_PREFIX,
          keycloakServerProperties.getContextPath());
        servlet.addInitParameter(ResteasyContextParameters.RESTEASY_USE_CONTAINER_FORM_PARAMS, 
          "true");
        servlet.addUrlMappings(keycloakServerProperties.getContextPath() + "/*");
        servlet.setLoadOnStartup(1);
        servlet.setAsyncSupported(true);
        return servlet;
    }

    @Bean
    FilterRegistrationBean keycloakSessionManagement(
      KeycloakServerProperties keycloakServerProperties) {
        FilterRegistrationBean filter = new FilterRegistrationBean<>();
 filter.setName("Keycloak Session Management");
 filter.setFilter(new EmbeddedKeycloakRequestFilter());
 filter.addUrlPatterns(keycloakServerProperties.getContextPath() + "/*");

 return filter;
    }

    private void mockJndiEnvironment(DataSource dataSource) throws NamingException {   
        NamingManager.setInitialContextFactoryBuilder(
          (env) -> (environment) -> new InitialContext() {
            @Override
            public Object lookup(Name name) {
                return lookup(name.toString());
            }
 
            @Override
            public Object lookup(String name) {
                if ("spring/datasource".equals(name)) {
                    return dataSource;
                } else if (name.startsWith("java:jboss/ee/concurrency/executor/")) {
                    return fixedThreadPool();
                }
                return null;
            }

            @Override
            public NameParser getNameParser(String name) {
                return CompositeName::new;
            }

            @Override
            public void close() {
            }
        });
    }
     
    @Bean("fixedThreadPool")
    public ExecutorService fixedThreadPool() {
        return Executors.newFixedThreadPool(5);
    }
     
    @Bean
    @ConditionalOnMissingBean(name = "springBootPlatform")
    protected SimplePlatformProvider springBootPlatform() {
        return (SimplePlatformProvider) Platform.getPlatform();
    }
}

注意:先不用管编译错误,稍后会定义 EmbeddedKeycloakRequestFilter 类。

如上的,首先将 Keycloak 配置为 JAX-RS 应用,并使用 KeycloakServerProperties 来持久存储 Realm 定义文件中指定的 Keycloak 属性。然后,添加了一个会话管理过滤器(Session Management Filter),并模拟了一个 JNDI 环境,以使用 spring/datasource 也就是我们的存 H2 内数据库。

5、KeycloakServerProperties

现在来看看上节提到的 KeycloakServerProperties

@ConfigurationProperties(prefix = "keycloak.server")
public class KeycloakServerProperties {
    String contextPath = "/auth";
    String realmImportFile = "baeldung-realm.json";
    AdminUser adminUser = new AdminUser();

    //get、set 省略

    public static class AdminUser {
        String username = "admin";
        String password = "admin";

        // get、set  省略
    }
}

如你所见,这是一个简单的 POJO,用于设置 contextPathadminUserrealm 定义文件。

6、EmbeddedKeycloakApplication

接着,来看看如下配置类,它使用之前设置的配置来创建 Realm。

public class EmbeddedKeycloakApplication extends KeycloakApplication {
    private static final Logger LOG = LoggerFactory.getLogger(EmbeddedKeycloakApplication.class);
    static KeycloakServerProperties keycloakServerProperties;

    protected void loadConfig() {
        JsonConfigProviderFactory factory = new RegularJsonConfigProviderFactory();
        Config.init(factory.create()
          .orElseThrow(() -> new NoSuchElementException("No value present")));
    }
     
    @Override
    protected ExportImportManager bootstrap() {
        final ExportImportManager exportImportManager = super.bootstrap();
        createMasterRealmAdminUser();
        createBaeldungRealm();
        return exportImportManager;
    }

    private void createMasterRealmAdminUser() {
        KeycloakSession session = getSessionFactory().create();
        ApplianceBootstrap applianceBootstrap = new ApplianceBootstrap(session);
        AdminUser admin = keycloakServerProperties.getAdminUser();
        try {
            session.getTransactionManager().begin();
            applianceBootstrap.createMasterRealmUser(admin.getUsername(), admin.getPassword());
            session.getTransactionManager().commit();
        } catch (Exception ex) {
            LOG.warn("Couldn't create keycloak master admin user: {}", ex.getMessage());
            session.getTransactionManager().rollback();
        }
        session.close();
    }

    private void createBaeldungRealm() {
        KeycloakSession session = getSessionFactory().create();
        try {
            session.getTransactionManager().begin();
            RealmManager manager = new RealmManager(session);
            Resource lessonRealmImportFile = new ClassPathResource(
              keycloakServerProperties.getRealmImportFile());
            manager.importRealm(JsonSerialization.readValue(lessonRealmImportFile.getInputStream(),
              RealmRepresentation.class));
            session.getTransactionManager().commit();
        } catch (Exception ex) {
            LOG.warn("Failed to import Realm json file: {}", ex.getMessage());
            session.getTransactionManager().rollback();
        }
        session.close();
    }
}

7、自定义平台实现

如前所述,Keycloak 由 RedHat/JBoss 开发。因此,它提供了在 Wildfly 服务器上部署应用或作为 Quarkus 解决方案的功能和扩展库。

在本例中,我们放弃这些替代方案,因此,我们必须为一些特定平台的接口和类提供自定义实现。

例如,在刚刚配置的 EmbeddedKeycloakApplication 中,首先加载了 Keycloak 的服务器配置 keycloak-server.json,使用了一个空实现的 JsonConfigProviderFactory(抽象类)子类:

public class RegularJsonConfigProviderFactory extends JsonConfigProviderFactory { }

然后,继承了 KeycloakApplication,创建了两个 Realm:masterbaeldung。它们是根据 Realm 定义文件 baeldung-realm.json 中指定的属性创建的。

如你所见,使用 KeycloakSession 来执行所有事务,为使其正常工作,必须创建一个自定义 AbstractRequestFilterEmbeddedKeycloakRequestFilter),并在 EmbeddedKeycloakConfig 中使用 KeycloakSessionServletFilter 为其设置一个 Bean。

此外,还需要几个自定义 Provider,这样我们就能拥有自己的 org.keycloak.common.util.ResteasyProviderorg.keycloak.platform.PlatformProvider 实现,而无需依赖外部依赖。

注意,这些自定义 Provider 的信息需要包含在项目的 META-INF/services 文件夹中,以便在运行时获取。

8、把一切整合起来

Keycloak 大大简化了应用端所需的配置。无需以编程方式定义数据源或任何 Security 配置。

我们需要通过 Spring 和 Spring Boot 应用的配置,来把这一切整合起来。

8.1、application.yml

使用 YAML 来进行 Spring 配置:

server:
  port: 8083

spring:
  datasource:
    username: sa
    url: jdbc:h2:mem:testdb;DB_CLOSE_ON_EXIT=FALSE

keycloak:
  server:
    contextPath: /auth
    adminUser:
      username: bael-admin
      password: ********
    realmImportFile: baeldung-realm.json

8.2、Spring Boot 应用

最后,是 Spring Boot 应用:

@SpringBootApplication(exclude = LiquibaseAutoConfiguration.class)
@EnableConfigurationProperties(KeycloakServerProperties.class)
public class AuthorizationServerApp {
    private static final Logger LOG = LoggerFactory.getLogger(AuthorizationServerApp.class);
    
    public static void main(String[] args) throws Exception {
        SpringApplication.run(AuthorizationServerApp.class, args);
    }

    @Bean
    ApplicationListener<ApplicationReadyEvent> onApplicationReadyEventListener(
      ServerProperties serverProperties, KeycloakServerProperties keycloakServerProperties) {
        return (evt) -> {
            Integer port = serverProperties.getPort();
            String keycloakContextPath = keycloakServerProperties.getContextPath();
            LOG.info("Embedded Keycloak started: http://localhost:{}{} to use keycloak", 
              port, keycloakContextPath);
        };
    }
}

注意,这里启用了 KeycloakServerProperties 配置,以便将其注入 ApplicationListener Bean。

运行该类后,就可以访问授权服务器的欢迎页面 http://localhost:8083/auth/

8.3、可执行 JAR

还可以创建一个可执行 jar 文件来打包和运行应用:

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <mainClass>com.baeldung.auth.AuthorizationServerApp</mainClass>
        <requiresUnpack>
            <dependency>
                <groupId>org.keycloak</groupId>
                <artifactId>keycloak-model-jpa</artifactId>
            </dependency>
        </requiresUnpack>
    </configuration>
</plugin>

配置如上,指定了 main 类,还指示 Maven 解压缩一些 Keycloak 依赖。这将在运行时解压 Fat Jar 中的库,现在可以使用标准的 java -jar <artifact-name> 命令运行应用了。

9、总结

本文介绍了如何在 Spring Boot 中嵌入 Keycloak 服务器,此实现的最初想法由 Thomas Darimont 提出,可在 embedded-spring-boot-keycloak-server 项目中找到。


Ref:https://www.baeldung.com/keycloak-embedded-in-spring-boot-app