diff --git a/jetty-io/src/main/java/org/eclipse/jetty/io/ssl/SslClientConnectionFactory.java b/jetty-io/src/main/java/org/eclipse/jetty/io/ssl/SslClientConnectionFactory.java index 588794d2da96..ea7dd5f3094f 100644 --- a/jetty-io/src/main/java/org/eclipse/jetty/io/ssl/SslClientConnectionFactory.java +++ b/jetty-io/src/main/java/org/eclipse/jetty/io/ssl/SslClientConnectionFactory.java @@ -34,6 +34,9 @@ import org.eclipse.jetty.util.component.ContainerLifeCycle; import org.eclipse.jetty.util.ssl.SslContextFactory; +/** + *

A ClientConnectionFactory that creates client-side {@link SslConnection} instances.

+ */ public class SslClientConnectionFactory implements ClientConnectionFactory { public static final String SSL_CONTEXT_FACTORY_CONTEXT_KEY = "ssl.context.factory"; @@ -120,7 +123,10 @@ public org.eclipse.jetty.io.Connection newConnection(EndPoint endPoint, Map context) return ClientConnectionFactory.super.customize(connection, context); } + /** + *

A factory for {@link SSLEngine} objects.

+ *

Typically implemented by {@link SslContextFactory.Client} + * to support more flexible creation of SSLEngine instances.

+ */ + public interface SslEngineFactory + { + /** + *

Creates a new {@link SSLEngine} instance for the given peer host and port, + * and with the given context to help the creation of the SSLEngine.

+ * + * @param host the peer host + * @param port the peer port + * @param context the {@link ClientConnectionFactory} context + * @return a new SSLEngine instance + */ + public SSLEngine newSslEngine(String host, int port, Map context); + } + private class HTTPSHandshakeListener implements SslHandshakeListener { private final Map context; diff --git a/jetty-proxy/src/test/java/org/eclipse/jetty/proxy/ClientAuthProxyTest.java b/jetty-proxy/src/test/java/org/eclipse/jetty/proxy/ClientAuthProxyTest.java new file mode 100644 index 000000000000..d6601e3700a8 --- /dev/null +++ b/jetty-proxy/src/test/java/org/eclipse/jetty/proxy/ClientAuthProxyTest.java @@ -0,0 +1,549 @@ +// +// ======================================================================== +// Copyright (c) 1995-2021 Mort Bay Consulting Pty Ltd and others. +// ------------------------------------------------------------------------ +// All rights reserved. This program and the accompanying materials +// are made available under the terms of the Eclipse Public License v1.0 +// and Apache License v2.0 which accompanies this distribution. +// +// The Eclipse Public License is available at +// http://www.eclipse.org/legal/epl-v10.html +// +// The Apache License v2.0 is available at +// http://www.opensource.org/licenses/apache2.0.php +// +// You may elect to redistribute this code under either of these licenses. +// ======================================================================== +// + +package org.eclipse.jetty.proxy; + +import java.io.IOException; +import java.security.KeyStore; +import java.security.Principal; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.SSLSession; +import javax.net.ssl.X509ExtendedKeyManager; +import javax.security.auth.x500.X500Principal; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.HttpClientTransport; +import org.eclipse.jetty.client.HttpDestination; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.client.http.HttpClientTransportOverHTTP; +import org.eclipse.jetty.http.HttpScheme; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.io.ClientConnectionFactory; +import org.eclipse.jetty.io.Connection; +import org.eclipse.jetty.io.ssl.SslClientConnectionFactory; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.SecureRequestCustomizer; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.SslConnectionFactory; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.eclipse.jetty.toolchain.test.MavenTestingUtils; +import org.eclipse.jetty.util.component.LifeCycle; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.eclipse.jetty.util.thread.QueuedThreadPool; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + *

Tests for client and proxy authentication using certificates.

+ *

There are 3 KeyStores:

+ *
+ *
{@code client_keystore.p12}
+ *
{@code server} -> the server certificate with CN=server
+ *
{@code user1_client} -> the client certificate for user1, signed with the server certificate
+ *
{@code user2_client} -> the client certificate for user2, signed with the server certificate
+ *
+ *
+ *
{@code proxy_keystore.p12}
+ *
{@code proxy} -> the proxy domain private key and certificate with CN=proxy
+ *
{@code server} -> the server domain certificate with CN=server
+ *
{@code user1_proxy} -> the proxy client certificate for user1, signed with the server certificate
+ *
{@code user2_proxy} -> the proxy client certificate for user2, signed with the server certificate
+ *
+ *
+ *
{@code server_keystore.p12}
+ *
{@code server} -> the server domain private key and certificate with CN=server, + * with extension ca:true to sign client and proxy certificates.
+ *
+ *

In this way, a remote client can connect to the proxy and be authenticated, + * and the proxy can connect to the server on behalf of that remote client, since + * the proxy has a certificate correspondent to the one of the remote client.

+ *

The main problem is to make sure that the {@code HttpClient} in the proxy uses different connections + * to connect to the same server, and that those connections are authenticated via TLS client certificate + * with the correct certificate, avoiding that requests made by {@code user2} are sent over connections + * that are authenticated with {@code user1} certificates.

+ */ +public class ClientAuthProxyTest +{ + private Server server; + private ServerConnector serverConnector; + private Server proxy; + private ServerConnector proxyConnector; + private HttpClient client; + + private void startServer(Handler handler) throws Exception + { + QueuedThreadPool serverThreads = new QueuedThreadPool(); + serverThreads.setName("server"); + server = new Server(serverThreads); + + HttpConfiguration httpConfig = new HttpConfiguration(); + httpConfig.addCustomizer(new SecureRequestCustomizer()); + HttpConnectionFactory http = new HttpConnectionFactory(httpConfig); + + SslContextFactory.Server serverTLS = new SslContextFactory.Server(); + serverTLS.setNeedClientAuth(true); + // The KeyStore is also a TrustStore. + serverTLS.setKeyStorePath(MavenTestingUtils.getTestResourceFile("client_auth/server_keystore.p12").getAbsolutePath()); + serverTLS.setKeyStorePassword("storepwd"); + serverTLS.setKeyStoreType("PKCS12"); + + SslConnectionFactory ssl = new SslConnectionFactory(serverTLS, http.getProtocol()); + + serverConnector = new ServerConnector(server, 1, 1, ssl, http); + server.addConnector(serverConnector); + + server.setHandler(handler); + + server.start(); + System.err.println("SERVER = localhost:" + serverConnector.getLocalPort()); + } + + private void startServer() throws Exception + { + startServer(new EmptyServerHandler() + { + @Override + protected void service(String target, org.eclipse.jetty.server.Request jettyRequest, HttpServletRequest request, HttpServletResponse response) throws IOException + { + X509Certificate[] certificates = (X509Certificate[])request.getAttribute(SecureRequestCustomizer.JAVAX_SERVLET_REQUEST_X_509_CERTIFICATE); + Assertions.assertNotNull(certificates); + X509Certificate certificate = certificates[0]; + X500Principal principal = certificate.getSubjectX500Principal(); + ServletOutputStream output = response.getOutputStream(); + output.println(principal.toString()); + output.println(request.getRemotePort()); + } + }); + } + + private void startProxy(AbstractProxyServlet servlet) throws Exception + { + QueuedThreadPool proxyThreads = new QueuedThreadPool(); + proxyThreads.setName("proxy"); + proxy = new Server(); + + HttpConfiguration httpConfig = new HttpConfiguration(); + httpConfig.addCustomizer(new SecureRequestCustomizer()); + HttpConnectionFactory http = new HttpConnectionFactory(httpConfig); + + SslContextFactory.Server proxyTLS = new SslContextFactory.Server(); + proxyTLS.setNeedClientAuth(true); + // The KeyStore is also a TrustStore. + proxyTLS.setKeyStorePath(MavenTestingUtils.getTestResourceFile("client_auth/proxy_keystore.p12").getAbsolutePath()); + proxyTLS.setKeyStorePassword("storepwd"); + proxyTLS.setKeyStoreType("PKCS12"); + + SslConnectionFactory ssl = new SslConnectionFactory(proxyTLS, http.getProtocol()); + + proxyConnector = new ServerConnector(proxy, 1, 1, ssl, http); + proxy.addConnector(proxyConnector); + + ServletContextHandler context = new ServletContextHandler(proxy, "/"); + context.addServlet(new ServletHolder(servlet), "/*"); + proxy.setHandler(context); + + proxy.start(); + System.err.println("PROXY = localhost:" + proxyConnector.getLocalPort()); + } + + private void startClient() throws Exception + { + SslContextFactory.Client clientTLS = new SslContextFactory.Client(); + // Disable TLS-level hostname verification. + clientTLS.setEndpointIdentificationAlgorithm(null); + clientTLS.setKeyStorePath(MavenTestingUtils.getTestResourceFile("client_auth/client_keystore.p12").getAbsolutePath()); + clientTLS.setKeyStorePassword("storepwd"); + clientTLS.setKeyStoreType("PKCS12"); + client = new HttpClient(clientTLS); + QueuedThreadPool clientThreads = new QueuedThreadPool(); + clientThreads.setName("client"); + client.setExecutor(clientThreads); + client.start(); + } + + @AfterEach + public void dispose() throws Exception + { + LifeCycle.stop(client); + LifeCycle.stop(proxy); + LifeCycle.stop(server); + } + + private static String retrieveUser(HttpServletRequest request) + { + X509Certificate[] certificates = (X509Certificate[])request.getAttribute(SecureRequestCustomizer.JAVAX_SERVLET_REQUEST_X_509_CERTIFICATE); + String clientName = certificates[0].getSubjectX500Principal().getName(); + Matcher matcher = Pattern.compile("CN=([^,]+)").matcher(clientName); + if (matcher.find()) + { + // Retain only "userN". + return matcher.group(1).split("_")[0]; + } + return null; + } + + @Test + public void testClientAuthProxyingWithMultipleHttpClients() throws Exception + { + // Using a different HttpClient (with a different SslContextFactory.Client) + // per user works, but there is a lot of duplicated state in the HttpClients: + // Executors and Schedulers (although they can be shared), but also CookieManagers + // ProtocolHandlers, etc. + // The proxy has different SslContextFactory.Client statically configured + // for different users. + + startServer(); + startProxy(new AsyncProxyServlet() + { + private final Map httpClients = new ConcurrentHashMap<>(); + + @Override + protected Request newProxyRequest(HttpServletRequest request, String rewrittenTarget) + { + String user = retrieveUser(request); + HttpClient httpClient = getOrCreateHttpClient(user); + Request proxyRequest = httpClient.newRequest(rewrittenTarget) + .method(request.getMethod()) + .attribute(CLIENT_REQUEST_ATTRIBUTE, request); + // Send the request to the server. + proxyRequest.port(serverConnector.getLocalPort()); + // No need to tag the request when using different HttpClients. + return proxyRequest; + } + + private HttpClient getOrCreateHttpClient(String user) + { + if (user == null) + return getHttpClient(); + return httpClients.computeIfAbsent(user, key -> + { + SslContextFactory.Client clientTLS = new SslContextFactory.Client(); + // Disable TLS-level hostname verification for this test. + clientTLS.setEndpointIdentificationAlgorithm(null); + clientTLS.setKeyStorePath(MavenTestingUtils.getTestResourceFile("client_auth/proxy_keystore.p12").getAbsolutePath()); + clientTLS.setKeyStorePassword("storepwd"); + clientTLS.setKeyStoreType("PKCS12"); + clientTLS.setCertAlias(key + "_proxy"); + // TODO: httpClients should share Executor and Scheduler at least. + HttpClient httpClient = new HttpClient(new HttpClientTransportOverHTTP(1), clientTLS); + LifeCycle.start(httpClient); + return httpClient; + }); + } + }); + startClient(); + + testRequestsFromRemoteClients(); + } + + @Test + public void testClientAuthProxyingWithMultipleServerSubDomains() throws Exception + { + // Another idea is to use multiple subdomains for the server, + // such as user1.server.com, user2.server.com, with the server + // providing a *.server.com certificate. + // The proxy must pick the right alias dynamically based on the + // remote client request. + // For this test we use 127.0.0.N addresses. + + startServer(); + startProxy(new AsyncProxyServlet() + { + private final AtomicInteger userIds = new AtomicInteger(); + private final Map subDomains = new ConcurrentHashMap<>(); + + @Override + protected Request newProxyRequest(HttpServletRequest request, String rewrittenTarget) + { + String user = retrieveUser(request); + // Obviously not fool proof, but for the 2 users in this test it does the job. + String subDomain = subDomains.computeIfAbsent(user, key -> "127.0.0." + userIds.incrementAndGet()); + Request proxyRequest = super.newProxyRequest(request, rewrittenTarget); + proxyRequest.host(subDomain).port(serverConnector.getLocalPort()); + // Tag the request. + proxyRequest.tag(new AliasTLSTag(user)); + return proxyRequest; + } + + @Override + protected HttpClient newHttpClient() + { + SslContextFactory.Client clientTLS = new SslContextFactory.Client() + { + @Override + protected KeyManager[] getKeyManagers(KeyStore keyStore) throws Exception + { + KeyManager[] keyManagers = super.getKeyManagers(keyStore); + for (int i = 0; i < keyManagers.length; i++) + { + keyManagers[i] = new ProxyAliasX509ExtendedKeyManager(keyManagers[i]); + } + return keyManagers; + } + }; + // Disable TLS-level hostname verification for this test. + clientTLS.setEndpointIdentificationAlgorithm(null); + clientTLS.setKeyStorePath(MavenTestingUtils.getTestResourceFile("client_auth/proxy_keystore.p12").getAbsolutePath()); + clientTLS.setKeyStorePassword("storepwd"); + clientTLS.setKeyStoreType("PKCS12"); + return new HttpClient(new HttpClientTransportOverHTTP(1), clientTLS); + } + }); + startClient(); + + testRequestsFromRemoteClients(); + } + + @Test + public void testClientAuthProxyingWithSSLSessionResumptionDisabled() throws Exception + { + // To user the same HttpClient and server hostName, we need to disable + // SSLSession caching, which is only possible by creating SSLEngine + // without peer host information. + // This is more CPU intensive because TLS sessions can never be resumed. + + startServer(); + startProxy(new AsyncProxyServlet() + { + @Override + protected Request newProxyRequest(HttpServletRequest request, String rewrittenTarget) + { + String user = retrieveUser(request); + Request proxyRequest = super.newProxyRequest(request, rewrittenTarget); + proxyRequest.port(serverConnector.getLocalPort()); + // Tag the request. + proxyRequest.tag(new AliasTLSTag(user)); + return proxyRequest; + } + + @Override + protected HttpClient newHttpClient() + { + SslContextFactory.Client clientTLS = new SslContextFactory.Client() + { + @Override + public SSLEngine newSSLEngine(String host, int port) + { + // This disable TLS session resumption and requires + // endpointIdentificationAlgorithm=null because the TLS implementation + // does not have the peer host to verify the server certificate. + return newSSLEngine(); + } + + @Override + protected KeyManager[] getKeyManagers(KeyStore keyStore) throws Exception + { + KeyManager[] keyManagers = super.getKeyManagers(keyStore); + for (int i = 0; i < keyManagers.length; i++) + { + keyManagers[i] = new ProxyAliasX509ExtendedKeyManager(keyManagers[i]); + } + return keyManagers; + } + }; + // Disable hostname verification is required. + clientTLS.setEndpointIdentificationAlgorithm(null); + clientTLS.setKeyStorePath(MavenTestingUtils.getTestResourceFile("client_auth/proxy_keystore.p12").getAbsolutePath()); + clientTLS.setKeyStorePassword("storepwd"); + clientTLS.setKeyStoreType("PKCS12"); + return new HttpClient(new HttpClientTransportOverHTTP(1), clientTLS); + + } + }); + startClient(); + + testRequestsFromRemoteClients(); + } + + @Test + public void testClientAuthProxyingWithCompositeSslContextFactory() throws Exception + { + // The idea here is to have a composite SslContextFactory that holds one for each user. + // It requires a change in SslClientConnectionFactory to "sniff" for the composite. + + startServer(); + startProxy(new AsyncProxyServlet() + { + @Override + protected Request newProxyRequest(HttpServletRequest request, String rewrittenTarget) + { + String user = retrieveUser(request); + Request proxyRequest = super.newProxyRequest(request, rewrittenTarget); + proxyRequest.port(serverConnector.getLocalPort()); + proxyRequest.tag(user); + return proxyRequest; + } + + @Override + protected HttpClient newHttpClient() + { + ProxyAliasClientSslContextFactory clientTLS = configure(new ProxyAliasClientSslContextFactory(), null); + // Statically add SslContextFactory.Client instances per each user. + clientTLS.factories.put("user1", configure(new SslContextFactory.Client(), "user1")); + clientTLS.factories.put("user2", configure(new SslContextFactory.Client(), "user2")); + return new HttpClient(new HttpClientTransportOverHTTP(1), clientTLS); + } + + private T configure(T tls, String user) + { + // Disable TLS-level hostname verification for this test. + tls.setEndpointIdentificationAlgorithm(null); + tls.setKeyStorePath(MavenTestingUtils.getTestResourceFile("client_auth/proxy_keystore.p12").getAbsolutePath()); + tls.setKeyStorePassword("storepwd"); + tls.setKeyStoreType("PKCS12"); + if (user != null) + { + tls.setCertAlias(user + "_proxy"); + LifeCycle.start(tls); + } + return tls; + } + }); + startClient(); + + testRequestsFromRemoteClients(); + } + + private void testRequestsFromRemoteClients() throws Exception + { + // User1 makes a request to the proxy using its own certificate. + SslContextFactory clientTLS = client.getSslContextFactory(); + clientTLS.reload(ssl -> ssl.setCertAlias("user1_client")); + ContentResponse response = client.newRequest("localhost", proxyConnector.getLocalPort()) + .scheme(HttpScheme.HTTPS.asString()) + .timeout(5, TimeUnit.SECONDS) + .tag("user1") + .send(); + Assertions.assertEquals(HttpStatus.OK_200, response.getStatus()); + String[] parts = response.getContentAsString().split("\n"); + String proxyClientSubject1 = parts[0]; + String proxyClientPort1 = parts[1]; + + // User2 makes a request to the proxy using its own certificate. + clientTLS.reload(ssl -> ssl.setCertAlias("user2_client")); + response = client.newRequest("localhost", proxyConnector.getLocalPort()) + .scheme(HttpScheme.HTTPS.asString()) + .timeout(5, TimeUnit.SECONDS) + .tag("user2") + .send(); + Assertions.assertEquals(HttpStatus.OK_200, response.getStatus()); + parts = response.getContentAsString().split("\n"); + String proxyClientSubject2 = parts[0]; + String proxyClientPort2 = parts[1]; + + Assertions.assertNotEquals(proxyClientSubject1, proxyClientSubject2); + Assertions.assertNotEquals(proxyClientPort1, proxyClientPort2); + } + + private static class AliasTLSTag implements ClientConnectionFactory.Decorator + { + private final String user; + + private AliasTLSTag(String user) + { + this.user = user; + } + + @Override + public ClientConnectionFactory apply(ClientConnectionFactory factory) + { + return (endPoint, context) -> + { + Connection connection = factory.newConnection(endPoint, context); + SSLEngine sslEngine = (SSLEngine)context.get(SslClientConnectionFactory.SSL_ENGINE_CONTEXT_KEY); + sslEngine.getSession().putValue("user", user); + return connection; + }; + } + + @Override + public boolean equals(Object obj) + { + if (this == obj) + return true; + if (obj == null || getClass() != obj.getClass()) + return false; + AliasTLSTag that = (AliasTLSTag)obj; + return user.equals(that.user); + } + + @Override + public int hashCode() + { + return Objects.hash(user); + } + } + + private static class ProxyAliasX509ExtendedKeyManager extends SslContextFactory.X509ExtendedKeyManagerWrapper + { + private ProxyAliasX509ExtendedKeyManager(KeyManager keyManager) + { + super((X509ExtendedKeyManager)keyManager); + } + + @Override + public String chooseEngineClientAlias(String[] keyTypes, Principal[] issuers, SSLEngine engine) + { + for (String keyType : keyTypes) + { + String[] aliases = getClientAliases(keyType, issuers); + if (aliases != null) + { + SSLSession sslSession = engine.getSession(); + String user = (String)sslSession.getValue("user"); + String alias = user + "_proxy"; + if (Arrays.asList(aliases).contains(alias)) + return alias; + } + } + return super.chooseEngineClientAlias(keyTypes, issuers, engine); + } + } + + private static class ProxyAliasClientSslContextFactory extends SslContextFactory.Client implements SslClientConnectionFactory.SslEngineFactory + { + private final Map factories = new ConcurrentHashMap<>(); + + @Override + public SSLEngine newSslEngine(String host, int port, Map context) + { + HttpDestination destination = (HttpDestination)context.get(HttpClientTransport.HTTP_DESTINATION_CONTEXT_KEY); + String user = (String)destination.getOrigin().getTag(); + return factories.compute(user, (key, value) -> value != null ? value : this).newSSLEngine(host, port); + } + } +} diff --git a/jetty-proxy/src/test/resources/client_auth/client_keystore.p12 b/jetty-proxy/src/test/resources/client_auth/client_keystore.p12 new file mode 100644 index 000000000000..cf324d7ad20f Binary files /dev/null and b/jetty-proxy/src/test/resources/client_auth/client_keystore.p12 differ diff --git a/jetty-proxy/src/test/resources/client_auth/proxy_keystore.p12 b/jetty-proxy/src/test/resources/client_auth/proxy_keystore.p12 new file mode 100644 index 000000000000..50a21ec94c28 Binary files /dev/null and b/jetty-proxy/src/test/resources/client_auth/proxy_keystore.p12 differ diff --git a/jetty-proxy/src/test/resources/client_auth/server_keystore.p12 b/jetty-proxy/src/test/resources/client_auth/server_keystore.p12 new file mode 100644 index 000000000000..73ecd4d656a1 Binary files /dev/null and b/jetty-proxy/src/test/resources/client_auth/server_keystore.p12 differ