From 8d8c2cc2c07a5f013d4e7915c01c65a6ca93495c Mon Sep 17 00:00:00 2001 From: Marco Massenzio Date: Mon, 24 Oct 2022 18:36:12 -1000 Subject: [PATCH] [#21] Enable fetching KeyPair from AWS SecretsManager Moved KeypairReader Bean creation outside of jwt-opa Library configuration should not get involved in deciding how and where the keys are loaded. Also removed the key properties from the token properties. so as to simplify adding more sources for secrets (e.g., Vault, Azure, etc.). Added fake AWS creds to Test GH Action --- .github/workflows/verify.yml | 15 ++- .gitignore | 4 +- aws-upload-keys.sh | 41 ++++--- jwt-opa/build.gradle | 10 +- .../java/com/alertavert/opa/Constants.java | 22 +++- .../opa/ExcludeFromCoverageGenerated.java | 26 ++++ .../KeyMaterialConfiguration.java | 66 +++------- .../opa/configuration/TokensProperties.java | 25 ++-- .../security/aws/AwsClientConfiguration.java | 106 ++++++++++++++++ .../security/aws/AwsSecretsKeypairReader.java | 113 ++++++++++++++++++ .../aws/AwsSecretsManagerResolver.java | 54 +++++++++ .../opa/security/aws/SecretsResolver.java | 20 ++++ .../security/crypto/KeypairFileReader.java | 23 ++-- .../opa/security/crypto/KeypairReader.java | 4 +- .../test/java/com/alertavert/opa/JwtOpa.java | 22 ++++ .../KeyMaterialConfigurationTest.java | 39 +++--- .../aws/AwsSecretsKeypairReaderTest.java | 83 +++++++++++++ .../aws/AwsSecretsManagerResolverTest.java | 108 +++++++++++++++++ .../crypto/KeypairFileReaderTest.java | 14 ++- .../src/test/resources/application-test.yaml | 6 +- testdata/{test-key-pub.pem => test-key.pub} | 0 webapp-example/build.gradle | 10 +- .../alertavert/opademo/api/JwtController.java | 13 +- .../configuration/SecurityConfiguration.java | 68 +++++++++-- .../src/main/resources/application.yaml | 24 ++-- .../data/ReactiveUsersRepositoryTest.java | 2 +- .../src/test/resources/application-test.yaml | 9 +- 27 files changed, 756 insertions(+), 171 deletions(-) create mode 100644 jwt-opa/src/main/java/com/alertavert/opa/ExcludeFromCoverageGenerated.java create mode 100644 jwt-opa/src/main/java/com/alertavert/opa/security/aws/AwsClientConfiguration.java create mode 100644 jwt-opa/src/main/java/com/alertavert/opa/security/aws/AwsSecretsKeypairReader.java create mode 100644 jwt-opa/src/main/java/com/alertavert/opa/security/aws/AwsSecretsManagerResolver.java create mode 100644 jwt-opa/src/main/java/com/alertavert/opa/security/aws/SecretsResolver.java create mode 100644 jwt-opa/src/test/java/com/alertavert/opa/security/aws/AwsSecretsKeypairReaderTest.java create mode 100644 jwt-opa/src/test/java/com/alertavert/opa/security/aws/AwsSecretsManagerResolverTest.java rename testdata/{test-key-pub.pem => test-key.pub} (100%) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index ec87d12..368f5a0 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -20,7 +20,16 @@ jobs: - uses: actions/setup-java@v1 with: java-version: 17 - - name: Setup fake Credentials - run: cp gradle.properties.fake gradle.properties + - name: Setup fake Credentials for Tests + run: | + cp gradle.properties.fake gradle.properties + mkdir -p ~/.aws + cat < ~/.aws/credentials + [default] + aws_access_key_id = fake + aws_secret_access_key = fake + EOF - name: Build & Test - run: chmod +x gradlew && ./gradlew build test + run: | + echo "keys: $(ls -l testdata)" + chmod +x gradlew && ./gradlew test diff --git a/.gitignore b/.gitignore index 7986d0a..ee76ffc 100644 --- a/.gitignore +++ b/.gitignore @@ -28,9 +28,8 @@ generated/ # Key Material is never a good candidate for source control, # unless they are used for test purposes (only). -*.pub *.pem -!**/testdata/*.pem +!**/testdata/test-key.* # Code Coverage .coverage @@ -39,7 +38,6 @@ tests/cover # Gradle related files .gradle gradlew.bat -build/ !*/gradle/wrapper/gradle-wrapper.jar ### STS ### diff --git a/aws-upload-keys.sh b/aws-upload-keys.sh index 9c76f90..6971917 100755 --- a/aws-upload-keys.sh +++ b/aws-upload-keys.sh @@ -20,27 +20,31 @@ # # Generates a Elliptict Cryptography keypair using openssl -set -e +set -eu function usage { - echo "Usage: $(basename $0) KEY [DIR] + echo "Usage: $(basename $0) KEY SECRET - KEY the name of the key pair to upload, required; - - DIR optionally, a directory where the keypair is stored + KEY the path to the key pair to upload, WITHOUT extension + SECRET the name of the secret to create in AWS Secrets Manager This script uploads a key pair named 'KEY.pem' and 'KEY.pub' to AWS Secrets Manager, using the \$AWS_PROFILE env var to obtain the credentials and the region to upload to. +Use \$AWS_ENDPOINT to specify a custom endpoint for the Secrets Manager service, if not using +the default AWS endpoint (eg, when testing against a localstack container, you can use +http://localhost:4566). + The pair can be generated using the keygen.sh script. Requires the aws binary CLI (https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) " } KEY=${1:-} -DIR=${2:-} +SECRET=${2:-} +ENDPOINT_URL="" -if [[ -z ${KEY} || ${1:-} == "--help" || ${1:-} == "-h" ]]; then +if [[ -z ${KEY} || -z ${SECRET} || ${1:-} == "-h" ]]; then usage exit 1 fi @@ -52,27 +56,32 @@ then exit 1 fi +if [[ -n ${AWS_ENDPOINT:-} ]]; then + ENDPOINT_URL="--endpoint-url ${AWS_ENDPOINT}" +fi PRIV=${KEY}.pem PUB=${KEY}.pub -if [[ -n ${DIR} && -d ${DIR} ]]; then - PRIV=${DIR}/${PRIV} - PUB=${DIR}/${PUB} +if [[ ! -f ${PRIV} || ! -f ${PUB} ]]; then + usage + echo "ERROR: Cannot find ${PRIV} and/or ${PUB} keys" + exit 1 fi out=$(mktemp /tmp/secret-XXXXXXXX.tmp) cat <$out { "priv": "$(while read -r line; do if [[ ! ${line} =~ ^----- ]]; \ - then echo -n ${line}; fi; done < $PRIV)", - "pub": "$(while read -r line; do [[ ${line} =~ ^----- ]] || echo -n ${line}; done < $PUB)", + then echo -n ${line}; fi; done < ${PRIV})", + "pub": "$(while read -r line; do [[ ${line} =~ ^----- ]] || echo -n ${line}; done < ${PUB})", "algorithm": "EC" } EOF -aws secretsmanager create-secret --name $KEY \ - --description "Keypair $KEY generated by the $(basename $0) script" \ - --secret-string file://$out + +arn=$(aws ${ENDPOINT_URL} secretsmanager create-secret --name ${SECRET} \ + --description "Keypair ${KEY} generated by the $(basename $0) script" \ + --secret-string file://${out} | jq -r '.ARN') rm $out -echo "[SUCCESS] Key Pair uploaded to AWS: ${KEY}" +echo "[SUCCESS] Key Pair ${KEY} uploaded to AWS: ${arn}" diff --git a/jwt-opa/build.gradle b/jwt-opa/build.gradle index e1ab96c..3372654 100644 --- a/jwt-opa/build.gradle +++ b/jwt-opa/build.gradle @@ -29,6 +29,7 @@ plugins { } ext { + awsSdkVersion = '2.17.102' jsonpathVersion = "2.5.0" lombokVersion = "1.18.22" minCoverageRatio = 0.80 @@ -37,7 +38,7 @@ ext { } group 'com.alertavert' -version '0.8.0' +version '0.9.0' // OpenJDK 17 LTS is the only Java version supported sourceCompatibility = JavaVersion.VERSION_17 @@ -47,6 +48,7 @@ if (JavaVersion.current() != JavaVersion.VERSION_17) { } repositories { + mavenLocal() mavenCentral() } @@ -91,12 +93,18 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-webflux' + // AWS SDK for Secrets Manager, see: https://docs.aws.amazon.com/code-samples/latest/catalog/code-catalog-javav2-example_code-secretsmanager.html + implementation "software.amazon.awssdk:secretsmanager:${awsSdkVersion}" + testImplementation "com.jayway.jsonpath:json-path-assert:$jsonpathVersion" testImplementation "org.mockito:mockito-core:$mockitoVersion" // Test Containers: https://www.testcontainers.org/ testImplementation "org.testcontainers:testcontainers:$tcVersion" testImplementation "org.testcontainers:junit-jupiter:$tcVersion" + testImplementation "org.testcontainers:localstack:$tcVersion" + testImplementation group: 'com.amazonaws', name: 'aws-java-sdk-core', version: '1.12.326' + } jacocoTestCoverageVerification { diff --git a/jwt-opa/src/main/java/com/alertavert/opa/Constants.java b/jwt-opa/src/main/java/com/alertavert/opa/Constants.java index b05f469..f2609a1 100644 --- a/jwt-opa/src/main/java/com/alertavert/opa/Constants.java +++ b/jwt-opa/src/main/java/com/alertavert/opa/Constants.java @@ -46,12 +46,24 @@ public class Constants { */ public static final String ELLIPTIC_CURVE = "EC"; + /** Marker for a Public Key object */ + public static final String PUBLIC_KEY = "PUBLIC KEY"; + + /** Marker for a Private Key object */ + public static final String PRIVATE_KEY = "PRIVATE KEY"; + /** * Passphrase-based encryption (see * {@link com.alertavert.opa.configuration.KeyMaterialConfiguration}. */ public static final String PASSPHRASE = "SECRET"; + /** + * The name of the Env Var which contains the name of the file storing the AWS API Token in a + * running EKS container. + */ + public static final String AWS_TOKEN_FILE = "AWS_WEB_IDENTITY_TOKEN_FILE"; + /** OPA API version */ public static final String OPA_VERSION = "v1"; @@ -66,7 +78,7 @@ public class Constants { public static final String DEFAULT_HEALTH_ROUTE = "/health"; /** - * The default login endpoing, by default only allowed using HTTP Basic auth, but will not + * The default login endpoint, by default only allowed using HTTP Basic auth, but will not * require a valid API Token and won't try to authorize access. */ public static final String DEFAULT_LOGIN_ROUTE = "/login"; @@ -87,6 +99,14 @@ public class Constants { public static final String API_TOKEN = "api_token"; + public static final String KEYPAIR_LOADED = "Keypair loaded from AWS Secrets Manager: " + + "secret-name = {}"; + public static final String KEYPAIR_ERROR = "Cannot load secret from AWS: secret-name = {}, " + + "error = {}"; + + public static final String CREDENTIALS_PROVIDER_LOG = "Creating a {} Credentials Provider: {}"; + public static final String CREDENTIALS_PROVIDER_ERROR = "Cannot create Credentials Provider: {}"; + /** * A completely inactive user, that needs to act as a placeholder when the `username` is not * found in the Users DB, and would trigger an exception in the Java Security HTTP Basic diff --git a/jwt-opa/src/main/java/com/alertavert/opa/ExcludeFromCoverageGenerated.java b/jwt-opa/src/main/java/com/alertavert/opa/ExcludeFromCoverageGenerated.java new file mode 100644 index 0000000..90c1c6e --- /dev/null +++ b/jwt-opa/src/main/java/com/alertavert/opa/ExcludeFromCoverageGenerated.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2021 AlertAvert.com. All rights reserved. + * Author: Marco Massenzio (marco@alertavert.com) + */ +package com.alertavert.opa; + +/** + * Marker annotation for classes to be excluded by Jacoco test coverage report and rules. + * + * The naming is a bit contrived as Jacoco requires that the + * {@literal Generated} string be present in the annotation; classes annotated + * with {@link ExcludeFromCoverageGenerated} are not generated at all + * (Lombok does annotate methods/classes with {@link lombok.Generated}). + * + * Typically classes annotated with this marker SHOULD only contain either + * fairly trivial logic (e.g., configuration properties parsing and conversion) or access runtime + * components (e.g., AWS) where testing would require extensive (and, ultimately, self-defeating) + * mocking or real online services. + * + * USE WITH MODERATION and, most importantly, do not use it to + * bypass the Code Coverage check. + * + * @author M. Massenzio, 2022-04-14 + */ +public @interface ExcludeFromCoverageGenerated { +} diff --git a/jwt-opa/src/main/java/com/alertavert/opa/configuration/KeyMaterialConfiguration.java b/jwt-opa/src/main/java/com/alertavert/opa/configuration/KeyMaterialConfiguration.java index e453557..c9b7c80 100644 --- a/jwt-opa/src/main/java/com/alertavert/opa/configuration/KeyMaterialConfiguration.java +++ b/jwt-opa/src/main/java/com/alertavert/opa/configuration/KeyMaterialConfiguration.java @@ -47,9 +47,6 @@ public class KeyMaterialConfiguration { public KeyMaterialConfiguration(TokensProperties properties) { this.tokensProperties = properties; - if (properties.getSignature().getKeypair() == null) { - throw new IllegalStateException(UNDEFINED_KEYPAIR); - } } @Bean @@ -58,59 +55,24 @@ public String issuer() { } @Bean - Algorithm hmac(KeyPair keyPair) { - TokensProperties.SignatureProperties properties = tokensProperties.getSignature(); - - return switch (properties.getAlgorithm()) { - case PASSPHRASE -> Algorithm.HMAC256(properties.getSecret()); - case ELLIPTIC_CURVE -> Algorithm.ECDSA256((ECPublicKey) keyPair.getPublic(), - (ECPrivateKey) keyPair.getPrivate()); - default -> throw new IllegalArgumentException(String.format("Algorithm [%s] not supported", - properties.getAlgorithm())); - }; + Algorithm hmac(KeypairReader reader) { + switch (reader.algorithm()) { + case PASSPHRASE: + return Algorithm.HMAC256(tokensProperties.getSecret()); + case ELLIPTIC_CURVE: + KeyPair keyPair = reader.loadKeys(); + return Algorithm.ECDSA256((ECPublicKey) keyPair.getPublic(), + (ECPrivateKey) keyPair.getPrivate()); + default: + throw new IllegalArgumentException(String.format("Algorithm [%s] not supported", + reader.algorithm())); + } } @Bean - JWTVerifier verifier(KeypairReader reader) throws IOException { - return JWT.require(hmac(keyPair(reader))) + JWTVerifier verifier(Algorithm algorithm) throws IOException { + return JWT.require(algorithm) .withIssuer(issuer()) .build(); } - - @Bean - public KeyPair keyPair(KeypairReader reader) throws IOException { - return reader.loadKeys(); - } - - /** - * Default key pair reader from the file system; to load a key pair from a different storage - * (e.g., Vault) implement your custom {@link KeypairReader} and inject it as a {@literal - * reader} bean. - * - *

This reader will interpret the {@literal keypair.priv,pub} properties as paths. - * - *

To use your custom {@link KeypairReader} implementation, define your bean as primary: - * -

-       @Bean @Primary
-       public KeypairReader reader() {
-         return new KeypairReader() {
-           @Override
-           public KeyPair loadKeys() throws KeyLoadException {
-              // do something here
-              return someKeypair;
-           }
-         };
-   
- * - * @return a reader which will try and load the key pair from the filesystem. - */ - @Bean - public KeypairReader filereader() { - TokensProperties.SignatureProperties props = tokensProperties.getSignature(); - return new KeypairFileReader( - props.getAlgorithm(), - Paths.get(props.getKeypair().getPriv()), - Paths.get(props.getKeypair().getPub())); - } } diff --git a/jwt-opa/src/main/java/com/alertavert/opa/configuration/TokensProperties.java b/jwt-opa/src/main/java/com/alertavert/opa/configuration/TokensProperties.java index 10836d6..614b59b 100644 --- a/jwt-opa/src/main/java/com/alertavert/opa/configuration/TokensProperties.java +++ b/jwt-opa/src/main/java/com/alertavert/opa/configuration/TokensProperties.java @@ -30,24 +30,21 @@ @ConfigurationProperties(prefix = "tokens") public class TokensProperties { - @Data - public static class Pair { - String priv; - String pub; - } - - @Data - public static class SignatureProperties { - private String algorithm; - private Pair keypair; - private String secret; - } - /** * Corresponds to the {@literal "iss"} claim; the authority that has issued the token */ private String issuer; + /** + * Passphrase-based signature uses this secret. + * + * NOT recommended as this is insecure, prefer the use of private/public key + * pairs. + * + * @see com.alertavert.opa.security.crypto.KeypairReader + */ + private String secret; + /** * {@literal true} by default, used in conjunction with {@link #expiresAfterSec} to determine * whether the token should expire and, if so, how long after it has been created (the @@ -68,6 +65,4 @@ public static class SignatureProperties { * By default this value is 0, i.e., the token is immediately available for use. */ long notBeforeDelaySec = 0L; - - SignatureProperties signature; } diff --git a/jwt-opa/src/main/java/com/alertavert/opa/security/aws/AwsClientConfiguration.java b/jwt-opa/src/main/java/com/alertavert/opa/security/aws/AwsClientConfiguration.java new file mode 100644 index 0000000..19e8b38 --- /dev/null +++ b/jwt-opa/src/main/java/com/alertavert/opa/security/aws/AwsClientConfiguration.java @@ -0,0 +1,106 @@ +// Copyright (c) 2022 AlertAvert.com. All rights reserved. +// + +package com.alertavert.opa.security.aws; + +import com.alertavert.opa.Constants; +import com.alertavert.opa.ExcludeFromCoverageGenerated; +import lombok.extern.slf4j.Slf4j; +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.util.StringUtils; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.ProfileCredentialsProvider; +import software.amazon.awssdk.auth.credentials.WebIdentityTokenFileCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient; +import software.amazon.awssdk.services.secretsmanager.SecretsManagerClientBuilder; + +import java.net.URI; + +import static com.alertavert.opa.Constants.AWS_TOKEN_FILE; +import static com.alertavert.opa.Constants.CREDENTIALS_PROVIDER_LOG; + + +/** + *

PatientsApplicationConfiguration

+ * + *

System and application configuration goes here. + * + *

Here we are trying to configure the AWS client, based on + * whether we auto-detect the Server to be running locally, or on EKS.

+ * + * @author M. Massenzio, 2020-10-06 + */ +@Configuration +@Slf4j +@ExcludeFromCoverageGenerated +public class AwsClientConfiguration { + + @Value("${aws.region:}") + String region; + + @Value("${aws.profile:}") + String profile; + + @Value("${aws.endpoint:}") + String endpoint; + + @Value("${aws.keypair.secret_name:}") + private String keypairSecretName; + + public Region region() { + return Region.of(region); + } + + public String awsProfile() { + return profile; + } + + /** + *

Instantiates a Client for AWS Secrets Manager service, using a `profile` (whose credentials + * are stored in {@literal ~/.aws/credentials}) if the {@literal aws.profile} property is set + * (typically for local runs/tests); otherwise it uses the + * {@link WebIdentityTokenFileCredentialsProvider Token Provider} which derives the API Token + * from a file stored in the container at the location pointed at by the env var + * {@link Constants#AWS_TOKEN_FILE}; the latter is used when running the service in an EKS Container. + * + *

For the above to work, the STS service SDK must be in the classpath + *

+   *   implementation 'software.amazon.awssdk:sts'
+   * 
+ * + * @return a Client to access Secrets Manager and retrieve secrets (passwords) + */ + @Bean @Profile("aws") + public SecretsManagerClient secretsManagerClient() { + log.debug("Instantiating the SecretsManager Client for region = {}", region()); + + AwsCredentialsProvider provider; + if (StringUtils.hasText(profile)) { + log.info(CREDENTIALS_PROVIDER_LOG, "Profile", profile); + provider = ProfileCredentialsProvider.create(profile); + } else { + log.info(CREDENTIALS_PROVIDER_LOG, "Token File", System.getenv(AWS_TOKEN_FILE)); + provider = WebIdentityTokenFileCredentialsProvider.create(); + } + + SecretsManagerClientBuilder builder = SecretsManagerClient.builder() + .region(region()) + .credentialsProvider(provider); + + // Typically this is overridden in tests, to point to a local Secrets Manager (e.g. using + // LocalStack, pointing to http://localhost:4566) + if (StringUtils.hasText(endpoint)) { + builder.endpointOverride(URI.create(endpoint)); + } + return builder.build(); + } + + @Bean + String keypairSecretName() { + return keypairSecretName; + } +} diff --git a/jwt-opa/src/main/java/com/alertavert/opa/security/aws/AwsSecretsKeypairReader.java b/jwt-opa/src/main/java/com/alertavert/opa/security/aws/AwsSecretsKeypairReader.java new file mode 100644 index 0000000..3bdc084 --- /dev/null +++ b/jwt-opa/src/main/java/com/alertavert/opa/security/aws/AwsSecretsKeypairReader.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2021 AlertAvert.com. All rights reserved. + * Author: Marco Massenzio (marco@alertavert.com) + */ + +package com.alertavert.opa.security.aws; + +import com.alertavert.opa.ExcludeFromCoverageGenerated; +import com.alertavert.opa.security.crypto.KeyLoadException; +import com.alertavert.opa.security.crypto.KeypairReader; +import com.alertavert.opa.thirdparty.PemUtils; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Data; +import lombok.Value; +import lombok.extern.slf4j.Slf4j; +import org.bouncycastle.util.encoders.Base64; +import org.bouncycastle.util.io.pem.PemObject; +import org.springframework.util.StringUtils; +import reactor.core.publisher.Mono; + +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.PublicKey; + +import static com.alertavert.opa.Constants.KEYPAIR_ERROR; +import static com.alertavert.opa.Constants.KEYPAIR_LOADED; +import static com.alertavert.opa.Constants.PRIVATE_KEY; +import static com.alertavert.opa.Constants.PUBLIC_KEY; + +/** + *

AwsSecretsKeypairReader

+ * + *

Insert class description here. + * + * @author M. Massenzio, 2022-01-24 + */ +@Value +@Slf4j +public class AwsSecretsKeypairReader implements KeypairReader { + private static final ObjectMapper JSON_DECODER = new ObjectMapper(); + + /** + * Utility class to simplify the parsing of the secret value, stored as a JSON object. + */ + @Data + public static class SecretKeys { + String algorithm; + String priv; + String pub; + } + + SecretsResolver resolver; + String secretName; + + public AwsSecretsKeypairReader(SecretsResolver resolver, String secretName) { + this.resolver = resolver; + this.secretName = secretName; + } + + /** + * Utility method to read the keys from the JSON object returned by AWS Secrets Manager. + * + * @param jsonEncodedKeys the JSON string returned by the Secrets Manager + * @return a {@link SecretKeys} object + */ + @ExcludeFromCoverageGenerated + private SecretKeys getKeysFromJson(String jsonEncodedKeys) { + try { + return JSON_DECODER.readValue(jsonEncodedKeys, SecretKeys.class); + } catch (Exception exception) { + log.error("Cannot parse secret: {}", exception.getMessage()); + throw new KeyLoadException(exception); + } + } + + private Mono getSecret() { + if (!StringUtils.hasText(secretName)) { + return Mono.error(new KeyLoadException("AWS SecretsManager: no secret name specified")); + } + return resolver.getSecret(secretName) + .map(this::getKeysFromJson) + .switchIfEmpty(Mono.error(new KeyLoadException("No secret found for " + secretName))); + } + + @Override + public KeyPair loadKeys() throws KeyLoadException { + // TODO @MM: This is a bad pattern, blocking on a Reactive stream; we need to fix this API. + return getSecret() + .map(keys -> new KeyPair(loadPublicKey(keys), loadPrivateKey(keys))) + .doOnSuccess(kp -> log.info(KEYPAIR_LOADED, secretName)) + .onErrorMap(KeyLoadException::new) + .switchIfEmpty(Mono.error(new KeyLoadException("Cannot load keys from secret " + secretName))) + .doOnError(ex -> log.error(KEYPAIR_ERROR, secretName, ex.getMessage())) + .block(); + } + + @Override + public String algorithm() { + return getSecret() + .map(SecretKeys::getAlgorithm) + .block(); + } + + private PublicKey loadPublicKey(SecretKeys secretKeys) { + PemObject object = new PemObject(PUBLIC_KEY, Base64.decode(secretKeys.pub)); + return PemUtils.getPublicKey(object.getContent(), secretKeys.algorithm); + } + + private PrivateKey loadPrivateKey(SecretKeys secretKeys) { + PemObject object = new PemObject(PRIVATE_KEY, Base64.decode(secretKeys.priv)); + return PemUtils.getPrivateKey(object.getContent(), secretKeys.algorithm); + } +} diff --git a/jwt-opa/src/main/java/com/alertavert/opa/security/aws/AwsSecretsManagerResolver.java b/jwt-opa/src/main/java/com/alertavert/opa/security/aws/AwsSecretsManagerResolver.java new file mode 100644 index 0000000..912c21c --- /dev/null +++ b/jwt-opa/src/main/java/com/alertavert/opa/security/aws/AwsSecretsManagerResolver.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2021 AlertAvert.com. All rights reserved. + * Author: Marco Massenzio (marco@alertavert.com) + */ + +package com.alertavert.opa.security.aws; + +import com.alertavert.opa.ExcludeFromCoverageGenerated; +import com.alertavert.opa.security.crypto.KeyLoadException; +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Mono; +import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient; +import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueRequest; +import software.amazon.awssdk.services.secretsmanager.model.GetSecretValueResponse; +import software.amazon.awssdk.services.secretsmanager.model.SecretsManagerException; + +/** + *

AwsSecretsManagerResolver

+ * + *

Accesses + * AWS Secrets Manager and retrieves the secrets, if any, stored there. + * + * @author M. Massenzio, 2022-01-04 + */ +@Slf4j +public class AwsSecretsManagerResolver implements SecretsResolver { + + private final SecretsManagerClient secretsClient; + + public AwsSecretsManagerResolver(SecretsManagerClient secretsClient) { + this.secretsClient = secretsClient; + } + + @Override + public Mono getSecret(String secretName) { + log.debug("Retrieving SecretValue for secret = {}", secretName); + try { + GetSecretValueRequest valueRequest = GetSecretValueRequest.builder() + .secretId(secretName) + .build(); + GetSecretValueResponse valueResponse = secretsClient.getSecretValue(valueRequest); + return Mono.just(valueResponse.secretString()); + } catch (SecretsManagerException e) { + log.error("Could not retrieve secret = {}, error = {}", secretName, + e.awsErrorDetails().errorMessage()); + return Mono.error(new KeyLoadException(e)); + } catch (software.amazon.awssdk.core.exception.SdkClientException sdkException) { + // This typically happens when AWS client cannot authenticate with the given credentials. + log.error("AWS SDK Client error retrieving secret = {}, error = {}", + secretName, sdkException.getLocalizedMessage()); + return Mono.error(new KeyLoadException(sdkException)); + } + } +} diff --git a/jwt-opa/src/main/java/com/alertavert/opa/security/aws/SecretsResolver.java b/jwt-opa/src/main/java/com/alertavert/opa/security/aws/SecretsResolver.java new file mode 100644 index 0000000..da90217 --- /dev/null +++ b/jwt-opa/src/main/java/com/alertavert/opa/security/aws/SecretsResolver.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2021 AlertAvert.com. All rights reserved. + * Author: Marco Massenzio (marco@alertavert.com) + */ + +package com.alertavert.opa.security.aws; + +import reactor.core.publisher.Mono; + +/** + *

Secrets Resolver

+ * + *

Interface to resolve secrets in the application. + * + *

Concrete classes will extract secrets from whatever store (local filesystem, env vars, + * Hashicorp Vault, AWS Secrets Manager) as configured and return to the caller. + */ +public interface SecretsResolver { + Mono getSecret(String secretName); +} diff --git a/jwt-opa/src/main/java/com/alertavert/opa/security/crypto/KeypairFileReader.java b/jwt-opa/src/main/java/com/alertavert/opa/security/crypto/KeypairFileReader.java index a6db527..9ea1720 100644 --- a/jwt-opa/src/main/java/com/alertavert/opa/security/crypto/KeypairFileReader.java +++ b/jwt-opa/src/main/java/com/alertavert/opa/security/crypto/KeypairFileReader.java @@ -18,6 +18,7 @@ package com.alertavert.opa.security.crypto; +import com.alertavert.opa.Constants; import com.alertavert.opa.thirdparty.PemUtils; import lombok.Value; import lombok.extern.slf4j.Slf4j; @@ -34,19 +35,14 @@ /** *

KeypairFileReader

* - *

Loads a Private/Public keypair from the filesystem. + *

Loads a Private/Public keypair from the filesystem; the actual format of the keys should + * match the one expected by the {@link #algorithm}; we use here the {@link PemUtils} utility + * classes. * - *

This will interpret the key names as paths (if not absolute paths, relative to the - * directory from where the server was launched: - *

- *   tokens:
- *     signature:
- *       algorithm: "EC"
- *       keypair:
- *         priv: "../testdata/test-key.pem"
- *         pub: "../testdata/test-key-pub.pem"
- * 
+ *

For an example as to how to generate a pair {@link Constants#ELLIPTIC_CURVE Elliptic Curve} + * cryptography key pair see the {@literal keygen.sh} script. * + * @see PemUtils * @author M. Massenzio, 2022-01-24 */ @Slf4j @Value @@ -60,6 +56,11 @@ public KeyPair loadKeys() throws KeyLoadException { return new KeyPair(loadPublicKey(), loadPrivateKey()); } + @Override + public String algorithm() { + return algorithm; + } + private PrivateKey loadPrivateKey() { log.info("Reading private key from file {}", secretKeyPath.toAbsolutePath()); PrivateKey pk; diff --git a/jwt-opa/src/main/java/com/alertavert/opa/security/crypto/KeypairReader.java b/jwt-opa/src/main/java/com/alertavert/opa/security/crypto/KeypairReader.java index 6b7aabc..d0aacc7 100644 --- a/jwt-opa/src/main/java/com/alertavert/opa/security/crypto/KeypairReader.java +++ b/jwt-opa/src/main/java/com/alertavert/opa/security/crypto/KeypairReader.java @@ -24,10 +24,12 @@ *

KeypairReader

* *

Classes implementing this interface will retrieve keys from their storage for use with the - * application. + * application, in a format compatible with the expected {@link #algorithm()}. * * @author M. Massenzio, 2022-01-24 */ public interface KeypairReader { KeyPair loadKeys() throws KeyLoadException; + + String algorithm(); } diff --git a/jwt-opa/src/test/java/com/alertavert/opa/JwtOpa.java b/jwt-opa/src/test/java/com/alertavert/opa/JwtOpa.java index bd65150..df108e6 100644 --- a/jwt-opa/src/test/java/com/alertavert/opa/JwtOpa.java +++ b/jwt-opa/src/test/java/com/alertavert/opa/JwtOpa.java @@ -18,6 +18,8 @@ package com.alertavert.opa; +import com.alertavert.opa.security.crypto.KeyLoadException; +import com.alertavert.opa.security.crypto.KeypairReader; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -25,6 +27,10 @@ import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; +import java.security.KeyPair; + +import static com.alertavert.opa.Constants.PASSPHRASE; + /** * Simple marker class to hold Spring Boot annotations. */ @@ -37,4 +43,20 @@ PasswordEncoder encoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); } + // A trivial reader that simply tells the application to use the passphrase stored in the + // tokens.secret property. + @Bean + KeypairReader keypairReader() { + return new KeypairReader() { + @Override + public KeyPair loadKeys() throws KeyLoadException { + return null; + } + + @Override + public String algorithm() { + return PASSPHRASE; + } + }; + } } diff --git a/jwt-opa/src/test/java/com/alertavert/opa/configuration/KeyMaterialConfigurationTest.java b/jwt-opa/src/test/java/com/alertavert/opa/configuration/KeyMaterialConfigurationTest.java index 7b40a9d..208ff49 100644 --- a/jwt-opa/src/test/java/com/alertavert/opa/configuration/KeyMaterialConfigurationTest.java +++ b/jwt-opa/src/test/java/com/alertavert/opa/configuration/KeyMaterialConfigurationTest.java @@ -21,6 +21,7 @@ import com.alertavert.opa.AbstractTestBase; import com.alertavert.opa.Constants; import com.alertavert.opa.jwt.JwtTokenProvider; +import com.alertavert.opa.security.crypto.KeypairFileReader; import com.alertavert.opa.security.crypto.KeypairReader; import com.auth0.jwt.JWT; import com.auth0.jwt.JWTVerifier; @@ -31,8 +32,10 @@ import org.springframework.beans.factory.annotation.Value; import java.io.IOException; +import java.nio.file.Paths; import java.security.KeyPair; +import static com.alertavert.opa.Constants.ELLIPTIC_CURVE; import static org.assertj.core.api.Assertions.assertThat; class KeyMaterialConfigurationTest extends AbstractTestBase { @@ -40,44 +43,31 @@ class KeyMaterialConfigurationTest extends AbstractTestBase { @Autowired KeyMaterialConfiguration configuration; - @Autowired - KeypairReader reader; - @Value("${tokens.issuer}") private String issuer; + @Autowired + Algorithm hmac; + @Test void issuer() { assertThat(configuration.issuer()).isEqualTo(issuer); } @Test - void hmac() throws IOException { - KeyPair pair = configuration.keyPair(reader); - assertThat(pair).isNotNull(); - Algorithm hmac = configuration.hmac(pair); - assertThat(hmac).isNotNull(); - assertThat(hmac.getName()).isEqualTo("ES256"); + void hmacGetsSecret() { + // This simply tests the injection works as intended. + assertThat(hmac.getName()).isEqualTo("HS256"); } @Test void verifier() throws IOException { - JWTVerifier verifier = configuration.verifier(reader); + // We test here that we can get a JWT Verifier, using a Key pair, loaded from file. + KeypairReader fileReader = new KeypairFileReader(ELLIPTIC_CURVE, + Paths.get("../testdata/test.pem"), Paths.get("../testdata/test-pub.pem")); + Algorithm algo = configuration.hmac(fileReader); + JWTVerifier verifier = configuration.verifier(algo); assertThat(verifier).isNotNull(); - } - - @Test - void keyPair() throws IOException { - KeyPair pair = configuration.keyPair(reader); - assertThat(pair).isNotNull(); - assertThat(pair.getPrivate().getFormat()).isEqualTo("PKCS#8"); - assertThat(pair.getPrivate().getAlgorithm()).isEqualTo(Constants.ELLIPTIC_CURVE); - } - - @Test - void signVerify() throws IOException { - KeyPair pair = configuration.keyPair(reader); - Algorithm hmac = configuration.hmac(pair); String token = JWT.create() .withIssuer(issuer) @@ -85,7 +75,6 @@ void signVerify() throws IOException { .withClaim(JwtTokenProvider.ROLES, Lists.list("TEST")) .sign(hmac); - JWTVerifier verifier = configuration.verifier(reader); assertThat(verifier.verify(token)).isNotNull(); } } diff --git a/jwt-opa/src/test/java/com/alertavert/opa/security/aws/AwsSecretsKeypairReaderTest.java b/jwt-opa/src/test/java/com/alertavert/opa/security/aws/AwsSecretsKeypairReaderTest.java new file mode 100644 index 0000000..7b758b5 --- /dev/null +++ b/jwt-opa/src/test/java/com/alertavert/opa/security/aws/AwsSecretsKeypairReaderTest.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2022 AlertAvert.com. All rights reserved. + * + * 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. + * + * Author: Marco Massenzio (marco@alertavert.com) + */ + +package com.alertavert.opa.security.aws; + +import com.alertavert.opa.security.crypto.KeypairReader; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient; +import software.amazon.awssdk.services.secretsmanager.model.CreateSecretRequest; + +import java.security.KeyPair; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + *

AwsSecretsKeypairReaderTest

+ * + * @author M. Massenzio, 2022-10-28 + */ +@SpringBootTest(classes = { + AwsClientConfiguration.class, +}) +@ActiveProfiles(profiles = {"test", "aws"}) +@ContextConfiguration(initializers = {AwsSecretsManagerResolverTest.Initializer.class}) +class AwsSecretsKeypairReaderTest { + + @Autowired + SecretsManagerClient secretsManagerClient; + + String keypairName = "test-keypair"; + String secret = """ + { + "priv": "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgYEIUVkTtwBilNcNoEzsP3jdslIlOtXQ5pByuzxhLJTChRANCAAS+gitL0EgxyFCvdT6rJ39DbCrLLwLReTA5OXahcIEeCBygfyh35H8T9r9uHszSOCpAk1QQMuhqURzyWEaKjk92", + "pub": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEvoIrS9BIMchQr3U+qyd/Q2wqyy8C0XkwOTl2oXCBHggcoH8od+R/E/a/bh7M0jgqQJNUEDLoalEc8lhGio5Pdg==", + "algorithm": "EC" + } + """; + String priv = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgYEIUVkTtwBilNcNoEzsP3jdslIlO" + + "tXQ5pByuzxhLJTChRANCAAS+gitL0EgxyFCvdT6rJ39DbCrLLwLReTA5OXahcIEeCBygfyh35H8T9r9uHs" + + "zSOCpAk1QQMuhqURzyWEaKjk92"; + + + @BeforeEach + void setUp() { + secretsManagerClient.createSecret(CreateSecretRequest.builder() + .name(keypairName) + .secretString(secret) + .build()); + } + + @Test + public void getSecret() { + KeypairReader reader = new AwsSecretsKeypairReader( + new AwsSecretsManagerResolver(secretsManagerClient), keypairName); + KeyPair keyPair = reader.loadKeys(); + assertNotNull(keyPair); + assertThat(Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded())) + .isEqualTo(priv); + assertThat(reader.algorithm()).isEqualTo("EC"); + } +} diff --git a/jwt-opa/src/test/java/com/alertavert/opa/security/aws/AwsSecretsManagerResolverTest.java b/jwt-opa/src/test/java/com/alertavert/opa/security/aws/AwsSecretsManagerResolverTest.java new file mode 100644 index 0000000..3aa51a0 --- /dev/null +++ b/jwt-opa/src/test/java/com/alertavert/opa/security/aws/AwsSecretsManagerResolverTest.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2022 AlertAvert.com. All rights reserved. + * + * 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. + * + * Author: Marco Massenzio (marco@alertavert.com) + */ + +package com.alertavert.opa.security.aws; + +import com.alertavert.opa.JwtOpa; +import com.alertavert.opa.configuration.JwtSecurityConfiguration; +import com.alertavert.opa.configuration.KeyMaterialConfiguration; +import com.alertavert.opa.configuration.OpaServerConfiguration; +import com.alertavert.opa.security.crypto.KeyLoadException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.lang.NonNull; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.testcontainers.containers.localstack.LocalStackContainer; +import org.testcontainers.containers.wait.strategy.WaitStrategy; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.utility.DockerImageName; +import software.amazon.awssdk.services.secretsmanager.SecretsManagerClient; +import software.amazon.awssdk.services.secretsmanager.model.CreateSecretRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +/** + *

AwsSecretsManagerResolverTest

+ * + * @author M. Massenzio, 2022-10-26 + */ +@SpringBootTest(classes = { + AwsClientConfiguration.class, +}) +@ActiveProfiles(profiles = {"test", "aws"}) +@ContextConfiguration(initializers = {AwsSecretsManagerResolverTest.Initializer.class}) +class AwsSecretsManagerResolverTest { + + @Autowired + SecretsManagerClient client; + + @Autowired + String keypairSecretName; + + AwsSecretsManagerResolver resolver; + + public static class Initializer implements ApplicationContextInitializer { + @Container + public static LocalStackContainer AWS_LOCAL = + new LocalStackContainer(DockerImageName.parse("localstack/localstack:1.2")) + .withServices(LocalStackContainer.Service.SECRETSMANAGER); + + @Override + public void initialize(@NonNull ConfigurableApplicationContext context) { + AWS_LOCAL.start(); + TestPropertyValues.of( + "aws.region:" + AWS_LOCAL.getRegion(), + "aws.profile:default", + "aws.endpoint:" + AWS_LOCAL.getEndpointOverride(LocalStackContainer.Service.SECRETSMANAGER), + "aws.keypair.secret_name:test-secret" + ) + .applyTo(context.getEnvironment()); + } + } + + @BeforeEach + public void setup() { + resolver = new AwsSecretsManagerResolver(client); + assertThat(resolver).isNotNull(); + } + + @Test + void getSecret() { + client.createSecret(CreateSecretRequest.builder() + .name(keypairSecretName) + .secretString("test-secret-value") + .build()); + + String secret = resolver.getSecret(keypairSecretName).block(); + assertThat(secret).isNotNull(); + assertThat(secret).isEqualTo("test-secret-value"); + } + + @Test + void getSecretNotFound() { + assertThrows(KeyLoadException.class, () -> resolver.getSecret("not-found").block()); + } +} diff --git a/jwt-opa/src/test/java/com/alertavert/opa/security/crypto/KeypairFileReaderTest.java b/jwt-opa/src/test/java/com/alertavert/opa/security/crypto/KeypairFileReaderTest.java index 25d65ca..33e56f3 100644 --- a/jwt-opa/src/test/java/com/alertavert/opa/security/crypto/KeypairFileReaderTest.java +++ b/jwt-opa/src/test/java/com/alertavert/opa/security/crypto/KeypairFileReaderTest.java @@ -20,6 +20,7 @@ import com.alertavert.opa.AbstractTestBase; import com.alertavert.opa.configuration.TokensProperties; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -36,12 +37,17 @@ */ class KeypairFileReaderTest extends AbstractTestBase { - @Autowired - KeypairReader reader; + KeypairFileReader reader; @Autowired TokensProperties properties; + @BeforeEach + public void setup() { + reader = new KeypairFileReader("EC", + Paths.get("testdata/test.pem"), Paths.get("testdata/test-pub.pem")); + } + @Test void loadKeys() { KeyPair pair = reader.loadKeys(); @@ -49,12 +55,12 @@ void loadKeys() { assertThat(pair.getPrivate()).isNotNull(); assertThat(pair.getPublic()).isNotNull(); - assertThat(pair.getPublic().getAlgorithm()).isEqualTo(properties.getSignature().getAlgorithm()); + assertThat(pair.getPublic().getAlgorithm()).isEqualTo("EC"); } @Test void nonExistKeysThrows() { - KeypairReader reader = new KeypairFileReader(properties.getSignature().getAlgorithm(), + KeypairReader reader = new KeypairFileReader("EC", Paths.get("/etc/bogus/none.pub"), Paths.get("/etc/bogus/none.pem")); assertThrows(KeyLoadException.class, reader::loadKeys); } diff --git a/jwt-opa/src/test/resources/application-test.yaml b/jwt-opa/src/test/resources/application-test.yaml index 21d4149..a7957ff 100644 --- a/jwt-opa/src/test/resources/application-test.yaml +++ b/jwt-opa/src/test/resources/application-test.yaml @@ -8,11 +8,7 @@ logging: tokens: issuer: "demo-issuer" - signature: - algorithm: "EC" - keypair: - priv: "../testdata/test-key.pem" - pub: "../testdata/test-key-pub.pem" + secret: "test-passphrase" # This is used to build the Rule validation endpoint: # http://localhost:8181/v1/data/com.alertavert.policies/allow diff --git a/testdata/test-key-pub.pem b/testdata/test-key.pub similarity index 100% rename from testdata/test-key-pub.pem rename to testdata/test-key.pub diff --git a/webapp-example/build.gradle b/webapp-example/build.gradle index 16b6659..a598692 100644 --- a/webapp-example/build.gradle +++ b/webapp-example/build.gradle @@ -37,7 +37,7 @@ ext { // TODO: upgrade to the latest version when published (0.8.0) // This can be changed to an yet-unpublished version by using mavenLocal() // for local tests. - jwtOpaVersion = "0.7.5" + jwtOpaVersion = "0.8.0" lombokVersion = "1.18.22" tcVersion = "1.15.1" } @@ -49,7 +49,8 @@ bootJar { dependencies { // We use the actual dependency here, instead of depending on the module in the repository so // as to emulate an actual project using jwt-opa externally. - implementation "com.alertavert:jwt-opa:${jwtOpaVersion}" + implementation project (':jwt-opa') +// implementation "com.alertavert:jwt-opa:${jwtOpaVersion}" compileOnly "org.projectlombok:lombok:${lombokVersion}" annotationProcessor "org.projectlombok:lombok:${lombokVersion}" @@ -85,4 +86,9 @@ test { // A build will fail unless test coverage reaches minimum. // See: https://reflectoring.io/jacoco/ finalizedBy jacocoTestCoverageVerification + + testLogging { + showExceptions = true + showCauses = true + } } diff --git a/webapp-example/src/main/java/com/alertavert/opademo/api/JwtController.java b/webapp-example/src/main/java/com/alertavert/opademo/api/JwtController.java index 93d2b2f..a691fb2 100644 --- a/webapp-example/src/main/java/com/alertavert/opademo/api/JwtController.java +++ b/webapp-example/src/main/java/com/alertavert/opademo/api/JwtController.java @@ -19,6 +19,7 @@ package com.alertavert.opademo.api; import com.alertavert.opa.jwt.JwtTokenProvider; +import com.alertavert.opa.security.crypto.KeypairReader; import com.alertavert.opademo.data.ReactiveUsersRepository; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; @@ -45,15 +46,17 @@ @RestController public class JwtController { - @Autowired JwtTokenProvider provider; - - @Autowired ReactiveUsersRepository repository; - - @Autowired KeyPair keyPair; + public JwtController(JwtTokenProvider provider, ReactiveUsersRepository repository, + KeypairReader reader) { + this.provider = provider; + this.repository = repository; + keyPair = reader.loadKeys(); + } + @Data @AllArgsConstructor static class ApiToken { diff --git a/webapp-example/src/main/java/com/alertavert/opademo/configuration/SecurityConfiguration.java b/webapp-example/src/main/java/com/alertavert/opademo/configuration/SecurityConfiguration.java index 1aab686..c2f00db 100644 --- a/webapp-example/src/main/java/com/alertavert/opademo/configuration/SecurityConfiguration.java +++ b/webapp-example/src/main/java/com/alertavert/opademo/configuration/SecurityConfiguration.java @@ -18,9 +18,13 @@ package com.alertavert.opademo.configuration; +import com.alertavert.opa.configuration.TokensProperties; +import com.alertavert.opa.security.crypto.KeypairFileReader; +import com.alertavert.opa.security.crypto.KeypairReader; import com.alertavert.opademo.data.ReactiveUsersRepository; import com.alertavert.opademo.data.User; import lombok.Data; +import lombok.Value; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -31,6 +35,8 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.reactive.CorsConfigurationSource; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.List; import static com.alertavert.opa.Constants.EMPTY_USERDETAILS; @@ -43,25 +49,37 @@ @Configuration @EnableWebFluxSecurity @Slf4j -@EnableConfigurationProperties(SecurityConfiguration.CorsProperties.class) +@EnableConfigurationProperties( + {SecurityConfiguration.CorsProperties.class, + SecurityConfiguration.KeyProperties.class}) public class SecurityConfiguration { /** CORS Configuration allows all routes ("*") */ public static final String DEFAULT_ALL_ALLOWED = "*"; private final CorsProperties properties; + private final KeyProperties keyProperties; - public SecurityConfiguration(CorsProperties properties) { + public SecurityConfiguration(CorsProperties properties, KeyProperties keyProperties) { this.properties = properties; + this.keyProperties = keyProperties; } @Data - @ConfigurationProperties(prefix = "cors") - public static class CorsProperties { - List allowed = List.of(DEFAULT_ALL_ALLOWED); - List methods = List.of(DEFAULT_ALL_ALLOWED); - List headers = List.of(DEFAULT_ALL_ALLOWED); - } + @ConfigurationProperties(prefix = "cors") + public static class CorsProperties { + List allowed = List.of(DEFAULT_ALL_ALLOWED); + List methods = List.of(DEFAULT_ALL_ALLOWED); + List headers = List.of(DEFAULT_ALL_ALLOWED); + } + + @Data + @ConfigurationProperties(prefix = "keys") + public static class KeyProperties { + String algorithm; + String priv; + String pub; + } @Bean @@ -103,4 +121,38 @@ public CorsConfigurationSource corsConfiguration() { return conf; }; } + + /** + * Default key pair reader from the file system; to load a key pair from a different storage + * (e.g., Vault) implement your custom {@link KeypairReader} and inject it as a {@literal + * reader} bean. + * + *

This reader will interpret the {@literal keys.priv,pub} properties as paths. + * + *

To use your custom {@link KeypairReader} implementation, define your bean as primary: + * + //@formatter:off +

+   @Bean @Primary
+   public KeypairReader reader() {
+     return new KeypairReader() {
+       @Override
+      public KeyPair loadKeys() throws KeyLoadException {
+          // do something here
+          return someKeypair;
+      }
+   };
+   
+ //@formatter:on + * + * @return a reader which will try and load the key pair from the filesystem. + */ + @Bean + public KeypairReader filereader() { + Path priv = Paths.get(keyProperties.getPriv()).toAbsolutePath(); + Path pub = Paths.get(keyProperties.getPub()).toAbsolutePath(); + + log.info("Loading keys from {} and {}", priv, pub); + return new KeypairFileReader(keyProperties.getAlgorithm(), priv, pub); + } } diff --git a/webapp-example/src/main/resources/application.yaml b/webapp-example/src/main/resources/application.yaml index 9194419..5028fc6 100644 --- a/webapp-example/src/main/resources/application.yaml +++ b/webapp-example/src/main/resources/application.yaml @@ -34,7 +34,9 @@ spring: default-property-inclusion: "non_null" --- spring: - profiles: debug + config: + activate: + on-profile: debug # Doing this only for debug builds, as it may leak sensitive information # to an attacker. @@ -61,18 +63,14 @@ tokens: # larger than 0 to enforce a delay on using the token). not_before_delay_sec: 0 - signature: - # We currently support Elliptic Curve signatures, and passphrase-based secrets. - # If using a passphrase (not recommended) use something like this: - # algorithm: "SECRET" - # secret: "my very zekret pa55phrase" - algorithm: "EC" - keypair: - # These can be absolute or relative paths to a pair of private/public keys - # used to sign/verify the API Token (JWTs). - # See README.md for more information on how to generate them. - priv: "../private/ec-key.pem" - pub: "../private/ec-key-pub.pem" +keys: + # Elliptic Curve Signature algorithm + algorithm: "EC" + # Relative paths to a pair of private/public keys + # used to sign/verify the API Token (JWTs). + # See README.md for more information on how to generate them. + priv: "private/ec-key.pem" + pub: "private/ec-key-pub.pem" logging: level: diff --git a/webapp-example/src/test/java/com/alertavert/opademo/data/ReactiveUsersRepositoryTest.java b/webapp-example/src/test/java/com/alertavert/opademo/data/ReactiveUsersRepositoryTest.java index 263e95e..1f33a82 100644 --- a/webapp-example/src/test/java/com/alertavert/opademo/data/ReactiveUsersRepositoryTest.java +++ b/webapp-example/src/test/java/com/alertavert/opademo/data/ReactiveUsersRepositoryTest.java @@ -42,7 +42,7 @@ @ContextConfiguration(initializers = {ReactiveUsersRepositoryTest.Initializer.class}) public class ReactiveUsersRepositoryTest { - public static final String IMAGE_NAME = "mongo:4.0.10"; + public static final String IMAGE_NAME = "mongo:4"; private final static MongoDBContainer mongoDBContainer = new MongoDBContainer( DockerImageName.parse(IMAGE_NAME)); diff --git a/webapp-example/src/test/resources/application-test.yaml b/webapp-example/src/test/resources/application-test.yaml index 2ad1ab5..a4fef31 100644 --- a/webapp-example/src/test/resources/application-test.yaml +++ b/webapp-example/src/test/resources/application-test.yaml @@ -19,11 +19,10 @@ tokens: expires_after_sec: 60 not_before_delay_sec: 15 - signature: - algorithm: "EC" - keypair: - priv: "../testdata/test-key.pem" - pub: "../testdata/test-key-pub.pem" +keys: + algorithm: "EC" + priv: "../testdata/test-key.pem" + pub: "../testdata/test-key.pub" # This is used to build the Rule validation endpoint: