From eda475093b359ad37a170eea6b71168dff1d6448 Mon Sep 17 00:00:00 2001 From: Petr Weinfurt Date: Thu, 31 Aug 2023 15:32:59 +0200 Subject: [PATCH] feat: Forward client certificate from central gateway to domain gateway in request header (#3046) * Add new filter for re-sending auth source. Signed-off-by: Petr Weinfurt * add license text Signed-off-by: Petr Weinfurt * Read certificate from request header Signed-off-by: at670475 * add integration test Signed-off-by: at670475 * add header to test Signed-off-by: at670475 * add unit test Signed-off-by: at670475 * add error to the log Signed-off-by: at670475 * optmize code and check for certificate in the attibute Signed-off-by: at670475 * Add certificate signature to the additional header Signed-off-by: Petr Weinfurt * Add public rest endpoint to provide JWK set. Signed-off-by: Petr Weinfurt * WellKnownRestController and tests Signed-off-by: Petr Weinfurt * add licenses Signed-off-by: Petr Weinfurt * Add simple integration test Signed-off-by: Petr Weinfurt * Add integration tests for well-known endpoint. Signed-off-by: Petr Weinfurt * Add some javadoc. Signed-off-by: Petr Weinfurt * Fix integration test Signed-off-by: Petr Weinfurt * validate cert Signed-off-by: at670475 * Simplify retrieving the public key. Signed-off-by: Petr Weinfurt * Fix for integration tests Signed-off-by: Petr Weinfurt * Cloud gateway provides certificate chain on public endpoint (instead of public key). Certificate is sent in Client-Cert header. Signed-off-by: Petr Weinfurt * Cloud gateway implementation fixes Signed-off-by: Petr Weinfurt * Categorize certs filter WIP Signed-off-by: Petr Weinfurt * Merge with master branch Signed-off-by: Petr Weinfurt * Fix casting exception Signed-off-by: Petr Weinfurt * Cleanup. Add log messages. Signed-off-by: Petr Weinfurt * Fixes Signed-off-by: Petr Weinfurt * Add tests for CategorizeCertsFilter Signed-off-by: Petr Weinfurt * Add CertificateValidatorTest Signed-off-by: Petr Weinfurt * Add caching trusted certificates Signed-off-by: Petr Weinfurt * Fix message types Signed-off-by: Petr Weinfurt * Add javadoc Signed-off-by: Petr Weinfurt * remove unnecessary dependency Signed-off-by: Petr Weinfurt * Prepare SSL Auth for integration test. Signed-off-by: Petr Weinfurt * Remove obsolete test Signed-off-by: Petr Weinfurt * Add new runtime variables to start.sh files. Signed-off-by: Petr Weinfurt * Add new options to manifest.yaml files Signed-off-by: Petr Weinfurt * fix integration test Signed-off-by: Petr Weinfurt * Update IT Signed-off-by: Petr Weinfurt * Update keystore for IT Signed-off-by: Petr Weinfurt * Update error messages Signed-off-by: Petr Weinfurt * Update common name in the certificate Signed-off-by: Petr Weinfurt * Update common name in the certificate Signed-off-by: Petr Weinfurt * Update common name in the certificate Signed-off-by: Petr Weinfurt * Update common name in the certificate Signed-off-by: Petr Weinfurt * Add tests for CategorizeCertsFilter Signed-off-by: Petr Weinfurt * Add CA certificate to keystore Signed-off-by: Petr Weinfurt * Add unit tests Signed-off-by: Petr Weinfurt * disable forwarded cert in gateway on localhost Signed-off-by: Petr Weinfurt * Resolve code review Signed-off-by: Petr Weinfurt * Increase coverage Signed-off-by: Petr Weinfurt * Externalize the update public keys function to the CategorizeCertsFilter. Update tests. Signed-off-by: Petr Weinfurt * Increase coverage. Signed-off-by: Petr Weinfurt --------- Signed-off-by: Petr Weinfurt Signed-off-by: at670475 Co-authored-by: Andrea Tabone Co-authored-by: achmelo <37397715+achmelo@users.noreply.github.com> --- .github/workflows/containers.yml | 8 +- .../security/SecurityConfiguration.java | 6 +- apiml-security-common/build.gradle | 1 + .../common/filter/CategorizeCertsFilter.java | 92 ++++- .../common/verify/CertificateValidator.java | 86 ++++ .../verify/TrustedCertificatesProvider.java | 97 +++++ .../security-common-log-messages.yml | 42 ++ ...ethodSecurityExpressionControllerTest.java | 2 +- .../filter/CategorizeCertsFilterTest.java | 369 ++++++++++++++++-- .../verify/CertificateValidatorTest.java | 107 +++++ .../TrustedCertificatesProviderTest.java | 192 +++++++++ .../resources/security-service-messages.yml | 45 +++ .../src/main/resources/bin/start.sh | 2 + .../src/main/resources/manifest.yaml | 4 + cloud-gateway-service/build.gradle | 3 + .../config/RoutingConfig.java | 11 +- .../CertificatesRestController.java | 36 ++ .../filters/ClientCertFilterFactory.java | 78 ++++ .../service/CertificateChainService.java | 87 +++++ .../src/main/resources/application.yml | 1 + .../CertificatesRestControllerTest.java | 139 +++++++ .../filters/ClientCertFilterFactoryTest.java | 327 ++++++++++++++++ .../service/CertificateChainServiceTest.java | 166 ++++++++ .../src/test/resources/application.yml | 1 + .../zowe/apiml/security/SecurityUtils.java | 3 +- config/local/gateway-service.yml | 3 + .../src/main/resources/bin/start.sh | 4 + .../src/main/resources/manifest.yaml | 4 + .../config/NewSecurityConfiguration.java | 14 +- .../src/main/resources/application.yml | 2 + .../src/main/resources/ehcache.xml | 1 + .../schemes/PassticketSchemeTest.java | 6 +- .../schemes/X509SchemeTest.java | 2 +- .../proxy/CloudGatewayProxyTest.java | 110 +++++- .../zowe/apiml/util/requests/Endpoints.java | 2 + keystore/docker/all-services.ext | 2 + keystore/docker/all-services.keystore.p12 | Bin 4349 -> 5709 bytes 37 files changed, 1986 insertions(+), 69 deletions(-) create mode 100644 apiml-security-common/src/main/java/org/zowe/apiml/security/common/verify/CertificateValidator.java create mode 100644 apiml-security-common/src/main/java/org/zowe/apiml/security/common/verify/TrustedCertificatesProvider.java create mode 100644 apiml-security-common/src/test/java/org/zowe/apiml/security/common/verify/CertificateValidatorTest.java create mode 100644 apiml-security-common/src/test/java/org/zowe/apiml/security/common/verify/TrustedCertificatesProviderTest.java create mode 100644 cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/controller/CertificatesRestController.java create mode 100644 cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/ClientCertFilterFactory.java create mode 100644 cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/CertificateChainService.java create mode 100644 cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/controller/CertificatesRestControllerTest.java create mode 100644 cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/filters/ClientCertFilterFactoryTest.java create mode 100644 cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/CertificateChainServiceTest.java diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml index 2378664714..43086a452e 100644 --- a/.github/workflows/containers.yml +++ b/.github/workflows/containers.yml @@ -140,6 +140,9 @@ jobs: image: ghcr.io/balhar-jakub/discovery-service:${{ github.run_id }}-${{ github.run_number }} gateway-service: image: ghcr.io/balhar-jakub/gateway-service:${{ github.run_id }}-${{ github.run_number }} + env: + APIML_SECURITY_X509_ACCEPTFORWARDEDCERT: true + APIML_SECURITY_X509_CERTIFICATESURL: https://cloud-gateway-service:10023/gateway/certificates gateway-service-2: image: ghcr.io/balhar-jakub/gateway-service:${{ github.run_id }}-${{ github.run_number }} env: @@ -150,11 +153,12 @@ jobs: image: ghcr.io/balhar-jakub/cloud-gateway-service:${{ github.run_id }}-${{ github.run_number }} env: APIML_GATEWAY_TIMEOUT: 2 + APIML_SERVICE_FORWARDCLIENTCERTENABLED: true discoverable-client: image: ghcr.io/balhar-jakub/discoverable-client:${{ github.run_id }}-${{ github.run_number }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: ref: ${{ github.head_ref }} @@ -198,7 +202,7 @@ jobs: mock-services: image: ghcr.io/balhar-jakub/mock-services:${{ github.run_id }}-${{ github.run_number }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: ref: ${{ github.head_ref }} diff --git a/api-catalog-services/src/main/java/org/zowe/apiml/apicatalog/security/SecurityConfiguration.java b/api-catalog-services/src/main/java/org/zowe/apiml/apicatalog/security/SecurityConfiguration.java index 92daa8ea06..1f48ea2206 100644 --- a/api-catalog-services/src/main/java/org/zowe/apiml/apicatalog/security/SecurityConfiguration.java +++ b/api-catalog-services/src/main/java/org/zowe/apiml/apicatalog/security/SecurityConfiguration.java @@ -45,6 +45,7 @@ import org.zowe.apiml.security.common.filter.CategorizeCertsFilter; import org.zowe.apiml.security.common.login.LoginFilter; import org.zowe.apiml.security.common.login.ShouldBeAlreadyAuthenticatedFilter; +import org.zowe.apiml.security.common.verify.CertificateValidator; import java.util.Collections; import java.util.Set; @@ -69,6 +70,7 @@ public class SecurityConfiguration { private final HandlerInitializer handlerInitializer; private final GatewayLoginProvider gatewayLoginProvider; private final GatewayTokenProvider gatewayTokenProvider; + private final CertificateValidator certificateValidator; @Qualifier("publicKeyCertificatesBase64") private final Set publicKeyCertificatesBase64; @Value("${server.attls.enabled:false}") @@ -124,9 +126,9 @@ private UserDetailsService x509UserDetailsService() { } private CategorizeCertsFilter reversedCategorizeCertFilter() { - CategorizeCertsFilter out = new CategorizeCertsFilter(publicKeyCertificatesBase64); + CategorizeCertsFilter out = new CategorizeCertsFilter(publicKeyCertificatesBase64, certificateValidator); out.setCertificateForClientAuth(crt -> out.getPublicKeyCertificatesBase64().contains(out.base64EncodePublicKey(crt))); - out.setNotCertificateForClientAuth(crt -> !out.getPublicKeyCertificatesBase64().contains(out.base64EncodePublicKey(crt))); + out.setApimlCertificate(crt -> !out.getPublicKeyCertificatesBase64().contains(out.base64EncodePublicKey(crt))); return out; } } diff --git a/apiml-security-common/build.gradle b/apiml-security-common/build.gradle index 8fff72e95f..3cc9afb463 100644 --- a/apiml-security-common/build.gradle +++ b/apiml-security-common/build.gradle @@ -10,6 +10,7 @@ dependencies { implementation libs.apache.commons.lang3 implementation libs.bcprov; implementation libs.bcpkix; + implementation libs.http.client; implementation libs.spring.aop implementation libs.spring.beans diff --git a/apiml-security-common/src/main/java/org/zowe/apiml/security/common/filter/CategorizeCertsFilter.java b/apiml-security-common/src/main/java/org/zowe/apiml/security/common/filter/CategorizeCertsFilter.java index 00a6ae79aa..86ca52bb55 100644 --- a/apiml-security-common/src/main/java/org/zowe/apiml/security/common/filter/CategorizeCertsFilter.java +++ b/apiml-security-common/src/main/java/org/zowe/apiml/security/common/filter/CategorizeCertsFilter.java @@ -10,21 +10,28 @@ package org.zowe.apiml.security.common.filter; +import lombok.Getter; import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; +import org.zowe.apiml.message.log.ApimlLogger; +import org.zowe.apiml.product.logging.annotations.InjectApimlLogger; +import org.zowe.apiml.security.common.verify.CertificateValidator; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; import javax.servlet.http.HttpServletResponse; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; -import java.util.Arrays; -import java.util.Base64; -import java.util.Set; +import java.util.*; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -38,28 +45,91 @@ public class CategorizeCertsFilter extends OncePerRequestFilter { private static final String ATTRNAME_CLIENT_AUTH_X509_CERTIFICATE = "client.auth.X509Certificate"; private static final String ATTRNAME_JAVAX_SERVLET_REQUEST_X509_CERTIFICATE = "javax.servlet.request.X509Certificate"; private static final String LOG_FORMAT_FILTERING_CERTIFICATES = "Filtering certificates: {} -> {}"; + private static final String CLIENT_CERT_HEADER = "Client-Cert"; + @InjectApimlLogger + private final ApimlLogger apimlLog = ApimlLogger.empty(); + + @Getter private final Set publicKeyCertificatesBase64; - public Set getPublicKeyCertificatesBase64() { - return publicKeyCertificatesBase64; - } + private final CertificateValidator certificateValidator; /** * Get certificates from request (if exists), separate them (to use only APIML certificate to request sign and * other for authentication) and store again into request. + * If authentication via certificate in header is enabled, get client certificate from the Client-Cert header + * only if the request certificates are all trusted and validated by {@link CertificateValidator} service. + * The client certificate is then stored in a separate custom request attribute. The APIML certificates stays + * in the default attribute. * * @param request Request to filter certificates */ private void categorizeCerts(ServletRequest request) { X509Certificate[] certs = (X509Certificate[]) request.getAttribute(ATTRNAME_JAVAX_SERVLET_REQUEST_X509_CERTIFICATE); if (certs != null) { + if (certificateValidator.isForwardingEnabled() && certificateValidator.isTrusted(certs)) { + certificateValidator.updateAPIMLPublicKeyCertificates(certs); + Optional clientCert = getClientCertFromHeader((HttpServletRequest) request); + if (clientCert.isPresent()) { + // add the client certificate to the certs array + String subjectDN = ((X509Certificate) clientCert.get()).getSubjectX500Principal().getName(); + log.debug("Found client certificate in header, adding it to the request. Subject DN: {}", subjectDN); + certs = Arrays.copyOf(certs, certs.length + 1); + certs[certs.length - 1] = (X509Certificate) clientCert.get(); + } + } + request.setAttribute(ATTRNAME_CLIENT_AUTH_X509_CERTIFICATE, selectCerts(certs, certificateForClientAuth)); - request.setAttribute(ATTRNAME_JAVAX_SERVLET_REQUEST_X509_CERTIFICATE, selectCerts(certs, notCertificateForClientAuth)); + request.setAttribute(ATTRNAME_JAVAX_SERVLET_REQUEST_X509_CERTIFICATE, selectCerts(certs, apimlCertificate)); log.debug(LOG_FORMAT_FILTERING_CERTIFICATES, ATTRNAME_CLIENT_AUTH_X509_CERTIFICATE, request.getAttribute(ATTRNAME_CLIENT_AUTH_X509_CERTIFICATE)); } } + private Optional getClientCertFromHeader(HttpServletRequest request) { + String certFromHeader = request.getHeader(CLIENT_CERT_HEADER); + + if (StringUtils.isNotEmpty(certFromHeader)) { + try { + Certificate certificate = CertificateFactory + .getInstance("X.509") + .generateCertificate(new ByteArrayInputStream(Base64.getDecoder().decode(certFromHeader))); + return Optional.of(certificate); + } catch (Exception e) { + apimlLog.log("org.zowe.apiml.security.common.filter.errorParsingCertificate", e.getMessage(), certFromHeader); + } + } + return Optional.empty(); + } + + /** + * Wraps the http servlet request into wrapper which makes sure that the Client-Cert header + * will not be accessible anymore from the request + * + * @param originalRequest incoming original http request object + * @return wrapped http request object with overridden functions + */ + private HttpServletRequest mutate(HttpServletRequest originalRequest) { + return new HttpServletRequestWrapper(originalRequest) { + + @Override + public String getHeader(String name) { + if (CLIENT_CERT_HEADER.equalsIgnoreCase(name)) { + return null; + } + return super.getHeader(name); + } + + @Override + public Enumeration getHeaders(String name) { + if (CLIENT_CERT_HEADER.equalsIgnoreCase(name)) { + return Collections.enumeration(new LinkedList<>()); + } + return super.getHeaders(name); + } + }; + } + /** * This filter removes all certificates in attribute "javax.servlet.request.X509Certificate" which has no relations * with private certificate of apiml and then call original implementation (without "foreign" certificates) @@ -71,7 +141,7 @@ private void categorizeCerts(ServletRequest request) { @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { categorizeCerts(request); - filterChain.doFilter(request, response); + filterChain.doFilter(mutate(request), response); } private X509Certificate[] selectCerts(X509Certificate[] certs, Predicate test) { @@ -88,12 +158,12 @@ public void setCertificateForClientAuth(Predicate certificateFo this.certificateForClientAuth = certificateForClientAuth; } - public void setNotCertificateForClientAuth(Predicate notCertificateForClientAuth) { - this.notCertificateForClientAuth = notCertificateForClientAuth; + public void setApimlCertificate(Predicate apimlCertificate) { + this.apimlCertificate = apimlCertificate; } Predicate certificateForClientAuth = crt -> !getPublicKeyCertificatesBase64().contains(base64EncodePublicKey(crt)); - Predicate notCertificateForClientAuth = crt -> getPublicKeyCertificatesBase64().contains(base64EncodePublicKey(crt)); + Predicate apimlCertificate = crt -> getPublicKeyCertificatesBase64().contains(base64EncodePublicKey(crt)); } diff --git a/apiml-security-common/src/main/java/org/zowe/apiml/security/common/verify/CertificateValidator.java b/apiml-security-common/src/main/java/org/zowe/apiml/security/common/verify/CertificateValidator.java new file mode 100644 index 0000000000..fcf0c67d25 --- /dev/null +++ b/apiml-security-common/src/main/java/org/zowe/apiml/security/common/verify/CertificateValidator.java @@ -0,0 +1,86 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.security.common.verify; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.zowe.apiml.message.log.ApimlLogger; +import org.zowe.apiml.product.logging.annotations.InjectApimlLogger; + +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.List; +import java.util.Set; + +/** + * Service to verify if given certificate chain can be trusted. + */ +@Service +@Slf4j +public class CertificateValidator { + + final TrustedCertificatesProvider trustedCertificatesProvider; + + @InjectApimlLogger + private final ApimlLogger apimlLog = ApimlLogger.empty(); + + @Getter + @Value("${apiml.security.x509.acceptForwardedCert:false}") + private boolean forwardingEnabled; + + @Value("${apiml.security.x509.certificatesUrl:}") + private String proxyCertificatesEndpoint; + private final Set publicKeyCertificatesBase64; + + + @Autowired + public CertificateValidator(TrustedCertificatesProvider trustedCertificatesProvider, + @Qualifier("publicKeyCertificatesBase64") Set publicKeyCertificatesBase64) { + this.trustedCertificatesProvider = trustedCertificatesProvider; + this.publicKeyCertificatesBase64 = publicKeyCertificatesBase64; + } + + /** + * Compare given certificates with a list of trusted certs. + * + * @param certs Certificates to compare with known trusted ones + * @return true if all given certificates are known false otherwise + */ + public boolean isTrusted(X509Certificate[] certs) { + List trustedCerts = trustedCertificatesProvider.getTrustedCerts(proxyCertificatesEndpoint); + for (X509Certificate cert : certs) { + if (!trustedCerts.contains(cert)) { + apimlLog.log("org.zowe.apiml.security.common.verify.untrustedCert"); + log.debug("Untrusted certificate is {}", cert); + return false; + } + } + log.debug("All certificates are trusted."); + return true; + } + + /** + * Updates the list of public keys from certificates that belong to APIML + * + * @param certs List of certificates coming from the central Gateway + */ + public void updateAPIMLPublicKeyCertificates(X509Certificate[] certs) { + for (X509Certificate cert : certs) { + String publicKey = Base64.getEncoder().encodeToString(cert.getPublicKey().getEncoded()); + publicKeyCertificatesBase64.add(publicKey); + } + } +} diff --git a/apiml-security-common/src/main/java/org/zowe/apiml/security/common/verify/TrustedCertificatesProvider.java b/apiml-security-common/src/main/java/org/zowe/apiml/security/common/verify/TrustedCertificatesProvider.java new file mode 100644 index 0000000000..3cbc430898 --- /dev/null +++ b/apiml-security-common/src/main/java/org/zowe/apiml/security/common/verify/TrustedCertificatesProvider.java @@ -0,0 +1,97 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.security.common.verify; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.util.EntityUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.zowe.apiml.message.log.ApimlLogger; +import org.zowe.apiml.product.logging.annotations.InjectApimlLogger; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.util.*; + +@Service +@Slf4j +public class TrustedCertificatesProvider { + + private final CloseableHttpClient httpClient; + + @InjectApimlLogger + private final ApimlLogger apimlLog = ApimlLogger.empty(); + + @Autowired + public TrustedCertificatesProvider(@Qualifier("secureHttpClientWithoutKeystore") CloseableHttpClient httpClient) { + this.httpClient = httpClient; + } + + /** + * Query given rest endpoint to get the certificate chain from remote proxy gateway. + * The endpoint should be publicly available and should provide the certificate chain in PEM format. + * + * @param certificatesEndpoint Given full URL to the remote proxy gateway certificates endpoint + * @return List of certificates or empty list + */ + @Cacheable(value = "trustedCertificates", key = "#certificatesEndpoint", unless = "#result.isEmpty()") + public List getTrustedCerts(String certificatesEndpoint) { + List trustedCerts = new ArrayList<>(); + String pem = callCertificatesEndpoint(certificatesEndpoint); + if (StringUtils.isNotEmpty(pem)) { + try { + Collection certs = CertificateFactory + .getInstance("X.509") + .generateCertificates(new ByteArrayInputStream(pem.getBytes())); + trustedCerts.addAll(certs); + } catch (Exception e) { + apimlLog.log("org.zowe.apiml.security.common.verify.errorParsingCertificates", e.getMessage()); + } + } + return trustedCerts; + } + + private String callCertificatesEndpoint(String url) { + try { + HttpGet httpGet = new HttpGet(new URI(url)); + HttpResponse httpResponse = httpClient.execute(httpGet); + final int statusCode = httpResponse.getStatusLine() != null ? httpResponse.getStatusLine().getStatusCode() : 0; + String body = ""; + if (httpResponse.getEntity() != null) { + body = EntityUtils.toString(httpResponse.getEntity(), StandardCharsets.UTF_8); + } + if (statusCode != HttpStatus.SC_OK) { + apimlLog.log("org.zowe.apiml.security.common.verify.invalidResponse", url, statusCode, body); + return null; + } + log.debug("Trusted certificates from {}: {}", url, body); + return body; + + } catch (URISyntaxException e) { + apimlLog.log("org.zowe.apiml.security.common.verify.invalidURL", e.getMessage()); + } catch (IOException e) { + apimlLog.log("org.zowe.apiml.security.common.verify.httpError", e.getMessage()); + } + return null; + } +} diff --git a/apiml-security-common/src/main/resources/security-common-log-messages.yml b/apiml-security-common/src/main/resources/security-common-log-messages.yml index 20ff2f50b2..9f3b7af6ec 100644 --- a/apiml-security-common/src/main/resources/security-common-log-messages.yml +++ b/apiml-security-common/src/main/resources/security-common-log-messages.yml @@ -88,6 +88,48 @@ messages: # TLS,Certificate messages # 500-599 + - key: org.zowe.apiml.security.common.filter.errorParsingCertificate + number: ZWEAT500 + type: ERROR + text: "Failed to parse the client certificate forwarded from the central Gateway. Error message %s. The client certificate was %s" + reason: "The string sent by the central Gateway was not recognized as valid DER-encoded certificate in the Base64 printable form." + action: "Ensure that the forwarding of client certificate is enabled also in the central Gateway. Check for any error messages from the central Gateway." + + - key: org.zowe.apiml.security.common.verify.invalidResponse + number: ZWEAT501 + type: ERROR + text: "Failed to get trusted certificates from the central Gateway. Unexpected response from %s endpoint. Status code: %s. Response body: %s" + reason: "The response status code is different from expected 200 OK." + action: "Ensure that the parameter apiml.security.x509.certificatesUrl is correctly configured with the complete URL to the central Gateway certificates endpoint. Test the URL manually." + + - key: org.zowe.apiml.security.common.verify.invalidURL + number: ZWEAT502 + type: ERROR + text: "Invalid URL specified to get trusted certificates from the central Gateway. Error message: %s" + reason: "The parameter apiml.security.x509.certificatesUrl is not correctly configured with the complete URL to the central Gateway certificates endpoint." + action: "Ensure that the parameter apiml.security.x509.certificatesUrl is correctly configured." + + - key: org.zowe.apiml.security.common.verify.httpError + number: ZWEAT503 + type: ERROR + text: "An error occurred during retrieval of trusted certificates from the central Gateway. Error message: %s" + reason: "The communication with the cloud gateway got interrupted or an error occurred during processing the response." + action: "Check the provided error message. Contact the support." + + - key: org.zowe.apiml.security.common.verify.errorParsingCertificates + number: ZWEAT504 + type: ERROR + text: "Failed to parse the trusted certificates provided by the central Gateway. Error message %s" + reason: "The string sent by the central Gateway was not recognized as valid DER-encoded certificates in the Base64 printable form." + action: "Check that the URL configured in apiml.security.x509.certificatesUrl responds with valid DER-encoded certificates in the Base64 printable form." + + - key: org.zowe.apiml.security.common.verify.untrustedCert + number: ZWEAT505 + type: ERROR + text: "Incoming request certificate is not one of the trusted certificates provided by the central Gateway." + reason: "The Gateway performs additional check of request certificates when the central Gateway forwards incoming client certificate to the domain Gateway. This check may fail when the certificatesUrl parameter does not point to proper central Gateway certificates endpoint." + action: "Check that the URL configured in apiml.security.x509.certificatesUrl points to the central Gateway and it responds with valid DER-encoded certificates in the Base64 printable form." + # Various messages # 600-699 diff --git a/apiml-security-common/src/test/java/org/zowe/apiml/security/common/auth/SafMethodSecurityExpressionControllerTest.java b/apiml-security-common/src/test/java/org/zowe/apiml/security/common/auth/SafMethodSecurityExpressionControllerTest.java index b3348360f6..490568de86 100644 --- a/apiml-security-common/src/test/java/org/zowe/apiml/security/common/auth/SafMethodSecurityExpressionControllerTest.java +++ b/apiml-security-common/src/test/java/org/zowe/apiml/security/common/auth/SafMethodSecurityExpressionControllerTest.java @@ -123,7 +123,7 @@ public ObjectMapper objectMapper() { @Bean public MessageService messageService() { - return new YamlMessageService("/security-common-log-messages.yml"); + return new YamlMessageService("/security-service-messages.yml"); } @Bean diff --git a/apiml-security-common/src/test/java/org/zowe/apiml/security/common/filter/CategorizeCertsFilterTest.java b/apiml-security-common/src/test/java/org/zowe/apiml/security/common/filter/CategorizeCertsFilterTest.java index 664d10eeca..09ef5558db 100644 --- a/apiml-security-common/src/test/java/org/zowe/apiml/security/common/filter/CategorizeCertsFilterTest.java +++ b/apiml-security-common/src/test/java/org/zowe/apiml/security/common/filter/CategorizeCertsFilterTest.java @@ -10,21 +10,27 @@ package org.zowe.apiml.security.common.filter; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; import org.zowe.apiml.security.common.utils.X509Utils; +import org.zowe.apiml.security.common.verify.CertificateValidator; -import javax.servlet.FilterChain; import javax.servlet.ServletException; -import javax.servlet.ServletRequest; -import javax.servlet.ServletResponse; -import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletRequest; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.util.Arrays; -import java.util.Collections; +import java.util.Base64; import java.util.HashSet; import static org.junit.jupiter.api.Assertions.*; @@ -32,17 +38,53 @@ class CategorizeCertsFilterTest { + private static final String CLIENT_CERT_HEADER = "Client-Cert"; + private static final String CLIENT_CERT_HEADER_VALUE = + "MIIEFTCCAv2gAwIBAgIEKWdbVTANBgkqhkiG9w0BAQsFADCBjDELMAkGA1UEBhMC" + + "Q1oxDTALBgNVBAgTBEJybm8xDTALBgNVBAcTBEJybm8xFDASBgNVBAoTC1pvd2Ug" + + "U2FtcGxlMRwwGgYDVQQLExNBUEkgTWVkaWF0aW9uIExheWVyMSswKQYDVQQDEyJa" + + "b3dlIFNlbGYtU2lnbmVkIFVudHJ1c3RlZCBTZXJ2aWNlMB4XDTE4MTIwNzIwMDc1" + + "MloXDTI4MTIwNDIwMDc1MlowgYwxCzAJBgNVBAYTAkNaMQ0wCwYDVQQIEwRCcm5v" + + "MQ0wCwYDVQQHEwRCcm5vMRQwEgYDVQQKEwtab3dlIFNhbXBsZTEcMBoGA1UECxMT" + + "QVBJIE1lZGlhdGlvbiBMYXllcjErMCkGA1UEAxMiWm93ZSBTZWxmLVNpZ25lZCBV" + + "bnRydXN0ZWQgU2VydmljZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB" + + "AJti8p4nr8ztRSbemrAv1ytVLQMbXozhLe3lNaiVADGTFPZYeJ2lDt7oAl238HOY" + + "ScpOz+JjTeUkL0jsjNYgMhi4J07II/3sJL0SBfVqvvgjUL4BvcpdBl0crSuI/3D4" + + "OaPue+ZmPFijwdCcw5JbazMoOka/zUwpYYdbwxPUH2BbKfwtmmygX88nkJcRSoQO" + + "KBdNsUs+QRuUiokZ/FJi7uiOsNZ8eEfQv6qJ7mOJ7l1IrMcNm3jHgodoQi/4jXO1" + + "np/hZaz/ZDni9kBwcyd64AViB2v7VrrBmjdESt1mtCIMvKMlwAZAqrDO75Q9pepO" + + "Y7zbN4s9s7IUfyb9431xg2MCAwEAAaN9MHswHQYDVR0lBBYwFAYIKwYBBQUHAwIG" + + "CCsGAQUFBwMBMA4GA1UdDwEB/wQEAwIE8DArBgNVHREEJDAighVsb2NhbGhvc3Qu" + + "bG9jYWxkb21haW6CCWxvY2FsaG9zdDAdBgNVHQ4EFgQUIeSN7aNtwH2MnBAGDLre" + + "TtcSaZ4wDQYJKoZIhvcNAQELBQADggEBAELPbHlG60nO164yrBjZcpQJ/2e5ThOR" + + "8efXUWExuy/NpwVx0vJg4tb8s9NI3X4pRh3WyD0uGPGkO9w+CAvgUaECePLYjkov" + + "KIS6Cvlcav9nWqdZau1fywltmOLu8Sq5i42Yvb7ZcPOEwDShpuq0ql7LR7j7P4XH" + + "+JkA0k9Zi6RfYJAyOOpbD2R4JoMbxBKrxUVs7cEajl2ltckjyRWoB6FBud1IthRR" + + "mZoPMtlCleKlsKp7yJiE13hpX+qIGnzEQE2gNgQ94dSl4m2xO6pnyDRMAEncmd33" + + "oehy77omRxNsLzkWe6mjaC8ShMGzG9jYR02iN2h4083/PVXvTZIqwhg="; + private static Certificate clientCertfromHeader; private CategorizeCertsFilter filter; - private ServletRequest request; - private ServletResponse response; - private FilterChain chain; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + private MockFilterChain chain; private X509Certificate[] certificates; + private CertificateValidator certificateValidator; + + @BeforeAll + public static void init() throws CertificateException { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + InputStream certStream = new ByteArrayInputStream(Base64.getDecoder().decode(CLIENT_CERT_HEADER_VALUE)); + clientCertfromHeader = cf.generateCertificate(certStream); + } @BeforeEach public void setUp() { request = new MockHttpServletRequest(); - response = mock(HttpServletResponse.class); - chain = mock(FilterChain.class); + response = new MockHttpServletResponse(); + chain = new MockFilterChain(); + certificateValidator = mock(CertificateValidator.class); + when(certificateValidator.isForwardingEnabled()).thenReturn(false); + when(certificateValidator.isTrusted(any())).thenReturn(false); } @Nested @@ -50,7 +92,7 @@ class GivenNoPublicKeysInFilter { @BeforeEach void setUp() { - filter = new CategorizeCertsFilter(Collections.emptySet()); + filter = new CategorizeCertsFilter(new HashSet<>(), certificateValidator); } @Nested @@ -59,10 +101,35 @@ class WhenNoCertificatesInRequest { @Test void thenRequestNotChanged() throws IOException, ServletException { filter.doFilter(request, response, chain); + HttpServletRequest nextRequest = (HttpServletRequest) chain.getRequest(); + assertNotNull(nextRequest); - assertNull(request.getAttribute("javax.servlet.request.X509Certificate")); - assertNull(request.getAttribute("client.auth.X509Certificate")); - verify(chain, times(1)).doFilter(request, response); + assertNull(nextRequest.getAttribute("javax.servlet.request.X509Certificate")); + assertNull(nextRequest.getAttribute("client.auth.X509Certificate")); + assertNull(nextRequest.getHeader(CLIENT_CERT_HEADER)); + assertFalse(nextRequest.getHeaders(CLIENT_CERT_HEADER).hasMoreElements()); + } + + @Nested + class WhenForwardingEnabled { + + @BeforeEach + void setUp() { + when(certificateValidator.isForwardingEnabled()).thenReturn(true); + when(certificateValidator.isTrusted(any())).thenReturn(true); + } + + @Test + void thenRequestNotChanged() throws ServletException, IOException { + filter.doFilter(request, response, chain); + HttpServletRequest nextRequest = (HttpServletRequest) chain.getRequest(); + assertNotNull(nextRequest); + + assertNull(nextRequest.getAttribute("javax.servlet.request.X509Certificate")); + assertNull(nextRequest.getAttribute("client.auth.X509Certificate")); + assertNull(nextRequest.getHeader(CLIENT_CERT_HEADER)); + assertFalse(nextRequest.getHeaders(CLIENT_CERT_HEADER).hasMoreElements()); + } } } @@ -83,38 +150,182 @@ void setUp() { @Test void thenAllClientCertificates() throws IOException, ServletException { filter.doFilter(request, response, chain); + HttpServletRequest nextRequest = (HttpServletRequest) chain.getRequest(); + assertNotNull(nextRequest); - X509Certificate[] apimlCerts = (X509Certificate[]) request.getAttribute("javax.servlet.request.X509Certificate"); + X509Certificate[] apimlCerts = (X509Certificate[]) nextRequest.getAttribute("javax.servlet.request.X509Certificate"); assertNotNull(apimlCerts); assertEquals(0, apimlCerts.length); - X509Certificate[] clientCerts = (X509Certificate[]) request.getAttribute("client.auth.X509Certificate"); + X509Certificate[] clientCerts = (X509Certificate[]) nextRequest.getAttribute("client.auth.X509Certificate"); assertNotNull(clientCerts); assertEquals(4, clientCerts.length); assertArrayEquals(certificates, clientCerts); - verify(chain, times(1)).doFilter(request, response); + assertNull(nextRequest.getHeader(CLIENT_CERT_HEADER)); + assertFalse(nextRequest.getHeaders(CLIENT_CERT_HEADER).hasMoreElements()); } @Test void thenAllApimlCertificatesWithReversedLogic() throws IOException, ServletException { filter.setCertificateForClientAuth(crt -> filter.getPublicKeyCertificatesBase64().contains(filter.base64EncodePublicKey(crt))); - filter.setNotCertificateForClientAuth(crt -> !filter.getPublicKeyCertificatesBase64().contains(filter.base64EncodePublicKey(crt))); + filter.setApimlCertificate(crt -> !filter.getPublicKeyCertificatesBase64().contains(filter.base64EncodePublicKey(crt))); filter.doFilter(request, response, chain); + HttpServletRequest nextRequest = (HttpServletRequest) chain.getRequest(); + assertNotNull(nextRequest); - X509Certificate[] cientCerts = (X509Certificate[]) request.getAttribute("client.auth.X509Certificate"); + X509Certificate[] cientCerts = (X509Certificate[]) nextRequest.getAttribute("client.auth.X509Certificate"); assertNotNull(cientCerts); assertEquals(0, cientCerts.length); - X509Certificate[] apimlCerts = (X509Certificate[]) request.getAttribute("javax.servlet.request.X509Certificate"); + X509Certificate[] apimlCerts = (X509Certificate[]) nextRequest.getAttribute("javax.servlet.request.X509Certificate"); assertNotNull(apimlCerts); assertEquals(4, apimlCerts.length); assertArrayEquals(certificates, apimlCerts); - verify(chain, times(1)).doFilter(request, response); + assertNull(nextRequest.getHeader(CLIENT_CERT_HEADER)); + assertFalse(nextRequest.getHeaders(CLIENT_CERT_HEADER).hasMoreElements()); + } + + @Nested + class WhenCertificateInHeaderAndForwardingEnabled { + + @BeforeEach + public void setUp() { + request.addHeader(CLIENT_CERT_HEADER, CLIENT_CERT_HEADER_VALUE); + when(certificateValidator.isForwardingEnabled()).thenReturn(true); + } + + @Test + void givenTrustedCerts_thenClientCertHeaderAccepted() throws ServletException, IOException { + when(certificateValidator.isTrusted(certificates)).thenReturn(true); + // when incoming certs are all trusted means that all their public keys are added to the filter + filter.getPublicKeyCertificatesBase64().add(X509Utils.correctBase64("foreignCert1")); + filter.getPublicKeyCertificatesBase64().add(X509Utils.correctBase64("foreignCert2")); + filter.getPublicKeyCertificatesBase64().add(X509Utils.correctBase64("apimlCert1")); + filter.getPublicKeyCertificatesBase64().add(X509Utils.correctBase64("apimlCert2")); + + filter.doFilter(request, response, chain); + HttpServletRequest nextRequest = (HttpServletRequest) chain.getRequest(); + assertNotNull(nextRequest); + + X509Certificate[] apimlCerts = (X509Certificate[]) nextRequest.getAttribute("javax.servlet.request.X509Certificate"); + assertNotNull(apimlCerts); + assertEquals(4, apimlCerts.length); + assertArrayEquals(certificates, apimlCerts); + + X509Certificate[] clientCerts = (X509Certificate[]) nextRequest.getAttribute("client.auth.X509Certificate"); + assertNotNull(clientCerts); + assertEquals(1, clientCerts.length); + assertSame(clientCertfromHeader, clientCerts[0]); + + assertNull(nextRequest.getHeader(CLIENT_CERT_HEADER)); + assertFalse(nextRequest.getHeaders(CLIENT_CERT_HEADER).hasMoreElements()); + } + + @Test + void givenNotTrustedCerts_thenClientCertHeaderIgnored() throws ServletException, IOException { + when(certificateValidator.isTrusted(certificates)).thenReturn(false); + filter.doFilter(request, response, chain); + HttpServletRequest nextRequest = (HttpServletRequest) chain.getRequest(); + assertNotNull(nextRequest); + + X509Certificate[] apimlCerts = (X509Certificate[]) nextRequest.getAttribute("javax.servlet.request.X509Certificate"); + assertNotNull(apimlCerts); + assertEquals(0, apimlCerts.length); + + X509Certificate[] clientCerts = (X509Certificate[]) nextRequest.getAttribute("client.auth.X509Certificate"); + assertNotNull(clientCerts); + assertEquals(4, clientCerts.length); + assertArrayEquals(certificates, clientCerts); + + assertNull(nextRequest.getHeader(CLIENT_CERT_HEADER)); + assertFalse(nextRequest.getHeaders(CLIENT_CERT_HEADER).hasMoreElements()); + } + } + + @Nested + class WhenCertificateInHeaderAndForwardingDisabled { + + @BeforeEach + public void setUp() { + request.addHeader(CLIENT_CERT_HEADER, CLIENT_CERT_HEADER_VALUE); + when(certificateValidator.isForwardingEnabled()).thenReturn(false); + } + + @Test + void thenClientCertHeaderIgnored() throws ServletException, IOException { + filter.doFilter(request, response, chain); + HttpServletRequest nextRequest = (HttpServletRequest) chain.getRequest(); + assertNotNull(nextRequest); + + X509Certificate[] apimlCerts = (X509Certificate[]) nextRequest.getAttribute("javax.servlet.request.X509Certificate"); + assertNotNull(apimlCerts); + assertEquals(0, apimlCerts.length); + + X509Certificate[] clientCerts = (X509Certificate[]) nextRequest.getAttribute("client.auth.X509Certificate"); + assertNotNull(clientCerts); + assertEquals(4, clientCerts.length); + assertArrayEquals(certificates, clientCerts); + + assertNull(nextRequest.getHeader(CLIENT_CERT_HEADER)); + assertFalse(nextRequest.getHeaders(CLIENT_CERT_HEADER).hasMoreElements()); + } + } + + @Nested + class WhenInvalidCertificateInHeaderAndForwardingEnabled { + + @BeforeEach + public void setUp() { + request.addHeader(CLIENT_CERT_HEADER, "invalid_cert"); + when(certificateValidator.isForwardingEnabled()).thenReturn(true); + when(certificateValidator.isTrusted(certificates)).thenReturn(true); + } + + @Test + void thenCertificateInHeaderIgnored() throws ServletException, IOException { + filter.doFilter(request, response, chain); + HttpServletRequest nextRequest = (HttpServletRequest) chain.getRequest(); + assertNotNull(nextRequest); + + X509Certificate[] apimlCerts = (X509Certificate[]) nextRequest.getAttribute("javax.servlet.request.X509Certificate"); + assertNotNull(apimlCerts); + assertEquals(0, apimlCerts.length); + + X509Certificate[] clientCerts = (X509Certificate[]) nextRequest.getAttribute("client.auth.X509Certificate"); + assertNotNull(clientCerts); + assertEquals(4, clientCerts.length); + assertArrayEquals(certificates, clientCerts); + + assertNull(nextRequest.getHeader(CLIENT_CERT_HEADER)); + assertFalse(nextRequest.getHeaders(CLIENT_CERT_HEADER).hasMoreElements()); + } } } + + @Nested + class WhenOtherHeadersInRequest { + + private static final String COMMON_HEADER = "User-Agent"; + private static final String COMMON_HEADER_VALUE = "dummy"; + @BeforeEach + void setUp() { + request.addHeader(COMMON_HEADER, COMMON_HEADER_VALUE); + } + + @Test + void thenOtherHeadersPassThrough() throws ServletException, IOException { + filter.doFilter(request, response, chain); + HttpServletRequest nextRequest = (HttpServletRequest) chain.getRequest(); + assertNotNull(nextRequest); + + assertEquals(COMMON_HEADER_VALUE, nextRequest.getHeader(COMMON_HEADER)); + assertEquals(COMMON_HEADER_VALUE, nextRequest.getHeaders(COMMON_HEADER).nextElement()); + } + } + } @Nested @@ -125,7 +336,7 @@ void setUp() { filter = new CategorizeCertsFilter(new HashSet<>(Arrays.asList( X509Utils.correctBase64("apimlCert1"), X509Utils.correctBase64("apimlCert2") - ))); + )), certificateValidator); } @Nested @@ -145,42 +356,140 @@ void setUp() { @Test void thenCategorizedCerts() throws IOException, ServletException { filter.doFilter(request, response, chain); + HttpServletRequest nextRequest = (HttpServletRequest) chain.getRequest(); + assertNotNull(nextRequest); - X509Certificate[] apimlCerts = (X509Certificate[]) request.getAttribute("javax.servlet.request.X509Certificate"); + X509Certificate[] apimlCerts = (X509Certificate[]) nextRequest.getAttribute("javax.servlet.request.X509Certificate"); assertNotNull(apimlCerts); assertEquals(2, apimlCerts.length); assertSame(certificates[1], apimlCerts[0]); assertSame(certificates[3], apimlCerts[1]); - X509Certificate[] clientCerts = (X509Certificate[]) request.getAttribute("client.auth.X509Certificate"); + X509Certificate[] clientCerts = (X509Certificate[]) nextRequest.getAttribute("client.auth.X509Certificate"); assertNotNull(clientCerts); assertEquals(2, clientCerts.length); assertSame(certificates[0], clientCerts[0]); assertSame(certificates[2], clientCerts[1]); - verify(chain, times(1)).doFilter(request, response); + assertNull(nextRequest.getHeader(CLIENT_CERT_HEADER)); + assertFalse(nextRequest.getHeaders(CLIENT_CERT_HEADER).hasMoreElements()); } @Test void thenCategorizedCertsWithReversedLogic() throws IOException, ServletException { filter.setCertificateForClientAuth(crt -> filter.getPublicKeyCertificatesBase64().contains(filter.base64EncodePublicKey(crt))); - filter.setNotCertificateForClientAuth(crt -> !filter.getPublicKeyCertificatesBase64().contains(filter.base64EncodePublicKey(crt))); + filter.setApimlCertificate(crt -> !filter.getPublicKeyCertificatesBase64().contains(filter.base64EncodePublicKey(crt))); filter.doFilter(request, response, chain); + HttpServletRequest nextRequest = (HttpServletRequest) chain.getRequest(); + assertNotNull(nextRequest); - X509Certificate[] clientCerts = (X509Certificate[]) request.getAttribute("client.auth.X509Certificate"); + X509Certificate[] clientCerts = (X509Certificate[]) nextRequest.getAttribute("client.auth.X509Certificate"); assertNotNull(clientCerts); assertEquals(2, clientCerts.length); assertSame(certificates[1], clientCerts[0]); assertSame(certificates[3], clientCerts[1]); - X509Certificate[] apimlCerts = (X509Certificate[]) request.getAttribute("javax.servlet.request.X509Certificate"); + X509Certificate[] apimlCerts = (X509Certificate[]) nextRequest.getAttribute("javax.servlet.request.X509Certificate"); assertNotNull(apimlCerts); assertEquals(2, apimlCerts.length); assertSame(certificates[0], apimlCerts[0]); assertSame(certificates[2], apimlCerts[1]); - verify(chain, times(1)).doFilter(request, response); + assertNull(nextRequest.getHeader(CLIENT_CERT_HEADER)); + assertFalse(nextRequest.getHeaders(CLIENT_CERT_HEADER).hasMoreElements()); + } + + @Nested + class WhenCertificateInHeaderAndForwardingEnabled { + + @BeforeEach + public void setUp() { + request.addHeader(CLIENT_CERT_HEADER, CLIENT_CERT_HEADER_VALUE); + when(certificateValidator.isForwardingEnabled()).thenReturn(true); + } + + @Test + void givenTrustedCerts_thenClientCertHeaderAccepted() throws ServletException, IOException { + when(certificateValidator.isTrusted(certificates)).thenReturn(true); + // when incoming certs are all trusted means that all their public keys are added to the filter + filter.getPublicKeyCertificatesBase64().add(X509Utils.correctBase64("foreignCert1")); + filter.getPublicKeyCertificatesBase64().add(X509Utils.correctBase64("foreignCert2")); + filter.getPublicKeyCertificatesBase64().add(X509Utils.correctBase64("apimlCert1")); + filter.getPublicKeyCertificatesBase64().add(X509Utils.correctBase64("apimlCert2")); + + filter.doFilter(request, response, chain); + HttpServletRequest nextRequest = (HttpServletRequest) chain.getRequest(); + assertNotNull(nextRequest); + + X509Certificate[] apimlCerts = (X509Certificate[]) nextRequest.getAttribute("javax.servlet.request.X509Certificate"); + assertNotNull(apimlCerts); + assertEquals(4, apimlCerts.length); + assertArrayEquals(certificates, apimlCerts); + + X509Certificate[] clientCerts = (X509Certificate[]) nextRequest.getAttribute("client.auth.X509Certificate"); + assertNotNull(clientCerts); + assertEquals(1, clientCerts.length); + assertSame(clientCertfromHeader, clientCerts[0]); + + assertNull(nextRequest.getHeader(CLIENT_CERT_HEADER)); + assertFalse(nextRequest.getHeaders(CLIENT_CERT_HEADER).hasMoreElements()); + } + + @Test + void givenNotTrustedCerts_thenClientCertHeaderIgnored() throws ServletException, IOException { + when(certificateValidator.isTrusted(certificates)).thenReturn(false); + filter.doFilter(request, response, chain); + HttpServletRequest nextRequest = (HttpServletRequest) chain.getRequest(); + assertNotNull(nextRequest); + + X509Certificate[] apimlCerts = (X509Certificate[]) nextRequest.getAttribute("javax.servlet.request.X509Certificate"); + assertNotNull(apimlCerts); + assertEquals(2, apimlCerts.length); + assertSame(certificates[1], apimlCerts[0]); + assertSame(certificates[3], apimlCerts[1]); + + X509Certificate[] clientCerts = (X509Certificate[]) nextRequest.getAttribute("client.auth.X509Certificate"); + assertNotNull(clientCerts); + assertEquals(2, clientCerts.length); + assertSame(certificates[0], clientCerts[0]); + assertSame(certificates[2], clientCerts[1]); + + assertNull(nextRequest.getHeader(CLIENT_CERT_HEADER)); + assertFalse(nextRequest.getHeaders(CLIENT_CERT_HEADER).hasMoreElements()); + } + } + + @Nested + class WhenCertificateInHeaderAndForwardingDisabled { + + @BeforeEach + public void setUp() { + request.addHeader(CLIENT_CERT_HEADER, CLIENT_CERT_HEADER_VALUE); + when(certificateValidator.isForwardingEnabled()).thenReturn(false); + } + + @Test + void thenClientCertHeaderIgnored() throws ServletException, IOException { + filter.doFilter(request, response, chain); + HttpServletRequest nextRequest = (HttpServletRequest) chain.getRequest(); + assertNotNull(nextRequest); + + X509Certificate[] apimlCerts = (X509Certificate[]) nextRequest.getAttribute("javax.servlet.request.X509Certificate"); + assertNotNull(apimlCerts); + assertEquals(2, apimlCerts.length); + assertSame(certificates[1], apimlCerts[0]); + assertSame(certificates[3], apimlCerts[1]); + + X509Certificate[] clientCerts = (X509Certificate[]) nextRequest.getAttribute("client.auth.X509Certificate"); + assertNotNull(clientCerts); + assertEquals(2, clientCerts.length); + assertSame(certificates[0], clientCerts[0]); + assertSame(certificates[2], clientCerts[1]); + + assertNull(nextRequest.getHeader(CLIENT_CERT_HEADER)); + assertFalse(nextRequest.getHeaders(CLIENT_CERT_HEADER).hasMoreElements()); + } } } } diff --git a/apiml-security-common/src/test/java/org/zowe/apiml/security/common/verify/CertificateValidatorTest.java b/apiml-security-common/src/test/java/org/zowe/apiml/security/common/verify/CertificateValidatorTest.java new file mode 100644 index 0000000000..dac6c2d8f5 --- /dev/null +++ b/apiml-security-common/src/test/java/org/zowe/apiml/security/common/verify/CertificateValidatorTest.java @@ -0,0 +1,107 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.security.common.verify; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.zowe.apiml.security.common.utils.X509Utils; + +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class CertificateValidatorTest { + + private static final String URL_PROVIDE_TRUSTED_CERTS = "trusted_certs_url"; + private static final String URL_WITH_NO_TRUSTED_CERTS = "invalid_url_for_trusted_certs"; + private static final X509Certificate cert1 = X509Utils.getCertificate(X509Utils.correctBase64("correct_certificate_1")); + private static final X509Certificate cert2 = X509Utils.getCertificate(X509Utils.correctBase64("correct_certificate_2")); + private static final X509Certificate cert3 = X509Utils.getCertificate(X509Utils.correctBase64("correct_certificate_3")); + private CertificateValidator certificateValidator; + private Set publicKeys; + + @BeforeEach + void setUp() { + List trustedCerts = new ArrayList<>(); + trustedCerts.add(cert1); + trustedCerts.add(cert2); + TrustedCertificatesProvider mockProvider = mock(TrustedCertificatesProvider.class); + when(mockProvider.getTrustedCerts(URL_PROVIDE_TRUSTED_CERTS)).thenReturn(trustedCerts); + when(mockProvider.getTrustedCerts(URL_WITH_NO_TRUSTED_CERTS)).thenReturn(Collections.emptyList()); + certificateValidator = new CertificateValidator(mockProvider, Collections.emptySet()); + } + + @Nested + class WhenTrustedCertsProvided { + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(certificateValidator, "proxyCertificatesEndpoint", URL_PROVIDE_TRUSTED_CERTS, String.class); + } + @Test + void whenAllCertificatesFoundThenTheyAreTrusted() { + assertTrue(certificateValidator.isTrusted(new X509Certificate[]{cert1})); + assertTrue(certificateValidator.isTrusted(new X509Certificate[]{cert2})); + assertTrue(certificateValidator.isTrusted(new X509Certificate[]{cert1, cert2})); + } + + @Test + void whenSomeCertificateNotFoundThenAllUntrusted() { + assertFalse(certificateValidator.isTrusted(new X509Certificate[]{cert3})); + assertFalse(certificateValidator.isTrusted(new X509Certificate[]{cert1, cert3})); + assertFalse(certificateValidator.isTrusted(new X509Certificate[]{cert2, cert3})); + } + } + + @Nested + class WhenNoTrustedCertsProvided { + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(certificateValidator, "proxyCertificatesEndpoint", URL_WITH_NO_TRUSTED_CERTS, String.class); + } + @Test + void thenAnyCertificateIsNotTrusted() { + assertFalse(certificateValidator.isTrusted(new X509Certificate[]{cert1})); + assertFalse(certificateValidator.isTrusted(new X509Certificate[]{cert2})); + assertFalse(certificateValidator.isTrusted(new X509Certificate[]{cert3})); + } + } + + @Nested + class WhenUpdatePublicKeys { + + @BeforeEach + void setUp() { + publicKeys = Stream.of("public_key_1", "public_key_2").collect(Collectors.toCollection(HashSet::new)); + certificateValidator = new CertificateValidator(mock(TrustedCertificatesProvider.class), publicKeys); + } + + @Test + void whenUpdatePublicKeys_thenSetUpdated() { + certificateValidator.updateAPIMLPublicKeyCertificates(new X509Certificate[]{cert1, cert2, cert3}); + + assertEquals(5, publicKeys.size()); + assertTrue(publicKeys.contains(Base64.getEncoder().encodeToString(cert1.getPublicKey().getEncoded()))); + assertTrue(publicKeys.contains(Base64.getEncoder().encodeToString(cert2.getPublicKey().getEncoded()))); + assertTrue(publicKeys.contains(Base64.getEncoder().encodeToString(cert3.getPublicKey().getEncoded()))); + + } + } +} diff --git a/apiml-security-common/src/test/java/org/zowe/apiml/security/common/verify/TrustedCertificatesProviderTest.java b/apiml-security-common/src/test/java/org/zowe/apiml/security/common/verify/TrustedCertificatesProviderTest.java new file mode 100644 index 0000000000..e219f56405 --- /dev/null +++ b/apiml-security-common/src/test/java/org/zowe/apiml/security/common/verify/TrustedCertificatesProviderTest.java @@ -0,0 +1,192 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.security.common.verify; + +import org.apache.http.HttpEntity; +import org.apache.http.HttpStatus; +import org.apache.http.StatusLine; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.impl.client.CloseableHttpClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class TrustedCertificatesProviderTest { + + private static final String VALID_CERTIFICATE = + "-----BEGIN CERTIFICATE-----\n" + + "MIID7zCCAtegAwIBAgIED0TPEjANBgkqhkiG9w0BAQsFADB6MQswCQYDVQQGEwJD\n" + + "WjEPMA0GA1UECBMGUHJhZ3VlMQ8wDQYDVQQHEwZQcmFndWUxFDASBgNVBAoTC1pv\n" + + "d2UgU2FtcGxlMRwwGgYDVQQLExNBUEkgTWVkaWF0aW9uIExheWVyMRUwEwYDVQQD\n" + + "Ewxab3dlIFNlcnZpY2UwHhcNMTgxMjA3MTQ1NzIyWhcNMjgxMjA0MTQ1NzIyWjB6\n" + + "MQswCQYDVQQGEwJDWjEPMA0GA1UECBMGUHJhZ3VlMQ8wDQYDVQQHEwZQcmFndWUx\n" + + "FDASBgNVBAoTC1pvd2UgU2FtcGxlMRwwGgYDVQQLExNBUEkgTWVkaWF0aW9uIExh\n" + + "eWVyMRUwEwYDVQQDEwxab3dlIFNlcnZpY2UwggEiMA0GCSqGSIb3DQEBAQUAA4IB\n" + + "DwAwggEKAoIBAQC6Orc/EJ5/t2qam1DiYU/xVbHaQrjd6uvpj2HTvOOohtFZ7/Kx\n" + + "yMAezgB8DBR4+77qXXsdP9ngnTl/i22yGwvo7Tlz6dhnQLnks7VFr1eGGC2ks+rL\n" + + "BJsF/RQexmONG9ddexWD8SOYoW9RRapQqETbcllxOenvzXruOEzaXhMazkK9Cg+J\n" + + "ucNb9HcfhIM0rjLZhqG8Gc8dAtCcxF/xHlVyFQq8fr4u2p/wGmARM14iZeQltQV7\n" + + "F3gxmw3djfcNM5S3tirPrHlZb76ZmmQEn4QiLSP198Lm+4QKAOw1dUpMf4eELO4c\n" + + "EFUHXQUCHLWc5NztZxWW40NrDbZEjcRI5ah7AgMBAAGjfTB7MB0GA1UdJQQWMBQG\n" + + "CCsGAQUFBwMCBggrBgEFBQcDATAOBgNVHQ8BAf8EBAMCBPAwKwYDVR0RBCQwIoIV\n" + + "bG9jYWxob3N0LmxvY2FsZG9tYWlugglsb2NhbGhvc3QwHQYDVR0OBBYEFHL1ygBb\n" + + "UCI/ktdk3TgQA6EJlATIMA0GCSqGSIb3DQEBCwUAA4IBAQBHALBlFf0P1TBR1MHQ\n" + + "vXYDFAW+PiyF7zP0HcrvQTAGYhF7uJtRIamapjUdIsDVbqY0RhoFnBOu8ti2z0pW\n" + + "djw47f3X/yj98n+J2aYcO64Ar+ovx93P01MA8+Mz1u/LwXk4pmrbUIcOEtyNu+vT\n" + + "a0jDobC++3Zfv5Y+iD2M8L+jacSMZNCqQByhKtTkAICXg9LMccx4XLYtJ65zGP2h\n" + + "4TEK0MMfO2G1/vUmdb3tq17zKdukj3MUS254mENCck7ioNFR0Cc9lzuSHyBrdb0x\n" + + "M/iHeamNblckK/r1roDjhCAQz9DtmETad/o7qGNFxDTRRShRV9Lww0fFB7PaV7u/\n" + + "VPx2\n" + + "-----END CERTIFICATE-----"; + + private static final String VALID_CERT_SUBJECT_DN = + "CN=Zowe Service,OU=API Mediation Layer,O=Zowe Sample,L=Prague,ST=Prague,C=CZ"; + + private static final String CERTS_URL = "https://localhost/gateway/certificates"; + + private TrustedCertificatesProvider provider; + private CloseableHttpClient closeableHttpClient; + private CloseableHttpResponse httpResponse; + private StatusLine statusLine; + private HttpEntity responseEntity; + + @BeforeEach + void setup() throws IOException { + closeableHttpClient = mock(CloseableHttpClient.class); + httpResponse = mock(CloseableHttpResponse.class); + statusLine = mock(StatusLine.class); + when(statusLine.getStatusCode()).thenReturn(HttpStatus.SC_OK); + when(httpResponse.getStatusLine()).thenReturn(statusLine); + when(closeableHttpClient.execute(any())).thenReturn(httpResponse); + responseEntity = mock(HttpEntity.class); + when(httpResponse.getEntity()).thenReturn(responseEntity); + } + + @Nested + class GivenResponseWithValidCertificate { + + @BeforeEach + void setup() throws IOException { + when(responseEntity.getContent()).thenReturn(new ByteArrayInputStream(VALID_CERTIFICATE.getBytes())); + } + + @Test + void whenGetTrustedCerts_thenCertificatesReturned() { + provider = new TrustedCertificatesProvider(closeableHttpClient); + List result = provider.getTrustedCerts(CERTS_URL); + assertNotNull(result); + assertEquals(1, result.size()); + + X509Certificate trustedCert = (X509Certificate) result.get(0); + assertEquals(VALID_CERT_SUBJECT_DN, trustedCert.getSubjectX500Principal().getName()); + } + + @Test + void whenInvalidUrl_thenNoCertificatesReturned() { + provider = new TrustedCertificatesProvider(closeableHttpClient); + List result = provider.getTrustedCerts("htpp>\\\\//wrong.url"); + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void whenIOError_thenNoCertificatesReturned() throws IOException { + when(closeableHttpClient.execute(any())).thenThrow(new IOException("communication error")); + provider = new TrustedCertificatesProvider(closeableHttpClient); + List result = provider.getTrustedCerts(CERTS_URL); + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + } + + @Nested + class GivenResponseWithInvalidCertificate { + @BeforeEach + void setup() throws IOException { + when(responseEntity.getContent()).thenReturn(new ByteArrayInputStream("invalid_certificate".getBytes())); + } + + @Test + void whenGetTrustedCerts_thenNoCertificatesReturned() { + provider = new TrustedCertificatesProvider(closeableHttpClient); + List result = provider.getTrustedCerts(CERTS_URL); + assertNotNull(result); + assertTrue(result.isEmpty()); + // check for log message + } + } + + @Nested + class GivenEmptyResponse { + @BeforeEach + void setup() throws IOException { + when(responseEntity.getContent()).thenReturn(new ByteArrayInputStream(new byte[0])); + } + + @Test + void whenGetTrustedCerts_thenNoCertificatesReturned() { + provider = new TrustedCertificatesProvider(closeableHttpClient); + List result = provider.getTrustedCerts(CERTS_URL); + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void whenNoHttpEntity_thenNoCertificatesReturned() { + when(httpResponse.getEntity()).thenReturn(null); + + provider = new TrustedCertificatesProvider(closeableHttpClient); + List result = provider.getTrustedCerts(CERTS_URL); + assertNotNull(result); + assertTrue(result.isEmpty()); + } + } + + @Nested + class GivenErrorResponseCode { + @BeforeEach + void setup() { + when(statusLine.getStatusCode()).thenReturn(HttpStatus.SC_BAD_REQUEST); + } + + @Test + void whenGetTrustedCerts_thenNoCertificatesReturned() { + provider = new TrustedCertificatesProvider(closeableHttpClient); + List result = provider.getTrustedCerts(CERTS_URL); + assertNotNull(result); + assertTrue(result.isEmpty()); + //check for log message + } + + @Test + void whenNoStatusLine_thenNoCertificatesReturned() { + when(httpResponse.getStatusLine()).thenReturn(null); + + provider = new TrustedCertificatesProvider(closeableHttpClient); + List result = provider.getTrustedCerts(CERTS_URL); + assertNotNull(result); + assertTrue(result.isEmpty()); + //check for log message + } + } +} diff --git a/apiml-security-common/src/test/resources/security-service-messages.yml b/apiml-security-common/src/test/resources/security-service-messages.yml index ad444d7901..8748ba1ac7 100644 --- a/apiml-security-common/src/test/resources/security-service-messages.yml +++ b/apiml-security-common/src/test/resources/security-service-messages.yml @@ -55,6 +55,51 @@ messages: type: ERROR text: "No authorization token provided for URL '%s'" + # TLS,Certificate messages + # 500-599 + + - key: org.zowe.apiml.security.common.filter.errorParsingCertificate + number: ZWEAT500 + type: ERROR + text: "Failed to parse the client certificate forwarded from the central Gateway. Error message %s. The client certificate was %s" + reason: "The string sent by the central Gateway was not recognized as valid DER-encoded certificate in the Base64 printable form." + action: "Ensure that the forwarding of client certificate is enabled also in the central Gateway. Check for any error messages from the central Gateway." + + - key: org.zowe.apiml.security.common.verify.invalidResponse + number: ZWEAT501 + type: ERROR + text: "Failed to get trusted certificates from the central Gateway. Unexpected response from %s endpoint. Status code: %s. Response body: %s" + reason: "The response status code is different from expected 200 OK." + action: "Ensure that the parameter apiml.security.x509.certificatesUrl is correctly configured with the complete URL to the central Gateway certificates endpoint. Test the URL manually." + + - key: org.zowe.apiml.security.common.verify.invalidURL + number: ZWEAT502 + type: ERROR + text: "Invalid URL specified to get trusted certificates from the central Gateway. Error message: %s" + reason: "The parameter apiml.security.x509.certificatesUrl is not correctly configured with the complete URL to the central Gateway certificates endpoint." + action: "Ensure that the parameter apiml.security.x509.certificatesUrl is correctly configured." + + - key: org.zowe.apiml.security.common.verify.httpError + number: ZWEAT503 + type: ERROR + text: "An error occurred during retrieval of trusted certificates from the central Gateway. Error message: %s" + reason: "The communication with the cloud gateway got interrupted or an error occurred during processing the response." + action: "Check the provided error message. Contact the support." + + - key: org.zowe.apiml.security.common.verify.errorParsingCertificates + number: ZWEAT504 + type: ERROR + text: "Failed to parse the trusted certificates provided by the central Gateway. Error message %s" + reason: "The string sent by the central Gateway was not recognized as valid DER-encoded certificates in the Base64 printable form." + action: "Check that the URL configured in apiml.security.x509.certificatesUrl responds with valid DER-encoded certificates in the Base64 printable form." + + - key: org.zowe.apiml.security.common.verify.untrustedCert + number: ZWEAT505 + type: ERROR + text: "Incoming request certificate is not one of the trusted certificates provided by the central Gateway." + reason: "The Gateway performs additional check of request certificates when the central Gateway forwards incoming client certificate to the domain Gateway. This check may fail when the certificatesUrl parameter does not point to proper central Gateway certificates endpoint." + action: "Check that the URL configured in apiml.security.x509.certificatesUrl points to the central Gateway and it responds with valid DER-encoded certificates in the Base64 printable form." + # Personal access token messages - key: org.zowe.apiml.security.query.invalidAccessTokenBody number: ZWEAT605 diff --git a/cloud-gateway-package/src/main/resources/bin/start.sh b/cloud-gateway-package/src/main/resources/bin/start.sh index e6f5e4647f..d1c7ec25bf 100755 --- a/cloud-gateway-package/src/main/resources/bin/start.sh +++ b/cloud-gateway-package/src/main/resources/bin/start.sh @@ -27,6 +27,7 @@ # - LIBPATH # - LIBRARY_PATH # - ZWE_components_discovery_port - the port the discovery service will use +# - ZWE_configs_apiml_service_forwardClientCertEnabled # - ZWE_configs_certificate_keystore_alias - The alias of the key within the keystore # - ZWE_configs_certificate_keystore_file - The keystore to use for SSL certificates # - ZWE_configs_certificate_keystore_password - The password to access the keystore supplied by KEYSTORE @@ -94,6 +95,7 @@ _BPX_JOBNAME=${ZWE_zowe_job_prefix}${CLOUD_GATEWAY_CODE} java \ -Dspring.profiles.include=$LOG_LEVEL \ -Dapiml.service.hostname=${ZWE_haInstance_hostname:-localhost} \ -Dapiml.service.port=${ZWE_configs_port:-10023} \ + -Dapiml.service.forwardClientCertEnabled=${ZWE_configs_apiml_service_forwardClientCertEnabled:-false} \ -Dapiml.logs.location=${ZWE_zowe_logDirectory} \ -Dapiml.zoweManifest=${ZWE_zowe_runtimeDirectory}/manifest.json \ -Dserver.address=0.0.0.0 \ diff --git a/cloud-gateway-package/src/main/resources/manifest.yaml b/cloud-gateway-package/src/main/resources/manifest.yaml index 9b718865ec..274e010a27 100644 --- a/cloud-gateway-package/src/main/resources/manifest.yaml +++ b/cloud-gateway-package/src/main/resources/manifest.yaml @@ -27,3 +27,7 @@ configs: port: 7563 debug: false sslDebug: "" + apiml: + service: + # Enables forwarding client certificate from request to next gateway in a special request header + forwardClientCertEnabled: false diff --git a/cloud-gateway-service/build.gradle b/cloud-gateway-service/build.gradle index cebb69b0ef..32c7946499 100644 --- a/cloud-gateway-service/build.gradle +++ b/cloud-gateway-service/build.gradle @@ -79,6 +79,7 @@ dependencies { implementation libs.spring.aop implementation libs.spring.expression implementation libs.bcpkix + implementation libs.nimbusJoseJwt compileOnly libs.lombok annotationProcessor libs.lombok @@ -88,6 +89,8 @@ dependencies { testAnnotationProcessor libs.lombok testImplementation libs.spring.boot.starter.test testImplementation libs.rest.assured + + testImplementation libs.mockito.inline } bootJar { diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/RoutingConfig.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/RoutingConfig.java index 3f0cc15db8..afd52d9264 100644 --- a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/RoutingConfig.java +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/config/RoutingConfig.java @@ -26,18 +26,27 @@ public class RoutingConfig { @Value("${apiml.service.ignoredHeadersWhenCorsEnabled:-}") private String ignoredHeadersWhenCorsEnabled; + @Value("${apiml.service.forwardClientCertEnabled:false}") + private String forwardingClientCertEnabled; + @Bean public List filters() { FilterDefinition circuitBreakerFilter = new FilterDefinition(); circuitBreakerFilter.setName("CircuitBreaker"); + FilterDefinition retryFilter = new FilterDefinition(); retryFilter.setName("Retry"); - retryFilter.addArg("retries", "5"); retryFilter.addArg("statuses", "SERVICE_UNAVAILABLE"); + + FilterDefinition clientCertFilter = new FilterDefinition(); + clientCertFilter.setName("ClientCertFilterFactory"); + clientCertFilter.addArg("forwardingEnabled", forwardingClientCertEnabled); + List filters = new ArrayList<>(); filters.add(circuitBreakerFilter); filters.add(retryFilter); + filters.add(clientCertFilter); for (String headerName : ignoredHeadersWhenCorsEnabled.split(",")) { FilterDefinition removeHeaders = new FilterDefinition(); removeHeaders.setName("RemoveRequestHeader"); diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/controller/CertificatesRestController.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/controller/CertificatesRestController.java new file mode 100644 index 0000000000..b6f3297187 --- /dev/null +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/controller/CertificatesRestController.java @@ -0,0 +1,36 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.cloudgatewayservice.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.zowe.apiml.cloudgatewayservice.service.CertificateChainService; + +/** + * This simple controller provides a public endpoint with the client certificate chain. + */ +@RequiredArgsConstructor +@RestController +@RequestMapping(CertificatesRestController.CONTROLLER_PATH) +public class CertificatesRestController { + public static final String CONTROLLER_PATH = "/gateway/certificates"; + + private final CertificateChainService certificateChainService; + + @GetMapping + public ResponseEntity getCertificates() { + return new ResponseEntity<>(certificateChainService.getCertificatesInPEMFormat(), HttpStatus.OK); + } +} diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/ClientCertFilterFactory.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/ClientCertFilterFactory.java new file mode 100644 index 0000000000..3d65b340aa --- /dev/null +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/filters/ClientCertFilterFactory.java @@ -0,0 +1,78 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.cloudgatewayservice.filters; + +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Service; +import org.zowe.apiml.constants.ApimlConstants; + +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.util.Base64; + +/** + * Objective is to include new header in the request which contains incoming client certificate + * so that further processing (mapping to mainframe userId) is possible by the domain gateway. + */ +@Service +@Slf4j +public class ClientCertFilterFactory extends AbstractGatewayFilterFactory { + + private static final String CLIENT_CERT_HEADER = "Client-Cert"; + + public ClientCertFilterFactory() { + super(Config.class); + } + + /** + * Filter business logic - Always remove any existing Client-Cert header from incoming request. + * If feature is enabled, then extracts the client certificate, encode it and put it to new Client-Cert header. + * If encoding fails then add X-Zowe-Auth-Failure header with the error message to the request. + * + * @param config Configuration values of this filter + * @return GatewayFilter object + */ + @Override + public GatewayFilter apply(Config config) { + return ((exchange, chain) -> { + ServerHttpRequest request = exchange.getRequest().mutate().headers(headers -> { + headers.remove(CLIENT_CERT_HEADER); + if (config.isForwardingEnabled() && exchange.getRequest().getSslInfo() != null) { + X509Certificate[] certificates = exchange.getRequest().getSslInfo().getPeerCertificates(); + if (certificates != null && certificates.length > 0) { + try { + final String encodedCert = Base64.getEncoder().encodeToString(certificates[0].getEncoded()); + headers.add(CLIENT_CERT_HEADER, encodedCert); + log.debug("Incoming client certificate has been added to the {} header.", CLIENT_CERT_HEADER); + } catch (CertificateEncodingException e) { + log.debug("Failed to encode the incoming client certificate. Error message: {}", e.getMessage()); + headers.add(ApimlConstants.AUTH_FAIL_HEADER, "Invalid client certificate in request. Error message: " + e.getMessage()); + } + } + } + }).build(); + return chain.filter(exchange.mutate().request(request).build()); + }); + } + + public static class Config { + @Setter + private String forwardingEnabled; + + public boolean isForwardingEnabled() { + return Boolean.parseBoolean(forwardingEnabled); + } + } +} diff --git a/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/CertificateChainService.java b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/CertificateChainService.java new file mode 100644 index 0000000000..2cf4559276 --- /dev/null +++ b/cloud-gateway-service/src/main/java/org/zowe/apiml/cloudgatewayservice/service/CertificateChainService.java @@ -0,0 +1,87 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.cloudgatewayservice.service; + +import lombok.extern.slf4j.Slf4j; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.zowe.apiml.message.log.ApimlLogger; +import org.zowe.apiml.message.yaml.YamlMessageServiceInstance; +import org.zowe.apiml.security.HttpsConfig; +import org.zowe.apiml.security.HttpsConfigError; +import org.zowe.apiml.security.SecurityUtils; + +import javax.annotation.PostConstruct; +import java.io.IOException; +import java.io.StringWriter; +import java.security.cert.Certificate; + +/** + * This service provides gateway's certificate chain which is used for the southbound communication + */ +@Service +@Slf4j +public class CertificateChainService { + + //TODO Once the separate configuration of keystore for client is implemented (PR https://github.com/zowe/api-layer/pull/3051) then update this SSL configuration. + @Value("${server.ssl.keyStore:#{null}}") + private String keyStore; + + @Value("${server.ssl.keyStorePassword:#{null}}") + private char[] keyStorePassword; + + @Value("${server.ssl.keyPassword:#{null}}") + private char[] keyPassword; + + @Value("${server.ssl.keyStoreType:PKCS12}") + private String keyStoreType; + + @Value("${server.ssl.keyAlias:#{null}}") + private String keyAlias; + + private static final ApimlLogger apimlLog = ApimlLogger.of(CertificateChainService.class, YamlMessageServiceInstance.getInstance()); + Certificate[] certificates; + + public String getCertificatesInPEMFormat() { + StringWriter stringWriter = new StringWriter(); + if (certificates != null && certificates.length > 0) { + try (JcaPEMWriter jcaPEMWriter = new JcaPEMWriter(stringWriter)) { + for (Certificate cert : certificates) { + jcaPEMWriter.writeObject(cert); + } + } catch (IOException e) { + log.error("Failed to convert a certificate to PEM format. {}", e.getMessage()); + return null; + } + } + + return stringWriter.toString(); + } + + @PostConstruct + void loadCertChain() { + HttpsConfig config = HttpsConfig.builder() + .keyAlias(keyAlias) + .keyStore(keyStore) + .keyPassword(keyPassword) + .keyStorePassword(keyStorePassword) + .keyStoreType(keyStoreType) + .build(); + try { + certificates = SecurityUtils.loadCertificateChain(config); + } catch (Exception e) { + apimlLog.log("org.zowe.apiml.common.sslContextInitializationError", e.getMessage()); + throw new HttpsConfigError("Error initializing SSL Context: " + e.getMessage(), + e, HttpsConfigError.ErrorCode.HTTP_CLIENT_INITIALIZATION_FAILED, config); + } + } +} diff --git a/cloud-gateway-service/src/main/resources/application.yml b/cloud-gateway-service/src/main/resources/application.yml index bb325edcc5..f6b7314cb7 100644 --- a/cloud-gateway-service/src/main/resources/application.yml +++ b/cloud-gateway-service/src/main/resources/application.yml @@ -12,6 +12,7 @@ apiml: hostname: localhost corsEnabled: true ignoredHeadersWhenCorsEnabled: Access-Control-Request-Method,Access-Control-Request-Headers,Access-Control-Allow-Origin,Access-Control-Allow-Methods,Access-Control-Allow-Headers,Access-Control-Allow-Credentials,Origin + forwardClientCertEnabled: false security: ssl: nonStrictVerifySslCertificatesOfServices: true diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/controller/CertificatesRestControllerTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/controller/CertificatesRestControllerTest.java new file mode 100644 index 0000000000..64d83fa1c9 --- /dev/null +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/controller/CertificatesRestControllerTest.java @@ -0,0 +1,139 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.cloudgatewayservice.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.zowe.apiml.cloudgatewayservice.service.CertificateChainService; + +import static org.mockito.Mockito.*; + +@WebFluxTest(controllers = CertificatesRestController.class, excludeAutoConfiguration = {ReactiveSecurityAutoConfiguration.class}) +class CertificatesRestControllerTest { + private static final String NO_CERTIFICATES = ""; + + private static final String SINGLE_CERTIFICATE = + "-----BEGIN CERTIFICATE-----\n" + + "MIICUTCCAfugAwIBAgIBADANBgkqhkiG9w0BAQQFADBXMQswCQYDVQQGEwJDTjEL\n" + + "MAkGA1UECBMCUE4xCzAJBgNVBAcTAkNOMQswCQYDVQQKEwJPTjELMAkGA1UECxMC\n" + + "VU4xFDASBgNVBAMTC0hlcm9uZyBZYW5nMB4XDTA1MDcxNTIxMTk0N1oXDTA1MDgx\n" + + "NDIxMTk0N1owVzELMAkGA1UEBhMCQ04xCzAJBgNVBAgTAlBOMQswCQYDVQQHEwJD\n" + + "TjELMAkGA1UEChMCT04xCzAJBgNVBAsTAlVOMRQwEgYDVQQDEwtIZXJvbmcgWWFu\n" + + "ZzBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQCp5hnG7ogBhtlynpOS21cBewKE/B7j\n" + + "V14qeyslnr26xZUsSVko36ZnhiaO/zbMOoRcKK9vEcgMtcLFuQTWDl3RAgMBAAGj\n" + + "gbEwga4wHQYDVR0OBBYEFFXI70krXeQDxZgbaCQoR4jUDncEMH8GA1UdIwR4MHaA\n" + + "FFXI70krXeQDxZgbaCQoR4jUDncEoVukWTBXMQswCQYDVQQGEwJDTjELMAkGA1UE\n" + + "CBMCUE4xCzAJBgNVBAcTAkNOMQswCQYDVQQKEwJPTjELMAkGA1UECxMCVU4xFDAS\n" + + "BgNVBAMTC0hlcm9uZyBZYW5nggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEE\n" + + "BQADQQA/ugzBrjjK9jcWnDVfGHlk3icNRq0oV7Ri32z/+HQX67aRfgZu7KWdI+Ju\n" + + "Wm7DCfrPNGVwFWUQOmsPue9rZBgO\n" + + "-----END CERTIFICATE-----"; + + private static final String CERTIFICATE_CHAIN = + "-----BEGIN CERTIFICATE-----\n" + + "MIICUTCCAfugAwIBAgIBADANBgkqhkiG9w0BAQQFADBXMQswCQYDVQQGEwJDTjEL\n" + + "MAkGA1UECBMCUE4xCzAJBgNVBAcTAkNOMQswCQYDVQQKEwJPTjELMAkGA1UECxMC\n" + + "VU4xFDASBgNVBAMTC0hlcm9uZyBZYW5nMB4XDTA1MDcxNTIxMTk0N1oXDTA1MDgx\n" + + "NDIxMTk0N1owVzELMAkGA1UEBhMCQ04xCzAJBgNVBAgTAlBOMQswCQYDVQQHEwJD\n" + + "TjELMAkGA1UEChMCT04xCzAJBgNVBAsTAlVOMRQwEgYDVQQDEwtIZXJvbmcgWWFu\n" + + "ZzBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQCp5hnG7ogBhtlynpOS21cBewKE/B7j\n" + + "V14qeyslnr26xZUsSVko36ZnhiaO/zbMOoRcKK9vEcgMtcLFuQTWDl3RAgMBAAGj\n" + + "gbEwga4wHQYDVR0OBBYEFFXI70krXeQDxZgbaCQoR4jUDncEMH8GA1UdIwR4MHaA\n" + + "FFXI70krXeQDxZgbaCQoR4jUDncEoVukWTBXMQswCQYDVQQGEwJDTjELMAkGA1UE\n" + + "CBMCUE4xCzAJBgNVBAcTAkNOMQswCQYDVQQKEwJPTjELMAkGA1UECxMCVU4xFDAS\n" + + "BgNVBAMTC0hlcm9uZyBZYW5nggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEE\n" + + "BQADQQA/ugzBrjjK9jcWnDVfGHlk3icNRq0oV7Ri32z/+HQX67aRfgZu7KWdI+Ju\n" + + "Wm7DCfrPNGVwFWUQOmsPue9rZBgO\n" + + "-----END CERTIFICATE-----\n" + + "-----BEGIN CERTIFICATE-----\n" + + "MIICUTCCAfugAwIBAgIBADANBgkqhkiG9w0BAQQFADBXMQswCQYDVQQGEwJDTjEL\n" + + "MAkGA1UECBMCUE4xCzAJBgNVBAcTAkNOMQswCQYDVQQKEwJPTjELMAkGA1UECxMC\n" + + "VU4xFDASBgNVBAMTC0hlcm9uZyBZYW5nMB4XDTA1MDcxNTIxMTk0N1oXDTA1MDgx\n" + + "NDIxMTk0N1owVzELMAkGA1UEBhMCQ04xCzAJBgNVBAgTAlBOMQswCQYDVQQHEwJD\n" + + "TjELMAkGA1UEChMCT04xCzAJBgNVBAsTAlVOMRQwEgYDVQQDEwtIZXJvbmcgWWFu\n" + + "ZzBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQCp5hnG7ogBhtlynpOS21cBewKE/B7j\n" + + "V14qeyslnr26xZUsSVko36ZnhiaO/zbMOoRcKK9vEcgMtcLFuQTWDl3RAgMBAAGj\n" + + "gbEwga4wHQYDVR0OBBYEFFXI70krXeQDxZgbaCQoR4jUDncEMH8GA1UdIwR4MHaA\n" + + "FFXI70krXeQDxZgbaCQoR4jUDncEoVukWTBXMQswCQYDVQQGEwJDTjELMAkGA1UE\n" + + "CBMCUE4xCzAJBgNVBAcTAkNOMQswCQYDVQQKEwJPTjELMAkGA1UECxMCVU4xFDAS\n" + + "BgNVBAMTC0hlcm9uZyBZYW5nggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEE\n" + + "BQADQQA/ugzBrjjK9jcWnDVfGHlk3icNRq0oV7Ri32z/+HQX67aRfgZu7KWdI+Ju\n" + + "Wm7DCfrPNGVwFWUQOmsPue9rZBgO\n" + + "-----END CERTIFICATE-----"; + + @Autowired + WebTestClient webTestClient; + + @MockBean + private CertificateChainService mockCertificateChainService; + + @Nested + class WhenNoCertificate { + @BeforeEach + void setUp() { + when(mockCertificateChainService.getCertificatesInPEMFormat()).thenReturn(NO_CERTIFICATES); + } + + @Test + void thenResponseIsEmpty() { + webTestClient.get() + .uri(CertificatesRestController.CONTROLLER_PATH) + .exchange() + .expectStatus().isOk() + .expectBody(String.class).isEqualTo(NO_CERTIFICATES); + verify(mockCertificateChainService, times(1)).getCertificatesInPEMFormat(); + } + } + + @Nested + class WhenSingleCertificate { + @BeforeEach + void setUp() { + when(mockCertificateChainService.getCertificatesInPEMFormat()).thenReturn(SINGLE_CERTIFICATE); + } + + @Test + void thenResponseContainsOneCertificate() { + + webTestClient.get() + .uri(CertificatesRestController.CONTROLLER_PATH) + .exchange() + .expectStatus().isOk() + .expectBody(String.class).isEqualTo(SINGLE_CERTIFICATE); + verify(mockCertificateChainService, times(1)).getCertificatesInPEMFormat(); + } + } + + @Nested + class WhenCertificateChain { + @BeforeEach + void setUp() { + when(mockCertificateChainService.getCertificatesInPEMFormat()).thenReturn(CERTIFICATE_CHAIN); + } + + @Test + void thenResponseContainsAllCertificates() { + + webTestClient.get() + .uri(CertificatesRestController.CONTROLLER_PATH) + .exchange() + .expectStatus().isOk() + .expectBody(String.class).isEqualTo(CERTIFICATE_CHAIN); + verify(mockCertificateChainService, times(1)).getCertificatesInPEMFormat(); + } + } +} diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/filters/ClientCertFilterFactoryTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/filters/ClientCertFilterFactoryTest.java new file mode 100644 index 0000000000..d4ca5b86c0 --- /dev/null +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/filters/ClientCertFilterFactoryTest.java @@ -0,0 +1,327 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.cloudgatewayservice.filters; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.http.server.reactive.SslInfo; +import org.springframework.web.server.ServerWebExchange; +import org.zowe.apiml.constants.ApimlConstants; +import reactor.core.publisher.Mono; + +import java.net.InetSocketAddress; +import java.net.URI; +import java.security.Principal; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ClientCertFilterFactoryTest { + + private static final String CLIENT_CERT_HEADER = "Client-Cert"; + private static final byte[] CERTIFICATE_BYTES = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".getBytes(); + private static final String ENCODED_CERT = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo="; //Base64.getEncoder().encodeToString(CERTIFICATE_BYTES); + private final X509Certificate[] x509Certificates = new X509Certificate[1]; + + SslInfo sslInfo = mock(SslInfo.class); + ServerWebExchange exchange = mock(ServerWebExchange.class); + ServerHttpRequest request = mock(ServerHttpRequest.class); + GatewayFilterChain chain = mock(GatewayFilterChain.class); + ClientCertFilterFactory filterFactory; + ClientCertFilterFactory.Config filterConfig = new ClientCertFilterFactory.Config(); + ServerHttpRequest.Builder requestBuilder; + + @BeforeEach + void setup() { + x509Certificates[0] = mock(X509Certificate.class); + filterFactory = new ClientCertFilterFactory(); + requestBuilder = new ServerHttpRequestBuilderMock(); + ServerWebExchange.Builder exchangeBuilder = new ServerWebExchangeBuilderMock(); + + when(sslInfo.getPeerCertificates()).thenReturn(x509Certificates); + when(exchange.getRequest()).thenReturn(request); + when(request.getSslInfo()).thenReturn(sslInfo); + + when(request.mutate()).thenReturn(requestBuilder); + when(exchange.mutate()).thenReturn(exchangeBuilder); + when(chain.filter(exchange)).thenReturn(Mono.empty()); + } + + @Nested + class GivenValidCertificateInRequest { + + @BeforeEach + void setup() throws CertificateException { + when(x509Certificates[0].getEncoded()).thenReturn(CERTIFICATE_BYTES); + } + + @Test + void whenEnabled_thenAddHeaderToRequest() { + filterConfig.setForwardingEnabled("true"); + GatewayFilter filter = filterFactory.apply(filterConfig); + Mono result = filter.filter(exchange, chain); + result.block(); + + assertNull(exchange.getRequest().getHeaders().get(ApimlConstants.AUTH_FAIL_HEADER)); + assertNotNull(exchange.getRequest().getHeaders().get(CLIENT_CERT_HEADER)); + assertEquals(ENCODED_CERT, exchange.getRequest().getHeaders().get(CLIENT_CERT_HEADER).get(0)); + } + + @Test + void whenDisabled_thenNoHeadersInRequest() { + filterConfig.setForwardingEnabled("false"); + GatewayFilter filter = filterFactory.apply(filterConfig); + Mono result = filter.filter(exchange, chain); + result.block(); + + assertNull(exchange.getRequest().getHeaders().get(ApimlConstants.AUTH_FAIL_HEADER)); + assertNull(exchange.getRequest().getHeaders().get(CLIENT_CERT_HEADER)); + } + + @Nested + class WhenClientCertHeaderIsAlreadyInRequest { + + @BeforeEach + void setup() { + requestBuilder.header(CLIENT_CERT_HEADER, "This value cannot pass through the filter."); + } + + @Test + void whenEnabled_thenHeaderContainsNewValue() { + filterConfig.setForwardingEnabled("true"); + GatewayFilter filter = filterFactory.apply(filterConfig); + Mono result = filter.filter(exchange, chain); + result.block(); + + assertNull(exchange.getRequest().getHeaders().get(ApimlConstants.AUTH_FAIL_HEADER)); + assertNotNull(exchange.getRequest().getHeaders().get(CLIENT_CERT_HEADER)); + assertEquals(ENCODED_CERT, exchange.getRequest().getHeaders().get(CLIENT_CERT_HEADER).get(0)); + } + + @Test + void whenDisabled_thenNoHeadersInRequest() { + filterConfig.setForwardingEnabled("false"); + GatewayFilter filter = filterFactory.apply(filterConfig); + Mono result = filter.filter(exchange, chain); + result.block(); + + assertNull(exchange.getRequest().getHeaders().get(ApimlConstants.AUTH_FAIL_HEADER)); + assertNull(exchange.getRequest().getHeaders().get(CLIENT_CERT_HEADER)); + } + + @Nested + class WhenNoSSLSessionInformation { + + @BeforeEach + void setup() { + when(request.getSslInfo()).thenReturn(null); + } + + @ParameterizedTest + @ValueSource(strings = {"true", "false"}) + void thenNoHeadersInRequest(String enabled) { + filterConfig.setForwardingEnabled(enabled); + GatewayFilter filter = filterFactory.apply(filterConfig); + Mono result = filter.filter(exchange, chain); + result.block(); + + assertNull(exchange.getRequest().getHeaders().get(ApimlConstants.AUTH_FAIL_HEADER)); + assertNull(exchange.getRequest().getHeaders().get(CLIENT_CERT_HEADER)); + } + } + } + + @Nested + class WhenNoSSLSessionInformation { + + @BeforeEach + void setup() { + when(request.getSslInfo()).thenReturn(null); + } + + @ParameterizedTest + @ValueSource(strings = {"true", "false"}) + void thenNoHeadersInRequest(String enabled) { + filterConfig.setForwardingEnabled(enabled); + GatewayFilter filter = filterFactory.apply(filterConfig); + Mono result = filter.filter(exchange, chain); + result.block(); + + assertNull(exchange.getRequest().getHeaders().get(ApimlConstants.AUTH_FAIL_HEADER)); + assertNull(exchange.getRequest().getHeaders().get(CLIENT_CERT_HEADER)); + } + } + } + + + @Nested + class GivenInvalidCertificateInRequest { + + @BeforeEach + void setup() throws CertificateEncodingException { + filterConfig.setForwardingEnabled("true"); + requestBuilder.header(CLIENT_CERT_HEADER, "This value cannot pass through the filter."); + when(x509Certificates[0].getEncoded()).thenThrow(new CertificateEncodingException("incorrect encoding")); + } + + + @Test + void thenProvideInfoInFailHeader() { + GatewayFilter filter = filterFactory.apply(filterConfig); + Mono result = filter.filter(exchange, chain); + result.block(); + + assertNull(exchange.getRequest().getHeaders().get(CLIENT_CERT_HEADER)); + assertNotNull(exchange.getRequest().getHeaders().get(ApimlConstants.AUTH_FAIL_HEADER)); + assertEquals("Invalid client certificate in request. Error message: incorrect encoding", exchange.getRequest().getHeaders().get(ApimlConstants.AUTH_FAIL_HEADER).get(0)); + } + } + + @Nested + class GivenNoClientCertificateInRequest { + + @BeforeEach + void setup() { + requestBuilder.header(CLIENT_CERT_HEADER, "This value cannot pass through the filter."); + when(sslInfo.getPeerCertificates()).thenReturn(new X509Certificate[0]); + } + + @ParameterizedTest + @ValueSource(strings = {"true", "false"}) + void thenContinueFilterChainWithoutClientCertHeader(String enabled) { + filterConfig.setForwardingEnabled(enabled); + GatewayFilter filter = filterFactory.apply(filterConfig); + Mono result = filter.filter(exchange, chain); + result.block(); + assertNull(exchange.getRequest().getHeaders().get(CLIENT_CERT_HEADER)); + assertNull(exchange.getRequest().getHeaders().get(ApimlConstants.AUTH_FAIL_HEADER)); + } + } + + @Nested + class GivenNoSSLSessionInformationInRequest { + + @BeforeEach + void setup() { + requestBuilder.header(CLIENT_CERT_HEADER, "This value cannot pass through the filter."); + when(request.getSslInfo()).thenReturn(null); + } + + @ParameterizedTest + @ValueSource(strings = {"true", "false"}) + void thenContinueFilterChainWithoutClientCertHeader(String enabled) { + filterConfig.setForwardingEnabled(enabled); + GatewayFilter filter = filterFactory.apply(filterConfig); + Mono result = filter.filter(exchange, chain); + result.block(); + + assertNull(exchange.getRequest().getHeaders().get(ApimlConstants.AUTH_FAIL_HEADER)); + assertNull(exchange.getRequest().getHeaders().get(CLIENT_CERT_HEADER)); + } + } + + private class ServerHttpRequestBuilderMock implements ServerHttpRequest.Builder { + HttpHeaders headers = new HttpHeaders(); + + @Override + public ServerHttpRequest.Builder method(HttpMethod httpMethod) { + return null; + } + + @Override + public ServerHttpRequest.Builder uri(URI uri) { + return null; + } + + @Override + public ServerHttpRequest.Builder path(String path) { + return null; + } + + @Override + public ServerHttpRequest.Builder contextPath(String contextPath) { + return null; + } + + @Override + public ServerHttpRequest.Builder header(String headerName, String... headerValues) { + headers.add(headerName, headerValues[0]); + return this; + } + + @Override + public ServerHttpRequest.Builder headers(Consumer headersConsumer) { + headersConsumer.accept(this.headers); + return this; + } + + @Override + public ServerHttpRequest.Builder sslInfo(SslInfo sslInfo) { + return null; + } + + @Override + public ServerHttpRequest.Builder remoteAddress(InetSocketAddress remoteAddress) { + return null; + } + + @Override + public ServerHttpRequest build() { + when(request.getHeaders()).thenReturn(headers); + return request; + } + } + + private class ServerWebExchangeBuilderMock implements ServerWebExchange.Builder { + ServerHttpRequest request; + + @Override + public ServerWebExchange.Builder request(Consumer requestBuilderConsumer) { + return null; + } + + @Override + public ServerWebExchange.Builder request(ServerHttpRequest request) { + this.request = request; + return this; + } + + @Override + public ServerWebExchange.Builder response(ServerHttpResponse response) { + return null; + } + + @Override + public ServerWebExchange.Builder principal(Mono principalMono) { + return null; + } + + @Override + public ServerWebExchange build() { + when(exchange.getRequest()).thenReturn(request); + return exchange; + } + } +} diff --git a/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/CertificateChainServiceTest.java b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/CertificateChainServiceTest.java new file mode 100644 index 0000000000..11249b3948 --- /dev/null +++ b/cloud-gateway-service/src/test/java/org/zowe/apiml/cloudgatewayservice/service/CertificateChainServiceTest.java @@ -0,0 +1,166 @@ +/* + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + */ + +package org.zowe.apiml.cloudgatewayservice.service; + +import lombok.NonNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.test.util.ReflectionTestUtils; +import org.zowe.apiml.security.HttpsConfigError; +import org.zowe.apiml.security.SecurityUtils; + +import java.io.ByteArrayInputStream; +import java.security.KeyStoreException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class CertificateChainServiceTest { + private CertificateChainService certificateChainService; + + private static final String CERTIFICATE_1 = + "-----BEGIN CERTIFICATE-----\n" + + "MIIENzCCAx+gAwIBAgIEBUx4bjANBgkqhkiG9w0BAQsFADCBnjELMAkGA1UEBhMC\n" + + "Q1oxDzANBgNVBAgTBlByYWd1ZTEPMA0GA1UEBxMGUHJhZ3VlMRQwEgYDVQQKEwta\n" + + "b3dlIFNhbXBsZTEcMBoGA1UECxMTQVBJIE1lZGlhdGlvbiBMYXllcjE5MDcGA1UE\n" + + "AxMwWm93ZSBEZXZlbG9wbWVudCBJbnN0YW5jZXMgQ2VydGlmaWNhdGUgQXV0aG9y\n" + + "aXR5MB4XDTE5MDExMTEyMTIwN1oXDTI5MDEwODEyMTIwN1owejELMAkGA1UEBhMC\n" + + "Q1oxDzANBgNVBAgTBlByYWd1ZTEPMA0GA1UEBxMGUHJhZ3VlMRQwEgYDVQQKEwta\n" + + "b3dlIFNhbXBsZTEcMBoGA1UECxMTQVBJIE1lZGlhdGlvbiBMYXllcjEVMBMGA1UE\n" + + "AxMMWm93ZSBTZXJ2aWNlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA\n" + + "jo7rxDzO51tfSmqahMbY6lsXLO+/tXYk1ZcIufsh5L+UMs5StHlfSglbiRgWhfdJ\n" + + "DTZb9R760klXL7QRYwBcYn3yhdYTsTB0+RJddPlTQzxAx45xV7b+fCtsQqBFZk5a\n" + + "es/TduyHCHXQRl+iLos13isrl5LSB66ohKxMtflPBeqTM/ptNBbq72XqFCQIZClC\n" + + "lvMMYnxrW2FNfftxpLQbeFu3KN/8V4gcQoSUvE8YU8PYbVUnuhURActywrxHpke5\n" + + "q/tYQR8iDb6D1ZwLU8+/rTrnPbZq+O2DP7vRyBP9pHS/WNSxY1sTnz7gQ2OlUL+B\n" + + "EQLgRXRPc5ev1kwn0kVd8QIDAQABo4GfMIGcMB8GA1UdIwQYMBaAFPA6lVzMZhd6\n" + + "jkR4JClljOSWs0J1MB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDATAOBgNV\n" + + "HQ8BAf8EBAMCBPAwKwYDVR0RBCQwIoIVbG9jYWxob3N0LmxvY2FsZG9tYWluggls\n" + + "b2NhbGhvc3QwHQYDVR0OBBYEFJDw32hIl2AHqtLlFJtyVkrIlaGjMA0GCSqGSIb3\n" + + "DQEBCwUAA4IBAQAwO1TPIg5ebOiotTtJgj2wbyYFBfqljLrBMEfgP6h6ZOkj5fQI\n" + + "dZSLNmyY/PSk8IHUPE43QzEPV8Bd2zOwtDzbrnfvtuKLuLzPr+shih3gpUoSYGLU\n" + + "2miZZerk4AhpOrjIaUvKgcZ5QU7EQy32kQuKf9ldozxgnOzgN60G5z/qae7fYZxo\n" + + "SeV/nq8t7AkognCwHAKx8Iy418ucsfAuXQbursVWMi3KHrSENimZ+3fgCJ3ym0QT\n" + + "qwTpojppW5F9SWkJ4Q31l+oRROwIRKm44XSB8DVFnX/k8gzTPMylfQ+GwEyVHcyA\n" + + "R9zBnNhbbueFLlG9CBMeCHCyia6DUdIQlY5/\n" + + "-----END CERTIFICATE-----\n"; + + private static final String CERTIFICATE_2 = + "-----BEGIN CERTIFICATE-----\n" + + "MIID+zCCAuOgAwIBAgIEdkRICDANBgkqhkiG9w0BAQsFADCBnjELMAkGA1UEBhMC\n" + + "Q1oxDzANBgNVBAgTBlByYWd1ZTEPMA0GA1UEBxMGUHJhZ3VlMRQwEgYDVQQKEwta\n" + + "b3dlIFNhbXBsZTEcMBoGA1UECxMTQVBJIE1lZGlhdGlvbiBMYXllcjE5MDcGA1UE\n" + + "AxMwWm93ZSBEZXZlbG9wbWVudCBJbnN0YW5jZXMgQ2VydGlmaWNhdGUgQXV0aG9y\n" + + "aXR5MB4XDTE5MDExMTEyMTIwNVoXDTI5MDEwODEyMTIwNVowgZ4xCzAJBgNVBAYT\n" + + "AkNaMQ8wDQYDVQQIEwZQcmFndWUxDzANBgNVBAcTBlByYWd1ZTEUMBIGA1UEChML\n" + + "Wm93ZSBTYW1wbGUxHDAaBgNVBAsTE0FQSSBNZWRpYXRpb24gTGF5ZXIxOTA3BgNV\n" + + "BAMTMFpvd2UgRGV2ZWxvcG1lbnQgSW5zdGFuY2VzIENlcnRpZmljYXRlIEF1dGhv\n" + + "cml0eTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALyotswfS+sLTmwO\n" + + "08ocbkNWPccRVWGWaP/LvfLe1USmhUOMO7E38ztTy8AJYBxrFTPr2lL3rXybRHCn\n" + + "Lscz0XNvkNll6Yef71ghaLbpe0V12Jygw4J9BAbYdVIsrP+brR3pijGVO/ECvJwD\n" + + "815ODsGU3Staw9HFlHO7dWss/TM2uz3Y6oVLObuhEWvAXiU3fW3PpFebRUlhLe5g\n" + + "yprGSZDFQAJpiqD7Nac5uZB53ETSPI+Cyku2E5CPx3qDJh9ueiHyaqmtbhBXjOue\n" + + "7rHU9F03zpldofqp4WDMnrl9ktzQDx+OHY5HI+gsaKV/MEX3YVrD+Rdc1GTc0JiI\n" + + "OS2VBCsCAwEAAaM/MD0wDwYDVR0TAQH/BAUwAwEB/zALBgNVHQ8EBAMCAgQwHQYD\n" + + "VR0OBBYEFPA6lVzMZhd6jkR4JClljOSWs0J1MA0GCSqGSIb3DQEBCwUAA4IBAQCt\n" + + "onZY1WkhTXmBxIl6EW/IDmcXZeYgucw590I7iVVXDi53oCM16AIM6pniqMP/iku5\n" + + "2MX2JqGD//eEnJDt6q+qA4htJSb7lswjbC90xLkGAKAuDsC2cKGaoQAeTh5ouP7C\n" + + "itN2+xVjZTfyAg3ZxmhXmVKVsv4rRpiAOYvX7R7ewNjpJkBeTQouind5rKtabzPD\n" + + "0nHKF0u/Y8FaEwv8zFRffsnl0/3nqfnT6l0mvekDP+LhIKZI9TwIJYkP9PGraR50\n" + + "HgUnKdoaJuPVQfbiMzISRqXygfTdmVnY9CEP9/W2S4NgaLXI6AkNLEcLNvz/CKJg\n" + + "TRqXQKkvunUCrHxi5oV+\n" + + "-----END CERTIFICATE-----\n"; + + private Certificate[] certificates = new Certificate[2]; + + @Nested + class GivenValidCertificateChain { + + @BeforeEach + void setup() throws CertificateException { + certificates[0] = generateCert(CERTIFICATE_1); + certificates[1] = generateCert(CERTIFICATE_2); + certificateChainService = new CertificateChainService(); + ReflectionTestUtils.setField(certificateChainService, "certificates", certificates, Certificate[].class); + } + + @Test + void whenGetCertificates_thenPEMIsProduced() { + String result = certificateChainService.getCertificatesInPEMFormat(); + assertNotNull(result); + assertEquals(CERTIFICATE_1 + CERTIFICATE_2, result); + } + } + + @Nested + class GivenNoCertificatesInChain { + @BeforeEach + void setup() { + certificateChainService = new CertificateChainService(); + ReflectionTestUtils.setField(certificateChainService, "certificates", new Certificate[0], Certificate[].class); + } + + @Test + void whenGetCertificates_thenEmptyStringReturned() { + String result = certificateChainService.getCertificatesInPEMFormat(); + assertNotNull(result); + assertTrue(result.isEmpty()); + } + } + + @Nested + class GivenInvalidCertificateInChain { + @BeforeEach + void setup() throws CertificateException { + certificates[0] = generateCert(CERTIFICATE_1); + certificates[1] = mock(Certificate.class); + when(certificates[1].getEncoded()).thenReturn("INVALID_CERT_CONTENT".getBytes()); + certificateChainService = new CertificateChainService(); + ReflectionTestUtils.setField(certificateChainService, "certificates", certificates, Certificate[].class); + } + + @Test + void whenGetCertificates_thenNullReturned() { + String result = certificateChainService.getCertificatesInPEMFormat(); + assertNull(result); + } + } + + @Nested + class GivenExceptionDuringChainLoad { + + @BeforeEach + void setup() { + certificateChainService = new CertificateChainService(); + } + + @Test + void whenConstructService_thenExceptionThrown() { + try (MockedStatic securityUtilsMockedStatic = mockStatic(SecurityUtils.class)) { + securityUtilsMockedStatic.when(() -> SecurityUtils.loadCertificateChain(any())) + .thenThrow(new KeyStoreException("invalid keystore")); + Exception thrown = assertThrows(HttpsConfigError.class, () -> certificateChainService.loadCertChain()); + assertEquals("Error initializing SSL Context: invalid keystore", thrown.getMessage()); + } + } + } + + private Certificate generateCert(@NonNull String pem) throws CertificateException { + ByteArrayInputStream in = new ByteArrayInputStream(pem.getBytes()); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return cf.generateCertificate(in); + } +} diff --git a/cloud-gateway-service/src/test/resources/application.yml b/cloud-gateway-service/src/test/resources/application.yml index 5727fd2375..f6811128b6 100644 --- a/cloud-gateway-service/src/test/resources/application.yml +++ b/cloud-gateway-service/src/test/resources/application.yml @@ -11,6 +11,7 @@ apiml: hostname: localhost corsEnabled: true ignoredHeadersWhenCorsEnabled: Access-Control-Request-Method,Access-Control-Request-Headers,Access-Control-Allow-Origin,Access-Control-Allow-Methods,Access-Control-Allow-Headers,Access-Control-Allow-Credentials,Origin + forwardClientCertEnabled: false server: port: ${apiml.service.port} ssl: diff --git a/common-service-core/src/main/java/org/zowe/apiml/security/SecurityUtils.java b/common-service-core/src/main/java/org/zowe/apiml/security/SecurityUtils.java index f56a59426e..725d05744c 100644 --- a/common-service-core/src/main/java/org/zowe/apiml/security/SecurityUtils.java +++ b/common-service-core/src/main/java/org/zowe/apiml/security/SecurityUtils.java @@ -25,6 +25,7 @@ import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -113,7 +114,7 @@ public static Certificate[] loadCertificateChain(HttpsConfig config) throws Cert * @throws IOException */ public static Set loadCertificateChainBase64(HttpsConfig config) throws CertificateException, NoSuchAlgorithmException, KeyStoreException, IOException { - final Set out = new HashSet<>(); + final Set out = ConcurrentHashMap.newKeySet(); for (Certificate certificate : loadCertificateChain(config)) { final byte[] certificateEncoded = certificate.getPublicKey().getEncoded(); final String base64 = Base64.getEncoder().encodeToString(certificateEncoded); diff --git a/config/local/gateway-service.yml b/config/local/gateway-service.yml index a7a95454f3..ac7a4e4d5d 100644 --- a/config/local/gateway-service.yml +++ b/config/local/gateway-service.yml @@ -33,6 +33,9 @@ apiml: verifySslCertificatesOfServices: true x509: enabled: true + acceptForwardedCert: false + certificatesUrl: https://localhost:10023/gateway/certificates + banner: console spring: diff --git a/gateway-package/src/main/resources/bin/start.sh b/gateway-package/src/main/resources/bin/start.sh index c45d8fd126..a606db8ad4 100755 --- a/gateway-package/src/main/resources/bin/start.sh +++ b/gateway-package/src/main/resources/bin/start.sh @@ -41,6 +41,8 @@ # - ZWE_configs_apiml_security_x509_enabled # - ZWE_configs_apiml_security_x509_externalMapperUrl # - ZWE_configs_apiml_security_x509_externalMapperUser +# - ZWE_configs_apiml_security_x509_acceptForwardedCert +# - ZWE_configs_apiml_security_x509_certificatesUrl # - ZWE_configs_apiml_security_zosmf_applid # - ZWE_configs_apiml_security_oidc_enabled # - ZWE_configs_apiml_security_oidc_clientId @@ -248,6 +250,8 @@ _BPX_JOBNAME=${ZWE_zowe_job_prefix}${GATEWAY_CODE} java \ -Dapiml.security.x509.enabled=${ZWE_configs_apiml_security_x509_enabled:-false} \ -Dapiml.security.x509.externalMapperUrl=${ZWE_configs_apiml_security_x509_externalMapperUrl:-"https://${ZWE_haInstance_hostname:-localhost}:${ZWE_configs_port:-7554}/zss/api/v1/certificate/x509/map"} \ -Dapiml.security.x509.externalMapperUser=${ZWE_configs_apiml_security_x509_externalMapperUser:-${ZWE_zowe_setup_security_users_zowe:-ZWESVUSR}} \ + -Dapiml.security.x509.acceptForwardedCert=${ZWE_configs_apiml_security_x509_acceptForwardedCert:-false} \ + -Dapiml.security.x509.certificatesUrl=${ZWE_configs_apiml_security_x509_certificatesUrl:-} \ -Dapiml.security.authorization.provider=${ZWE_configs_apiml_security_authorization_provider:-} \ -Dapiml.security.authorization.endpoint.enabled=${ZWE_configs_apiml_security_authorization_endpoint_enabled:-false} \ -Dapiml.security.authorization.endpoint.url=${ZWE_configs_apiml_security_authorization_endpoint_url:-"https://${ZWE_haInstance_hostname:-localhost}:${ZWE_configs_port:-7554}/zss/api/v1/saf-auth"} \ diff --git a/gateway-package/src/main/resources/manifest.yaml b/gateway-package/src/main/resources/manifest.yaml index 70ffe1175b..2bca9e52e4 100644 --- a/gateway-package/src/main/resources/manifest.yaml +++ b/gateway-package/src/main/resources/manifest.yaml @@ -55,6 +55,10 @@ configs: externalMapperUrl: # default value is Zowe runtime user defined in zowe.yaml "zowe.setup.security.users.zowe" externalMapperUser: + # Enables consumption of forwarded client certificate from proxy gateway in a special request header + acceptForwardedCert: false + # full URL to proxy gateway endpoint which provides certificate chain (/gateway/certificates) + certificatesUrl: oidc: enabled: false clientId: diff --git a/gateway-service/src/main/java/org/zowe/apiml/gateway/security/config/NewSecurityConfiguration.java b/gateway-service/src/main/java/org/zowe/apiml/gateway/security/config/NewSecurityConfiguration.java index 667c62464b..afb48e9db8 100644 --- a/gateway-service/src/main/java/org/zowe/apiml/gateway/security/config/NewSecurityConfiguration.java +++ b/gateway-service/src/main/java/org/zowe/apiml/gateway/security/config/NewSecurityConfiguration.java @@ -64,6 +64,7 @@ import org.zowe.apiml.security.common.filter.StoreAccessTokenInfoFilter; import org.zowe.apiml.security.common.handler.FailedAuthenticationHandler; import org.zowe.apiml.security.common.login.*; +import org.zowe.apiml.security.common.verify.CertificateValidator; import java.util.Collections; import java.util.Set; @@ -100,6 +101,7 @@ public class NewSecurityConfiguration { private final FailedAccessTokenHandler failedAccessTokenHandler; @Qualifier("publicKeyCertificatesBase64") private final Set publicKeyCertificatesBase64; + private final CertificateValidator certificateValidator; private final X509AuthenticationProvider x509AuthenticationProvider; @Value("${server.attls.enabled:false}") private boolean isAttlsEnabled; @@ -160,7 +162,7 @@ private class CustomSecurityFilters extends AbstractHttpConfigurer { @Override public void configure(HttpSecurity http) { - http.addFilterBefore(new CategorizeCertsFilter(publicKeyCertificatesBase64), org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter.class) + http.addFilterBefore(new CategorizeCertsFilter(publicKeyCertificatesBase64, certificateValidator), org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter.class) .addFilterBefore(loginFilter(http), org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter.class) .addFilterAfter(x509AuthenticationFilter(), org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter.class); } @@ -559,9 +561,9 @@ private BearerContentFilter bearerContentFilter(AuthenticationManager authentica } private CategorizeCertsFilter reversedCategorizeCertFilter() { - CategorizeCertsFilter out = new CategorizeCertsFilter(publicKeyCertificatesBase64); + CategorizeCertsFilter out = new CategorizeCertsFilter(publicKeyCertificatesBase64, certificateValidator); out.setCertificateForClientAuth(crt -> out.getPublicKeyCertificatesBase64().contains(out.base64EncodePublicKey(crt))); - out.setNotCertificateForClientAuth(crt -> !out.getPublicKeyCertificatesBase64().contains(out.base64EncodePublicKey(crt))); + out.setApimlCertificate(crt -> !out.getPublicKeyCertificatesBase64().contains(out.base64EncodePublicKey(crt))); return out; } } @@ -613,7 +615,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .permitAll() .and().logout().disable() // sort out client and apiml internal certificates - .addFilterBefore(new CategorizeCertsFilter(publicKeyCertificatesBase64), AnonymousAuthenticationFilter.class) + .addFilterBefore(new CategorizeCertsFilter(publicKeyCertificatesBase64, certificateValidator), AnonymousAuthenticationFilter.class) .build(); } } diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml index 4b60506a47..a92173051b 100644 --- a/gateway-service/src/main/resources/application.yml +++ b/gateway-service/src/main/resources/application.yml @@ -86,6 +86,8 @@ apiml: provider: zosmf x509: enabled: false + acceptForwardedCert: false + certificatesUrl: externalMapperUrl: saf: provider: rest diff --git a/gateway-service/src/main/resources/ehcache.xml b/gateway-service/src/main/resources/ehcache.xml index 5b7fc01a4c..19dceab253 100644 --- a/gateway-service/src/main/resources/ehcache.xml +++ b/gateway-service/src/main/resources/ehcache.xml @@ -15,5 +15,6 @@ + diff --git a/integration-tests/src/test/java/org/zowe/apiml/integration/authentication/schemes/PassticketSchemeTest.java b/integration-tests/src/test/java/org/zowe/apiml/integration/authentication/schemes/PassticketSchemeTest.java index bb3bb003a6..05833a51be 100644 --- a/integration-tests/src/test/java/org/zowe/apiml/integration/authentication/schemes/PassticketSchemeTest.java +++ b/integration-tests/src/test/java/org/zowe/apiml/integration/authentication/schemes/PassticketSchemeTest.java @@ -97,7 +97,6 @@ class GivenCloudGatewayUrlTests { @Test @Tag("CloudGatewayServiceRouting") void givenValidJWT_thenTranslateToPassticket() { - RestAssured.useRelaxedHTTPSValidation(); String scgUrl = String.format("%s://%s:%s%s", conf.getScheme(), conf.getHost(), conf.getPort(), REQUEST_INFO_ENDPOINT); verifyPassTicketHeaders( given().cookie(COOKIE_NAME, jwt) @@ -110,15 +109,14 @@ void givenValidJWT_thenTranslateToPassticket() { @Test @Tag("CloudGatewayServiceRouting") void givenNoJWT_thenErrorHeaderIsCreated() { - RestAssured.useRelaxedHTTPSValidation(); String scgUrl = String.format("%s://%s:%s%s", conf.getScheme(), conf.getHost(), conf.getPort(), REQUEST_INFO_ENDPOINT); given() .when() .get(scgUrl) .then() + .statusCode(SC_OK) .body("headers.x-zowe-auth-failure", startsWith("ZWEAG141E")) - .header(ApimlConstants.AUTH_FAIL_HEADER, startsWith("ZWEAG141E")) - .statusCode(200); + .header(ApimlConstants.AUTH_FAIL_HEADER, startsWith("ZWEAG141E")); } } diff --git a/integration-tests/src/test/java/org/zowe/apiml/integration/authentication/schemes/X509SchemeTest.java b/integration-tests/src/test/java/org/zowe/apiml/integration/authentication/schemes/X509SchemeTest.java index fd8d2a7904..5eb4fd9f9e 100644 --- a/integration-tests/src/test/java/org/zowe/apiml/integration/authentication/schemes/X509SchemeTest.java +++ b/integration-tests/src/test/java/org/zowe/apiml/integration/authentication/schemes/X509SchemeTest.java @@ -27,7 +27,7 @@ import java.net.URI; import static io.restassured.RestAssured.given; -import static org.hamcrest.CoreMatchers.startsWith; +import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.core.Is.is; import static org.zowe.apiml.util.requests.Endpoints.X509_ENDPOINT; diff --git a/integration-tests/src/test/java/org/zowe/apiml/integration/proxy/CloudGatewayProxyTest.java b/integration-tests/src/test/java/org/zowe/apiml/integration/proxy/CloudGatewayProxyTest.java index f7fbe0a5a5..52c210e727 100644 --- a/integration-tests/src/test/java/org/zowe/apiml/integration/proxy/CloudGatewayProxyTest.java +++ b/integration-tests/src/test/java/org/zowe/apiml/integration/proxy/CloudGatewayProxyTest.java @@ -11,19 +11,27 @@ package org.zowe.apiml.integration.proxy; import io.restassured.RestAssured; +import org.apache.commons.lang3.StringUtils; import org.apache.http.HttpStatus; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; -import org.zowe.apiml.util.config.CloudGatewayConfiguration; -import org.zowe.apiml.util.config.ConfigReader; +import org.zowe.apiml.security.HttpsConfig; +import org.zowe.apiml.security.SecurityUtils; +import org.zowe.apiml.util.config.*; import java.net.URI; import java.net.URISyntaxException; +import java.security.cert.Certificate; import java.time.Duration; +import java.util.Base64; import static io.restassured.RestAssured.given; -import static org.junit.jupiter.api.Assertions.assertTimeout; -import static org.zowe.apiml.util.requests.Endpoints.DISCOVERABLE_GREET; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.startsWith; +import static org.junit.jupiter.api.Assertions.*; +import static org.zowe.apiml.util.requests.Endpoints.*; @Tag("CloudGatewayProxyTest") class CloudGatewayProxyTest { @@ -32,12 +40,18 @@ class CloudGatewayProxyTest { private static final String HEADER_X_FORWARD_TO = "X-Forward-To"; - static CloudGatewayConfiguration conf = ConfigReader.environmentConfiguration().getCloudGatewayConfiguration(); + static CloudGatewayConfiguration conf; - @Test - void givenRequestHeader_thenRouteToProvidedHost() throws URISyntaxException { + @BeforeAll + static void init() throws Exception { RestAssured.useRelaxedHTTPSValidation(); + SslContext.prepareSslAuthentication(ItSslConfigFactory.integrationTests()); + + conf = ConfigReader.environmentConfiguration().getCloudGatewayConfiguration(); + } + @Test + void givenRequestHeader_thenRouteToProvidedHost() throws URISyntaxException { String scgUrl = String.format("%s://%s:%s/%s", conf.getScheme(), conf.getHost(), conf.getPort(), "gateway/version"); given().header(HEADER_X_FORWARD_TO, "apiml1") .get(new URI(scgUrl)).then().statusCode(200); @@ -47,8 +61,6 @@ void givenRequestHeader_thenRouteToProvidedHost() throws URISyntaxException { @Test void givenBasePath_thenRouteToProvidedHost() throws URISyntaxException { - RestAssured.useRelaxedHTTPSValidation(); - String scgUrl1 = String.format("%s://%s:%s/%s", conf.getScheme(), conf.getHost(), conf.getPort(), "apiml1/gateway/version"); String scgUrl2 = String.format("%s://%s:%s/%s", conf.getScheme(), conf.getHost(), conf.getPort(), "apiml2/gateway/version"); given().get(new URI(scgUrl1)).then().statusCode(200); @@ -57,7 +69,6 @@ void givenBasePath_thenRouteToProvidedHost() throws URISyntaxException { @Test void givenRequestTimeoutIsReached_thenDropConnection() { - RestAssured.useRelaxedHTTPSValidation(); String scgUrl = String.format("%s://%s:%s%s?%s=%d", conf.getScheme(), conf.getHost(), conf.getPort(), DISCOVERABLE_GREET, "delayMs", DEFAULT_TIMEOUT + SECOND); assertTimeout(Duration.ofMillis(DEFAULT_TIMEOUT * 3), () -> { given() @@ -68,4 +79,83 @@ void givenRequestTimeoutIsReached_thenDropConnection() { .statusCode(HttpStatus.SC_GATEWAY_TIMEOUT); }); } + + @Nested + class GivenClientCertificateInRequest { + + @Test + void givenRequestHeader_thenCertPassedToDomainGateway() { + String scgUrl = String.format("%s://%s:%s%s", conf.getScheme(), conf.getHost(), conf.getPort(), X509_ENDPOINT); + given() + .config(SslContext.clientCertValid) + .header(HEADER_X_FORWARD_TO, "apiml1") + .when() + .get(scgUrl) + .then() + .statusCode(HttpStatus.SC_OK) + .body("dn", startsWith("CN=APIMTST")) + .body("cn", is("APIMTST")); + } + + @Test + void givenBasePath_thenCertPassedToDomainGateway() { + String scgUrl = String.format("%s://%s:%s/%s%s", conf.getScheme(), conf.getHost(), conf.getPort(), "apiml1", X509_ENDPOINT); + given() + .config(SslContext.clientCertValid) + .when() + .get(scgUrl) + .then() + .statusCode(HttpStatus.SC_OK) + .body("dn", startsWith("CN=APIMTST")) + .body("cn", is("APIMTST")); + } + } + + @Nested + class GivenGatewayCertificatesRequest { + + private final String trustedCerts; + + { + TlsConfiguration tlsConf = ConfigReader.environmentConfiguration().getTlsConfiguration(); + HttpsConfig httpsConf = HttpsConfig.builder() + .keyAlias(tlsConf.getKeyAlias()) + .keyStore(tlsConf.getKeyStore()) + .keyPassword(tlsConf.getKeyPassword()) + .keyStorePassword(tlsConf.getKeyStorePassword()) + .keyStoreType(tlsConf.getKeyStoreType()) + .build(); + + // build the expected certificate chain + final Base64.Encoder mimeEncoder = Base64.getMimeEncoder(64, "\n".getBytes()); + StringBuilder sb = new StringBuilder(); + try { + for (Certificate cert : SecurityUtils.loadCertificateChain(httpsConf)) { + sb.append("-----BEGIN CERTIFICATE-----\n") + .append(mimeEncoder.encodeToString(cert.getEncoded())).append("\n") + .append("-----END CERTIFICATE-----\n"); + } + } catch (Exception e) { + fail("Failed to load certificate chain.", e); + } + trustedCerts = sb.toString(); + assertTrue(StringUtils.isNotEmpty(trustedCerts)); + } + + @Test + void thenCertificatesChainProvided() throws URISyntaxException { + String scgUrl = String.format("%s://%s:%s%s", conf.getScheme(), conf.getHost(), conf.getPort(), CLOUD_GATEWAY_CERTIFICATES); + String response = + given() + .when() + .get(new URI(scgUrl)) + .then() + .statusCode(HttpStatus.SC_OK) + .extract().body().asString(); + + assertTrue(StringUtils.isNotEmpty(response)); + assertEquals(trustedCerts, response); + } + + } } diff --git a/integration-tests/src/test/java/org/zowe/apiml/util/requests/Endpoints.java b/integration-tests/src/test/java/org/zowe/apiml/util/requests/Endpoints.java index f3c216da4c..eb32410714 100644 --- a/integration-tests/src/test/java/org/zowe/apiml/util/requests/Endpoints.java +++ b/integration-tests/src/test/java/org/zowe/apiml/util/requests/Endpoints.java @@ -70,4 +70,6 @@ public class Endpoints { public final static String API_SERVICE_VERSION_DIFF_ENDPOINT = "/apicatalog/api/v1/apidoc/discoverableclient/zowe.apiml.discoverableclient.rest v1.0.0/zowe.apiml.discoverableclient.rest v2.0.0"; public final static String API_SERVICE_VERSION_DIFF_ENDPOINT_WRONG_VERSION = "/apicatalog/api/v1/apidoc/discoverableclient/zowe.apiml.discoverableclient.rest v1.0.0/zowe.apiml.discoverableclient.rest v3.0.0"; public final static String API_SERVICE_VERSION_DIFF_ENDPOINT_WRONG_SERVICE = "/apicatalog/api/v1/apidoc/invalidService/v1/v2"; + + public final static String CLOUD_GATEWAY_CERTIFICATES = "/gateway/certificates"; } diff --git a/keystore/docker/all-services.ext b/keystore/docker/all-services.ext index b423a14dd6..a2070cfd25 100644 --- a/keystore/docker/all-services.ext +++ b/keystore/docker/all-services.ext @@ -23,3 +23,5 @@ DNS.17 = mock-services-2 DNS.18 = metrics-service DNS.19 = metrics-service-2 DNS.20 = reverse-proxy +DNS.21 = cloud-gateway-service +DNS.22 = cloud-gateway-service-2 diff --git a/keystore/docker/all-services.keystore.p12 b/keystore/docker/all-services.keystore.p12 index a2d913aad8ffe6e48bde35602732c51e0ca7de15..96e547fa4f3202b8be5d46d906c4a3c2eff4ad11 100644 GIT binary patch delta 5651 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 delta 4281 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 bYNG@^f}wd|$uib=k_x17kT*vH0|ADh$>SaV