diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaIDPProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaIDPProvider.java new file mode 100644 index 0000000000..1e19a6162c --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaIDPProvider.java @@ -0,0 +1,340 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.jpa; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.NoResultException; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaDelete; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.JoinType; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import org.jboss.logging.Logger; +import org.keycloak.broker.provider.IdentityProvider; +import org.keycloak.broker.provider.IdentityProviderFactory; +import org.keycloak.broker.social.SocialIdentityProvider; +import org.keycloak.connections.jpa.JpaConnectionProvider; +import org.keycloak.models.IDPProvider; +import org.keycloak.models.IdentityProviderModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelException; +import org.keycloak.models.RealmModel; +import org.keycloak.models.jpa.entities.IdentityProviderEntity; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.utils.StringUtil; + +import static org.keycloak.models.jpa.PaginationUtils.paginateQuery; +import static org.keycloak.utils.StreamsUtil.closing; + +/** + * A JPA based implementation of {@link IDPProvider}. + * + * @author Stefan Guilhen + */ +public class JpaIDPProvider implements IDPProvider { + + protected static final Logger logger = Logger.getLogger(IDPProvider.class); + + private final EntityManager em; + private final KeycloakSession session; + + public JpaIDPProvider(KeycloakSession session) { + this.session = session; + this.em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); + } + + @Override + public IdentityProviderModel create(IdentityProviderModel identityProvider) { + IdentityProviderEntity entity = new IdentityProviderEntity(); + if (identityProvider.getInternalId() == null) { + entity.setInternalId(KeycloakModelUtils.generateId()); + } else { + entity.setInternalId(identityProvider.getInternalId()); + } + + entity.setAlias(identityProvider.getAlias()); + entity.setRealmId(this.getRealm().getId()); + entity.setDisplayName(identityProvider.getDisplayName()); + entity.setProviderId(identityProvider.getProviderId()); + entity.setEnabled(identityProvider.isEnabled()); + entity.setStoreToken(identityProvider.isStoreToken()); + entity.setAddReadTokenRoleOnCreate(identityProvider.isAddReadTokenRoleOnCreate()); + entity.setTrustEmail(identityProvider.isTrustEmail()); + entity.setAuthenticateByDefault(identityProvider.isAuthenticateByDefault()); + entity.setFirstBrokerLoginFlowId(identityProvider.getFirstBrokerLoginFlowId()); + entity.setPostBrokerLoginFlowId(identityProvider.getPostBrokerLoginFlowId()); + entity.setConfig(identityProvider.getConfig()); + entity.setLinkOnly(identityProvider.isLinkOnly()); + em.persist(entity); + // flush so that constraint violations are flagged and converted into model exception now rather than at the end of the tx. + em.flush(); + + identityProvider.setInternalId(entity.getInternalId()); + return identityProvider; + } + + @Override + public void update(IdentityProviderModel identityProvider) { + // find idp by id and update it. + IdentityProviderEntity entity = this.getEntityById(identityProvider.getInternalId(), true); + entity.setAlias(identityProvider.getAlias()); + entity.setDisplayName(identityProvider.getDisplayName()); + entity.setEnabled(identityProvider.isEnabled()); + entity.setTrustEmail(identityProvider.isTrustEmail()); + entity.setAuthenticateByDefault(identityProvider.isAuthenticateByDefault()); + entity.setFirstBrokerLoginFlowId(identityProvider.getFirstBrokerLoginFlowId()); + entity.setPostBrokerLoginFlowId(identityProvider.getPostBrokerLoginFlowId()); + entity.setAddReadTokenRoleOnCreate(identityProvider.isAddReadTokenRoleOnCreate()); + entity.setStoreToken(identityProvider.isStoreToken()); + entity.setConfig(identityProvider.getConfig()); + entity.setLinkOnly(identityProvider.isLinkOnly()); + + // flush so that constraint violations are flagged and converted into model exception now rather than at the end of the tx. + em.flush(); + + // send identity provider updated event. + RealmModel realm = this.getRealm(); + session.getKeycloakSessionFactory().publish(new RealmModel.IdentityProviderUpdatedEvent() { + + @Override + public RealmModel getRealm() { + return realm; + } + + @Override + public IdentityProviderModel getUpdatedIdentityProvider() { + return identityProvider; + } + + @Override + public KeycloakSession getKeycloakSession() { + return session; + } + }); + } + + @Override + public boolean remove(String alias) { + // find provider by alias in the DB and remove it. + IdentityProviderEntity entity = this.getEntityByAlias(alias); + + if (entity != null) { + em.remove(entity); + // flush so that constraint violations are flagged and converted into model exception now rather than at the end of the tx. + em.flush(); + + // send identity provider removed event. + RealmModel realm = this.getRealm(); + IdentityProviderModel model = toModel(entity); + session.getKeycloakSessionFactory().publish(new RealmModel.IdentityProviderRemovedEvent() { + + @Override + public RealmModel getRealm() { + return realm; + } + + @Override + public IdentityProviderModel getRemovedIdentityProvider() { + return model; + } + + @Override + public KeycloakSession getKeycloakSession() { + return session; + } + }); + return true; + } + return false; + } + + @Override + public void removeAll() { + CriteriaBuilder builder = em.getCriteriaBuilder(); + CriteriaDelete delete = builder.createCriteriaDelete(IdentityProviderEntity.class); + Root idp = delete.from(IdentityProviderEntity.class); + delete.where(builder.equal(idp.get("realmId"), this.getRealm().getId())); + this.em.createQuery(delete).executeUpdate(); + } + + @Override + public IdentityProviderModel getById(String internalId) { + return toModel(getEntityById(internalId, false)); + } + + @Override + public IdentityProviderModel getByAlias(String alias) { + return toModel(getEntityByAlias(alias)); + } + + @Override + public Stream getAllStream(String search, Integer first, Integer max) { + CriteriaBuilder builder = em.getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(IdentityProviderEntity.class); + Root idp = query.from(IdentityProviderEntity.class); + + List predicates = new ArrayList<>(); + predicates.add(builder.equal(idp.get("realmId"), getRealm().getId())); + + if (StringUtil.isNotBlank(search)) { + if (search.startsWith("\"") && search.endsWith("\"")) { + // exact search - alias must be an exact match + search = search.substring(1, search.length() - 1); + predicates.add(builder.equal(idp.get("alias"), search)); + } else { + search = search.replace("%", "\\%").replace("_", "\\_").replace("*", "%"); + if (!search.endsWith("%")) { + search += "%"; // default to prefix search + } + + predicates.add(builder.like(builder.lower(idp.get("alias")), search.toLowerCase(), '\\')); + } + } + + query.orderBy(builder.asc(idp.get("alias"))); + TypedQuery typedQuery = em.createQuery(query.select(idp).where(predicates.toArray(Predicate[]::new))); + return closing(paginateQuery(typedQuery, first, max).getResultStream()).map(this::toModel); + } + + @Override + public Stream getAllStream(Map attrs, Integer first, Integer max) { + CriteriaBuilder builder = em.getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(IdentityProviderEntity.class); + Root idp = query.from(IdentityProviderEntity.class); + + List predicates = new ArrayList<>(); + predicates.add(builder.equal(idp.get("realmId"), getRealm().getId())); + + if (attrs != null) { + for (Map.Entry entry : attrs.entrySet()) { + if (StringUtil.isBlank(entry.getKey())) { + continue; + } + Join configJoin = idp.join("config", JoinType.LEFT); + predicates.add(builder.and( + builder.equal(configJoin.get("name"), entry.getKey()), + builder.equal(configJoin.get("value"), entry.getValue()))); + } + } + + query.orderBy(builder.asc(idp.get("alias"))); + TypedQuery typedQuery = em.createQuery(query.select(idp).where(predicates.toArray(Predicate[]::new))); + return closing(paginateQuery(typedQuery, first, max).getResultStream()).map(this::toModel); + } + + @Override + public long count() { + CriteriaBuilder builder = em.getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(Long.class); + Root idp = query.from(IdentityProviderEntity.class); + query.select(builder.count(query.from(IdentityProviderEntity.class))); + query.where(builder.equal(idp.get("realmId"), getRealm().getId())); + return em.createQuery(query).getSingleResult(); + } + + @Override + public void close() { + } + + private IdentityProviderEntity getEntityById(String id, boolean failIfNotFound) { + IdentityProviderEntity entity = em.find(IdentityProviderEntity.class, id); + if (entity == null) { + if (failIfNotFound) { + throw new ModelException("Identity Provider with internal id [" + id + "] does not exist"); + } + return null; + } + + // check realm to ensure this entity is fetched in the context of the correct realm. + if (!this.getRealm().getId().equals(entity.getRealmId())) { + throw new ModelException("Identity Provider with internal id [" + entity.getInternalId() + "] does not belong to realm [" + getRealm().getName() + "]"); + } + return entity; + } + + private IdentityProviderEntity getEntityByAlias(String alias) { + CriteriaBuilder builder = em.getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(IdentityProviderEntity.class); + Root idp = query.from(IdentityProviderEntity.class); + + Predicate predicate = builder.and(builder.equal(idp.get("realmId"), getRealm().getId()), + builder.equal(idp.get("alias"), alias)); + + TypedQuery typedQuery = em.createQuery(query.select(idp).where(predicate)); + try { + return typedQuery.getSingleResult(); + } catch (NoResultException nre) { + return null; + } + } + + private IdentityProviderModel toModel(IdentityProviderEntity entity) { + if (entity == null) { + return null; + } + + IdentityProviderModel identityProviderModel = getModelFromProviderFactory(entity.getProviderId()); + identityProviderModel.setProviderId(entity.getProviderId()); + identityProviderModel.setAlias(entity.getAlias()); + identityProviderModel.setDisplayName(entity.getDisplayName()); + identityProviderModel.setInternalId(entity.getInternalId()); + Map config = new HashMap<>(entity.getConfig()); + identityProviderModel.setConfig(config); + identityProviderModel.setEnabled(entity.isEnabled()); + identityProviderModel.setLinkOnly(entity.isLinkOnly()); + identityProviderModel.setTrustEmail(entity.isTrustEmail()); + identityProviderModel.setAuthenticateByDefault(entity.isAuthenticateByDefault()); + identityProviderModel.setFirstBrokerLoginFlowId(entity.getFirstBrokerLoginFlowId()); + identityProviderModel.setPostBrokerLoginFlowId(entity.getPostBrokerLoginFlowId()); + identityProviderModel.setStoreToken(entity.isStoreToken()); + identityProviderModel.setAddReadTokenRoleOnCreate(entity.isAddReadTokenRoleOnCreate()); + + return identityProviderModel; + } + + private IdentityProviderModel getModelFromProviderFactory(String providerId) { + + IdentityProviderFactory factory = (IdentityProviderFactory) session.getKeycloakSessionFactory().getProviderFactory(IdentityProvider.class, providerId); + if (factory == null) { + factory = (IdentityProviderFactory) session.getKeycloakSessionFactory().getProviderFactory(SocialIdentityProvider.class, providerId); + } + + if (factory != null) { + return factory.createConfig(); + } else { + logger.warn("Couldn't find a suitable identity provider factory for " + providerId); + return new IdentityProviderModel(); + } + } + + private RealmModel getRealm() { + RealmModel realm = session.getContext().getRealm(); + if (realm == null) { + throw new IllegalStateException("Session not bound to a realm"); + } + return realm; + } +} diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/JpaIDPProviderFactory.java b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaIDPProviderFactory.java new file mode 100644 index 0000000000..64d3b824a3 --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/JpaIDPProviderFactory.java @@ -0,0 +1,54 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.models.jpa; + +import org.keycloak.Config; +import org.keycloak.models.IDPProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +/** + * A JPA based implementation of {@link IDPProviderFactory}. + * + * @author Stefan Guilhen + */ +public class JpaIDPProviderFactory implements IDPProviderFactory { + + public static final String ID = "jpa"; + + @Override + public JpaIDPProvider create(KeycloakSession session) { + return new JpaIDPProvider(session); + } + + @Override + public void init(Config.Scope config) { + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return ID; + } +} diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/IdentityProviderEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/IdentityProviderEntity.java index 4b4c89401a..4793aa3fd4 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/IdentityProviderEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/IdentityProviderEntity.java @@ -23,10 +23,8 @@ import jakarta.persistence.CollectionTable; import jakarta.persistence.Column; import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; import jakarta.persistence.MapKeyColumn; import jakarta.persistence.Table; import java.util.Map; @@ -43,9 +41,8 @@ public class IdentityProviderEntity { @Access(AccessType.PROPERTY) // we do this because relationships often fetch id, but not entity. This avoids an extra SQL protected String internalId; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "REALM_ID") - protected RealmEntity realm; + @Column(name = "REALM_ID") + protected String realmId; @Column(name="PROVIDER_ID") private String providerId; @@ -102,12 +99,12 @@ public class IdentityProviderEntity { this.providerId = providerId; } - public RealmEntity getRealm() { - return this.realm; + public String getRealmId() { + return this.realmId; } - public void setRealm(RealmEntity realm) { - this.realm = realm; + public void setRealmId(String realmId) { + this.realmId = realmId; } public String getAlias() { @@ -216,4 +213,4 @@ public class IdentityProviderEntity { return internalId.hashCode(); } -} \ No newline at end of file +} diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmEntity.java index 0fb5445ac5..45025ac7c7 100755 --- a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmEntity.java +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RealmEntity.java @@ -168,15 +168,15 @@ public class RealmEntity { @Column(name="VALUE") @CollectionTable(name="REALM_EVENTS_LISTENERS", joinColumns={ @JoinColumn(name="REALM_ID") }) protected Set eventsListeners; - + @ElementCollection @Column(name="VALUE") @CollectionTable(name="REALM_ENABLED_EVENT_TYPES", joinColumns={ @JoinColumn(name="REALM_ID") }) protected Set enabledEventTypes; - + @Column(name="ADMIN_EVENTS_ENABLED") protected boolean adminEventsEnabled; - + @Column(name="ADMIN_EVENTS_DETAILS_ENABLED") protected boolean adminEventsDetailsEnabled; @@ -186,7 +186,7 @@ public class RealmEntity { @Column(name="DEFAULT_ROLE") protected String defaultRoleId; - @OneToMany(cascade ={CascadeType.REMOVE}, orphanRemoval = true, mappedBy = "realm") + @OneToMany(cascade ={CascadeType.REMOVE}, orphanRemoval = true) protected List identityProviders = new LinkedList<>(); @OneToMany(cascade ={CascadeType.REMOVE}, orphanRemoval = true, mappedBy = "realm") @@ -304,7 +304,7 @@ public class RealmEntity { public void setVerifyEmail(boolean verifyEmail) { this.verifyEmail = verifyEmail; } - + public boolean isLoginWithEmailAllowed() { return loginWithEmailAllowed; } @@ -312,7 +312,7 @@ public class RealmEntity { public void setLoginWithEmailAllowed(boolean loginWithEmailAllowed) { this.loginWithEmailAllowed = loginWithEmailAllowed; } - + public boolean isDuplicateEmailsAllowed() { return duplicateEmailsAllowed; } @@ -538,7 +538,7 @@ public class RealmEntity { public void setEventsListeners(Set eventsListeners) { this.eventsListeners = eventsListeners; } - + public Set getEnabledEventTypes() { if (enabledEventTypes == null) { enabledEventTypes = new HashSet<>(); @@ -549,7 +549,7 @@ public class RealmEntity { public void setEnabledEventTypes(Set enabledEventTypes) { this.enabledEventTypes = enabledEventTypes; } - + public boolean isAdminEventsEnabled() { return adminEventsEnabled; } @@ -627,7 +627,7 @@ public class RealmEntity { } public void addIdentityProvider(IdentityProviderEntity entity) { - entity.setRealm(this); + entity.setRealmId(this.id); getIdentityProviders().add(entity); } @@ -675,7 +675,7 @@ public class RealmEntity { } return authenticators; } - + public void setAuthenticatorConfigs(Collection authenticators) { this.authenticators = authenticators; } diff --git a/model/jpa/src/main/resources/META-INF/services/org.keycloak.models.IDPProviderFactory b/model/jpa/src/main/resources/META-INF/services/org.keycloak.models.IDPProviderFactory new file mode 100644 index 0000000000..c1835b5a72 --- /dev/null +++ b/model/jpa/src/main/resources/META-INF/services/org.keycloak.models.IDPProviderFactory @@ -0,0 +1,18 @@ +# +# Copyright 2024 Red Hat, Inc. and/or its affiliates +# and other contributors as indicated by the @author tags. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +org.keycloak.models.jpa.JpaIDPProviderFactory \ No newline at end of file