From a2777a80602e05f285df849f59b1d77c5fa2869c Mon Sep 17 00:00:00 2001 From: Alexey Nesterov Date: Tue, 25 Feb 2020 11:07:49 +0000 Subject: [PATCH] Add reactive certificate mapping support --- pom.xml | 5 + .../router/CertificateLoader.java | 91 +++++++++ .../router/ClientCertificateMapper.java | 59 +----- .../ReactiveClientCertificateMapper.java | 53 +++++ ...entCertificateMapperAutoConfiguration.java | 39 ++++ .../router/SimpleSslInfoHolder.java | 42 ++++ .../router/SslInfoRequestDecorator.java | 59 ++++++ src/main/resources/META-INF/spring.factories | 2 +- .../router/ClientCertificateMapperTest.java | 20 +- .../ReactiveClientCertificateMapperTest.java | 186 ++++++++++++++++++ 10 files changed, 490 insertions(+), 66 deletions(-) create mode 100644 src/main/java/org/cloudfoundry/router/CertificateLoader.java create mode 100644 src/main/java/org/cloudfoundry/router/ReactiveClientCertificateMapper.java create mode 100644 src/main/java/org/cloudfoundry/router/ReactiveClientCertificateMapperAutoConfiguration.java create mode 100644 src/main/java/org/cloudfoundry/router/SimpleSslInfoHolder.java create mode 100644 src/main/java/org/cloudfoundry/router/SslInfoRequestDecorator.java create mode 100644 src/test/java/org/cloudfoundry/router/ReactiveClientCertificateMapperTest.java diff --git a/pom.xml b/pom.xml index 1a8cf2b..e785286 100644 --- a/pom.xml +++ b/pom.xml @@ -56,6 +56,11 @@ spring-boot-starter provided + + org.springframework + spring-webflux + provided + org.springframework.boot spring-boot-starter-test diff --git a/src/main/java/org/cloudfoundry/router/CertificateLoader.java b/src/main/java/org/cloudfoundry/router/CertificateLoader.java new file mode 100644 index 0000000..a8c84a5 --- /dev/null +++ b/src/main/java/org/cloudfoundry/router/CertificateLoader.java @@ -0,0 +1,91 @@ +/* + * Copyright 2017-2020 the original author or authors. + * + * 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 org.cloudfoundry.router; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +class CertificateLoader { + + public static final String HEADER_NAME = "X-Forwarded-Client-Cert"; + private final CertificateFactory certificateFactory; + + public CertificateLoader(CertificateFactory certificateFactory) { + this.certificateFactory = certificateFactory; + } + + public List getCertificates(Iterable headerValues) throws CertificateException, IOException { + List certificates = new ArrayList<>(); + + for (String rawCertificate : getRawCertificates(headerValues)) { + try (InputStream in = new ByteArrayInputStream(decodeHeader(rawCertificate))) { + certificates.add((X509Certificate) this.certificateFactory.generateCertificate(in)); + } + } + + return certificates; + } + + private byte[] decodeHeader(String rawCertificate) { + try { + return Base64.getDecoder().decode(rawCertificate); + } catch (IllegalArgumentException e1) { + try { + return URLDecoder.decode(rawCertificate, "utf-8").getBytes(); + } catch (UnsupportedEncodingException e2) { + throw new IllegalArgumentException("Header contains value that is neither base64 nor url encoded"); + } + } + } + + private List getRawCertificates(Iterable headerValues) { + if (headerValues == null) { + return Collections.emptyList(); + } + + Iterator candidates = headerValues.iterator(); + List rawCertificates = new ArrayList<>(); + while (candidates.hasNext()) { + String candidate = candidates.next(); + + if (hasMultipleCertificates(candidate)) { + rawCertificates.addAll(Arrays.asList(candidate.split(","))); + } else { + rawCertificates.add(candidate); + } + } + + return rawCertificates; + } + + private boolean hasMultipleCertificates(String candidate) { + return candidate.indexOf(',') != -1; + } + +} diff --git a/src/main/java/org/cloudfoundry/router/ClientCertificateMapper.java b/src/main/java/org/cloudfoundry/router/ClientCertificateMapper.java index 226ddda..2c38c7b 100644 --- a/src/main/java/org/cloudfoundry/router/ClientCertificateMapper.java +++ b/src/main/java/org/cloudfoundry/router/ClientCertificateMapper.java @@ -47,14 +47,12 @@ final class ClientCertificateMapper implements Filter { static final String ATTRIBUTE = "javax.servlet.request.X509Certificate"; - static final String HEADER = "X-Forwarded-Client-Cert"; - private final Logger logger = Logger.getLogger(this.getClass().getName()); - private final CertificateFactory certificateFactory; + private final CertificateLoader certificateLoader; ClientCertificateMapper() throws CertificateException { - this.certificateFactory = CertificateFactory.getInstance("X.509"); + this.certificateLoader = new CertificateLoader(CertificateFactory.getInstance("X.509")); } @Override @@ -66,7 +64,8 @@ public void destroy() { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { if (request instanceof HttpServletRequest) { try { - List certificates = getCertificates((HttpServletRequest) request); + Enumeration header = ((HttpServletRequest) request).getHeaders(CertificateLoader.HEADER_NAME); + List certificates = this.certificateLoader.getCertificates(Collections.list(header)); if (!certificates.isEmpty()) { request.setAttribute(ATTRIBUTE, certificates.toArray(new X509Certificate[0])); @@ -83,54 +82,4 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha public void init(FilterConfig filterConfig) { } - - private byte[] decodeHeader(String rawCertificate) { - try { - return Base64.getDecoder().decode(rawCertificate); - } catch (IllegalArgumentException e1) { - try { - return URLDecoder.decode(rawCertificate, "utf-8").getBytes(); - } catch (UnsupportedEncodingException e2) { - throw new IllegalArgumentException("Header contains value that is neither base64 nor url encoded"); - } - } - } - - private List getCertificates(HttpServletRequest request) throws CertificateException, IOException { - List certificates = new ArrayList<>(); - - for (String rawCertificate : getRawCertificates(request)) { - try (InputStream in = new ByteArrayInputStream(decodeHeader(rawCertificate))) { - certificates.add((X509Certificate) this.certificateFactory.generateCertificate(in)); - } - } - - return certificates; - } - - private List getRawCertificates(HttpServletRequest request) { - Enumeration candidates = request.getHeaders(HEADER); - - if (candidates == null) { - return Collections.emptyList(); - } - - List rawCertificates = new ArrayList<>(); - while (candidates.hasMoreElements()) { - String candidate = candidates.nextElement(); - - if (hasMultipleCertificates(candidate)) { - rawCertificates.addAll(Arrays.asList(candidate.split(","))); - } else { - rawCertificates.add(candidate); - } - } - - return rawCertificates; - } - - private boolean hasMultipleCertificates(String candidate) { - return candidate.indexOf(',') != -1; - } - } diff --git a/src/main/java/org/cloudfoundry/router/ReactiveClientCertificateMapper.java b/src/main/java/org/cloudfoundry/router/ReactiveClientCertificateMapper.java new file mode 100644 index 0000000..918f127 --- /dev/null +++ b/src/main/java/org/cloudfoundry/router/ReactiveClientCertificateMapper.java @@ -0,0 +1,53 @@ +/* + * Copyright 2017-2020 the original author or authors. + * + * 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 org.cloudfoundry.router; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +import java.io.IOException; +import java.security.cert.CertificateException; + +public class ReactiveClientCertificateMapper implements WebFilter { + + private final Logger logger = LoggerFactory.getLogger(ReactiveClientCertificateMapper.class); + + private final CertificateLoader certificateLoader; + + public ReactiveClientCertificateMapper(CertificateLoader certificateLoader) { + this.certificateLoader = certificateLoader; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + try { + final SslInfoRequestDecorator requestDecorator = new SslInfoRequestDecorator(this.certificateLoader, exchange.getRequest()); + final ServerWebExchange exchangeWithSslInfo = exchange.mutate().request(requestDecorator).build(); + return chain.filter(exchangeWithSslInfo); + } catch (CertificateException e) { + this.logger.warn("Unable to parse certificates in X-Forwarded-Client-Cert"); + } catch (IOException e) { + return Mono.error(e); + } + + return chain.filter(exchange); + } +} diff --git a/src/main/java/org/cloudfoundry/router/ReactiveClientCertificateMapperAutoConfiguration.java b/src/main/java/org/cloudfoundry/router/ReactiveClientCertificateMapperAutoConfiguration.java new file mode 100644 index 0000000..74a3dbc --- /dev/null +++ b/src/main/java/org/cloudfoundry/router/ReactiveClientCertificateMapperAutoConfiguration.java @@ -0,0 +1,39 @@ +/* + * Copyright 2017-2020 the original author or authors. + * + * 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 org.cloudfoundry.router; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnCloudPlatform; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.cloud.CloudPlatform; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.server.WebFilter; + +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; + +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) +@ConditionalOnCloudPlatform(CloudPlatform.CLOUD_FOUNDRY) +@Configuration +public class ReactiveClientCertificateMapperAutoConfiguration { + + @Bean + WebFilter reactiveCertificateMapper() throws CertificateException { + CertificateLoader certificateLoader = new CertificateLoader(CertificateFactory.getInstance("X.509")); + return new ReactiveClientCertificateMapper(certificateLoader); + } +} diff --git a/src/main/java/org/cloudfoundry/router/SimpleSslInfoHolder.java b/src/main/java/org/cloudfoundry/router/SimpleSslInfoHolder.java new file mode 100644 index 0000000..1a94c68 --- /dev/null +++ b/src/main/java/org/cloudfoundry/router/SimpleSslInfoHolder.java @@ -0,0 +1,42 @@ +/* + * Copyright 2017-2020 the original author or authors. + * + * 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 org.cloudfoundry.router; + +import org.springframework.http.server.reactive.SslInfo; + +import java.security.cert.X509Certificate; + +public class SimpleSslInfoHolder implements SslInfo { + + private final String sessionId; + private final X509Certificate[] peerCertificates; + + public SimpleSslInfoHolder(String sessionId, X509Certificate[] peerCertificates) { + this.sessionId = sessionId; + this.peerCertificates = peerCertificates; + } + + @Override + public String getSessionId() { + return this.sessionId; + } + + @Override + public X509Certificate[] getPeerCertificates() { + return this.peerCertificates; + } +} diff --git a/src/main/java/org/cloudfoundry/router/SslInfoRequestDecorator.java b/src/main/java/org/cloudfoundry/router/SslInfoRequestDecorator.java new file mode 100644 index 0000000..8d2f26c --- /dev/null +++ b/src/main/java/org/cloudfoundry/router/SslInfoRequestDecorator.java @@ -0,0 +1,59 @@ +/* + * Copyright 2017-2020 the original author or authors. + * + * 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 org.cloudfoundry.router; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpRequestDecorator; +import org.springframework.http.server.reactive.SslInfo; + +import java.io.IOException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Enumeration; +import java.util.List; + +public class SslInfoRequestDecorator extends ServerHttpRequestDecorator { + + private final Logger logger = LoggerFactory.getLogger(SslInfoRequestDecorator.class); + + private final CertificateLoader certificateLoader; + + private final SslInfo sslInfo; + + public SslInfoRequestDecorator(CertificateLoader certificateLoader, ServerHttpRequest delegate) throws CertificateException, IOException { + super(delegate); + + this.certificateLoader = certificateLoader; + final List headers = delegate.getHeaders().get(CertificateLoader.HEADER_NAME); + + if (delegate.getSslInfo() != null || headers == null || headers.isEmpty()) { + logger.debug("Original request contains SslInfo, skipping certificate loading from header"); + this.sslInfo = delegate.getSslInfo(); + } + else { + logger.debug("Original request does not contain SslInfo, loading certificates from header"); + this.sslInfo = new SimpleSslInfoHolder(null, certificateLoader.getCertificates(headers).toArray(new X509Certificate[0])); + } + } + + @Override + public SslInfo getSslInfo() { + return this.sslInfo; + } +} diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories index ae9f5ff..7460590 100644 --- a/src/main/resources/META-INF/spring.factories +++ b/src/main/resources/META-INF/spring.factories @@ -1 +1 @@ -org.springframework.boot.autoconfigure.EnableAutoConfiguration=org.cloudfoundry.router.ClientCertificateMapperAutoConfiguration +org.springframework.boot.autoconfigure.EnableAutoConfiguration=org.cloudfoundry.router.ClientCertificateMapperAutoConfiguration,org.cloudfoundry.router.ReactiveClientCertificateMapperAutoConfiguration diff --git a/src/test/java/org/cloudfoundry/router/ClientCertificateMapperTest.java b/src/test/java/org/cloudfoundry/router/ClientCertificateMapperTest.java index 58842ed..cdd12a6 100644 --- a/src/test/java/org/cloudfoundry/router/ClientCertificateMapperTest.java +++ b/src/test/java/org/cloudfoundry/router/ClientCertificateMapperTest.java @@ -107,7 +107,7 @@ public ClientCertificateMapperTest() throws CertificateException { @Test public void emptyHeader() throws IOException, ServletException { - this.request.addHeader(ClientCertificateMapper.HEADER, ""); + this.request.addHeader(CertificateLoader.HEADER_NAME, ""); this.mapper.doFilter(this.request, this.response, this.filterChain); @@ -117,7 +117,7 @@ public void emptyHeader() throws IOException, ServletException { @Test public void invalidHeader() throws IOException, ServletException { - this.request.addHeader(ClientCertificateMapper.HEADER, "Invalid Header Value"); + this.request.addHeader(CertificateLoader.HEADER_NAME, "Invalid Header Value"); this.mapper.doFilter(this.request, this.response, this.filterChain); @@ -127,8 +127,8 @@ public void invalidHeader() throws IOException, ServletException { @Test public void invalidMultipleHeaders() throws IOException, ServletException { - this.request.addHeader(ClientCertificateMapper.HEADER, CERTIFICATE_1); - this.request.addHeader(ClientCertificateMapper.HEADER, "Invalid Header Value"); + this.request.addHeader(CertificateLoader.HEADER_NAME, CERTIFICATE_1); + this.request.addHeader(CertificateLoader.HEADER_NAME, "Invalid Header Value"); this.mapper.doFilter(this.request, this.response, this.filterChain); @@ -138,7 +138,7 @@ public void invalidMultipleHeaders() throws IOException, ServletException { @Test public void invalidMultipleInOneHeader() throws IOException, ServletException { - this.request.addHeader(ClientCertificateMapper.HEADER, String.format("%s,Invalid Header Value", CERTIFICATE_1)); + this.request.addHeader(CertificateLoader.HEADER_NAME, String.format("%s,Invalid Header Value", CERTIFICATE_1)); this.mapper.doFilter(this.request, this.response, this.filterChain); @@ -148,8 +148,8 @@ public void invalidMultipleInOneHeader() throws IOException, ServletException { @Test public void multipleHeaders() throws IOException, ServletException { - this.request.addHeader(ClientCertificateMapper.HEADER, CERTIFICATE_1); - this.request.addHeader(ClientCertificateMapper.HEADER, CERTIFICATE_2); + this.request.addHeader(CertificateLoader.HEADER_NAME, CERTIFICATE_1); + this.request.addHeader(CertificateLoader.HEADER_NAME, CERTIFICATE_2); this.mapper.doFilter(this.request, this.response, this.filterChain); @@ -159,7 +159,7 @@ public void multipleHeaders() throws IOException, ServletException { @Test public void multipleInOneHeader() throws IOException, ServletException { - this.request.addHeader(ClientCertificateMapper.HEADER, String.format("%s,%s", CERTIFICATE_1, CERTIFICATE_2)); + this.request.addHeader(CertificateLoader.HEADER_NAME, String.format("%s,%s", CERTIFICATE_1, CERTIFICATE_2)); this.mapper.doFilter(this.request, this.response, this.filterChain); @@ -170,7 +170,7 @@ public void multipleInOneHeader() throws IOException, ServletException { @Test public void nginxHeader() throws IOException, ServletException { - this.request.addHeader(ClientCertificateMapper.HEADER, String.format("%s", NGINX_ESCAPED_CERT)); + this.request.addHeader(CertificateLoader.HEADER_NAME, String.format("%s", NGINX_ESCAPED_CERT)); this.mapper.doFilter(this.request, this.response, this.filterChain); @@ -186,4 +186,4 @@ public void noHeader() throws IOException, ServletException { assertThat(this.request.getAttribute(ClientCertificateMapper.ATTRIBUTE)).isNull(); } -} \ No newline at end of file +} diff --git a/src/test/java/org/cloudfoundry/router/ReactiveClientCertificateMapperTest.java b/src/test/java/org/cloudfoundry/router/ReactiveClientCertificateMapperTest.java new file mode 100644 index 0000000..a5eed85 --- /dev/null +++ b/src/test/java/org/cloudfoundry/router/ReactiveClientCertificateMapperTest.java @@ -0,0 +1,186 @@ +/* + * Copyright 2017-2020 the original author or authors. + * + * 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 org.cloudfoundry.router; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ReactiveClientCertificateMapperTest { + + private static final String CERTIFICATE_1 = "" + + "MIIDLTCCAhWgAwIBAgIkMDg3ZjVmZGMtOThkNy00MGMwLTY0ZDMtZmQ5NWFmODMx" + + "OThkMA0GCSqGSIb3DQEBCwUAMBoxGDAWBgNVBAMMD2NyZWRodWJDbGllbnRDQTAe" + + "Fw0xNzA1MDIwMDQ5MzFaFw0xNzA1MDMwMDQ5MzFaMGIxMTAvBgNVBAsTKGFwcDoy" + + "MzI4MmZkMS0zNWI0LTQ1ZGQtYTYwMi04Zjc2ZjRhNjBkMTExLTArBgNVBAMTJDA4" + + "N2Y1ZmRjLTk4ZDctNDBjMC02NGQzLWZkOTVhZjgzMTk4ZDCCASIwDQYJKoZIhvcN" + + "AQEBBQADggEPADCCAQoCggEBAPhcSn56pIVWI0RpwrkC3WcvumLw+3i/oj3YBbEx" + + "AUAFJMFl/yt1zpAghLvYOOiiUS/W04SKp8Z9FHlmNabJOzV40RIciSbYCW0tBeFG" + + "KNkgolTGamvRLZkkHUJdywEQkvnMG7+2XczDBoCZ7fdBepg6gieSqGhQwl/sO7x/" + + "TouvQnujKwJLiXOKQq00TkT+MVEzOZyOMlqFh9r2XjUGuh1HnRM0IAj6buR5663t" + + "4lAQqOluTAVNCKWSrAMIKb0G4QPTQ4pKRTeMEnTijFErtKlpzc64HYrBpufj1K/q" + + "TxYIy3EgeT3UVSclSub14M4/r/mOmWotYP81BR1Ko7pxV28CAwEAAaMTMBEwDwYD" + + "VR0RBAgwBocECv4AAjANBgkqhkiG9w0BAQsFAAOCAQEAuG8A33+Un2rvXA+qAf40" + + "gBponN2mjx0drasw/MqBnclUL1MYvOepqcGxxNB/1Ok/bKKDMr03ugVaxzAdoknA" + + "NwIyY/ghL6xHs/JrmuSGDs9BeNF0y8TOpQmmjh1EDFtR9YFuTRP1OZ6XBf5fbd80" + + "Q684k/Wu8ELywZJd53FKcTPJRQ/Yjn4QFJORtcNFlvMFWTmJLLiMDbI8JBcqMLZH" + + "sgdyBtV7kJdZU3nszgFEPspYzFfxQZmq6V+pJb+dmG2jYWrX/R21J9x1dJHBCoPp" + + "XcqQm8pYsDxi+HTGS6an78sHqrvU5uQJq2MW8o6iBJR80bFgWSl7GTqK3Xz5iTxU" + + "Ew=="; + + private static final String CERTIFICATE_2 = "" + + "MIIC1TCCAb2gAwIBAgIUL3dmX9jNj2XqQaXv9noNfU84VoowDQYJKoZIhvcNAQEL" + + "BQAwGjEYMBYGA1UEAwwPY3JlZGh1YkNsaWVudENBMB4XDTE3MDMzMDE5MTg0NVoX" + + "DTE4MDMzMDE5MTg0NVowGjEYMBYGA1UEAwwPY3JlZGh1YkNsaWVudENBMIIBIjAN" + + "BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz1bJ1NkS+uDl3xMo8fvPFRsXdZUW" + + "Un4N9nOfX/bfTWHrDKgW6+qrkkDBW4NLw0IHfgV99HwAygmiMC5La2HJg3JzcRMn" + + "dq9MosrNjv5wVtkQAReLVCcZ+EMb4f+0tlbespsfMQpKYfksovXHTSv+zbvbE+pX" + + "ObSUYpbZ09LtvbVL9s6hO5E9P9uXuV+ZSOZTISqtEIF6sXOKjx6WTCanG6jqf4+4" + + "Lyasffen18NcMld6f7cfEgExUO7OVN86J28+LcILICAOB2m8ug4KnDkigaJp25ou" + + "bPl/YnJtMh75buBjiOLI5p9j/n2mliUTKC5fJ54fb6MoMKXgXPAC7bcz5wIDAQAB" + + "oxMwETAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBXc7cDaA8D" + + "Iuoxnt5SVAk9R664OiMxOiQJ7oavdcU1S2hS22MOzAM1gMAwur1C8fmjcHthma4a" + + "IFzzyvWlT3cfKmr+e1CVU0fOr1f4kFFval4kSa9uFbqaqQlj6dovoO34W9eadTyN" + + "mACol2RdG0tjYWzbUaHdA21PdcezhiVw+PnXbzfKSnjWoxv0id1JTTPnVqfghTjG" + + "pEqerOIo3+YRhkUsUEhJ9SFa58dtlKRPtKQjSuMTeBgQiU7WCpueFfPqRM1Ab7bP" + + "OeiChAJVyknz/Mu1KmQxoZ43JfCyUIdtT5oE7CWIJt3qVwJYLgykuYV8vXEnIALB" + + "p/ob7SaWTJJO"; + + private static final String NGINX_ESCAPED_CERT = "" + + "%2D%2D%2D%2D%2DBEGIN%20CERTIFICATE%2D%2D%2D%2D%2D%0D%0AMIIDLTCCA" + + "hWgAwIBAgIkMDg3ZjVmZGMtOThkNy00MGMwLTY0ZDMtZmQ5NWFmODMx%0D%0AOTh" + + "kMA0GCSqGSIb3DQEBCwUAMBoxGDAWBgNVBAMMD2NyZWRodWJDbGllbnRDQTAe%0D" + + "%0AFw0xNzA1MDIwMDQ5MzFaFw0xNzA1MDMwMDQ5MzFaMGIxMTAvBgNVBAsTKGFwc" + + "Doy%0D%0AMzI4MmZkMS0zNWI0LTQ1ZGQtYTYwMi04Zjc2ZjRhNjBkMTExLTArBgN" + + "VBAMTJDA4%0D%0AN2Y1ZmRjLTk4ZDctNDBjMC02NGQzLWZkOTVhZjgzMTk4ZDCCA" + + "SIwDQYJKoZIhvcN%0D%0AAQEBBQADggEPADCCAQoCggEBAPhcSn56pIVWI0Rpwrk" + + "C3WcvumLw%2B3i%2Foj3YBbEx%0D%0AAUAFJMFl%2Fyt1zpAghLvYOOiiUS%2FW0" + + "4SKp8Z9FHlmNabJOzV40RIciSbYCW0tBeFG%0D%0AKNkgolTGamvRLZkkHUJdywE" + + "QkvnMG7%2B2XczDBoCZ7fdBepg6gieSqGhQwl%2FsO7x%2F%0D%0ATouvQnujKwJ" + + "LiXOKQq00TkT%2BMVEzOZyOMlqFh9r2XjUGuh1HnRM0IAj6buR5663t%0D%0A4lA" + + "QqOluTAVNCKWSrAMIKb0G4QPTQ4pKRTeMEnTijFErtKlpzc64HYrBpufj1K%2Fq%" + + "0D%0ATxYIy3EgeT3UVSclSub14M4%2Fr%2FmOmWotYP81BR1Ko7pxV28CAwEAAaM" + + "TMBEwDwYD%0D%0AVR0RBAgwBocECv4AAjANBgkqhkiG9w0BAQsFAAOCAQEAuG8A3" + + "3%2BUn2rvXA%2BqAf40%0D%0AgBponN2mjx0drasw%2FMqBnclUL1MYvOepqcGxx" + + "NB%2F1Ok%2FbKKDMr03ugVaxzAdoknA%0D%0ANwIyY%2FghL6xHs%2FJrmuSGDs9" + + "BeNF0y8TOpQmmjh1EDFtR9YFuTRP1OZ6XBf5fbd80%0D%0AQ684k%2FWu8ELywZJ" + + "d53FKcTPJRQ%2FYjn4QFJORtcNFlvMFWTmJLLiMDbI8JBcqMLZH%0D%0AsgdyBtV" + + "7kJdZU3nszgFEPspYzFfxQZmq6V%2BpJb%2BdmG2jYWrX%2FR21J9x1dJHBCoPp%" + + "0D%0AXcqQm8pYsDxi%2BHTGS6an78sHqrvU5uQJq2MW8o6iBJR80bFgWSl7GTqK3" + + "Xz5iTxU%0D%0AEw%3D%3D%0D%0A%2D%2D%2D%2D%2DEND%20CERTIFICATE%2D%2" + + "D%2D%2D%2D%0D%0A"; + + + private ReactiveClientCertificateMapper mapper; + private ServerWebExchange actualExchange; + + @Before + public void setUp() throws CertificateException { + this.mapper = new ReactiveClientCertificateMapper(new CertificateLoader(CertificateFactory.getInstance("X.509"))); + } + + private ServerWebExchange doFilter(MockServerHttpRequest request) { + MockServerWebExchange serverWebExchange = MockServerWebExchange.from(request); + + this.mapper.filter(serverWebExchange, exchange -> { + actualExchange = exchange; + return Mono.empty(); + }).block(); + + return actualExchange; + } + + @Test + public void emptyHeader() { + MockServerHttpRequest request = MockServerHttpRequest.get("/").header(CertificateLoader.HEADER_NAME, "").build(); + + ServerWebExchange exchange = doFilter(request); + + assertThat(exchange.getRequest().getSslInfo()).isNull(); + } + + @Test + public void invalidHeader() { + MockServerHttpRequest request = MockServerHttpRequest.get("/").header(CertificateLoader.HEADER_NAME, "Invalid Header Value").build(); + + ServerWebExchange exchange = doFilter(request); + + assertThat(exchange.getRequest().getSslInfo()).isNull(); + } + + @Test + public void invalidMultipleHeaders() { + MockServerHttpRequest request = MockServerHttpRequest.get("/").header(CertificateLoader.HEADER_NAME, CERTIFICATE_1, "Invalid Header Value").build(); + + ServerWebExchange exchange = doFilter(request); + + assertThat(exchange.getRequest().getSslInfo()).isNull(); + } + + @Test + public void invalidMultipleInOneHeader() { + MockServerHttpRequest request = MockServerHttpRequest.get("/").header(CertificateLoader.HEADER_NAME, String.format("%s,Invalid Header Value", CERTIFICATE_1)).build(); + + ServerWebExchange exchange = doFilter(request); + + assertThat(exchange.getRequest().getSslInfo()).isNull(); + } + + @Test + public void multipleHeaders() { + MockServerHttpRequest request = MockServerHttpRequest.get("/").header(CertificateLoader.HEADER_NAME, CERTIFICATE_1, CERTIFICATE_2).build(); + + ServerWebExchange exchange = doFilter(request); + + assertThat(exchange.getRequest().getSslInfo().getPeerCertificates()).hasSize(2); + } + + @Test + public void multipleInOneHeader() { + MockServerHttpRequest request = MockServerHttpRequest.get("/").header(CertificateLoader.HEADER_NAME, String.format("%s,%s", CERTIFICATE_1, CERTIFICATE_2)).build(); + + ServerWebExchange exchange = doFilter(request); + + assertThat(exchange.getRequest().getSslInfo().getPeerCertificates()).hasSize(2); + } + + @Test + public void nginxHeader() { + MockServerHttpRequest request = MockServerHttpRequest.get("/").header(CertificateLoader.HEADER_NAME, String.format("%s", NGINX_ESCAPED_CERT)).build(); + + ServerWebExchange exchange = doFilter(request); + + assertThat(exchange.getRequest().getSslInfo().getPeerCertificates()).hasSize(1); + } + + @Test + public void noHeader() { + MockServerHttpRequest request = MockServerHttpRequest.get("/").build(); + + ServerWebExchange exchange = doFilter(request); + + assertThat(exchange.getRequest().getSslInfo()).isNull(); + } +}