Skip to content

Commit 0f4032b

Browse files
committed
Added test for two-way SSL.
Made a couple of small improvements to how errors are reported for a couple of the scenarios in this test too.
1 parent e05f32f commit 0f4032b

File tree

2 files changed

+345
-0
lines changed

2 files changed

+345
-0
lines changed

marklogic-client-api/src/main/java/com/marklogic/client/impl/OkHttpServices.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,14 @@ private FailedRequest extractErrorFields(Response response) {
185185
failure.setStatusString("Forbidden");
186186
failure.setStatusCode(STATUS_FORBIDDEN);
187187
return failure;
188+
} else if (response.code() == STATUS_FORBIDDEN && "".equals(responseBody)) {
189+
// When the responseBody is empty, this seems preferable vs the "Server (not a REST instance?)" message
190+
// which is very confusing given that the app server usually is a REST instance.
191+
FailedRequest failure = new FailedRequest();
192+
failure.setMessageString("No message received from server.");
193+
failure.setStatusString("Forbidden");
194+
failure.setStatusCode(STATUS_FORBIDDEN);
195+
return failure;
188196
}
189197

190198
InputStream is = new ByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8));
@@ -515,6 +523,13 @@ private Response sendRequestOnce(Request request) {
515523
try {
516524
return getConnection().newCall(request).execute();
517525
} catch (IOException e) {
526+
if (e instanceof SSLException) {
527+
String message = e.getMessage();
528+
if (message != null && message.contains("readHandshakeRecord")) {
529+
throw new MarkLogicIOException(String.format("SSL error occurred: %s; ensure you are using a valid certificate " +
530+
"if the MarkLogic app server requires a client certificate for SSL.", message));
531+
}
532+
}
518533
String message = String.format(
519534
"Error occurred while calling %s; %s: %s " +
520535
"; possible reasons for the error include that a MarkLogic app server may not be listening on the port, " +
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
package com.marklogic.client.test;
2+
3+
import com.fasterxml.jackson.databind.node.ObjectNode;
4+
import com.marklogic.client.DatabaseClient;
5+
import com.marklogic.client.DatabaseClientFactory;
6+
import com.marklogic.client.ForbiddenUserException;
7+
import com.marklogic.client.MarkLogicIOException;
8+
import com.marklogic.client.document.DocumentDescriptor;
9+
import com.marklogic.client.eval.EvalResultIterator;
10+
import com.marklogic.client.io.StringHandle;
11+
import com.marklogic.client.test.junit5.RequireSSLExtension;
12+
import com.marklogic.mgmt.ManageClient;
13+
import com.marklogic.mgmt.resource.appservers.ServerManager;
14+
import com.marklogic.rest.util.Fragment;
15+
import org.junit.jupiter.api.AfterEach;
16+
import org.junit.jupiter.api.BeforeEach;
17+
import org.junit.jupiter.api.Test;
18+
import org.junit.jupiter.api.extension.ExtendWith;
19+
import org.junit.jupiter.api.io.TempDir;
20+
import org.springframework.util.FileCopyUtils;
21+
22+
import javax.net.ssl.KeyManagerFactory;
23+
import javax.net.ssl.SSLContext;
24+
import javax.net.ssl.X509TrustManager;
25+
import java.io.BufferedReader;
26+
import java.io.File;
27+
import java.io.FileInputStream;
28+
import java.io.IOException;
29+
import java.io.InputStream;
30+
import java.io.InputStreamReader;
31+
import java.nio.file.Path;
32+
import java.security.KeyStore;
33+
import java.util.concurrent.ExecutorService;
34+
import java.util.concurrent.Executors;
35+
import java.util.function.Consumer;
36+
37+
import static org.junit.jupiter.api.Assertions.assertEquals;
38+
import static org.junit.jupiter.api.Assertions.assertNotNull;
39+
import static org.junit.jupiter.api.Assertions.assertThrows;
40+
import static org.junit.jupiter.api.Assertions.assertTrue;
41+
42+
@ExtendWith(RequireSSLExtension.class)
43+
public class TwoWaySSLTest {
44+
45+
// Used for creating a temporary JKS (Java KeyStore) file.
46+
@TempDir
47+
Path tempDir;
48+
49+
private DatabaseClient securityClient;
50+
private ManageClient manageClient;
51+
private File keyStoreFile;
52+
53+
54+
@BeforeEach
55+
void setup() throws Exception {
56+
// Create a client using the java-unittest app server - which requires SSL via RequiresSSLExtension - and that
57+
// talks to the Security database.
58+
securityClient = Common.newClientBuilder()
59+
.withUsername(Common.SERVER_ADMIN_USER).withPassword(Common.SERVER_ADMIN_PASS)
60+
.withSSLProtocol("TLSv1.2")
61+
.withTrustManager(Common.TRUST_ALL_MANAGER)
62+
.withSSLHostnameVerifier(DatabaseClientFactory.SSLHostnameVerifier.ANY)
63+
.withDatabase("Security").build();
64+
65+
final String certificateAuthorityId = createCertificateAuthority();
66+
ClientCertificate clientCertificate = createClientCertificate();
67+
makeAppServerRequireTwoWaySSL(certificateAuthorityId);
68+
69+
writeClientCertificateFilesToTempDir(clientCertificate, tempDir);
70+
createPkcs12File(tempDir);
71+
createKeystoreFile(tempDir);
72+
keyStoreFile = new File(tempDir.toFile(), "client.jks");
73+
}
74+
75+
@AfterEach
76+
void teardown() {
77+
removeTwoWaySSLConfig();
78+
deleteCertificateAuthority();
79+
}
80+
81+
/**
82+
* After two-way SSL is configured on the java-unittest app server, verify that a DatabaseClient using a proper
83+
* SSLContext can connect to the app server.
84+
*/
85+
@Test
86+
void testTwoWaySSL() throws Exception {
87+
if (Common.USE_REVERSE_PROXY_SERVER) {
88+
return;
89+
}
90+
91+
final String uri = "/optic/test/musician1.json";
92+
93+
// This client uses our Java KeyStore file with a client certificate in it, so it should work.
94+
DatabaseClient clientWithCert = Common.newClientBuilder()
95+
.withSSLHostnameVerifier(DatabaseClientFactory.SSLHostnameVerifier.ANY)
96+
.withSSLContext(createSSLContextWithClientCertificate(keyStoreFile))
97+
.withTrustManager(RequireSSLExtension.newTrustManager())
98+
.build();
99+
100+
DocumentDescriptor descriptor = clientWithCert.newJSONDocumentManager().exists(uri);
101+
assertNotNull(descriptor);
102+
assertEquals(uri, descriptor.getUri());
103+
104+
// This client uses a new SSL context without the client certificate, so it should fail.
105+
DatabaseClient clientWithoutCert = Common.newClientBuilder()
106+
.withSSLHostnameVerifier(DatabaseClientFactory.SSLHostnameVerifier.ANY)
107+
.withSSLProtocol("TLSv1.2")
108+
.withTrustManager(RequireSSLExtension.newTrustManager())
109+
.build();
110+
111+
MarkLogicIOException ex = assertThrows(MarkLogicIOException.class,
112+
() -> clientWithoutCert.newJSONDocumentManager().exists(uri));
113+
assertTrue(ex.getMessage().contains("SSL error occurred: readHandshakeRecord; ensure you are using a " +
114+
"valid certificate if the MarkLogic app server requires a client certificate for SSL."),
115+
"Unexpected exception: " + ex.getMessage());
116+
117+
// And now a client that doesn't even try to use SSL. It's not clear if a ForbiddenUserException is correct
118+
// here, but it's what the Java Client was throwing when this test was written.
119+
ForbiddenUserException userException = assertThrows(ForbiddenUserException.class,
120+
() -> Common.newClient().newJSONDocumentManager().exists(uri));
121+
assertTrue(userException.getMessage().contains("User is not allowed to check the existence of documents"),
122+
"Unexpected exception: " + userException.getMessage());
123+
}
124+
125+
private SSLContext createSSLContextWithClientCertificate(File keystoreFile) throws Exception {
126+
KeyStore keyStore = KeyStore.getInstance("JKS");
127+
keyStore.load(new FileInputStream(keystoreFile), "password".toCharArray());
128+
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("SunX509");
129+
keyManagerFactory.init(keyStore, "password".toCharArray());
130+
SSLContext sslContext = SSLContext.getInstance("TLSv1.2");
131+
sslContext.init(
132+
keyManagerFactory.getKeyManagers(),
133+
new X509TrustManager[]{RequireSSLExtension.newTrustManager()},
134+
null);
135+
return sslContext;
136+
}
137+
138+
/**
139+
* See https://docs.marklogic.com/pki:create-authority for more information. This results in both a new
140+
* CA in MarkLogic and a new "secure credential".
141+
*/
142+
private String createCertificateAuthority() {
143+
String xquery = "xquery version \"1.0-ml\";\n" +
144+
"import module namespace pki = \"http://marklogic.com/xdmp/pki\" at \"/MarkLogic/pki.xqy\";\n" +
145+
"declare namespace x509 = \"http://marklogic.com/xdmp/x509\";\n" +
146+
"\n" +
147+
"pki:create-authority(\n" +
148+
" \"java-client-test\", \"Java Client Certificate Authority\",\n" +
149+
" element x509:subject {\n" +
150+
" element x509:countryName {\"US\"},\n" +
151+
" element x509:stateOrProvinceName {\"California\"},\n" +
152+
" element x509:localityName {\"San Carlos\"},\n" +
153+
" element x509:organizationName {\"MarkLogicJavaClientTest\"},\n" +
154+
" element x509:organizationalUnitName {\"Engineering\"},\n" +
155+
" element x509:commonName {\"JavaClientCA\"},\n" +
156+
" element x509:emailAddress {\"java-client@example.org\"}\n" +
157+
" },\n" +
158+
" fn:current-dateTime(),\n" +
159+
" fn:current-dateTime() + xs:dayTimeDuration(\"P365D\"),\n" +
160+
" (xdmp:permission(\"admin\",\"read\")))";
161+
162+
return securityClient.newServerEval().xquery(xquery).evalAs(String.class);
163+
}
164+
165+
/**
166+
* See https://docs.marklogic.com/pki:authority-create-client-certificate for more information.
167+
*/
168+
private ClientCertificate createClientCertificate() {
169+
String xquery = "xquery version \"1.0-ml\";\n" +
170+
"import module namespace sec = \"http://marklogic.com/xdmp/security\" at \"/MarkLogic/security.xqy\"; \n" +
171+
"import module namespace pki = \"http://marklogic.com/xdmp/pki\" at \"/MarkLogic/pki.xqy\";\n" +
172+
"declare namespace x509 = \"http://marklogic.com/xdmp/x509\";\n" +
173+
"\n" +
174+
"pki:authority-create-client-certificate(\n" +
175+
" xdmp:credential-id(\"java-client-test\"),\n" +
176+
" element x509:subject {\n" +
177+
" element x509:countryName {\"US\"},\n" +
178+
" element x509:stateOrProvinceName {\"California\"},\n" +
179+
" element x509:localityName {\"San Carlos\"},\n" +
180+
" element x509:organizationName {\"ProgressMarkLogic\"},\n" +
181+
" element x509:organizationalUnitName {\"Engineering\"},\n" +
182+
" element x509:commonName {\"ElmerFudd\"},\n" +
183+
" element x509:emailAddress {\"elmer.fudd@example.org\"}\n" +
184+
" },\n" +
185+
" fn:current-dateTime(),\n" +
186+
" fn:current-dateTime() + xs:dayTimeDuration(\"P365D\"))\n";
187+
188+
EvalResultIterator iter = securityClient.newServerEval().xquery(xquery).eval();
189+
String cert = null;
190+
String key = null;
191+
while (iter.hasNext()) {
192+
if (cert == null) {
193+
cert = iter.next().getString();
194+
} else {
195+
key = iter.next().getString();
196+
}
197+
}
198+
return new ClientCertificate(cert, key);
199+
}
200+
201+
private static class ClientCertificate {
202+
final String pemEncodedCertificate;
203+
final String privateKey;
204+
205+
public ClientCertificate(String pemEncodedCertificate, String privateKey) {
206+
this.pemEncodedCertificate = pemEncodedCertificate;
207+
this.privateKey = privateKey;
208+
}
209+
}
210+
211+
/**
212+
* Via the RequiresSSLExtension, the app server already requires a 1-way SSL connection. This configures the
213+
* app server to both require a client certificate and have that client certificate associated with the
214+
* CA that was created earlier in the test.
215+
*/
216+
private void makeAppServerRequireTwoWaySSL(String certificateAuthorityId) {
217+
String certificateAuthorityCertificate = getCertificateAuthorityCertificate(certificateAuthorityId);
218+
219+
manageClient = Common.newManageClient();
220+
ObjectNode payload = Common.newServerPayload()
221+
.put("ssl-require-client-certificate", true)
222+
.put("ssl-client-issuer-authority-verification", true);
223+
payload.putArray("ssl-client-certificate-pem").add(certificateAuthorityCertificate);
224+
new ServerManager(manageClient).save(payload.toString());
225+
}
226+
227+
/**
228+
* Couldn't find a Manage API endpoint that returns the CA certificate, so directly accessing the Security
229+
* database and reading a known URI to get an XML document associated with the CA's secure credential, which has
230+
* the certificate in it.
231+
*/
232+
private String getCertificateAuthorityCertificate(String certificateAuthorityId) {
233+
String certificateUri = String.format("http://marklogic.com/xdmp/credentials/%s", certificateAuthorityId);
234+
String xml = securityClient.newXMLDocumentManager().read(certificateUri, new StringHandle()).get();
235+
return new Fragment(xml).getElementValue("/sec:credential/sec:credential-certificate");
236+
}
237+
238+
private void deleteCertificateAuthority() {
239+
String xquery = "xquery version \"1.0-ml\";\n" +
240+
"import module namespace pki = \"http://marklogic.com/xdmp/pki\" at \"/MarkLogic/pki.xqy\";\n" +
241+
"\n" +
242+
"pki:delete-authority(\"java-client-test\")";
243+
244+
securityClient.newServerEval().xquery(xquery).evalAs(String.class);
245+
}
246+
247+
/**
248+
* Restores the app server back to only requiring 1-way SSL.
249+
*/
250+
private void removeTwoWaySSLConfig() {
251+
ObjectNode payload = Common.newServerPayload()
252+
.put("ssl-require-client-certificate", false)
253+
.put("ssl-client-issuer-authority-verification", false);
254+
payload.putArray("ssl-client-certificate-pem");
255+
new ServerManager(manageClient).save(payload.toString());
256+
}
257+
258+
/**
259+
* Writes the client certificate PEM and private keys to disk so that they can accessed by the openssl program.
260+
*
261+
* @param clientCertificate
262+
* @param tempDir
263+
* @throws IOException
264+
*/
265+
private void writeClientCertificateFilesToTempDir(ClientCertificate clientCertificate, Path tempDir) throws IOException {
266+
File certFile = new File(tempDir.toFile(), "cert.pem");
267+
FileCopyUtils.copy(clientCertificate.pemEncodedCertificate.getBytes(), certFile);
268+
File keyFile = new File(tempDir.toFile(), "client.key");
269+
FileCopyUtils.copy(clientCertificate.privateKey.getBytes(), keyFile);
270+
}
271+
272+
/**
273+
* See https://stackoverflow.com/a/8224863/3306099 for where this approach was obtained from.
274+
*/
275+
private void createPkcs12File(Path tempDir) throws Exception {
276+
ProcessBuilder builder = new ProcessBuilder();
277+
builder.directory(tempDir.toFile());
278+
builder.command("openssl", "pkcs12", "-export",
279+
"-in", "cert.pem", "-inkey", "client.key",
280+
"-out", "client.p12",
281+
"-name", "my-client",
282+
"-passout", "pass:password");
283+
284+
ExecutorService executorService = Executors.newSingleThreadExecutor();
285+
Process process = builder.start();
286+
executorService.submit(new StreamGobbler(process.getInputStream(), System.out::println));
287+
executorService.submit(new StreamGobbler(process.getErrorStream(), System.err::println));
288+
int exitCode = process.waitFor();
289+
assertEquals(0, exitCode, "Unable to create pkcs12 file using openssl");
290+
}
291+
292+
private void createKeystoreFile(Path tempDir) throws Exception {
293+
ProcessBuilder builder = new ProcessBuilder();
294+
builder.directory(tempDir.toFile());
295+
builder.command("keytool", "-importkeystore",
296+
"-deststorepass", "password",
297+
"-destkeypass", "password",
298+
"-destkeystore", "client.jks",
299+
"-srckeystore", "client.p12",
300+
"-srcstoretype", "PKCS12",
301+
"-srcstorepass", "password",
302+
"-alias", "my-client");
303+
304+
Process process = builder.start();
305+
ExecutorService executorService = Executors.newSingleThreadExecutor();
306+
executorService.submit(new StreamGobbler(process.getInputStream(), System.out::println));
307+
executorService.submit(new StreamGobbler(process.getErrorStream(), System.err::println));
308+
int exitCode = process.waitFor();
309+
assertEquals(0, exitCode, "Unable to create keystore using keytool");
310+
}
311+
312+
/**
313+
* Copied from https://www.baeldung.com/run-shell-command-in-java .
314+
*/
315+
private static class StreamGobbler implements Runnable {
316+
private InputStream inputStream;
317+
private Consumer<String> consumer;
318+
319+
public StreamGobbler(InputStream inputStream, Consumer<String> consumer) {
320+
this.inputStream = inputStream;
321+
this.consumer = consumer;
322+
}
323+
324+
@Override
325+
public void run() {
326+
new BufferedReader(new InputStreamReader(inputStream)).lines()
327+
.forEach(consumer);
328+
}
329+
}
330+
}

0 commit comments

Comments
 (0)