美文网首页
iOS Developer的全栈之路 - Keycloak(8)

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

作者: 西西的一天 | 来源:发表于2020-02-28 22:25 被阅读0次

    在我们试图将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的版本,所有相应的依赖需保持版本号一致:

    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
    
        <groupId>org.example</groupId>
        <artifactId>userstorage</artifactId>
        <packaging>jar</packaging>
        <version>1.0-SNAPSHOT</version>
    
        <properties>
            <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
            <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
            <java.version>1.8</java.version>
        </properties>
    
        <dependencies>
            <dependency>
                <groupId>org.keycloak</groupId>
                <artifactId>keycloak-adapter-spi</artifactId>
                <version>8.0.1</version>
            </dependency>
            <dependency>
                <groupId>org.keycloak</groupId>
                <artifactId>keycloak-server-spi</artifactId>
                <version>8.0.1</version>
                <scope>provided</scope>
            </dependency>
            <dependency>
                <groupId>org.keycloak</groupId>
                <artifactId>keycloak-core</artifactId>
                <version>8.0.1</version>
            </dependency>
            <dependency>
                <groupId>org.jboss.logging</groupId>
                <artifactId>jboss-logging</artifactId>
                <version>3.4.1.Final</version>
            </dependency>
        </dependencies>
    </project>
    

    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<String, UserModel> loadedUsers = new HashMap<String, UserModel>();
    
        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<String> 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<String> 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<PropertyFileUserStorageProvider> {
    
        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<ProviderConfigProperty> 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为于同级即可:
    <spi name="storage">
      <provider name="readonly-property-file" enabled="true" />
    </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)

          本文链接:https://www.haomeiwen.com/subject/ycwlhhtx.html