1. 简介

在本教程中,我们将介绍如何为 Keycloak 添加一个自定义的用户存储提供者(Custom User Provider),以便与现有的或非标准的用户存储系统集成。Keycloak 是一款开源的身份和访问管理解决方案,虽然其内置功能已经非常强大,但在某些场景下,比如需要对接遗留系统的用户数据时,仍需要我们扩展其能力。

2. Keycloak 自定义提供者概览

Keycloak 原生支持基于 SAML、OpenID Connect 和 OAuth2 等协议的标准集成方式。但面对复杂或非标准的用户存储系统,我们需要借助 自定义提供者(Custom Provider) 来实现灵活对接。

2.1. 自定义提供者的部署与发现机制

✅ 最简单的自定义提供者就是一个标准的 JAR 包,里面包含一个或多个服务实现类。

Keycloak 启动时会通过标准的 java.util.ServiceLoader 机制扫描 classpath,自动加载所有可用的提供者。我们只需要在 JAR 的 META-INF/services 目录下创建一个以 SPI 接口全限定名为文件名的文件,并在其中写入实现类的全限定名即可。

例如,在 Keycloak 的管理控制台中访问 Server Info 页面,可以看到当前已加载的所有 SPI 及其提供者:

keycloak server info providers 1

左列是 SPI 接口名称,右列为当前已注册的实现。

2.2. 常见 SPI 列表

Keycloak 提供了多个 SPI,其中一些常用的包括:

  • org.keycloak.authentication.AuthenticatorFactory:用于定义用户或客户端的认证流程
  • org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory:用于自定义 Keycloak 在访问 /auth/realms/master/login-actions/action-token 时执行的动作,比如密码重置
  • org.keycloak.events.EventListenerProviderFactory:监听 Keycloak 的事件,可用于审计日志
  • org.keycloak.adapters.saml.RoleMappingsProvider:将外部 SAML 角色映射为 Keycloak 内部角色
  • org.keycloak.storage.UserStorageProviderFactory:用于接入自定义用户存储系统(本教程重点)
  • org.keycloak.vault.VaultProviderFactory:用于自定义密钥存储,比如数据库密码、加密密钥等

⚠️ 以上只是部分 SPI,完整列表请参考 Keycloak 官方文档

3. 自定义提供者实现

我们将实现一个 只读 的用户存储提供者,其用户数据存储在一个简单的 SQL 表中:

create table if not exists users(
    username varchar(64) not null primary key,
    password varchar(64) not null,
    email varchar(128),
    firstName varchar(128) not null,
    lastName varchar(128) not null,
    birthDate DATE not null
);

✅ 该提供者只允许用户登录,不允许修改用户信息或密码。这并非 Keycloak 的限制,而是我们在实现中选择的策略。

3.1. 项目结构与依赖配置

我们使用 Maven 构建项目,打包为标准的 JAR 文件。

为了提升开发效率,避免频繁重启 Keycloak 实例,我们采用嵌入式 Keycloak 的方式来进行测试:

<dependency>
    <groupId>org.keycloak</groupId>
    <artifactId>keycloak-model-legacy</artifactId>
    <version>22.0.0</version>
</dependency>
<dependency>
    <groupId>com.baeldung</groupId>
    <artifactId>oauth-authorization-server</artifactId>
    <version>0.1.0-SNAPSHOT</version>
    <scope>test</scope>
</dependency>

⚠️ oauth-authorization-server 需要从 Baeldung GitHub 仓库 构建。

3.2. 实现 UserStorageProviderFactory

UserStorageProviderFactory 是创建用户存储提供者的工厂类,我们只需实现两个核心方法:

  • getId():返回提供者的唯一标识符
  • create():返回实际的提供者实例
public class CustomUserStorageProviderFactory
  implements UserStorageProviderFactory<CustomUserStorageProvider> {
    @Override
    public String getId() {
        return "custom-user-provider";
    }

    @Override
    public CustomUserStorageProvider create(KeycloakSession ksession, ComponentModel model) {
        return new CustomUserStorageProvider(ksession, model);
    }
}

✅ 创建 SPI 注册文件:在 src/main/resources/META-INF/services/ 下新建文件:

org.keycloak.storage.UserStorageProviderFactory

内容如下:

com.baeldung.auth.provider.user.CustomUserStorageProviderFactory

3.3. 实现 UserStorageProvider

UserStorageProvider 是一个标记接口,实际功能由多个混入接口(Capability Interface)提供。我们实现以下接口:

  • UserLookupProvider:支持通过用户名、邮箱等查找用户
  • CredentialInputValidator:验证用户凭证(如密码)
  • UserQueryProvider:支持用户搜索和分页查询
public class CustomUserStorageProvider implements UserStorageProvider, 
  UserLookupProvider,
  CredentialInputValidator, 
  UserQueryProvider {
  
    private KeycloakSession ksession;
    private ComponentModel model;

    public CustomUserStorageProvider(KeycloakSession ksession, ComponentModel model) {
        this.ksession = ksession;
        this.model = model;
    }

    // 实现各个接口的方法...
}

3.4. 实现 UserLookupProvider

通过用户名、邮箱或 ID 查询用户:

@Override
public UserModel getUserByUsername(String username, RealmModel realm) {
    try (Connection c = DbUtil.getConnection(this.model)) {
        PreparedStatement st = c.prepareStatement(
          "select username, firstName, lastName, email, birthDate from users where username = ?");
        st.setString(1, username);
        st.execute();
        ResultSet rs = st.getResultSet();
        if (rs.next()) {
            return mapUser(realm, rs);
        } else {
            return null;
        }
    } catch (SQLException ex) {
        throw new RuntimeException("Database error:" + ex.getMessage(), ex);
    }
}

其中,mapUser() 方法将数据库记录映射为 UserModel

private UserModel mapUser(RealmModel realm, ResultSet rs) throws SQLException {
    CustomUser user = new CustomUser.Builder(ksession, realm, model, rs.getString("username"))
      .email(rs.getString("email"))
      .firstName(rs.getString("firstName"))
      .lastName(rs.getString("lastName"))
      .birthDate(rs.getDate("birthDate"))
      .build();
    return user;
}

3.5. 获取数据库连接

通过 ComponentModel 获取数据库配置:

public class DbUtil {
    public static Connection getConnection(ComponentModel config) throws SQLException {
        String driverClass = config.get(CONFIG_KEY_JDBC_DRIVER);
        try {
            Class.forName(driverClass);
        } catch (ClassNotFoundException nfe) {
            // 错误处理...
        }
        return DriverManager.getConnection(
          config.get(CONFIG_KEY_JDBC_URL),
          config.get(CONFIG_KEY_DB_USERNAME),
          config.get(CONFIG_KEY_DB_PASSWORD));
    }
}

3.6. 配置元数据

通过 ProviderConfigurationBuilder 定义配置项:

configMetadata = ProviderConfigurationBuilder.create()
  .property()
    .name(CONFIG_KEY_JDBC_DRIVER)
    .label("JDBC Driver Class")
    .type(ProviderConfigProperty.STRING_TYPE)
    .defaultValue("org.h2.Driver")
    .helpText("Fully qualified class name of the JDBC driver")
    .add()
  // 其他配置项...
  .build();

✅ 在 validateConfiguration() 中验证配置是否有效:

@Override
public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config)
  throws ComponentValidationException {
   try (Connection c = DbUtil.getConnection(config)) {
       c.createStatement().execute(config.get(CONFIG_KEY_VALIDATION_QUERY));
   } catch (Exception ex) {
       throw new ComponentValidationException("Unable to validate database connection", ex);
   }
}

3.7. 实现 CredentialInputValidator

用于验证用户凭证(如密码):

@Override
public boolean supportsCredentialType(String credentialType) {
    return PasswordCredentialModel.TYPE.endsWith(credentialType);
}

@Override
public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
    return supportsCredentialType(credentialType);
}

@Override
public boolean isValid(RealmModel realm, UserModel user, CredentialInput credentialInput) {
    if (!this.supportsCredentialType(credentialInput.getType())) {
        return false;
    }
    StorageId sid = new StorageId(user.getId());
    String username = sid.getExternalId();

    try (Connection c = DbUtil.getConnection(this.model)) {
        PreparedStatement st = c.prepareStatement("select password from users where username = ?");
        st.setString(1, username);
        st.execute();
        ResultSet rs = st.getResultSet();
        if (rs.next()) {
            String pwd = rs.getString(1);
            return pwd.equals(credentialInput.getChallengeResponse());
        } else {
            return false;
        }
    } catch (SQLException ex) {
        throw new RuntimeException("Database error:" + ex.getMessage(), ex);
    }
}

3.8. 实现 UserQueryProvider

支持用户搜索和分页查询:

@Override
public Stream<UserModel> searchForUserStream(RealmModel realm, String search, Integer firstResult, Integer maxResults) {
    try (Connection c = DbUtil.getConnection(this.model)) {
        PreparedStatement st = c.prepareStatement(
          "select username, firstName, lastName, email, birthDate from users where username like ? order by username limit ? offset ?");
        st.setString(1, search);
        st.setInt(2, maxResults);
        st.setInt(3, firstResult);
        st.execute();
        ResultSet rs = st.getResultSet();
        List<UserModel> users = new ArrayList<>();
        while (rs.next()) {
            users.add(mapUser(realm, rs));
        }
        return users.stream();
    } catch (SQLException ex) {
        throw new RuntimeException("Database error:" + ex.getMessage(), ex);
    }
}

4. 测试

✅ 启动嵌入式 Keycloak 实例后,访问管理控制台:

keycloak server info providers pic3

✅ 查看 Server Info > Providers 页面,确认自定义提供者已加载:

keycloak server info providers pic5

✅ 进入 Realm > User Federation 页面,确认配置生效:

keycloak server info providers pic6-1

✅ 登录测试用户(user1/user2/user3,密码均为 changeit):

keycloak server info providers pic7-1

⚠️ 由于是只读实现,用户信息不可修改。

5. 总结

本文通过一个完整的示例,展示了如何在 Keycloak 中实现一个自定义用户存储提供者,支持对接外部数据库并实现用户认证。虽然我们只实现了只读功能,但 Keycloak 支持更复杂的双向同步场景,开发者可根据需求进一步扩展。



原始标题:Using Custom User Providers with Keycloak | Baeldung