6.5. 简单只读查找示例
为了说明实施用户存储 SPI 的基础知识,我们来浏览一个简单的示例。在本章中,您将看到一个简单 UserStorageProvider 的实现,它会在一个简单的属性文件中查找用户。属性文件包含用户名和密码定义,并硬编码到 classpath 上的特定位置。该提供程序将能够按 ID 和用户名查找用户,并能够验证密码。源自此提供程序的用户将只读。
6.5.1. 供应商类 复制链接链接已复制到粘贴板!
首先,我们将进入的事情是 UserStorageProvider 类。
public class PropertyFileUserStorageProvider implements
UserStorageProvider,
UserLookupProvider,
CredentialInputValidator,
CredentialInputUpdater
{
...
}
我们的供应商类 PropertyFileUserStorageProvider 实现了许多接口。它实现 UserStorageProvider,因为这是 SPI 的基本要求。它实现了 UserLookupProvider 接口,因为我们希望能够使用此提供程序存储的用户登录。它实现了 CredentialInputValidator 接口,因为我们希望能够使用登录屏幕验证输入的密码。我们的属性文件是只读的。我们实施 CredentialInputUpdater,因为当用户尝试更新其密码时,我们希望发出错误条件。
protected KeycloakSession session;
protected Properties properties;
protected ComponentModel model;
// map of loaded users in this transaction
protected Map<String, UserModel> loadedUsers = new HashMap<>();
public PropertyFileUserStorageProvider(KeycloakSession session, ComponentModel model, Properties properties) {
this.session = session;
this.model = model;
this.properties = properties;
}
此提供程序类的构造器将存储对 KeycloakSession、ComponentModel 和 属性文件的引用。我们稍后将用到所有这些产品。另请注意,有加载的用户映射。每当我们发现某个用户,我们将将其存储在此地图中,以便我们避免在同一事务中再次重新创建。这是遵循许多提供商需要执行此操作的良好做法(即,与 JPA 集成的任何提供商)。请记住,每个事务都会创建提供程序类实例,并在事务完成后关闭。
6.5.1.1. UserLookupProvider 实现 复制链接链接已复制到粘贴板!
@Override
public UserModel getUserByUsername(RealmModel realm, String username) {
UserModel adapter = loadedUsers.get(username);
if (adapter == null) {
String password = properties.getProperty(username);
if (password != null) {
adapter = createAdapter(realm, username);
loadedUsers.put(username, adapter);
}
}
return adapter;
}
protected UserModel createAdapter(RealmModel realm, String username) {
return new AbstractUserAdapter(session, realm, model) {
@Override
public String getUsername() {
return username;
}
};
}
@Override
public UserModel getUserById(RealmModel realm, String id) {
StorageId storageId = new StorageId(id);
String username = storageId.getExternalId();
return getUserByUsername(realm, username);
}
@Override
public UserModel getUserByEmail(RealmModel realm, String email) {
return null;
}
当用户登录时,Red Hat build of Keycloak 登录页会调用 getUserByUsername () 方法。在我们的实施中,我们首先检查 loadedUsers 映射,以查看用户是否已在此事务中载入了用户。如果没有加载,我们将查看用户名的属性文件。如果存在,我们创建一个 UserModel 的实现,将其存储在 loadUsers 中,以备将来参考,并返回此实例。
createAdapter () 方法使用帮助程序类 org.keycloak.storage.adapter.AbstractUserAdapter。这为 UserModel 提供了基础实施。它使用用户的用户名作为外部 ID,根据所需的存储 id 格式自动生成用户 id。
"f:" + component id + ":" + username
每个 get 方法 AbstractUserAdapter 都会返回 null 或空集合。但是,返回角色和组映射的方法将返回为每个用户配置域的默认角色和组。每个组 AbstractUserAdapter 都会抛出 org.keycloak.storage.ReadOnlyException。因此,如果您试图在管理控制台中修改用户,则会出现错误。
getUserById () 方法使用 org.keycloak.storage.StorageId helper 类解析 id 参数。调用 StorageId.getExternalId () 方法,以获取嵌入在 id 参数中的用户名。然后,方法将委托为 getUserByUsername ()。
电子邮件不会被存储,因此 getUserByEmail () 方法返回 null。
6.5.1.2. CredentialInputValidator 实现 复制链接链接已复制到粘贴板!
接下来,让我们来看看 CredentialInputValidator 的方法实施。
@Override
public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
String password = properties.getProperty(user.getUsername());
return credentialType.equals(PasswordCredentialModel.TYPE) && password != null;
}
@Override
public boolean supportsCredentialType(String credentialType) {
return credentialType.equals(PasswordCredentialModel.TYPE);
}
@Override
public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
if (!supportsCredentialType(input.getType())) return false;
String password = properties.getProperty(user.getUsername());
if (password == null) return false;
return password.equals(input.getChallengeResponse());
}
运行时调用 isConfiguredFor () 方法,以确定是否为用户配置了特定的凭证类型。这个方法检查是否为用户设置了密码。
supportsCredentialType () 方法返回是否支持特定凭证类型的验证。我们将检查该凭证类型是否为 密码。
isValid () 方法负责验证密码。CredentialInput 参数实际上是所有凭证类型的一个抽象接口。我们确保支持凭证类型,并且也是 UserCredentialModel 的实例。当用户通过登录页面登录时,密码输入的纯文本将放入 UserCredentialModel 的实例。isValid () 方法针对存储在属性文件中的纯文本密码检查这个值。返回值 true 表示密码有效。
6.5.1.3. CredentialInputUpdater 实现 复制链接链接已复制到粘贴板!
如前所述,我们实现 CredentialInputUpdater 接口的唯一原因是,禁止修改用户密码。我们必须这样做的原因是,否则运行时允许在红帽构建的 Keycloak 本地存储中覆盖密码。本章稍后将对此进行更多讨论。
@Override
public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) {
if (input.getType().equals(PasswordCredentialModel.TYPE)) throw new ReadOnlyException("user is read only for this update");
return false;
}
@Override
public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) {
}
@Override
public Stream<String> getDisableableCredentialTypesStream(RealmModel realm, UserModel user) {
return Stream.empty();
}
updateCredential () 方法只检查凭证类型是否为 password。如果是,则会抛出 ReadOnlyException。
6.5.2. 供应商工厂实施 复制链接链接已复制到粘贴板!
现在,供应商类已完成,我们现在将注意供应商工厂类。
public class PropertyFileUserStorageProviderFactory
implements UserStorageProviderFactory<PropertyFileUserStorageProvider> {
public static final String PROVIDER_NAME = "readonly-property-file";
@Override
public String getId() {
return PROVIDER_NAME;
}
首先要注意的是,在实施 UserStorageProviderFactory 类时,您必须传递 concrete provider 类实现作为模板参数。此处我们指定之前定义的供应商类: PropertyFileUserStorageProvider。
如果没有指定 template 参数,您的供应商将无法正常工作。运行时执行类内省,以确定提供程序实施 的能力接口。
getId () 方法标识运行时中的工厂,当您要为域启用用户存储供应商时,也会是 admin 控制台中显示的字符串。
6.5.2.1. 初始化 复制链接链接已复制到粘贴板!
private static final Logger logger = Logger.getLogger(PropertyFileUserStorageProviderFactory.class);
protected Properties properties = new Properties();
@Override
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);
}
}
}
@Override
public PropertyFileUserStorageProvider create(KeycloakSession session, ComponentModel model) {
return new PropertyFileUserStorageProvider(session, model, properties);
}
UserStorageProviderFactory 接口具有可实现的可选 init () 方法。当红帽构建的 Keycloak 引导时,只会为每个供应商工厂创建一个实例。另外,引导时都会调用 init () 方法。还有一个 postInit () 方法,也可以实施。在调用每个工厂的 init () 方法后,会调用其 postInit () 方法。
在我们的 init () 方法实现中,我们从 classpath 中找到包含我们用户声明的属性文件。然后,使用存储了用户名和密码组合来加载 properties 字段。
Config.Scope 参数是通过服务器配置的工厂配置。
例如,使用以下参数运行服务器:
kc.[sh|bat] start --spi-storage-readonly-property-file-path=/other-users.properties
我们可以指定用户属性文件的 classpath,而不是对其进行硬编码。然后,您可以在 PropertyFileUserStorageProviderFactory.init () 中检索配置:
public void init(Config.Scope config) {
String path = config.get("path");
InputStream is = getClass().getClassLoader().getResourceAsStream(path);
...
}
6.5.2.2. 创建方法 复制链接链接已复制到粘贴板!
创建供应商工厂的最后一步是 create () 方法。
@Override
public PropertyFileUserStorageProvider create(KeycloakSession session, ComponentModel model) {
return new PropertyFileUserStorageProvider(session, model, properties);
}
我们只需分配 PropertyFileUserStorageProvider 类。此创建方法将为每个事务调用一次。
6.5.3. 打包和部署 复制链接链接已复制到粘贴板!
用于供应商实现的类文件应放在 jar 中。您还必须在 META-INF/services/org.keycloak.storage.UserProviderFactory 文件中声明供应商工厂类。
org.keycloak.examples.federation.properties.FilePropertiesStorageFactory
要部署此 jar,将其复制到 provider/ 目录中,然后运行 bin/kc.[sh|bat] build。
6.5.4. 在管理门户中启用提供程序 复制链接链接已复制到粘贴板!
您可以在管理控制台的 User Federation 页面中为每个域启用用户存储供应商。
用户 Federation
流程
从列表中选择刚才创建的提供程序:
readonly-property-file。这时将显示我们供应商的配置页面。
单击 Save,因为我们无需配置。
配置的供应商
返回到主 User Federation 页面
现在,您会看到列出的供应商。
用户 Federation
现在,您将能够使用 users.properties 文件中声明的用户登录。此用户只能在登录后查看帐户页面。