Добрый день!
Продолжим нашу тему по кастомизации портала 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'а.