通過Keycloak使用自定義用戶提供程序
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上找到。