Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added X509 certificate context key when client certificate is present and pem trust store configuration #4185

Merged
merged 1 commit into from
May 16, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ Notable changes:
- WebServer: Fix wrong connection close (#3830) [3866](https://github.com/oracle/helidon/pull/3866)
- WebServer: New default for io.netty.allocator.maxOrder (master) [3826](https://github.com/oracle/helidon/pull/3826) [3834](https://github.com/oracle/helidon/pull/3834)
- WebServer: Swallowed error fix [3791](https://github.com/oracle/helidon/pull/3791)
- WebServer: Provide access to client x509 certificate when under mTLS [4185](https://github.com/oracle/helidon/pull/4185)
- Webclient: New flag to force the use of relative URIs in WebClient [3614](https://github.com/oracle/helidon/pull/3614)
- Dependencies: Bump up cron utils [3677](https://github.com/oracle/helidon/pull/3677)
- Dependencies: Upgrade Neo4j to 4.4.3. for Helidon 3.x [3863](https://github.com/oracle/helidon/pull/3863)
Expand Down
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<Builde
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())
tomas-langer marked this conversation as resolved.
Show resolved Hide resolved
.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);
}
}
}