diff --git a/.github/workflows/ci-actions.yml b/.github/workflows/ci-actions.yml index 6908ff81f80c3..77e6b5414a4ad 100644 --- a/.github/workflows/ci-actions.yml +++ b/.github/workflows/ci-actions.yml @@ -355,13 +355,14 @@ jobs: kafka kafka-streams - category: Security1 - timeout: 30 + timeout: 40 keycloak: "true" test-modules: > elytron-security-oauth2 elytron-security elytron-security-jdbc - elytron-undertow + elytron-undertow + elytron-security-ldap - category: Security2 timeout: 45 keycloak: "true" diff --git a/bom/deployment/pom.xml b/bom/deployment/pom.xml index d734353960eb1..05d90016fe75b 100644 --- a/bom/deployment/pom.xml +++ b/bom/deployment/pom.xml @@ -760,6 +760,11 @@ quarkus-security-jpa-deployment ${project.version} + + io.quarkus + quarkus-elytron-security-ldap-deployment + ${project.version} + io.quarkus quarkus-security-test-utils diff --git a/bom/runtime/pom.xml b/bom/runtime/pom.xml index 8aa72a7d053d6..6ecae25b2c0d8 100644 --- a/bom/runtime/pom.xml +++ b/bom/runtime/pom.xml @@ -860,6 +860,11 @@ quarkus-security-jpa ${project.version} + + io.quarkus + quarkus-elytron-security-ldap + ${project.version} + io.quarkus quarkus-vault @@ -2082,6 +2087,11 @@ wildfly-elytron-realm-jdbc ${wildfly-elytron.version} + + org.wildfly.security + wildfly-elytron-realm-ldap + ${wildfly-elytron.version} + org.wildfly.security wildfly-elytron-ssl diff --git a/docs/src/main/asciidoc/security-ldap.adoc b/docs/src/main/asciidoc/security-ldap.adoc new file mode 100644 index 0000000000000..5937520e12763 --- /dev/null +++ b/docs/src/main/asciidoc/security-ldap.adoc @@ -0,0 +1,226 @@ +//// +This guide is maintained in the main Quarkus repository +and pull requests should be submitted there: +https://github.com/quarkusio/quarkus/tree/master/docs/src/main/asciidoc +//// += Quarkus - Using Security with an LDAP Realm + +include::./attributes.adoc[] + +This guide demonstrates how your Quarkus application can use an LDAP server to authenticate and authorize your user identities. + + +== Prerequisites + +To complete this guide, you need: + +* less than 15 minutes +* an IDE +* JDK 1.8+ installed with `JAVA_HOME` configured appropriately +* Apache Maven {maven-version} + +== Architecture + +In this example, we build a very simple microservice which offers three endpoints: + +* `/api/public` +* `/api/users/me` +* `/api/admin` + +The `/api/public` endpoint can be accessed anonymously. +The `/api/admin` endpoint is protected with RBAC (Role-Based Access Control) where only users granted with the `adminRole` role can access. At this endpoint, we use the `@RolesAllowed` annotation to declaratively enforce the access constraint. +The `/api/users/me` endpoint is also protected with RBAC (Role-Based Access Control) where only users granted with the `standardRole` role can access. As a response, it returns a JSON document with details about the user. + +== Solution + +We recommend that you follow the instructions in the next sections and create the application step by step. +However, you can go right to the completed example. + +Clone the Git repository: `git clone {quickstarts-clone-url}`, or download an {quickstarts-archive-url}[archive]. + +The solution is located in the `security-ldap-quickstart` {quickstarts-tree-url}/security-ldap-quickstart[directory]. + +== Creating the Maven Project + +First, we need a new project. Create a new project with the following command: + +[source, subs=attributes+] +---- +mvn io.quarkus:quarkus-maven-plugin:{quarkus-version}:create \ + -DprojectGroupId=org.acme \ + -DprojectArtifactId=security-ldap-quickstart \ + -Dextensions="elytron-security-ldap, resteasy" +cd security-ldap-quickstart +---- + +This command generates a Maven project, importing the `elytron-security-ldap` extension +which is a https://docs.wildfly.org/19/WildFly_Elytron_Security.html#ldap-security-realm[`wildfly-elytron-realm-ldap`] adapter for Quarkus applications. + +== Writing the application + +Let's start by implementing the `/api/public` endpoint. As you can see from the source code below, it is just a regular JAX-RS resource: + +[source,java] +---- +package org.acme.elytron.security.ldap; + +import javax.annotation.security.PermitAll; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; + +@Path("/api/public") +public class PublicResource { + + @GET + @PermitAll + @Produces(MediaType.TEXT_PLAIN) + public String publicResource() { + return "public"; + } +} +---- + +The source code for the `/api/admin` endpoint is also very simple. The main difference here is that we are using a `@RolesAllowed` annotation to make sure that only users granted with the `adminRole` role can access the endpoint: + + +[source,java] +---- +package org.acme.elytron.security.ldap; + +import javax.annotation.security.RolesAllowed; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; + +@Path("/api/admin") +public class AdminResource { + + @GET + @RolesAllowed("adminRole") + @Produces(MediaType.TEXT_PLAIN) + public String adminResource() { + return "admin"; + } +} +---- + +Finally, let's consider the `/api/users/me` endpoint. As you can see from the source code below, we are trusting only users with the `standardRole` role. +We are using `SecurityContext` to get access to the current authenticated Principal and we return the user's name. This information is loaded from the LDAP server. + +[source,java] +---- +package org.acme.elytron.security.ldap; + +import javax.annotation.security.RolesAllowed; +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.SecurityContext; + +@Path("/api/users") +public class UserResource { + + @GET + @RolesAllowed("standardRole") + @Path("/me") + @Produces(MediaType.APPLICATION_JSON) + public String me(@Context SecurityContext securityContext) { + return securityContext.getUserPrincipal().getName(); + } +} +---- + +=== Configuring the Application + +[source,properties] +---- +quarkus.security.ldap.enabled=true + +quarkus.security.ldap.dir-context.principal=uid=tool,ou=accounts,o=YourCompany,c=DE +quarkus.security.ldap.dir-context.url=ldaps://ldap.server.local +quarkus.security.ldap.dir-context.password=PASSWORD + +quarkus.security.ldap.identity-mapping.rdn-identifier=uid +quarkus.security.ldap.identity-mapping.search-base-dn=ou=users,ou=tool,o=YourCompany,c=DE + +quarkus.security.ldap.identity-mapping.attribute-mappings."0".from=cn +quarkus.security.ldap.identity-mapping.attribute-mappings."0".to=groups +quarkus.security.ldap.identity-mapping.attribute-mappings."0".filter=(member=uid={0}) +quarkus.security.ldap.identity-mapping.attribute-mappings."0".filter-base-dn=ou=roles,ou=tool,o=YourCompany,c=DE +---- + +The `elytron-security-ldap` extension requires a dir-context and an identity-mapping with at least one attribute-mapping to authenticate the user and its identity. + +== Testing the Application + +The application is now protected and the identities are provided by our LDAP server. +The very first thing to check is to ensure the anonymous access works. + +[source,shell] +---- +$ curl -i -X GET http://localhost:8080/api/public +HTTP/1.1 200 OK +Content-Length: 6 +Content-Type: text/plain;charset=UTF-8 + +public% +---- + +Now, let's try a to hit a protected resource anonymously. + +[source,shell] +---- +$ curl -i -X GET http://localhost:8080/api/admin +HTTP/1.1 401 Unauthorized +Content-Length: 14 +Content-Type: text/html;charset=UTF-8 + +Not authorized% +---- + +So far so good, now let's try with an allowed user. + +[source,shell] +---- +$ curl -i -X GET -u adminUser:adminUserPassword http://localhost:8080/api/admin +HTTP/1.1 200 OK +Content-Length: 5 +Content-Type: text/plain;charset=UTF-8 + +admin% +---- +By providing the `adminUser:adminUserPassword` credentials, the extension authenticated the user and loaded their roles. +The `adminUser` user is authorized to access to the protected resources. + +The user `adminUser` should be forbidden to access a resource protected with `@RolesAllowed("standardRole")` because it doesn't have this role. +[source,shell] +---- +$ curl -i -X GET -u adminUser:adminUserPassword http://localhost:8080/api/users/me +HTTP/1.1 403 Forbidden +Content-Length: 34 +Content-Type: text/html;charset=UTF-8 + +Forbidden% +---- + +Finally, using the user `standardUser` works and the security context contains the principal details (username for instance). +[source,shell] +---- +curl -i -X GET -u standardUser:standardUserPassword http://localhost:8080/api/users/me +HTTP/1.1 200 OK +Content-Length: 4 +Content-Type: text/plain;charset=UTF-8 + +user% +---- + +[[configuration-reference]] +== Configuration Reference + +include::{generated-dir}/config/quarkus-elytron-security-ldap.adoc[opts=optional, leveloffset=+1] diff --git a/extensions/elytron-security-ldap/deployment/pom.xml b/extensions/elytron-security-ldap/deployment/pom.xml new file mode 100644 index 0000000000000..3cf64668dd879 --- /dev/null +++ b/extensions/elytron-security-ldap/deployment/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + io.quarkus + quarkus-elytron-security-ldap-parent + 999-SNAPSHOT + ../pom.xml + + + quarkus-elytron-security-ldap-deployment + Quarkus - Elytron Security LDAP - Deployment + + + + io.quarkus + quarkus-elytron-security-ldap + + + + io.quarkus + quarkus-core-deployment + + + io.quarkus + quarkus-arc-deployment + + + io.quarkus + quarkus-elytron-security-deployment + + + + io.quarkus + quarkus-undertow + test + + + io.quarkus + quarkus-undertow-deployment + test + + + io.quarkus + quarkus-resteasy-deployment + test + + + io.quarkus + quarkus-junit5-internal + test + + + io.rest-assured + rest-assured + test + + + io.quarkus + quarkus-test-ldap + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + + diff --git a/extensions/elytron-security-ldap/deployment/src/main/java/io/quarkus/elytron/security/ldap/deployment/ElytronSecurityLdapProcessor.java b/extensions/elytron-security-ldap/deployment/src/main/java/io/quarkus/elytron/security/ldap/deployment/ElytronSecurityLdapProcessor.java new file mode 100644 index 0000000000000..acf5da21ed6d1 --- /dev/null +++ b/extensions/elytron-security-ldap/deployment/src/main/java/io/quarkus/elytron/security/ldap/deployment/ElytronSecurityLdapProcessor.java @@ -0,0 +1,67 @@ +package io.quarkus.elytron.security.ldap.deployment; + +import org.wildfly.security.auth.server.SecurityRealm; + +import io.quarkus.arc.deployment.BeanContainerBuildItem; +import io.quarkus.deployment.Capabilities; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.CapabilityBuildItem; +import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; +import io.quarkus.elytron.security.deployment.ElytronPasswordMarkerBuildItem; +import io.quarkus.elytron.security.deployment.SecurityRealmBuildItem; +import io.quarkus.elytron.security.ldap.LdapRecorder; +import io.quarkus.elytron.security.ldap.QuarkusDirContextFactory; +import io.quarkus.elytron.security.ldap.config.LdapSecurityRealmConfig; +import io.quarkus.runtime.RuntimeValue; + +class ElytronSecurityLdapProcessor { + + LdapSecurityRealmConfig ldap; + + @BuildStep + CapabilityBuildItem capability() { + return new CapabilityBuildItem(Capabilities.SECURITY_ELYTRON_LDAP); + } + + @BuildStep() + FeatureBuildItem feature() { + return new FeatureBuildItem(FeatureBuildItem.SECURITY_LDAP); + } + + /** + * Check to see if a LdapRealmConfig was specified and enabled and create a + * {@linkplain org.wildfly.security.auth.realm.ldap.LdapSecurityRealm} + * + * @param recorder - runtime security recorder + * @param securityRealm - the producer factory for the SecurityRealmBuildItem + * @throws Exception - on any failure + */ + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + void configureLdapRealmAuthConfig(LdapRecorder recorder, + BuildProducer securityRealm, + BeanContainerBuildItem beanContainerBuildItem //we need this to make sure ArC is initialized + ) throws Exception { + if (ldap.enabled) { + RuntimeValue realm = recorder.createRealm(ldap); + securityRealm.produce(new SecurityRealmBuildItem(realm, ldap.realmName, null)); + } + } + + @BuildStep + ElytronPasswordMarkerBuildItem marker() { + if (ldap.enabled) { + return new ElytronPasswordMarkerBuildItem(); + } + return null; + } + + @BuildStep + ReflectiveClassBuildItem enableReflection() { + return new ReflectiveClassBuildItem(true, true, QuarkusDirContextFactory.INITIAL_CONTEXT_FACTORY); + } +} diff --git a/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/CustomRoleDecoder.java b/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/CustomRoleDecoder.java new file mode 100644 index 0000000000000..9697832847407 --- /dev/null +++ b/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/CustomRoleDecoder.java @@ -0,0 +1,28 @@ +package io.quarkus.elytron.security.ldap; + +import java.util.HashSet; +import java.util.Set; +import java.util.stream.StreamSupport; + +import javax.enterprise.context.ApplicationScoped; + +import org.wildfly.security.authz.Attributes; +import org.wildfly.security.authz.AuthorizationIdentity; +import org.wildfly.security.authz.RoleDecoder; +import org.wildfly.security.authz.Roles; + +@ApplicationScoped +public class CustomRoleDecoder implements RoleDecoder { + + @Override + public Roles decodeRoles(AuthorizationIdentity authorizationIdentity) { + Attributes.Entry groupsEntry = authorizationIdentity.getAttributes().get("Roles"); + Set roles = new HashSet<>(); + StreamSupport.stream(groupsEntry.spliterator(), false).forEach(groups -> { + for (String role : groups.split(",")) { + roles.add(role.trim()); + } + }); + return Roles.fromSet(roles); + } +} diff --git a/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/CustomRoleDecoderTest.java b/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/CustomRoleDecoderTest.java new file mode 100644 index 0000000000000..e4be68b51a431 --- /dev/null +++ b/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/CustomRoleDecoderTest.java @@ -0,0 +1,24 @@ +package io.quarkus.elytron.security.ldap; + +import java.util.Arrays; +import java.util.stream.Stream; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class CustomRoleDecoderTest extends LdapSecurityRealmTest { + + static Class[] testClassesWithCustomRoleDecoder = Stream.concat( + Arrays.stream(testClasses), + Arrays.stream(new Class[] { CustomRoleDecoder.class })).toArray(Class[]::new); + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClasses(testClassesWithCustomRoleDecoder) + .addAsResource("custom-role-decoder/application.properties", "application.properties")); + +} diff --git a/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/LdapSecurityRealmTest.java b/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/LdapSecurityRealmTest.java new file mode 100644 index 0000000000000..ed589a1af7aed --- /dev/null +++ b/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/LdapSecurityRealmTest.java @@ -0,0 +1,152 @@ +package io.quarkus.elytron.security.ldap; + +import static org.hamcrest.Matchers.equalTo; + +import org.junit.jupiter.api.Test; + +import io.quarkus.elytron.security.ldap.rest.ParametrizedPathsResource; +import io.quarkus.elytron.security.ldap.rest.RolesEndpointClassLevel; +import io.quarkus.elytron.security.ldap.rest.SingleRoleSecuredServlet; +import io.quarkus.elytron.security.ldap.rest.SubjectExposingResource; +import io.quarkus.elytron.security.ldap.rest.TestApplication; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.ldap.LdapServerTestResource; +import io.restassured.RestAssured; + +/** + * Tests of BASIC authentication mechanism with the minimal config required + */ + +@QuarkusTestResource(LdapServerTestResource.class) +public abstract class LdapSecurityRealmTest { + + protected static Class[] testClasses = { + SingleRoleSecuredServlet.class, TestApplication.class, RolesEndpointClassLevel.class, + ParametrizedPathsResource.class, SubjectExposingResource.class + }; + + // Basic @ServletSecurity tests + @Test() + public void testSecureAccessFailure() { + RestAssured.when().get("/servlet-secured").then() + .statusCode(401); + } + + @Test() + public void testSecureRoleFailure() { + RestAssured.given().auth().preemptive().basic("noRoleUser", "noRoleUserPassword") + .when().get("/servlet-secured").then() + .statusCode(403); + } + + @Test() + public void testSecureAccessSuccess() { + RestAssured.given().auth().preemptive().basic("standardUser", "standardUserPassword") + .when().get("/servlet-secured").then() + .statusCode(200); + } + + /** + * Test access a secured jaxrs resource without any authentication. should see 401 error code. + */ + @Test + public void testJaxrsGetFailure() { + RestAssured.when().get("/jaxrs-secured/roles-class").then() + .statusCode(401); + } + + /** + * Test access a secured jaxrs resource with authentication, but no authorization. should see 403 error code. + */ + @Test + public void testJaxrsGetRoleFailure() { + RestAssured.given().auth().preemptive().basic("noRoleUser", "noRoleUserPassword") + .when().get("/jaxrs-secured/roles-class").then() + .statusCode(403); + } + + /** + * Test access a secured jaxrs resource with authentication, and authorization. should see 200 success code. + */ + @Test + public void testJaxrsGetRoleSuccess() { + RestAssured.given().auth().preemptive().basic("standardUser", "standardUserPassword") + .when().get("/jaxrs-secured/roles-class").then() + .statusCode(200); + } + + /** + * Test access a secured jaxrs resource with authentication, and authorization. should see 200 success code. + */ + @Test + public void testJaxrsPathAdminRoleSuccess() { + RestAssured.given().auth().preemptive().basic("adminUser", "adminUserPassword") + .when().get("/jaxrs-secured/parameterized-paths/my/banking/admin").then() + .statusCode(200); + } + + @Test + public void testJaxrsPathAdminRoleFailure() { + RestAssured.given().auth().preemptive().basic("standardUser", "standardUserPassword") + .when().get("/jaxrs-secured/parameterized-paths/my/banking/admin").then() + .statusCode(403); + } + + /** + * Test access a secured jaxrs resource with authentication, and authorization. should see 200 success code. + */ + @Test + public void testJaxrsPathUserRoleSuccess() { + RestAssured.given().auth().preemptive().basic("standardUser", "standardUserPassword") + .when().get("/jaxrs-secured/parameterized-paths/my/banking/view").then() + .statusCode(200); + } + + /** + * Test access a secured jaxrs resource with authentication, and authorization. should see 200 success code. + */ + @Test + public void testJaxrsUserRoleSuccess() { + RestAssured.given().auth().preemptive().basic("standardUser", "standardUserPassword") + .when().get("/jaxrs-secured/subject/secured").then() + .statusCode(200) + .body(equalTo("standardUser")); + } + + @Test + public void testJaxrsInjectedPrincipalSuccess() { + RestAssured.given().auth().preemptive().basic("standardUser", "standardUserPassword") + .when().get("/jaxrs-secured/subject/principal-secured").then() + .statusCode(200) + .body(equalTo("standardUser")); + } + + /** + * Test access a @PermitAll secured jaxrs resource without any authentication. should see a 200 success code. + */ + @Test + public void testJaxrsGetPermitAll() { + RestAssured.when().get("/jaxrs-secured/subject/unsecured").then() + .statusCode(200) + .body(equalTo("anonymous")); + } + + /** + * Test access a @DenyAll secured jaxrs resource without authentication. should see a 401 success code. + */ + @Test + public void testJaxrsGetDenyAllWithoutAuth() { + RestAssured.when().get("/jaxrs-secured/subject/denied").then() + .statusCode(401); + } + + /** + * Test access a @DenyAll secured jaxrs resource with authentication. should see a 403 success code. + */ + @Test + public void testJaxrsGetDenyAllWithAuth() { + RestAssured.given().auth().preemptive().basic("standardUser", "standardUserPassword") + .when().get("/jaxrs-secured/subject/denied").then() + .statusCode(403); + } +} diff --git a/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/MinimalConfigurationTest.java b/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/MinimalConfigurationTest.java new file mode 100644 index 0000000000000..6ae33d5b81698 --- /dev/null +++ b/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/MinimalConfigurationTest.java @@ -0,0 +1,17 @@ +package io.quarkus.elytron.security.ldap; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class MinimalConfigurationTest extends LdapSecurityRealmTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClasses(testClasses) + .addAsResource("minimal-config/application.properties", "application.properties")); + +} diff --git a/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/rest/ParametrizedPathsResource.java b/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/rest/ParametrizedPathsResource.java new file mode 100644 index 0000000000000..33d8be9dfdf78 --- /dev/null +++ b/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/rest/ParametrizedPathsResource.java @@ -0,0 +1,23 @@ +package io.quarkus.elytron.security.ldap.rest; + +import javax.annotation.security.RolesAllowed; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; + +@Path("/parameterized-paths") +public class ParametrizedPathsResource { + @GET + @Path("/my/{path}/admin") + @RolesAllowed("adminRole") + public String admin(@PathParam("path") String path) { + return "Admin accessed " + path; + } + + @GET + @Path("/my/{path}/view") + @RolesAllowed("standardRole") + public String view(@PathParam("path") String path) { + return "View accessed " + path; + } +} diff --git a/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/rest/RolesEndpointClassLevel.java b/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/rest/RolesEndpointClassLevel.java new file mode 100644 index 0000000000000..fc8e8f8b8ebed --- /dev/null +++ b/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/rest/RolesEndpointClassLevel.java @@ -0,0 +1,20 @@ +package io.quarkus.elytron.security.ldap.rest; + +import javax.annotation.security.RolesAllowed; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.SecurityContext; + +/** + * Test JAXRS endpoint with RolesAllowed specified at the class level + */ +@Path("/roles-class") +@RolesAllowed("standardRole") +public class RolesEndpointClassLevel { + @GET + public String echo(@Context SecurityContext sec) { + return "Hello " + sec.getUserPrincipal().getName(); + } + +} diff --git a/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/rest/SingleRoleSecuredServlet.java b/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/rest/SingleRoleSecuredServlet.java new file mode 100644 index 0000000000000..a9c6df3421306 --- /dev/null +++ b/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/rest/SingleRoleSecuredServlet.java @@ -0,0 +1,24 @@ +package io.quarkus.elytron.security.ldap.rest; + +import java.io.IOException; + +import javax.servlet.annotation.HttpConstraint; +import javax.servlet.annotation.ServletSecurity; +import javax.servlet.annotation.WebInitParam; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * Basic secured servlet test target + */ +@ServletSecurity(@HttpConstraint(rolesAllowed = { "standardRole" })) +@WebServlet(name = "SingleRoleSecuredServlet", urlPatterns = "/servlet-secured", initParams = { + @WebInitParam(name = "message", value = "A secured message") }) +public class SingleRoleSecuredServlet extends HttpServlet { + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.getWriter().write(getInitParameter("message")); + } +} diff --git a/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/rest/SubjectExposingResource.java b/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/rest/SubjectExposingResource.java new file mode 100644 index 0000000000000..124a0a7f704e7 --- /dev/null +++ b/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/rest/SubjectExposingResource.java @@ -0,0 +1,57 @@ +package io.quarkus.elytron.security.ldap.rest; + +import java.security.Principal; + +import javax.annotation.security.DenyAll; +import javax.annotation.security.PermitAll; +import javax.annotation.security.RolesAllowed; +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.SecurityContext; + +@Path("subject") +public class SubjectExposingResource { + + @Inject + Principal principal; + + @GET + @RolesAllowed("standardRole") + @Path("secured") + public String getSubjectSecured(@Context SecurityContext sec) { + Principal user = sec.getUserPrincipal(); + String name = user != null ? user.getName() : "anonymous"; + return name; + } + + @GET + @RolesAllowed("standardRole") + @Path("principal-secured") + public String getPrincipalSecured(@Context SecurityContext sec) { + if (principal == null) { + throw new IllegalStateException("No injected principal"); + } + String name = principal.getName(); + return name; + } + + @GET + @Path("unsecured") + @PermitAll + public String getSubjectUnsecured(@Context SecurityContext sec) { + Principal user = sec.getUserPrincipal(); + String name = user != null ? user.getName() : "anonymous"; + return name; + } + + @DenyAll + @GET + @Path("denied") + public String getSubjectDenied(@Context SecurityContext sec) { + Principal user = sec.getUserPrincipal(); + String name = user != null ? user.getName() : "anonymous"; + return name; + } +} diff --git a/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/rest/TestApplication.java b/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/rest/TestApplication.java new file mode 100644 index 0000000000000..752efd59d42e6 --- /dev/null +++ b/extensions/elytron-security-ldap/deployment/src/test/java/io/quarkus/elytron/security/ldap/rest/TestApplication.java @@ -0,0 +1,11 @@ +package io.quarkus.elytron.security.ldap.rest; + +import javax.enterprise.context.ApplicationScoped; +import javax.ws.rs.ApplicationPath; +import javax.ws.rs.core.Application; + +@ApplicationScoped +@ApplicationPath("/jaxrs-secured") +public class TestApplication extends Application { + // intentionally left empty +} diff --git a/extensions/elytron-security-ldap/deployment/src/test/resources/custom-role-decoder/application.properties b/extensions/elytron-security-ldap/deployment/src/test/resources/custom-role-decoder/application.properties new file mode 100644 index 0000000000000..4e5cf424913d3 --- /dev/null +++ b/extensions/elytron-security-ldap/deployment/src/test/resources/custom-role-decoder/application.properties @@ -0,0 +1,13 @@ +quarkus.security.ldap.enabled=true + +quarkus.security.ldap.dir-context.principal=uid=admin,ou=system +quarkus.security.ldap.dir-context.url=ldap://127.0.0.1:10389 +quarkus.security.ldap.dir-context.password=secret + +quarkus.security.ldap.identity-mapping.rdn-identifier=uid +quarkus.security.ldap.identity-mapping.search-base-dn=ou=Users,dc=quarkus,dc=io + +quarkus.security.ldap.identity-mapping.attribute-mappings."0".from=cn +quarkus.security.ldap.identity-mapping.attribute-mappings."0".to=Roles +quarkus.security.ldap.identity-mapping.attribute-mappings."0".filter=(member=uid={0},ou=Users,dc=quarkus,dc=io) +quarkus.security.ldap.identity-mapping.attribute-mappings."0".filter-base-dn=ou=Roles,dc=quarkus,dc=io \ No newline at end of file diff --git a/extensions/elytron-security-ldap/deployment/src/test/resources/minimal-config/application.properties b/extensions/elytron-security-ldap/deployment/src/test/resources/minimal-config/application.properties new file mode 100644 index 0000000000000..e5d01d25ea966 --- /dev/null +++ b/extensions/elytron-security-ldap/deployment/src/test/resources/minimal-config/application.properties @@ -0,0 +1,11 @@ +quarkus.security.ldap.enabled=true + +quarkus.security.ldap.dir-context.principal=uid=admin,ou=system +quarkus.security.ldap.dir-context.url=ldap://127.0.0.1:10389 +quarkus.security.ldap.dir-context.password=secret + +quarkus.security.ldap.identity-mapping.search-base-dn=ou=Users,dc=quarkus,dc=io + +quarkus.security.ldap.identity-mapping.attribute-mappings."0".from=cn +quarkus.security.ldap.identity-mapping.attribute-mappings."0".filter=(member=uid={0},ou=Users,dc=quarkus,dc=io) +quarkus.security.ldap.identity-mapping.attribute-mappings."0".filter-base-dn=ou=Roles,dc=quarkus,dc=io diff --git a/extensions/elytron-security-ldap/pom.xml b/extensions/elytron-security-ldap/pom.xml new file mode 100644 index 0000000000000..c7f782508322a --- /dev/null +++ b/extensions/elytron-security-ldap/pom.xml @@ -0,0 +1,21 @@ + + + 4.0.0 + + io.quarkus + quarkus-extensions-parent + 999-SNAPSHOT + ../pom.xml + + + quarkus-elytron-security-ldap-parent + Quarkus - Elytron Security LDAP + + pom + + deployment + runtime + + diff --git a/extensions/elytron-security-ldap/runtime/pom.xml b/extensions/elytron-security-ldap/runtime/pom.xml new file mode 100644 index 0000000000000..c97b7bb7c9fbd --- /dev/null +++ b/extensions/elytron-security-ldap/runtime/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + + io.quarkus + quarkus-elytron-security-ldap-parent + 999-SNAPSHOT + ../pom.xml + + + quarkus-elytron-security-ldap + Quarkus - Elytron Security LDAP - Runtime + Secure your applications with username/password via LDAP + + + io.quarkus + quarkus-core + + + io.quarkus + quarkus-elytron-security + + + io.quarkus + quarkus-arc + + + org.wildfly.security + wildfly-elytron-realm-ldap + + + + + + + io.quarkus + quarkus-bootstrap-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + diff --git a/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/DelegatingLdapContext.java b/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/DelegatingLdapContext.java new file mode 100644 index 0000000000000..c90a9da278e2c --- /dev/null +++ b/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/DelegatingLdapContext.java @@ -0,0 +1,497 @@ +package io.quarkus.elytron.security.ldap; + +import java.security.AccessController; +import java.security.PrivilegedAction; +import java.util.Hashtable; + +import javax.naming.Binding; +import javax.naming.Context; +import javax.naming.Name; +import javax.naming.NameClassPair; +import javax.naming.NameParser; +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.ReferralException; +import javax.naming.directory.Attributes; +import javax.naming.directory.DirContext; +import javax.naming.directory.ModificationItem; +import javax.naming.directory.SearchControls; +import javax.naming.directory.SearchResult; +import javax.naming.ldap.Control; +import javax.naming.ldap.ExtendedRequest; +import javax.naming.ldap.ExtendedResponse; +import javax.naming.ldap.InitialLdapContext; +import javax.naming.ldap.LdapContext; +import javax.net.SocketFactory; + +import org.wildfly.common.Assert; +import org.wildfly.security.auth.realm.ldap.ThreadLocalSSLSocketFactory; +import org.wildfly.security.manager.action.SetContextClassLoaderAction; + +class DelegatingLdapContext implements LdapContext { + + private final DirContext delegating; + private final CloseHandler closeHandler; + private final SocketFactory socketFactory; + + interface CloseHandler { + void handle(DirContext context) throws NamingException; + } + + DelegatingLdapContext(DirContext delegating, CloseHandler closeHandler, SocketFactory socketFactory) + throws NamingException { + this.delegating = delegating; + this.closeHandler = closeHandler; + this.socketFactory = socketFactory; + } + + // for needs of newInstance() + private DelegatingLdapContext(DirContext delegating, SocketFactory socketFactory) throws NamingException { + this.delegating = delegating; + this.closeHandler = null; // close handler should not be applied to copy + this.socketFactory = socketFactory; + } + + public LdapContext newInitialLdapContext(Hashtable environment, Control[] connCtls) throws NamingException { + ClassLoader previous = setSocketFactory(); + try { + return new InitialLdapContext(environment, null); + } finally { + unsetSocketFactory(previous); + } + } + + @Override + public void close() throws NamingException { + if (closeHandler == null) { + delegating.close(); + } else { + closeHandler.handle(delegating); + } + } + + // for needs of search() + private NamingEnumeration wrap(NamingEnumeration delegating) { + return new NamingEnumeration() { + + @Override + public boolean hasMoreElements() { + ClassLoader previous = setSocketFactory(); + try { + return delegating.hasMoreElements(); + } finally { + unsetSocketFactory(previous); + } + } + + @Override + public SearchResult nextElement() { + ClassLoader previous = setSocketFactory(); + try { + return delegating.nextElement(); + } finally { + unsetSocketFactory(previous); + } + } + + @Override + public SearchResult next() throws NamingException { + ClassLoader previous = setSocketFactory(); + try { + return delegating.next(); + } finally { + unsetSocketFactory(previous); + } + } + + @Override + public boolean hasMore() throws NamingException { + ClassLoader previous = setSocketFactory(); + try { + return delegating.hasMore(); + } finally { + unsetSocketFactory(previous); + } + } + + @Override + public void close() throws NamingException { + delegating.close(); + } + }; + } + + public DelegatingLdapContext wrapReferralContextObtaining(ReferralException e) throws NamingException { + ClassLoader previous = setSocketFactory(); + try { + return new DelegatingLdapContext((DirContext) e.getReferralContext(), socketFactory); + } finally { + unsetSocketFactory(previous); + } + } + + @Override + public String toString() { + return super.toString() + "->" + delegating.toString(); + } + + // LdapContext specific + + @Override + public ExtendedResponse extendedOperation(ExtendedRequest request) throws NamingException { + if (!(delegating instanceof LdapContext)) + throw Assert.unsupported(); + return ((LdapContext) delegating).extendedOperation(request); + } + + @Override + public LdapContext newInstance(Control[] requestControls) throws NamingException { + if (!(delegating instanceof LdapContext)) + throw Assert.unsupported(); + LdapContext newContext = ((LdapContext) delegating).newInstance(requestControls); + return new DelegatingLdapContext(newContext, socketFactory); + } + + @Override + public void reconnect(Control[] controls) throws NamingException { + if (!(delegating instanceof LdapContext)) + throw Assert.unsupported(); + ClassLoader previous = setSocketFactory(); + try { + ((LdapContext) delegating).reconnect(controls); + } finally { + unsetSocketFactory(previous); + } + } + + @Override + public Control[] getConnectControls() throws NamingException { + if (!(delegating instanceof LdapContext)) + throw Assert.unsupported(); + return ((LdapContext) delegating).getConnectControls(); + } + + @Override + public void setRequestControls(Control[] requestControls) throws NamingException { + if (!(delegating instanceof LdapContext)) + throw Assert.unsupported(); + ((LdapContext) delegating).setRequestControls(requestControls); + } + + @Override + public Control[] getRequestControls() throws NamingException { + if (!(delegating instanceof LdapContext)) + throw Assert.unsupported(); + return ((LdapContext) delegating).getRequestControls(); + } + + @Override + public Control[] getResponseControls() throws NamingException { + if (!(delegating instanceof LdapContext)) + throw Assert.unsupported(); + return ((LdapContext) delegating).getResponseControls(); + } + + // DirContext methods delegates only + + @Override + public void bind(String name, Object obj, Attributes attrs) throws NamingException { + delegating.bind(name, obj, attrs); + } + + @Override + public Attributes getAttributes(Name name) throws NamingException { + return delegating.getAttributes(name); + } + + @Override + public Attributes getAttributes(String name) throws NamingException { + return delegating.getAttributes(name); + } + + @Override + public Attributes getAttributes(Name name, String[] attrIds) throws NamingException { + return delegating.getAttributes(name, attrIds); + } + + @Override + public Attributes getAttributes(String name, String[] attrIds) throws NamingException { + return delegating.getAttributes(name, attrIds); + } + + @Override + public void modifyAttributes(Name name, int mod_op, Attributes attrs) throws NamingException { + delegating.modifyAttributes(name, mod_op, attrs); + } + + @Override + public void modifyAttributes(String name, int mod_op, Attributes attrs) throws NamingException { + delegating.modifyAttributes(name, mod_op, attrs); + } + + @Override + public void modifyAttributes(Name name, ModificationItem[] mods) throws NamingException { + delegating.modifyAttributes(name, mods); + } + + @Override + public void modifyAttributes(String name, ModificationItem[] mods) throws NamingException { + delegating.modifyAttributes(name, mods); + } + + @Override + public void bind(Name name, Object obj, Attributes attrs) throws NamingException { + delegating.bind(name, obj, attrs); + } + + @Override + public void rebind(Name name, Object obj, Attributes attrs) throws NamingException { + delegating.rebind(name, obj, attrs); + } + + @Override + public void rebind(String name, Object obj, Attributes attrs) throws NamingException { + delegating.rebind(name, obj, attrs); + } + + @Override + public DirContext createSubcontext(Name name, Attributes attrs) throws NamingException { + return delegating.createSubcontext(name, attrs); + } + + @Override + public DirContext createSubcontext(String name, Attributes attrs) throws NamingException { + return delegating.createSubcontext(name, attrs); + } + + @Override + public DirContext getSchema(Name name) throws NamingException { + return delegating.getSchema(name); + } + + @Override + public DirContext getSchema(String name) throws NamingException { + return delegating.getSchema(name); + } + + @Override + public DirContext getSchemaClassDefinition(Name name) throws NamingException { + return delegating.getSchemaClassDefinition(name); + } + + @Override + public DirContext getSchemaClassDefinition(String name) throws NamingException { + return delegating.getSchemaClassDefinition(name); + } + + @Override + public NamingEnumeration search(Name name, Attributes matchingAttributes, String[] attributesToReturn) + throws NamingException { + return wrap(delegating.search(name, matchingAttributes, attributesToReturn)); + } + + @Override + public NamingEnumeration search(String name, Attributes matchingAttributes, String[] attributesToReturn) + throws NamingException { + return wrap(delegating.search(name, matchingAttributes, attributesToReturn)); + } + + @Override + public NamingEnumeration search(Name name, Attributes matchingAttributes) throws NamingException { + return wrap(delegating.search(name, matchingAttributes)); + } + + @Override + public NamingEnumeration search(String name, Attributes matchingAttributes) throws NamingException { + return wrap(delegating.search(name, matchingAttributes)); + } + + @Override + public NamingEnumeration search(Name name, String filter, SearchControls cons) throws NamingException { + return wrap(delegating.search(name, filter, cons)); + } + + @Override + public NamingEnumeration search(String name, String filter, SearchControls cons) throws NamingException { + return wrap(delegating.search(name, filter, cons)); + } + + @Override + public NamingEnumeration search(Name name, String filterExpr, Object[] filterArgs, SearchControls cons) + throws NamingException { + return wrap(delegating.search(name, filterExpr, filterArgs, cons)); + } + + @Override + public NamingEnumeration search(String name, String filterExpr, Object[] filterArgs, SearchControls cons) + throws NamingException { + return wrap(delegating.search(name, filterExpr, filterArgs, cons)); + } + + @Override + public Object lookup(Name name) throws NamingException { + return delegating.lookup(name); + } + + @Override + public Object lookup(String name) throws NamingException { + return delegating.lookup(name); + } + + @Override + public void bind(Name name, Object obj) throws NamingException { + delegating.bind(name, obj); + } + + @Override + public void bind(String name, Object obj) throws NamingException { + delegating.bind(name, obj); + } + + @Override + public void rebind(Name name, Object obj) throws NamingException { + delegating.rebind(name, obj); + } + + @Override + public void rebind(String name, Object obj) throws NamingException { + delegating.rebind(name, obj); + } + + @Override + public void unbind(Name name) throws NamingException { + delegating.unbind(name); + } + + @Override + public void unbind(String name) throws NamingException { + delegating.unbind(name); + } + + @Override + public void rename(Name oldName, Name newName) throws NamingException { + delegating.rename(oldName, newName); + } + + @Override + public void rename(String oldName, String newName) throws NamingException { + delegating.rename(oldName, newName); + } + + @Override + public NamingEnumeration list(Name name) throws NamingException { + return delegating.list(name); + } + + @Override + public NamingEnumeration list(String name) throws NamingException { + return delegating.list(name); + } + + @Override + public NamingEnumeration listBindings(Name name) throws NamingException { + return delegating.listBindings(name); + } + + @Override + public NamingEnumeration listBindings(String name) throws NamingException { + return delegating.listBindings(name); + } + + @Override + public void destroySubcontext(Name name) throws NamingException { + delegating.destroySubcontext(name); + } + + @Override + public void destroySubcontext(String name) throws NamingException { + delegating.destroySubcontext(name); + } + + @Override + public Context createSubcontext(Name name) throws NamingException { + return delegating.createSubcontext(name); + } + + @Override + public Context createSubcontext(String name) throws NamingException { + return delegating.createSubcontext(name); + } + + @Override + public Object lookupLink(Name name) throws NamingException { + return delegating.lookupLink(name); + } + + @Override + public Object lookupLink(String name) throws NamingException { + return delegating.lookupLink(name); + } + + @Override + public NameParser getNameParser(Name name) throws NamingException { + return delegating.getNameParser(name); + } + + @Override + public NameParser getNameParser(String name) throws NamingException { + return delegating.getNameParser(name); + } + + @Override + public Name composeName(Name name, Name prefix) throws NamingException { + return delegating.composeName(name, prefix); + } + + @Override + public String composeName(String name, String prefix) throws NamingException { + return delegating.composeName(name, prefix); + } + + @Override + public Object addToEnvironment(String propName, Object propVal) throws NamingException { + return delegating.addToEnvironment(propName, propVal); + } + + @Override + public Object removeFromEnvironment(String propName) throws NamingException { + return delegating.removeFromEnvironment(propName); + } + + @Override + public Hashtable getEnvironment() throws NamingException { + return delegating.getEnvironment(); + } + + @Override + public String getNameInNamespace() throws NamingException { + return delegating.getNameInNamespace(); + } + + private ClassLoader setSocketFactory() { + if (socketFactory != null) { + ThreadLocalSSLSocketFactory.set(socketFactory); + return setClassLoaderTo(getSocketFactoryClassLoader()); + } + return null; + } + + private void unsetSocketFactory(ClassLoader previous) { + if (socketFactory != null) { + ThreadLocalSSLSocketFactory.unset(); + setClassLoaderTo(previous); + } + } + + private ClassLoader getSocketFactoryClassLoader() { + return ThreadLocalSSLSocketFactory.class.getClassLoader(); + } + + private ClassLoader setClassLoaderTo(final ClassLoader targetClassLoader) { + return doPrivileged(new SetContextClassLoaderAction(targetClassLoader)); + } + + private static T doPrivileged(final PrivilegedAction action) { + return System.getSecurityManager() != null ? AccessController.doPrivileged(action) : action.run(); + } +} diff --git a/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/LdapRecorder.java b/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/LdapRecorder.java new file mode 100644 index 0000000000000..83dac6927d9e8 --- /dev/null +++ b/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/LdapRecorder.java @@ -0,0 +1,69 @@ +package io.quarkus.elytron.security.ldap; + +import java.util.ArrayList; +import java.util.List; + +import javax.naming.NamingException; +import javax.naming.directory.DirContext; + +import org.wildfly.common.function.ExceptionSupplier; +import org.wildfly.security.auth.realm.ldap.AttributeMapping; +import org.wildfly.security.auth.realm.ldap.DirContextFactory; +import org.wildfly.security.auth.realm.ldap.LdapSecurityRealmBuilder; +import org.wildfly.security.auth.server.SecurityRealm; + +import io.quarkus.elytron.security.ldap.config.AttributeMappingConfig; +import io.quarkus.elytron.security.ldap.config.DirContextConfig; +import io.quarkus.elytron.security.ldap.config.IdentityMappingConfig; +import io.quarkus.elytron.security.ldap.config.LdapSecurityRealmConfig; +import io.quarkus.runtime.RuntimeValue; +import io.quarkus.runtime.annotations.Recorder; + +@Recorder +public class LdapRecorder { + + /** + * Create a runtime value for a {@linkplain LdapSecurityRealm} + * + * @param config - the realm config + * @return - runtime value wrapper for the SecurityRealm + */ + public RuntimeValue createRealm(LdapSecurityRealmConfig config) { + LdapSecurityRealmBuilder builder = LdapSecurityRealmBuilder.builder() + .setDirContextSupplier(createDirContextSupplier(config.dirContext)) + .identityMapping() + .map(createAttributeMappings(config.identityMapping)) + .setRdnIdentifier(config.identityMapping.rdnIdentifier) + .setSearchDn(config.identityMapping.searchBaseDn) + .build(); + + if (config.directVerification) { + builder.addDirectEvidenceVerification(false); + } + + return new RuntimeValue<>(builder.build()); + } + + private ExceptionSupplier createDirContextSupplier(DirContextConfig dirContext) { + DirContextFactory dirContextFactory = new QuarkusDirContextFactory( + dirContext.url, + dirContext.principal, + dirContext.password); + return () -> dirContextFactory.obtainDirContext(DirContextFactory.ReferralMode.IGNORE); + } + + private AttributeMapping[] createAttributeMappings(IdentityMappingConfig identityMappingConfig) { + List attributeMappings = new ArrayList<>(); + + for (AttributeMappingConfig attributeMappingConfig : identityMappingConfig.attributeMappings.values()) { + attributeMappings.add(AttributeMapping.fromFilter(attributeMappingConfig.filter) + .from(attributeMappingConfig.from) + .to(attributeMappingConfig.to) + .searchDn(attributeMappingConfig.filterBaseDn) + .build()); + } + + AttributeMapping[] attributeMappingsArray = new AttributeMapping[attributeMappings.size()]; + return attributeMappings.toArray(attributeMappingsArray); + } +} diff --git a/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/QuarkusDirContextFactory.java b/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/QuarkusDirContextFactory.java new file mode 100644 index 0000000000000..6dde3648e0826 --- /dev/null +++ b/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/QuarkusDirContextFactory.java @@ -0,0 +1,151 @@ +package io.quarkus.elytron.security.ldap; + +import java.security.AccessController; +import java.security.PrivilegedAction; +import java.util.Hashtable; + +import javax.naming.NamingException; +import javax.naming.directory.DirContext; +import javax.naming.directory.InitialDirContext; +import javax.naming.ldap.InitialLdapContext; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.NameCallback; +import javax.security.auth.callback.PasswordCallback; + +import org.wildfly.security.auth.realm.ldap.DirContextFactory; +import org.wildfly.security.manager.action.SetContextClassLoaderAction; + +public class QuarkusDirContextFactory implements DirContextFactory { + // private static final ElytronMessages log = Logger.getMessageLogger(ElytronMessages.class, "org.wildfly.security"); + + private static final String CONNECT_TIMEOUT = "com.sun.jndi.ldap.connect.timeout"; + private static final String READ_TIMEOUT = "com.sun.jndi.ldap.read.timeout"; + private static final String SOCKET_FACTORY = "java.naming.ldap.factory.socket"; + public static final String INITIAL_CONTEXT_FACTORY = "com.sun.jndi.ldap.LdapCtxFactory"; + private static final String SECURITY_AUTHENTICATION = "simple"; + + private static final String DEFAULT_CONNECT_TIMEOUT = "5000"; // ms + private static final String DEFAULT_READ_TIMEOUT = "60000"; // ms + private static final String LDAPS_SCHEME = "ldaps"; + + private final String providerUrl; + private final String securityPrincipal; + private final String securityCredential; + private final ClassLoader targetClassLoader; + + public QuarkusDirContextFactory(String providerUrl, String securityPrincipal, String securityCredential) { + this.providerUrl = providerUrl; + this.securityPrincipal = securityPrincipal; + this.securityCredential = securityCredential; + this.targetClassLoader = getClass().getClassLoader(); + } + + @Override + public DirContext obtainDirContext(ReferralMode mode) throws NamingException { + char[] charPassword = null; + if (securityCredential != null) { // password from String + charPassword = securityCredential.toCharArray(); + } + return createDirContext(securityPrincipal, charPassword, mode); + } + + @Override + public DirContext obtainDirContext(CallbackHandler handler, ReferralMode mode) throws NamingException { + NameCallback nameCallback = new NameCallback("Principal Name"); + PasswordCallback passwordCallback = new PasswordCallback("Password", false); + + try { + handler.handle(new Callback[] { nameCallback, passwordCallback }); + } catch (Exception e) { + throw new RuntimeException("Could not obtain credential", e); + // throw log.couldNotObtainCredentialWithCause(e); + } + + String securityPrincipal = nameCallback.getName(); + + if (securityPrincipal == null) { + throw new RuntimeException("Could not obtain principal"); + // throw log.couldNotObtainPrincipal(); + } + + char[] securityCredential = passwordCallback.getPassword(); + + if (securityCredential == null) { + throw new RuntimeException("Could not obtain credential"); + // throw log.couldNotObtainCredential(); + } + + return createDirContext(securityPrincipal, securityCredential, mode); + } + + private DirContext createDirContext(String securityPrincipal, char[] securityCredential, ReferralMode mode) + throws NamingException { + final ClassLoader oldClassLoader = setClassLoaderTo(targetClassLoader); + try { + Hashtable env = new Hashtable<>(); + + env.put(InitialDirContext.INITIAL_CONTEXT_FACTORY, INITIAL_CONTEXT_FACTORY); + env.put(InitialDirContext.PROVIDER_URL, providerUrl); + env.put(InitialDirContext.SECURITY_AUTHENTICATION, SECURITY_AUTHENTICATION); + if (securityPrincipal != null) { + env.put(InitialDirContext.SECURITY_PRINCIPAL, securityPrincipal); + } + if (securityCredential != null) { + env.put(InitialDirContext.SECURITY_CREDENTIALS, securityCredential); + } + env.put(InitialDirContext.REFERRAL, mode == null ? ReferralMode.IGNORE.getValue() : mode.getValue()); + env.put(CONNECT_TIMEOUT, DEFAULT_CONNECT_TIMEOUT); + env.put(READ_TIMEOUT, DEFAULT_READ_TIMEOUT); + + // if (log.isDebugEnabled()) { + // log.debugf("Creating [" + InitialDirContext.class + "] with environment:"); + // env.forEach((key, value) -> log.debugf(" Property [%s] with value [%s]", key, + // key != InitialDirContext.SECURITY_CREDENTIALS ? Arrays2.objectToString(value) : "******")); + // } + + InitialLdapContext initialContext; + + try { + initialContext = new InitialLdapContext(env, null); + } catch (NamingException ne) { + // log.debugf(ne, "Could not create [%s]. Failed to connect to LDAP server.", InitialLdapContext.class); + throw ne; + } + + // log.debugf("[%s] successfully created. Connection established to LDAP server.", initialContext); + + return new DelegatingLdapContext(initialContext, this::returnContext, null); + } finally { + setClassLoaderTo(oldClassLoader); + } + } + + @Override + public void returnContext(DirContext context) { + + if (context == null) { + return; + } + + if (context instanceof InitialDirContext) { + final ClassLoader oldClassLoader = setClassLoaderTo(targetClassLoader); + try { + context.close(); + // log.debugf("Context [%s] was closed. Connection closed or just returned to the pool.", context); + } catch (NamingException ignored) { + } finally { + setClassLoaderTo(oldClassLoader); + } + } + } + + private ClassLoader setClassLoaderTo(final ClassLoader targetClassLoader) { + return doPrivileged(new SetContextClassLoaderAction(targetClassLoader)); + } + + private static T doPrivileged(final PrivilegedAction action) { + return System.getSecurityManager() != null ? AccessController.doPrivileged(action) : action.run(); + } + +} diff --git a/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/config/AttributeMappingConfig.java b/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/config/AttributeMappingConfig.java new file mode 100644 index 0000000000000..5a933bf81e9de --- /dev/null +++ b/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/config/AttributeMappingConfig.java @@ -0,0 +1,35 @@ +package io.quarkus.elytron.security.ldap.config; + +import io.quarkus.runtime.annotations.ConfigGroup; +import io.quarkus.runtime.annotations.ConfigItem; + +/** + * Configuration information used to populate a {@linkplain org.wildfly.security.auth.realm.ldap.AttributeMapping} + */ +@ConfigGroup +public class AttributeMappingConfig { + + /** + * The roleAttributeId from which is mapped (e.g. "cn") + */ + @ConfigItem + public String from; + + /** + * The identifier whom the attribute is mapped to (in Quarkus: "groups", in WildFly this is "Roles") + */ + @ConfigItem(defaultValue = "groups") + public String to; + + /** + * The filter (also named "roleFilter") + */ + @ConfigItem + public String filter; + + /** + * The filter base dn (also named "rolesContextDn") + */ + @ConfigItem + public String filterBaseDn; +} diff --git a/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/config/DirContextConfig.java b/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/config/DirContextConfig.java new file mode 100644 index 0000000000000..95476076b255f --- /dev/null +++ b/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/config/DirContextConfig.java @@ -0,0 +1,35 @@ +package io.quarkus.elytron.security.ldap.config; + +import io.quarkus.runtime.annotations.ConfigGroup; +import io.quarkus.runtime.annotations.ConfigItem; + +@ConfigGroup +public class DirContextConfig { + + /** + * The url of the ldap server + */ + @ConfigItem + public String url; + + /** + * The principal: user which is used to connect to ldap server (also named "bindDn") + */ + @ConfigItem + public String principal; + + /** + * The password which belongs to the principal (also named "bindCredential") + */ + @ConfigItem + public String password; + + @Override + public String toString() { + return "DirContextConfig{" + + "url='" + url + '\'' + + ", principal='" + principal + '\'' + + ", password='" + password + '\'' + + '}'; + } +} diff --git a/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/config/IdentityMappingConfig.java b/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/config/IdentityMappingConfig.java new file mode 100644 index 0000000000000..1846c259df185 --- /dev/null +++ b/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/config/IdentityMappingConfig.java @@ -0,0 +1,37 @@ +package io.quarkus.elytron.security.ldap.config; + +import java.util.Map; + +import io.quarkus.runtime.annotations.ConfigGroup; +import io.quarkus.runtime.annotations.ConfigItem; + +@ConfigGroup +public class IdentityMappingConfig { + + /** + * The identifier which correlates to the provided user (also named "baseFilter") + */ + @ConfigItem(defaultValue = "uid") + public String rdnIdentifier; + + /** + * The dn where we look for users + */ + @ConfigItem + public String searchBaseDn; + + /** + * The configs how we get from the attribute to the Role + */ + @ConfigItem + public Map attributeMappings; + + @Override + public String toString() { + return "IdentityMappingConfig{" + + "rdnIdentifier='" + rdnIdentifier + '\'' + + ", searchBaseDn='" + searchBaseDn + '\'' + + ", attributeMappings=" + attributeMappings + + '}'; + } +} diff --git a/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/config/LdapSecurityRealmConfig.java b/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/config/LdapSecurityRealmConfig.java new file mode 100644 index 0000000000000..2e8a1015eaa79 --- /dev/null +++ b/extensions/elytron-security-ldap/runtime/src/main/java/io/quarkus/elytron/security/ldap/config/LdapSecurityRealmConfig.java @@ -0,0 +1,54 @@ +package io.quarkus.elytron.security.ldap.config; + +import io.quarkus.runtime.annotations.ConfigItem; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; + +/** + * A configuration object for a jdbc based realm configuration, + * {@linkplain org.wildfly.security.auth.realm.ldap.LdapSecurityRealm} + */ +@ConfigRoot(name = "security.ldap", phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED) +public class LdapSecurityRealmConfig { + + /** + * The option to enable the ldap elytron module + */ + @ConfigItem + public boolean enabled; + + /** + * The elytron realm name + */ + @ConfigItem(defaultValue = "Quarkus") + public String realmName; + + /** + * Provided credentials are verified against ldap? + */ + @ConfigItem(defaultValue = "true") + public boolean directVerification; + + /** + * The ldap server configuration + */ + @ConfigItem + public DirContextConfig dirContext; + + /** + * The config which we use to map an identity + */ + @ConfigItem + public IdentityMappingConfig identityMapping; + + @Override + public String toString() { + return "LdapSecurityRealmConfig{" + + "enabled=" + enabled + + ", realmName='" + realmName + '\'' + + ", directVerification=" + directVerification + + ", dirContext=" + dirContext + + ", identityMapping=" + identityMapping + + '}'; + } +} diff --git a/extensions/elytron-security-ldap/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/elytron-security-ldap/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 0000000000000..4fc9dcd772342 --- /dev/null +++ b/extensions/elytron-security-ldap/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,10 @@ +--- +name: "Elytron Security LDAP Realm" +metadata: + keywords: + - "security" + - "ldap" + guide: "https://quarkus.io/guides/security-ldap" + categories: + - "security" + status: "preview" diff --git a/extensions/pom.xml b/extensions/pom.xml index 9e4763a2500a7..32fa588d0b59d 100644 --- a/extensions/pom.xml +++ b/extensions/pom.xml @@ -109,6 +109,7 @@ elytron-security-common elytron-security elytron-security-jdbc + elytron-security-ldap elytron-security-properties-file elytron-security-oauth2 smallrye-jwt diff --git a/integration-tests/elytron-security-ldap/pom.xml b/integration-tests/elytron-security-ldap/pom.xml new file mode 100644 index 0000000000000..3df55afc22147 --- /dev/null +++ b/integration-tests/elytron-security-ldap/pom.xml @@ -0,0 +1,115 @@ + + + 4.0.0 + + io.quarkus + quarkus-integration-tests-parent + 999-SNAPSHOT + ../pom.xml + + + quarkus-elytron-security-ldap-integration-test + Quarkus - Integration Tests - Elytron Security LDAP + + + + io.quarkus + quarkus-resteasy + + + io.quarkus + quarkus-elytron-security-ldap + + + + + io.quarkus + quarkus-test-ldap + test + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + + + + + io.quarkus + quarkus-maven-plugin + + + + build + + + + + + + + + + native-image + + + native + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + integration-test + verify + + + + ${project.build.directory}/${project.build.finalName}-runner + + + + + + + io.quarkus + quarkus-maven-plugin + ${project.version} + + + native-image + + native-image + + + false + true + true + false + false + ${graalvmHome} + true + false + + + + + + + + + + diff --git a/integration-tests/elytron-security-ldap/src/main/java/io/quarkus/elytron/security/ldap/it/ElytronSecurityLdapResource.java b/integration-tests/elytron-security-ldap/src/main/java/io/quarkus/elytron/security/ldap/it/ElytronSecurityLdapResource.java new file mode 100644 index 0000000000000..1d54f0c2f047f --- /dev/null +++ b/integration-tests/elytron-security-ldap/src/main/java/io/quarkus/elytron/security/ldap/it/ElytronSecurityLdapResource.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 io.quarkus.elytron.security.ldap.it; + +import javax.annotation.security.RolesAllowed; +import javax.enterprise.context.ApplicationScoped; +import javax.ws.rs.GET; +import javax.ws.rs.Path; + +@Path("/api") +@ApplicationScoped +public class ElytronSecurityLdapResource { + + @GET + @Path("/anonymous") + public String anonymous() { + return "anonymous"; + } + + @GET + @Path("/requiresStandardRole") + @RolesAllowed("standardRole") + public String authenticated() { + return "authorized"; + } + + @GET + @Path("/requiresAdminRole") + @RolesAllowed("adminRole") + public String forbidden() { + return "authorized"; + } + +} diff --git a/integration-tests/elytron-security-ldap/src/main/resources/application.properties b/integration-tests/elytron-security-ldap/src/main/resources/application.properties new file mode 100644 index 0000000000000..e9282033bed8b --- /dev/null +++ b/integration-tests/elytron-security-ldap/src/main/resources/application.properties @@ -0,0 +1,12 @@ +quarkus.security.ldap.enabled=true + +quarkus.security.ldap.dir-context.principal=uid=admin,ou=system +quarkus.security.ldap.dir-context.url=ldap://127.0.0.1:10389 +quarkus.security.ldap.dir-context.password=secret + +quarkus.security.ldap.identity-mapping.rdn-identifier=uid +quarkus.security.ldap.identity-mapping.search-base-dn=ou=Users,dc=quarkus,dc=io + +quarkus.security.ldap.identity-mapping.attribute-mappings."0".from=cn +quarkus.security.ldap.identity-mapping.attribute-mappings."0".filter=(member=uid={0},ou=Users,dc=quarkus,dc=io) +quarkus.security.ldap.identity-mapping.attribute-mappings."0".filter-base-dn=ou=Roles,dc=quarkus,dc=io \ No newline at end of file diff --git a/integration-tests/elytron-security-ldap/src/test/java/io/quarkus/elytron/security/ldap/it/ElytronLdapExtensionTestResources.java b/integration-tests/elytron-security-ldap/src/test/java/io/quarkus/elytron/security/ldap/it/ElytronLdapExtensionTestResources.java new file mode 100644 index 0000000000000..c0144886d8b05 --- /dev/null +++ b/integration-tests/elytron-security-ldap/src/test/java/io/quarkus/elytron/security/ldap/it/ElytronLdapExtensionTestResources.java @@ -0,0 +1,8 @@ +package io.quarkus.elytron.security.ldap.it; + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.ldap.LdapServerTestResource; + +@QuarkusTestResource(LdapServerTestResource.class) +public class ElytronLdapExtensionTestResources { +} diff --git a/integration-tests/elytron-security-ldap/src/test/java/io/quarkus/elytron/security/ldap/it/ElytronSecurityLdapIT.java b/integration-tests/elytron-security-ldap/src/test/java/io/quarkus/elytron/security/ldap/it/ElytronSecurityLdapIT.java new file mode 100644 index 0000000000000..f772691da5036 --- /dev/null +++ b/integration-tests/elytron-security-ldap/src/test/java/io/quarkus/elytron/security/ldap/it/ElytronSecurityLdapIT.java @@ -0,0 +1,8 @@ +package io.quarkus.elytron.security.ldap.it; + +import io.quarkus.test.junit.NativeImageTest; + +@NativeImageTest +class ElytronSecurityLdapIT extends ElytronSecurityLdapTest { + +} diff --git a/integration-tests/elytron-security-ldap/src/test/java/io/quarkus/elytron/security/ldap/it/ElytronSecurityLdapTest.java b/integration-tests/elytron-security-ldap/src/test/java/io/quarkus/elytron/security/ldap/it/ElytronSecurityLdapTest.java new file mode 100644 index 0000000000000..7c16318bfc8c6 --- /dev/null +++ b/integration-tests/elytron-security-ldap/src/test/java/io/quarkus/elytron/security/ldap/it/ElytronSecurityLdapTest.java @@ -0,0 +1,85 @@ +package io.quarkus.elytron.security.ldap.it; + +import static org.hamcrest.Matchers.containsString; + +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; + +@QuarkusTest +class ElytronSecurityLdapTest { + + @Test + void anonymous() { + RestAssured.given() + .when() + .get("/api/anonymous") + .then() + .statusCode(200) + .body(containsString("anonymous")); + } + + @Test + void standard_role_not_authenticated() { + RestAssured.given() + .redirects().follow(false) + .when() + .get("/api/requiresStandardRole") + .then() + .statusCode(401); + } + + @Test + void standard_role_authenticated() { + RestAssured.given() + .redirects().follow(false) + .when() + .auth().preemptive().basic("standardUser", "standardUserPassword") + .get("/api/requiresStandardRole") + .then() + .statusCode(200); + } + + @Test + void standard_role_not_authorized() { + RestAssured.given() + .redirects().follow(false) + .when() + .auth().preemptive().basic("adminUser", "adminUserPassword") + .get("/api/requiresStandardRole") + .then() + .statusCode(403); + } + + @Test + void admin_role_authorized() { + RestAssured.given() + .when() + .auth().preemptive().basic("adminUser", "adminUserPassword") + .get("/api/requiresAdminRole") + .then() + .statusCode(200); + } + + @Test + void admin_role_not_authenticated() { + RestAssured.given() + .redirects().follow(false) + .when() + .get("/api/requiresAdminRole") + .then() + .statusCode(401); + } + + @Test + void admin_role_not_authorized() { + RestAssured.given() + .redirects().follow(false) + .when() + .auth().preemptive().basic("standardUser", "standardUserPassword") + .get("/api/requiresAdminRole") + .then() + .statusCode(403); + } +} diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index 12a7627c49295..fd154577b50e2 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -92,6 +92,7 @@ narayana-stm narayana-jta elytron-security-jdbc + elytron-security-ldap vertx-graphql jpa-without-entity quartz