1.简介
在本教程中,我们将展示如何向流行的开源身份管理解决方案Keycloak添加自定义提供程序,以便我们可以将其与现有和/或非标准用户存储一起使用。
2.带有Keycloak的自定义提供程序概述
现成的Keycloak基于SAML,OpenID Connect和OAuth2等协议提供了一系列基于标准的集成。尽管此内置功能非常强大,但有时还不够。一个共同的要求,尤其是在涉及旧系统的情况下,是将这些系统中的用户集成到Keycloak中。为了适应这种和类似的集成方案,Keycloak支持自定义提供程序的概念。
定制提供商在Keycloak的体系结构中起着关键作用。对于每个主要功能,例如登录流程,身份验证,授权,都有一个相应的服务提供商接口。这种方法使我们可以为任何这些服务插入自定义实现,然后Keycloak将使用它,因为它是其自己的服务之一。
2.1。自定义提供商部署和发现
以最简单的形式,自定义提供程序只是一个包含一个或多个服务实现的标准jar文件。在启动时,Keycloak将扫描其类路径,并使用标准java.util.ServiceLoader
机制选择所有可用的提供程序。这意味着我们要做的就是在jar的META-INF/services
文件夹中创建一个要以特定服务接口命名的文件,并将实现的完全限定名称放入其中。
但是,我们可以为Keycloak添加哪种服务?如果我们转到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事件的提供程序。EventType
Javadoc页面包含提供程序可以处理的自定义可用事件的列表。使用此SPI的典型用途是创建审核数据库 -
org.keycloak.adapters.saml.RoleMappingsProvider
:将从外部身份提供者收到的SAML角色映射到Keycloak的角色。这种映射非常灵活,允许我们在给定领域的上下文中重命名,删除和/或添加角色 -
org.keycloak.storage.UserStorageProviderFactory
:允许Keycloak访问自定义用户存储 -
org.keycloak.vault.VaultProviderFactory
:允许我们使用自定义文件库来存储特定于领域的机密。这些信息可以包括加密密钥,数据库凭证等信息。
现在,该列表绝不涵盖所有可用的SPI:它们是记录最充分的,实际上,最有可能需要自定义。
3.定制提供商实现
正如我们在本文的简介中提到的那样,我们的提供程序示例将允许我们将Keycloak与只读的自定义用户存储库一起使用。例如,在我们的例子中,此用户存储库只是一个带有一些属性的常规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
);
为了支持此自定义用户存储,我们必须实现UserStorageProviderFactory
SPI并将其部署到现有的Keycloak实例中。
这里的重点是只读部分。这样,我们的意思是用户将能够使用其凭据登录到Keycloak,但不能更改自定义存储中的任何信息,包括密码。但是,这不是Keycloak的限制,因为它实际上支持双向更新。内置的LDAP提供程序是支持此功能的提供程序的一个很好的示例。
3.1。项目设置
我们的自定义提供程序项目只是一个创建jar文件的常规Maven项目。为了避免我们的提供者在常规的Keycloak实例中花费大量时间进行编译,部署,重启,我们将使用一个不错的技巧:将Keycloak作为测试时间的依赖项嵌入到我们的项目中。
我们已经介绍了如何将Keycloack嵌入到SpringBoot应用程序中,因此在这里我们将不做详细介绍。通过采用这种技术,我们将获得更快的启动时间和热重装功能,从而为开发人员提供更流畅的体验。在这里,我们将重用示例SpringBoot应用程序以直接从自定义提供程序运行测试,因此我们将其添加为测试依赖项:
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<version>12.0.2</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>12.0.2</version>
</dependency>
<dependency>
<groupId>com.baeldung</groupId>
<artifactId>oauth-authorization-server</artifactId>
<version>0.1.0-SNAPSHOT</version>
<scope>test</scope>
</dependency>
我们将最新的11系列版本用于keycloak-core
和keycloak-server-spi
Keycloak依赖项。
但是,必须从Baeldung的Spring Security OAuth存储库本地构建oauth-authorization-server
依赖项。
3.2。 UserStorageProviderFactory
实现
让我们通过创建UserStorageProviderFactory
实现来启动我们的提供程序,并使其可供Keycloak发现。
该接口包含十一个方法,但是我们只需要实现其中两个:
-
getId()
:返回此提供程序的唯一标识符,Keycloak将在其管理页面上显示该标识符。 -
create()
:返回实际的Provider实现。
Keycloak为每个事务调用create()
方法,并传递KeycloakSession
和ComponentModel
作为参数。在此,交易是指需要访问用户存储的任何操作。最典型的示例是登录流程:在某个时候,Keycloak将为给定的Realm调用每个已配置的用户存储,以验证凭据。因此,此时我们应该避免执行任何昂贵的初始化操作,因为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);
}
}
我们为提供者ID选择了“custom-user-provider”
,我们的create()
实现只是返回了UserStorageProvider
实现的新实例。现在,我们一定不要忘记创建服务定义文件并将其添加到我们的项目中。该文件应命名为org.keycloak.storage.UserStorageProviderFactory
并放置在我们最终jar的META-INF/services
文件夹中。
由于我们使用的是标准Maven项目,因此这意味着我们将其添加到src/main/resources/META-INF/services
文件夹中:
该文件的内容仅是SPI实现的完全限定名称:
# SPI class implementation
com.baeldung.auth.provider.user.CustomUserStorageProviderFactory
3.3。 UserStorageProvider
实现
乍一看, UserStorageProvider
实现看起来并不像我们期望的那样。它仅包含一些回调方法,它们均与实际用户无关。原因是Keycloak希望我们的提供商也可以实现其他支持特定用户管理方面的混合接口。
可用接口的完整列表在Keycloak的文档中提供,在此将它们称为Provider Capabilities.
对于简单的只读提供程序,我们需要实现的唯一接口是UserLookupProvider
。它仅提供查找功能,这意味着Keycloak将在需要时自动将用户导入其内部数据库。但是,原始用户的密码将不会用于身份验证。为此,我们还需要实现CredentialInputValidator
。
最后,一个共同的要求是能够在Keycloak的管理界面中的自定义商店中显示现有用户。这要求我们实现另一个接口: UserQueryProvider
。这增加了一些查询方法,并充当我们商店的DAO。
因此,鉴于这些要求,这就是我们的实现的外观:
public class CustomUserStorageProvider implements UserStorageProvider,
UserLookupProvider,
CredentialInputValidator,
UserQueryProvider {
// ... private members omitted
public CustomUserStorageProvider(KeycloakSession ksession, ComponentModel model) {
this.ksession = ksession;
this.model = model;
}
// ... implementation methods for each supported capability
}
请注意,我们正在保存传递给构造函数的值。稍后我们将看到它们如何在我们的实施中发挥重要作用。
3.4。 UserLookupProvider
实现
Keycloak使用此接口中的方法来恢复给定UserModel
实例的id
,用户名或电子邮件。在这种情况下,id是该用户的唯一标识符,格式为:'f:' unique_id
':' external_id
- 'f:'只是一个固定的前缀,指示这是联盟用户
-
unique_id
是用户的Keycloak的ID -
external_id
是给定用户商店使用的用户标识符。在我们的例子中,这就是username
名列的值
让我们继续从getUserByUsername()
开始实现此接口的方法:
@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);
}
}
不出所料,这是一个简单的数据库查询,使用提供的username
查找其信息。有两个有趣的地方需要解释: DbUtil.getConnection()
和mapUser()
。
DbUtil
是一个帮助程序类,该类以某种方式从在构造函数中获取的ComponentModel
中包含的信息返回JDBC Connection
。稍后我们将介绍其详细信息。
至于mapUser()
,它的工作是将包含用户数据的数据库记录映射到UserModel
实例。 UserModel
代表用户实体(如Keycloak所见),并具有读取其属性的方法。我们在此处提供的此接口的实现扩展了Keycloak提供的AbstractUserAdapter
类。我们还向实现中添加了一个Builder
内部类,因此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;
}
同样,其他方法基本上遵循上述相同的模式,因此我们将不对其进行详细介绍。请参阅提供商的代码,并检查所有getUserByXXX
和searchForUser
方法。
3.5。建立Connection
现在,让我们看一下DbUtil.getConnection()
方法:
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) {
// ... error handling omitted
}
return DriverManager.getConnection(
config.get(CONFIG_KEY_JDBC_URL),
config.get(CONFIG_KEY_DB_USERNAME),
config.get(CONFIG_KEY_DB_PASSWORD));
}
}
我们可以看到ComponentModel
是创建所有必需参数的地方。但是,Keycloak如何知道我们的自定义提供程序需要哪些参数?要回答这个问题,我们需要回到CustomUserStorageProviderFactory.
3.6。配置元数据
为基本合同CustomUserStorageProviderFactory
, UserStorageProviderFactory
,包含允许Keycloak到查询配置属性的元数据,并且还重要的方法,以验证赋值。在我们的例子中,我们将定义一些建立JDBC连接所需的配置参数。由于此元数据是静态的,因此我们将在构造函数中创建它,而getConfigProperties()
将仅返回它。
public class CustomUserStorageProviderFactory
implements UserStorageProviderFactory<CustomUserStorageProvider> {
protected final List<ProviderConfigProperty> configMetadata;
public CustomUserStorageProviderFactory() {
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()
// ... repeat this for every property (omitted)
.build();
}
// ... other methods omitted
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return configMetadata;
}
@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);
}
}
}
在validateConfiguration()
,我们将获得将提供的内容添加到Realm时验证传递的参数所需的一切。在我们的例子中,我们使用此信息来建立数据库连接并执行验证查询。如果出了问题,我们只抛出ComponentValidationException
,向Keycloak发出参数无效的信号。
而且,尽管此处未显示,但我们也可以使用onCreated()
方法来附加逻辑,该逻辑将在管理员每次将我们的提供程序添加到Realm时执行。这使我们可以执行一次初始化时逻辑,以准备使用我们的存储,这在某些情况下可能是必需的。例如,我们可以使用此方法来修改数据库并添加一列以记录给定用户是否已使用Keycloak。
3.7。 CredentialInputValidator
实现
该接口包含验证用户凭据的方法。由于Keycloak支持不同类型的凭据(密码,OTP令牌,X.509证书等),因此我们的提供程序必须告知它是否在supportsCredentialType()
中supportsCredentialType()
给定类型, and
在isConfiguredFor()
给定领域的上下文中对其进行配置。 isConfiguredFor()
。
在我们的例子中,我们仅支持密码,并且由于不需要任何额外的配置,因此我们可以将后一种方法委托给前者:
@Override
public boolean supportsCredentialType(String credentialType) {
return PasswordCredentialModel.TYPE.endsWith(credentialType);
}
@Override
public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
return supportsCredentialType(credentialType);
}
实际的密码验证发生在isValid()
方法中:
@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);
}
}
在这里,有几点要讨论。首先,请注意我们如何使用从Keycloak的ID初始化的StorageId
像从UserModel,
提取外部ID。我们可以使用这个id具有众所周知的格式并从那里提取用户名这一事实,但是最好在这里安全使用,并将此知识封装在Keycloak提供的类中。
接下来,是实际的密码验证。对于我们简单且理所当然的非常不安全的数据库,密码检查是微不足道的:只需将数据库值与用户提供的值(可通过getChallengeResponse()
进行getChallengeResponse()
进行比较即可。当然,真实世界的提供者将需要更多步骤,例如从数据库中生成哈希通知的密码和盐值,并比较哈希。
最后,用户存储区通常具有一些与密码相关联的生命周期:最长使用期限,被阻止和/或处于非活动状态等。无论如何,在实现提供程序时, isValid()
方法都是添加此逻辑的地方。
3.8。 UserQueryProvider
实现
UserQueryProvider
功能接口告诉Keycloak我们的提供程序可以在其商店中搜索用户。这很方便,因为通过支持此功能,我们将能够在管理控制台中查看用户。
该接口的方法包括getUsersCount(),
用于获取商店中的用户总数getXXX()
以及几个getXXX()
和searchXXX()
方法。该查询界面不仅支持查找用户,还支持查找组,这一次我们将不介绍。
由于这些方法的实现非常相似,因此让我们仅看其中之一searchForUser()
:
@Override
public List<UserModel> searchForUser(String search, RealmModel realm, int firstResult, int 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;
}
catch(SQLException ex) {
throw new RuntimeException("Database error:" + ex.getMessage(),ex);
}
}
我们可以看到,这里没有什么特别的:只是常规的JDBC代码。值得一提的实现说明: UserQueryProvider
方法通常具有分页和非分页版本。由于用户存储区可能具有大量记录,因此非分页版本应使用明智的默认值简单地委派给分页版本。更好的是,我们可以添加一个配置参数来定义什么是“合理的默认值”。
4.测试
现在我们已经实现了提供程序,是时候使用嵌入式Keycloak实例在本地对其进行测试了。该项目的代码包含一个实时测试类,我们已经使用该类来引导Keycloak和自定义用户数据库,然后在休眠一小时之前在控制台上打印访问URL。
使用此设置,我们可以通过在浏览器中打开打印的URL来验证我们的自定义提供程序是否按预期工作:
要访问管理控制台,我们将使用管理员凭据,该凭据可以通过查看application-test.yml
文件获得。登录后,让我们导航到“服务器信息”页面:
在“提供程序”选项卡上,我们可以看到我们的自定义提供程序与其他内置存储提供程序一起显示:
我们还可以检查Baeldung领域是否已在使用此提供程序。为此,我们可以在左上方的下拉菜单中选择它,然后导航到“ User Federation
页面:
接下来,让我们测试实际登录到该领域。我们将使用领域的账户管理页面,用户可以在其中管理其数据。我们的实时测试将在进入睡眠状态之前打印此URL,因此我们只需从控制台复制它,然后将其粘贴到浏览器的地址栏中即可。
测试数据包含三个用户:user1,user2和user3。它们的密码都是相同的:“ changeit”。成功登录后,我们将看到“账户管理”页面显示导入的用户数据:
但是,如果我们尝试修改任何数据,则会收到错误消息。这是预料之中的,因为我们的提供程序是只读的,因此Keycloak不允许对其进行修改。现在,由于支持双向同步超出了本文的范围,因此我们将其保留不变。
5.结论
在本文中,我们展示了如何使用用户存储提供程序作为具体示例为Keycloak创建自定义提供程序。示例的完整源代码可以在GitHub上找到。
0 评论