iOS Developer的全栈之路 - Keycloak(8)

在我们试图将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的方式如下图所示:
add user federation.png

默认情况下,下拉菜单中,只有Keycloak内建的kerberos和ldap两种,如何将自己定义的宽展添加进下拉列表呢?那么就需要将我们自定义的代码打包成jar包,放置在standalone/deployments目录下。

创建Maven工程

笔者使用的是Intellij创建一个空Maven工程,如图所示:
create project.png

修改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最核心的是需要实现两个接口UserStorageProviderUserStorageProviderFactoryUserStorageProvider定义了如何查找用户以及其他和用户相关的操作,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; }
}

可以看到,其中的大部分都是空实现,用到的几个方法,我们来分别介绍一下:

  1. init: 当Keycloak启动时,只会为每个provider创建一个factory类,而这个init的方法就是在Keycloak启动时被调用的。
  2. create: 每次去操作用户数据时,都会调用这个create方法来创建一个PropertyFileUserStorageProvider实例。
  3. getId: 还记得在Keycloak配置User Federation的页面中的下拉菜单里所显示的自定义SPI,这个方法就是用于提供一个名字。

打包部署

除了实现上面的getId,想让Keycloak识别并加载我们自定义的SPI,还需要其他几个步骤。

  1. 修改standalone.xml,这个配置文件位于standalone/configuration目录下,添加我们定义的Provider,和该xml中的其他spi为于同级即可:

  

  1. 添加META-INF
    在Maven项目下的resource目录中添加META-INF目录,再在其中添加services目录。创建一个名org.keycloak.storage.UserStorageProviderFactory的文件,用于告知Keycloak来加载我们的Factory。在文件中添加一行,就是我们自定义Factory的完成路径:
com.iossocket.PropertyFileUserStorageProviderFactory
  1. 运行mvn package
  2. 将生成好的jar文件复制到standalone/deployments目录下,重启Keycloak即可,此时我们就可以使用properties文件中的user1来登录了。

你可能感兴趣的:(iOS Developer的全栈之路 - Keycloak(8))