Benutzerdefinierter Benutzeranbieter für Keycloak

Durch die Integration von Keycloak in ein vorhandenes System ist es sehr wahrscheinlich, dass Sie während der Authentifizierung Benutzer aus einer alten Datenbank laden müssen, in der Informationen über sie in einer ausgefallenen Form gespeichert werden können. Diese Aufgabe wird gelöst, indem Sie einen eigenen Benutzeranbieter erstellen (Benutzerverbundanbieter in der Keycloak-Terminologie). Im Folgenden finden Sie eine kurze Anleitung zum Schreiben eines solchen Anbieters.






Falls Sie mit Keycloak nicht vertraut sind, finden Sie hier ein Zitat aus Wikipedia:





Keycloak  ist ein Open-Source-Single-Sign-On-Produkt mit Zugriffskontrolle für moderne Anwendungen und Dienste.





In der modernen Microservice-Welt ist Keycloak vor allem als OAuth 2.0-Anbieter interessant, mit dem Sie Kunden Token für den Zugriff auf bestimmte Services ausstellen können.





Technisch gesehen ist Keycloak eine Webanwendung innerhalb des WildFly-Servers, die jemandem Gänsehaut aus Erinnerungen an ein blutiges Unternehmen verursachen kann. Aber genug Theorie, es ist Zeit, die Ärmel hochzukrempeln!





Unser Keycloak-Plugin wird eine kleine Anwendung im WAR-Paket sein. Zum Erstellen reicht Java 8 aus. Nehmen Sie Gradle als Build-Tool und geben Sie die folgenden Module in den Abhängigkeiten an:





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



, .





Wie bereits erwähnt, lädt das Plugin den Datenbanktreiber von einem bestimmten Keycloak-Modul. Um Keycloak mitzuteilen, dass wir von diesem Modul abhängig sind, müssen Sie zusätzlich eine Datei jboss-deployment-structure.xml



im Verzeichnis erstellen META-INF



:





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

      
      



Damit Keycloak unser Plugin abholen kann, sollte es (Plugin) im Verzeichnis abgelegt werden $KEYCLOAK_HOME/standalone/deployments



. Wenn das Plugin im Keycloak-Administrationsbereich im Abschnitt " Benutzerverbund" erfolgreich bereitgestellt wurde, können Sie einen Anbieter mit einer Kennung hinzufügen. Anschließend habr.legacy-database



können Sie mit der Ausgabe von Token beginnen.





Der Plugin-Quellcode ist auf GitHub verfügbar .





Das ist alles. Vielen Dank für Ihre Aufmerksamkeit!








All Articles