diff --git a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/X509IdentityProvider.java b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/X509IdentityProvider.java index ae1ff274da163..d7bcff7deb67c 100644 --- a/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/X509IdentityProvider.java +++ b/extensions/security/runtime/src/main/java/io/quarkus/security/runtime/X509IdentityProvider.java @@ -1,6 +1,13 @@ package io.quarkus.security.runtime; import java.security.cert.X509Certificate; +import java.util.Map; +import java.util.Set; + +import javax.naming.InvalidNameException; +import javax.naming.ldap.LdapName; +import javax.naming.ldap.Rdn; +import javax.security.auth.x500.X500Principal; import jakarta.inject.Singleton; @@ -12,6 +19,8 @@ @Singleton public class X509IdentityProvider implements IdentityProvider { + private static final String COMMON_NAME = "CN"; + private static final String ROLES_ATTRIBUTE = "roles"; @Override public Class getRequestType() { @@ -21,10 +30,51 @@ public Class getRequestType() { @Override public Uni authenticate(CertificateAuthenticationRequest request, AuthenticationRequestContext context) { X509Certificate certificate = request.getCertificate().getCertificate(); - + Map> roles = request.getAttribute(ROLES_ATTRIBUTE); return Uni.createFrom().item(QuarkusSecurityIdentity.builder() .setPrincipal(certificate.getSubjectX500Principal()) .addCredential(request.getCertificate()) + .addRoles(extractRoles(certificate, roles)) .build()); } + + private Set extractRoles(X509Certificate certificate, Map> roles) { + if (roles == null) { + return Set.of(); + } + X500Principal principal = certificate.getSubjectX500Principal(); + if (principal == null || principal.getName() == null) { + return Set.of(); + } + Set matchedRoles = roles.get(principal.getName()); + if (matchedRoles != null) { + return matchedRoles; + } + String commonName = getCommonName(principal); + if (commonName != null) { + matchedRoles = roles.get(commonName); + if (matchedRoles != null) { + return matchedRoles; + } + } + return Set.of(); + } + + private static String getCommonName(X500Principal principal) { + try { + LdapName ldapDN = new LdapName(principal.getName()); + + // Apparently for some CN variations it might not produce correct results + // Can be tuned as necessary. + for (Rdn rdn : ldapDN.getRdns()) { + if (COMMON_NAME.equals(rdn.getType())) { + return rdn.getValue().toString(); + } + } + } catch (InvalidNameException ex) { + // Failing the augmentation process because of this exception seems unnecessary + // The common name my include some characters unexpected by the legacy LdapName API specification. + } + return null; + } } diff --git a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java index e414b69b3f7a9..2375e244ddb28 100644 --- a/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java +++ b/extensions/vertx-http/deployment/src/main/java/io/quarkus/vertx/http/deployment/HttpSecurityProcessor.java @@ -30,6 +30,7 @@ import io.quarkus.runtime.RuntimeValue; import io.quarkus.security.spi.runtime.MethodDescription; import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig; +import io.quarkus.vertx.http.runtime.HttpConfiguration; import io.quarkus.vertx.http.runtime.management.ManagementInterfaceBuildTimeConfig; import io.quarkus.vertx.http.runtime.security.BasicAuthenticationMechanism; import io.quarkus.vertx.http.runtime.security.EagerSecurityInterceptorStorage; @@ -92,6 +93,17 @@ AdditionalBeanBuildItem initMtlsClientAuth(HttpBuildTimeConfig buildTimeConfig) return null; } + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + void setMtlsCertificateRoleProperties( + HttpSecurityRecorder recorder, + HttpConfiguration config, + HttpBuildTimeConfig buildTimeConfig) { + if (isMtlsClientAuthenticationEnabled(buildTimeConfig)) { + recorder.setMtlsCertificateRoleProperties(config); + } + } + @BuildStep(onlyIf = IsApplicationBasicAuthRequired.class) AdditionalBeanBuildItem initBasicAuth(HttpBuildTimeConfig buildTimeConfig, BuildProducer annotationsTransformerProducer, diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AuthRuntimeConfig.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AuthRuntimeConfig.java index 8552e30ef6b6b..f601117ff07e9 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AuthRuntimeConfig.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AuthRuntimeConfig.java @@ -1,5 +1,6 @@ package io.quarkus.vertx.http.runtime; +import java.nio.file.Path; import java.util.Map; import java.util.Optional; @@ -24,6 +25,16 @@ public class AuthRuntimeConfig { @ConfigItem(name = "policy") public Map rolePolicy; + /** + * Properties file containing the client certificate common name (CN) to role mappings. + * Use it only if the mTLS authentication mechanism is enabled with either + * `quarkus.http.ssl.client-auth=required` or `quarkus.http.ssl.client-auth=request`. + *

+ * Properties file is expected to have the `CN=role1,role,...,roleN` format and should be encoded using UTF-8. + */ + @ConfigItem + public Optional certificateRoleProperties; + /** * The authentication realm */ diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/CertificateConfig.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/CertificateConfig.java index 9666a72d9f2db..377a59dc44a70 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/CertificateConfig.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/CertificateConfig.java @@ -14,7 +14,6 @@ * Provide either the certificate and key files or a keystore. */ @ConfigGroup -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") public class CertificateConfig { /** diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java index 65d25a92394a8..6345c47d66458 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java @@ -1,7 +1,18 @@ package io.quarkus.vertx.http.runtime.security; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.Reader; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; +import java.util.Properties; +import java.util.Set; import java.util.concurrent.CompletionException; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -13,8 +24,11 @@ import org.jboss.logging.Logger; +import io.quarkus.arc.Arc; +import io.quarkus.arc.InstanceHandle; import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.annotations.Recorder; +import io.quarkus.runtime.configuration.ConfigurationException; import io.quarkus.security.AuthenticationCompletionException; import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.AuthenticationRedirectException; @@ -362,4 +376,51 @@ private synchronized void setPatchMatchingPolicyEnabled() { protected abstract boolean httpPermissionsEmpty(); } + + public void setMtlsCertificateRoleProperties(HttpConfiguration config) { + InstanceHandle mtls = Arc.container().instance(MtlsAuthenticationMechanism.class); + + if (mtls.isAvailable() && config.auth.certificateRoleProperties.isPresent()) { + Path rolesPath = config.auth.certificateRoleProperties.get(); + URL rolesResource = null; + if (Files.exists(rolesPath)) { + try { + rolesResource = rolesPath.toUri().toURL(); + } catch (MalformedURLException e) { + // The Files.exists(rolesPath) check has succeeded therefore this exception can't happen in this case + } + } else { + rolesResource = Thread.currentThread().getContextClassLoader().getResource(rolesPath.toString()); + } + if (rolesResource == null) { + throw new ConfigurationException( + "quarkus.http.auth.certificate-role-properties location can not be resolved", + Set.of("quarkus.http.auth.certificate-role-properties")); + } + + try (Reader reader = new BufferedReader( + new InputStreamReader(rolesResource.openStream(), StandardCharsets.UTF_8))) { + Properties rolesProps = new Properties(); + rolesProps.load(reader); + + Map> roles = new HashMap<>(); + for (Map.Entry e : rolesProps.entrySet()) { + log.debugf("Added role mapping for %s:%s", e.getKey(), e.getValue()); + roles.put((String) e.getKey(), parseRoles((String) e.getValue())); + } + + mtls.get().setRoleMappings(roles); + } catch (Exception e) { + log.warnf("Unable to read roles mappings from %s:%s", rolesPath, e.getMessage()); + } + } + } + + private static Set parseRoles(String value) { + Set roles = new HashSet<>(); + for (String s : value.split(",")) { + roles.add(s.trim()); + } + return Set.copyOf(roles); + } } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/MtlsAuthenticationMechanism.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/MtlsAuthenticationMechanism.java index 32ad48bdf07fe..ee95746c7006c 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/MtlsAuthenticationMechanism.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/MtlsAuthenticationMechanism.java @@ -20,6 +20,7 @@ import java.security.cert.Certificate; import java.security.cert.X509Certificate; import java.util.Collections; +import java.util.Map; import java.util.Set; import javax.net.ssl.SSLPeerUnverifiedException; @@ -38,6 +39,8 @@ * The authentication handler responsible for mTLS client authentication */ public class MtlsAuthenticationMechanism implements HttpAuthenticationMechanism { + private static final String ROLES_ATTRIBUTE = "roles"; + Map> roles = Map.of(); @Override public Uni authenticate(RoutingContext context, @@ -56,9 +59,12 @@ public Uni authenticate(RoutingContext context, return Uni.createFrom().nullItem(); } context.put(HttpAuthenticationMechanism.class.getName(), this); + + AuthenticationRequest authRequest = new CertificateAuthenticationRequest( + new CertificateCredential(X509Certificate.class.cast(certificate))); + authRequest.setAttribute(ROLES_ATTRIBUTE, roles); return identityProviderManager - .authenticate(HttpSecurityUtils.setRoutingContextAttribute(new CertificateAuthenticationRequest( - new CertificateCredential(X509Certificate.class.cast(certificate))), context)); + .authenticate(HttpSecurityUtils.setRoutingContextAttribute(authRequest, context)); } @Override @@ -76,4 +82,8 @@ public Set> getCredentialTypes() { public Uni getCredentialTransport(RoutingContext context) { return Uni.createFrom().item(new HttpCredentialTransport(HttpCredentialTransport.Type.X509, "X509")); } + + void setRoleMappings(Map> roles) { + this.roles = Collections.unmodifiableMap(roles); + } } diff --git a/integration-tests/mtls-certificates/pom.xml b/integration-tests/mtls-certificates/pom.xml new file mode 100644 index 0000000000000..c62a8e7a0a050 --- /dev/null +++ b/integration-tests/mtls-certificates/pom.xml @@ -0,0 +1,117 @@ + + + 4.0.0 + + + quarkus-integration-tests-parent + io.quarkus + 999-SNAPSHOT + + + quarkus-integration-test-mtls-certificates + Quarkus - Integration Tests - mTLS Client Certificate tests + + + + io.quarkus + quarkus-resteasy-reactive + + + io.quarkus + quarkus-security + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + + + + + io.quarkus + quarkus-security-deployment + ${project.version} + pom + test + + + * + * + + + + + io.quarkus + quarkus-resteasy-reactive-deployment + ${project.version} + pom + test + + + * + * + + + + + + + + + io.quarkus + quarkus-maven-plugin + + + + build + + + + + + + + + native + + + native + + + + + + maven-failsafe-plugin + + + + integration-test + verify + + + + + ${project.build.directory}/${project.build.finalName}-runner + + + + + + + + + + native + + + + diff --git a/integration-tests/mtls-certificates/src/main/java/io/quarkus/it/vertx/CertificateRoleMappingResource.java b/integration-tests/mtls-certificates/src/main/java/io/quarkus/it/vertx/CertificateRoleMappingResource.java new file mode 100644 index 0000000000000..d42ec1fc65425 --- /dev/null +++ b/integration-tests/mtls-certificates/src/main/java/io/quarkus/it/vertx/CertificateRoleMappingResource.java @@ -0,0 +1,42 @@ +package io.quarkus.it.vertx; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; + +@Path("/protected") +public class CertificateRoleMappingResource { + + @Inject + SecurityIdentity identity; + + @Authenticated + @GET + @Produces(MediaType.TEXT_PLAIN) + @Path("/authenticated") + public String name() { + return identity.getPrincipal().getName(); + } + + @GET + @Produces(MediaType.TEXT_PLAIN) + @Path("/authorized-user") + @RolesAllowed("user") + public String authorizedName() { + return identity.getPrincipal().getName(); + } + + @GET + @Produces(MediaType.TEXT_PLAIN) + @Path("/authorized-admin") + @RolesAllowed("admin") + public String authorizedAdmin() { + return identity.getPrincipal().getName(); + } +} diff --git a/integration-tests/mtls-certificates/src/main/resources/application.properties b/integration-tests/mtls-certificates/src/main/resources/application.properties new file mode 100644 index 0000000000000..871c0f0ee71bb --- /dev/null +++ b/integration-tests/mtls-certificates/src/main/resources/application.properties @@ -0,0 +1,10 @@ +quarkus.http.ssl.certificate.key-store-file=server-keystore.jks +quarkus.http.ssl.certificate.key-store-password=password +quarkus.http.ssl.certificate.key-store-key-alias=server +quarkus.http.ssl.certificate.key-store-key-password=serverpw +quarkus.http.ssl.certificate.trust-store-file=server-truststore.jks +quarkus.http.ssl.certificate.trust-store-password=password +quarkus.http.ssl.client-auth=REQUIRED +quarkus.http.auth.certificate-role-properties=role-mappings.txt +quarkus.native.additional-build-args=-H:IncludeResources=.*\\.jks,-H:IncludeResources=.*\\.txt + diff --git a/integration-tests/mtls-certificates/src/main/resources/role-mappings.txt b/integration-tests/mtls-certificates/src/main/resources/role-mappings.txt new file mode 100644 index 0000000000000..506946d5cdf50 --- /dev/null +++ b/integration-tests/mtls-certificates/src/main/resources/role-mappings.txt @@ -0,0 +1,2 @@ +client=user,admin +localhost=user \ No newline at end of file diff --git a/integration-tests/mtls-certificates/src/main/resources/server-keystore.jks b/integration-tests/mtls-certificates/src/main/resources/server-keystore.jks new file mode 100644 index 0000000000000..c7ac8b12c43bf Binary files /dev/null and b/integration-tests/mtls-certificates/src/main/resources/server-keystore.jks differ diff --git a/integration-tests/mtls-certificates/src/main/resources/server-truststore.jks b/integration-tests/mtls-certificates/src/main/resources/server-truststore.jks new file mode 100644 index 0000000000000..87dc406af5528 Binary files /dev/null and b/integration-tests/mtls-certificates/src/main/resources/server-truststore.jks differ diff --git a/integration-tests/mtls-certificates/src/test/java/io/quarkus/it/vertx/CertificateRoleMappingIT.java b/integration-tests/mtls-certificates/src/test/java/io/quarkus/it/vertx/CertificateRoleMappingIT.java new file mode 100644 index 0000000000000..2e33b3dc59c98 --- /dev/null +++ b/integration-tests/mtls-certificates/src/test/java/io/quarkus/it/vertx/CertificateRoleMappingIT.java @@ -0,0 +1,7 @@ +package io.quarkus.it.vertx; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +public class CertificateRoleMappingIT extends CertificateRoleMappingTest { +} diff --git a/integration-tests/mtls-certificates/src/test/java/io/quarkus/it/vertx/CertificateRoleMappingTest.java b/integration-tests/mtls-certificates/src/test/java/io/quarkus/it/vertx/CertificateRoleMappingTest.java new file mode 100644 index 0000000000000..5490ca1d97ab1 --- /dev/null +++ b/integration-tests/mtls-certificates/src/test/java/io/quarkus/it/vertx/CertificateRoleMappingTest.java @@ -0,0 +1,60 @@ +package io.quarkus.it.vertx; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; + +import java.net.URL; + +import org.junit.jupiter.api.Test; + +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.specification.RequestSpecification; + +@QuarkusTest +public class CertificateRoleMappingTest { + + @TestHTTPResource(ssl = true) + URL url; + + @Test + public void testAuthenticated() { + given().spec(getMtlsRequestSpec("client-keystore-1.jks")).get("/protected/authenticated") + .then().body(equalTo("CN=client,OU=cert,O=quarkus,L=city,ST=state,C=AU")); + given().spec(getMtlsRequestSpec("client-keystore-2.jks")).get("/protected/authenticated") + .then().body(equalTo("CN=localhost,OU=quarkus,O=quarkus,L=city,ST=state,C=IE")); + } + + @Test + public void testAuthorizedUser() { + given().spec(getMtlsRequestSpec("client-keystore-1.jks")).get("/protected/authorized-user") + .then().body(equalTo("CN=client,OU=cert,O=quarkus,L=city,ST=state,C=AU")); + given().spec(getMtlsRequestSpec("client-keystore-2.jks")).get("/protected/authorized-user") + .then().body(equalTo("CN=localhost,OU=quarkus,O=quarkus,L=city,ST=state,C=IE")); + } + + @Test + public void testAuthorizedAdmin() { + given().spec(getMtlsRequestSpec("client-keystore-1.jks")).get("/protected/authorized-admin") + .then().body(equalTo("CN=client,OU=cert,O=quarkus,L=city,ST=state,C=AU")); + given().spec(getMtlsRequestSpec("client-keystore-2.jks")).get("/protected/authorized-admin") + .then().statusCode(403); + } + + @Test + public void testNoClientCertificate() { + given().get("/protected/authenticated").then().statusCode(401); + given().get("/protected/authorized-user").then().statusCode(401); + given().get("/protected/authorized-admin").then().statusCode(401); + } + + private RequestSpecification getMtlsRequestSpec(String clientKeyStore) { + return new RequestSpecBuilder() + .setBaseUri(String.format("%s://%s", url.getProtocol(), url.getHost())) + .setPort(url.getPort()) + .setKeyStore(clientKeyStore, "password") + .setTrustStore("client-truststore.jks", "password") + .build(); + } +} diff --git a/integration-tests/mtls-certificates/src/test/resources/client-keystore-1.jks b/integration-tests/mtls-certificates/src/test/resources/client-keystore-1.jks new file mode 100644 index 0000000000000..cf6d6ba454864 Binary files /dev/null and b/integration-tests/mtls-certificates/src/test/resources/client-keystore-1.jks differ diff --git a/integration-tests/mtls-certificates/src/test/resources/client-keystore-2.jks b/integration-tests/mtls-certificates/src/test/resources/client-keystore-2.jks new file mode 100644 index 0000000000000..6961a6b1d0203 Binary files /dev/null and b/integration-tests/mtls-certificates/src/test/resources/client-keystore-2.jks differ diff --git a/integration-tests/mtls-certificates/src/test/resources/client-truststore.jks b/integration-tests/mtls-certificates/src/test/resources/client-truststore.jks new file mode 100644 index 0000000000000..112fb9857fbd7 Binary files /dev/null and b/integration-tests/mtls-certificates/src/test/resources/client-truststore.jks differ diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index 69e5ccd2ea296..3e9da6012ddb9 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -416,6 +416,7 @@ istio management-interface management-interface-auth + mtls-certificates virtual-threads