1. 概述

Keycloak是一款开源的身份认证和控制访问解决方案。由Red Hat主导,使用Java和JBoss开发。

本教程中,我们将学习如何在Spring Boot中搭建一个嵌入式的Keycloak服务器

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

2. Keycloak 预配置

首先,让我们了解如何预配置Keycloak服务器。

Keycloak包含了一组realm(领域),realm为用户管理的隔离单元(域管理着用户角色,会话,组等信息,一个用户只能属于并且能登陆到一个域,域之间是互相独立隔离的)。要预配置它,我们需要指定一个JSON格式的realm定义文件。

所有可以通过Keycloak管理控制台 配置的信息,都保存在该JSON文件中

我们的授权服务器预配置将保存到baeldung-realm.json文件中。让我们看一下文件中的一些相关配置:

  • users: 我们有2个默认的用户,john@test.commike@other.com
  • clients: 需要接入Keycloak并被Keycloak保护的应用。我将定义一个id为newClient的客户端
  • standardFlowEnabled: 设为true启用授权码模式
  • redirectUris: 认证成功后需要重定向的URL
  • webOrigins: 设置为 “+”redirectUris中所有URL提供CORS支持

Keycloak默认颁发JWT tokens,所以这里不需要单独配置。下面看下Maven配置。

3. Maven 配置

因为我们将Keycloak嵌入到Spring Boot应用中了,所以无需单独下载它。

我们需要加入下面依赖:

<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-data-jpa</artifactId>
</dependency>

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

这里我们使用的Spring Boot版本为2.2.6.RELEASE。spring-boot-starter-data-jpaH2是我们的数据库层依赖,其他springframework.boot依赖用于Web支持,因为我们还需要能够将Keycloak授权服务器以及管理控制台作为Web服务运行。

我们还需要为Keycloak和RESTEasy添加依赖:

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

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

获取最新版本请访问KeycloakRESTEasy的Maven仓库。

最后,我们需要重写<infinispan.version>属性,以使用Keycloak声明的版本而不是Spring Boot定义的版本:

<properties>
    <infinispan.version>10.1.8.Final</infinispan.version>
</properties>

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("javax.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;
                }
                return null;
            }

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

            @Override
            public void close() {
            }
        });
    }
}

备注:不用担心编译错误,我们稍后将定义EmbeddedKeycloakRequestFilter类。

可以看到,我们首先将Keycloak配置为具有KeycloakServerProperties的JAX-RS应用程序,用于持久化realm定义文件中指定的Keycloak属性。然后,我们添加了一个Session管理过滤器,并模拟了一个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();

    // getters and setters

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

        // getters and setters        
    }
}

可以看到, 这是一个简单的普通类(POJO),用于设置contextPath, adminUser 和 realm 配置文件名

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")));
    }
    public EmbeddedKeycloakApplication() {
        createMasterRealmAdminUser();
        createBaeldungRealm();
    }

    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解决方案。

In this case, we're moving away from those alternatives, and as a consequence, we have to provide custom implementations for some platform-specific interfaces and classes.

For example, in the EmbeddedKeycloakApplication we just configured we first loaded Keycloak's server configuration keycloak-server.json, using an empty subclass of the abstract JsonConfigProviderFactory:

public class RegularJsonConfigProviderFactory extends JsonConfigProviderFactory { }

Then, we extended KeycloakApplication to create two realms: master and baeldung. These are created as per the properties specified in our realm definition file, baeldung-realm.json.

As you can see, we use a KeycloakSession to perform all the transactions, and for this to work properly, we had to create a custom AbstractRequestFilter (EmbeddedKeycloakRequestFilter) and set up a bean for this using a KeycloakSessionServletFilter in the EmbeddedKeycloakConfig file.

Additionally, we need a couple of custom providers so that we have our own implementations of org.keycloak.common.util.ResteasyProvider and org.keycloak.platform.PlatformProvider and do not rely on external dependencies.

Importantly, information about these custom providers should be included in the project's META-INF/services folder so that they are picked up at runtime.

8. 全部整合到一起

As we saw, Keycloak has much simplified the required configurations from the application side. There is no need to programmatically define the datasource or any security configurations.

To bring it all together, we need to define the configuration for Spring and a Spring Boot Application.

8.1. application.yml

We'll be using a simple YAML for the Spring configurations:

server:
  port: 8083

spring:
  datasource:
    username: sa
    url: jdbc:h2:mem:testdb

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

8.2. Spring Boot 应用

Lastly, here's the Spring Boot Application:

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

Notably, here we have enabled the KeycloakServerProperties configuration to inject it into the ApplicationListener bean.

After running this class, we can access the authorization server's welcome page at http://localhost:8083/auth/.

9. 总结

In this quick tutorial, we saw how to setup a Keycloak server embedded in a Spring Boot application. The source code for this application is available over on GitHub.

The original idea for this implementation was developed by Thomas Darimont and can be found in the project embedded-spring-boot-keycloak-server.