Keycloakのカスタムユーザープロバイダー

Keycloakを既存のシステムに統合することにより、認証中にユーザーに関する情報をかなり凝った形式で保存できる古いデータベースからユーザーをロードしなければならない可能性が高くなります。このタスクは、独自のユーザープロバイダー(Keycloak用語ではユーザーフェデレーションプロバイダー)を作成することで解決されます。以下は、そのようなプロバイダーを作成するための短いガイドです。






Keycloakに慣れていない場合は、ウィキペディアからの引用をご覧ください。





Keycloak は、最新のアプリケーションとサービスを対象とした、アクセス制御を備えたオープンソースのシングルサインオン製品です。





現代のマイクロサービスの世界では、Keycloakは主にOAuth 2.0プロバイダーとして興味深いものであり、クライアントにトークンを発行して特定のサービスにアクセスできます。





技術的には、KeycloakはWildFlyサーバー内のWebアプリケーションであり、血まみれの企業の記憶から誰かに鳥肌を与えることができます。しかし、十分な理論、それはあなたの袖をまくり上げる時です!





Keycloakプラグインは、小さなWARパッケージアプリケーションになります。ビルドするには、Java 8で十分です。ビルドツールとしてGradleを使用し、依存関係で次のモジュールを指定します。





compileOnly "org.keycloak:keycloak-core:12.0.3"
compileOnly "org.keycloak:keycloak-server-spi:12.0.3"
compileOnly "org.jboss.logging:jboss-logging:3.4.1.Final"

implementation "org.springframework:spring-core:5.3.3"
implementation "org.springframework:spring-jdbc:5.3.3"
implementation "org.springframework.security:spring-security-core:5.4.4"

testImplementation platform('org.junit:junit-bom:5.7.1')
testImplementation 'org.junit.jupiter:junit-jupiter'
testImplementation 'org.mockito:mockito-core:3.7.7'

testRuntimeOnly 'javax.ws.rs:javax.ws.rs-api:2.1.1'
testRuntimeOnly 'com.h2database:h2:1.4.200'
      
      



Spring Framework , , Spring. , .





- Keycloak. , Keycloak .





JBoss Logging, "" WildFly. , , - , SLF4J.





io.freefair.lombok



.





Keycloak org.keycloak.storage.UserStorageProvider



, , . , , , org.keycloak.storage.user.UserLookupProvider



org.keycloak.credential.CredentialInputValidator



. , . - . .





org.keycloak.models.UserModel



( ). org.keycloak.storage.adapter.AbstractUserAdapter



, org.keycloak.models.UserModel



:





public class LegacyDatabaseUserModel extends AbstractUserAdapter {
    public static final String ATTRIBUTE_PASSWORD = "password";
  
    private final MultivaluedHashMap<String, String> attributes = new MultivaluedHashMap<>();
    private final Set<RoleModel> roles;

    private LegacyDatabaseUserModel(Builder builder) {
        super(builder.session, builder.realm, builder.storageProviderModel);
        this.attributes.putSingle(UserModel.USERNAME, builder.username);
        this.attributes.putSingle(UserModel.FIRST_NAME, builder.firstName);
        this.attributes.putSingle(UserModel.LAST_NAME, builder.lastName);
        this.attributes.putSingle(ATTRIBUTE_PASSWORD, builder.password);
        this.roles = Collections.unmodifiableSet(builder.roles);
    }

    public static Builder builder() {
        return new Builder();
    }
  
    @Override
    public String getUsername() {
        return getFirstAttribute(UserModel.USERNAME);
    }

    @Override
    public String getFirstName() {
        return getFirstAttribute(UserModel.FIRST_NAME);
    }

    @Override
    public String getLastName() {
        return getFirstAttribute(UserModel.LAST_NAME);
    }
  
    @Override
    public Map<String, List<String>> getAttributes() {
        return new MultivaluedHashMap<>(attributes);
    }

    @Override
    public String getFirstAttribute(String name) {
        return attributes.getFirst(name);
    }

    @Override
    public List<String> getAttribute(String name) {
        return attributes.get(name);
    }

    @Override
    protected Set<RoleModel> getRoleMappingsInternal() {
        return roles;
    }

    public static class Builder {
        ...
    }
}
      
      



, , , - . Map



, , - .





org.keycloak.models.RoleModel



. , :





@AllArgsConstructor
public class LegacyDatabaseRoleModel implements RoleModel {
    @Getter
    private final RoleContainerModel container;
    @Getter
    private final String name;

    @Override
    public String getId() {
        return getName();
    }

    @Override
    public void setName(String name) {
        throw new ReadOnlyException("Role is read only for this update");
    }

    @Override
    public String getDescription() {
        return null;
    }

    @Override
    public void setDescription(String description) {
        throw new ReadOnlyException("Role is read only for this update");
    }

    @Override
    public boolean isComposite() {
        return false;
    }

    @Override
    public void addCompositeRole(RoleModel role) {
        throw new ReadOnlyException("Role is read only for this update");
    }

    @Override
    public void removeCompositeRole(RoleModel role) {
        throw new ReadOnlyException("Role is read only for this update");
    }

    @Override
    public Stream<RoleModel> getCompositesStream() {
        return Stream.empty();
    }

    @Override
    public boolean isClientRole() {
        return false;
    }

    @Override
    public String getContainerId() {
        return container.getId();
    }

    @Override
    public boolean hasRole(RoleModel role) {
        return false;
    }

    @Override
    public Map<String, List<String>> getAttributes() {
        return Collections.emptyMap();
    }

    @Override
    public void setSingleAttribute(String name, String value) {
        throw new ReadOnlyException("Role is read only for this update");
    }

    @Override
    public void setAttribute(String name, List<String> values) {
        throw new ReadOnlyException("Role is read only for this update");
    }

    @Override
    public void removeAttribute(String name) {
        throw new ReadOnlyException("Role is read only for this update");
    }

    @Override
    public Stream<String> getAttributeStream(String name) {
        return Stream.empty();
    }
}

      
      



, . , - .





. . . :





private final ConcurrentMap<UserModelKey, LegacyDatabaseUserModel> loadedUsers = new ConcurrentHashMap<>();

@Override
public LegacyDatabaseUserModel getUserByUsername(String username, RealmModel realm) {
    UserModelKey userKey = new UserModelKey(username, realm.getId());
    return loadedUsers.computeIfAbsent(userKey, k -> {
        LegacyDatabaseUserModel user = findUserByName(username, realm);
        if (user != null) {
            log.debugv("User is loaded by name \"{0}\"", username);
        }
        return user;
    });
}
      
      



, Keycloak . java.util.concurrent.ConcurrentMap



. findUserByName



, org.springframework.jdbc.core.JdbcTemplate



  org.springframework.jdbc.core.ResultSetExtractor



, , .





private LegacyDatabaseUserModel findUserByName(String username, RealmModel realm) {
	return jdbcTemplate.query(SQL_FIND_USER_BY_NAME, new Object[]{username}, new int[]{Types.VARCHAR},
				new LegacyDatabaseUserModelResultSetExtractor(realm));
}
      
      



@RequiredArgsConstructor
private class LegacyDatabaseUserModelResultSetExtractor implements ResultSetExtractor<LegacyDatabaseUserModel> {
    final RealmModel realm;

    @Override
    public LegacyDatabaseUserModel extractData(ResultSet rs) throws SQLException, DataAccessException {
        if (!rs.next()) {
            return null;
        }

        LegacyDatabaseUserModel.Builder userModelBuilder = LegacyDatabaseUserModel.builder()
                .session(session)
                .realm(realm)
                .storageProviderModel(storageProviderModel)
                .username(rs.getString(1))
                .password(rs.getString(2))
                .firstName(rs.getString(3))
                .lastName(rs.getString(4))
                .withRole(new LegacyDatabaseRoleModel(realm, rs.getString(5)));

        while (rs.next()) {
            userModelBuilder.withRole(new LegacyDatabaseRoleModel(realm, rs.getString(5)));
        }

        return userModelBuilder.build();
    }
}
      
      



. :





@Override
public LegacyDatabaseUserModel getUserById(String id, RealmModel realm) {
	StorageId storageId = new StorageId(id);
	String username = storageId.getExternalId();
	return getUserByUsername(username, realm);
}
      
      



: f:<storageProvideId>:<username>



. org.keycloak.storage.StorageId



.





, :





@Override
public boolean isValid(RealmModel realm, UserModel userModel, CredentialInput credentialInput) {
    if (!supportsCredentialType(credentialInput.getType())) {
        log.debugv("Credential type \"{0}\" is not supported", credentialInput.getType());
        return false;
    }

    String password = user.getFirstAttribute(LegacyDatabaseUserModel.ATTRIBUTE_PASSWORD);
    return passwordEncoder.matches(credentialInput.getChallengeResponse(), password);
}
      
      



, , ( ). . - Map



. org.keycloak.models.UserModel



, , "" isValid



com.habr.keycloak.model.LegacyDatabaseUserModel



- . org.springframework.security.crypto.password.PasswordEncoder



.





Keycloak . . org.keycloak.storage.UserStorageProviderFactory



. :





  1. ;





  2. .





@Override
public void init(Config.Scope config) {
    initDataSource();
    initPasswordEncoder();
}
      
      



:





private PropertySource<Map<String, Object>> getPropertySource() {
    if (propertySource == null) {
        propertySource = getDefaultPropertySource();
    }
    return propertySource;
}

private PropertySource<Map<String, Object>> getDefaultPropertySource() {
    return new PropertiesPropertySource("default", System.getProperties());
}
      
      



, Keycloak-way *.properties



, standalone.xml



:





@Override
public void init(Config.Scope config) {
    String propertyFilePath = config.get("property-file-path");
    ...
      
      



.





, :





private void initDataSource() {
    String driverClassName = getDataSourceDriverClassName();
    String url = getDataSourceUrl();

    SimpleDriverDataSource dataSource = new SimpleDriverDataSource();
    try {
        dataSource.setDriverClass((Class<? extends Driver>) Class.forName(driverClassName));
        dataSource.setUrl(url);
        dataSource.setUsername(getDataSourceUsername());
        dataSource.setPassword(getDataSourcePassword());
        this.dataSource = dataSource;
        log.debugv("Data source to connect with database \"{0}\" is created", url);
    } catch (ClassNotFoundException e) {
        throw new IllegalStateException("JDBC driver class \"" + driverClassName + "\" is not found", e);
    }
}
      
      



: WAR- , Keycloak. , , . , . Keycloak.





org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder



.





- Factory Finder', META-INF/services



org.keycloak.storage.UserStorageProviderFactory



, .





前述のように、プラグインは特定のKeycloakモジュールからデータベースドライバーをロードします。このモジュールに依存していることをKeycloakに伝えるにjboss-deployment-structure.xml



は、ディレクトリにファイルを追加で作成する必要がありますMETA-INF







<?xml version="1.0" encoding="UTF-8"?>
<jboss-deployment-structure>
    <deployment>
        <dependencies>
            <module name="org.postgresql"/>
        </dependencies>
    </deployment>
</jboss-deployment-structure>

      
      



Keycloakがプラグインを取得するには、プラグイン(プラグイン)をディレクトリに配置する必要があります$KEYCLOAK_HOME/standalone/deployments



プラグインがKeycloak管理パネルの[ユーザーフェデレーション]セクションで正常にデプロイされた場合、識別子を持つプロバイダーを追加habr.legacy-database



できます。その後、トークンの発行を開始できます。





プラグインのソースコードはGitHubで入手できます





それで全部です。清聴ありがとうございました!








All Articles