From ab8789739f3e2e285404538139a912b0aaeafbed Mon Sep 17 00:00:00 2001 From: "MICHEL Arnault (UA 2118)" Date: Mon, 15 Oct 2018 16:37:59 +0200 Subject: [PATCH] [KEYCLOAK-8580] Add Nginx certificate lookup provider --- ...lientCertificateFromHttpHeadersLookup.java | 2 +- .../NginxProxySslClientCertificateLookup.java | 360 ++++++++++++++++++ ...roxySslClientCertificateLookupFactory.java | 42 ++ ...es.x509.X509ClientCertificateLookupFactory | 1 + .../resources/META-INF/keycloak-server.json | 6 + 5 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 services/src/main/java/org/keycloak/services/x509/NginxProxySslClientCertificateLookup.java create mode 100644 services/src/main/java/org/keycloak/services/x509/NginxProxySslClientCertificateLookupFactory.java diff --git a/services/src/main/java/org/keycloak/services/x509/AbstractClientCertificateFromHttpHeadersLookup.java b/services/src/main/java/org/keycloak/services/x509/AbstractClientCertificateFromHttpHeadersLookup.java index 83b1b6bfc7..1e8b5043f4 100644 --- a/services/src/main/java/org/keycloak/services/x509/AbstractClientCertificateFromHttpHeadersLookup.java +++ b/services/src/main/java/org/keycloak/services/x509/AbstractClientCertificateFromHttpHeadersLookup.java @@ -40,7 +40,7 @@ public abstract class AbstractClientCertificateFromHttpHeadersLookup implements protected final String sslClientCertHttpHeader; protected final String sslCertChainHttpHeaderPrefix; - private final int certificateChainLength; + protected final int certificateChainLength; public AbstractClientCertificateFromHttpHeadersLookup(String sslCientCertHttpHeader, String sslCertChainHttpHeaderPrefix, diff --git a/services/src/main/java/org/keycloak/services/x509/NginxProxySslClientCertificateLookup.java b/services/src/main/java/org/keycloak/services/x509/NginxProxySslClientCertificateLookup.java new file mode 100644 index 0000000000..7b11f5d7be --- /dev/null +++ b/services/src/main/java/org/keycloak/services/x509/NginxProxySslClientCertificateLookup.java @@ -0,0 +1,360 @@ +/* + * Copyright 2017 Analytical Graphics, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed 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 org.keycloak.services.x509; + +import java.io.UnsupportedEncodingException; +import java.security.GeneralSecurityException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.PublicKey; +import java.security.SignatureException; +import java.security.cert.CertPath; +import java.security.cert.CertPathBuilder; +import java.security.cert.CertPathBuilderException; +import java.security.cert.CertStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CollectionCertStoreParameters; +import java.security.cert.PKIXBuilderParameters; +import java.security.cert.TrustAnchor; +import java.security.cert.X509CertSelector; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.jboss.logging.Logger; +import org.jboss.logging.Logger.Level; +import org.jboss.resteasy.spi.HttpRequest; +import org.keycloak.common.util.PemException; +import org.keycloak.common.util.PemUtils; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.truststore.TruststoreProvider; +import org.keycloak.truststore.TruststoreProviderFactory; + +/** + * The NGINX Provider extract end user X.509 certificate send during TLS mutual authentication, + * and forwarded in an http header. + * + * NGINX configuration must have : + * + * server { + * ... + * ssl_client_certificate path-to-my-trustyed-cas-for client auth.pem; + * ssl_verify_client on|optional_no_ca; + * ssl_verify_depth 2; + * ... + * location / { + * ... + * sproxy_set_header ssl-client-cert $ssl_client_escaped_cert; + * ... + * } + * + * + * Note that $ssl_client_cert is deprecated, use only $ssl_client_escaped_cert with this implementation + * + * @author Arnault MICHEL + * @version $Revision: 1 $ + * @since 10/09/2018 + */ + +public class NginxProxySslClientCertificateLookup extends AbstractClientCertificateFromHttpHeadersLookup { + + private static final Logger log = Logger.getLogger(NginxProxySslClientCertificateLookup.class); + + private static KeyStore truststore = null; + private static Set trustedRootCerts = null; + private static Set intermediateCerts = null; + + + public NginxProxySslClientCertificateLookup(String sslCientCertHttpHeader, + String sslCertChainHttpHeaderPrefix, + int certificateChainLength, + KeycloakSession kcsession) { + super(sslCientCertHttpHeader, sslCertChainHttpHeaderPrefix, certificateChainLength); + + if (!loadKeycloakTrustStore(kcsession)) { + log.warn("Keycloak Truststore is null or empty, but it's needed to rebuild client certificate chain with nginx Keycloak Provider"); + log.warn(" see Keycloak documentation here : https://www.keycloak.org/docs/latest/server_installation/index.html#_truststore"); + } + log.debug(" Keycloak truststore loaded for NGINX client certificate provider."); + } + + /** + * Removing PEM Headers and end of lines + * @param pem + * @return + */ + private static String removeBeginEnd(String pem) { + pem = pem.replace("-----BEGIN CERTIFICATE-----", ""); + pem = pem.replace("-----END CERTIFICATE-----", ""); + pem = pem.replace("\r\n", ""); + pem = pem.replace("\n", ""); + return pem.trim(); + } + + /** + * Decoding end user certificate, including URL decodeding due to ssl_client_escaped_cert nginx variable. + */ + @Override + protected X509Certificate decodeCertificateFromPem(String pem) throws PemException { + + if (pem == null) { + log.info("End user TLS Certificate is NULL! "); + return null; + } + try { + pem = java.net.URLDecoder.decode(pem, "UTF-8"); + } catch (UnsupportedEncodingException e) { + log.error("Cannot URL decode the end user TLS Certificate : " + pem,e); + } + + if (pem.startsWith("-----BEGIN CERTIFICATE-----")) { + pem = removeBeginEnd(pem); + } + + return PemUtils.decodeCertificate(pem); + } + + @Override + public X509Certificate[] getCertificateChain(HttpRequest httpRequest) throws GeneralSecurityException { + List chain = new ArrayList<>(); + + // Get the client certificate + X509Certificate clientCert = getCertificateFromHttpHeader(httpRequest, sslClientCertHttpHeader); + log.debugf("End user certificate found : DN=[%s] SerialNumber=[%s]", clientCert.getSubjectDN().toString(), clientCert.getSerialNumber().toString() ); + + if (clientCert != null) { + + // Rebuilding the end user certificate chain using Keycloak Truststore + X509Certificate[] certChain = buildChain(clientCert); + for (X509Certificate cacert : certChain) { + chain.add(cacert); + log.debugf("Rebuilded user cert chain DN : %s", cacert.getSubjectDN().toString() ); + } + } + return chain.toArray(new X509Certificate[0]); + } + + /** + * As NGINX cannot actually send the CA Chain in http header, + * @param end_user_auth_cert + * @return + */ + public X509Certificate[] buildChain(X509Certificate end_user_auth_cert) { + + String javasecuritydebugoriginalsettings = setJVMDebuggingForCertPathBuilder(); + + X509Certificate[] user_cert_chain = null; + + try { + + // No truststore : no way! + if (truststore == null) { + log.warn("Keycloak Truststore is null, but it is required !"); + log.warn(" see https://www.keycloak.org/docs/latest/server_installation/index.html#_truststore"); + return null; + } + + // Create the selector that specifies the starting certificate + X509CertSelector selector = new X509CertSelector(); + selector.setCertificate(end_user_auth_cert); + + // Create the trust anchors (set of root CA certificates) + Set trustAnchors = new HashSet(); + for (X509Certificate trustedRootCert : trustedRootCerts) { + trustAnchors.add(new TrustAnchor(trustedRootCert, null)); + } + // Configure the PKIX certificate builder algorithm parameters + PKIXBuilderParameters pkixParams = new PKIXBuilderParameters( trustAnchors, selector); + + // Disable CRL checks, as it's possibly done after depending on Keycloak settings + pkixParams.setRevocationEnabled(false); + pkixParams.setExplicitPolicyRequired(false); + pkixParams.setAnyPolicyInhibited(false); + pkixParams.setPolicyQualifiersRejected(false); + pkixParams.setMaxPathLength(certificateChainLength); + + // Adding the list of intermediate certificates + end user certificate + intermediateCerts.add(end_user_auth_cert); + CollectionCertStoreParameters intermediateCA_userCert = new CollectionCertStoreParameters(intermediateCerts); + CertStore intermediateCertStore = CertStore.getInstance("Collection", intermediateCA_userCert, "BC"); + pkixParams.addCertStore(intermediateCertStore); + + // Build and verify the certification chain (revocation status excluded) + CertPathBuilder certPathBuilder = CertPathBuilder.getInstance("PKIX","BC"); + CertPath certPath = certPathBuilder.build(pkixParams).getCertPath(); + log.debug("Certification path building OK, and contains " + certPath.getCertificates().size() + " X509 Certificates"); + + //Remove end user certificate + intermediateCerts.remove(end_user_auth_cert); + + user_cert_chain = convertCertPathtoX509CertArray( certPath ); + + } catch (NoSuchAlgorithmException e) { + log.error(e.getLocalizedMessage(),e); + } catch (CertPathBuilderException e) { + if ( log.isEnabled(Level.TRACE) ) + log.debug(e.getLocalizedMessage(),e); + else + log.warn(e.getLocalizedMessage()); + } catch (InvalidAlgorithmParameterException e) { + log.error(e.getLocalizedMessage(),e); + } catch (NoSuchProviderException e) { + log.error(e.getLocalizedMessage(),e); + } + + //Reset java security debug property to original value + if (javasecuritydebugoriginalsettings!=null) + System.setProperty("java.security.debug",javasecuritydebugoriginalsettings); + + //Remove end user certificate + intermediateCerts.remove(end_user_auth_cert); + + return null; + } + + /** + * Add setting JVM system properties for helping debugging CertPathBuilder + * only if the trace log level is enabled. + * + * @return the original value of system property java.security.debug + */ + private String setJVMDebuggingForCertPathBuilder() { + + String origjvmsecdebprop = null; + if ( log.isEnabled(Level.TRACE) ) { + origjvmsecdebprop = System.getProperty("java.security.debug"); + if (origjvmsecdebprop.indexOf("certpath") == -1) { + if (origjvmsecdebprop.length() == 0) + System.setProperty("java.security.debug","certpath"); + else + System.setProperty("java.security.debug",origjvmsecdebprop + ",certpath"); + } + + } + return origjvmsecdebprop; + + } + + public X509Certificate[] convertCertPathtoX509CertArray( CertPath certPath ) { + + X509Certificate[] x509certchain = null; + + if (certPath!=null) { + List trustedX509Chain = new ArrayList(); + for (Certificate certificate : certPath.getCertificates() ) + if ( certificate instanceof X509Certificate ) + trustedX509Chain.add((X509Certificate)certificate); + x509certchain = trustedX509Chain.toArray(new X509Certificate[0]); + } + + return x509certchain; + + } + + public boolean loadKeycloakTrustStore(KeycloakSession kcsession) { + + boolean isTSLoaded = false; + KeycloakSessionFactory factory = kcsession.getKeycloakSessionFactory(); + TruststoreProviderFactory truststoreFactory = (TruststoreProviderFactory) factory.getProviderFactory(TruststoreProvider.class, "file"); + + TruststoreProvider provider = truststoreFactory.create(kcsession); + if ( ! (provider != null && provider.getTruststore() == null ) ) { + truststore = provider.getTruststore(); + readTruststore(); + + isTSLoaded = true; + } + + return isTSLoaded; + } + + /** + * Get all certificates from Keycloak Truststore, and classify them in two lists : root CAs and intermediates CAs + */ + private void readTruststore() { + + //Reading truststore aliases & certificates + Enumeration enumeration; + trustedRootCerts = new HashSet(); + intermediateCerts = new HashSet(); + try { + + enumeration = truststore.aliases(); + + while(enumeration.hasMoreElements()) { + + String alias = (String)enumeration.nextElement(); + Certificate certificate = truststore.getCertificate(alias); + + if (certificate instanceof X509Certificate) { + X509Certificate cax509cert = (X509Certificate) certificate; + if (isSelfSigned(cax509cert)) { + trustedRootCerts.add(cax509cert); + log.debug("Adding certificate from trustore as trsusted root CA (alias : "+alias + " | Subject DN : " + ((X509Certificate) certificate).getSubjectDN() +")"); + } else { + intermediateCerts.add(cax509cert); + log.debug("Adding certificate from trustore as intermediate CA (alias : "+alias + " | Subject DN : " + ((X509Certificate) certificate).getSubjectDN() +")"); + } + } else + log.warn("Skipping certificate in "+ alias + " because it's not an X509Certificate"); + + } + } catch (KeyStoreException e) { + log.error("Error while reading Keycloak truststore "+e.getMessage(),e); + } catch (CertificateException e) { + log.error("Error while reading Keycloak truststore "+e.getMessage(),e); + } catch (NoSuchAlgorithmException e) { + log.error("Error while reading Keycloak truststore "+e.getMessage(),e); + } catch (NoSuchProviderException e) { + log.error("Error while reading Keycloak truststore "+e.getMessage(),e); + } + } + + /** + * Checks whether given X.509 certificate is self-signed. + */ + public boolean isSelfSigned(X509Certificate cert) + throws CertificateException, NoSuchAlgorithmException, + NoSuchProviderException { + try { + // Try to verify certificate signature with its own public key + PublicKey key = cert.getPublicKey(); + cert.verify(key); + log.trace("certificate " + cert.getSubjectDN() + " detected as root CA"); + return true; + } catch (SignatureException sigEx) { + // Invalid signature --> not self-signed + log.trace("certificate have a bad signature : " + sigEx.getMessage(),sigEx); + } catch (InvalidKeyException keyEx) { + // Invalid key --> not self-signed + log.trace("certificate " + cert.getSubjectDN() + " detected as intermediate CA"); + } + return false; + } + +} diff --git a/services/src/main/java/org/keycloak/services/x509/NginxProxySslClientCertificateLookupFactory.java b/services/src/main/java/org/keycloak/services/x509/NginxProxySslClientCertificateLookupFactory.java new file mode 100644 index 0000000000..dc167768e6 --- /dev/null +++ b/services/src/main/java/org/keycloak/services/x509/NginxProxySslClientCertificateLookupFactory.java @@ -0,0 +1,42 @@ +package org.keycloak.services.x509; +/* + * Copyright 2017 Analytical Graphics, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed 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. + * + */ + +import org.keycloak.models.KeycloakSession; + +/** + * @author Arnault MICHEL + * @version $Revision: 1 $ + * @since 10/09/2018 + */ + +public class NginxProxySslClientCertificateLookupFactory extends AbstractClientCertificateFromHttpHeadersLookupFactory { + + private final static String PROVIDER = "nginx"; + + @Override + public X509ClientCertificateLookup create(KeycloakSession session) { + return new NginxProxySslClientCertificateLookup(sslClientCertHttpHeader, + sslChainHttpHeaderPrefix, certificateChainLength, session); + } + + @Override + public String getId() { + return PROVIDER; + } +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.services.x509.X509ClientCertificateLookupFactory b/services/src/main/resources/META-INF/services/org.keycloak.services.x509.X509ClientCertificateLookupFactory index 7aef09b2dc..5940bcfca3 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.services.x509.X509ClientCertificateLookupFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.services.x509.X509ClientCertificateLookupFactory @@ -19,3 +19,4 @@ org.keycloak.services.x509.DefaultClientCertificateLookupFactory org.keycloak.services.x509.HaProxySslClientCertificateLookupFactory org.keycloak.services.x509.ApacheProxySslClientCertificateLookupFactory +org.keycloak.services.x509.NginxProxySslClientCertificateLookupFactory diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/META-INF/keycloak-server.json b/testsuite/integration-arquillian/tests/base/src/test/resources/META-INF/keycloak-server.json index 79ced07822..a40cf3944d 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/META-INF/keycloak-server.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/META-INF/keycloak-server.json @@ -174,6 +174,12 @@ "sslClientCert": "x-ssl-client-cert", "sslCertChainPrefix": "x-ssl-client-cert-chain", "certificateChainLength": 1 + }, + "nginx": { + "enabled": true, + "sslClientCert": "x-ssl-client-cert", + "sslCertChainPrefix": "x-ssl-client-cert-chain", + "certificateChainLength": 1 } } }