diff --git a/docs/src/main/asciidoc/security-jpa.adoc b/docs/src/main/asciidoc/security-jpa.adoc index e93fd321100fb..4735b1ea11a09 100644 --- a/docs/src/main/asciidoc/security-jpa.adoc +++ b/docs/src/main/asciidoc/security-jpa.adoc @@ -185,6 +185,13 @@ For applications running in a production environment, do not store passwords as However, it is possible to store passwords as plain text with the `@Password(PasswordType.CLEAR)` annotation when operating in a test environment. ==== +[TIP] +==== +The xref:hibernate-orm.adoc#multitenancy[Hibernate Multitenancy] is supported and you can store the user entity in a persistence unit with enabled multitenancy. +However, if your `io.quarkus.hibernate.orm.runtime.tenant.TenantResolver` must access the `io.vertx.ext.web.RoutingContext` to resolve request details, you must disable proactive authentication. +For more information about proactive authentication, please see the Quarkus xref:security-proactive-authentication.adoc[Proactive authentication] guide. +==== + include::{generated-dir}/config/quarkus-security-jpa.adoc[opts=optional, leveloffset=+2] == References diff --git a/extensions/security-jpa/deployment/src/main/java/io/quarkus/security/jpa/deployment/QuarkusSecurityJpaProcessor.java b/extensions/security-jpa/deployment/src/main/java/io/quarkus/security/jpa/deployment/QuarkusSecurityJpaProcessor.java index 5c61111d08b36..a3a892bfe8399 100644 --- a/extensions/security-jpa/deployment/src/main/java/io/quarkus/security/jpa/deployment/QuarkusSecurityJpaProcessor.java +++ b/extensions/security-jpa/deployment/src/main/java/io/quarkus/security/jpa/deployment/QuarkusSecurityJpaProcessor.java @@ -13,10 +13,10 @@ import jakarta.inject.Singleton; import jakarta.persistence.EntityManager; -import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.Query; import org.hibernate.Session; +import org.hibernate.SessionFactory; import org.hibernate.SimpleNaturalIdLoadAccess; import org.hibernate.annotations.NaturalId; import org.jboss.jandex.AnnotationInstance; @@ -42,7 +42,10 @@ import io.quarkus.gizmo.MethodDescriptor; import io.quarkus.gizmo.ResultHandle; import io.quarkus.hibernate.orm.PersistenceUnit; +import io.quarkus.hibernate.orm.deployment.PersistenceUnitDescriptorBuildItem; +import io.quarkus.hibernate.orm.runtime.migration.MultiTenancyStrategy; import io.quarkus.panache.common.deployment.PanacheEntityClassesBuildItem; +import io.quarkus.runtime.configuration.ConfigurationException; import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.identity.request.TrustedAuthenticationRequest; import io.quarkus.security.identity.request.UsernamePasswordAuthenticationRequest; @@ -56,7 +59,7 @@ class QuarkusSecurityJpaProcessor { private static final DotName DOTNAME_NATURAL_ID = DotName.createSimple(NaturalId.class.getName()); - private static final DotName ENTITY_MANAGER_FACTORY_FACTORY = DotName.createSimple(EntityManagerFactory.class.getName()); + private static final DotName SESSION_FACTORY_FACTORY = DotName.createSimple(SessionFactory.class.getName()); private static final DotName JPA_IDENTITY_PROVIDER_NAME = DotName.createSimple(JpaIdentityProvider.class.getName()); private static final DotName JPA_TRUSTED_IDENTITY_PROVIDER_NAME = DotName .createSimple(JpaTrustedIdentityProvider.class.getName()); @@ -68,19 +71,21 @@ FeatureBuildItem feature() { } @BuildStep - void configureJpaAuthConfig(ApplicationIndexBuildItem index, - BuildProducer beanProducer, + void configureJpaAuthConfig(ApplicationIndexBuildItem index, List puDescriptors, + BuildProducer beanProducer, SecurityJpaBuildTimeConfig secJpaConfig, Optional jpaSecurityDefinitionBuildItem, PanacheEntityPredicateBuildItem panacheEntityPredicate) { if (jpaSecurityDefinitionBuildItem.isPresent()) { + final boolean requireActiveCDIRequestContext = shouldActivateCDIReqCtx(puDescriptors, secJpaConfig); JpaSecurityDefinition jpaSecurityDefinition = jpaSecurityDefinitionBuildItem.get().get(); generateIdentityProvider(index.getIndex(), jpaSecurityDefinition, jpaSecurityDefinition.passwordType(), - jpaSecurityDefinition.customPasswordProvider(), beanProducer, panacheEntityPredicate); + jpaSecurityDefinition.customPasswordProvider(), beanProducer, panacheEntityPredicate, + requireActiveCDIRequestContext); generateTrustedIdentityProvider(index.getIndex(), jpaSecurityDefinition, - beanProducer, panacheEntityPredicate); + beanProducer, panacheEntityPredicate, requireActiveCDIRequestContext); } } @@ -90,7 +95,7 @@ InjectionPointTransformerBuildItem transformer(SecurityJpaBuildTimeConfig config @Override public boolean appliesTo(Type requiredType) { - return requiredType.name().equals(ENTITY_MANAGER_FACTORY_FACTORY); + return requiredType.name().equals(SESSION_FACTORY_FACTORY); } public void transform(TransformationContext context) { @@ -123,7 +128,8 @@ private Set collectPanacheEntities(List p private void generateIdentityProvider(Index index, JpaSecurityDefinition jpaSecurityDefinition, AnnotationValue passwordTypeValue, AnnotationValue passwordProviderValue, - BuildProducer beanProducer, PanacheEntityPredicateBuildItem panacheEntityPredicate) { + BuildProducer beanProducer, PanacheEntityPredicateBuildItem panacheEntityPredicate, + boolean requireActiveCDIRequestContext) { GeneratedBeanGizmoAdaptor gizmoAdaptor = new GeneratedBeanGizmoAdaptor(beanProducer); String name = jpaSecurityDefinition.annotatedClass.name() + "__JpaIdentityProviderImpl"; @@ -137,6 +143,10 @@ private void generateIdentityProvider(Index index, JpaSecurityDefinition jpaSecu .setModifiers(Modifier.PRIVATE) .getFieldDescriptor(); + if (requireActiveCDIRequestContext) { + activateCDIRequestContext(classCreator); + } + try (MethodCreator methodCreator = classCreator.getMethodCreator("authenticate", SecurityIdentity.class, EntityManager.class, UsernamePasswordAuthenticationRequest.class)) { methodCreator.setModifiers(Modifier.PUBLIC); @@ -161,7 +171,8 @@ private void generateIdentityProvider(Index index, JpaSecurityDefinition jpaSecu } private void generateTrustedIdentityProvider(Index index, JpaSecurityDefinition jpaSecurityDefinition, - BuildProducer beanProducer, PanacheEntityPredicateBuildItem panacheEntityPredicate) { + BuildProducer beanProducer, PanacheEntityPredicateBuildItem panacheEntityPredicate, + boolean requireActiveCDIRequestContext) { GeneratedBeanGizmoAdaptor gizmoAdaptor = new GeneratedBeanGizmoAdaptor(beanProducer); String name = jpaSecurityDefinition.annotatedClass.name() + "__JpaTrustedIdentityProviderImpl"; @@ -175,6 +186,10 @@ private void generateTrustedIdentityProvider(Index index, JpaSecurityDefinition EntityManager.class, TrustedAuthenticationRequest.class)) { methodCreator.setModifiers(Modifier.PUBLIC); + if (requireActiveCDIRequestContext) { + activateCDIRequestContext(classCreator); + } + ResultHandle username = methodCreator.invokeVirtualMethod( MethodDescriptor.ofMethod(TrustedAuthenticationRequest.class, "getPrincipal", String.class), methodCreator.getMethodParam(1)); @@ -234,6 +249,30 @@ private ResultHandle lookupUserById(JpaSecurityDefinition jpaSecurityDefinition, return user; } + private static void activateCDIRequestContext(ClassCreator classCreator) { + try (MethodCreator methodCreator = classCreator.getMethodCreator("requireActiveCDIRequestContext", + DotName.createSimple(boolean.class.getName()).toString())) { + methodCreator.setModifiers(Modifier.PROTECTED); + methodCreator.returnBoolean(true); + } + } + + private static boolean shouldActivateCDIReqCtx(List puDescriptors, + SecurityJpaBuildTimeConfig secJpaConfig) { + var descriptor = puDescriptors.stream() + .filter(desc -> secJpaConfig.persistenceUnitName().equals(desc.getPersistenceUnitName())).findFirst(); + if (descriptor.isEmpty()) { + throw new ConfigurationException("Persistence unit '" + secJpaConfig.persistenceUnitName() + + "' specified with the 'quarkus.security-jpa.persistence-unit-name' configuration property" + + " does not exist. Please set valid persistence unit name."); + } + // 'io.quarkus.hibernate.orm.runtime.tenant.TenantResolver' is only resolved when CDI request context is active + // we need to active request context even when TenantResolver is @ApplicationScoped for tenant to be set + // see io.quarkus.hibernate.orm.runtime.tenant.HibernateCurrentTenantIdentifierResolver.resolveCurrentTenantIdentifier + // for more information + return descriptor.get().getConfig().getMultiTenancyStrategy() != MultiTenancyStrategy.NONE; + } + static final class EnabledIfNonDefaultPersistenceUnit implements BooleanSupplier { private final boolean useNonDefaultPersistenceUnit; diff --git a/extensions/security-jpa/deployment/src/test/java/io/quarkus/security/jpa/CustomHibernateTenantResolver.java b/extensions/security-jpa/deployment/src/test/java/io/quarkus/security/jpa/CustomHibernateTenantResolver.java new file mode 100644 index 0000000000000..a5b87c2e2e9d0 --- /dev/null +++ b/extensions/security-jpa/deployment/src/test/java/io/quarkus/security/jpa/CustomHibernateTenantResolver.java @@ -0,0 +1,35 @@ +package io.quarkus.security.jpa; + +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; + +import io.quarkus.hibernate.orm.PersistenceUnitExtension; +import io.quarkus.hibernate.orm.runtime.tenant.TenantResolver; +import io.vertx.ext.web.RoutingContext; + +@PersistenceUnitExtension +@RequestScoped +public class CustomHibernateTenantResolver implements TenantResolver { + + static volatile boolean useRoutingContext = false; + + @Inject + RoutingContext routingContext; + + @Override + public String getDefaultTenantId() { + return "one"; + } + + @Override + public String resolveTenantId() { + if (useRoutingContext) { + var tenant = routingContext.queryParam("tenant"); + if (!tenant.isEmpty()) { + return tenant.get(0); + } + } + return "two"; + } + +} diff --git a/extensions/security-jpa/deployment/src/test/java/io/quarkus/security/jpa/EagerAuthMultiTenantPersistenceUnitTest.java b/extensions/security-jpa/deployment/src/test/java/io/quarkus/security/jpa/EagerAuthMultiTenantPersistenceUnitTest.java new file mode 100644 index 0000000000000..2aa7aeedda8d3 --- /dev/null +++ b/extensions/security-jpa/deployment/src/test/java/io/quarkus/security/jpa/EagerAuthMultiTenantPersistenceUnitTest.java @@ -0,0 +1,37 @@ +package io.quarkus.security.jpa; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.RestAssured; + +public class EagerAuthMultiTenantPersistenceUnitTest extends JpaSecurityRealmTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(testClasses) + .addClass(MinimalUserEntity.class) + .addClass(CustomHibernateTenantResolver.class) + .addAsResource("minimal-config/import.sql", "import.sql") + .addAsResource("multitenant-persistence-unit/application.properties", "application.properties")); + + @Test + public void testRoutingCtxAccessInsideTenantResolver() { + // RoutingContext is not used inside TenantResolver to resolve tenant + RestAssured.given().auth().preemptive().basic("user", "user").when().get("/jaxrs-secured/roles-class/routing-context") + .then().statusCode(200); + + // RoutingContext is used and proactive auth is enabled => expect error + CustomHibernateTenantResolver.useRoutingContext = true; + try { + RestAssured.given().auth().preemptive().basic("user", "user").queryParam("tenant", "two").when() + .get("/jaxrs-secured/roles-class") + .then().statusCode(500); + } finally { + CustomHibernateTenantResolver.useRoutingContext = false; + } + } + +} diff --git a/extensions/security-jpa/deployment/src/test/java/io/quarkus/security/jpa/LazyAuthMultiTenantPersistenceUnitTest.java b/extensions/security-jpa/deployment/src/test/java/io/quarkus/security/jpa/LazyAuthMultiTenantPersistenceUnitTest.java new file mode 100644 index 0000000000000..00916a836ece3 --- /dev/null +++ b/extensions/security-jpa/deployment/src/test/java/io/quarkus/security/jpa/LazyAuthMultiTenantPersistenceUnitTest.java @@ -0,0 +1,44 @@ +package io.quarkus.security.jpa; + +import org.hamcrest.Matchers; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.RestAssured; + +public class LazyAuthMultiTenantPersistenceUnitTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(MinimalUserEntity.class, CustomHibernateTenantResolver.class, RolesEndpointClassLevel.class) + .addAsResource("minimal-config/import.sql", "import.sql") + .addAsResource("multitenant-persistence-unit/application.properties", "application.properties") + .addAsResource(new StringAsset("quarkus.http.auth.proactive=false\n"), + "META-INF/microprofile-config.properties")); + + @Test + public void testRoutingCtxAccessInsideTenantResolver() { + // RoutingContext is used and proactive auth is disabled => no issues + CustomHibernateTenantResolver.useRoutingContext = true; + try { + // tenant 'one' + RestAssured.given().auth().preemptive().basic("user", "user") + .queryParam("tenant", "one").when().get("/roles-class/routing-context").then() + .statusCode(200).body(Matchers.is("true")); + // tenant 'two' + RestAssured.given().auth().preemptive().basic("user", "user") + .queryParam("tenant", "two").when().get("/roles-class/routing-context").then() + .statusCode(200).body(Matchers.is("true")); + // tenant 'unknown' + RestAssured.given().auth().preemptive().basic("user", "user") + .queryParam("tenant", "unknown").when().get("/roles-class/routing-context").then() + .statusCode(500); + } finally { + CustomHibernateTenantResolver.useRoutingContext = false; + } + } + +} diff --git a/extensions/security-jpa/deployment/src/test/java/io/quarkus/security/jpa/RolesEndpointClassLevel.java b/extensions/security-jpa/deployment/src/test/java/io/quarkus/security/jpa/RolesEndpointClassLevel.java index d1585c21ee309..fbead74f68468 100644 --- a/extensions/security-jpa/deployment/src/test/java/io/quarkus/security/jpa/RolesEndpointClassLevel.java +++ b/extensions/security-jpa/deployment/src/test/java/io/quarkus/security/jpa/RolesEndpointClassLevel.java @@ -1,20 +1,33 @@ package io.quarkus.security.jpa; import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.SecurityContext; +import io.vertx.ext.web.RoutingContext; + /** * Test JAXRS endpoint with RolesAllowed specified at the class level */ @Path("/roles-class") @RolesAllowed("user") public class RolesEndpointClassLevel { + + @Inject + RoutingContext routingContext; + @GET public String echo(@Context SecurityContext sec) { return "Hello " + sec.getUserPrincipal().getName(); } + @Path("routing-context") + @GET + public boolean hasRoutingContext() { + return routingContext != null; + } + } diff --git a/extensions/security-jpa/deployment/src/test/resources/multitenant-persistence-unit/application.properties b/extensions/security-jpa/deployment/src/test/resources/multitenant-persistence-unit/application.properties new file mode 100644 index 0000000000000..42b5b2cfb7489 --- /dev/null +++ b/extensions/security-jpa/deployment/src/test/resources/multitenant-persistence-unit/application.properties @@ -0,0 +1,19 @@ +quarkus.datasource.db-kind=h2 +quarkus.datasource.username=sa +quarkus.datasource.password=sa +quarkus.datasource.jdbc.url=jdbc:h2:mem:default + +quarkus.datasource.one.db-kind=h2 +quarkus.datasource.one.username=sa +quarkus.datasource.one.password=sa +quarkus.datasource.one.jdbc.url=jdbc:h2:mem:shared + +quarkus.datasource.two.db-kind=h2 +quarkus.datasource.two.username=sa +quarkus.datasource.two.password=sa +quarkus.datasource.two.jdbc.url=jdbc:h2:mem:shared + +quarkus.hibernate-orm.multitenant=DATABASE +quarkus.hibernate-orm.sql-load-script=import.sql +quarkus.hibernate-orm.database.generation=drop-and-create +quarkus.hibernate-orm.packages=io.quarkus.security.jpa diff --git a/extensions/security-jpa/runtime/src/main/java/io/quarkus/security/jpa/runtime/JpaIdentityProvider.java b/extensions/security-jpa/runtime/src/main/java/io/quarkus/security/jpa/runtime/JpaIdentityProvider.java index 0d94b5752823d..15583f19b7bdb 100644 --- a/extensions/security-jpa/runtime/src/main/java/io/quarkus/security/jpa/runtime/JpaIdentityProvider.java +++ b/extensions/security-jpa/runtime/src/main/java/io/quarkus/security/jpa/runtime/JpaIdentityProvider.java @@ -5,12 +5,14 @@ import jakarta.inject.Inject; import jakarta.persistence.EntityManager; -import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.Query; import org.hibernate.FlushMode; +import org.hibernate.Session; +import org.hibernate.SessionFactory; import org.jboss.logging.Logger; +import io.quarkus.arc.Arc; import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.identity.AuthenticationRequestContext; import io.quarkus.security.identity.IdentityProvider; @@ -24,7 +26,7 @@ public abstract class JpaIdentityProvider implements IdentityProvider getRequestType() { @@ -37,27 +39,41 @@ public Uni authenticate(UsernamePasswordAuthenticationRequest return context.runBlocking(new Supplier() { @Override public SecurityIdentity get() { - EntityManager em = entityManagerFactory.createEntityManager(); - ((org.hibernate.Session) em).setHibernateFlushMode(FlushMode.MANUAL); - ((org.hibernate.Session) em).setDefaultReadOnly(true); - try { - return authenticate(em, request); - } catch (SecurityException e) { - log.debug("Authentication failed", e); - throw new AuthenticationFailedException(); - } finally { - em.close(); + if (requireActiveCDIRequestContext() && !Arc.container().requestContext().isActive()) { + var requestContext = Arc.container().requestContext(); + requestContext.activate(); + try { + return authenticate(request); + } finally { + requestContext.terminate(); + } } + return authenticate(request); } }); } + private SecurityIdentity authenticate(UsernamePasswordAuthenticationRequest request) { + try (Session session = sessionFactory.openSession()) { + session.setHibernateFlushMode(FlushMode.MANUAL); + session.setDefaultReadOnly(true); + return authenticate(session, request); + } catch (SecurityException e) { + log.debug("Authentication failed", e); + throw new AuthenticationFailedException(); + } + } + protected T getSingleUser(Query query) { @SuppressWarnings("unchecked") List results = (List) query.getResultList(); return JpaIdentityProviderUtil.getSingleUser(results); } + protected boolean requireActiveCDIRequestContext() { + return false; + } + public abstract SecurityIdentity authenticate(EntityManager em, UsernamePasswordAuthenticationRequest request); diff --git a/extensions/security-jpa/runtime/src/main/java/io/quarkus/security/jpa/runtime/JpaTrustedIdentityProvider.java b/extensions/security-jpa/runtime/src/main/java/io/quarkus/security/jpa/runtime/JpaTrustedIdentityProvider.java index c2bce02e629cc..00de38dda2989 100644 --- a/extensions/security-jpa/runtime/src/main/java/io/quarkus/security/jpa/runtime/JpaTrustedIdentityProvider.java +++ b/extensions/security-jpa/runtime/src/main/java/io/quarkus/security/jpa/runtime/JpaTrustedIdentityProvider.java @@ -5,12 +5,14 @@ import jakarta.inject.Inject; import jakarta.persistence.EntityManager; -import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.Query; import org.hibernate.FlushMode; +import org.hibernate.Session; +import org.hibernate.SessionFactory; import org.jboss.logging.Logger; +import io.quarkus.arc.Arc; import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.identity.AuthenticationRequestContext; import io.quarkus.security.identity.IdentityProvider; @@ -24,7 +26,7 @@ public abstract class JpaTrustedIdentityProvider implements IdentityProvider getRequestType() { @@ -37,21 +39,35 @@ public Uni authenticate(TrustedAuthenticationRequest request, return context.runBlocking(new Supplier() { @Override public SecurityIdentity get() { - EntityManager em = entityManagerFactory.createEntityManager(); - ((org.hibernate.Session) em).setHibernateFlushMode(FlushMode.MANUAL); - ((org.hibernate.Session) em).setDefaultReadOnly(true); - try { - return authenticate(em, request); - } catch (SecurityException e) { - log.debug("Authentication failed", e); - throw new AuthenticationFailedException(); - } finally { - em.close(); + if (requireActiveCDIRequestContext() && !Arc.container().requestContext().isActive()) { + var requestContext = Arc.container().requestContext(); + requestContext.activate(); + try { + return authenticate(request); + } finally { + requestContext.terminate(); + } } + return authenticate(request); } }); } + private SecurityIdentity authenticate(TrustedAuthenticationRequest request) { + try (Session session = sessionFactory.openSession()) { + session.setHibernateFlushMode(FlushMode.MANUAL); + session.setDefaultReadOnly(true); + return authenticate(session, request); + } catch (SecurityException e) { + log.debug("Authentication failed", e); + throw new AuthenticationFailedException(); + } + } + + protected boolean requireActiveCDIRequestContext() { + return false; + } + protected T getSingleUser(Query query) { @SuppressWarnings("unchecked") List results = (List) query.getResultList();