diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 23af7abd..303dc77f 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -13,10 +13,10 @@ jobs: - uses: actions/checkout@v2 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v1 with: - java-version: 11 + java-version: 17 - name: Cache SonarCloud packages uses: actions/cache@v1 with: @@ -33,4 +33,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: mvn -s maven/cnaf-mirror-settings.xml -B -U install org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=italiangrid_storm-webdav \ No newline at end of file + run: mvn -s maven/cnaf-mirror-settings.xml -B -U install org.sonarsource.scanner.maven:sonar-maven-plugin:sonar -Dsonar.projectKey=italiangrid_storm-webdav diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb6334e..435384f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,36 +1,65 @@ # Changelog +## 1.4.2 (2023-06-27) + +## Description + +This release: + +* upgrades significant dependencies (spring-boot, canl, bouncycastle, jQuery) +* removes the support for TRACE method +* tunes some default values (default TPC timeout, default heap size, etc.) +* and fixes other minor bugs/issues. + +### fixes + +* [[STOR-1396](https://issues.infn.it/jira/browse/STOR-1396)] - Ensure adler32 checksums are always 8 chars long +* [[STOR-1450](https://issues.infn.it/jira/browse/STOR-1450)] - Increase default timeout for TPC to 30 seconds +* [[STOR-1500](https://issues.infn.it/jira/browse/STOR-1500)] - When redis is disabled the health indicator for redis should be disabled +* [[STOR-1574](https://issues.infn.it/jira/browse/STOR-1574)] - Old java/canl creates problems with encoding of subject/issuer names in self-signed certificates +* [[STOR-1440](https://issues.infn.it/jira/browse/STOR-1440)] - StoRM WebDAV should configure a bigger heap by default +* [[STOR-1497](https://issues.infn.it/jira/browse/STOR-1497)] - Upgrade canl-java to v2.6.0 +* [[STOR-1515](https://issues.infn.it/jira/browse/STOR-1515)] - StoRM WebDAV metrics on TPC.pull/push.throughput +* [[STOR-1555](https://issues.infn.it/jira/browse/STOR-1555)] - Upgrade jQuery version +* [[STOR-1556](https://issues.infn.it/jira/browse/STOR-1556)] - Remove TRACE from allowed methods +* [[STOR-1557](https://issues.infn.it/jira/browse/STOR-1557)] - Upgrade Spring Boot version to the latest +* [[STOR-1558](https://issues.infn.it/jira/browse/STOR-1558)] - Update bouncycastle version to 1.67 +* [[STOR-1576](https://issues.infn.it/jira/browse/STOR-1576)] - Add .well-known endpoint for StoRM WebDAV to point to the Tape REST endpoint + + +## 1.4.1 (2021-05-12) + +This release fixes the failed state shown on stop/restart of the service due to a misunderstood exit code meaning. + +### Fixed + +- [[STOR-1400](https://issues.infn.it/jira/browse/STOR-1400)] - StoRM WebDAV service enters failed state when stopped + ## 1.4.0 (2021-04-01) ### Added -- [Add support for externalized session management](https://issues.infn.it/jira/browse/STOR-1336) +- [[STOR-1336](https://issues.infn.it/jira/browse/STOR-1336)] - Add support for externalized session management ### Fixed -- [Login with OIDC button not shown for error - pages](https://issues.infn.it/jira/browse/STOR-1335) -- [StoRM WebDAV: Login with OIDC button displayed only on storage area index - page]( https://issues.infn.it/jira/browse/STOR-1332) -- [StoRM WebDAV rpm doesn't set the proper ownership on - /var/log/storm](https://issues.infn.it/jira/browse/STOR-1298) -- [StoRM WebDAV package should install Java - 11](https://issues.infn.it/jira/browse/STOR-1358) +- [[STOR-1335](https://issues.infn.it/jira/browse/STOR-1335)] - Login with OIDC button not shown for error + pages +- [[STOR-1332](https://issues.infn.it/jira/browse/STOR-1332)] - Login with OIDC button displayed only on storage area index page +- [[STOR-1298](https://issues.infn.it/jira/browse/STOR-1298)] - StoRM WebDAV RPM doesn't set the proper ownership on `/var/log/storm` +- [[STOR-1358](https://issues.infn.it/jira/browse/STOR-1358)] - StoRM WebDAV package should install Java 11 ## 1.2.0 (2019-08-??) ### Added -- [Spring boot updated to 2.1.4.RELEASE][STOR-1098] -- [Introduced support for Conscrypt JSSE provider to improve TLS - performace][STOR-1097] +- [[STOR-1098](https://issues.infn.it/jira/browse/STOR-1098)] - Spring boot updated to 2.1.4.RELEASE +- [[STOR-1097](https://issues.infn.it/jira/browse/STOR-1097)] - Introduced support for Conscrypt JSSE provider to improve TLS performance ### Fixed -- [StoRM WebDAV default configuration does not depend anymore on - iam-test.indigo-datacloud.eu][STOR-1095] -- [Unreachable OpenID Connect provider causes StoRM WebDAV startup - failure][STOR-1096] +- [[STOR-1095](https://issues.infn.it/jira/browse/STOR-1095)] - StoRM WebDAV default configuration does not depend anymore on `iam-test.indigo-datacloud.eu` +- [[STOR-1096](https://issues.infn.it/jira/browse/STOR-1096)] - Unreachable OpenID Connect provider causes StoRM WebDAV startup failure ## 1.1.0 (2019-02-28) @@ -46,8 +75,3 @@ - POST handled as GET fixed - -[STOR-1095]: https://issues.infn.it/jira/browse/STOR-1095 -[STOR-1096]: https://issues.infn.it/jira/browse/STOR-1096 -[STOR-1097]: https://issues.infn.it/jira/browse/STOR-1097 -[STOR-1098]: https://issues.infn.it/jira/browse/STOR-1098 diff --git a/etc/storm-webdav/logback-access.xml b/etc/storm-webdav/logback-access.xml index a5f130c0..629060cc 100644 --- a/etc/storm-webdav/logback-access.xml +++ b/etc/storm-webdav/logback-access.xml @@ -11,7 +11,7 @@ - %a %localPort "%reqAttribute{storm.remoteUser}" %date{"yyyy-MM-dd'T'HH:mm:ss.SSSXXX", UTC} "%reqAttribute{storm.requestId}" "%m %U %H" %s %b %D + %replace(%a){'^$','-'} %localPort "%reqAttribute{storm.remoteUser}" %date{"yyyy-MM-dd'T'HH:mm:ss.SSSXXX", UTC} "%reqAttribute{storm.requestId}" "%m %U %H" %s %b %D diff --git a/etc/systemd/system/storm-webdav.service.d/storm-webdav.conf b/etc/systemd/system/storm-webdav.service.d/storm-webdav.conf index f173bd26..5ecdd990 100644 --- a/etc/systemd/system/storm-webdav.service.d/storm-webdav.conf +++ b/etc/systemd/system/storm-webdav.service.d/storm-webdav.conf @@ -127,3 +127,36 @@ Environment="STORM_WEBDAV_TPC_MAX_CONNECTIONS_PER_ROUTE=25" # Source file for the tape REST API well-known endpoint # Default: '/etc/storm/webdav/wlcg-tape-rest-api.json' # Environment="STORM_WEBDAV_TAPE_WELLKNOWN_SOURCE=/etc/storm/webdav/wlcg-tape-rest-api.json" + +# Buffer size for both internal and third-party copy requests. +# This adds more efficiency than to write the whole data. Valid values are numbers >= 4096. +# Default: 1048576 +# Environment="STORM_WEBDAV_BUFFER_FILE_BUFFER_SIZE_BYTES=1048576" + +# Enable checksum filter which adds checksum as an header following RFC 3230. +# Default: true +# Environment="STORM_WEBDAV_CHECKSUM_FILTER_ENABLED=true" + +# Enable Macaroon filter to process Macaroon tokens. Requires authz server enabled. +# Default: true +# Environment="STORM_WEBDAV_MACAROON_FILTER_ENABLED=true" + +# TLS protocol for non-TPC requests +# Default: TLS +# Environment="STORM_WEBDAV_TLS_PROTOCOL=TLS" + +# VOMS Trust Store directory +# Default: /etc/grid-security/vomsdir +# Environment="STORM_WEBDAV_VOMS_TRUST_STORE_DIR=/etc/grid-security/vomsdir" + +# VOMS Trust Store refresh interval +# Default: 43200 +# Environment="STORM_WEBDAV_VOMS_TRUST_STORE_REFRESH_INTERVAL_SEC=43200" + +# Enable caching for VOMS certificate validation +# Default: true +# Environment="STORM_WEBDAV_VOMS_CACHE_ENABLE=true" + +# Cache entries lifetime, used if caching for VOMS certificate validation is enabled +# Default: 300 +# Environment="STORM_WEBDAV_VOMS_CACHE_ENTRY_LIFETIME_SEC=300" \ No newline at end of file diff --git a/pom.xml b/pom.xml index 92ee39e4..98940045 100644 --- a/pom.xml +++ b/pom.xml @@ -12,7 +12,7 @@ org.springframework.boot spring-boot-starter-parent - 2.7.10 + 2.7.18 @@ -20,8 +20,7 @@ UTF-8 UTF-8 - 3.6.0 - 11 + 3.8.0 2.4 2.4 @@ -32,14 +31,14 @@ 11 - 2.7.10 + 2.7.18 italiangrid_storm-webdav italiangrid https://sonarcloud.io - 0.4.6.v20220506 + 3.3.3 2.7.1.7 2.3 @@ -50,14 +49,13 @@ 4.2.2 4.2.1 - 31.1-jre + 32.0.0-jre 1.0.5.1 2.3.3.RELEASE 6.0.2 5.5.1 - 1.72 @@ -65,16 +63,6 @@ ${project.name} - - - org.apache.maven.plugins - maven-compiler-plugin - - 11 - 11 - - - org.springframework.boot spring-boot-maven-plugin @@ -99,7 +87,6 @@ src/assembly/tarball.xml - storm-webdav @@ -107,6 +94,9 @@ single + + false + @@ -190,13 +180,6 @@ org.springframework.boot spring-boot-starter-actuator - - - - org.apache.logging.log4j - log4j-api - - @@ -256,6 +239,12 @@ org.springframework.boot spring-boot-starter-test test + + + com.vaadin.external.google + android-json + + @@ -326,11 +315,6 @@ metrics-core - - io.dropwizard.metrics - metrics-jvm - - io.dropwizard.metrics metrics-jetty9 @@ -348,45 +332,30 @@ - org.italiangrid - jetty-utils - ${jetty-utils.version} - - - javax.activation - activation - - - javax.mail - mail - - - org.eclipse.jetty.aggregate - jetty-all - - - ch.qos.logback - logback-core - - - ch.qos.logback - logback-classic - - + org.eclipse.jetty.http2 + http2-server + + + + org.eclipse.jetty + jetty-alpn-conscrypt-server - org.bouncycastle - bcpkix-jdk18on - ${bouncycastle.version} + org.slf4j + slf4j-api - org.bouncycastle - bcprov-jdk18on - ${bouncycastle.version} + org.slf4j + log4j-over-slf4j + + org.italiangrid + voms-api-java + ${voms-api-java.version} + ch.qos.logback diff --git a/src/main/java/org/italiangrid/storm/webdav/config/OAuthProperties.java b/src/main/java/org/italiangrid/storm/webdav/config/OAuthProperties.java index bd833381..aab8d1f6 100644 --- a/src/main/java/org/italiangrid/storm/webdav/config/OAuthProperties.java +++ b/src/main/java/org/italiangrid/storm/webdav/config/OAuthProperties.java @@ -96,6 +96,9 @@ public boolean isEnforceAudienceChecks() { @Min(value = 1, message = "The refresh period must be a positive integer") int refreshPeriodMinutes = 60; + @Min(value = 1, message = "The refresh timeout must be a positive integer") + int refreshTimeoutSeconds = 30; + public List getIssuers() { return issuers; } @@ -112,6 +115,14 @@ public void setRefreshPeriodMinutes(int refreshPeriodMinutes) { this.refreshPeriodMinutes = refreshPeriodMinutes; } + public int getRefreshTimeoutSeconds() { + return refreshTimeoutSeconds; + } + + public void setRefreshTimeoutSeconds(int refreshTimeoutSeconds) { + this.refreshTimeoutSeconds = refreshTimeoutSeconds; + } + public void setEnableOidc(boolean enableOidc) { this.enableOidc = enableOidc; } diff --git a/src/main/java/org/italiangrid/storm/webdav/config/ServiceConfiguration.java b/src/main/java/org/italiangrid/storm/webdav/config/ServiceConfiguration.java index 9b3a0e03..c5fc69a0 100644 --- a/src/main/java/org/italiangrid/storm/webdav/config/ServiceConfiguration.java +++ b/src/main/java/org/italiangrid/storm/webdav/config/ServiceConfiguration.java @@ -33,10 +33,14 @@ public interface ServiceConfiguration { public long getTrustAnchorsRefreshIntervalInSeconds(); + public int getMinConnections(); + public int getMaxConnections(); public int getMaxQueueSize(); + public int getThreadPoolMaxIdleTimeInMsec(); + public int getConnectorMaxIdleTimeInMsec(); public String getSAConfigDir(); diff --git a/src/main/java/org/italiangrid/storm/webdav/config/ServiceConfigurationProperties.java b/src/main/java/org/italiangrid/storm/webdav/config/ServiceConfigurationProperties.java index b6e09321..dbacabad 100644 --- a/src/main/java/org/italiangrid/storm/webdav/config/ServiceConfigurationProperties.java +++ b/src/main/java/org/italiangrid/storm/webdav/config/ServiceConfigurationProperties.java @@ -249,6 +249,20 @@ public void setPolicies(List policies) { } } + public static class ServerProperties { + + @Positive + int maxIdleTimeMsec = 3600000; + + public int getMaxIdleTimeMsec() { + return maxIdleTimeMsec; + } + + public void setMaxIdleTimeMsec(int maxIdleTimeMsec) { + this.maxIdleTimeMsec = maxIdleTimeMsec; + } + } + public static class ConnectorProperties { @Positive @@ -260,10 +274,13 @@ public static class ConnectorProperties { int securePort = 8443; @Positive - int maxConnections = 200; + int minConnections = 50; + + @Positive + int maxConnections = 300; @Positive - int maxQueueSize = 512; + int maxQueueSize = 900; @Positive int maxIdleTimeMsec = 30000; @@ -292,6 +309,14 @@ public void setSecurePort(int securePort) { this.securePort = securePort; } + public int getMinConnections() { + return minConnections; + } + + public void setMinConnections(int minConnections) { + this.minConnections = minConnections; + } + public int getMaxConnections() { return maxConnections; } @@ -597,6 +622,8 @@ public void setTrustStore(VOMSTrustStoreProperties trustStore) { private MacaroonFilterProperties macaroonFilter; + private ServerProperties server; + private ConnectorProperties connector; private TLSProperties tls; @@ -643,12 +670,18 @@ public void setTls(TLSProperties tls) { this.tls = tls; } + public ServerProperties getServer() { + return server; + } + + public void setServer(ServerProperties server) { + this.server = server; + } public ConnectorProperties getConnector() { return connector; } - public void setConnector(ConnectorProperties connector) { this.connector = connector; } @@ -732,55 +765,56 @@ public long getTrustAnchorsRefreshIntervalInSeconds() { return getTls().getTrustAnchorsRefreshIntervalSecs(); } + @Override + public int getMinConnections() { + return getConnector().getMinConnections(); + } @Override public int getMaxConnections() { return getConnector().getMaxConnections(); } - @Override public int getMaxQueueSize() { return getConnector().getMaxQueueSize(); } + @Override + public int getThreadPoolMaxIdleTimeInMsec() { + return getServer().getMaxIdleTimeMsec(); + } @Override public int getConnectorMaxIdleTimeInMsec() { return getConnector().getMaxIdleTimeMsec(); } - @Override public String getSAConfigDir() { return getSa().getConfigDir(); } - @Override public boolean enableVOMapFiles() { return getVoMapFiles().isEnabled(); } - @Override public String getVOMapFilesConfigDir() { return getVoMapFiles().getConfigDir(); } - @Override public long getVOMapFilesRefreshIntervalInSeconds() { return getVoMapFiles().getRefreshIntervalSec(); } - @Override public boolean isAuthorizationDisabled() { return getAuthz().isDisabled(); } - @Override public boolean requireClientCertificateAuthentication() { return getTls().isRequireClientCert(); @@ -887,4 +921,5 @@ public TapeProperties getTape() { public void setTape(TapeProperties tape) { this.tape = tape; } + } diff --git a/src/main/java/org/italiangrid/storm/webdav/oauth/utils/DefaultOidcConfigurationFetcher.java b/src/main/java/org/italiangrid/storm/webdav/oauth/utils/DefaultOidcConfigurationFetcher.java index 090ee2db..4515bf3c 100644 --- a/src/main/java/org/italiangrid/storm/webdav/oauth/utils/DefaultOidcConfigurationFetcher.java +++ b/src/main/java/org/italiangrid/storm/webdav/oauth/utils/DefaultOidcConfigurationFetcher.java @@ -18,34 +18,47 @@ import static java.lang.String.format; import java.net.URI; +import java.time.Duration; +import java.util.Arrays; import java.util.Map; +import java.util.Objects; +import org.italiangrid.storm.webdav.config.OAuthProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; +import com.nimbusds.jose.KeySourceException; +import com.nimbusds.jose.RemoteKeySourceException; + @Service public class DefaultOidcConfigurationFetcher implements OidcConfigurationFetcher { public static final String WELL_KNOWN_FRAGMENT = "/.well-known/openid-configuration"; public static final String ISSUER_MISMATCH_ERROR_TEMPLATE = "Issuer in metadata '%s' does not match with requested issuer '%s'"; - public static final String NO_JWKS_URI_ERROR_TEMPLATE = + public static final String NO_JWKS_URI_ERROR_TEMPLATE = "No jwks_uri found in metadata for issuer '%s'"; + private static final MediaType APPLICATION_JWK_SET_JSON = + new MediaType("application", "jwk-set+json"); + public static final Logger LOG = LoggerFactory.getLogger(DefaultOidcConfigurationFetcher.class); - final RestTemplateBuilder restBuilder; + final RestTemplate restTemplate; - @Autowired - public DefaultOidcConfigurationFetcher(RestTemplateBuilder restBuilder) { - this.restBuilder = restBuilder; + public DefaultOidcConfigurationFetcher(RestTemplateBuilder restBuilder, + OAuthProperties oAuthProperties) { + final Duration timeout = Duration.ofSeconds(oAuthProperties.getRefreshTimeoutSeconds()); + this.restTemplate = restBuilder.setConnectTimeout(timeout).setReadTimeout(timeout).build(); } private void metadataChecks(String issuer, Map oidcConfiguration) { @@ -59,40 +72,63 @@ private void metadataChecks(String issuer, Map oidcConfiguration throw new OidcConfigurationResolutionError( format(ISSUER_MISMATCH_ERROR_TEMPLATE, metadataIssuer, issuer)); } - + if (!oidcConfiguration.containsKey("jwks_uri")) { - throw new OidcConfigurationResolutionError(format(NO_JWKS_URI_ERROR_TEMPLATE,issuer)); + throw new OidcConfigurationResolutionError(format(NO_JWKS_URI_ERROR_TEMPLATE, issuer)); } } @Override public Map loadConfigurationForIssuer(String issuer) { LOG.debug("Fetching OpenID configuration for {}", issuer); - + ParameterizedTypeReference> typeReference = new ParameterizedTypeReference>() {}; - RestTemplate rest = restBuilder.build(); - URI uri = UriComponentsBuilder.fromUriString(issuer + WELL_KNOWN_FRAGMENT).build().toUri(); - + ResponseEntity> response = null; try { - - RequestEntity request = RequestEntity.get(uri).build(); - Map conf = rest.exchange(request, typeReference).getBody(); - metadataChecks(issuer, conf); - return conf; + response = restTemplate.exchange(RequestEntity.get(uri).build(), typeReference); } catch (RuntimeException e) { - final String errorMsg = - format("Unable to resolve OpenID configuration for issuer '%s' from '%s': %s", issuer, - uri, e.getMessage()); - + final String errorMsg = format("Unable to resolve OpenID configuration from '%s'", uri); if (LOG.isDebugEnabled()) { - LOG.error(errorMsg, e); + LOG.error("{}: {}", errorMsg, e.getMessage()); } - throw new OidcConfigurationResolutionError(errorMsg, e); } + if (response.getStatusCodeValue() != 200) { + throw new OidcConfigurationResolutionError( + format("Received status code: %s", response.getStatusCodeValue())); + } + if (Objects.isNull(response.getBody())) { + throw new OidcConfigurationResolutionError("Received null body"); + } + metadataChecks(issuer, response.getBody()); + return response.getBody(); } + @Override + public String loadJWKSourceForURL(URI uri) throws KeySourceException { + + LOG.debug("Fetching JWK from {}", uri); + + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(Arrays.asList(MediaType.APPLICATION_JSON, APPLICATION_JWK_SET_JSON)); + ResponseEntity response = null; + try { + RequestEntity request = RequestEntity.get(uri).headers(headers).build(); + response = restTemplate.exchange(request, String.class); + } catch (RuntimeException e) { + final String errorMsg = format("Unable to get JWK from '%s'", uri); + if (LOG.isDebugEnabled()) { + LOG.error("{}: {}", errorMsg, e.getMessage()); + } + throw new RemoteKeySourceException(errorMsg, e); + } + if (response.getStatusCodeValue() != 200) { + throw new KeySourceException(format("Unable to get JWK from '%s': received status code %s", + uri, response.getStatusCodeValue())); + } + return response.getBody(); + } } diff --git a/src/main/java/org/italiangrid/storm/webdav/oauth/utils/NoExpirationStringCache.java b/src/main/java/org/italiangrid/storm/webdav/oauth/utils/NoExpirationStringCache.java new file mode 100644 index 00000000..4bc9e253 --- /dev/null +++ b/src/main/java/org/italiangrid/storm/webdav/oauth/utils/NoExpirationStringCache.java @@ -0,0 +1,69 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare, 2014-2023. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.italiangrid.storm.webdav.oauth.utils; + +import java.util.concurrent.Callable; + +import org.springframework.cache.support.AbstractValueAdaptingCache; +import org.springframework.lang.Nullable; + +public class NoExpirationStringCache extends AbstractValueAdaptingCache { + + private static final String NAME = "NoExpirationCache"; + private final String value; + + public NoExpirationStringCache(String value) { + super(false); + this.value = value; + } + + @Override + public String getName() { + return NAME; + } + + @Override + public Object getNativeCache() { + return this; + } + + @Override + @Nullable + protected Object lookup(Object key) { + return value; + } + + @Override + public void put(Object key, Object value) { + // Nothing to do + } + + @Override + public void evict(Object key) { + // Nothing to do + } + + @Override + public void clear() { + // Nothing to do + } + + @SuppressWarnings("unchecked") + @Override + public T get(Object key, Callable valueLoader) { + return (T) fromStoreValue(value); + } +} diff --git a/src/main/java/org/italiangrid/storm/webdav/oauth/utils/OidcConfigurationFetcher.java b/src/main/java/org/italiangrid/storm/webdav/oauth/utils/OidcConfigurationFetcher.java index 786e9f1b..7aa30179 100644 --- a/src/main/java/org/italiangrid/storm/webdav/oauth/utils/OidcConfigurationFetcher.java +++ b/src/main/java/org/italiangrid/storm/webdav/oauth/utils/OidcConfigurationFetcher.java @@ -15,10 +15,15 @@ */ package org.italiangrid.storm.webdav.oauth.utils; +import java.net.URI; import java.util.Map; +import com.nimbusds.jose.KeySourceException; + public interface OidcConfigurationFetcher { Map loadConfigurationForIssuer(String issuer); + String loadJWKSourceForURL(URI uri) throws KeySourceException; + } diff --git a/src/main/java/org/italiangrid/storm/webdav/oauth/utils/TrustedJwtDecoderCacheLoader.java b/src/main/java/org/italiangrid/storm/webdav/oauth/utils/TrustedJwtDecoderCacheLoader.java index efde8ee9..f9e19765 100644 --- a/src/main/java/org/italiangrid/storm/webdav/oauth/utils/TrustedJwtDecoderCacheLoader.java +++ b/src/main/java/org/italiangrid/storm/webdav/oauth/utils/TrustedJwtDecoderCacheLoader.java @@ -15,6 +15,7 @@ */ package org.italiangrid.storm.webdav.oauth.utils; +import java.net.URI; import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; @@ -28,8 +29,8 @@ import org.italiangrid.storm.webdav.oauth.validator.WlcgProfileValidator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.cache.Cache; import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.jwt.Jwt; @@ -53,7 +54,6 @@ public class TrustedJwtDecoderCacheLoader extends CacheLoader oidcConfiguration = fetcher.loadConfigurationForIssuer(issuer); + URI jwksUri = URI.create(oidcConfiguration.get("jwks_uri").toString()); + Cache noExpirationCache = + new NoExpirationStringCache(fetcher.loadJWKSourceForURL(jwksUri)); NimbusJwtDecoder decoder = - NimbusJwtDecoder.withJwkSetUri((oidcConfiguration.get("jwks_uri").toString())).build(); + NimbusJwtDecoder.withJwkSetUri((oidcConfiguration.get("jwks_uri").toString())) + .cache(noExpirationCache) + .build(); OAuth2TokenValidator jwtValidator = JwtValidators.createDefaultWithIssuer(issuer); OAuth2TokenValidator wlcgProfileValidator = new WlcgProfileValidator(); diff --git a/src/main/java/org/italiangrid/storm/webdav/server/DefaultJettyServerCustomizer.java b/src/main/java/org/italiangrid/storm/webdav/server/DefaultJettyServerCustomizer.java index 4e798c4e..82c028e0 100644 --- a/src/main/java/org/italiangrid/storm/webdav/server/DefaultJettyServerCustomizer.java +++ b/src/main/java/org/italiangrid/storm/webdav/server/DefaultJettyServerCustomizer.java @@ -32,7 +32,6 @@ import org.italiangrid.storm.webdav.config.ServiceConfiguration; import org.italiangrid.storm.webdav.config.ServiceConfigurationProperties; import org.italiangrid.storm.webdav.config.StorageAreaConfiguration; -import org.italiangrid.utils.jetty.TLSServerConnectorBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.web.ServerProperties; diff --git a/src/main/java/org/italiangrid/storm/webdav/server/DefaultWebServerFactory.java b/src/main/java/org/italiangrid/storm/webdav/server/DefaultWebServerFactory.java index b2fb8dfb..56d286cf 100644 --- a/src/main/java/org/italiangrid/storm/webdav/server/DefaultWebServerFactory.java +++ b/src/main/java/org/italiangrid/storm/webdav/server/DefaultWebServerFactory.java @@ -15,14 +15,16 @@ */ package org.italiangrid.storm.webdav.server; +import java.util.concurrent.ArrayBlockingQueue; + import org.italiangrid.storm.webdav.config.ServiceConfiguration; -import org.italiangrid.utils.jetty.ThreadPoolBuilder; import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.boot.web.embedded.jetty.JettyServerCustomizer; import org.springframework.boot.web.embedded.jetty.JettyServletWebServerFactory; import org.springframework.boot.web.server.WebServerFactoryCustomizer; import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.jetty9.InstrumentedQueuedThreadPool; public class DefaultWebServerFactory implements WebServerFactoryCustomizer { @@ -42,18 +44,19 @@ public DefaultWebServerFactory(ServiceConfiguration configuration, this.metricRegistry = registry; } + private InstrumentedQueuedThreadPool getInstrumentedThreadPool() { + InstrumentedQueuedThreadPool tPool = + new InstrumentedQueuedThreadPool(metricRegistry, configuration.getMaxConnections(), + configuration.getMinConnections(), configuration.getThreadPoolMaxIdleTimeInMsec(), + new ArrayBlockingQueue<>(configuration.getMaxQueueSize()), "storm.http"); + tPool.setName("thread-pool"); + return tPool; + } + @Override public void customize(JettyServletWebServerFactory factory) { - factory.setThreadPool(ThreadPoolBuilder.instance() - .withMaxRequestQueueSize(configuration.getMaxQueueSize()) - .withMaxThreads(serverProperties.getJetty().getThreads().getMax()) - .withMinThreads(serverProperties.getJetty().getThreads().getMin()) - .registry(metricRegistry) - .withPrefix("storm.http") - .withName("thread-pool") - .build()); - + factory.setThreadPool(getInstrumentedThreadPool()); factory.addServerCustomizers(serverCustomizer); } diff --git a/src/main/java/org/italiangrid/storm/webdav/server/TLSConnectorBuilderError.java b/src/main/java/org/italiangrid/storm/webdav/server/TLSConnectorBuilderError.java new file mode 100644 index 00000000..8d05acb8 --- /dev/null +++ b/src/main/java/org/italiangrid/storm/webdav/server/TLSConnectorBuilderError.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare, 2014-2023. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.italiangrid.storm.webdav.server; + +public class TLSConnectorBuilderError extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public TLSConnectorBuilderError(Throwable cause) { + super(cause); + } + + public TLSConnectorBuilderError(String message, Throwable cause) { + super(message, cause); + } + + public TLSConnectorBuilderError(String message) { + super(message); + } + +} diff --git a/src/main/java/org/italiangrid/storm/webdav/server/TLSServerConnectorBuilder.java b/src/main/java/org/italiangrid/storm/webdav/server/TLSServerConnectorBuilder.java new file mode 100644 index 00000000..67379b58 --- /dev/null +++ b/src/main/java/org/italiangrid/storm/webdav/server/TLSServerConnectorBuilder.java @@ -0,0 +1,664 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare, 2014-2023. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.italiangrid.storm.webdav.server; + +import static java.util.Objects.isNull; + +import java.io.File; +import java.io.IOException; +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.Security; +import java.security.cert.CertificateException; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; + +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.conscrypt.OpenSSLProvider; +import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory; +import org.eclipse.jetty.http.HttpVersion; +import org.eclipse.jetty.http2.HTTP2Cipher; +import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory; +import org.eclipse.jetty.server.ConnectionFactory; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.HttpConnectionFactory; +import org.eclipse.jetty.server.SecureRequestCustomizer; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.SslConnectionFactory; +import org.eclipse.jetty.util.ssl.SslContextFactory; + +import com.codahale.metrics.MetricRegistry; +import com.codahale.metrics.jetty9.InstrumentedConnectionFactory; + +import eu.emi.security.authn.x509.X509CertChainValidatorExt; +import eu.emi.security.authn.x509.helpers.ssl.SSLTrustManager; +import eu.emi.security.authn.x509.impl.PEMCredential; + +/** + * A builder that configures a Jetty server TLS connector integrated with CANL + * {@link X509CertChainValidatorExt} certificate validation services. + * + */ +public class TLSServerConnectorBuilder { + + /** + * Conscrypt provider name. + */ + public static final String CONSCRYPT_PROVIDER = "Conscrypt"; + + /** + * Default service certificate file. + */ + public static final String DEFAULT_CERTIFICATE_FILE = "/etc/grid-security/hostcert.pem"; + + /** + * Default service certificate private key file. + */ + public static final String DEFAULT_CERTIFICATE_KEY_FILE = "/etc/grid-security/hostcert.pem"; + + /** + * The port for this connector. + */ + private int port; + + /** + * The certificate file location. + */ + private String certificateFile = DEFAULT_CERTIFICATE_FILE; + + /** + * The certificate private key file location. + */ + private String certificateKeyFile = DEFAULT_CERTIFICATE_KEY_FILE; + + /** + * The password to decrypt the certificate private key file. + */ + private char[] certicateKeyPassword = null; + + /** + * The certificate validator used by this connector builder. + */ + private final X509CertChainValidatorExt certificateValidator; + + /** + * Whether client auth will be required for this connector. + */ + private boolean tlsNeedClientAuth = false; + + /** + * Whether cluent auth is supported for this connector. + */ + private boolean tlsWantClientAuth = true; + + /** + * Supported SSL protocols. + */ + private String[] includeProtocols; + + /** + * Disabled SSL protocols. + */ + private String[] excludeProtocols; + + /** + * Supported cipher suites. + */ + private String[] includeCipherSuites; + + /** + * Disabled cipher suites. + */ + private String[] excludeCipherSuites; + + /** + * The HTTP configuration for the connector being created. + */ + private HttpConfiguration httpConfiguration; + + /** + * The key manager to use for the connector being created. + */ + private KeyManager keyManager; + + /** + * The server for which the connector is being created. + */ + private final Server server; + + /** + * The metric name to associate to the connector being built. + */ + private String metricName; + + /** + * The metric registry. + */ + private MetricRegistry registry; + + /** + * Whether the Conscrypt provider should be used instead of the default JSSE implementation + */ + private boolean useConscrypt = false; + + + /** + * Whether HTTP/2 should be configured + */ + private boolean enableHttp2 = false; + + /** + * Which TLS protocol string should be used + */ + private String tlsProtocol = "TLSv1.2"; + + /** + * Custom TLS hostname verifier + */ + private HostnameVerifier hostnameVerifier = null; + + /** + * Disable JSSE hostname verification + */ + private boolean disableJsseHostnameVerification = false; + + /** + * Number of acceptors threads for the connector + */ + private int acceptors = -1; + + /** + * Number of selector threads for the connector + */ + private int selectors = -1; + + /** + * Returns an instance of the {@link TLSServerConnectorBuilder}. + * + * @param s the {@link Server} for which the connector is being created + * @param certificateValidator a {@link X509CertChainValidatorExt} used to validate certificates + * @return an instance of the {@link TLSServerConnectorBuilder} + */ + public static TLSServerConnectorBuilder instance(Server s, + X509CertChainValidatorExt certificateValidator) { + + return new TLSServerConnectorBuilder(s, certificateValidator); + } + + /** + * Private ctor. + * + * @param s the {@link Server} for which the connector is being created + * @param certificateValidator a {@link X509CertChainValidatorExt} used to validate certificates + */ + private TLSServerConnectorBuilder(Server s, X509CertChainValidatorExt certificateValidator) { + + if (s == null) { + throw new IllegalArgumentException("Server cannot be null"); + } + + if (certificateValidator == null) { + throw new IllegalArgumentException("certificateValidator cannot be null"); + } + + this.server = s; + this.certificateValidator = certificateValidator; + } + + private void credentialsSanityChecks() { + + checkFileExistsAndIsReadable(new File(certificateFile), "Error accessing certificate file"); + + checkFileExistsAndIsReadable(new File(certificateKeyFile), + "Error accessing certificate key file"); + + } + + private void loadCredentials() { + + credentialsSanityChecks(); + + PEMCredential serviceCredentials = null; + + try { + + serviceCredentials = + new PEMCredential(certificateKeyFile, certificateFile, certicateKeyPassword); + + } catch (KeyStoreException | CertificateException | IOException e) { + + throw new TLSConnectorBuilderError("Error setting up service credentials", e); + } + + keyManager = serviceCredentials.getKeyManager(); + } + + /** + * Configures SSL session parameters for the jetty {@link SslContextFactory}. + * + * @param contextFactory the {@link SslContextFactory} being configured + */ + private void configureContextFactory(SslContextFactory.Server contextFactory) { + + if (excludeProtocols != null) { + contextFactory.setExcludeProtocols(excludeProtocols); + } + + if (includeProtocols != null) { + contextFactory.setIncludeProtocols(includeProtocols); + } + + if (excludeCipherSuites != null) { + contextFactory.setExcludeCipherSuites(excludeCipherSuites); + } + + if (includeCipherSuites != null) { + contextFactory.setIncludeCipherSuites(includeCipherSuites); + } + + contextFactory.setWantClientAuth(tlsWantClientAuth); + contextFactory.setNeedClientAuth(tlsNeedClientAuth); + + if (useConscrypt) { + contextFactory.setProvider(CONSCRYPT_PROVIDER); + } else { + contextFactory.setProvider(BouncyCastleProvider.PROVIDER_NAME); + } + + if (hostnameVerifier != null) { + contextFactory.setHostnameVerifier(hostnameVerifier); + } + + if (disableJsseHostnameVerification) { + contextFactory.setEndpointIdentificationAlgorithm(null); + } + + } + + /** + * Builds a default {@link HttpConfiguration} for the TLS-enabled connector being created + * + * @return the default {@link HttpConfiguration} + */ + private HttpConfiguration defaultHttpConfiguration() { + + HttpConfiguration httpsConfig = new HttpConfiguration(); + + httpsConfig.setSecureScheme("https"); + + httpsConfig.setSecurePort(port); + + httpsConfig.setOutputBufferSize(32768); + httpsConfig.setRequestHeaderSize(8192); + httpsConfig.setResponseHeaderSize(8192); + + httpsConfig.setSendServerVersion(true); + httpsConfig.setSendDateHeader(false); + + httpsConfig.addCustomizer(new SecureRequestCustomizer()); + + return httpsConfig; + + } + + /** + * Gives access to the {@link HttpConfiguration} used for the TLS-enabled connector being created. + * If the configuration is not set, it creates it using {@link #defaultHttpConfiguration()}. + * + * @return the {@link HttpConfiguration} being used for the TLS-enabled connector. + */ + public HttpConfiguration httpConfiguration() { + + if (httpConfiguration == null) { + httpConfiguration = defaultHttpConfiguration(); + } + + return httpConfiguration; + + } + + /** + * Sets the port for the connector being created. + * + * @param port the port for the connector + * @return this builder + */ + public TLSServerConnectorBuilder withPort(int port) { + + this.port = port; + return this; + } + + /** + * Sets the certificate file for the connector being created. + * + * @param certificateFile the certificate file + * @return this builder + */ + public TLSServerConnectorBuilder withCertificateFile(String certificateFile) { + + this.certificateFile = certificateFile; + return this; + } + + /** + * Sets the certificate key file for the connector being created. + * + * @param certificateKeyFile the certificate key file + * @return this builder + */ + public TLSServerConnectorBuilder withCertificateKeyFile(String certificateKeyFile) { + + this.certificateKeyFile = certificateKeyFile; + return this; + } + + /** + * The the certificate key password for the connector being built + * + * @param certificateKeyPassword the certificate key password + * @return this builder + */ + public TLSServerConnectorBuilder withCertificateKeyPassword(char[] certificateKeyPassword) { + + this.certicateKeyPassword = certificateKeyPassword; + return this; + } + + /** + * Sets the {@link SslContextFactory#setNeedClientAuth(boolean)} parameter for the connector being + * created. + * + * @param needClientAuth true if client authentication is required + * @return this builder + */ + public TLSServerConnectorBuilder withNeedClientAuth(boolean needClientAuth) { + + this.tlsNeedClientAuth = needClientAuth; + return this; + } + + /** + * Sets the {@link SslContextFactory#setWantClientAuth(boolean)} parameter for the connector being + * created. + * + * @param wantClientAuth true if client authentication is wanted + * @return this builder + */ + public TLSServerConnectorBuilder withWantClientAuth(boolean wantClientAuth) { + + this.tlsWantClientAuth = wantClientAuth; + return this; + } + + /** + * Sets SSL included protocols. See {@link SslContextFactory#setIncludeProtocols(String...)}. + * + * @param includeProtocols the array of included protocol names + * @return this builder + */ + public TLSServerConnectorBuilder withIncludeProtocols(String... includeProtocols) { + + this.includeProtocols = includeProtocols; + return this; + } + + /** + * Sets SSL excluded protocols. See {@link SslContextFactory#setExcludeProtocols(String...)}. + * + * @param excludeProtocols the array of excluded protocol names + * @return this builder + */ + public TLSServerConnectorBuilder withExcludeProtocols(String... excludeProtocols) { + + this.excludeProtocols = excludeProtocols; + return this; + } + + /** + * Sets the SSL included cipher suites. + * + * @param includeCipherSuites the array of included cipher suites. + * @return this builder + */ + public TLSServerConnectorBuilder withIncludeCipherSuites(String... includeCipherSuites) { + + this.includeCipherSuites = includeCipherSuites; + return this; + } + + /** + * Sets the SSL ecluded cipher suites. + * + * @param excludeCipherSuites the array of excluded cipher suites. + * @return this builder + */ + public TLSServerConnectorBuilder withExcludeCipherSuites(String... excludeCipherSuites) { + + this.excludeCipherSuites = excludeCipherSuites; + return this; + } + + /** + * Sets the {@link HttpConfiguration} for the connector being built. + * + * @param conf the {@link HttpConfiguration} to use + * @return this builder + */ + public TLSServerConnectorBuilder withHttpConfiguration(HttpConfiguration conf) { + + this.httpConfiguration = conf; + return this; + } + + /** + * Sets the {@link KeyManager} for the connector being built. + * + * @param km the {@link KeyManager} to use + * @return this builder + */ + public TLSServerConnectorBuilder withKeyManager(KeyManager km) { + + this.keyManager = km; + return this; + } + + public TLSServerConnectorBuilder withConscrypt(boolean conscryptEnabled) { + this.useConscrypt = conscryptEnabled; + return this; + } + + public TLSServerConnectorBuilder withHttp2(boolean http2Enabled) { + this.enableHttp2 = http2Enabled; + return this; + } + + public TLSServerConnectorBuilder metricRegistry(MetricRegistry registry) { + this.registry = registry; + return this; + } + + public TLSServerConnectorBuilder metricName(String metricName) { + this.metricName = metricName; + return this; + } + + public TLSServerConnectorBuilder withTlsProtocol(String tlsProtocol) { + this.tlsProtocol = tlsProtocol; + return this; + } + + public TLSServerConnectorBuilder withHostnameVerifier(HostnameVerifier verifier) { + this.hostnameVerifier = verifier; + return this; + } + + public TLSServerConnectorBuilder withDisableJsseHostnameVerification( + boolean disableJsseHostnameVerification) { + this.disableJsseHostnameVerification = disableJsseHostnameVerification; + return this; + } + + public TLSServerConnectorBuilder withAcceptors(int acceptors) { + this.acceptors = acceptors; + return this; + } + + public TLSServerConnectorBuilder withSelectors(int selectors) { + this.selectors = selectors; + return this; + } + + private SSLContext buildSSLContext() { + + SSLContext sslCtx; + + try { + + KeyManager[] kms = new KeyManager[] {keyManager}; + SSLTrustManager tm = new SSLTrustManager(certificateValidator); + + if (useConscrypt) { + + if (isNull(Security.getProvider(CONSCRYPT_PROVIDER))) { + Security.addProvider(new OpenSSLProvider()); + } + + sslCtx = SSLContext.getInstance(tlsProtocol, CONSCRYPT_PROVIDER); + } else { + sslCtx = SSLContext.getInstance(tlsProtocol); + } + + sslCtx.init(kms, new TrustManager[] {tm}, null); + + } catch (NoSuchAlgorithmException e) { + + throw new TLSConnectorBuilderError("TLS protocol not supported: " + e.getMessage(), e); + } catch (KeyManagementException e) { + throw new TLSConnectorBuilderError(e); + } catch (NoSuchProviderException e) { + throw new TLSConnectorBuilderError("TLS provider error: " + e.getMessage(), e); + } + + return sslCtx; + } + + /** + * Builds a {@link ServerConnector} based on the {@link TLSServerConnectorBuilder} parameters + * + * @return a {@link ServerConnector} + */ + public ServerConnector build() { + + if (keyManager == null) { + loadCredentials(); + } + + SSLContext sslContext = buildSSLContext(); + SslContextFactory.Server cf = new SslContextFactory.Server(); + + cf.setSslContext(sslContext); + + + configureContextFactory(cf); + + if (httpConfiguration == null) { + httpConfiguration = defaultHttpConfiguration(); + } + + + HttpConnectionFactory httpConnFactory = new HttpConnectionFactory(httpConfiguration); + ConnectionFactory connFactory = null; + + if (registry != null) { + connFactory = new InstrumentedConnectionFactory(httpConnFactory, registry.timer(metricName)); + } else { + connFactory = httpConnFactory; + } + + + ConnectionFactory h2ConnFactory = null; + ServerConnector connector = null; + + if (enableHttp2) { + + HTTP2ServerConnectionFactory h2cf = new HTTP2ServerConnectionFactory(httpConfiguration); + + if (registry != null) { + h2ConnFactory = new InstrumentedConnectionFactory(h2cf, registry.timer(metricName)); + } else { + h2ConnFactory = h2cf; + } + ALPNServerConnectionFactory alpn = createAlpnProtocolFactory(httpConnFactory); + cf.setCipherComparator(HTTP2Cipher.COMPARATOR); + cf.setUseCipherSuitesOrder(true); + + SslConnectionFactory sslCf = new SslConnectionFactory(cf, alpn.getProtocol()); + + connector = new ServerConnector(server, acceptors, selectors, sslCf, alpn, h2ConnFactory, + httpConnFactory); + + } else { + + connector = new ServerConnector(server, acceptors, selectors, + new SslConnectionFactory(cf, HttpVersion.HTTP_1_1.asString()), connFactory); + } + + connector.setPort(port); + return connector; + } + + private ALPNServerConnectionFactory createAlpnProtocolFactory( + HttpConnectionFactory httpConnectionFactory) { + ALPNServerConnectionFactory alpn = new ALPNServerConnectionFactory(); + alpn.setDefaultProtocol(httpConnectionFactory.getProtocol()); + return alpn; + } + + + /** + * Checks that file exists and is readable. + * + * @param f the {@link File} to be checked + * @param prefix A prefix string for the error message, in case the file does not exist and is not + * readable + * @throws RuntimeException if the file does not exist or is not readable + */ + private void checkFileExistsAndIsReadable(File f, String prefix) { + + String errorMessage = null; + + if (!f.exists()) { + errorMessage = "File does not exists"; + } else if (!f.canRead()) { + errorMessage = "File is not readable"; + } else if (f.isDirectory()) { + errorMessage = "File is a directory"; + } + + if (errorMessage != null) { + String msg = String.format("%s: %s [%s]", prefix, errorMessage, f.getAbsolutePath()); + throw new TLSConnectorBuilderError(msg); + } + + } +} diff --git a/src/main/java/org/italiangrid/storm/webdav/server/servlet/LogRequestFilter.java b/src/main/java/org/italiangrid/storm/webdav/server/servlet/LogRequestFilter.java index 9334759e..1c3b3abd 100644 --- a/src/main/java/org/italiangrid/storm/webdav/server/servlet/LogRequestFilter.java +++ b/src/main/java/org/italiangrid/storm/webdav/server/servlet/LogRequestFilter.java @@ -16,6 +16,8 @@ package org.italiangrid.storm.webdav.server.servlet; import java.io.IOException; +import java.util.List; +import java.util.Objects; import java.util.Optional; import javax.servlet.Filter; @@ -33,10 +35,15 @@ import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; +import com.google.common.collect.Lists; + public class LogRequestFilter implements Filter { public static final Logger log = LoggerFactory.getLogger(LogRequestFilter.class); + private static final List IP_HEADERS = Lists.newArrayList("X-Forwarded-For", + "Proxy-Client-IP", "WL-Proxy-Client-IP", "HTTP_CLIENT_IP", "HTTP_X_FORWARDED_FOR"); + @Override public void destroy() {} @@ -56,7 +63,7 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse res = (HttpServletResponse) response; - String resMsg = String.format("%s %s %s %d [user:<%s>, authorities:<%s>]", req.getRemoteAddr(), + String resMsg = String.format("%s %s %s %d [user:<%s>, authorities:<%s>]", getClientIpAddr(req), req.getMethod(), req.getRequestURI(), res.getStatus(), authn.isPresent() ? authn.get().getName() : null, authn.isPresent() ? authn.get().getAuthorities() : null); @@ -64,6 +71,21 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha log.debug(resMsg); } + + public static String getClientIpAddr(HttpServletRequest request) { + + String remoteIp = request.getRemoteAddr(); + if (remoteIp != null) { + return remoteIp; + } + return IP_HEADERS.stream() + .map(request::getHeader) + .filter(Objects::nonNull) + .filter(ip -> !ip.isEmpty() && !ip.equalsIgnoreCase("unknown")) + .findFirst() + .orElse("???.???.???.???"); + } + @Override public void init(FilterConfig config) throws ServletException {} diff --git a/src/main/java/org/italiangrid/storm/webdav/spring/AppConfig.java b/src/main/java/org/italiangrid/storm/webdav/spring/AppConfig.java index ce9a7a88..22d43a01 100644 --- a/src/main/java/org/italiangrid/storm/webdav/spring/AppConfig.java +++ b/src/main/java/org/italiangrid/storm/webdav/spring/AppConfig.java @@ -16,7 +16,7 @@ package org.italiangrid.storm.webdav.spring; import static java.util.Objects.isNull; -import static org.italiangrid.utils.jetty.TLSServerConnectorBuilder.CONSCRYPT_PROVIDER; +import static org.italiangrid.storm.webdav.server.TLSServerConnectorBuilder.CONSCRYPT_PROVIDER; import java.io.IOException; import java.net.MalformedURLException; diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 6b1c7356..8d9d02e0 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -2,8 +2,19 @@ server: jetty: accesslog: enabled: false + +management: + # endpoint: + # env: + # additional-keys-to-sanitize: client-secret + endpoints: + web: + exposure: + include: env + oauth: enable-oidc: false + storm: connector: port: 8086 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6eaaa236..4db620d9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,16 +11,19 @@ spring: issuer-uri: https://iam-escape.cloud.cnaf.infn.it/ indigo: issuer-uri: https://iam-test.indigo-datacloud.eu/ + session: store-type: none server: - # StoRM webdav will bind on this address + # StoRM WebDAV will bind on this address address: ${STORM_WEBDAV_SERVER_ADDRESS:0.0.0.0} + # StoRM WebDAV server should support graceful shutdown, allowing active requests time to complete, or shut down immediately + # Values: graceful, immediate + shutdown: ${STORM_WEBDAV_SERVER_SHUTDOWN:graceful} error: whitelabel: enabled: false - jetty: threads: max: ${storm.connector.max-connections} @@ -28,7 +31,7 @@ server: management: health: redis: - enabled: false + enabled: false tpc: tls-protocol: ${STORM_WEBDAV_TPC_TLS_PROTOCOL:TLSv1.2} @@ -50,7 +53,8 @@ tpc: enable-expect-continue-threshold: ${STORM_WEBDAV_TPC_ENABLE_EXPECT_CONTINUE_THRESHOLD:1048576} oauth: - refresh-period-minutes: 60 + refresh-period-minutes: ${STORM_WEBDAV_OAUTH_REFRESH_PERIOD_MINUTES:60} + refresh-timeout-seconds: ${STORM_WEBDAV_OAUTH_REFRESH_TIMEOUT_SECONDS:30} issuers: storm: @@ -81,18 +85,28 @@ storm: macaroon-filter: enabled: ${STORM_WEBDAV_MACAROON_FILTER_ENABLED:true} + server: + # Jetty Thread-Pool maximum idle time (in milliseconds) + max-idle-time-msec: ${STORM_WEBDAV_SERVER_MAX_IDLE_TIME:3600000} + connector: # HTTP connector port port: ${STORM_WEBDAV_HTTP_PORT:8085} # HTTPS connector port secure-port: ${STORM_WEBDAV_HTTPS_PORT:8443} + # Min concurrent connections + min-connections: ${STORM_WEBDAV_MIN_CONNECTIONS:50} # Max concurrent connections max-connections: ${STORM_WEBDAV_MAX_CONNECTIONS:300} # Connection queue size max-queue-size: ${STORM_WEBDAV_MAX_QUEUE_SIZE:900} # Connector Maximum idle time (in milliseconds) - max-idle-time-msec: ${STORM_WEBDAV_CONNECTOR_MAX_IDLE_TIME:30000} + max-idle-time-msec: ${STORM_WEBDAV_CONNECTOR_MAX_IDLE_TIME:30000} output-buffer-size-bytes: ${storm.buffer.file-buffer-size-bytes} + # Number of acceptor threads to use. When the value is -1, the default, the number of acceptors is derived from the operating environment. + jetty-acceptors: ${STORM_WEBDAV_CONNECTOR_ACCEPTORS:-1} + # Number of selector threads to use. When the value is -1, the default, the number of selectors is derived from the operating environment. + jetty-selectors: ${STORM_WEBDAV_CONNECTOR_SELECTORS:-1} tls: # Path to the service certificate. @@ -158,4 +172,5 @@ storm: tape: well-known: - source: ${STORM_WEBDAV_TAPE_WELLKNOWN_SOURCE:/etc/storm/webdav/wlcg-tape-rest-api.json} \ No newline at end of file + source: ${STORM_WEBDAV_TAPE_WELLKNOWN_SOURCE:/etc/storm/webdav/wlcg-tape-rest-api.json} + diff --git a/src/test/java/org/italiangrid/storm/webdav/server/TLSConnectorBuilderTest.java b/src/test/java/org/italiangrid/storm/webdav/server/TLSConnectorBuilderTest.java new file mode 100644 index 00000000..b7fcbba9 --- /dev/null +++ b/src/test/java/org/italiangrid/storm/webdav/server/TLSConnectorBuilderTest.java @@ -0,0 +1,117 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare, 2014-2023. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.italiangrid.storm.webdav.server; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertThrows; + +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.KeyManager; + +import org.apache.http.conn.ssl.NoopHostnameVerifier; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.italiangrid.storm.webdav.server.util.CANLListener; +import org.italiangrid.voms.util.CertificateValidatorBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import eu.emi.security.authn.x509.CrlCheckingMode; +import eu.emi.security.authn.x509.NamespaceCheckingMode; +import eu.emi.security.authn.x509.OCSPCheckingMode; +import eu.emi.security.authn.x509.X509CertChainValidatorExt; + +@ExtendWith(MockitoExtension.class) +class TLSConnectorBuilderTest { + + @Test + void tlsConnectorBuilderErrorTests() { + + TLSConnectorBuilderError e = new TLSConnectorBuilderError("This is an error!"); + assertThat(e.getMessage(), is("This is an error!")); + e = new TLSConnectorBuilderError("This is an error!", new RuntimeException()); + assertThat(e.getMessage(), is("This is an error!")); + e = new TLSConnectorBuilderError(new RuntimeException("This is an error!")); + assertThat(e.getCause() instanceof RuntimeException, is(true)); + assertThat(e.getMessage(), containsString("This is an error!")); + } + + @Test + void illegalArgumentExceptionThrown() { + + Server server = Mockito.mock(Server.class); + X509CertChainValidatorExt validator = Mockito.mock(X509CertChainValidatorExt.class); + + assertThrows(IllegalArgumentException.class, () -> { + TLSServerConnectorBuilder.instance(null, validator); + }); + assertThrows(IllegalArgumentException.class, () -> { + TLSServerConnectorBuilder.instance(server, null); + }); + assertThrows(IllegalArgumentException.class, () -> { + TLSServerConnectorBuilder.instance(null, null); + }); + } + + private X509CertChainValidatorExt getValidator() { + + CANLListener l = new org.italiangrid.storm.webdav.server.util.CANLListener(); + CertificateValidatorBuilder builder = new CertificateValidatorBuilder(); + + long refreshInterval = TimeUnit.SECONDS.toMillis(3600); + + return builder.namespaceChecks(NamespaceCheckingMode.EUGRIDPMA_AND_GLOBUS_REQUIRE) + .crlChecks(CrlCheckingMode.IF_VALID) + .ocspChecks(OCSPCheckingMode.IGNORE) + .lazyAnchorsLoading(false) + .storeUpdateListener(l) + .validationErrorListener(l) + .trustAnchorsDir("src/test/resources/trust-anchors") + .trustAnchorsUpdateInterval(refreshInterval) + .build(); + } + + @Test + void tlsConnectorBuilderTests() { + + Server server = new Server(); + X509CertChainValidatorExt validator = getValidator(); + TLSServerConnectorBuilder builder = TLSServerConnectorBuilder.instance(server, validator); + HttpConfiguration httpConfiguration = builder.httpConfiguration(); + KeyManager keyManager = Mockito.mock(KeyManager.class); + builder.withPort(1234) + .withCertificateFile("fake-certificate") + .withCertificateKeyFile("fake-key") + .withCertificateKeyPassword("secret".toCharArray()) + .withHttpConfiguration(httpConfiguration) + .withKeyManager(keyManager) + .withExcludeCipherSuites("one", "two") + .withIncludeCipherSuites("three", "four") + .withIncludeProtocols("protocol", "another-protocol") + .withExcludeProtocols("another-more-protocol") + .withHostnameVerifier(new NoopHostnameVerifier()) + .withConscrypt(false); + + ServerConnector c = builder.build(); + assertThat(c.getPort(), is(1234)); + } +} \ No newline at end of file diff --git a/src/test/java/org/italiangrid/storm/webdav/test/oauth/jwk/OidcConfigurationFetcherTest.java b/src/test/java/org/italiangrid/storm/webdav/test/oauth/jwk/OidcConfigurationFetcherTest.java new file mode 100644 index 00000000..0725f914 --- /dev/null +++ b/src/test/java/org/italiangrid/storm/webdav/test/oauth/jwk/OidcConfigurationFetcherTest.java @@ -0,0 +1,307 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare, 2014-2023. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.italiangrid.storm.webdav.test.oauth.jwk; + +import static java.lang.String.format; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.italiangrid.storm.webdav.oauth.utils.DefaultOidcConfigurationFetcher.ISSUER_MISMATCH_ERROR_TEMPLATE; +import static org.italiangrid.storm.webdav.oauth.utils.DefaultOidcConfigurationFetcher.NO_JWKS_URI_ERROR_TEMPLATE; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.springframework.http.HttpStatus.NOT_FOUND; +import static org.springframework.http.HttpStatus.OK; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.text.ParseException; +import java.util.Map; + +import org.apache.commons.io.FileUtils; +import org.italiangrid.storm.webdav.config.OAuthProperties; +import org.italiangrid.storm.webdav.oauth.utils.DefaultOidcConfigurationFetcher; +import org.italiangrid.storm.webdav.oauth.utils.OidcConfigurationFetcher; +import org.italiangrid.storm.webdav.oauth.utils.OidcConfigurationResolutionError; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import com.google.common.collect.Maps; +import com.nimbusds.jose.KeySourceException; +import com.nimbusds.jose.RemoteKeySourceException; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.KeyType; + +@ExtendWith(MockitoExtension.class) +class OidcConfigurationFetcherTest { + + static final String ISSUER = "https://iam-dev.cloud.cnaf.infn.it/"; + static final String JWK_URI = ISSUER + "jwk"; + + static final String ANOTHER_ISSUER = "https://iam.cloud.infn.it/"; + static final String ANOTHER_JWK_URI = ANOTHER_ISSUER + "jwk"; + + static final String KID = "rsa1"; + + + final ParameterizedTypeReference> typeReference = + new ParameterizedTypeReference>() {}; + + @Mock + RestTemplate restTemplate; + @Mock + RestTemplateBuilder restBuilder; + @Mock + OAuthProperties oAuthProperties; + + private Map getMapWithIssuerAndJwkUri(String issuer, String jwkUri) { + Map m = Maps.newHashMap(); + m.put("issuer", issuer); + m.put("jwks_uri", jwkUri); + return m; + } + + @SuppressWarnings("unchecked") + private ResponseEntity> getWellKnownResponse(HttpStatus status, + Map map) { + + ResponseEntity> mockedEntity = + (ResponseEntity>) Mockito.mock(ResponseEntity.class); + lenient().when(mockedEntity.getStatusCode()).thenReturn(status); + lenient().when(mockedEntity.getStatusCodeValue()).thenReturn(status.value()); + lenient().when(mockedEntity.getBody()).thenReturn(map); + return mockedEntity; + } + + private String loadJwkFromFile() throws IOException { + + ClassLoader classLoader = getClass().getClassLoader(); + File file = new File(classLoader.getResource("jwk/test-keystore.jwks").getFile()); + return FileUtils.readFileToString(file, "UTF-8"); + } + + @SuppressWarnings("unchecked") + private ResponseEntity getJWKURIResponse(HttpStatus status, String data) { + + ResponseEntity mockedEntity = + (ResponseEntity) Mockito.mock(ResponseEntity.class); + lenient().when(mockedEntity.getBody()).thenReturn(data); + lenient().when(mockedEntity.getStatusCode()).thenReturn(status); + lenient().when(mockedEntity.getStatusCodeValue()).thenReturn(status.value()); + return mockedEntity; + } + + private OidcConfigurationFetcher getFetcher(ResponseEntity> wellKnownResponse, + ResponseEntity jwkResponse) { + + lenient().when(restTemplate.exchange(any(), eq(typeReference))).thenReturn(wellKnownResponse); + lenient().when(restTemplate.exchange(any(), eq(String.class))).thenReturn(jwkResponse); + return getFetcher(restTemplate); + } + + private OidcConfigurationFetcher getFetcherWithException(ResponseEntity> wellKnownResponse) { + + lenient().when(restTemplate.exchange(any(), eq(typeReference))).thenReturn(wellKnownResponse); + lenient().when(restTemplate.exchange(any(), eq(String.class))).thenThrow(new RuntimeException("ERROR")); + return getFetcher(restTemplate); + } + + private OidcConfigurationFetcher getFetcher(RestTemplate restTemplate) { + + lenient().when(restBuilder.build()).thenReturn(restTemplate); + lenient().when(restBuilder.setConnectTimeout(any())).thenReturn(restBuilder); + lenient().when(restBuilder.setReadTimeout(any())).thenReturn(restBuilder); + lenient().when(oAuthProperties.getRefreshTimeoutSeconds()).thenReturn(30); + lenient().when(oAuthProperties.getRefreshPeriodMinutes()).thenReturn(1); + return new DefaultOidcConfigurationFetcher(restBuilder, oAuthProperties); + } + + private OidcConfigurationFetcher getSuccessfulFetcher() throws RestClientException, IOException { + + ResponseEntity> mockedResponseMapEntity = + getWellKnownResponse(OK, getMapWithIssuerAndJwkUri(ISSUER, JWK_URI)); + ResponseEntity mockedResponseStringEntity = getJWKURIResponse(OK, loadJwkFromFile()); + return getFetcher(mockedResponseMapEntity, mockedResponseStringEntity); + } + + private OidcConfigurationFetcher getSuccessfulFetcherWithWrongIssuer() + throws RestClientException, IOException { + + ResponseEntity> mockedResponseMapEntity = + getWellKnownResponse(OK, getMapWithIssuerAndJwkUri(ANOTHER_ISSUER, ANOTHER_JWK_URI)); + ResponseEntity mockedResponseStringEntity = getJWKURIResponse(OK, loadJwkFromFile()); + return getFetcher(mockedResponseMapEntity, mockedResponseStringEntity); + } + + private OidcConfigurationFetcher getSuccessfulFetcherWithNoIssuer() + throws RestClientException, IOException { + + Map map = getMapWithIssuerAndJwkUri(ANOTHER_ISSUER, ANOTHER_JWK_URI); + map.remove("issuer"); + ResponseEntity> mockedResponseMapEntity = getWellKnownResponse(OK, map); + ResponseEntity mockedResponseStringEntity = getJWKURIResponse(OK, loadJwkFromFile()); + return getFetcher(mockedResponseMapEntity, mockedResponseStringEntity); + } + + private OidcConfigurationFetcher getSuccessfulFetcherWithNoJwk() + throws RestClientException, IOException { + + Map map = getMapWithIssuerAndJwkUri(ISSUER, JWK_URI); + map.remove("jwks_uri"); + ResponseEntity> mockedResponseMapEntity = getWellKnownResponse(OK, map); + ResponseEntity mockedResponseStringEntity = getJWKURIResponse(OK, loadJwkFromFile()); + return getFetcher(mockedResponseMapEntity, mockedResponseStringEntity); + } + + private OidcConfigurationFetcher getFetcherWithErrorOnFetch() throws RestClientException { + + ResponseEntity> mockedResponseMapEntity = + getWellKnownResponse(NOT_FOUND, null); + return getFetcher(mockedResponseMapEntity, null); + } + + private OidcConfigurationFetcher getFetcherWithErrorOnGetJwk() throws RestClientException { + + ResponseEntity> mockedResponseMapEntity = + getWellKnownResponse(OK, getMapWithIssuerAndJwkUri(ISSUER, JWK_URI)); + ResponseEntity mockedResponseStringEntity = getJWKURIResponse(NOT_FOUND, null); + return getFetcher(mockedResponseMapEntity, mockedResponseStringEntity); + } + + private OidcConfigurationFetcher getFetcherWithRuntimeExceptionOnGetJwk() throws RestClientException { + + ResponseEntity> mockedResponseMapEntity = + getWellKnownResponse(OK, getMapWithIssuerAndJwkUri(ISSUER, JWK_URI)); + return getFetcherWithException(mockedResponseMapEntity); + } + + @BeforeEach + public void setDebugLevel() { + System.setProperty("logging.level.org.italiangrid.storm", "DEBUG"); + } + + @Test + void fetchWellKnownEndpointWithSuccessTests() throws RestClientException, IOException { + + OidcConfigurationFetcher fetcher = getSuccessfulFetcher(); + Map conf = fetcher.loadConfigurationForIssuer(ISSUER); + assertNotNull(conf); + assertThat(conf.get("issuer"), is(ISSUER)); + assertThat(conf.get("jwks_uri"), is(JWK_URI)); + } + + @Test + void fetchWellKnownEndpointWithErrorTests() throws RestClientException { + + OidcConfigurationFetcher fetcher = getFetcherWithErrorOnFetch(); + RuntimeException exception = assertThrows(RuntimeException.class, () -> { + fetcher.loadConfigurationForIssuer(ISSUER); + }); + String expectedMessage = "Received status code: 404"; + String actualMessage = exception.getMessage(); + + assertEquals(expectedMessage, actualMessage); + } + + @Test + void fetchWellKnownEndpointWrongIssuerTests() throws RestClientException, IOException { + + OidcConfigurationFetcher fetcher = getSuccessfulFetcherWithWrongIssuer(); + OidcConfigurationResolutionError exception = + assertThrows(OidcConfigurationResolutionError.class, () -> { + fetcher.loadConfigurationForIssuer(ISSUER); + }); + assertEquals(format(ISSUER_MISMATCH_ERROR_TEMPLATE, ANOTHER_ISSUER, ISSUER), + exception.getMessage()); + } + + @Test + void fetchWellKnownEndpointNoIssuerTests() throws RestClientException, IOException { + + OidcConfigurationFetcher fetcher = getSuccessfulFetcherWithNoIssuer(); + OidcConfigurationResolutionError exception = + assertThrows(OidcConfigurationResolutionError.class, () -> { + fetcher.loadConfigurationForIssuer(ISSUER); + }); + assertEquals(format(ISSUER_MISMATCH_ERROR_TEMPLATE, "(unavailable)", ISSUER), + exception.getMessage()); + } + + @Test + void fetchWellKnownEndpointNoJwkTests() throws RestClientException, IOException { + + OidcConfigurationFetcher fetcher = getSuccessfulFetcherWithNoJwk(); + OidcConfigurationResolutionError exception = + assertThrows(OidcConfigurationResolutionError.class, () -> { + fetcher.loadConfigurationForIssuer(ISSUER); + }); + assertEquals(format(NO_JWKS_URI_ERROR_TEMPLATE, ISSUER), exception.getMessage()); + } + + @Test + void fetchJWKEndpointTests() + throws RestClientException, IOException, ParseException, KeySourceException { + + OidcConfigurationFetcher fetcher = getSuccessfulFetcher(); + JWKSet key = JWKSet.parse(fetcher.loadJWKSourceForURL(URI.create(JWK_URI))); + + assertNotNull(key.getKeyByKeyId(KID)); + assertThat(key.getKeyByKeyId(KID).getKeyType(), is(KeyType.RSA)); + } + + @Test + void fetchJWKEndpointWithErrorTests() throws RestClientException { + + OidcConfigurationFetcher fetcher = getFetcherWithErrorOnGetJwk(); + final URI jwkUri = URI.create(JWK_URI); + KeySourceException exception = assertThrows(KeySourceException.class, () -> { + fetcher.loadJWKSourceForURL(jwkUri); + }); + String expectedMessage = + "Unable to get JWK from '" + jwkUri + "': received status code " + NOT_FOUND.value(); + String actualMessage = exception.getMessage(); + + assertEquals(expectedMessage, actualMessage); + } + + @Test + void fetchJWKEndpointWithRuntimeException() throws RestClientException { + + OidcConfigurationFetcher fetcher = getFetcherWithRuntimeExceptionOnGetJwk(); + final URI jwkUri = URI.create(JWK_URI); + RemoteKeySourceException exception = assertThrows(RemoteKeySourceException.class, () -> { + fetcher.loadJWKSourceForURL(jwkUri); + }); + String expectedMessage = "Unable to get JWK from 'https://iam-dev.cloud.cnaf.infn.it/jwk'"; + String actualMessage = exception.getMessage(); + + assertEquals(expectedMessage, actualMessage); + } +} diff --git a/src/test/java/org/italiangrid/storm/webdav/test/oauth/jwk/TrustedJwtDecoderCacheLoaderTest.java b/src/test/java/org/italiangrid/storm/webdav/test/oauth/jwk/TrustedJwtDecoderCacheLoaderTest.java new file mode 100644 index 00000000..62af0313 --- /dev/null +++ b/src/test/java/org/italiangrid/storm/webdav/test/oauth/jwk/TrustedJwtDecoderCacheLoaderTest.java @@ -0,0 +1,109 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare, 2014-2023. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.italiangrid.storm.webdav.test.oauth.jwk; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.lenient; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.apache.commons.io.FileUtils; +import org.italiangrid.storm.webdav.config.OAuthProperties; +import org.italiangrid.storm.webdav.config.OAuthProperties.AuthorizationServer; +import org.italiangrid.storm.webdav.config.ServiceConfigurationProperties; +import org.italiangrid.storm.webdav.config.ServiceConfigurationProperties.AuthorizationServerProperties; +import org.italiangrid.storm.webdav.oauth.utils.OidcConfigurationFetcher; +import org.italiangrid.storm.webdav.oauth.utils.TrustedJwtDecoderCacheLoader; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; + +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.util.concurrent.ListenableFuture; +import com.nimbusds.jose.KeySourceException; + +@ExtendWith(MockitoExtension.class) +class TrustedJwtDecoderCacheLoaderTest { + + private static final String ISSUER = "https://wlcg.cloud.cnaf.infn.it/"; + private static final String JWK_URI = "https://wlcg.cloud.cnaf.infn.it/jwks"; + + @Mock + ServiceConfigurationProperties properties; + @Mock + OAuthProperties oauthProperties; + @Mock + RestTemplateBuilder builder; + @Mock + OidcConfigurationFetcher fetcher; + + private ExecutorService executor; + private TrustedJwtDecoderCacheLoader jwtLoader; + + @BeforeEach + public void setup() throws IOException, KeySourceException { + + AuthorizationServer as = new AuthorizationServer(); + as.setIssuer(ISSUER); + as.setJwkUri(JWK_URI); + List issuerServers = Lists.newArrayList(as); + lenient().when(oauthProperties.getIssuers()).thenReturn(issuerServers); + + Map oidcConfiguration = Maps.newHashMap(); + oidcConfiguration.put("issuer", ISSUER); + oidcConfiguration.put("jwks_uri", JWK_URI); + + ClassLoader classLoader = getClass().getClassLoader(); + File file = new File(classLoader.getResource("jwk/test-keystore.jwks").getFile()); + String data = FileUtils.readFileToString(file, "UTF-8"); + + lenient().when(fetcher.loadConfigurationForIssuer(ISSUER)).thenReturn(oidcConfiguration); + lenient().when(fetcher.loadJWKSourceForURL(URI.create(JWK_URI))).thenReturn(data); + + AuthorizationServerProperties props = new AuthorizationServerProperties(); + props.setEnabled(false); + props.setIssuer("http://localhost"); + lenient().when(properties.getAuthzServer()).thenReturn(props); + + executor = Executors.newScheduledThreadPool(1); + + jwtLoader = + new TrustedJwtDecoderCacheLoader(properties, oauthProperties, builder, fetcher, executor); + + } + + @Test + void testLoadRemoteIssuerConfiguration() throws Exception { + + JwtDecoder decoder = jwtLoader.load(ISSUER); + assertTrue(decoder instanceof NimbusJwtDecoder); + ListenableFuture reloaded = jwtLoader.reload(ISSUER, decoder); + JwtDecoder newDecoder = reloaded.get(); + assertTrue(newDecoder instanceof NimbusJwtDecoder); + } +} diff --git a/src/test/java/org/italiangrid/storm/webdav/test/oauth/jwt/NoExpirationStringCacheTest.java b/src/test/java/org/italiangrid/storm/webdav/test/oauth/jwt/NoExpirationStringCacheTest.java new file mode 100644 index 00000000..a5c8653c --- /dev/null +++ b/src/test/java/org/italiangrid/storm/webdav/test/oauth/jwt/NoExpirationStringCacheTest.java @@ -0,0 +1,45 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare, 2014-2023. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.italiangrid.storm.webdav.test.oauth.jwt; + +import static org.junit.Assert.assertEquals; + +import org.italiangrid.storm.webdav.oauth.utils.NoExpirationStringCache; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class NoExpirationStringCacheTest { + + private static final String CACHED_VALUE = "this-is-my-cached-value"; + private static final String FAKE_ISSUER = "http://localhost"; + + @Test + void noExpirationCacheWorks() { + + NoExpirationStringCache cache = new NoExpirationStringCache(CACHED_VALUE); + + assertEquals("NoExpirationCache", cache.getName()); + assertEquals(cache, cache.getNativeCache()); + assertEquals(CACHED_VALUE, cache.get(FAKE_ISSUER).get()); + cache.clear(); + cache.put(FAKE_ISSUER, CACHED_VALUE); + assertEquals(CACHED_VALUE, cache.get(FAKE_ISSUER).get()); + cache.evict(FAKE_ISSUER); + assertEquals(CACHED_VALUE, cache.get(FAKE_ISSUER).get()); + } +} \ No newline at end of file diff --git a/src/test/resources/jwk/test-keystore.jwks b/src/test/resources/jwk/test-keystore.jwks new file mode 100644 index 00000000..cde1441e --- /dev/null +++ b/src/test/resources/jwk/test-keystore.jwks @@ -0,0 +1,16 @@ +{ + "keys": [ + { + "p": "-qdvzeHU7w_ToV2RlS2QlVggXNL2YfpRWQxvrO8pHZC_dVgYFwKz5nadOMzR1BK0tPuCTWuuI66sFgaA9VENGypdIYoCF2O1FBLFK6GjOO-uc0LZEbIDa6Xn0G7UYOWcLaiYriHTtC_Pzp11L7VGjrUlX4HRgU_B3X1oeGn0mbM", + "kty": "RSA", + "q": "5S2b9tYHi9zBcNGZ8X6GM4TAL4UU9mABH0rKIyzbudkG7Wxxbj6I18skuHzfOOPI4c8sTQSv6IVAr2n1bn3_E5RSyPpbtDSCTYGzhijXl9wZ0ba2NidFrVjnL-KPx_gcHKnUHebKvsIEdjxeuqaaZ1kqEJX326b450Frghd78p8", + "d": "oDb1qfQTaP73jEZHgkOG5C9dY5EJZW57fX_BYUJ-yYTuTHPWZBVDKw9I_Ir1tSYyuTF-Bfb4iPim46gnEBM3AdvMian2iajvrN_rJFUJHo65vtY9xCXCD0d_Jct5JMyOafP5LF3cP38yDcyZRS_JeyKGB6U1KhbL-gG4hrQGS8qO3rdY_JQiLDLVdRRptHsPphS44JHXdP2qeVNJ41-CTfPWKiMIUOC0fj-As-dbTzRXuLXs04NayAdM-yhvRiwKujEfL8YbKW9CDJIgJfm2vzWHXFus5Y11S2Zr65cWxxVvRfnAFbFO1AkIkJc2jHZ4xLxfDU2kTi20sOMq1UFrvQ", + "e": "AQAB", + "kid": "rsa1", + "qi": "bN1wnq_0VkJlECMGPmeRFdZCX2LgAMrgwbJpysRw5J04vO9YsVmAcB_4xqoDjDUg7koioAp3IOhMGjOJWpYzCqWzsaA_84kX4WKGsr2xz6oaFSgt1FvtBY4GqEeZj8RG0LMtEtKSyjHGieA0hd4TUcqSdNZ4osT98Bfd7z3peYc", + "dp": "YfyaxI2IRHyXavm9M-hAIWH2JNOD5gGJU5p8_cnw9NHlRuZNZJF16p5sEAxh6tn1MtsvsTxrMx_RvjqEp2IsEXaaOcZN0v7zhwlfcxMZT-TC-eQkH7rLg4Wz_dOVytt4FpFWPpySuloGjusXKLNhBeDi31dMo5SeYQvpj0k8iek", + "dq": "9_lhyLPNdohmxqwE5kkA7L23NbPJ-svmavWBwo3HMlCiLkQoeCEx8EzebsCux9-wfKSuSqfHrtCALU15QxUR6x2SdeRvVY17cGHm3kNTA_4j8cbBYdccjXSksitzZ-wOfvVDjxcqST2llkm8NjoO18Siv0-F4SXKLG-c5CaE9w", + "n": "4GRvJuFantVV3JdjwQOAkfREnwUFp2znRBTOIJhPamyH4gf4YlI5PQT79415NV4_HrWYzgooH5AK6-7WE-TLLGEAVK5vdk4vv79bG7ukvjvBPxAjEhQn6-Amln88iXtvicEGbh--3CKbQj1jryVU5aWM6jzweaabFSeCILVEd6ZT7ofXaAqan9eLzU5IEtTPy5MfrrOvWw5Q7D2yzMqc5LksmaQSw8XtmhA8gnENnIqjAMmPtRltf93wjtmiamgVENOVPdN-93Nd5w-pnMwEyoO6Q9JqXxV6lD6qBRxI7_5t4_vmVxcbbxcZbSAMoHqA2pbSMJ4Jcw-27Hct9jesLQ" + } + ] +} \ No newline at end of file