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 及其提供者:
左列是 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 实例后,访问管理控制台:
✅ 查看 Server Info > Providers 页面,确认自定义提供者已加载:
✅ 进入 Realm > User Federation 页面,确认配置生效:
✅ 登录测试用户(user1/user2/user3,密码均为 changeit):
⚠️ 由于是只读实现,用户信息不可修改。
5. 总结
本文通过一个完整的示例,展示了如何在 Keycloak 中实现一个自定义用户存储提供者,支持对接外部数据库并实现用户认证。虽然我们只实现了只读功能,但 Keycloak 支持更复杂的双向同步场景,开发者可根据需求进一步扩展。