Skip to content

Commit a2777a8

Browse files
committed
Add reactive certificate mapping support
1 parent 32fd656 commit a2777a8

10 files changed

+490
-66
lines changed

pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@
5656
<artifactId>spring-boot-starter</artifactId>
5757
<scope>provided</scope>
5858
</dependency>
59+
<dependency>
60+
<groupId>org.springframework</groupId>
61+
<artifactId>spring-webflux</artifactId>
62+
<scope>provided</scope>
63+
</dependency>
5964
<dependency>
6065
<groupId>org.springframework.boot</groupId>
6166
<artifactId>spring-boot-starter-test</artifactId>
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright 2017-2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.cloudfoundry.router;
18+
19+
import java.io.ByteArrayInputStream;
20+
import java.io.IOException;
21+
import java.io.InputStream;
22+
import java.io.UnsupportedEncodingException;
23+
import java.net.URLDecoder;
24+
import java.security.cert.CertificateException;
25+
import java.security.cert.CertificateFactory;
26+
import java.security.cert.X509Certificate;
27+
import java.util.ArrayList;
28+
import java.util.Arrays;
29+
import java.util.Base64;
30+
import java.util.Collections;
31+
import java.util.Iterator;
32+
import java.util.List;
33+
34+
class CertificateLoader {
35+
36+
public static final String HEADER_NAME = "X-Forwarded-Client-Cert";
37+
private final CertificateFactory certificateFactory;
38+
39+
public CertificateLoader(CertificateFactory certificateFactory) {
40+
this.certificateFactory = certificateFactory;
41+
}
42+
43+
public List<X509Certificate> getCertificates(Iterable<String> headerValues) throws CertificateException, IOException {
44+
List<X509Certificate> certificates = new ArrayList<>();
45+
46+
for (String rawCertificate : getRawCertificates(headerValues)) {
47+
try (InputStream in = new ByteArrayInputStream(decodeHeader(rawCertificate))) {
48+
certificates.add((X509Certificate) this.certificateFactory.generateCertificate(in));
49+
}
50+
}
51+
52+
return certificates;
53+
}
54+
55+
private byte[] decodeHeader(String rawCertificate) {
56+
try {
57+
return Base64.getDecoder().decode(rawCertificate);
58+
} catch (IllegalArgumentException e1) {
59+
try {
60+
return URLDecoder.decode(rawCertificate, "utf-8").getBytes();
61+
} catch (UnsupportedEncodingException e2) {
62+
throw new IllegalArgumentException("Header contains value that is neither base64 nor url encoded");
63+
}
64+
}
65+
}
66+
67+
private List<String> getRawCertificates(Iterable<String> headerValues) {
68+
if (headerValues == null) {
69+
return Collections.emptyList();
70+
}
71+
72+
Iterator<String> candidates = headerValues.iterator();
73+
List<String> rawCertificates = new ArrayList<>();
74+
while (candidates.hasNext()) {
75+
String candidate = candidates.next();
76+
77+
if (hasMultipleCertificates(candidate)) {
78+
rawCertificates.addAll(Arrays.asList(candidate.split(",")));
79+
} else {
80+
rawCertificates.add(candidate);
81+
}
82+
}
83+
84+
return rawCertificates;
85+
}
86+
87+
private boolean hasMultipleCertificates(String candidate) {
88+
return candidate.indexOf(',') != -1;
89+
}
90+
91+
}

src/main/java/org/cloudfoundry/router/ClientCertificateMapper.java

Lines changed: 4 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,12 @@ final class ClientCertificateMapper implements Filter {
4747

4848
static final String ATTRIBUTE = "javax.servlet.request.X509Certificate";
4949

50-
static final String HEADER = "X-Forwarded-Client-Cert";
51-
5250
private final Logger logger = Logger.getLogger(this.getClass().getName());
5351

54-
private final CertificateFactory certificateFactory;
52+
private final CertificateLoader certificateLoader;
5553

5654
ClientCertificateMapper() throws CertificateException {
57-
this.certificateFactory = CertificateFactory.getInstance("X.509");
55+
this.certificateLoader = new CertificateLoader(CertificateFactory.getInstance("X.509"));
5856
}
5957

6058
@Override
@@ -66,7 +64,8 @@ public void destroy() {
6664
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
6765
if (request instanceof HttpServletRequest) {
6866
try {
69-
List<X509Certificate> certificates = getCertificates((HttpServletRequest) request);
67+
Enumeration<String> header = ((HttpServletRequest) request).getHeaders(CertificateLoader.HEADER_NAME);
68+
List<X509Certificate> certificates = this.certificateLoader.getCertificates(Collections.list(header));
7069

7170
if (!certificates.isEmpty()) {
7271
request.setAttribute(ATTRIBUTE, certificates.toArray(new X509Certificate[0]));
@@ -83,54 +82,4 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha
8382
public void init(FilterConfig filterConfig) {
8483

8584
}
86-
87-
private byte[] decodeHeader(String rawCertificate) {
88-
try {
89-
return Base64.getDecoder().decode(rawCertificate);
90-
} catch (IllegalArgumentException e1) {
91-
try {
92-
return URLDecoder.decode(rawCertificate, "utf-8").getBytes();
93-
} catch (UnsupportedEncodingException e2) {
94-
throw new IllegalArgumentException("Header contains value that is neither base64 nor url encoded");
95-
}
96-
}
97-
}
98-
99-
private List<X509Certificate> getCertificates(HttpServletRequest request) throws CertificateException, IOException {
100-
List<X509Certificate> certificates = new ArrayList<>();
101-
102-
for (String rawCertificate : getRawCertificates(request)) {
103-
try (InputStream in = new ByteArrayInputStream(decodeHeader(rawCertificate))) {
104-
certificates.add((X509Certificate) this.certificateFactory.generateCertificate(in));
105-
}
106-
}
107-
108-
return certificates;
109-
}
110-
111-
private List<String> getRawCertificates(HttpServletRequest request) {
112-
Enumeration<String> candidates = request.getHeaders(HEADER);
113-
114-
if (candidates == null) {
115-
return Collections.emptyList();
116-
}
117-
118-
List<String> rawCertificates = new ArrayList<>();
119-
while (candidates.hasMoreElements()) {
120-
String candidate = candidates.nextElement();
121-
122-
if (hasMultipleCertificates(candidate)) {
123-
rawCertificates.addAll(Arrays.asList(candidate.split(",")));
124-
} else {
125-
rawCertificates.add(candidate);
126-
}
127-
}
128-
129-
return rawCertificates;
130-
}
131-
132-
private boolean hasMultipleCertificates(String candidate) {
133-
return candidate.indexOf(',') != -1;
134-
}
135-
13685
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright 2017-2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.cloudfoundry.router;
18+
19+
import org.slf4j.Logger;
20+
import org.slf4j.LoggerFactory;
21+
import org.springframework.web.server.ServerWebExchange;
22+
import org.springframework.web.server.WebFilter;
23+
import org.springframework.web.server.WebFilterChain;
24+
import reactor.core.publisher.Mono;
25+
26+
import java.io.IOException;
27+
import java.security.cert.CertificateException;
28+
29+
public class ReactiveClientCertificateMapper implements WebFilter {
30+
31+
private final Logger logger = LoggerFactory.getLogger(ReactiveClientCertificateMapper.class);
32+
33+
private final CertificateLoader certificateLoader;
34+
35+
public ReactiveClientCertificateMapper(CertificateLoader certificateLoader) {
36+
this.certificateLoader = certificateLoader;
37+
}
38+
39+
@Override
40+
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
41+
try {
42+
final SslInfoRequestDecorator requestDecorator = new SslInfoRequestDecorator(this.certificateLoader, exchange.getRequest());
43+
final ServerWebExchange exchangeWithSslInfo = exchange.mutate().request(requestDecorator).build();
44+
return chain.filter(exchangeWithSslInfo);
45+
} catch (CertificateException e) {
46+
this.logger.warn("Unable to parse certificates in X-Forwarded-Client-Cert");
47+
} catch (IOException e) {
48+
return Mono.error(e);
49+
}
50+
51+
return chain.filter(exchange);
52+
}
53+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright 2017-2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.cloudfoundry.router;
18+
19+
import org.springframework.boot.autoconfigure.condition.ConditionalOnCloudPlatform;
20+
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
21+
import org.springframework.boot.cloud.CloudPlatform;
22+
import org.springframework.context.annotation.Bean;
23+
import org.springframework.context.annotation.Configuration;
24+
import org.springframework.web.server.WebFilter;
25+
26+
import java.security.cert.CertificateException;
27+
import java.security.cert.CertificateFactory;
28+
29+
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
30+
@ConditionalOnCloudPlatform(CloudPlatform.CLOUD_FOUNDRY)
31+
@Configuration
32+
public class ReactiveClientCertificateMapperAutoConfiguration {
33+
34+
@Bean
35+
WebFilter reactiveCertificateMapper() throws CertificateException {
36+
CertificateLoader certificateLoader = new CertificateLoader(CertificateFactory.getInstance("X.509"));
37+
return new ReactiveClientCertificateMapper(certificateLoader);
38+
}
39+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright 2017-2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.cloudfoundry.router;
18+
19+
import org.springframework.http.server.reactive.SslInfo;
20+
21+
import java.security.cert.X509Certificate;
22+
23+
public class SimpleSslInfoHolder implements SslInfo {
24+
25+
private final String sessionId;
26+
private final X509Certificate[] peerCertificates;
27+
28+
public SimpleSslInfoHolder(String sessionId, X509Certificate[] peerCertificates) {
29+
this.sessionId = sessionId;
30+
this.peerCertificates = peerCertificates;
31+
}
32+
33+
@Override
34+
public String getSessionId() {
35+
return this.sessionId;
36+
}
37+
38+
@Override
39+
public X509Certificate[] getPeerCertificates() {
40+
return this.peerCertificates;
41+
}
42+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* Copyright 2017-2020 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.cloudfoundry.router;
18+
19+
import org.slf4j.Logger;
20+
import org.slf4j.LoggerFactory;
21+
import org.springframework.http.server.reactive.ServerHttpRequest;
22+
import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
23+
import org.springframework.http.server.reactive.SslInfo;
24+
25+
import java.io.IOException;
26+
import java.security.cert.CertificateException;
27+
import java.security.cert.X509Certificate;
28+
import java.util.Enumeration;
29+
import java.util.List;
30+
31+
public class SslInfoRequestDecorator extends ServerHttpRequestDecorator {
32+
33+
private final Logger logger = LoggerFactory.getLogger(SslInfoRequestDecorator.class);
34+
35+
private final CertificateLoader certificateLoader;
36+
37+
private final SslInfo sslInfo;
38+
39+
public SslInfoRequestDecorator(CertificateLoader certificateLoader, ServerHttpRequest delegate) throws CertificateException, IOException {
40+
super(delegate);
41+
42+
this.certificateLoader = certificateLoader;
43+
final List<String> headers = delegate.getHeaders().get(CertificateLoader.HEADER_NAME);
44+
45+
if (delegate.getSslInfo() != null || headers == null || headers.isEmpty()) {
46+
logger.debug("Original request contains SslInfo, skipping certificate loading from header");
47+
this.sslInfo = delegate.getSslInfo();
48+
}
49+
else {
50+
logger.debug("Original request does not contain SslInfo, loading certificates from header");
51+
this.sslInfo = new SimpleSslInfoHolder(null, certificateLoader.getCertificates(headers).toArray(new X509Certificate[0]));
52+
}
53+
}
54+
55+
@Override
56+
public SslInfo getSslInfo() {
57+
return this.sslInfo;
58+
}
59+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
org.springframework.boot.autoconfigure.EnableAutoConfiguration=org.cloudfoundry.router.ClientCertificateMapperAutoConfiguration
1+
org.springframework.boot.autoconfigure.EnableAutoConfiguration=org.cloudfoundry.router.ClientCertificateMapperAutoConfiguration,org.cloudfoundry.router.ReactiveClientCertificateMapperAutoConfiguration

0 commit comments

Comments
 (0)