Skip to content

Commit

Permalink
Merge pull request #37269 from sberyozkin/certificate-role-mapping-su…
Browse files Browse the repository at this point in the history
…pport

Support certificate role mappings
  • Loading branch information
sberyozkin authored Dec 5, 2023
2 parents 6e146aa + 217511a commit 2aeab7f
Show file tree
Hide file tree
Showing 18 changed files with 386 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -12,6 +19,8 @@

@Singleton
public class X509IdentityProvider implements IdentityProvider<CertificateAuthenticationRequest> {
private static final String COMMON_NAME = "CN";
private static final String ROLES_ATTRIBUTE = "roles";

@Override
public Class<CertificateAuthenticationRequest> getRequestType() {
Expand All @@ -21,10 +30,51 @@ public Class<CertificateAuthenticationRequest> getRequestType() {
@Override
public Uni<SecurityIdentity> authenticate(CertificateAuthenticationRequest request, AuthenticationRequestContext context) {
X509Certificate certificate = request.getCertificate().getCertificate();

Map<String, Set<String>> roles = request.getAttribute(ROLES_ATTRIBUTE);
return Uni.createFrom().item(QuarkusSecurityIdentity.builder()
.setPrincipal(certificate.getSubjectX500Principal())
.addCredential(request.getCertificate())
.addRoles(extractRoles(certificate, roles))
.build());
}

private Set<String> extractRoles(X509Certificate certificate, Map<String, Set<String>> roles) {
if (roles == null) {
return Set.of();
}
X500Principal principal = certificate.getSubjectX500Principal();
if (principal == null || principal.getName() == null) {
return Set.of();
}
Set<String> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<AnnotationsTransformerBuildItem> annotationsTransformerProducer,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.quarkus.vertx.http.runtime;

import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;

Expand All @@ -24,6 +25,16 @@ public class AuthRuntimeConfig {
@ConfigItem(name = "policy")
public Map<String, PolicyConfig> 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`.
* <p/>
* Properties file is expected to have the `CN=role1,role,...,roleN` format and should be encoded using UTF-8.
*/
@ConfigItem
public Optional<Path> certificateRoleProperties;

/**
* The authentication realm
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
* Provide either the certificate and key files or a keystore.
*/
@ConfigGroup
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
public class CertificateConfig {

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -362,4 +376,51 @@ private synchronized void setPatchMatchingPolicyEnabled() {

protected abstract boolean httpPermissionsEmpty();
}

public void setMtlsCertificateRoleProperties(HttpConfiguration config) {
InstanceHandle<MtlsAuthenticationMechanism> 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<String, Set<String>> roles = new HashMap<>();
for (Map.Entry<Object, Object> 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<String> parseRoles(String value) {
Set<String> roles = new HashSet<>();
for (String s : value.split(",")) {
roles.add(s.trim());
}
return Set.copyOf(roles);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<String, Set<String>> roles = Map.of();

@Override
public Uni<SecurityIdentity> authenticate(RoutingContext context,
Expand All @@ -56,9 +59,12 @@ public Uni<SecurityIdentity> 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
Expand All @@ -76,4 +82,8 @@ public Set<Class<? extends AuthenticationRequest>> getCredentialTypes() {
public Uni<HttpCredentialTransport> getCredentialTransport(RoutingContext context) {
return Uni.createFrom().item(new HttpCredentialTransport(HttpCredentialTransport.Type.X509, "X509"));
}

void setRoleMappings(Map<String, Set<String>> roles) {
this.roles = Collections.unmodifiableMap(roles);
}
}
117 changes: 117 additions & 0 deletions integration-tests/mtls-certificates/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<artifactId>quarkus-integration-tests-parent</artifactId>
<groupId>io.quarkus</groupId>
<version>999-SNAPSHOT</version>
</parent>

<artifactId>quarkus-integration-test-mtls-certificates</artifactId>
<name>Quarkus - Integration Tests - mTLS Client Certificate tests</name>

<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-reactive</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-security</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>

<!-- Minimal test dependencies to *-deployment artifacts for consistent build order -->

<!-- Minimal test dependencies to *-deployment artifacts for consistent build order -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-security-deployment</artifactId>
<version>${project.version}</version>
<type>pom</type>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-reactive-deployment</artifactId>
<version>${project.version}</version>
<type>pom</type>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>build</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>native</id>
<activation>
<property>
<name>native</name>
</property>
</activation>
<build>
<plugins>
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
<configuration>
<systemPropertyVariables>
<native.image.path>
${project.build.directory}/${project.build.finalName}-runner
</native.image.path>
</systemPropertyVariables>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<properties>
<quarkus.package.type>native</quarkus.package.type>
</properties>
</profile>
</profiles>
</project>
Loading

0 comments on commit 2aeab7f

Please sign in to comment.