Skip to content

Commit

Permalink
Added X509 certificate context key when client certificate is present. (
Browse files Browse the repository at this point in the history
#4185) (#4226)

Fixes #2752. Fixes #3279.
  • Loading branch information
astromechza authored Jun 16, 2022
1 parent 0a4401b commit 8ae791f
Show file tree
Hide file tree
Showing 5 changed files with 205 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2017, 2021 Oracle and/or its affiliates.
* Copyright (c) 2017, 2022 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -651,6 +651,7 @@ public static final class PemBuilder implements io.helidon.common.Builder<KeyCon
private final StreamHolder privateKeyStream = new StreamHolder("privateKey");
private final StreamHolder publicKeyStream = new StreamHolder("publicKey");
private final StreamHolder certChainStream = new StreamHolder("certChain");
private final StreamHolder certificateStream = new StreamHolder("certificate");
private char[] pemKeyPassphrase;

private PemBuilder() {
Expand Down Expand Up @@ -716,6 +717,17 @@ public PemBuilder certChain(Resource resource) {
return this;
}

/**
* Read one or more certificates in PEM format from a resource definition. Used eg: in a trust store.
*
* @param resource key resource (file, classpath, URL etc.)
* @return updated builder instance
*/
public PemBuilder certificates(Resource resource) {
certificateStream.stream(resource);
return this;
}

/**
* Build {@link KeyConfig} based on information from PEM files only.
*
Expand Down Expand Up @@ -751,6 +763,10 @@ private Builder updateBuilder(Builder builder) {
}
}

if (certificateStream.isSet()) {
PemReader.readCertificates(certificateStream.stream()).forEach(builder::addCert);
}

return builder;
}

Expand All @@ -774,6 +790,7 @@ public PemBuilder config(Config config) {
pemConfig.get("key.resource").as(Resource::create).ifPresent(this::key);
pemConfig.get("key.passphrase").asString().map(String::toCharArray).ifPresent(this::keyPassphrase);
pemConfig.get("cert-chain.resource").as(Resource::create).ifPresent(this::certChain);
pemConfig.get("certificates.resource").as(Resource::create).ifPresent(this::certificates);

// and this is the old approach
Resource.create(config, "pem-key").ifPresent(this::key);
Expand Down
25 changes: 24 additions & 1 deletion docs/se/webserver/12_tls-configuration.adoc
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
///////////////////////////////////////////////////////////////////////////////

Copyright (c) 2020 Oracle and/or its affiliates.
Copyright (c) 2022 Oracle and/or its affiliates.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -90,6 +90,29 @@ WebServerTls.builder()
.build();
----
This can alternatively be configured with paths to PKCS#8 PEM files rather than KeyStores:
[source,yaml]
.WebServer TLS configuration file `application.yaml`
----
server:
tls:
#Truststore setup
trust:
pem:
certificates:
resource:
resource-path: "ca-bundle.pem"
private-key:
pem:
key:
resource:
resource-path: "key.pem"
cert-chain:
resource:
resource-path: "chain.pem"
----
== Configuration options
See all configuration options
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
import io.netty.handler.codec.http2.Http2Error;
import io.netty.handler.codec.http2.Http2Exception;

import static io.helidon.webserver.HttpInitializer.CLIENT_CERTIFICATE;
import static io.helidon.webserver.HttpInitializer.CLIENT_CERTIFICATE_NAME;
import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1;

Expand Down Expand Up @@ -310,6 +311,10 @@ private boolean channelReadHttpRequest(ChannelHandlerContext ctx, Context reques
request.headers().remove(Http.Header.X_HELIDON_CN);
Optional.ofNullable(ctx.channel().attr(CLIENT_CERTIFICATE_NAME).get())
.ifPresent(name -> request.headers().set(Http.Header.X_HELIDON_CN, name));
// If the client x509 certificate is present on the channel, add it to the context scope of the ongoing
// request so that helidon handlers can inspect and react to this.
Optional.ofNullable(ctx.channel().attr(CLIENT_CERTIFICATE).get())
.ifPresent(cert -> requestScope.register(WebServerTls.CLIENT_X509_CERTIFICATE, cert));

// Context, publisher and DataChunk queue for this request/response
DataChunkHoldingQueue queue = new DataChunkHoldingQueue();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2020, 2021 Oracle and/or its affiliates.
* Copyright (c) 2020, 2022 Oracle and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -56,6 +56,12 @@ public final class WebServerTls {
// be initialized at runtime
private static final LazyValue<Random> RANDOM = LazyValue.create(SecureRandom::new);

/**
* This constant is a context classifier for the x509 client certificate if it is present. Callers may use this
* constant to lookup the client certificate associated with the current request context.
*/
public static final String CLIENT_X509_CERTIFICATE = WebServerTls.class.getName() + ".client-x509-certificate";

private final Set<String> enabledTlsProtocols;
private final Set<String> cipherSuite;
private final SSLContext sslContext;
Expand Down
151 changes: 151 additions & 0 deletions webserver/webserver/src/test/java/io/helidon/webserver/MtlsTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/*
* Copyright (c) 2017, 2022 Oracle and/or its affiliates.
*
* 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 io.helidon.webserver;

import io.helidon.common.configurable.Resource;
import io.helidon.common.pki.KeyConfig;
import io.helidon.config.Config;
import io.helidon.config.MapConfigSource;
import io.helidon.webclient.WebClient;
import io.helidon.webclient.WebClientTls;
import io.netty.handler.codec.DecoderException;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

import java.security.cert.X509Certificate;
import java.util.HashMap;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import javax.net.ssl.SSLHandshakeException;
import javax.security.auth.x500.X500Principal;

import static org.hamcrest.CoreMatchers.instanceOf;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;

/**
* The test of SSL Netty layer with MTLS enabled.
*/
public class MtlsTest {

private static final Logger LOGGER = Logger.getLogger(MtlsTest.class.getName());

private static WebServer webServer;
private static WebClient clientWithoutCertificate;
private static WebClient clientWithCertificate;

/**
* Start the secured Web Server
*
* @param port the port on which to start the secured server; if less than 1,
* the port is dynamically selected
* @throws Exception in case of an error
*/
private static void startServer(int port) throws Exception {
HashMap<String, String> rawConfig = new HashMap<>();
rawConfig.put("client-auth", "REQUIRE");
rawConfig.put("trust.pem.certificates.resource.resource-path", "ssl/certificate.pem");
rawConfig.put("private-key.pem.key.resource.resource-path", "ssl/key.pkcs8.pem");
rawConfig.put("private-key.pem.cert-chain.resource.resource-path", "ssl/certificate.pem");

webServer = WebServer.builder(
Routing.builder()
.any((req, res) -> {
// This is annoyingly complex to pull just the CN out of an x509 cert, but it's generally easier if the caller
// has access to other libraries like bouncy castle.
Optional<X509Certificate> cert = req.context().get(WebServerTls.CLIENT_X509_CERTIFICATE, X509Certificate.class);
res.send(cert.map(X509Certificate::getSubjectX500Principal).map(X500Principal::getName)
.map(name -> Pattern.compile("(?:^|,\\s?)(?:CN=(?<val>\"(?:[^\"]|\"\")+\"|[^,]+))").matcher(name))
.map(matcher -> matcher.find() ? matcher.group(1) : "no match")
.orElse("unknown"));
}))
.port(port)
.tls(WebServerTls.builder()
.config(Config.create(MapConfigSource.create(rawConfig)))
.build())
.build()
.start()
.toCompletableFuture()
.get(10, TimeUnit.SECONDS);

LOGGER.info("Started secured server at: https://localhost:" + webServer.port());
}

@Test
public void testNoClientCert() {
ExecutionException exc = assertThrows(ExecutionException.class, () -> clientWithoutCertificate.get()
.uri("https://localhost:" + webServer.port())
.request(String.class)
.toCompletableFuture()
.get());
assertThat(exc.getCause(), instanceOf(DecoderException.class));
assertThat(exc.getCause().getCause(), instanceOf(SSLHandshakeException.class));
assertThat(exc.getCause().getCause().getMessage(), is("Received fatal alert: bad_certificate"));
}

@Test
public void testWithClientCert() throws Exception {
clientWithCertificate.get()
.uri("https://localhost:" + webServer.port())
.request(String.class)
.thenAccept(it -> assertThat(it, is("helidon-webserver-netty-test")))
.toCompletableFuture()
.get();
}

@BeforeAll
public static void startServer() throws Exception {
// start the server at a free port
startServer(0);
}

@BeforeAll
public static void setup() {
clientWithoutCertificate = WebClient.builder()
.tls(WebClientTls.builder()
.trustAll(true)
.build())
.build();
clientWithCertificate = WebClient.builder()
.tls(WebClientTls.builder()
// the certificate is self-signed so we can use it for TLS, but its CN obviously doesn't match 'localhost'
.disableHostnameVerification(true)
.certificateTrustStore(KeyConfig.pemBuilder()
.certificates(Resource.create("ssl/certificate.pem"))
.build())
.clientKeyStore(KeyConfig.pemBuilder()
.key(Resource.create("ssl/key.pkcs8.pem"))
.certChain(Resource.create("ssl/certificate.pem"))
.build())
.build())
.build();
}

@AfterAll
public static void teardown() throws Exception {
if (webServer != null) {
webServer.shutdown()
.toCompletableFuture()
.get(10, TimeUnit.SECONDS);
}
}
}

0 comments on commit 8ae791f

Please sign in to comment.