diff --git a/pom.xml b/pom.xml index 26da3423..8448c2c1 100644 --- a/pom.xml +++ b/pom.xml @@ -41,7 +41,6 @@ 1.70 1.0.1 - 1.3.2 3.6.0 0.19 @@ -145,14 +144,6 @@ ${gravitee-json-validation.version} test - - - org.apache.commons - commons-io - ${commons-io.version} - test - - diff --git a/src/main/java/io/gravitee/policy/sslenforcement/SslEnforcementPolicy.java b/src/main/java/io/gravitee/policy/sslenforcement/SslEnforcementPolicy.java index a5624451..b0d0c57d 100644 --- a/src/main/java/io/gravitee/policy/sslenforcement/SslEnforcementPolicy.java +++ b/src/main/java/io/gravitee/policy/sslenforcement/SslEnforcementPolicy.java @@ -19,24 +19,30 @@ import io.gravitee.common.util.Maps; import io.gravitee.gateway.api.Request; import io.gravitee.gateway.api.Response; +import io.gravitee.gateway.api.http.HttpHeaders; import io.gravitee.policy.api.PolicyChain; import io.gravitee.policy.api.PolicyResult; import io.gravitee.policy.api.annotations.OnRequest; +import io.gravitee.policy.sslenforcement.configuration.CertificateLocation; import io.gravitee.policy.sslenforcement.configuration.SslEnforcementPolicyConfiguration; +import java.io.ByteArrayInputStream; +import java.net.URLDecoder; +import java.nio.charset.Charset; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.Optional; import javax.net.ssl.SSLPeerUnverifiedException; import javax.net.ssl.SSLSession; import javax.security.auth.x500.X500Principal; -import org.bouncycastle.asn1.ASN1ObjectIdentifier; -import org.bouncycastle.asn1.x500.AttributeTypeAndValue; -import org.bouncycastle.asn1.x500.RDN; +import lombok.extern.slf4j.Slf4j; import org.bouncycastle.asn1.x500.X500Name; -import org.bouncycastle.asn1.x500.style.IETFUtils; -import org.springframework.util.AntPathMatcher; +import org.springframework.util.StringUtils; /** * @author David BRASSELY (david.brassely at graviteesource.com) * @author GraviteeSource Team */ +@Slf4j public class SslEnforcementPolicy { private final SslEnforcementPolicyConfiguration configuration; @@ -70,13 +76,8 @@ public void onRequest(Request request, Response response, PolicyChain policyChai return; } - X500Principal peerPrincipal = null; - - try { - peerPrincipal = (X500Principal) sslSession.getPeerPrincipal(); - } catch (SSLPeerUnverifiedException e) {} - - if (configuration.isRequiresClientAuthentication() && peerPrincipal == null) { + var principal = extractX500Principal(request); + if (configuration.isRequiresClientAuthentication() && principal == null) { policyChain.failWith(PolicyResult.failure(AUTHENTICATION_REQUIRED, HttpStatusCode.UNAUTHORIZED_401, "Unauthorized")); return; @@ -87,7 +88,7 @@ public void onRequest(Request request, Response response, PolicyChain policyChai configuration.getWhitelistClientCertificates() != null && !configuration.getWhitelistClientCertificates().isEmpty() ) { - X500Name peerName = new X500Name(peerPrincipal.getName()); + X500Name peerName = new X500Name(principal.getName()); boolean found = false; @@ -95,7 +96,7 @@ public void onRequest(Request request, Response response, PolicyChain policyChai // Prepare name with javax.security to transform to valid bouncycastle Asn1ObjectIdentifier final X500Principal x500Principal = new X500Principal(name); final X500Name x500Name = new X500Name(x500Principal.getName()); - found = areEqual(x500Name, peerName); + found = X500NameComparator.areEqual(x500Name, peerName); if (found) { break; @@ -108,7 +109,7 @@ public void onRequest(Request request, Response response, PolicyChain policyChai CLIENT_FORBIDDEN, HttpStatusCode.FORBIDDEN_403, "You're not allowed to access this resource", - Maps.builder().put("name", peerPrincipal.getName()).build() + Maps.builder().put("name", principal.getName()).build() ) ); @@ -119,107 +120,48 @@ public void onRequest(Request request, Response response, PolicyChain policyChai policyChain.doNext(request, response); } - private boolean areEqual(X500Name name1, X500Name name2) { - final RDN[] rdns1 = name1.getRDNs(); - final RDN[] rdns2 = name2.getRDNs(); - - if (rdns1.length != rdns2.length) { - return false; - } - - boolean reverse = false; - - if (rdns1[0].getFirst() != null && rdns2[0].getFirst() != null) { - reverse = !rdns1[0].getFirst().getType().equals(rdns2[0].getFirst().getType()); // guess forward - } - - for (int i = 0; i != rdns1.length; i++) { - if (!foundMatch(reverse, rdns1[i], rdns2)) { - return false; - } - } - - return true; - } + private X500Principal extractX500Principal(Request request) { + if (configuration.getCertificateLocation() == CertificateLocation.SESSION) { + SSLSession sslSession = request.sslSession(); - private boolean foundMatch(boolean reverse, RDN rdn, RDN[] possRDNs) { - if (reverse) { - for (int i = possRDNs.length - 1; i >= 0; i--) { - if (possRDNs[i] != null && rDNAreEqual(rdn, possRDNs[i])) { - possRDNs[i] = null; - return true; - } - } - } else { - for (int i = 0; i != possRDNs.length; i++) { - if (possRDNs[i] != null && rDNAreEqual(rdn, possRDNs[i])) { - possRDNs[i] = null; - return true; + if (null != sslSession) { + try { + return (X500Principal) sslSession.getPeerPrincipal(); + } catch (SSLPeerUnverifiedException e) { + return null; } } + return null; } - return false; + return extractCertificate(request.headers(), configuration.getCertificateHeaderName()) + .map(X509Certificate::getSubjectX500Principal) + .orElse(null); } - private static boolean rDNAreEqual(RDN rdn1, RDN rdn2) { - if (rdn1.isMultiValued()) { - if (rdn2.isMultiValued()) { - AttributeTypeAndValue[] atvs1 = rdn1.getTypesAndValues(); - AttributeTypeAndValue[] atvs2 = rdn2.getTypesAndValues(); + public static Optional extractCertificate(final HttpHeaders httpHeaders, final String certHeader) { + Optional certificate = Optional.empty(); - if (atvs1.length != atvs2.length) { - return false; - } + String certHeaderValue = StringUtils.hasText(certHeader) ? httpHeaders.get(certHeader) : null; - for (int i = 0; i != atvs1.length; i++) { - if (!atvAreEqual(atvs1[i], atvs2[i])) { - return false; - } + if (certHeaderValue != null) { + try { + if (!certHeaderValue.contains("\n")) { + certHeaderValue = URLDecoder.decode(certHeaderValue, Charset.defaultCharset()); } - } else { - return false; + certHeaderValue = certHeaderValue.replaceAll("\t", "\n"); + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + certificate = + Optional.ofNullable( + (X509Certificate) certificateFactory.generateCertificate(new ByteArrayInputStream(certHeaderValue.getBytes())) + ); + } catch (Exception e) { + log.debug("Unable to retrieve peer certificate from request header '{}'", certHeader, e); } } else { - if (!rdn2.isMultiValued()) { - return atvAreEqual(rdn1.getFirst(), rdn2.getFirst()); - } else { - return false; - } - } - - return true; - } - - private static boolean atvAreEqual(AttributeTypeAndValue atv1, AttributeTypeAndValue atv2) { - if (atv1 == atv2) { - return true; - } - - if (atv1 == null) { - return false; - } - - if (atv2 == null) { - return false; - } - - ASN1ObjectIdentifier o1 = atv1.getType(); - ASN1ObjectIdentifier o2 = atv2.getType(); - - if (!o1.equals(o2)) { - return false; - } - - String v1 = IETFUtils.canonicalize(IETFUtils.valueToString(atv1.getValue())); - String v2 = IETFUtils.canonicalize(IETFUtils.valueToString(atv2.getValue())); - - AntPathMatcher matcher = new AntPathMatcher(); - - if (!matcher.match(v1, v2)) { - return false; + log.debug("Header '{}' missing, unable to retrieve client certificate", certHeader); } - return true; + return certificate; } } diff --git a/src/main/java/io/gravitee/policy/sslenforcement/X500NameComparator.java b/src/main/java/io/gravitee/policy/sslenforcement/X500NameComparator.java new file mode 100644 index 00000000..d9c07311 --- /dev/null +++ b/src/main/java/io/gravitee/policy/sslenforcement/X500NameComparator.java @@ -0,0 +1,128 @@ +/** + * Copyright (C) 2015 The Gravitee team (http://gravitee.io) + * + * 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.gravitee.policy.sslenforcement; + +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.x500.AttributeTypeAndValue; +import org.bouncycastle.asn1.x500.RDN; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.style.IETFUtils; +import org.springframework.util.AntPathMatcher; + +public class X500NameComparator { + + private X500NameComparator() {} + + public static boolean areEqual(X500Name name1, X500Name name2) { + final RDN[] rdns1 = name1.getRDNs(); + final RDN[] rdns2 = name2.getRDNs(); + + if (rdns1.length != rdns2.length) { + return false; + } + + boolean reverse = false; + + if (rdns1[0].getFirst() != null && rdns2[0].getFirst() != null) { + reverse = !rdns1[0].getFirst().getType().equals(rdns2[0].getFirst().getType()); // guess forward + } + + for (int i = 0; i != rdns1.length; i++) { + if (!foundMatch(reverse, rdns1[i], rdns2)) { + return false; + } + } + + return true; + } + + private static boolean foundMatch(boolean reverse, RDN rdn, RDN[] possRDNs) { + if (reverse) { + for (int i = possRDNs.length - 1; i >= 0; i--) { + if (possRDNs[i] != null && rDNAreEqual(rdn, possRDNs[i])) { + possRDNs[i] = null; + return true; + } + } + } else { + for (int i = 0; i != possRDNs.length; i++) { + if (possRDNs[i] != null && rDNAreEqual(rdn, possRDNs[i])) { + possRDNs[i] = null; + return true; + } + } + } + + return false; + } + + private static boolean rDNAreEqual(RDN rdn1, RDN rdn2) { + if (rdn1.isMultiValued()) { + if (rdn2.isMultiValued()) { + AttributeTypeAndValue[] atvs1 = rdn1.getTypesAndValues(); + AttributeTypeAndValue[] atvs2 = rdn2.getTypesAndValues(); + + if (atvs1.length != atvs2.length) { + return false; + } + + for (int i = 0; i != atvs1.length; i++) { + if (!atvAreEqual(atvs1[i], atvs2[i])) { + return false; + } + } + } else { + return false; + } + } else { + if (!rdn2.isMultiValued()) { + return atvAreEqual(rdn1.getFirst(), rdn2.getFirst()); + } else { + return false; + } + } + + return true; + } + + private static boolean atvAreEqual(AttributeTypeAndValue atv1, AttributeTypeAndValue atv2) { + if (atv1 == atv2) { + return true; + } + + if (atv1 == null) { + return false; + } + + if (atv2 == null) { + return false; + } + + ASN1ObjectIdentifier o1 = atv1.getType(); + ASN1ObjectIdentifier o2 = atv2.getType(); + + if (!o1.equals(o2)) { + return false; + } + + String v1 = IETFUtils.canonicalize(IETFUtils.valueToString(atv1.getValue())); + String v2 = IETFUtils.canonicalize(IETFUtils.valueToString(atv2.getValue())); + + AntPathMatcher matcher = new AntPathMatcher(); + + return matcher.match(v1, v2); + } +} diff --git a/src/main/java/io/gravitee/policy/sslenforcement/configuration/CertificateLocation.java b/src/main/java/io/gravitee/policy/sslenforcement/configuration/CertificateLocation.java new file mode 100644 index 00000000..c30ec375 --- /dev/null +++ b/src/main/java/io/gravitee/policy/sslenforcement/configuration/CertificateLocation.java @@ -0,0 +1,40 @@ +/** + * Copyright (C) 2015 The Gravitee team (http://gravitee.io) + * + * 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.gravitee.policy.sslenforcement.configuration; + +import java.util.Map; +import lombok.Getter; + +@Getter +public enum CertificateLocation { + HEADER("header"), + SESSION("sessions"); + + private static final Map LABELS_MAP = Map.of(HEADER.label, HEADER, SESSION.label, SESSION); + + private final String label; + + CertificateLocation(String label) { + this.label = label; + } + + public static CertificateLocation fromLabel(String label) { + if (label != null) { + return LABELS_MAP.get(label); + } + return null; + } +} diff --git a/src/main/java/io/gravitee/policy/sslenforcement/configuration/SslEnforcementPolicyConfiguration.java b/src/main/java/io/gravitee/policy/sslenforcement/configuration/SslEnforcementPolicyConfiguration.java index 35c8dbf1..e8ec23f0 100644 --- a/src/main/java/io/gravitee/policy/sslenforcement/configuration/SslEnforcementPolicyConfiguration.java +++ b/src/main/java/io/gravitee/policy/sslenforcement/configuration/SslEnforcementPolicyConfiguration.java @@ -17,41 +17,33 @@ import io.gravitee.policy.api.PolicyConfiguration; import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; /** * @author David BRASSELY (david.brassely at graviteesource.com) * @author GraviteeSource Team */ +@Builder +@Data +@AllArgsConstructor +@NoArgsConstructor public class SslEnforcementPolicyConfiguration implements PolicyConfiguration { - private boolean requiresSsl; + @Builder.Default + private boolean requiresSsl = true; private boolean requiresClientAuthentication; - // Whitelist client certificates + /** Allowed client certificates (requires client authentication) **/ private List whitelistClientCertificates; - public List getWhitelistClientCertificates() { - return whitelistClientCertificates; - } + @Builder.Default + private CertificateLocation certificateLocation = CertificateLocation.SESSION; - public void setWhitelistClientCertificates(List whitelistClientCertificates) { - this.whitelistClientCertificates = whitelistClientCertificates; - } - - public boolean isRequiresSsl() { - return requiresSsl; - } - - public void setRequiresSsl(boolean requiresSsl) { - this.requiresSsl = requiresSsl; - } - - public boolean isRequiresClientAuthentication() { - return requiresClientAuthentication; - } - - public void setRequiresClientAuthentication(boolean requiresClientAuthentication) { - this.requiresClientAuthentication = requiresClientAuthentication; - } + /** Name of the header where to find the client certificate when using header certificate location **/ + @Builder.Default + private String certificateHeaderName = "ssl-client-cert"; } diff --git a/src/main/resources/schemas/schema-form.json b/src/main/resources/schemas/schema-form.json index 7a52e795..8c426f3b 100644 --- a/src/main/resources/schemas/schema-form.json +++ b/src/main/resources/schemas/schema-form.json @@ -15,6 +15,19 @@ "type" : "boolean", "default": false }, + "certificateLocation": { + "title": "Certificate location", + "description": "Location of the certificate.", + "type" : "string", + "enum": ["SESSION", "HEADER"], + "default": "SESSION" + }, + "certificateHeaderName": { + "title": "Header name", + "description": "Name of the header where to find the client certificate.", + "type": "string", + "default": "ssl-client-cert" + }, "whitelistClientCertificates" : { "type" : "array", "title": "Allowed client certificates (requires client authentication).", @@ -26,4 +39,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/test/java/io/gravitee/policy/sslenforcement/SslEnforcementPolicyTest.java b/src/test/java/io/gravitee/policy/sslenforcement/SslEnforcementPolicyTest.java index 95f2fc2a..f2999a63 100644 --- a/src/test/java/io/gravitee/policy/sslenforcement/SslEnforcementPolicyTest.java +++ b/src/test/java/io/gravitee/policy/sslenforcement/SslEnforcementPolicyTest.java @@ -15,32 +15,40 @@ */ package io.gravitee.policy.sslenforcement; -import static org.mockito.ArgumentMatchers.argThat; +import static java.util.Objects.requireNonNull; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import io.gravitee.gateway.api.Request; import io.gravitee.gateway.api.Response; +import io.gravitee.gateway.api.http.HttpHeaders; import io.gravitee.policy.api.PolicyChain; +import io.gravitee.policy.api.PolicyResult; +import io.gravitee.policy.sslenforcement.configuration.CertificateLocation; import io.gravitee.policy.sslenforcement.configuration.SslEnforcementPolicyConfiguration; +import java.net.URLEncoder; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Collections; import javax.net.ssl.SSLPeerUnverifiedException; import javax.net.ssl.SSLSession; import javax.security.auth.x500.X500Principal; +import lombok.SneakyThrows; +import org.assertj.core.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) class SslEnforcementPolicyTest { - private SslEnforcementPolicy policy; - - @Mock - private SslEnforcementPolicyConfiguration configuration; - @Mock private Request request; @@ -53,125 +61,140 @@ class SslEnforcementPolicyTest { @Mock protected PolicyChain policyChain; + @Captor + protected ArgumentCaptor resultCaptor; + @BeforeEach void init() { when(request.sslSession()).thenReturn(sslSession); - - policy = new SslEnforcementPolicy(configuration); - } - - @Test - void shouldGoToNextPolicy() { - policy.onRequest(request, response, policyChain); - - verify(policyChain).doNext(request, response); } @Test - void shouldFail_requiresSsl_withoutSession() { - when(configuration.isRequiresSsl()).thenReturn(true); - when(request.sslSession()).thenReturn(null); - - policy.onRequest(request, response, policyChain); - - verify(policyChain).failWith(argThat(result -> SslEnforcementPolicy.SSL_REQUIRED.equals(result.key()))); - } + void should_go_to_next_policy_when_require_ssl_is_disabled() { + var configuration = SslEnforcementPolicyConfiguration.builder().requiresSsl(false).build(); - @Test - void shouldFail_requiresClientAuthentication_withoutSession() { - when(configuration.isRequiresSsl()).thenReturn(true); - when(configuration.isRequiresClientAuthentication()).thenReturn(true); - - policy.onRequest(request, response, policyChain); - - verify(policyChain).failWith(argThat(result -> SslEnforcementPolicy.AUTHENTICATION_REQUIRED.equals(result.key()))); - } + new SslEnforcementPolicy(configuration).onRequest(request, response, policyChain); - @Test - void shouldFail_whitelistClientCertificate_unknownClient() throws SSLPeerUnverifiedException { - when(configuration.isRequiresSsl()).thenReturn(true); - when(configuration.isRequiresClientAuthentication()).thenReturn(true); - when(configuration.getWhitelistClientCertificates()) - .thenReturn(Collections.singletonList("CN=Duke,OU=JavaSoft,O=Sun Microsystems,C=US")); - when(sslSession.getPeerPrincipal()).thenReturn(new X500Principal("CN=Unknown")); - - policy.onRequest(request, response, policyChain); - - verify(policyChain).failWith(argThat(result -> SslEnforcementPolicy.CLIENT_FORBIDDEN.equals(result.key()))); + verify(policyChain).doNext(request, response); } - @Test - void shouldFail_whitelistClientCertificate_validClient() throws SSLPeerUnverifiedException { - when(configuration.isRequiresSsl()).thenReturn(true); - when(configuration.isRequiresClientAuthentication()).thenReturn(true); - when(configuration.getWhitelistClientCertificates()) - .thenReturn(Collections.singletonList("CN=Duke,OU=JavaSoft,O=Sun Microsystems,C=US")); + @ParameterizedTest + @ValueSource( + strings = { + "CN=Duke,OU=JavaSoft,O=Sun Microsystems,C=US", + "C=US, O=Sun Microsystems, OU=JavaSoft, CN=Duke", + "C=US, O=Sun Microsystems, CN=Duke, OU=JavaSoft", + } + ) + void should_go_to_next_policy_when_consumer_certificate_in_session_is_in_the_whitelist(String whitelist) + throws SSLPeerUnverifiedException { when(sslSession.getPeerPrincipal()).thenReturn(new X500Principal("CN=Duke,OU=JavaSoft,O=Sun Microsystems,C=US")); + var configuration = SslEnforcementPolicyConfiguration + .builder() + .requiresSsl(true) + .requiresClientAuthentication(true) + .whitelistClientCertificates(Collections.singletonList(whitelist)) + .build(); - policy.onRequest(request, response, policyChain); + new SslEnforcementPolicy(configuration).onRequest(request, response, policyChain); verify(policyChain).doNext(request, response); } - @Test - void shouldFail_whitelistClientCertificate_validClient_pattern() throws SSLPeerUnverifiedException { - when(configuration.isRequiresSsl()).thenReturn(true); - when(configuration.isRequiresClientAuthentication()).thenReturn(true); - when(configuration.getWhitelistClientCertificates()).thenReturn(Collections.singletonList("CN=Duke,OU=JavaSoft,O=*,C=US")); + @ParameterizedTest + @ValueSource( + strings = { + "CN=Duke,OU=JavaSoft,O=*,C=US", + "CN=Duke, OU=JavaSoft, O=*, C=??", + "C=US, O=*, OU=JavaSoft, CN=Duke", + "C=??, O=*, OU=JavaSoft, CN=Duke", + } + ) + void should_go_to_next_policy_when_consumer_certificate_in_session_match_to_pattern_in_the_whitelist(String pattern) + throws SSLPeerUnverifiedException { when(sslSession.getPeerPrincipal()).thenReturn(new X500Principal("CN=Duke,OU=JavaSoft,O=Sun Microsystems,C=US")); + var configuration = SslEnforcementPolicyConfiguration + .builder() + .requiresSsl(true) + .requiresClientAuthentication(true) + .whitelistClientCertificates(Collections.singletonList(pattern)) + .build(); - policy.onRequest(request, response, policyChain); + new SslEnforcementPolicy(configuration).onRequest(request, response, policyChain); verify(policyChain).doNext(request, response); } - @Test - void shouldFail_whitelistClientCertificate_validClient_pattern2() throws SSLPeerUnverifiedException { - when(configuration.isRequiresSsl()).thenReturn(true); - when(configuration.isRequiresClientAuthentication()).thenReturn(true); - when(configuration.getWhitelistClientCertificates()).thenReturn(Collections.singletonList("CN=Duke,OU=JavaSoft,O=*,C=??")); - when(sslSession.getPeerPrincipal()).thenReturn(new X500Principal("CN=Duke,OU=JavaSoft,O=Sun Microsystems,C=US")); - - policy.onRequest(request, response, policyChain); + @ParameterizedTest + @ValueSource( + strings = { + "CN=Duke,OU=JavaSoft,O=Sun Microsystems,C=US", + "C=US, O=Sun Microsystems, OU=JavaSoft, CN=Duke", + "C=US, O=Sun Microsystems, CN=Duke, OU=JavaSoft", + } + ) + @SneakyThrows + void should_go_to_next_policy_when_consumer_certificate_in_header_is_in_the_whitelist(String whitelist) { + var certs = loadCertificate(); + HttpHeaders headers = HttpHeaders.create().set("ssl-client-cert", certs); + when(request.headers()).thenReturn(headers); + + var configuration = SslEnforcementPolicyConfiguration + .builder() + .requiresSsl(true) + .requiresClientAuthentication(true) + .whitelistClientCertificates(Collections.singletonList(whitelist)) + .certificateLocation(CertificateLocation.HEADER) + .build(); + + new SslEnforcementPolicy(configuration).onRequest(request, response, policyChain); verify(policyChain).doNext(request, response); } @Test - void shouldFail_whitelistClientCertificate_reorder() throws SSLPeerUnverifiedException { - when(configuration.isRequiresSsl()).thenReturn(true); - when(configuration.isRequiresClientAuthentication()).thenReturn(true); - when(configuration.getWhitelistClientCertificates()).thenReturn(Collections.singletonList("C=FR, O=GraviteeSource, CN=localhost")); - when(sslSession.getPeerPrincipal()).thenReturn(new X500Principal("CN=localhost,O=GraviteeSource,C=FR")); + void should_fail_when_require_ssl_is_enabled_but_no_session() { + when(request.sslSession()).thenReturn(null); + var configuration = SslEnforcementPolicyConfiguration.builder().requiresSsl(true).build(); - policy.onRequest(request, response, policyChain); + new SslEnforcementPolicy(configuration).onRequest(request, response, policyChain); - verify(policyChain).doNext(request, response); + verify(policyChain).failWith(resultCaptor.capture()); + Assertions.assertThat(resultCaptor.getValue().key()).isEqualTo(SslEnforcementPolicy.SSL_REQUIRED); } @Test - void shouldFail_whitelistClientCertificate_reorder_pattern() throws SSLPeerUnverifiedException { - when(configuration.isRequiresSsl()).thenReturn(true); - when(configuration.isRequiresClientAuthentication()).thenReturn(true); - when(configuration.getWhitelistClientCertificates()).thenReturn(Collections.singletonList("C=FR, O=*, CN=localhost")); - when(sslSession.getPeerPrincipal()).thenReturn(new X500Principal("CN=localhost,O=GraviteeSource,C=FR")); + @SneakyThrows + void should_fail_when_require_client_authentication_is_enabled_but_no_certifcate() { + when(sslSession.getPeerPrincipal()).thenReturn(null); + var configuration = SslEnforcementPolicyConfiguration.builder().requiresSsl(true).requiresClientAuthentication(true).build(); - policy.onRequest(request, response, policyChain); + new SslEnforcementPolicy(configuration).onRequest(request, response, policyChain); - verify(policyChain).doNext(request, response); + verify(policyChain).failWith(resultCaptor.capture()); + Assertions.assertThat(resultCaptor.getValue().key()).isEqualTo(SslEnforcementPolicy.AUTHENTICATION_REQUIRED); } @Test - void shouldSuccess_withState() throws SSLPeerUnverifiedException { - when(configuration.isRequiresSsl()).thenReturn(true); - when(configuration.isRequiresClientAuthentication()).thenReturn(true); - when(configuration.getWhitelistClientCertificates()) - .thenReturn(Collections.singletonList("C=FR, O=GraviteeSource, CN=localhost, OU=Eng, L=Lille 1, S=Lille")); - when(sslSession.getPeerPrincipal()) - .thenReturn(new X500Principal("CN=localhost, O=GraviteeSource, C=FR, OU=Eng, L=Lille 1, S=Lille")); + @SneakyThrows + void should_fail_when_the_consumer_certificate_does_not_match_with_the_whitelist() { + when(sslSession.getPeerPrincipal()).thenReturn(new X500Principal("CN=Unknown")); + var configuration = SslEnforcementPolicyConfiguration + .builder() + .requiresSsl(true) + .requiresClientAuthentication(true) + .whitelistClientCertificates(Collections.singletonList("CN=Duke,OU=JavaSoft,O=Sun Microsystems,C=US")) + .build(); - policy.onRequest(request, response, policyChain); + new SslEnforcementPolicy(configuration).onRequest(request, response, policyChain); - verify(policyChain).doNext(request, response); + verify(policyChain).failWith(resultCaptor.capture()); + Assertions.assertThat(resultCaptor.getValue().key()).isEqualTo(SslEnforcementPolicy.CLIENT_FORBIDDEN); + } + + @SneakyThrows + private String loadCertificate() { + var cert = Files.readString(Path.of(requireNonNull(this.getClass().getResource("/cert.pem")).toURI())); + return URLEncoder.encode(cert, Charset.defaultCharset()); } } diff --git a/src/test/java/io/gravitee/policy/sslenforcement/configuration/SslEnforcementPolicyConfigurationTest.java b/src/test/java/io/gravitee/policy/sslenforcement/configuration/SslEnforcementPolicyConfigurationTest.java index 927ef04e..44f88dbe 100644 --- a/src/test/java/io/gravitee/policy/sslenforcement/configuration/SslEnforcementPolicyConfigurationTest.java +++ b/src/test/java/io/gravitee/policy/sslenforcement/configuration/SslEnforcementPolicyConfigurationTest.java @@ -15,15 +15,15 @@ */ package io.gravitee.policy.sslenforcement.configuration; +import static java.util.Objects.requireNonNull; import static org.assertj.core.api.Assertions.assertThat; import io.gravitee.json.validation.InvalidJsonException; import io.gravitee.json.validation.JsonSchemaValidator; import io.gravitee.json.validation.JsonSchemaValidatorImpl; -import java.io.IOException; -import java.io.InputStream; -import java.io.StringWriter; -import org.apache.commons.io.IOUtils; +import java.nio.file.Files; +import java.nio.file.Path; +import lombok.SneakyThrows; import org.json.JSONArray; import org.json.JSONObject; import org.junit.jupiter.api.Assertions; @@ -36,8 +36,6 @@ class SslEnforcementPolicyConfigurationTest { final String schema = loadResource("/schemas/schema-form.json"); - SslEnforcementPolicyConfigurationTest() throws IOException {} - @Test @DisplayName("Should use default values when configuration is empty") void shouldSetDefaultValues() { @@ -46,6 +44,8 @@ void shouldSetDefaultValues() { JSONObject defaultConfig = new JSONObject(); defaultConfig.put("requiresSsl", true); defaultConfig.put("requiresClientAuthentication", false); + defaultConfig.put("certificateLocation", "SESSION"); + defaultConfig.put("certificateHeaderName", "ssl-client-cert"); assertThat(validated).isEqualTo(defaultConfig.toString()); } @@ -84,18 +84,11 @@ void shouldThrowWithInvalidDistinguishedNames() { String config = new JSONObject().put("whitelistClientCertificates", distinguishedNames).toString(); - Assertions.assertThrows( - InvalidJsonException.class, - () -> { - jsonSchemaValidator.validate(schema, config); - } - ); + Assertions.assertThrows(InvalidJsonException.class, () -> jsonSchemaValidator.validate(schema, config)); } - private String loadResource(String resource) throws IOException { - InputStream is = this.getClass().getResourceAsStream(resource); - StringWriter sw = new StringWriter(); - IOUtils.copy(is, sw, "UTF-8"); - return sw.toString(); + @SneakyThrows + private String loadResource(String resource) { + return Files.readString(Path.of(requireNonNull(this.getClass().getResource(resource)).toURI())); } } diff --git a/src/test/resources/cert.pem b/src/test/resources/cert.pem new file mode 100644 index 00000000..ab0ccf7c --- /dev/null +++ b/src/test/resources/cert.pem @@ -0,0 +1,32 @@ +-----BEGIN CERTIFICATE----- +MIIFdTCCA12gAwIBAgIUODffn9exS+PuJT56q99vE8KM/pUwDQYJKoZIhvcNAQEL +BQAwSjELMAkGA1UEBhMCVVMxGTAXBgNVBAoMEFN1biBNaWNyb3N5c3RlbXMxETAP +BgNVBAsMCEphdmFTb2Z0MQ0wCwYDVQQDDAREdWtlMB4XDTI0MDIyMjEzNDEzOVoX +DTM0MDIxOTEzNDEzOVowSjELMAkGA1UEBhMCVVMxGTAXBgNVBAoMEFN1biBNaWNy +b3N5c3RlbXMxETAPBgNVBAsMCEphdmFTb2Z0MQ0wCwYDVQQDDAREdWtlMIICIjAN +BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAp0TNN76qDUvDbSRezyF4TkHbuB6u +looXpz/FdVqD95E32cE8j8nQc7iVhoiPBo4HEvIDKe91csFtQMyQnogxQGLbu+02 +Cy8hm0iUpPsRvlwiNr1nLBIR3L6P6oP1RqjzWvGDUVS6SHDfyE2dWwYTpDymqUjl +bSvitrK5n4ED+eypgUuBaGLJQFKvxNQyC2c8pI9Z+E+VD7wrkRxtQRoPz7eLFhSe +52jBveqTm9l9VRmpcLo0rZCHQ1PhwfuAiF/QzCRmz89T3ap3EusL00LnbeMVRM/H +pZPR5SXhtrtJejCCt7wjHI6n62Zn6nu2P+klUeqO4fFZuK+L79dBWUsPwuAljL79 +Wt1Osvybyd842WcYiU/3b3vXkoEKCqN0Mgm6TL7lcokmCaXntztpO5o4RMuB/9xU +FTujnQpLZCPY+V8iTTRcVpKGfk0BHuWRGW1y1lh496Bp6mCivh7uY9N6N+m5LaJe +EZXb6oVwIxn+t7q6wLneBDqOS0+ELZaHLdV5LTVenQS8e1uVLYAkMAxkKQHgbot8 +w8c2/0iQf1fNQvrEc9Lm9+RW4fzvHujg6Ww3UW/SX5ddxlLPKPjo0z2kTUrJGB/Z +4WmkcMJOQxu+t+CGD6ZfhgmPEUUzsEBuJvMQBrtnmXUzOFhtgVaOs9Edv+Z3XHzB +a3YEEgff6m2ltckCAwEAAaNTMFEwHQYDVR0OBBYEFFTzVB3Kfe7gKIrkCA8nFNbm +a+psMB8GA1UdIwQYMBaAFFTzVB3Kfe7gKIrkCA8nFNbma+psMA8GA1UdEwEB/wQF +MAMBAf8wDQYJKoZIhvcNAQELBQADggIBAAzZwDYDIJJ47marA3FjI/z3EEo/cGIP +kNz83CpCtCSmYS/iqFzXVpmYv6isESycQDaHj7FRerSHBIWgOdlQB+eaIpO0lhzk +9VpWao4vqVXMWEOqQ85A7Ojz2hgtXy0mkpx0441QM6uciuVUQ13fHw4w7tUYrF+T +nLChB5VkR+XnskX1ACQrReAO86F05k8eHLSBVlc2s+k7o+BWy+hVTquQ/38ci+nH +Xwr/Qkz1bl6UGw1vsqlRwhyUOfUR3g3kxQe+JggykescSsEYkTGR663CGNpDnLMs +55Tkgo/kFn/HLOp4++XgckL1GHdTkVkGh5NpstrOnktgf2lftCZEWU0v9k6suyUq +RiQdKfXmSzCIKl+nNYpcPEGgA9jTaa5H7kKEQfvO5fP5D1cj83qDZawqsynaN3TS +2UralembbISPQZI9hwGzt/fOFSKAgKy6oDUl4MivmmKl7aGU/i7ZcT9N2FLsHZMI +H6ml7A08wHRfKWnYorrZ6razk8x33slwHjdMNZ4YcPgoTYYQ0atvcMWZJQQMjqHk +fEA+BG+8v9wqSX3WD2H/V075kf4W8LzY0D6N0kkN2lozL6oywKCXbQhfAj/j5zOG +RMKl9ZPdqf3gT1/SXQWVJzvu2gYZjo3TksmupEYBDnHe28gqdSzrL9n7QRS3pNZJ +sh3xabNkG+BC +-----END CERTIFICATE----- diff --git a/src/test/resources/generate-certificates.sh b/src/test/resources/generate-certificates.sh new file mode 100755 index 00000000..4b65f81d --- /dev/null +++ b/src/test/resources/generate-certificates.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +#set -e + +openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 3650 -nodes -subj "/C=US/O=Sun Microsystems/OU=JavaSoft/CN=Duke" diff --git a/src/test/resources/key.pem b/src/test/resources/key.pem new file mode 100644 index 00000000..e361c6c6 --- /dev/null +++ b/src/test/resources/key.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +x4FCbf7oVOYtQUCOdEiJr/X8xFXSLA== +-----END PRIVATE KEY-----