diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/DatabaseClientBuilder.java b/marklogic-client-api/src/main/java/com/marklogic/client/DatabaseClientBuilder.java index 298153f38..9498ce2ff 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/DatabaseClientBuilder.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/DatabaseClientBuilder.java @@ -143,9 +143,17 @@ public DatabaseClientBuilder withDigestAuth(String username, String password) { } public DatabaseClientBuilder withMarkLogicCloudAuth(String apiKey, String basePath) { - return withSecurityContextType(SECURITY_CONTEXT_TYPE_MARKLOGIC_CLOUD) + withSecurityContextType(SECURITY_CONTEXT_TYPE_MARKLOGIC_CLOUD) .withCloudApiKey(apiKey) .withBasePath(basePath); + + // Assume sensible defaults for establishing an SSL connection. In the scenario where the user's JVM's + // truststore has a certificate matching that of the MarkLogic Cloud instance, this saves the user from having + // to configure anything except the API key and base path. + if (null == props.get(PREFIX + "sslProtocol") && null == props.get(PREFIX + "sslContext")) { + withSSLProtocol("default"); + } + return this; } public DatabaseClientBuilder withKerberosAuth(String principal) { diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/impl/DatabaseClientPropertySource.java b/marklogic-client-api/src/main/java/com/marklogic/client/impl/DatabaseClientPropertySource.java index 04b1aebb1..5d12d89c5 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/impl/DatabaseClientPropertySource.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/impl/DatabaseClientPropertySource.java @@ -18,6 +18,8 @@ import com.marklogic.client.DatabaseClient; import com.marklogic.client.DatabaseClientBuilder; import com.marklogic.client.DatabaseClientFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import javax.net.ssl.SSLContext; import javax.net.ssl.X509TrustManager; @@ -37,7 +39,8 @@ */ public class DatabaseClientPropertySource { - private final static String PREFIX = DatabaseClientBuilder.PREFIX; + private static final Logger logger = LoggerFactory.getLogger(DatabaseClientPropertySource.class); + private static final String PREFIX = DatabaseClientBuilder.PREFIX; private final Function propertySource; @@ -226,6 +229,18 @@ private X509TrustManager determineTrustManager() { throw new IllegalArgumentException( String.format("Trust manager must be an instance of %s", X509TrustManager.class.getName())); } + // If the user chooses the "default" SSLContext, then it's already been initialized - but OkHttp still + // needs a separate X509TrustManager, so use the JVM's default trust manager. The assumption is that the + // default SSLContext was initialized with the JVM's default trust manager. A user can of course always override + // this by simply providing their own trust manager. + if ("default".equalsIgnoreCase((String) propertySource.apply(PREFIX + "sslProtocol"))) { + X509TrustManager defaultTrustManager = SSLUtil.getDefaultTrustManager(); + if (logger.isDebugEnabled() && defaultTrustManager != null && defaultTrustManager.getAcceptedIssuers() != null) { + logger.debug("Count of accepted issuers in default trust manager: {}", + defaultTrustManager.getAcceptedIssuers().length); + } + return defaultTrustManager; + } return null; } diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/impl/SSLUtil.java b/marklogic-client-api/src/main/java/com/marklogic/client/impl/SSLUtil.java new file mode 100644 index 000000000..f3f24e9c6 --- /dev/null +++ b/marklogic-client-api/src/main/java/com/marklogic/client/impl/SSLUtil.java @@ -0,0 +1,45 @@ +package com.marklogic.client.impl; + +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; + +public interface SSLUtil { + + static X509TrustManager getDefaultTrustManager() { + return (X509TrustManager) getDefaultTrustManagers()[0]; + } + + /** + * @return a non-empty array of TrustManager instances based on the JVM's default trust manager algorithm, with the + * first trust manager guaranteed to be an instance of X509TrustManager. + */ + static TrustManager[] getDefaultTrustManagers() { + final String defaultAlgorithm = TrustManagerFactory.getDefaultAlgorithm(); + TrustManagerFactory trustManagerFactory; + try { + trustManagerFactory = TrustManagerFactory.getInstance(defaultAlgorithm); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Unable to obtain trust manager factory using JVM's default trust manager algorithm: " + defaultAlgorithm, e); + } + + try { + trustManagerFactory.init((KeyStore) null); + } catch (KeyStoreException e) { + throw new RuntimeException("Unable to initialize trust manager factory obtained using JVM's default trust manager algorithm: " + defaultAlgorithm + + "; cause: " + e.getMessage(), e); + } + + TrustManager[] trustManagers = trustManagerFactory.getTrustManagers(); + if (trustManagers == null || trustManagers.length == 0) { + throw new RuntimeException("No trust managers found using the JVM's default trust manager algorithm: " + defaultAlgorithm); + } + if (!(trustManagers[0] instanceof X509TrustManager)) { + throw new RuntimeException("Default trust manager is not an X509TrustManager: " + trustManagers[0]); + } + return trustManagers; + } +} diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/MarkLogicCloudAuthenticationConfigurer.java b/marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/MarkLogicCloudAuthenticationConfigurer.java index 1993f1a2e..d2c0e9dc8 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/MarkLogicCloudAuthenticationConfigurer.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/MarkLogicCloudAuthenticationConfigurer.java @@ -61,6 +61,7 @@ private Response callTokenEndpoint(MarkLogicCloudAuthContext securityContext) { // Current assumption is that the SSL config provided for connecting to MarkLogic should also be applicable // for connecting to MarkLogic Cloud's "/token" endpoint. OkHttpUtil.configureSocketFactory(clientBuilder, securityContext.getSSLContext(), securityContext.getTrustManager()); + OkHttpUtil.configureHostnameVerifier(clientBuilder, securityContext.getSSLHostnameVerifier()); if (logger.isInfoEnabled()) { logger.info("Calling token endpoint at: " + tokenUrl); diff --git a/marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/OkHttpUtil.java b/marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/OkHttpUtil.java index 3691e7e43..08a5998a4 100644 --- a/marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/OkHttpUtil.java +++ b/marklogic-client-api/src/main/java/com/marklogic/client/impl/okhttp/OkHttpUtil.java @@ -3,6 +3,7 @@ import com.marklogic.client.DatabaseClientFactory; import com.marklogic.client.impl.HTTPKerberosAuthInterceptor; import com.marklogic.client.impl.HTTPSamlAuthInterceptor; +import com.marklogic.client.impl.SSLUtil; import okhttp3.ConnectionPool; import okhttp3.CookieJar; import okhttp3.Dns; @@ -13,15 +14,11 @@ import javax.net.ssl.HostnameVerifier; import javax.net.ssl.SSLContext; import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; import java.net.Inet4Address; import java.net.InetAddress; import java.net.UnknownHostException; import java.security.KeyManagementException; -import java.security.KeyStore; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -123,7 +120,7 @@ private static void configureSAMLAuth(DatabaseClientFactory.SAMLAuthContext saml * @param clientBuilder * @param sslVerifier */ - private static void configureHostnameVerifier(OkHttpClient.Builder clientBuilder, DatabaseClientFactory.SSLHostnameVerifier sslVerifier) { + static void configureHostnameVerifier(OkHttpClient.Builder clientBuilder, DatabaseClientFactory.SSLHostnameVerifier sslVerifier) { HostnameVerifier hostnameVerifier = null; if (DatabaseClientFactory.SSLHostnameVerifier.ANY.equals(sslVerifier)) { hostnameVerifier = (hostname, session) -> true; @@ -169,25 +166,16 @@ static void configureSocketFactory(OkHttpClient.Builder clientBuilder, SSLContex * @param sslContext */ private static void initializeSslContext(OkHttpClient.Builder clientBuilder, SSLContext sslContext) { + TrustManager[] trustManagers = SSLUtil.getDefaultTrustManagers(); try { - TrustManagerFactory trustMgrFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - trustMgrFactory.init((KeyStore) null); - TrustManager[] trustMgrs = trustMgrFactory.getTrustManagers(); - if (trustMgrs == null || trustMgrs.length == 0) { - throw new IllegalArgumentException("no trust manager and could not get default trust manager"); - } - if (!(trustMgrs[0] instanceof X509TrustManager)) { - throw new IllegalArgumentException("no trust manager and default is not an X509TrustManager"); - } - sslContext.init(null, trustMgrs, null); - clientBuilder.sslSocketFactory(new SSLSocketFactoryDelegator(sslContext.getSocketFactory()), (X509TrustManager) trustMgrs[0]); - } catch (KeyStoreException e) { - throw new IllegalArgumentException("no trust manager and cannot initialize factory for default", e); - } catch (NoSuchAlgorithmException e) { - throw new IllegalArgumentException("no trust manager and no algorithm for default manager", e); + // In a future release, we may want to check if getSocketFactory() works already, implying that the + // SSLContext has already been initialized. However, if that's the case, then it's not guaranteed that + // the default trust manager is the appropriate one to pass to OkHttp. + sslContext.init(null, trustManagers, null); } catch (KeyManagementException e) { - throw new IllegalArgumentException("no trust manager and cannot initialize context with default", e); + throw new RuntimeException("Unable to initialize SSLContext; cause: " + e.getMessage(), e); } + clientBuilder.sslSocketFactory(new SSLSocketFactoryDelegator(sslContext.getSocketFactory()), (X509TrustManager) trustManagers[0]); } static class DnsImpl implements Dns { diff --git a/marklogic-client-api/src/test/java/com/marklogic/client/test/DatabaseClientBuilderTest.java b/marklogic-client-api/src/test/java/com/marklogic/client/test/DatabaseClientBuilderTest.java index 5efac7a6f..6b40468bd 100644 --- a/marklogic-client-api/src/test/java/com/marklogic/client/test/DatabaseClientBuilderTest.java +++ b/marklogic-client-api/src/test/java/com/marklogic/client/test/DatabaseClientBuilderTest.java @@ -107,6 +107,15 @@ void cloudWithBasePath() { (DatabaseClientFactory.MarkLogicCloudAuthContext) bean.getSecurityContext(); assertEquals("my-key", context.getKey()); assertEquals("/my/path", bean.getBasePath()); + + assertNotNull(context.getSSLContext(), "If no sslProtocol or sslContext is set, the JVM's default SSL " + + "context should be used"); + + assertNotNull(context.getSSLContext().getSocketFactory(), "Since the JVM's default SSL context is expected " + + "to be used, it should already be initialized, and thus able to return a socket factory"); + + assertNotNull(context.getTrustManager(), "Since the JVM's default SSL context is used, the JVM's default " + + "trust manager should be used as well if the user doesn't provide their own"); } @Test @@ -174,6 +183,27 @@ void sslProtocol() { "initialize the SSLContext before using it by using the JVM's default trust manager."); } + @Test + void defaultSslProtocolAndNoTrustManager() { + bean = Common.newClientBuilder() + .withSSLProtocol("default") + .buildBean(); + + DatabaseClientFactory.SecurityContext context = bean.getSecurityContext(); + assertNotNull(context); + + SSLContext sslContext = context.getSSLContext(); + assertNotNull(sslContext); + assertNotNull(sslContext.getSocketFactory(), "A protocol of 'default' should result in the JVM's default " + + "SSLContext being used, which is expected to have been initialized already and can thus return a socket " + + "factory"); + + assertNotNull(context.getTrustManager(), "If the user specifies a protocol of 'default' but does not " + + "provide a trust manager, the assumption is that the JVM's default trust manager should be used, thus " + + "saving the user from having to do the work of providing this themselves."); + } + + @Test void invalidSslProtocol() { RuntimeException ex = assertThrows(RuntimeException.class, () -> Common.newClientBuilder() diff --git a/marklogic-client-api/src/test/java/com/marklogic/client/test/MarkLogicCloudAuthenticationDebugger.java b/marklogic-client-api/src/test/java/com/marklogic/client/test/MarkLogicCloudAuthenticationDebugger.java index ec87ef597..a157cfeb1 100644 --- a/marklogic-client-api/src/test/java/com/marklogic/client/test/MarkLogicCloudAuthenticationDebugger.java +++ b/marklogic-client-api/src/test/java/com/marklogic/client/test/MarkLogicCloudAuthenticationDebugger.java @@ -5,13 +5,13 @@ import com.marklogic.client.DatabaseClientFactory; import com.marklogic.client.io.JacksonHandle; -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; - /** * We don't yet have a way to run tests against a MarkLogic Cloud instance. In the meantime, this program and its * related Gradle task can be used for easy manual testing. + * + * For local testing against the ReverseProxyServer in the test-app project, which emulates MarkLogic Cloud, use + * "localhost" as the cloud host, "username:password" (often "admin:the admin password") as the apiKey, and + * "local/manage" as the basePath. */ public class MarkLogicCloudAuthenticationDebugger { @@ -20,11 +20,12 @@ public static void main(String[] args) throws Exception { String apiKey = args[1]; String basePath = args[2]; + // Expected to default to the JVM's default SSL context and default trust manager DatabaseClient client = new DatabaseClientBuilder() .withHost(cloudHost) .withMarkLogicCloudAuth(apiKey, basePath) - .withSSLContext(SSLContext.getDefault()) - .withTrustManager(Common.TRUST_ALL_MANAGER) + // Have to use "ANY", as the default is "COMMON", which won't work for our selfsigned cert + .withSSLHostnameVerifier(DatabaseClientFactory.SSLHostnameVerifier.ANY) .build(); DatabaseClient.ConnectionResult result = client.checkConnection(); diff --git a/test-app/build.gradle b/test-app/build.gradle index ba5ade8eb..75b88da53 100644 --- a/test-app/build.gradle +++ b/test-app/build.gradle @@ -8,6 +8,10 @@ dependencies { implementation "io.undertow:undertow-core:2.2.21.Final" implementation "io.undertow:undertow-servlet:2.2.21.Final" implementation "com.marklogic:ml-javaclient-util:4.4.0" + implementation 'org.slf4j:slf4j-api:1.7.36' + implementation 'ch.qos.logback:logback-classic:1.3.5' + implementation "com.fasterxml.jackson.core:jackson-databind:2.14.1" + implementation 'com.squareup.okhttp3:okhttp:4.10.0' } // See https://github.com/psxpaul/gradle-execfork-plugin for docs. @@ -22,3 +26,13 @@ task runReverseProxyServer(type: com.github.psxpaul.task.JavaExecFork, dependsOn standardOutput = file("$buildDir/reverse-proxy.log") errorOutput = file("$buildDir/reverse-proxy-error.log") } + +task runBlockingReverseProxyServer(type: JavaExec) { + description = "Run the reverse proxy server so that it blocks and waits for requests; use ctrl-C to stop it. " + + "This is intended for manual testing with the reverse proxy server. If you wish to enable an HTTPS port that is 443" + + "or any value less than 1024, you will need to use sudo to run this - e.g. " + + "sudo ./gradlew runBlockingReverseProxyServer -PrpsHttpsPort=443 ." + classpath = sourceSets.main.runtimeClasspath + main = "com.marklogic.client.test.ReverseProxyServer" + args = [rpsMarkLogicServer, rpsProxyServer, rpsHttpPort, rpsHttpsPort] +} diff --git a/test-app/gradle.properties b/test-app/gradle.properties index 23121168b..cef566178 100644 --- a/test-app/gradle.properties +++ b/test-app/gradle.properties @@ -6,3 +6,8 @@ mlRestPort=8012 mlContentDatabaseName=java-unittest mlTdeValidationEnabled=false mlForestsPerHost=java-functest,2,java-unittest,3 + +rpsMarkLogicServer=localhost +rpsProxyServer=localhost +rpsHttpPort=8020 +rpsHttpsPort=0 diff --git a/test-app/src/main/java/com/marklogic/client/test/ReverseProxyServer.java b/test-app/src/main/java/com/marklogic/client/test/ReverseProxyServer.java index 67f91fd44..c783139f0 100644 --- a/test-app/src/main/java/com/marklogic/client/test/ReverseProxyServer.java +++ b/test-app/src/main/java/com/marklogic/client/test/ReverseProxyServer.java @@ -1,5 +1,8 @@ package com.marklogic.client.test; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.marklogic.client.ext.helper.LoggingObject; import com.marklogic.client.ext.modulesloader.ssl.SimpleX509TrustManager; import io.undertow.Undertow; import io.undertow.client.ClientCallback; @@ -7,10 +10,15 @@ import io.undertow.client.UndertowClient; import io.undertow.server.HttpServerExchange; import io.undertow.server.ServerConnection; +import io.undertow.server.handlers.BlockingHandler; +import io.undertow.server.handlers.form.FormData; +import io.undertow.server.handlers.form.FormParserFactory; import io.undertow.server.handlers.proxy.ProxyCallback; import io.undertow.server.handlers.proxy.ProxyClient; import io.undertow.server.handlers.proxy.ProxyConnection; import io.undertow.server.handlers.proxy.ProxyHandler; +import io.undertow.util.Headers; +import okhttp3.Credentials; import org.springframework.core.io.ClassPathResource; import org.xnio.IoUtils; import org.xnio.OptionMap; @@ -20,9 +28,10 @@ import javax.net.ssl.TrustManager; import java.io.IOException; import java.net.URI; +import java.nio.charset.Charset; import java.security.KeyStore; import java.util.Date; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -34,14 +43,27 @@ *

* Note that this does not yet support digest authentication, which seems to be common with reverse proxy servers. * That's fine for testing the Java Client, as verifying that the basePath works is not related to what kind of - * authentication is required by MarkLogic. + * authentication is required by MarkLogic. But you'll need to ensure that any MarkLogic app server you proxy is using + * either basic or digestbasic authentication. + *

+ * As of 2023-01-26, this can now emulate MarkLogic Cloud. It exposes a "/token" endpoint that proxies to port 8022, + * which this server listens to as well (currently hardcoded). A fake access token is returned. Subsequent requests + * convert that fake access token into a basic authentication value that is included in the proxied request to + * MarkLogic. */ -public class ReverseProxyServer { +public class ReverseProxyServer extends LoggingObject { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final String FAKE_ACCESS_TOKEN_INDICATOR = "FAKE_RPS_TOKEN:"; /** - * Accepts up to 3 args: 1) the MarkLogic hostname to proxy to; 2) the hostname for this server; - * 3) the port for this server. For current use cases though, including Jenkins, localhost should suffice for both - * hostnames and 8020 should suffice as the port. + * Accepts up to 4 args: 1) the MarkLogic hostname to proxy to; 2) the hostname for this server; + * 3) the port for this server; 4) the port for the secure (HTTPS) server. For current use cases though, including + * Jenkins, localhost should suffice for both hostnames and 8020 should suffice as the port. + *

+ * If you wish to enable a secure server on port 443 - i.e. you're looking to emulate MarkLogic Cloud - you'll + * need to run this program as root. Check the build.gradle file for this project to see an example of how to do + * that via Gradle. * * @param args * @throws Exception @@ -50,7 +72,7 @@ public static void main(final String[] args) throws Exception { String markLogicHost = "localhost"; String serverHost = "localhost"; int serverPort = 8020; - int secureServerPort = 8021; + int secureServerPort = 0; if (args.length > 0) { markLogicHost = args[0]; @@ -58,54 +80,118 @@ public static void main(final String[] args) throws Exception { serverHost = args[1]; if (args.length > 2) { serverPort = Integer.parseInt(args[2]); + if (args.length > 3) { + secureServerPort = Integer.parseInt(args[3]); + } } } } + new ReverseProxyServer(markLogicHost, serverHost, serverPort, secureServerPort); + } + + public ReverseProxyServer(String markLogicHost, String serverHost, int serverPort, int secureServerPort) throws Exception { + logger.info("MarkLogic host: {}", markLogicHost); + logger.info("Proxy server host: {}", serverHost); + logger.info("Proxy server HTTP port: {}", serverPort); + logger.info("Proxy server HTTPS port: {}", secureServerPort); + // Set up the mapping of paths to MarkLogic ports. Paths with and without forward slashes are used to ensure // both work properly. - Map mapping = new HashMap<>(); - mapping.put("/test/marklogic/unit", new URI("http://" + markLogicHost + ":8012")); + Map mapping = new LinkedHashMap<>(); + mapping.put("/test/marklogic/unit", new URI(String.format("http://%s:8012", markLogicHost))); // 8014 is for fast functional tests. Slow tests are not yet proxied, which currently will be difficult as they // use a variety of ports. Best approach will be to convert them over to fast tests that all use 8014. - mapping.put("/testFunctional", new URI("http://" + markLogicHost + ":8014")); - System.out.println("Proxy mapping: " + mapping); + mapping.put("/testFunctional", new URI(String.format("http://%s:8014", markLogicHost))); + + // Generic mappings for standard MarkLogic app servers + mapping.put("/local/app-services", new URI(String.format("http://%s:8000", markLogicHost))); + mapping.put("/local/admin", new URI(String.format("http://%s:8001", markLogicHost))); + mapping.put("/local/manage", new URI(String.format("http://%s:8002", markLogicHost))); - Undertow.builder() + // Generic mappings for DHF app servers + mapping.put("/data-hub/staging", new URI(String.format("http://%s:8010", markLogicHost))); + mapping.put("/data-hub/final", new URI(String.format("http://%s:8011", markLogicHost))); + mapping.put("/data-hub/jobs", new URI(String.format("http://%s:8013", markLogicHost))); + + // Emulate MarkLogic Cloud "/token" requests by mapping to the handler defined below that can respond to + // these requests in a suitable fashion for manual testing. + mapping.put("/token", new URI(String.format("http://%s:8022", serverHost))); + + mapping.entrySet().forEach(entry -> { + logger.info("Mapped: " + entry.getKey() + " : " + entry.getValue()); + }); + + Undertow.Builder undertowBuilder = Undertow.builder() .addHttpListener(serverPort, serverHost) - .addHttpsListener(secureServerPort, serverHost, buildSSLContext()) + .addHttpListener(8022, serverHost, new BlockingHandler(exchange -> handleMarkLogicCloudTokenRequest(exchange))) .setIoThreads(4) .setHandler(ProxyHandler.builder() .setProxyClient(new ReverseProxyClient(mapping)) .setMaxRequestTime(30000) .build() - ) + ); + + if (secureServerPort > 0) { + logger.info("Adding an HTTPS listener on port: " + secureServerPort + "; note that if this port 443 " + + "or any value less than 1024, you will need to run this process as root.:"); + undertowBuilder.addHttpsListener(secureServerPort, serverHost, buildSSLContext()); + } else { + logger.info("Not adding an HTTPS listener; to enable one, ensure that the 4th command line argument to " + + "this program is an integer."); + } + + undertowBuilder .build() .start(); } /** - * Tried to enable SSL so that CheckSSLConnectionTest would succeed, but no luck so far. This is using a test - * keystore (copied from the marklogic-nifi project). Undertow seems to accept the request and then forward it - * along, but the test gets a 403. It seems that the basic authentication is not being sent correctly, as testing - * via a web browser results in the authentication being rejected. + * This emulates how MarkLogic Cloud works with a twist - it expects the user's API key to match the pattern + * "username:password". It then generates a basic authentication value for this username/password and returns that + * as the access token. The replaceFakeMarkLogicCloudHeaderIfNecessary method in ReverseProxyClient will then + * replace this fake access token with an appropriate basic authentication header value. + * + * @param exchange + */ + private void handleMarkLogicCloudTokenRequest(HttpServerExchange exchange) { + try { + logger.info("Emulating MarkLogic Cloud and handling /token request"); + FormData formData = FormParserFactory.builder().build().createParser(exchange).parseBlocking(); + String apiKey = formData.getFirst("key").getValue(); + String[] tokens = apiKey.split(":"); + String basicAuthValue = Credentials.basic(tokens[0], tokens[1], Charset.forName("UTF-8")); + ObjectNode response = objectMapper.createObjectNode().put("access_token", FAKE_ACCESS_TOKEN_INDICATOR + basicAuthValue); + exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "application/json"); + exchange.getResponseSender().send(response.toPrettyString()); + } catch (Exception ex) { + System.err.println("Unable to process MarkLogic Cloud token request: " + ex.getMessage()); + } + } + + /** + * Constructs an SSLContext based on the src/main/resources/selfsigned.jks keystore. See the README in that + * directory for more information on the keystore along with instructions for importing the certificate into your + * JVM truststore. * * @return * @throws Exception */ - private static SSLContext buildSSLContext() throws Exception { - final String keyStorePassword = "passwordpassword"; + private SSLContext buildSSLContext() throws Exception { + final String keyStorePassword = "password"; KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); - keyStore.load(new ClassPathResource("keystore.jks").getInputStream(), keyStorePassword.toCharArray()); + keyStore.load(new ClassPathResource("selfsigned.jks").getInputStream(), keyStorePassword.toCharArray()); kmf.init(keyStore, keyStorePassword.toCharArray()); SSLContext sslContext = SSLContext.getInstance("TLSv1.2"); + // Use a "trust-everything" approach for now; the client doesn't have to do this, but we don't have a use case + // yet for having our reverse proxy server validate certificates. sslContext.init(kmf.getKeyManagers(), new TrustManager[]{new SimpleX509TrustManager()}, null); return sslContext; } - private static class ReverseProxyClient implements ProxyClient { + private static class ReverseProxyClient extends LoggingObject implements ProxyClient { private static final ProxyTarget TARGET = new ProxyTarget() { }; @@ -125,9 +211,11 @@ public ProxyTarget findTarget(HttpServerExchange exchange) { @Override public void getConnection(ProxyTarget target, HttpServerExchange exchange, ProxyCallback callback, long timeout, TimeUnit timeUnit) { final String requestURI = exchange.getRequestURI(); - System.out.println("Received request: " + new Date() + "; " + requestURI); + logger.info("Received request: " + requestURI); URI targetUri = null; + replaceFakeMarkLogicCloudHeaderIfNecessary(exchange); + for (String path : mapping.keySet()) { if (requestURI.startsWith(path)) { targetUri = mapping.get(path); @@ -140,7 +228,7 @@ public void getConnection(ProxyTarget target, HttpServerExchange exchange, Proxy throw new IllegalArgumentException("Unsupported request URI: " + exchange.getRequestURI()); } - System.out.println("Proxying to: " + targetUri + exchange.getRequestURI()); + logger.info("Proxying to: " + targetUri + exchange.getRequestURI()); client.connect( new ConnectNotifier(callback, exchange), targetUri, @@ -149,6 +237,23 @@ public void getConnection(ProxyTarget target, HttpServerExchange exchange, Proxy OptionMap.EMPTY); } + /** + * Checks to see if the request has the fake MarkLogic Cloud authentication token in it, which is inserted + * by the "/token" handler. If so, that token is replaced with a basic authentication value, which requires that + * the MarkLogic server use basic or digestbasic authentication. + * + * @param exchange + */ + private void replaceFakeMarkLogicCloudHeaderIfNecessary(HttpServerExchange exchange) { + final String auth = exchange.getRequestHeaders().getFirst("Authorization"); + final String fakeBearerIndicator = "Bearer " + FAKE_ACCESS_TOKEN_INDICATOR; + if (auth != null && auth.startsWith(fakeBearerIndicator)) { + String basicAuthValue = auth.substring(fakeBearerIndicator.length()); + logger.info("Replacing fake MarkLogic Cloud Authorization header with a basic Authorization header: " + basicAuthValue); + exchange.getRequestHeaders().put(Headers.AUTHORIZATION, basicAuthValue); + } + } + private final class ConnectNotifier implements ClientCallback { private final ProxyCallback callback; private final HttpServerExchange exchange; diff --git a/test-app/src/main/resources/README.md b/test-app/src/main/resources/README.md new file mode 100644 index 000000000..9c60c08e3 --- /dev/null +++ b/test-app/src/main/resources/README.md @@ -0,0 +1,10 @@ +The selfsigned.jks file in this directory is used for enabling SSL in the ReverseProxyServer program. It is a simple +PKCS12 keystore with a single self-signed certificate in it named "selfsigned". A copy of that certificate is stored +in this directory as well with the name "selfsigned-cert.pem". + +To import the selfsigned certificate into your JVM truststore, run the following command, replacing "changeit" with +the correct password for your JVM's jssecacerts or cacerts file (check your JVM's jre/lib/security file to see which +one exists; also note that "changeit" may be correct since that's the default password for a cacerts file): + + keytool -importcert -file selfsigned-cert.pem -alias selfsigned -storepass changeit + diff --git a/test-app/src/main/resources/keystore.jks b/test-app/src/main/resources/keystore.jks deleted file mode 100644 index 246fe888e..000000000 Binary files a/test-app/src/main/resources/keystore.jks and /dev/null differ diff --git a/test-app/src/main/resources/logback.xml b/test-app/src/main/resources/logback.xml new file mode 100644 index 000000000..bef5b50a6 --- /dev/null +++ b/test-app/src/main/resources/logback.xml @@ -0,0 +1,17 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + diff --git a/test-app/src/main/resources/selfsigned-cert.pem b/test-app/src/main/resources/selfsigned-cert.pem new file mode 100644 index 000000000..89d97eae8 --- /dev/null +++ b/test-app/src/main/resources/selfsigned-cert.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDfTCCAmWgAwIBAgIEZeRCVzANBgkqhkiG9w0BAQsFADBvMQswCQYDVQQGEwJV +UzELMAkGA1UECBMCQ0ExEzARBgNVBAcTClNhbiBDYXJsb3MxEjAQBgNVBAoTCU1h +cmtMb2dpYzEUMBIGA1UECxMLRW5naW5lZXJpbmcxFDASBgNVBAMTC0phdmEgQ2xp +ZW50MB4XDTIzMDEyNTIzNDQxMVoXDTI0MDEyNTIzNDQxMVowbzELMAkGA1UEBhMC +VVMxCzAJBgNVBAgTAkNBMRMwEQYDVQQHEwpTYW4gQ2FybG9zMRIwEAYDVQQKEwlN +YXJrTG9naWMxFDASBgNVBAsTC0VuZ2luZWVyaW5nMRQwEgYDVQQDEwtKYXZhIENs +aWVudDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAIsHFzVqYDMH+4Ki +S925HvIiTKaRHCoGbhWjFiT4ylrzFOrY5k0lWh5kgLepktB6iXJvT9e51FjT3vml +IGZ6oA4hHbVMP996T5EE93HDcQdBrLRFlpZM+1Fwk3J7rrioqn0e2cbuo85JB2MV +/fDKV+7zVnIQwR+LWoFZ1k77qVCnHdCzVTjvP5T/8Ai89wWOZqZS6mWzv3QrNl7g +hx0M3Sco8yCy+hr8HpDgcdjNg8U+d+AJG4tCPV/J4MMnq2SQsSyGKCqTZK4b83Ht +301mG8MbqhGyKeatO3gapRsX4rTG5gAF8qu0abjTm5Tz2/5DCC3/rtSNvGtz0+z6 +WzVcP3kCAwEAAaMhMB8wHQYDVR0OBBYEFMRrfbYE6YgqKuwR1yrzrlsaTG8GMA0G +CSqGSIb3DQEBCwUAA4IBAQA80rGFKydWQ+q4wHkdIIt3llEgj2nUP81pp45iN3iU +u8nTLO9iWFOXG0esyZVTgF17r166i0pWTcchtI0MSe8JsqS8VmH8MCXZTBKpg4P6 +43jLAHmriPTnD8U4jL8aB+7PMWCWembiMifIk8kFE09Vl6L+emdYCeK1Zy8jut/f +awGLrMyMRQGBRd5vdiWC+bDvDeGVK6RyKMQHMhylKO8PeHNDdnnUW6m9HV6VkeH8 +l5Yt12gPim2h6yJrdaOX1sReu8/dwVopBpoqM/iDusRm6/apW6Oi45UbkuhZCPvP +GkUQxOaZweyI4XabBEX9HTJW2WAXfw04tAFrs59MpspP +-----END CERTIFICATE----- diff --git a/test-app/src/main/resources/selfsigned.jks b/test-app/src/main/resources/selfsigned.jks new file mode 100644 index 000000000..3221eb58d Binary files /dev/null and b/test-app/src/main/resources/selfsigned.jks differ