diff --git a/spring-cloud-vault-config/pom.xml b/spring-cloud-vault-config/pom.xml index 30d6f49f..b2f4b5f5 100644 --- a/spring-cloud-vault-config/pom.xml +++ b/spring-cloud-vault-config/pom.xml @@ -207,6 +207,44 @@ junit-vintage-engine test + + + com.unboundid + unboundid-ldapsdk + test + + + + com.squareup.okhttp3 + mockwebserver + test + + + + com.squareup.okhttp3 + okhttp-tls + test + + + + org.testcontainers + testcontainers + test + + + + org.testcontainers + junit-jupiter + 1.20.4 + test + + + + org.testcontainers + vault + 1.20.4 + test + diff --git a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/ClientAuthenticationFactory.java b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/ClientAuthenticationFactory.java index 607f267a..b2519ea8 100644 --- a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/ClientAuthenticationFactory.java +++ b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/ClientAuthenticationFactory.java @@ -22,6 +22,8 @@ import java.nio.file.Paths; import java.util.concurrent.atomic.AtomicReference; +import org.springframework.vault.authentication.UsernamePasswordAuthentication; +import org.springframework.vault.authentication.UsernamePasswordAuthenticationOptions; import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; @@ -114,7 +116,11 @@ ClientAuthentication createClientAuthentication() { case GCP_IAM -> gcpIamAuthentication(this.vaultProperties); case GITHUB -> gitHubAuthentication(this.vaultProperties); case KUBERNETES -> kubernetesAuthentication(this.vaultProperties); + case LDAP -> ldapAuthentication(this.vaultProperties); + case OKTA -> oktaAuthentication(this.vaultProperties); case PCF -> pcfAuthentication(this.vaultProperties); + case RADIUS -> radiusAuthentication(this.vaultProperties); + case USERPASS -> userpassAuthentication(this.vaultProperties); case TOKEN -> tokenAuthentication(this.vaultProperties); default -> throw new UnsupportedOperationException( String.format("Client authentication %s not supported", this.vaultProperties.getAuthentication())); @@ -354,6 +360,39 @@ private ClientAuthentication kubernetesAuthentication(VaultProperties vaultPrope return new KubernetesAuthentication(options, this.restOperations); } + private ClientAuthentication ldapAuthentication(VaultProperties vaultProperties) { + + VaultProperties.UsernamePasswordProperties ldap = vaultProperties.getLdap(); + + Assert.hasText(ldap.getUsername(), "Username (spring.cloud.vault.ldap.username) must not be empty"); + Assert.hasText(ldap.getPassword().toString(), "Password (spring.cloud.vault.ldap.password) must not be empty"); + + UsernamePasswordAuthenticationOptions options = UsernamePasswordAuthenticationOptions.builder() + .path(ldap.getPath()) + .username(ldap.getUsername()) + .password(ldap.getPassword()) + .build(); + + return new UsernamePasswordAuthentication(options, this.restOperations); + } + + private ClientAuthentication oktaAuthentication(VaultProperties vaultProperties) { + + VaultProperties.UsernamePasswordProperties okta = vaultProperties.getOkta(); + + Assert.hasText(okta.getUsername(), "Username (spring.cloud.vault.okta.username) must not be empty"); + Assert.hasText(okta.getPassword().toString(), "Password (spring.cloud.vault.okta.password) must not be empty"); + + UsernamePasswordAuthenticationOptions options = UsernamePasswordAuthenticationOptions.builder() + .path(okta.getPath()) + .username(okta.getUsername()) + .password(okta.getPassword()) + .totp(okta.getTotp()) + .build(); + + return new UsernamePasswordAuthentication(options, this.restOperations); + } + private ClientAuthentication pcfAuthentication(VaultProperties vaultProperties) { VaultProperties.PcfProperties pcfProperties = vaultProperties.getPcf(); @@ -377,6 +416,40 @@ private ClientAuthentication pcfAuthentication(VaultProperties vaultProperties) return new PcfAuthentication(builder.build(), this.restOperations); } + private ClientAuthentication radiusAuthentication(VaultProperties vaultProperties) { + + VaultProperties.UsernamePasswordProperties radius = vaultProperties.getRadius(); + + Assert.hasText(radius.getUsername(), "Username (spring.cloud.vault.radius.username) must not be empty"); + Assert.hasText(radius.getPassword().toString(), + "Password (spring.cloud.vault.radius.password) must not be empty"); + + UsernamePasswordAuthenticationOptions options = UsernamePasswordAuthenticationOptions.builder() + .path(radius.getPath()) + .username(radius.getUsername()) + .password(radius.getPassword()) + .build(); + + return new UsernamePasswordAuthentication(options, this.restOperations); + } + + private ClientAuthentication userpassAuthentication(VaultProperties vaultProperties) { + + VaultProperties.UsernamePasswordProperties userpass = vaultProperties.getUserpass(); + + Assert.hasText(userpass.getUsername(), "Username (spring.cloud.vault.userpass.username) must not be empty"); + Assert.hasText(userpass.getPassword().toString(), + "Password (spring.cloud.vault.userpass.password) must not be empty"); + + UsernamePasswordAuthenticationOptions options = UsernamePasswordAuthenticationOptions.builder() + .path(userpass.getPath()) + .username(userpass.getUsername()) + .password(userpass.getPassword()) + .build(); + + return new UsernamePasswordAuthentication(options, this.restOperations); + } + private ClientAuthentication certificateAuthentication(VaultProperties vaultProperties) { ClientCertificateAuthenticationOptionsBuilder builder = ClientCertificateAuthenticationOptions.builder(); diff --git a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultProperties.java b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultProperties.java index 8fdff356..a68a1cec 100644 --- a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultProperties.java +++ b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultProperties.java @@ -133,8 +133,16 @@ public class VaultProperties implements EnvironmentAware { private KubernetesProperties kubernetes = new KubernetesProperties(); + private UsernamePasswordProperties ldap = new UsernamePasswordProperties("ldap"); + + private UsernamePasswordProperties okta = new UsernamePasswordProperties("okta"); + private PcfProperties pcf = new PcfProperties(); + private UsernamePasswordProperties radius = new UsernamePasswordProperties("radius"); + + private UsernamePasswordProperties userpass = new UsernamePasswordProperties("userpass"); + private Ssl ssl = new Ssl(); private Config config = new Config(); @@ -329,6 +337,22 @@ public void setKubernetes(KubernetesProperties kubernetes) { this.kubernetes = kubernetes; } + public UsernamePasswordProperties getLdap() { + return ldap; + } + + public void setLdap(UsernamePasswordProperties ldap) { + this.ldap = ldap; + } + + public UsernamePasswordProperties getOkta() { + return okta; + } + + public void setOkta(UsernamePasswordProperties okta) { + this.okta = okta; + } + public PcfProperties getPcf() { return this.pcf; } @@ -337,6 +361,22 @@ public void setPcf(PcfProperties pcf) { this.pcf = pcf; } + public UsernamePasswordProperties getRadius() { + return radius; + } + + public void setRadius(UsernamePasswordProperties radius) { + this.radius = radius; + } + + public UsernamePasswordProperties getUserpass() { + return userpass; + } + + public void setUserpass(UsernamePasswordProperties userpass) { + this.userpass = userpass; + } + public Ssl getSsl() { return this.ssl; } @@ -382,7 +422,8 @@ public void setAuthentication(AuthenticationMethod authentication) { */ public enum AuthenticationMethod { - APPROLE, AWS_EC2, AWS_IAM, AZURE_MSI, CERT, CUBBYHOLE, GCP_GCE, GCP_IAM, GITHUB, KUBERNETES, NONE, PCF, TOKEN; + APPROLE, AWS_EC2, AWS_IAM, AZURE_MSI, CERT, CUBBYHOLE, GCP_GCE, GCP_IAM, GITHUB, KUBERNETES, LDAP, NONE, OKTA, + PCF, RADIUS, USERPASS, TOKEN; } @@ -1260,6 +1301,72 @@ public void setEnabledCipherSuites(List enabledCipherSuites) { } + /** + * Common properties for userpass, LDAP, Okta, and RADIUS authentications. + */ + static class UsernamePasswordProperties { + + /** + * Mount path of the username/password authentication backend. + */ + private String path; + + /** + * Username for the user + */ + private String username; + + /** + * Password for the user + */ + private CharSequence password; + + /** + * TOTP for OKTA multifactor authentication + */ + private CharSequence totp; + + /** + * @param defaultPath the default path + */ + protected UsernamePasswordProperties(String defaultPath) { + this.path = defaultPath; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public CharSequence getPassword() { + return password; + } + + public void setPassword(CharSequence password) { + this.password = password; + } + + public CharSequence getTotp() { + return totp; + } + + public void setTotp(CharSequence totp) { + this.totp = totp; + } + + } + /** * Property source properties. */ diff --git a/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/ClientAuthenticationFactoryUnitTests.java b/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/ClientAuthenticationFactoryUnitTests.java index fc89d6de..64405272 100644 --- a/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/ClientAuthenticationFactoryUnitTests.java +++ b/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/ClientAuthenticationFactoryUnitTests.java @@ -24,6 +24,7 @@ import java.nio.file.StandardOpenOption; import org.junit.jupiter.api.Test; +import org.springframework.vault.authentication.UsernamePasswordAuthentication; import software.amazon.awssdk.core.SdkSystemSetting; import software.amazon.awssdk.regions.Region; @@ -206,6 +207,43 @@ public void shouldSupportGitHubAuthentication() { assertThat(clientAuthentication).isInstanceOf(GitHubAuthentication.class); } + @Test + public void shouldSupportLdapAuthentication() { + + VaultProperties properties = new VaultProperties(); + properties.setAuthentication(VaultProperties.AuthenticationMethod.LDAP); + VaultProperties.UsernamePasswordProperties usernamePasswordProperties = new VaultProperties.UsernamePasswordProperties( + "ldap"); + usernamePasswordProperties.setUsername("username"); + usernamePasswordProperties.setPassword("password"); + properties.setLdap(usernamePasswordProperties); + + ClientAuthentication clientAuthentication = new ClientAuthenticationFactory(properties, new RestTemplate(), + new RestTemplate()) + .createClientAuthentication(); + + assertThat(clientAuthentication).isInstanceOf(UsernamePasswordAuthentication.class); + } + + @Test + public void shouldSupportOktaAuthentication() { + + VaultProperties properties = new VaultProperties(); + properties.setAuthentication(VaultProperties.AuthenticationMethod.OKTA); + VaultProperties.UsernamePasswordProperties usernamePasswordProperties = new VaultProperties.UsernamePasswordProperties( + "okta"); + usernamePasswordProperties.setUsername("username"); + usernamePasswordProperties.setPassword("password"); + usernamePasswordProperties.setTotp("totp"); + properties.setOkta(usernamePasswordProperties); + + ClientAuthentication clientAuthentication = new ClientAuthenticationFactory(properties, new RestTemplate(), + new RestTemplate()) + .createClientAuthentication(); + + assertThat(clientAuthentication).isInstanceOf(UsernamePasswordAuthentication.class); + } + @Test public void shouldSupportPcfAuthentication() { @@ -222,6 +260,42 @@ public void shouldSupportPcfAuthentication() { assertThat(clientAuthentication).isInstanceOf(PcfAuthentication.class); } + @Test + public void shouldSupportRadiusAuthentication() { + + VaultProperties properties = new VaultProperties(); + properties.setAuthentication(VaultProperties.AuthenticationMethod.RADIUS); + VaultProperties.UsernamePasswordProperties usernamePasswordProperties = new VaultProperties.UsernamePasswordProperties( + "radius"); + usernamePasswordProperties.setUsername("username"); + usernamePasswordProperties.setPassword("password"); + properties.setRadius(usernamePasswordProperties); + + ClientAuthentication clientAuthentication = new ClientAuthenticationFactory(properties, new RestTemplate(), + new RestTemplate()) + .createClientAuthentication(); + + assertThat(clientAuthentication).isInstanceOf(UsernamePasswordAuthentication.class); + } + + @Test + public void shouldSupportUserpassAuthentication() { + + VaultProperties properties = new VaultProperties(); + properties.setAuthentication(VaultProperties.AuthenticationMethod.USERPASS); + VaultProperties.UsernamePasswordProperties usernamePasswordProperties = new VaultProperties.UsernamePasswordProperties( + "userpass"); + usernamePasswordProperties.setUsername("username"); + usernamePasswordProperties.setPassword("password"); + properties.setUserpass(usernamePasswordProperties); + + ClientAuthentication clientAuthentication = new ClientAuthenticationFactory(properties, new RestTemplate(), + new RestTemplate()) + .createClientAuthentication(); + + assertThat(clientAuthentication).isInstanceOf(UsernamePasswordAuthentication.class); + } + @Test public void shouldSupportSslCertificateAuthentication() { diff --git a/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/VaultConfigLdapTests.java b/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/VaultConfigLdapTests.java new file mode 100644 index 00000000..deb3f19d --- /dev/null +++ b/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/VaultConfigLdapTests.java @@ -0,0 +1,161 @@ +/* + * Copyright 2016-present the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.vault.config; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import com.unboundid.ldap.listener.InMemoryDirectoryServer; +import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig; +import com.unboundid.ldap.listener.InMemoryListenerConfig; +import com.unboundid.ldap.sdk.LDAPException; +import com.unboundid.ldif.LDIFException; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.vault.util.Settings; +import org.springframework.cloud.vault.util.VaultRule; +import org.springframework.cloud.vault.util.VaultTestContextRunner; +import org.springframework.cloud.vault.util.Version; +import org.springframework.vault.core.VaultOperations; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assume.assumeTrue; + +/** + * Integration test using config infrastructure with LDAP authentication. + * + *

+ * In case this test should fail because of SSL make sure you run the test within the + * spring-cloud-vault-config/spring-cloud-vault-config directory as the keystore is + * referenced with {@code ../work/keystore.jks}. + * + * @author Issam El-atif + */ +class VaultConfigLdapTests { + + private static InMemoryDirectoryServer ldapServer; + + VaultTestContextRunner contextRunner = VaultTestContextRunner.of(VaultConfigLdapTests.class) + .withAuthentication(VaultProperties.AuthenticationMethod.LDAP) + .withConfiguration(VaultConfigLdapTests.TestApplication.class) + .withProperties("spring.cloud.vault.ldap.username", "testuser") + .withProperties("spring.cloud.vault.ldap.password", "testpass") + .withSettings(VaultTestContextRunner.TestSettings::bootstrap); + + @BeforeAll + static void beforeClass() throws LDAPException, LDIFException { + // LDAP server + ldapServer = createLdapServer(); + ldapServer.startListening(); + + // Vault config + VaultRule vaultRule = new VaultRule(); + vaultRule.before(); + + assumeTrue(vaultRule.prepare().getVersion().isGreaterThanOrEqualTo(Version.parse("0.8.0"))); + + VaultProperties vaultProperties = Settings.createVaultProperties(); + + if (!vaultRule.prepare().hasAuth(vaultProperties.getLdap().getPath())) { + vaultRule.prepare().mountAuth(vaultProperties.getLdap().getPath()); + } + + VaultOperations vaultOperations = vaultRule.prepare().getVaultOperations(); + + String rules = """ + { "name": "testpolicy", + "path": { + "*": { "policy": "read" } + } + }"""; + + vaultOperations.write("sys/policy/testpolicy", Collections.singletonMap("rules", rules)); + + vaultOperations.write("secret/" + VaultConfigLdapTests.class.getSimpleName(), + Collections.singletonMap("vault.value", "foo")); + + Map ldapConfig = new HashMap<>(); + ldapConfig.put("url", "ldap://localhost:" + ldapServer.getListenPort()); + ldapConfig.put("userdn", "cn=users,dc=example,dc=com"); + ldapConfig.put("groupdn", "cn=groups,dc=example,dc=com"); + ldapConfig.put("binddn", "cn=admin,dc=example,dc=com"); + ldapConfig.put("bindpass", "admin"); + ldapConfig.put("userattr", "cn"); + + vaultOperations.write("auth/ldap/config", ldapConfig); + + Map userConfig = new HashMap<>(); + userConfig.put("policies", "testpolicy"); + + vaultOperations.write("auth/ldap/users/testuser", userConfig); + + } + + private static InMemoryDirectoryServer createLdapServer() throws LDAPException, LDIFException { + InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig("dc=example,dc=com"); + config.addAdditionalBindCredentials("cn=admin,dc=example,dc=com", "admin"); + config.setListenerConfigs(InMemoryListenerConfig.createLDAPConfig("LDAP", 0)); + config.setSchema(null); // Disable schema validation + + InMemoryDirectoryServer ldapServer = new InMemoryDirectoryServer(config); + ldapServer.add("dn: dc=example,dc=com", "objectClass: top", "objectClass: domain", "dc: example"); + ldapServer.add("dn: cn=users,dc=example,dc=com", "objectClass: top", "objectClass: organizationalUnit", + "ou: users"); + ldapServer.add("dn: cn=groups,dc=example,dc=com", "objectClass: top", "objectClass: organizationalUnit", + "ou: groups"); + // Add test user + ldapServer.add("dn: cn=testuser,cn=users,dc=example,dc=com", "objectClass: top", "objectClass: person", + "objectClass: organizationalPerson", "objectClass: inetOrgPerson", "cn: testuser", "sn: User", + "userPassword: testpass"); + + return ldapServer; + } + + @AfterAll + static void afterClass() { + if (ldapServer != null) { + ldapServer.shutDown(true); + } + } + + @Test + void contextLoads() { + this.contextRunner.run(ctx -> { + TestApplication application = ctx.getBean(TestApplication.class); + assertThat(application.configValue).isEqualTo("foo"); + }); + } + + @SpringBootApplication + public static class TestApplication { + + @Value("${vault.value}") + String configValue; + + public static void main(String[] args) { + SpringApplication.run(TestApplication.class, args); + } + + } + +} diff --git a/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/VaultConfigOktaTests.java b/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/VaultConfigOktaTests.java new file mode 100644 index 00000000..411a5e84 --- /dev/null +++ b/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/VaultConfigOktaTests.java @@ -0,0 +1,242 @@ +/* + * Copyright 2016-present the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.vault.config; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.Map; + +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import okhttp3.tls.HandshakeCertificates; +import okhttp3.tls.HeldCertificate; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.BindMode; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.vault.VaultContainer; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.vault.util.VaultTestContextRunner; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.vault.authentication.TokenAuthentication; +import org.springframework.vault.client.VaultEndpoint; +import org.springframework.vault.core.VaultTemplate; +import org.springframework.vault.support.VaultMount; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test using config infrastructure with Okta authentication. + * + *

+ * This test uses Testcontainers to run Vault and MockWebServer to simulate the Okta + * authentication API. Testcontainers is required here because Vault's Okta auth method + * makes outbound HTTPS calls to the Okta API (e.g., {@code https://mock.localhost:8443}). + * Without Testcontainers, this would require modifying {@code /etc/hosts} to resolve + * {@code mock.localhost} to {@code 127.0.0.1}, which is not feasible in automated tests. + * Testcontainers solves this DNS limitation by using + * {@code withExtraHost("mock.localhost", "host-gateway")}, which adds a host entry inside + * the container that maps {@code mock.localhost} to the host machine, allowing Vault to + * reach the MockWebServer running on the host. + * + * @author Issam El-atif + */ +@Testcontainers +class VaultConfigOktaTests { + + private static final String VAULT_TOKEN = "00000000-0000-0000-0000-000000000000"; + + private static final int MOCK_SERVER_PORT = 8443; + + private static final String OKTA_USERNAME = "testuser"; + + private static final String OKTA_PASSWORD = "testpass"; + + private static final String OKTA_TOTP = "totp"; + + private static MockWebServer oktaServer; + + private static final HeldCertificate caCertificate; + + private static final HeldCertificate serverCertificate; + + private static final Path caCertFile; + + @Container + static VaultContainer vaultContainer; + + static { + try { + caCertificate = new HeldCertificate.Builder().certificateAuthority(0).build(); + + serverCertificate = new HeldCertificate.Builder().addSubjectAlternativeName("mock.localhost") + .signedBy(caCertificate) + .build(); + + caCertFile = Files.createTempFile("ca-cert", ".pem"); + Files.writeString(caCertFile, caCertificate.certificatePem()); + + vaultContainer = new VaultContainer<>("hashicorp/vault:1.13.3").withVaultToken(VAULT_TOKEN) + .withExtraHost("mock.localhost", "host-gateway") + .withAccessToHost(true) + .withFileSystemBind(caCertFile.toString(), "/etc/ssl/certs/mock-ca.pem", BindMode.READ_ONLY) + .withEnv("SSL_CERT_FILE", "/etc/ssl/certs/mock-ca.pem"); + } + catch (IOException ex) { + throw new RuntimeException("Failed to create certificates", ex); + } + } + + VaultTestContextRunner contextRunner = VaultTestContextRunner.of(VaultConfigOktaTests.class) + .withAuthentication(VaultProperties.AuthenticationMethod.OKTA) + .withConfiguration(TestApplication.class) + .withProperties("spring.cloud.vault.uri", vaultContainer.getHttpHostAddress()) + .withProperties("spring.cloud.vault.okta.username", OKTA_USERNAME) + .withProperties("spring.cloud.vault.okta.password", OKTA_PASSWORD) + .withProperties("spring.cloud.vault.okta.totp", OKTA_TOTP) + .withSettings(VaultTestContextRunner.TestSettings::bootstrap); + + @BeforeAll + static void beforeClass() throws Exception { + oktaServer = createMockOktaServer(); + oktaServer.start(MOCK_SERVER_PORT); + + VaultTemplate vaultTemplate = createVaultTemplate(); + configureVaultSecrets(vaultTemplate); + configureOktaAuth(vaultTemplate); + } + + private static VaultTemplate createVaultTemplate() { + VaultEndpoint endpoint = VaultEndpoint.from(vaultContainer.getHttpHostAddress()); + return new VaultTemplate(endpoint, new SimpleClientHttpRequestFactory(), + () -> new TokenAuthentication(VAULT_TOKEN).login()); + } + + private static void configureVaultSecrets(VaultTemplate vaultTemplate) { + vaultTemplate.opsForSys().unmount("secret"); + vaultTemplate.opsForSys() + .mount("secret", VaultMount.builder().type("kv").options(Collections.singletonMap("version", "1")).build()); + + vaultTemplate.write("secret/" + VaultConfigOktaTests.class.getSimpleName(), + Collections.singletonMap("vault.value", "foo")); + } + + private static void configureOktaAuth(VaultTemplate vaultTemplate) { + vaultTemplate.opsForSys().authMount("okta", VaultMount.create("okta")); + + String rules = """ + path "*" { + capabilities = ["read", "list"] + } + """; + vaultTemplate.write("sys/policy/testpolicy", Collections.singletonMap("rules", rules)); + + // Vault constructs Okta URL as: https://{org_name}.{base_url}/api/v1/authn + vaultTemplate.write("auth/okta/config", + Map.of("org_name", "mock", "base_url", "localhost:" + MOCK_SERVER_PORT)); + + vaultTemplate.write("auth/okta/users/" + OKTA_USERNAME, Map.of("policies", "testpolicy")); + } + + private static MockWebServer createMockOktaServer() { + MockWebServer server = new MockWebServer(); + + HandshakeCertificates serverCertificates = new HandshakeCertificates.Builder() + .heldCertificate(serverCertificate, caCertificate.certificate()) + .build(); + + server.useHttps(serverCertificates.sslSocketFactory(), false); + server.setDispatcher(new OktaDispatcher()); + + return server; + } + + @AfterAll + static void afterClass() throws IOException { + if (oktaServer != null) { + oktaServer.shutdown(); + } + if (caCertFile != null) { + Files.deleteIfExists(caCertFile); + } + } + + @Test + void contextLoads() { + this.contextRunner.run(ctx -> { + TestApplication application = ctx.getBean(TestApplication.class); + assertThat(application.configValue).isEqualTo("foo"); + }); + } + + static class OktaDispatcher extends Dispatcher { + + @NotNull + @Override + public MockResponse dispatch(RecordedRequest request) { + if ("/api/v1/authn".equals(request.getPath()) && "POST".equals(request.getMethod())) { + String requestBody = request.getBody().readUtf8(); + + if (requestBody.contains("\"username\":\"" + OKTA_USERNAME + "\"") + && requestBody.contains("\"password\":\"" + OKTA_PASSWORD + "\"")) { + + return new MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(""" + { + "status": "SUCCESS", + "sessionToken": "mock-session-token" + } + """); + } + + return new MockResponse().setResponseCode(401).setHeader("Content-Type", "application/json").setBody(""" + { + "errorCode": "E0000004", + "errorSummary": "Authentication failed" + } + """); + } + + return new MockResponse().setResponseCode(404); + } + + } + + @SpringBootApplication + public static class TestApplication { + + @Value("${vault.value}") + String configValue; + + public static void main(String[] args) { + SpringApplication.run(TestApplication.class, args); + } + + } + +} diff --git a/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/VaultConfigRadiusTests.java b/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/VaultConfigRadiusTests.java new file mode 100644 index 00000000..d0bfe904 --- /dev/null +++ b/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/VaultConfigRadiusTests.java @@ -0,0 +1,165 @@ +/* + * Copyright 2016-present the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.vault.config; + +import java.util.Collections; +import java.util.Map; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.vault.VaultContainer; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.vault.util.VaultTestContextRunner; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.vault.authentication.TokenAuthentication; +import org.springframework.vault.client.VaultEndpoint; +import org.springframework.vault.core.VaultTemplate; +import org.springframework.vault.support.VaultMount; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test using config infrastructure with RADIUS authentication. + * + *

+ * This test uses Testcontainers to run both Vault and FreeRADIUS in a shared Docker + * network. Testcontainers is required here because Vault's RADIUS auth method uses UDP + * protocol for authentication. UDP port forwarding from Docker to the host is unreliable, + * so both containers must be in the same network to communicate directly. + * + * @author Issam El-atif + */ +@Testcontainers +class VaultConfigRadiusTests { + + private static final String VAULT_TOKEN = "00000000-0000-0000-0000-000000000000"; + + private static final String RADIUS_USERNAME = "testuser"; + + private static final String RADIUS_PASSWORD = "testpass"; + + private static final String RADIUS_SECRET = "testing123"; + + private static final String RADIUS_ALIAS = "freeradius"; + + static Network network = Network.newNetwork(); + + // FreeRADIUS users config with test user + private static final String USERS_CONFIG = "%s Cleartext-Password := \"%s\"%n".formatted(RADIUS_USERNAME, + RADIUS_PASSWORD); + + // Custom FreeRADIUS clients config allowing any client + private static final String CLIENTS_CONFIG = """ + client localhost { + ipaddr = 127.0.0.1 + secret = testing123 + } + client any { + ipaddr = 0.0.0.0/0 + secret = %s + } + """.formatted(RADIUS_SECRET); + + @Container + static GenericContainer radiusContainer = new GenericContainer<>("freeradius/freeradius-server:latest") + .withNetwork(network) + .withNetworkAliases(RADIUS_ALIAS) + .withCommand("-X") + .withCopyToContainer(Transferable.of(USERS_CONFIG), "/etc/freeradius/mods-config/files/authorize") + .withCopyToContainer(Transferable.of(CLIENTS_CONFIG), "/etc/freeradius/clients.conf") + .waitingFor(Wait.forLogMessage(".*Ready to process requests.*\\n", 1)); + + @Container + static VaultContainer vaultContainer = new VaultContainer<>("hashicorp/vault:1.13.3").withVaultToken(VAULT_TOKEN) + .withNetwork(network); + + VaultTestContextRunner contextRunner = VaultTestContextRunner.of(VaultConfigRadiusTests.class) + .withAuthentication(VaultProperties.AuthenticationMethod.RADIUS) + .withConfiguration(TestApplication.class) + .withProperties("spring.cloud.vault.uri", vaultContainer.getHttpHostAddress()) + .withProperties("spring.cloud.vault.radius.username", RADIUS_USERNAME) + .withProperties("spring.cloud.vault.radius.password", RADIUS_PASSWORD) + .withSettings(VaultTestContextRunner.TestSettings::bootstrap); + + @BeforeAll + static void beforeClass() { + VaultTemplate vaultTemplate = createVaultTemplate(); + configureVaultSecrets(vaultTemplate); + configureRadiusAuth(vaultTemplate); + } + + private static VaultTemplate createVaultTemplate() { + VaultEndpoint endpoint = VaultEndpoint.from(vaultContainer.getHttpHostAddress()); + return new VaultTemplate(endpoint, new SimpleClientHttpRequestFactory(), + () -> new TokenAuthentication(VAULT_TOKEN).login()); + } + + private static void configureVaultSecrets(VaultTemplate vaultTemplate) { + vaultTemplate.opsForSys().unmount("secret"); + vaultTemplate.opsForSys() + .mount("secret", VaultMount.builder().type("kv").options(Collections.singletonMap("version", "1")).build()); + + vaultTemplate.write("secret/" + VaultConfigRadiusTests.class.getSimpleName(), + Collections.singletonMap("vault.value", "foo")); + } + + private static void configureRadiusAuth(VaultTemplate vaultTemplate) { + vaultTemplate.opsForSys().authMount("radius", VaultMount.create("radius")); + + String rules = """ + path "*" { + capabilities = ["read", "list"] + } + """; + vaultTemplate.write("sys/policy/testpolicy", Collections.singletonMap("rules", rules)); + + // Configure RADIUS auth to point to the FreeRADIUS container by network alias + vaultTemplate.write("auth/radius/config", Map.of("host", RADIUS_ALIAS, "secret", RADIUS_SECRET)); + + vaultTemplate.write("auth/radius/users/" + RADIUS_USERNAME, Map.of("policies", "testpolicy")); + } + + @Test + void contextLoads() { + this.contextRunner.run(ctx -> { + TestApplication application = ctx.getBean(TestApplication.class); + assertThat(application.configValue).isEqualTo("foo"); + }); + } + + @SpringBootApplication + public static class TestApplication { + + @Value("${vault.value}") + String configValue; + + public static void main(String[] args) { + SpringApplication.run(TestApplication.class, args); + } + + } + +} diff --git a/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/VaultConfigUserPassTests.java b/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/VaultConfigUserPassTests.java new file mode 100644 index 00000000..16a97285 --- /dev/null +++ b/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/VaultConfigUserPassTests.java @@ -0,0 +1,113 @@ +/* + * Copyright 2016-present the original author or authors. + * + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.vault.config; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.vault.util.Settings; +import org.springframework.cloud.vault.util.VaultRule; +import org.springframework.cloud.vault.util.VaultTestContextRunner; +import org.springframework.cloud.vault.util.Version; +import org.springframework.vault.core.VaultOperations; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assume.assumeTrue; + +/** + * Integration test using config infrastructure with UserPass authentication. + * + *

+ * In case this test should fail because of SSL make sure you run the test within the + * spring-cloud-vault-config/spring-cloud-vault-config directory as the keystore is + * referenced with {@code ../work/keystore.jks}. + * + * @author Issam El-atif + */ +class VaultConfigUserPassTests { + + VaultTestContextRunner contextRunner = VaultTestContextRunner.of(VaultConfigUserPassTests.class) + .withAuthentication(VaultProperties.AuthenticationMethod.USERPASS) + .withConfiguration(VaultConfigUserPassTests.TestApplication.class) + .withProperties("spring.cloud.vault.userpass.username", "testuser") + .withProperties("spring.cloud.vault.userpass.password", "testpass") + .withSettings(VaultTestContextRunner.TestSettings::bootstrap); + + @BeforeAll + static void beforeClass() { + + VaultRule vaultRule = new VaultRule(); + vaultRule.before(); + + assumeTrue(vaultRule.prepare().getVersion().isGreaterThanOrEqualTo(Version.parse("0.8.0"))); + + VaultProperties vaultProperties = Settings.createVaultProperties(); + + if (!vaultRule.prepare().hasAuth(vaultProperties.getUserpass().getPath())) { + vaultRule.prepare().mountAuth(vaultProperties.getUserpass().getPath()); + } + + VaultOperations vaultOperations = vaultRule.prepare().getVaultOperations(); + + String rules = """ + { "name": "testpolicy", + "path": { + "*": { "policy": "read" } + } + }"""; + + vaultOperations.write("sys/policy/testpolicy", Collections.singletonMap("rules", rules)); + + vaultOperations.write("secret/" + VaultConfigUserPassTests.class.getSimpleName(), + Collections.singletonMap("vault.value", "foo")); + + Map userConfig = new HashMap<>(); + userConfig.put("password", "testpass"); + userConfig.put("policies", "testpolicy"); + + vaultOperations.write("auth/userpass/users/testuser", userConfig); + + } + + @Test + void contextLoads() { + this.contextRunner.run(ctx -> { + TestApplication application = ctx.getBean(TestApplication.class); + assertThat(application.configValue).isEqualTo("foo"); + }); + } + + @SpringBootApplication + public static class TestApplication { + + @Value("${vault.value}") + String configValue; + + public static void main(String[] args) { + SpringApplication.run(TestApplication.class, args); + } + + } + +}