angle-left

Keycloak: кастомный User Federation провайдер

Добрый день!
Продолжим нашу тему по кастомизации портала SSO на базе Keycloak. Сегодня рассмотрим задачу создания своего Federation провайдера.

Суть проблемы: имеем жирную систему, работающую на СУБД Oracle, необходимо обеспечить возможность авторизации пользователей с использование креденшелов, хранящихся в этой БД, при этом необходимо обеспечить так же и синхронизацию ролей пользователей.

На наше счастье, разработчики Keycloak предусмотрели возможность написания своих собственных Federation провайдеров в дополнение существующим LDAP и Kerberos провайдерам. Найти документацию на интересующую нас тему можно тут, либо по ключевым словам "Keycloak User Storage SPI". 

Для решения нашей задачи нам потребуется подключить следующие зависимости:

<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-services</artifactId>
    <version>8.0.1</version>
    <scope>provided</scope>
</dependency>

Далее создаём ConnectionProviderFactory и ConnectionProvider, которые будут обеспечивать нам доступ в ту самую БД Oracle

 

public class ConnectionProviderFactory {
    private volatile EntityManagerFactory emf;

    private void lazyInit(KeycloakSession session) {
        if (emf == null) {
            synchronized (this) {
                if (emf == null) {

                    Map<String, Object> properties = new HashMap<>();

                    String unitName = "UserProviderUnit";//Имя persistence юнита из persistence

                    properties.put("hibernate.show_sql", false);
                    properties.put("hibernate.format_sql", true);
                    properties.put("hibernate.hbm2ddl.auto", "none");
                    Collection<ClassLoader> classLoaders = new ArrayList<>();
                    classLoaders.add(getClass().getClassLoader());
                    properties.put(AvailableSettings.CLASSLOADERS, classLoaders);
                    emf = JpaUtil.createEntityManagerFactory(session, unitName, properties, true);
                }
            }
        }
    }

    public ConnectionProvider create(KeycloakSession session) {
        lazyInit(session);

        EntityManager em;
        em = emf.createEntityManager();

        return new ConnectionProvider(em);
    }

    public void close() {
        if (emf != null) {
            emf.close();
        }
    }

}

 

public class ConnectionProvider {
    private final EntityManager em;

    public ConnectionProvider(EntityManager em) {
        this.em = em;
    }

    public EntityManager getEntityManager() {
        return em;
    }

    public void close() {
        em.close();
    }
}

 

Создаём модель, описываем её в файле resources/META-INF/persistence.xml.

Создадим класс-репозиторий, который будет для нас искать пользователей в БД Oracle.

class UserRepository {
    private final DeConnectionProvider connectionProvider;

    public UserRepository(DeConnectionProvider connectionProvider) {
        this.connectionProvider = connectionProvider;
    }

    public List<Person> getAllUsers() {
        EntityManager em = connectionProvider.getEntityManager();
        return em.createNamedQuery("Person.findAll", Person.class)
                .getResultList();
    }

    public Person findUserById(Long id) {
        EntityManager em = connectionProvider.getEntityManager();
        try {
            Person person = em.createNamedQuery("Person.findById", Person.class)
                    .setParameter("id", id)
                    .getSingleResult();
            return person;
        } catch (NoResultException e) {
            return null;
        }
    }

    public Person findUserByUsername(String username) {
        EntityManager em = connectionProvider.getEntityManager();
        try {
            Person findByLogin = em.createNamedQuery("Person.findByLogin", Person.class)
                    .setParameter("login", username)
                    .getSingleResult();

            return findByLogin;
        } catch (NoResultException e) {
            return null;
        }
    }

    public Person findUserByEmail(String email) {
        EntityManager em = connectionProvider.getEntityManager();
        try {
            Person findByEmail = em.createNamedQuery("Person.findByEmail", Person.class)
                    .setParameter("email", email)
                    .getSingleResult();

            return findByEmail;
        } catch (NoResultException e) {
            return null;
        }
    }

    public boolean validateCredentials(long id, String password) {
        EntityManager em = connectionProvider.getEntityManager();
        int valid;
        try {
            valid = em.createNamedQuery("Person.validatePassword", Integer.class)
                    .setParameter("password", password)
                    .setParameter("id", id)
                    .getSingleResult();
        } catch (NoResultException e) {
            return false;
        }
        return valid == 1;
    }

    public void close() {
        connectionProvider.close();
    }

}

Теперь перейдём к созданию непостредственно нашего User провайдера. Keycloak содержит большой набор интерфейсов, реализации которых отвечают за различные куски системы работы с пользователями.

Рассмотрим те из них, которые нам потребуются.

CredentialInputValidator

Реализация данного интерфейса отвечает за валидацию креденшелов пользователя и, в частности, за валидацию пароля, введённого пользователем на форме логина. Имеет три метода:

@Override
public boolean supportsCredentialType(String credentialType) {
    return PasswordCredentialModel.TYPE.equals(credentialType);
}

@Override
public boolean isConfiguredFor(RealmModel realmModel, UserModel userModel, String credentialType) {
    return supportsCredentialType(credentialType);
}

@Override
public boolean isValid(RealmModel realmModel, UserModel user, CredentialInput input) {
    if (!supportsCredentialType(input.getType()) || !(input instanceof UserCredentialModel)) {
        return false;
    }
    Person userByUsername = repository.findUserByUsername(user.getUsername());

    UserCredentialModel cred = (UserCredentialModel) input;
    return repository.validateCredentials(userByUsername.getId(), cred.getChallengeResponse());
}

 

UserLookupProvider

Реализация данного интерфейса позволяет нам добиться минимального функционала авторизации пользователя. Содержит три метода:

@Override
public UserModel getUserById(String id, RealmModel realm) {
    long externalId;
    try {
        externalId = Long.parseLong(StorageId.externalId(id));
    } catch (NumberFormatException e) {
        return null;
    }
    return createAdapter(realm, repository.findUserById(externalId));
}

@Override
public UserModel getUserByUsername(String username, RealmModel realm) {
    Person person = repository.findUserByUsername(username);
    if (person == null) return null;
    return createAdapter(realm, person);
}

@Override
public UserModel getUserByEmail(String email, RealmModel realm) {
    Person person = repository.findUserByEmail(email);
    if (person == null) return null;
    return createAdapter(realm, person);
}

Здесь createUserAdapter - метод, отвечающий за преобразование нашей модели к модели данных Keycloak путём создания адаптера.

protected UserModel createAdapter(RealmModel realm, Person person) {
    UserModel local = session.userLocalStorage().getUserByUsername(person.getPersonPasswd().getLogin(), realm);
    if (local == null) {
        local = session.userLocalStorage().addUser(realm, person.getPersonPasswd().getLogin());
        local.setFederationLink(model.getId());
    }
    return new UserModelDelegate(updateUser(local, person, realm));
}

 

UserStorageProvider

Интерфейс, определяющий, что данный компонтент является User провайдером. Из него нам необходим только следующий метод

@Override
public void close() {
    repository.close();
}

 

ImportedUserValidation

Реализация данного интерфейса позволит нам валидировать соответствие импортированного пользователя его оригиналу в БД Oracle. Имеет один метод, который вызывается каждый раз, когда происходит поиск пользователя в базе Keycloak, что происходит довольно-таки часто

@Override
public UserModel validate(RealmModel realm, UserModel user) {
    Person person = repository.findUserByUsername(user.getUsername());
    if (person == null) return null;
    return updateUser(user, person, realm);
}

Основная логика обновления юзера и иморта его ролей как раз содержится в методе updateUser

protected UserModel updateUser(UserModel local, Person person, RealmModel realm) {
    if (person.getPersonEmail() != null) {
        local.setEmail(person.getPersonEmail().getEmail());
        local.setEmailVerified(true);
    }

    Set<Role> deletedRoles = getDeletedRoles(person.getSecurityGroupSet());

    //Удаление ролей из кейклока, которые были удалены в системе
    for (RoleModel role : deletedRoles) {
        RoleModel deletedRole = realm.getRoleById(role.getId());
        if (deletedRole != null) {
            realm.removeRole(deletedRole);
        }
    }

    Set<Role> roles = getNotDeletedRoles(person.getSecurityGroupSet());
    //Удаление ролей пользователя из кейклока, если их нет в системе
    Set<RoleModel> grantedRoles = local.getRealmRoleMappings();
    for (RoleModel role : grantedRoles) {
        if (!roles.contains(new Role(role.getId(), role.getName(), role.getDescription()))
                && !realm.getDefaultRoles().contains(role.getName())) {
            local.deleteRoleMapping(role);
        }
    }

    //Добавление ролей в кейклок и выдача их юзеру, если они есть в системе
    for (Role role : roles) {
        if (!local.hasRole(role)) {
            RoleModel existingRole = realm.getRoleById(role.getId());
            if (existingRole == null) {
                existingRole = realm.addRole(role.getId(), role.getName());
            }
            existingRole.setDescription(role.getDescription());
            local.grantRole(existingRole);
        }
    }

    local.setFirstName(person.getFirstName());
    local.setLastName(person.getLastName());
    local.setEnabled(true);
    local.setSingleAttribute("middle_name", person.getMiddleName());
    local.setSingleAttribute("external_id", String.valueOf(person.getId()));
    return local;
}

protected Set<Role> getNotDeletedRoles(Set<SecurityGroup> securityGroups) {
    return securityGroups.stream()
            .filter(sg -> sg.getDeleted() == null)
            .map(sg -> new Role(sg.getId().toString(), sg.getName(), sg.getComment()))
            .collect(Collectors.toSet());
}

protected Set<Role> getDeletedRoles(Set<SecurityGroup> securityGroups) {
    return securityGroups.stream()
            .filter(sg -> sg.getDeleted() != null)
            .map(sg -> new Role(sg.getId().toString(), sg.getName(), sg.getComment()))
            .collect(Collectors.toSet());
}

 

Далее создаём фабрику для нашего провайдера

public class UserProviderFactory implements UserStorageProviderFactory<UserProvider> {
    private final ConnectionProviderFactory connectionProviderFactory = new ConnectionProviderFactory();

    @Override
    public DeUserProvider create(KeycloakSession session, ComponentModel model) {

        UserRepository repository = new UserRepository(connectionProviderFactory.create(session));

        return new UserProvider(session, model, repository);
    }

    @Override
    public String getId() {
        return "custom-user-provider";
    }
}

 

Завершающим этапом является создания файла recources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory с указанием полного имени нашей фаблики провайдеров.

com.tuneit.keycloak.provider.UserProviderFactory

После всех этих манипуляций можно собрать проект в jar'ник, задеплоить его в keycloak/standalone/deployments и создать новый Federation Provider в админке нужного Realm'а.