Skip to content

Commit

Permalink
Allow to configure certificate role mapping attribute
Browse files Browse the repository at this point in the history
  • Loading branch information
michalvavrik committed May 25, 2024
1 parent 5d72d9b commit 214c8b7
Show file tree
Hide file tree
Showing 39 changed files with 711 additions and 87 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.TokenCertificateValidator;
import io.quarkus.runtime.configuration.ConfigurationException;
import io.quarkus.security.runtime.X509IdentityProvider;
import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils;
import io.vertx.ext.auth.impl.CertificateHelper;

public class CertChainPublicKeyResolver implements RefreshableVerificationKeyResolver {
Expand Down Expand Up @@ -83,7 +83,7 @@ public Key resolveKey(JsonWebSignature jws, List<JsonWebStructure> nestingContex
// Finally, check the leaf certificate if required
if (!expectedLeafCertificateName.isEmpty()) {
// Compare the leaf certificate common name against the configured value
String leafCertificateName = X509IdentityProvider.getCommonName(chain.get(0).getSubjectX500Principal());
String leafCertificateName = HttpSecurityUtils.getCommonName(chain.get(0).getSubjectX500Principal());
if (!expectedLeafCertificateName.get().equals(leafCertificateName)) {
LOG.errorf("Wrong leaf certificate common name: %s", leafCertificateName);
throw new UnresolvableKeyException("Wrong leaf certificate common name");
Expand All @@ -106,4 +106,4 @@ public Key resolveKey(JsonWebSignature jws, List<JsonWebStructure> nestingContex
throw new UnresolvableKeyException("Invalid certificate chain", ex);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
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 java.util.function.Function;

import jakarta.inject.Singleton;

Expand All @@ -19,8 +14,7 @@

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

@Override
public Class<CertificateAuthenticationRequest> getRequestType() {
Expand All @@ -30,51 +24,15 @@ 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))
.addRoles(extractRoles(certificate, request.getAttribute(ROLES_MAPPER_ATTRIBUTE)))
.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();
}

public 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;
private static Set<String> extractRoles(X509Certificate certificate,
Function<X509Certificate, Set<String>> certificateToRoles) {
return certificateToRoles == null ? Set.of() : certificateToRoles.apply(certificate);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,41 @@ public class AuthRuntimeConfig {
public Map<String, List<String>> rolesMapping;

/**
* Properties file containing the client certificate common name (CN) to role mappings.
* Client certificate attribute whose values are going to be mapped to the 'SecurityIdentity' roles
* according to the roles mapping specified in the certificate properties file.
* The attribute must be either one of the Relative Distinguished Names (RDNs) or Subject Alternative Names (SANs).
* By default, the Common Name (CN) attribute value is used for roles mapping.
* Supported values are:
* <ul>
* <li>'DN_' followed by RDN type - Distinguished Name field.
* For example 'DN_CN' represents Common Name field.
* Multivalued RNDs and multiple instances of the same attributes are currently not supported.
* </li>
* <li>'SAN_RFC822' - Subject Alternative Name field RFC 822 Name.</li>
* <li>'SAN_DNS' - Subject Alternative Name field DNS Name.</li>
* <li>'SAN_X400' - Subject Alternative Name field x400 Address.</li>
* <li>'SAN_DIRECTORY' - Subject Alternative Name field Directory Name.</li>
* <li>'SAN_EDI' - Subject Alternative Name field EDI Party Name.</li>
* <li>'SAN_URI' - Subject Alternative Name field Uniform Resource Identifier (URI).</li>
* <li>'SAN_IP' - Subject Alternative Name field IP Address (IP).</li>
* <li>'SAN_OID' - Subject Alternative Name field Registered Object Identifier (OID).</li>
* <li>'SAN_ANY' - Subject Alternative Name field Other Name.
* Please note that only simple case of UTF8 identifier mapping is supported.
* For example, you can map 'other-identifier' to the SecurityIdentity roles.
* If you use 'openssl' tool, supported Other name definition would look like this:
* <code>subjectAltName=otherName:1.2.3.4;UTF8:other-identifier</code>
* </li>
* </ul>
*/
@ConfigItem(defaultValue = "DN_CN")
public String certificateRoleAttribute;

/**
* Properties file containing the client certificate attribute value 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.
* Properties file is expected to have the `CN_VALUE=role1,role,...,roleN` format and should be encoded using UTF-8.
*/
@ConfigItem
public Optional<Path> certificateRoleProperties;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
package io.quarkus.vertx.http.runtime.security;

import static io.quarkus.vertx.http.runtime.security.HttpSecurityUtils.COMMON_NAME;
import static io.quarkus.vertx.http.runtime.security.HttpSecurityUtils.getRdnValue;

import java.nio.charset.StandardCharsets;
import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;

import javax.security.auth.x500.X500Principal;

import org.jboss.logging.Logger;

import io.quarkus.runtime.configuration.ConfigurationException;
import io.vertx.ext.auth.impl.asn.ASN1;

public record CertificateRoleAttribute(Function<X509Certificate, Set<String>> rolesMapper) {

private static final Logger log = Logger.getLogger(CertificateRoleAttribute.class);
private static final String SAN_PREFIX = "SAN_";
private static final String DN_PREFIX = "DN_";

CertificateRoleAttribute(String configValue, Map<String, Set<String>> roles) {
this(of(configValue.toUpperCase(), Map.copyOf(roles)));
}

private static Function<X509Certificate, Set<String>> of(String configValue, Map<String, Set<String>> roles) {
if (configValue.contains(SAN_PREFIX)) {

return new Function<X509Certificate, Set<String>>() {
@Override
public Set<String> apply(X509Certificate certificate) {
return extractRolesFromCertSan(certificate, SAN.valueOf(configValue).generalNameType, roles);
}
};
} else if (configValue.startsWith(DN_PREFIX)) {

String rdnType = configValue.substring(DN_PREFIX.length());
return new Function<X509Certificate, Set<String>>() {
@Override
public Set<String> apply(X509Certificate certificate) {
return extractRolesFromCertRdn(certificate, roles, rdnType);
}
};
} else {

throw new ConfigurationException("Invalid certificate role attribute '%s'".formatted(configValue),
Set.of("quarkus.http.auth.certificate-role-attribute"));
}
}

private static Set<String> extractRolesFromCertRdn(X509Certificate certificate, Map<String, Set<String>> roles,
String rdnType) {
X500Principal principal = certificate.getSubjectX500Principal();
if (principal == null || principal.getName() == null) {
return Set.of();
}
Set<String> matchedRoles;
if (COMMON_NAME.equals(rdnType)) {
matchedRoles = roles.get(principal.getName());
if (matchedRoles != null) {
return matchedRoles;
}
}
String rdnValue = getRdnValue(principal, rdnType);
if (rdnValue != null) {
matchedRoles = roles.get(rdnValue);
if (matchedRoles != null) {
return matchedRoles;
}
}
return Set.of();
}

private enum SAN {

/**
* Subject Alternative Name field Other Name.
* Please note that only simple case of UTF8 identifier mapping is support.
* For example, you can map 'other-identifier' to the SecurityIdentity roles.
* If you use 'openssl' tool, supported Other name definition would look like this:
* <code>subjectAltName=otherName:1.2.3.4;UTF8:other-identifier</code>
*/
SAN_ANY(0),
/**
* Subject Alternative Name field RFC 822 Name.
*/
SAN_RFC822(1),
/**
* Subject Alternative Name field DNS Name.
*/
SAN_DNS(2),
/**
* Subject Alternative Name field x400 Address.
*/
SAN_X400(3),
/**
* Subject Alternative Name field Directory Name.
*/
SAN_DIRECTORY(4),
/**
* Subject Alternative Name field EDI Party Name.
*/
SAN_EDI(5),
/**
* Subject Alternative Name field Uniform Resource Identifier (URI).
*/
SAN_URI(6),
/**
* Subject Alternative Name field IP Address (IP).
*/
SAN_IP(7),
/**
* Subject Alternative Name field Registered Object Identifier (OID).
*/
SAN_OID(8);

private final int generalNameType;

SAN(int generalNameType) {
this.generalNameType = generalNameType;
}
}

private static Set<String> extractRolesFromCertSan(X509Certificate certificate, int generalNameType,
Map<String, Set<String>> roles) {
final Set<String> result = new HashSet<>();
try {
var sanList = certificate.getSubjectAlternativeNames();
if (sanList != null && !sanList.isEmpty()) {
for (List<?> objects : sanList) {
if (objects != null && objects.size() >= 2) {
if (objects.get(0) instanceof Integer thatGeneralNameType) {
if (thatGeneralNameType == generalNameType) {

// special handling for Other name
if (thatGeneralNameType == 0 && objects.get(1) instanceof byte[] byteArr) {
var asn1 = ASN1.parseASN1(byteArr);
if (asn1.is(ASN1.SEQUENCE) && asn1.length() == 2) {

var otherIdentifier = asn1.object(1);
while (otherIdentifier.length() == 1
&& otherIdentifier.is(ASN1.CONTEXT_SPECIFIC)) {
// there can be one extra context specific ASN with OpenJDK 17, hence loop
otherIdentifier = otherIdentifier.object(0);
}

if (otherIdentifier.is(ASN1.UTF8_STRING)) {
var value = new String(otherIdentifier.binary(0), StandardCharsets.UTF_8);
if (roles.containsKey(value)) {
result.addAll(roles.get(value));
break;
}
}
}
}

for (int i = 1; i < objects.size(); i++) {
if (objects.get(i) instanceof String name) {
if (roles.containsKey(name)) {
result.addAll(roles.get(name));
}
}
}
}
continue;
}
}
log.tracef("Cannot map SecurityIdentity roles from '%s' due to unsupported format", objects);
break;
}
}
} catch (CertificateParsingException e) {
log.tracef("Cannot map SecurityIdentity roles as certificate parsing failed");
}
return Set.copyOf(result);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,10 @@ public void setMtlsCertificateRoleProperties(HttpConfiguration config) {
roles.put((String) e.getKey(), parseRoles((String) e.getValue()));
}

mtls.get().setRoleMappings(roles);
if (!roles.isEmpty()) {
var certRolesAttribute = new CertificateRoleAttribute(config.auth.certificateRoleAttribute, roles);
mtls.get().setCertificateToRolesMapper(certRolesAttribute.rolesMapper());
}
} catch (Exception e) {
log.warnf("Unable to read roles mappings from %s:%s", rolesPath, e.getMessage());
}
Expand Down Expand Up @@ -483,4 +486,5 @@ private static Set<String> parseRoles(String value) {
}
return Set.copyOf(roles);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@

import java.util.Map;

import javax.naming.InvalidNameException;
import javax.naming.ldap.LdapName;
import javax.naming.ldap.Rdn;
import javax.security.auth.x500.X500Principal;

import io.quarkus.security.identity.request.AuthenticationRequest;
import io.vertx.ext.web.RoutingContext;

public final class HttpSecurityUtils {
public final static String ROUTING_CONTEXT_ATTRIBUTE = "quarkus.http.routing.context";
static final String COMMON_NAME = "CN";

private HttpSecurityUtils() {

Expand All @@ -24,4 +30,26 @@ public static RoutingContext getRoutingContextAttribute(AuthenticationRequest re
public static RoutingContext getRoutingContextAttribute(Map<String, Object> authenticationRequestAttributes) {
return (RoutingContext) authenticationRequestAttributes.get(ROUTING_CONTEXT_ATTRIBUTE);
}

public static String getCommonName(X500Principal principal) {
return getRdnValue(principal, COMMON_NAME);
}

static String getRdnValue(X500Principal principal, String rdnType) {
try {
LdapName ldapDN = new LdapName(principal.getName());

// Apparently for some RDN variations it might not produce correct results
// Can be tuned as necessary.
for (Rdn rdn : ldapDN.getRdns()) {
if (rdnType.equalsIgnoreCase(rdn.getType())) {
return rdn.getValue().toString();
}
}
} catch (InvalidNameException ex) {
// Failing the augmentation process because of this exception seems unnecessary
// The RDN my include some characters unexpected by the legacy LdapName API specification.
}
return null;
}
}
Loading

0 comments on commit 214c8b7

Please sign in to comment.