From 12863cd212765b514206cdf3d9daa8060cc29654 Mon Sep 17 00:00:00 2001 From: jaserud Date: Mon, 24 Aug 2020 09:50:15 -0400 Subject: [PATCH] adds jwt auth (#269) * initial jwt auth setup * refactor JWTConverter * adds test for jwt auth * use existing object id * remove commented code * make requested formating changes and add retry template * add more and clean up JWTSecurityTests * make requested changes, update variables and fix typo --- docker-compose.yml | 3 +- pom.xml | 8 +- score-server/pom.xml | 27 +- .../score/server/config/RetryConfig.java | 75 +++++ .../score/server/config/SecurityConfig.java | 39 +-- .../server/config/TokenServicesConfig.java | 80 +++++ .../security/DefaultPublicKeyFetcher.java | 21 ++ .../score/server/security/JWTConverter.java | 52 +++ .../security/MergedServerTokenServices.java | 50 +++ .../server/security/PublicKeyFetcher.java | 7 + .../src/main/resources/application.yml | 61 ++-- .../overture/score/server/JWTTestConfig.java | 55 ++++ .../server/security/JWTSecurityTest.java | 303 ++++++++++++++++++ .../MergedServerTokenServicesTest.java | 63 ++++ .../score/server/utils/JWTGenerator.java | 85 +++++ .../score/server/utils/JsonUtils.java | 32 ++ .../score/server/utils/JwtContext.java | 31 ++ 17 files changed, 937 insertions(+), 55 deletions(-) create mode 100644 score-server/src/main/java/bio/overture/score/server/config/RetryConfig.java create mode 100644 score-server/src/main/java/bio/overture/score/server/config/TokenServicesConfig.java create mode 100644 score-server/src/main/java/bio/overture/score/server/security/DefaultPublicKeyFetcher.java create mode 100644 score-server/src/main/java/bio/overture/score/server/security/JWTConverter.java create mode 100644 score-server/src/main/java/bio/overture/score/server/security/MergedServerTokenServices.java create mode 100644 score-server/src/main/java/bio/overture/score/server/security/PublicKeyFetcher.java create mode 100644 score-server/src/test/java/bio/overture/score/server/JWTTestConfig.java create mode 100644 score-server/src/test/java/bio/overture/score/server/security/JWTSecurityTest.java create mode 100644 score-server/src/test/java/bio/overture/score/server/security/MergedServerTokenServicesTest.java create mode 100644 score-server/src/test/java/bio/overture/score/server/utils/JWTGenerator.java create mode 100644 score-server/src/test/java/bio/overture/score/server/utils/JsonUtils.java create mode 100644 score-server/src/test/java/bio/overture/score/server/utils/JwtContext.java diff --git a/docker-compose.yml b/docker-compose.yml index 188b63b7..e1e1acc3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -66,7 +66,8 @@ services: AUTH_SERVER_SCOPE_STUDY_PREFIX: score. AUTH_SERVER_SCOPE_UPLOAD_SUFFIX: .WRITE AUTH_SERVER_SCOPE_DOWNLOAD_SUFFIX: .READ - AUTH_SERVER_SCOPE_SYSTEM: score.WRITE + AUTH_SERVER_SCOPE_DOWNLOAD_SYSTEM: score.WRITE + AUTH_SERVER_SCOPE_UPLOAD_SYSTEM: score.READ SERVER_SSL_ENABLED: "false" UPLOAD_PARTSIZE: 1073741824 UPLOAD_CONNECTION_TIMEOUT: 1200000 diff --git a/pom.xml b/pom.xml index b4e2170d..e4fd60f9 100644 --- a/pom.xml +++ b/pom.xml @@ -101,6 +101,11 @@ ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF S spring-retry ${spring-retry.version} + + org.springframework.security + spring-security-jwt + ${spring-security-jwt.version} + @@ -227,7 +232,8 @@ ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF S 2.1.6.RELEASE 1.1.2.RELEASE - 2.0.7.RELEASE + 2.3.5.RELEASE + 1.1.1.RELEASE Greenwich.SR3 diff --git a/score-server/pom.xml b/score-server/pom.xml index a89dfcd4..38920264 100644 --- a/score-server/pom.xml +++ b/score-server/pom.xml @@ -78,12 +78,21 @@ ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF S org.springframework.security.oauth spring-security-oauth2 + + org.springframework.security + spring-security-jwt + org.springframework.cloud spring-cloud-starter-vault-config + + org.springframework.retry + spring-retry + + com.amazonaws @@ -124,7 +133,23 @@ ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF S 1.2.2 test - + + org.springframework.security + spring-security-test + 5.1.9.RELEASE + test + + + io.jsonwebtoken + jjwt + 0.9.1 + test + + + org.mockito + mockito-core + test + diff --git a/score-server/src/main/java/bio/overture/score/server/config/RetryConfig.java b/score-server/src/main/java/bio/overture/score/server/config/RetryConfig.java new file mode 100644 index 00000000..a1693fcd --- /dev/null +++ b/score-server/src/main/java/bio/overture/score/server/config/RetryConfig.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2018. Ontario Institute for Cancer Research + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package bio.overture.score.server.config; + +import com.google.common.collect.ImmutableMap; +import lombok.val; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.backoff.BackOffPolicy; +import org.springframework.retry.backoff.ExponentialBackOffPolicy; +import org.springframework.retry.policy.SimpleRetryPolicy; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; + +import java.util.Map; + +import static java.lang.Boolean.TRUE; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.springframework.retry.backoff.ExponentialBackOffPolicy.DEFAULT_MULTIPLIER; + +@Configuration +public class RetryConfig { + + private static final int DEFAULT_MAX_RETRIES = 5; + private static final long DEFAULT_INITIAL_BACKOFF_INTERVAL = SECONDS.toMillis(15L); + + @Value("${auth.connection.maxRetries}") + private int maxRetries = DEFAULT_MAX_RETRIES; + + @Value("${auth.connection.initialBackoff}") + private long initialBackoff = DEFAULT_INITIAL_BACKOFF_INTERVAL; + + @Value("${auth.connection.multiplier}") + private double multiplier = DEFAULT_MULTIPLIER; + + @Bean + public RetryTemplate retryTemplate() { + val result = new RetryTemplate(); + result.setBackOffPolicy(defineBackOffPolicy()); + + result.setRetryPolicy( + new SimpleRetryPolicy(maxRetries, getRetryableExceptions(), true)); + return result; + } + + private BackOffPolicy defineBackOffPolicy() { + val backOffPolicy = new ExponentialBackOffPolicy(); + backOffPolicy.setInitialInterval(initialBackoff); + backOffPolicy.setMultiplier(multiplier); + + return backOffPolicy; + } + + private static Map, Boolean> getRetryableExceptions() { + return ImmutableMap.of( + ResourceAccessException.class, TRUE, + HttpServerErrorException.class, TRUE); + } +} diff --git a/score-server/src/main/java/bio/overture/score/server/config/SecurityConfig.java b/score-server/src/main/java/bio/overture/score/server/config/SecurityConfig.java index fa3fdcbd..e0390fb8 100644 --- a/score-server/src/main/java/bio/overture/score/server/config/SecurityConfig.java +++ b/score-server/src/main/java/bio/overture/score/server/config/SecurityConfig.java @@ -19,19 +19,12 @@ import bio.overture.score.server.metadata.MetadataService; import bio.overture.score.server.properties.ScopeProperties; -import bio.overture.score.server.security.AccessTokenConverterWithExpiry; -import bio.overture.score.server.security.CachingRemoteTokenServices; import bio.overture.score.server.security.scope.DownloadScopeAuthorizationStrategy; import bio.overture.score.server.security.scope.UploadScopeAuthorizationStrategy; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; -import lombok.val; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; -import org.springframework.context.annotation.Scope; +import org.springframework.context.annotation.*; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.context.SecurityContextHolder; @@ -39,8 +32,6 @@ import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; import org.springframework.security.oauth2.provider.authentication.BearerTokenExtractor; import org.springframework.security.oauth2.provider.authentication.TokenExtractor; -import org.springframework.security.oauth2.provider.token.AccessTokenConverter; -import org.springframework.security.oauth2.provider.token.RemoteTokenServices; import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter; import org.springframework.web.filter.OncePerRequestFilter; @@ -95,29 +86,6 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull Ht configureAuthorization(http); } - @Bean - public AccessTokenConverter accessTokenConverter() { - return new AccessTokenConverterWithExpiry(); - } - - @Bean - public RemoteTokenServices remoteTokenServices( - final @Value("${auth.server.url}") String checkTokenUrl, - final @Value("${auth.server.tokenName:token}") String tokenName, - final @Value("${auth.server.clientId}") String clientId, - final @Value("${auth.server.clientSecret}") String clientSecret) { - val remoteTokenServices = new CachingRemoteTokenServices(); - remoteTokenServices.setCheckTokenEndpointUrl(checkTokenUrl); - remoteTokenServices.setClientId(clientId); - remoteTokenServices.setClientSecret(clientSecret); - remoteTokenServices.setTokenName(tokenName); - remoteTokenServices.setAccessTokenConverter(accessTokenConverter()); - - log.debug("using auth server: " + checkTokenUrl); - - return remoteTokenServices; - } - private void configureAuthorization(HttpSecurity http) throws Exception { scopeProperties.logScopeProperties();; @@ -153,4 +121,7 @@ public DownloadScopeAuthorizationStrategy accessSecurity(@Autowired MetadataServ scopeProperties.getDownload().getSystem(), song); } -} \ No newline at end of file + + public ScopeProperties getScopeProperties() { return this.scopeProperties; } + +} diff --git a/score-server/src/main/java/bio/overture/score/server/config/TokenServicesConfig.java b/score-server/src/main/java/bio/overture/score/server/config/TokenServicesConfig.java new file mode 100644 index 00000000..8458ea81 --- /dev/null +++ b/score-server/src/main/java/bio/overture/score/server/config/TokenServicesConfig.java @@ -0,0 +1,80 @@ +package bio.overture.score.server.config; + +import bio.overture.score.server.security.*; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.security.oauth2.provider.token.AccessTokenConverter; +import org.springframework.security.oauth2.provider.token.DefaultTokenServices; +import org.springframework.security.oauth2.provider.token.RemoteTokenServices; +import org.springframework.security.oauth2.provider.token.store.JwtTokenStore; +import org.springframework.web.client.RestTemplate; + +@Configuration +@Slf4j +@Profile("secure") +public class TokenServicesConfig { + + @Value("${auth.server.url}") private String checkTokenUrl; + @Value("${auth.server.tokenName:token}") private String tokenName; + @Value("${auth.server.clientId}") private String clientId; + @Value("${auth.server.clientSecret}") private String clientSecret; + + @Bean + @Profile("!jwt") + public RemoteTokenServices remoteTokenServices() { + return createRemoteTokenServices(); + } + + @Bean + @Autowired + @Profile("jwt") + public MergedServerTokenServices mergedServerTokenServices( + @NonNull PublicKeyFetcher publicKeyFetcher, + @NonNull RetryTemplate retryTemplate + ) { + val jwtTokenServices = createJwtTokenServices(publicKeyFetcher.getPublicKey()); + val remoteTokenServices = createRemoteTokenServices(); + return new MergedServerTokenServices(jwtTokenServices, remoteTokenServices, retryTemplate); + } + + @Bean + @Autowired + @Profile("jwt") + public PublicKeyFetcher publicKeyFetcher( + @Value("${auth.jwt.publicKeyUrl}") @NonNull String publicKeyUrl, + @NonNull RetryTemplate retryTemplate) { + return new DefaultPublicKeyFetcher(publicKeyUrl, new RestTemplate(), retryTemplate); + } + + private AccessTokenConverter accessTokenConverter() { + return new AccessTokenConverterWithExpiry(); + } + + private RemoteTokenServices createRemoteTokenServices() { + val remoteTokenServices = new CachingRemoteTokenServices(); + remoteTokenServices.setCheckTokenEndpointUrl(checkTokenUrl); + remoteTokenServices.setClientId(clientId); + remoteTokenServices.setClientSecret(clientSecret); + remoteTokenServices.setTokenName(tokenName); + remoteTokenServices.setAccessTokenConverter(accessTokenConverter()); + + log.debug("using auth server: " + checkTokenUrl); + + return remoteTokenServices; + } + + private DefaultTokenServices createJwtTokenServices(String publicKey) { + val tokenStore = new JwtTokenStore(new JWTConverter(publicKey)); + val defaultTokenServices = new DefaultTokenServices(); + defaultTokenServices.setTokenStore(tokenStore); + return defaultTokenServices; + } + +} diff --git a/score-server/src/main/java/bio/overture/score/server/security/DefaultPublicKeyFetcher.java b/score-server/src/main/java/bio/overture/score/server/security/DefaultPublicKeyFetcher.java new file mode 100644 index 00000000..4f3448db --- /dev/null +++ b/score-server/src/main/java/bio/overture/score/server/security/DefaultPublicKeyFetcher.java @@ -0,0 +1,21 @@ +package bio.overture.score.server.security; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.val; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.web.client.RestTemplate; + +@RequiredArgsConstructor +public class DefaultPublicKeyFetcher implements PublicKeyFetcher { + + @NonNull private final String url; + @NonNull private final RestTemplate restTemplate; + @NonNull private final RetryTemplate retryTemplate; + + @Override + public String getPublicKey() { + val resp = retryTemplate.execute(x -> restTemplate.getForEntity(url, String.class)); + return resp.hasBody() ? resp.getBody() : null; + } +} diff --git a/score-server/src/main/java/bio/overture/score/server/security/JWTConverter.java b/score-server/src/main/java/bio/overture/score/server/security/JWTConverter.java new file mode 100644 index 00000000..b665f4ab --- /dev/null +++ b/score-server/src/main/java/bio/overture/score/server/security/JWTConverter.java @@ -0,0 +1,52 @@ +package bio.overture.score.server.security; + +import lombok.NonNull; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.springframework.security.oauth2.provider.OAuth2Authentication; +import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter; + +import java.util.*; + +@Slf4j +public class JWTConverter extends JwtAccessTokenConverter { + + private final static String CONTEXT = "context"; + private final static String SCOPE = "scope"; + + @SneakyThrows + public JWTConverter(String publicKey) { + super(); + this.setVerifierKey(publicKey); + this.afterPropertiesSet(); + } + + @Override + public OAuth2Authentication extractAuthentication(@NonNull Map map) { + // Currently EGO's JWT spec places scopes in map at 'context.scope' + // but extractAuthentication expects them in map's root at 'scope' + // so put all scopes into root level for spring security processing + val allScopes = getRootAndContextScopes(map); + HashMap updatedMap = new HashMap<>(map); + updatedMap.put(SCOPE, allScopes); + + return super.extractAuthentication(updatedMap); + } + + private Collection getRootAndContextScopes(Map map) { + List extractedScopes = new ArrayList<>(Collections.emptyList()); + try { + if (map.containsKey(CONTEXT)) { + val context = (Map) map.get(CONTEXT); + extractedScopes.addAll((Collection) context.get(SCOPE)); + } + if (map.containsKey(SCOPE)) { + extractedScopes.addAll((Collection) map.get(SCOPE)); + } + } catch (Exception e) { + log.error("Failed to extract scopes from JWT"); + } + return extractedScopes; + } +} diff --git a/score-server/src/main/java/bio/overture/score/server/security/MergedServerTokenServices.java b/score-server/src/main/java/bio/overture/score/server/security/MergedServerTokenServices.java new file mode 100644 index 00000000..a034fa94 --- /dev/null +++ b/score-server/src/main/java/bio/overture/score/server/security/MergedServerTokenServices.java @@ -0,0 +1,50 @@ +package bio.overture.score.server.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.common.OAuth2AccessToken; +import org.springframework.security.oauth2.common.exceptions.InvalidTokenException; +import org.springframework.security.oauth2.provider.OAuth2Authentication; +import org.springframework.security.oauth2.provider.token.DefaultTokenServices; +import org.springframework.security.oauth2.provider.token.RemoteTokenServices; +import org.springframework.security.oauth2.provider.token.ResourceServerTokenServices; + +import java.util.UUID; + +import static com.google.common.base.Strings.isNullOrEmpty; + +@RequiredArgsConstructor +public class MergedServerTokenServices implements ResourceServerTokenServices { + private final DefaultTokenServices jwtTokenService; + private final RemoteTokenServices remoteTokenServices; + private final RetryTemplate retryTemplate; + + @Override + public OAuth2Authentication loadAuthentication(String accessToken) throws AuthenticationException, InvalidTokenException { + if (isApiKey(accessToken)) { + return retryTemplate.execute(x -> remoteTokenServices.loadAuthentication(accessToken)); + } + return jwtTokenService.loadAuthentication(accessToken); + } + + @Override + public OAuth2AccessToken readAccessToken(String accessToken) { + if (isApiKey(accessToken)) { + return retryTemplate.execute(x ->remoteTokenServices.readAccessToken(accessToken)); + } + return jwtTokenService.readAccessToken(accessToken); + } + + private static boolean isApiKey(String value) { + if (isNullOrEmpty(value)) { + return false; + } + try { + UUID.fromString(value); + } catch (IllegalArgumentException e) { + return false; + } + return true; + } +} diff --git a/score-server/src/main/java/bio/overture/score/server/security/PublicKeyFetcher.java b/score-server/src/main/java/bio/overture/score/server/security/PublicKeyFetcher.java new file mode 100644 index 00000000..7cb6c3e5 --- /dev/null +++ b/score-server/src/main/java/bio/overture/score/server/security/PublicKeyFetcher.java @@ -0,0 +1,7 @@ +package bio.overture.score.server.security; + +@FunctionalInterface +public interface PublicKeyFetcher { + + String getPublicKey(); +} diff --git a/score-server/src/main/resources/application.yml b/score-server/src/main/resources/application.yml index fa0cc35e..95fb6bb0 100644 --- a/score-server/src/main/resources/application.yml +++ b/score-server/src/main/resources/application.yml @@ -27,31 +27,31 @@ server: compression: enabled: true mime-types: application/json - + s3: secured: true sigV4Enabled: true - + #amazon endpoint: s3-external-1.amazonaws.com metadata: useLegacyMode: false - + bucket: name.object: oicr.icgc name.state: oicr.icgc size.pool: 0 - size.key: 2 + size.key: 2 object: sentinel: heliograph - + collaboratory: upload.directory: upload upload.expiration: 6 data.directory: data - + # COL-131: Change pre-signed URLs TTL to 1 day max download.expiration: 1 @@ -59,11 +59,23 @@ upload: retry.limit: 5 partsize: 20000000 connection.timeout: 15000 - + # Every day at midnight - clean.cron: "0 0 0 * * ?" + clean.cron: "0 0 0 * * ?" clean.enabled: true +auth: + # Connection retries in case of connection failure + connection: + # Max number of retries + maxRetries: 5 + + # Initial timeoutMs before the first retry. In milliseconds. + initialBackoff: 15000 + + # Multiplier that defines value of consequent timeouts before the next retry. + # E.g. TIMEOUT(n) = TIMEOUT(n-1) * MULTIPLIER + multiplier: 2.0 --- @@ -75,7 +87,7 @@ upload: ############################################################################### spring.profiles: ssl - + # Server server: ssl: @@ -92,7 +104,7 @@ server: spring: profiles: amazon profiles.include: prod - + s3: endpoint: s3-external-1.amazonaws.com masterEncryptionKeyId: af628f04-ac12-4b11-bf83-6545fd44ad18 @@ -116,7 +128,7 @@ spring: s3: endpoint: https://object.cancercollaboratory.org:9080 - masterEncryptionKeyId: + masterEncryptionKeyId: metadata: url: https://song.cancercollaboratory.org @@ -132,18 +144,18 @@ spring: profiles: azure azure: - endpointProtocol: https + endpointProtocol: https accountName: oicricgc - accountKey: + accountKey: bucket: name.object: data policy.upload: UploadPolicy policy.download: DownloadPolicy - + download: partsize: 250000000 - + --- ############################################################################### @@ -166,7 +178,7 @@ metadata: ############################################################################### spring.profiles: secure - + # OAuth authentication server auth: server: @@ -187,6 +199,19 @@ auth: suffix: .upload --- +############################################################################### +# Profile - "jwt" +############################################################################### + +spring: + profiles: jwt + profiles.include: secure + +auth: + jwt: + publicKeyUrl: "https://localhost:8443/oauth/token/public_key" +--- + ############################################################################### # Profile - "dev" ############################################################################### @@ -196,7 +221,7 @@ spring.profiles: dev s3: secured: false endpoint: localhost:9444/s3 - accessKey: AKIAIOSFODNN7EXAMPLE + accessKey: AKIAIOSFODNN7EXAMPLE secretKey: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY # Server @@ -205,7 +230,7 @@ server: bucket: name.object: test.icgc name.state: test.icgc - + upload: clean.enabled: false diff --git a/score-server/src/test/java/bio/overture/score/server/JWTTestConfig.java b/score-server/src/test/java/bio/overture/score/server/JWTTestConfig.java new file mode 100644 index 00000000..916b4914 --- /dev/null +++ b/score-server/src/test/java/bio/overture/score/server/JWTTestConfig.java @@ -0,0 +1,55 @@ +package bio.overture.score.server; + +import bio.overture.score.server.security.PublicKeyFetcher; +import lombok.SneakyThrows; +import lombok.val; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.Base64; + +@Configuration +@Profile("test & jwt") +public class JWTTestConfig { + + private final KeyPair keyPair; + + @SneakyThrows + public JWTTestConfig() { + val keyGenerator = KeyPairGenerator.getInstance("RSA"); + keyGenerator.initialize(2048); + this.keyPair = keyGenerator.genKeyPair(); + } + + @Bean + @Primary + public KeyPair keyPair() { + return keyPair; + } + + @Bean + @Primary + public PublicKeyFetcher testPublicKeyFetcher() { + return this::getPublicKey; + } + + public String getPublicKey() { + return convertToPublicKeyWithHeader(getDecodedPublicKey()); + } + + public String getDecodedPublicKey() { + return Base64.getEncoder().encodeToString(keyPair().getPublic().getEncoded()); + } + + private static String convertToPublicKeyWithHeader(String key) { + val result = new StringBuilder(); + result.append("-----BEGIN PUBLIC KEY-----\n"); + result.append(key); + result.append("\n-----END PUBLIC KEY-----"); + return result.toString(); + } +} diff --git a/score-server/src/test/java/bio/overture/score/server/security/JWTSecurityTest.java b/score-server/src/test/java/bio/overture/score/server/security/JWTSecurityTest.java new file mode 100644 index 00000000..7c4e65c1 --- /dev/null +++ b/score-server/src/test/java/bio/overture/score/server/security/JWTSecurityTest.java @@ -0,0 +1,303 @@ +package bio.overture.score.server.security; + +import bio.overture.score.core.model.ObjectSpecification; +import bio.overture.score.server.config.SecurityConfig; +import bio.overture.score.server.metadata.MetadataEntity; +import bio.overture.score.server.metadata.MetadataService; +import bio.overture.score.server.repository.DownloadService; +import bio.overture.score.server.repository.UploadService; +import bio.overture.score.server.utils.JWTGenerator; +import bio.overture.score.server.utils.JwtContext; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.security.KeyPair; +import java.util.List; +import java.util.Map; + +import static bio.overture.score.server.security.JWTSecurityTest.RequestType.DOWNLOAD; +import static bio.overture.score.server.security.JWTSecurityTest.RequestType.UPLOAD; +import static bio.overture.score.server.security.JWTSecurityTest.ScopeOptions.*; +import static bio.overture.score.server.utils.JwtContext.buildJwtContext; +import static java.util.Objects.isNull; +import static org.apache.commons.lang.StringUtils.isBlank; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpStatus.*; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; + +@Slf4j +@SpringBootTest +@ContextConfiguration +@RunWith(SpringJUnit4ClassRunner.class) +@ActiveProfiles({"test", "secure", "jwt", "default", "dev"}) +public class JWTSecurityTest { + + // -- constants -- + private final static String CONTROLLED = "controlled"; + private final static String PUBLISHED = "PUBLISHED"; + + private final static String NON_EXISTING_PROJECT_CODE = "FAKE"; + + private final static String EXISTING_PROJECT_CODE = "TEST"; + private final static String EXISTING_OBJECT_ID = "123"; + private final static String EXISTING_GNOS_ID = "123"; + + private final static String DOWNLOAD_ENDPOINT = "/download/" + EXISTING_OBJECT_ID + "?offset=0&length=-1"; + private final static String UPLOAD_ENDPOINT = "/upload/" + EXISTING_OBJECT_ID + "/uploads?fileSize=1"; + + private final static boolean EXPIRED = true; + private final static boolean NOT_EXPIRED = !EXPIRED; + + // -- Dependencies -- + @Autowired private WebApplicationContext webApplicationContext; + @Autowired private SecurityConfig securityConfig; + @Autowired private KeyPair keyPair; + + // -- Mocking -- + private MockMvc mockMvc; + @MockBean private MetadataService metadataService; + @MockBean private DownloadService downloadService; + @MockBean private UploadService uploadService; + + private JWTGenerator jwtGenerator; + private Map> downloadScopesMap; + private Map> uploadScopesMap; + + @Before + @SneakyThrows + public void beforeEachTest() { + jwtGenerator = new JWTGenerator(keyPair); + if (mockMvc == null) { + this.mockMvc = + MockMvcBuilders.webAppContextSetup(webApplicationContext).apply(springSecurity()).build(); + + downloadScopesMap = Map.of( + VALID_SYSTEM, List.of(resolveSystemDownloadScope(), "id.READ"), + VALID_STUDY, List.of(resolveStudyDownloadScope(EXISTING_PROJECT_CODE), "id.READ"), + INVALID_STUDY, List.of(resolveStudyDownloadScope(NON_EXISTING_PROJECT_CODE), "id.READ") + ); + + uploadScopesMap = Map.of( + VALID_SYSTEM, List.of(resolveSystemUploadScope(), "id.WRITE"), + VALID_STUDY, List.of(resolveStudyUploadScope(EXISTING_PROJECT_CODE), "id.WRITE"), + INVALID_STUDY, List.of(resolveStudyUploadScope(NON_EXISTING_PROJECT_CODE), "id.WRITE") + ); + } + setMocks(); + } + + @Test public void jwtDownloadValidation_validStudyScope_Success() { + executeAndAssert(DOWNLOAD, VALID_STUDY, NOT_EXPIRED, OK); + } + @Test public void jwtDownloadValidation_validSystemScope_Success() { + executeAndAssert(DOWNLOAD, VALID_SYSTEM, NOT_EXPIRED, OK); + } + @Test public void jwtDownloadValidation_validStudyScopeExpired_Unauthorized() { + executeAndAssert(DOWNLOAD, VALID_STUDY, EXPIRED, UNAUTHORIZED); + } + @Test public void jwtDownloadValidation_validSystemScopeExpired_Unauthorized() { + executeAndAssert(DOWNLOAD, VALID_SYSTEM, EXPIRED, UNAUTHORIZED); + } + @Test public void jwtDownloadValidation_missingScopeField_Forbidden() { + executeAndAssert(DOWNLOAD, EMPTY_SCOPE, NOT_EXPIRED, FORBIDDEN); + } + @Test public void jwtDownloadValidation_missingScopeFieldExpired_Unauthorized() { + executeAndAssert(DOWNLOAD, EMPTY_SCOPE, EXPIRED, UNAUTHORIZED); + } + @Test public void jwtDownloadValidation_invalidSystemScope_Forbidden() { + executeAndAssert(DOWNLOAD, INVALID_SYSTEM, NOT_EXPIRED, FORBIDDEN); + } + @Test public void jwtDownloadValidation_invalidStudyScope_Forbidden() { + executeAndAssert(DOWNLOAD, INVALID_STUDY, NOT_EXPIRED, FORBIDDEN); + } + @Test public void jwtDownloadValidation_invalidSystemScopeExpired_Unauthorized() { + executeAndAssert(DOWNLOAD, INVALID_SYSTEM, EXPIRED, UNAUTHORIZED); + } + @Test public void jwtDownloadValidation_invalidStudyScopeExpired_Unauthorized() { + executeAndAssert(DOWNLOAD, INVALID_STUDY, EXPIRED, UNAUTHORIZED); + } + @Test public void jwtDownloadValidation_malformedAccessToken_Unauthorized() { + executeAndAssert(DOWNLOAD, MALFORMED, NOT_EXPIRED, UNAUTHORIZED); + } + + @Test public void jwtUploadValidation_validStudyScope_Success() { + executeAndAssert(UPLOAD, VALID_STUDY, NOT_EXPIRED, OK); + } + @Test public void jwtUploadValidation_validSystemScope_Success() { + executeAndAssert(UPLOAD, VALID_SYSTEM, NOT_EXPIRED, OK); + } + @Test public void jwtUploadValidation_validStudyScopeExpired_Unauthorized() { + executeAndAssert(UPLOAD, VALID_STUDY, EXPIRED, UNAUTHORIZED); + } + @Test public void jwtUploadValidation_validSystemScopeExpired_Unauthorized() { + executeAndAssert(UPLOAD, VALID_SYSTEM, EXPIRED, UNAUTHORIZED); + } + @Test public void jwtUploadValidation_missingScopeField_Forbidden() { + executeAndAssert(UPLOAD, EMPTY_SCOPE, NOT_EXPIRED, FORBIDDEN); + } + @Test public void jwtUploadValidation_missingScopeFieldExpired_Unauthorized() { + executeAndAssert(UPLOAD, EMPTY_SCOPE, EXPIRED, UNAUTHORIZED); + } + @Test public void jwtUploadValidation_invalidSystemScope_Forbidden() { + executeAndAssert(UPLOAD, INVALID_SYSTEM, NOT_EXPIRED, FORBIDDEN); + } + @Test public void jwtUploadValidation_invalidStudyScope_Forbidden() { + executeAndAssert(UPLOAD, INVALID_STUDY, NOT_EXPIRED, FORBIDDEN); + } + @Test public void jwtUploadValidation_invalidSystemScopeExpired_Unauthorized() { + executeAndAssert(UPLOAD, INVALID_SYSTEM, EXPIRED, UNAUTHORIZED); + } + @Test public void jwtUploadValidation_invalidStudyScopeExpired_Unauthorized() { + executeAndAssert(UPLOAD, INVALID_STUDY, EXPIRED, UNAUTHORIZED); + } + @Test public void jwtUploadValidation_malformedAccessToken_Unauthorized() { + executeAndAssert(UPLOAD, MALFORMED, NOT_EXPIRED, UNAUTHORIZED); + } + + private void executeAndAssert( + RequestType requestType, ScopeOptions scopeOptions, boolean expired, HttpStatus statusToAssert) { + val res = getResponseFromRequestWithJwt(requestType, scopeOptions, expired); + assertEquals(statusToAssert, res.getStatusCode()); + } + + private ResponseEntity getResponseFromRequestWithJwt( + RequestType requestType, ScopeOptions scopeOptions, boolean expired) { + val jwtString = generateConstrainedJWTString(scopeOptions, requestType, expired); + + val headers = createHeaderWithJwt(jwtString); + + ResponseEntity res = null; + if (requestType == RequestType.DOWNLOAD) { + res = executeRequest(HttpMethod.GET, DOWNLOAD_ENDPOINT, headers); + } else if (requestType == UPLOAD) { + res = executeRequest(HttpMethod.POST, UPLOAD_ENDPOINT, headers); + } else { + fail("shouldn't be here"); + } + return res; + } + + @SneakyThrows + private ResponseEntity executeRequest(HttpMethod httpMethod, String url, HttpHeaders headers) { + val mvcRequest = MockMvcRequestBuilders.request(httpMethod, url).headers(headers); + mvcRequest.content(""); + val mvcResult = mockMvc.perform(mvcRequest).andReturn(); + val mvcResponse = mvcResult.getResponse(); + val httpStatus = HttpStatus.resolve(mvcResponse.getStatus()); + String responseObject; + assert httpStatus != null; + if (httpStatus.isError()) { + responseObject = mvcResponse.getContentAsString(); + if (isBlank(responseObject) && !isNull(mvcResult.getResolvedException())) { + responseObject = mvcResult.getResolvedException().getMessage(); + } + } else { + responseObject = mvcResponse.getContentAsString(); + } + return ResponseEntity.status(mvcResponse.getStatus()).body(responseObject); + } + + private String generateConstrainedJWTString( + ScopeOptions scopeOptions, RequestType requestType, boolean expired) { + if (scopeOptions == ScopeOptions.MALFORMED) { return ""; } + + JwtContext context = null; + if (scopeOptions == ScopeOptions.INVALID_SYSTEM) { + context = buildJwtContext(List.of("song.READ")); + } else if (scopeOptions == ScopeOptions.EMPTY_SCOPE) { + context = buildJwtContext(List.of()); + } else if (requestType == DOWNLOAD) { + context = buildJwtContext(downloadScopesMap.get(scopeOptions)); + } else if (requestType == UPLOAD) { + context = buildJwtContext(uploadScopesMap.get(scopeOptions)); + } else { + fail("shouldn't be here"); + } + + String jwtString = null; + if (isNull(context)) { + jwtString = jwtGenerator.generateJwtNoContext(expired); + } else { + jwtString = jwtGenerator.generateJwtWithContext(context, expired); + } + return jwtString; + } + + private String resolveSystemDownloadScope() { + return securityConfig.getScopeProperties().getDownload().getSystem(); + } + + private String resolveSystemUploadScope() { + return securityConfig.getScopeProperties().getUpload().getSystem(); + } + + private String resolveStudyDownloadScope(String studyId) { + val studyDownloadScope = securityConfig.getScopeProperties().getDownload().getStudy(); + return studyDownloadScope.getPrefix() + studyId + studyDownloadScope.getSuffix(); + } + + private String resolveStudyUploadScope(String studyId) { + val studyUploadScope = securityConfig.getScopeProperties().getUpload().getStudy(); + return studyUploadScope.getPrefix() + studyId + studyUploadScope.getSuffix(); + } + + private HttpHeaders createHeaderWithJwt(String jwt) { + val headers = new HttpHeaders(); + headers.add("Authorization", "Bearer " + jwt); + return headers; + } + + private void setMocks() { + val metaData = + MetadataEntity.builder() + .id(EXISTING_OBJECT_ID) + .fileName("test.bam") + .projectCode(EXISTING_PROJECT_CODE) + .gnosId(EXISTING_GNOS_ID) + .access(CONTROLLED).build(); + + when(metadataService.getEntity(EXISTING_GNOS_ID)).thenReturn(metaData); + when(metadataService.getAnalysisStateForMetadata(metaData)).thenReturn(PUBLISHED); + + val dummyObject = ObjectSpecification.builder().objectId(EXISTING_OBJECT_ID).build(); + when(downloadService.download(eq(EXISTING_OBJECT_ID), anyLong(), anyLong(), anyBoolean(), anyBoolean())).thenReturn(dummyObject); + + val newObjectSpec = ObjectSpecification.builder().objectId(EXISTING_OBJECT_ID).build(); + when(uploadService.initiateUpload(eq(EXISTING_OBJECT_ID), anyByte(), anyString(), anyBoolean())).thenReturn(newObjectSpec); + } + + enum ScopeOptions { + VALID_SYSTEM, + VALID_STUDY, + INVALID_SYSTEM, + INVALID_STUDY, + MALFORMED, + EMPTY_SCOPE; + } + + enum RequestType { + UPLOAD, + DOWNLOAD + } +} diff --git a/score-server/src/test/java/bio/overture/score/server/security/MergedServerTokenServicesTest.java b/score-server/src/test/java/bio/overture/score/server/security/MergedServerTokenServicesTest.java new file mode 100644 index 00000000..2593ea1e --- /dev/null +++ b/score-server/src/test/java/bio/overture/score/server/security/MergedServerTokenServicesTest.java @@ -0,0 +1,63 @@ +package bio.overture.score.server.security; + + +import bio.overture.score.server.utils.JWTGenerator; +import lombok.SneakyThrows; +import lombok.val; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.security.oauth2.provider.token.DefaultTokenServices; +import org.springframework.security.oauth2.provider.token.RemoteTokenServices; + +import java.security.KeyPairGenerator; +import java.util.List; +import java.util.UUID; + +import static bio.overture.score.server.utils.JwtContext.buildJwtContext; +import static org.mockito.Mockito.*; + +@RunWith(MockitoJUnitRunner.class) +public class MergedServerTokenServicesTest { + private static final String API_KEY = UUID.randomUUID().toString(); + + private MergedServerTokenServices mergedServerTokenServices; + private RetryTemplate retryTemplate = new RetryTemplate(); + @Mock private RemoteTokenServices remoteTokenServices; + @Mock private DefaultTokenServices jwtTokenServices; + + private JWTGenerator jwtGenerator; + + @Before + @SneakyThrows + public void beforeTest() { + val keyGenerator = KeyPairGenerator.getInstance("RSA"); + keyGenerator.initialize(1024); + jwtGenerator = new JWTGenerator(keyGenerator.generateKeyPair()); + mergedServerTokenServices = new MergedServerTokenServices(jwtTokenServices, remoteTokenServices, retryTemplate); + } + + @Test + public void accessTokenResolution_apiKey_success() { + when(remoteTokenServices.loadAuthentication(API_KEY)).thenReturn(null); + when(remoteTokenServices.readAccessToken(API_KEY)).thenReturn(null); + mergedServerTokenServices.loadAuthentication(API_KEY); + mergedServerTokenServices.readAccessToken(API_KEY); + verify(remoteTokenServices, times(1)).loadAuthentication(API_KEY); + verify(remoteTokenServices, times(1)).readAccessToken(API_KEY); + } + + @Test + public void accessTokenResolution_jwt_success() { + val jwtString = jwtGenerator.generateJwtWithContext(buildJwtContext(List.of("score.WRITE")), false); + when(jwtTokenServices.loadAuthentication(jwtString)).thenReturn(null); + when(jwtTokenServices.readAccessToken(jwtString)).thenReturn(null); + mergedServerTokenServices.loadAuthentication(jwtString); + mergedServerTokenServices.readAccessToken(jwtString); + verify(jwtTokenServices, times(1)).loadAuthentication(jwtString); + verify(jwtTokenServices, times(1)).readAccessToken(jwtString); + } +} diff --git a/score-server/src/test/java/bio/overture/score/server/utils/JWTGenerator.java b/score-server/src/test/java/bio/overture/score/server/utils/JWTGenerator.java new file mode 100644 index 00000000..99feb314 --- /dev/null +++ b/score-server/src/test/java/bio/overture/score/server/utils/JWTGenerator.java @@ -0,0 +1,85 @@ +package bio.overture.score.server.utils; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.NonNull; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.security.KeyPair; +import java.util.Date; + +import static bio.overture.score.server.utils.JsonUtils.toJson; +import static bio.overture.score.server.utils.JsonUtils.toMap; +import static java.util.Objects.isNull; +import static java.util.concurrent.TimeUnit.HOURS; + +@Slf4j +@Component +@Profile({"test", "jwt"}) +public class JWTGenerator { + + public static final String DEFAULT_ISSUER = "ego"; + public static final String DEFAULT_ID = "68418f9f-65b9-4a17-ac1c-88acd9984fe0"; + public static final String DEFAULT_SUBJECT = "none"; + private static final SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.RS256; + + private final KeyPair keyPair; + + @Autowired + public JWTGenerator(@NonNull KeyPair keyPair) { + this.keyPair = keyPair; + } + + public String generateJwtNoContext(boolean expired) { + return generate(calcTTLMs(expired), null); + } + + public String generateJwtWithContext(JwtContext jwtContext, boolean expired) { + return generate(calcTTLMs(expired), jwtContext); + } + + @SneakyThrows + public Jws verifyAndGetClaims(String jwtString) { + val publicKey = keyPair.getPublic(); + return Jwts.parser().setSigningKey(publicKey).parseClaimsJws(jwtString); + } + + private static long calcTTLMs(boolean expired) { + return expired ? 0 : HOURS.toMillis(5); + } + + @SneakyThrows + private String generate(long ttlMs, JwtContext jwtContext) { + long nowMs = System.currentTimeMillis(); + + long expiry; + // if ttlMs <= 0 make it expired + if (ttlMs <= 0) { + expiry = nowMs - 10000; + nowMs -= 100000L; + } else { + expiry = nowMs + ttlMs; + } + + val decodedPrivateKey = keyPair.getPrivate(); + val jwtBuilder = + Jwts.builder() + .setId(DEFAULT_ID) + .setIssuedAt(new Date(nowMs)) + .setSubject(DEFAULT_SUBJECT) + .setIssuer(DEFAULT_ISSUER) + .setExpiration(new Date(expiry)) + .signWith(SIGNATURE_ALGORITHM, decodedPrivateKey); + if (!isNull(jwtContext)) { + jwtBuilder.addClaims(toMap(toJson(jwtContext))); + } + return jwtBuilder.compact(); + } +} diff --git a/score-server/src/test/java/bio/overture/score/server/utils/JsonUtils.java b/score-server/src/test/java/bio/overture/score/server/utils/JsonUtils.java new file mode 100644 index 00000000..fdb4e8eb --- /dev/null +++ b/score-server/src/test/java/bio/overture/score/server/utils/JsonUtils.java @@ -0,0 +1,32 @@ +package bio.overture.score.server.utils; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.SneakyThrows; + +import java.io.IOException; +import java.util.Map; + +public class JsonUtils { + private static final ObjectMapper mapper = new ObjectMapper(); + + @SneakyThrows + public static T fromJson(String json, Class toValue) { + return fromJson(mapper.readTree(json), toValue); + } + + public static T fromJson(JsonNode json, Class toValue) { + return mapper.convertValue(json, toValue); + } + + @SuppressWarnings("unchecked") + public static Map toMap(String json) + throws IllegalArgumentException, IOException { + return fromJson(json, Map.class); + } + + @SneakyThrows + public static String toJson(Object o) { + return mapper.writeValueAsString(o); + } +} diff --git a/score-server/src/test/java/bio/overture/score/server/utils/JwtContext.java b/score-server/src/test/java/bio/overture/score/server/utils/JwtContext.java new file mode 100644 index 00000000..bb87e551 --- /dev/null +++ b/score-server/src/test/java/bio/overture/score/server/utils/JwtContext.java @@ -0,0 +1,31 @@ +package bio.overture.score.server.utils; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.*; + +import java.util.Collection; + +import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(NON_EMPTY) +public class JwtContext { + + private JwtScope context; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(NON_EMPTY) + public static class JwtScope { + private Collection scope; + } + + public static JwtContext buildJwtContext(@NonNull Collection scopes) { + return JwtContext.builder().context(JwtScope.builder().scope(scopes).build()).build(); + } +}