Penyedia pengguna kustom untuk Keycloak

Dengan mengintegrasikan Keycloak ke dalam sistem yang sudah ada, kemungkinan besar Anda harus memuat pengguna dari database kuno selama otentikasi, di mana informasi tentang mereka dapat disimpan dalam bentuk yang agak mewah. Tugas ini diselesaikan dengan membuat penyedia pengguna Anda sendiri (Penyedia Federasi Pengguna dalam terminologi Keycloak). Di bawah ini adalah panduan singkat untuk menulis penyedia semacam itu.






Jika Anda tidak terbiasa dengan Keycloak, berikut adalah kutipan dari Wikipedia:





Keycloak  adalah produk sistem masuk tunggal sumber terbuka dengan kontrol akses, yang ditujukan untuk aplikasi dan layanan modern.





Di dunia layanan mikro modern, Keycloak menarik terutama sebagai penyedia OAuth 2.0, yang dengannya Anda dapat menerbitkan token kepada klien untuk mengakses layanan tertentu.





Secara teknis, Keycloak adalah aplikasi web di dalam server WildFly, yang dapat membuat seseorang merinding dari ingatan akan sebuah perusahaan berdarah. Tapi cukup teori, saatnya menyingsingkan lengan baju Anda!





Plugin Keycloak kami akan menjadi aplikasi paket WAR kecil. Untuk membuatnya, Java 8 sudah cukup. Gunakan Gradle sebagai alat build, dan tentukan modul berikut dalam dependensi:





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



, .





Seperti yang dinyatakan sebelumnya, plugin memuat driver database dari modul Keycloak tertentu. Untuk memberi tahu Keycloak bahwa kita bergantung pada modul ini, Anda perlu membuat file tambahan jboss-deployment-structure.xml



di direktori META-INF



:





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

      
      



Untuk Keycloak untuk mengambil plugin kami, itu (plugin) harus ditempatkan di direktori $KEYCLOAK_HOME/standalone/deployments



. Jika plugin berhasil diterapkan di panel admin Keycloak, di bagian Federasi Pengguna , Anda dapat menambahkan penyedia dengan pengenal habr.legacy-database



, setelah itu Anda dapat mulai menerbitkan token.





Kode sumber plugin tersedia di GitHub .





Itu saja. Terima kasih atas perhatian Anda!








All Articles