在我们试图将Keycloak引入一个已用项目时,通常项目已有用户系统,如果要进行完整的用户系统迁移,migrate到Keycloak,成本和风险都不可避免。而Keycloak的User Storage Federation就是为了解决这个问题,它可以帮助我们快速的完成集成。它内建了对LDAP和Active Directory的支持,可以像上一节中添加第三方登录那样进行简单的配置即可完成集成,也可以根据自己的需要编写SPI扩展,来完成一些定制化的集成。
在这一节中采用的就是第二种集成方式 - 自定义SPI。以官方文档里的一个场景为例,用户信息存储在properties文件中,key为用户名,value为密码,如下所示:
user1 = 123
user2 = 456
在添加自定义的SPI后,可以使用properties中的用户名和密码进行登录。
在此之前,先来看看Keycloak是如何查找用户的,当输入了用户名和密码后,Keycloak首先会查看cache中是否有此用户,找到了这直接返回;下一步将查看自己的DB;最后将遍历User Storage Federation。而通过自定义SPI便是作用于最后一步,换言之,如果在Keycloak自己的DB中已经有了这个用户,也就不会到我们的扩展中查找用户了。
添加User Federation的方式如下图所示:默认情况下,下拉菜单中,只有Keycloak内建的kerberos和ldap两种,如何将自己定义的宽展添加进下拉列表呢?那么就需要将我们自定义的代码打包成jar包,放置在standalone/deployments目录下。
创建Maven工程
笔者使用的是Intellij创建一个空Maven工程,如图所示:修改pom文件添加所需要的依赖,由于所使用的Keycloak是8.0.1的版本,所有相应的依赖需保持版本号一致:
4.0.0
org.example
userstorage
jar
1.0-SNAPSHOT
UTF-8
UTF-8
1.8
org.keycloak
keycloak-adapter-spi
8.0.1
org.keycloak
keycloak-server-spi
8.0.1
provided
org.keycloak
keycloak-core
8.0.1
org.jboss.logging
jboss-logging
3.4.1.Final
UserStorageProvider
实现自定义User Federation最核心的是需要实现两个接口UserStorageProvider
和UserStorageProviderFactory
。UserStorageProvider
定义了如何查找用户以及其他和用户相关的操作,UserStorageProviderFactory
则负责创建UserStorageProvider
。
但看UserStorageProvider
的代码,它只定义了三个方法,从注释上看,和用户操作没有什么关系,都是一些生命周期的回调:
public interface UserStorageProvider extends Provider {
// Callback when a realm is removed.
// Implement this if, for example, you want to do some cleanup in your user storage when a realm is removed
default
void preRemove(RealmModel realm) {}
// Callback when a group is removed.
// Allows you to do things like remove a user group mapping in your external store if appropriate
default
void preRemove(RealmModel realm, GroupModel group) {}
// Callback when a role is removed.
// Allows you to do things like remove a user role mapping in your external store if appropriate
default
void preRemove(RealmModel realm, RoleModel role) {}
/**
* Optional type that can be used by implementations to
* describe edit mode of user storage
*/
enum EditMode {...}
}
而真正操作用户的方法都存在于其他的接口中,我们可以通过下面这个示例来了解一下它们的作用:
import org.keycloak.component.ComponentModel;
import org.keycloak.credential.CredentialInput;
import org.keycloak.credential.CredentialInputUpdater;
import org.keycloak.credential.CredentialInputValidator;
import org.keycloak.models.*;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.storage.ReadOnlyException;
import org.keycloak.storage.StorageId;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.adapter.AbstractUserAdapter;
import org.keycloak.storage.user.UserLookupProvider;
import java.util.*;
public class PropertyFileUserStorageProvider implements
UserStorageProvider,
UserLookupProvider,
CredentialInputValidator,
CredentialInputUpdater {
protected KeycloakSession session;
protected Properties properties;
protected ComponentModel model;
protected HashMap loadedUsers = new HashMap();
public PropertyFileUserStorageProvider(KeycloakSession session, Properties properties, ComponentModel model) {
this.session = session;
this.properties = properties;
this.model = model;
}
public void close() { }
public UserModel getUserById(String id, RealmModel realmModel) {
StorageId storageId = new StorageId(id);
String username = storageId.getExternalId();
return getUserByUsername(username, realmModel);
}
public UserModel getUserByUsername(String username, RealmModel realmModel) {
UserModel adapter = loadedUsers.get(username);
if (adapter == null) {
String password = properties.getProperty(username);
if (password != null) {
adapter = createAdapter(realmModel, username);
loadedUsers.put(username, adapter);
}
}
return adapter;
}
protected UserModel createAdapter(RealmModel realm, final String username) {
return new AbstractUserAdapter(session, realm, model) {
public String getUsername() {
return username;
}
};
}
public UserModel getUserByEmail(String s, RealmModel realmModel) {
return null;
}
public boolean supportsCredentialType(String credentialType) {
return credentialType.equals(PasswordCredentialModel.TYPE);
}
public boolean isConfiguredFor(RealmModel realmModel, UserModel userModel, String credentialType) {
String password = properties.getProperty(userModel.getUsername());
return credentialType.equals(PasswordCredentialModel.TYPE) && password != null;
}
public boolean isValid(RealmModel realmModel, UserModel userModel, CredentialInput credentialInput) {
if (!supportsCredentialType(credentialInput.getType())) return false;
String password = properties.getProperty(userModel.getUsername());
if (password == null) return false;
return password.equals(credentialInput.getChallengeResponse());
}
public boolean updateCredential(RealmModel realmModel, UserModel userModel, CredentialInput credentialInput) {
if (credentialInput.getType().equals(PasswordCredentialModel.TYPE))
throw new ReadOnlyException("user is read only for this update");
return false;
}
public void disableCredentialType(RealmModel realmModel, UserModel userModel, String s) {
}
public Set getDisableableCredentialTypes(RealmModel realmModel, UserModel userModel) {
return Collections.emptySet();
}
}
UserLookupProvider
: 当需要从外部存储空间内查询用户信息,外部存储可以是另一个DB,或是其他server的某个endpoint。可以认为所有的自定义User Federation都需要实现这个接口,该接口中有三方方法:
public interface UserLookupProvider {
UserModel getUserById(String id, RealmModel realm);
UserModel getUserByUsername(String username, RealmModel realm);
UserModel getUserByEmail(String email, RealmModel realm);
}
在我们的示例中只实现了前两个,因为在properties文件中只有用户名和密码。而在这两个方法的实现中,最终调用的都是getUserByUsername
,根据这个username来构建一个UserModel对象,这个UserModel在Keycloak中也就是用户对象的抽象,它提供了获取和修改用户属性的功能,包括username,email,role等等。
CredentialInputValidator
: 用于校验用户的密码或其他credentials的正确性,前两个方法都是用于配置所支持的credential的类型,第三个方法则是用于真正的校验逻辑。
public interface CredentialInputValidator {
boolean supportsCredentialType(String credentialType);
boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType);
/**
* Tests whether a credential is valid
* @param realm The realm in which to which the credential belongs to
* @param user The user for which to test the credential
* @param credentialInput the credential details to verify
* @return true if the passed secret is correct
*/
boolean isValid(RealmModel realm, UserModel user, CredentialInput credentialInput);
}
CredentialInputUpdater
: 适用于提供如何更改credential的方法,由于我们的示例中,properties文件是一个只读类型的文件,所以并不支持修改credential,添加这个接口的目的,只是用于在web页面,当试图去更改密码时提示一个错误信息。
public interface CredentialInputUpdater {
boolean supportsCredentialType(String credentialType);
boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input);
void disableCredentialType(RealmModel realm, UserModel user, String credentialType);
Set getDisableableCredentialTypes(RealmModel realm, UserModel user);
}
UserStorageProviderFactory
这里我们要提供一个类来实现这个接口,用于创建我们的PropertyFileUserStorageProvider
,相对而言他的实现就简单很多:
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.UserStorageProviderFactory;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collections;
import java.util.List;
import java.util.Properties;
public class PropertyFileUserStorageProviderFactory implements
UserStorageProviderFactory {
public static final String PROVIDER_NAME = "readonly-property-file";
private static final Logger logger = Logger.getLogger(PropertyFileUserStorageProviderFactory.class);
protected Properties properties = new Properties();
private ComponentModel componentModel;
public void init(Config.Scope config) {
InputStream is = getClass().getClassLoader().getResourceAsStream("/users.properties");
if (is == null) {
logger.warn("Could not find users.properties in classpath");
} else {
try {
properties.load(is);
} catch (IOException ex) {
logger.error("Failed to load users.properties file", ex);
}
}
}
public PropertyFileUserStorageProvider create(KeycloakSession keycloakSession, ComponentModel componentModel) {
this.componentModel = componentModel;
return new PropertyFileUserStorageProvider(keycloakSession, properties, componentModel);
}
public String getId() {
return PROVIDER_NAME;
}
public UserStorageProvider create(KeycloakSession session) {
return new PropertyFileUserStorageProvider(session, properties, componentModel);
}
public void close() {}
public void postInit(KeycloakSessionFactory factory) {}
public List getConfigProperties() {
return Collections.emptyList();
}
public String getHelpText() { return null; }
}
可以看到,其中的大部分都是空实现,用到的几个方法,我们来分别介绍一下:
-
init
: 当Keycloak启动时,只会为每个provider创建一个factory类,而这个init的方法就是在Keycloak启动时被调用的。 -
create
: 每次去操作用户数据时,都会调用这个create方法来创建一个PropertyFileUserStorageProvider
实例。 -
getId
: 还记得在Keycloak配置User Federation的页面中的下拉菜单里所显示的自定义SPI,这个方法就是用于提供一个名字。
打包部署
除了实现上面的getId
,想让Keycloak识别并加载我们自定义的SPI,还需要其他几个步骤。
- 修改
standalone.xml
,这个配置文件位于standalone/configuration
目录下,添加我们定义的Provider,和该xml中的其他spi为于同级即可:
- 添加META-INF
在Maven项目下的resource目录中添加META-INF目录,再在其中添加services目录。创建一个名org.keycloak.storage.UserStorageProviderFactory
的文件,用于告知Keycloak来加载我们的Factory。在文件中添加一行,就是我们自定义Factory的完成路径:
com.iossocket.PropertyFileUserStorageProviderFactory
- 运行
mvn package
- 将生成好的jar文件复制到standalone/deployments目录下,重启Keycloak即可,此时我们就可以使用properties文件中的user1来登录了。