diff --git a/spring-vault-core/src/main/java/org/springframework/vault/config/AbstractVaultConfiguration.java b/spring-vault-core/src/main/java/org/springframework/vault/config/AbstractVaultConfiguration.java index a7196fec..d7689cb5 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/config/AbstractVaultConfiguration.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/config/AbstractVaultConfiguration.java @@ -32,6 +32,7 @@ import org.springframework.util.Assert; import org.springframework.vault.authentication.ClientAuthentication; import org.springframework.vault.authentication.LifecycleAwareSessionManager; import org.springframework.vault.authentication.SessionManager; +import org.springframework.vault.authentication.event.AuthenticationEventMulticaster; import org.springframework.vault.client.ClientHttpRequestFactoryFactory; import org.springframework.vault.client.RestTemplateBuilder; import org.springframework.vault.client.RestTemplateCustomizer; @@ -168,8 +169,15 @@ public abstract class AbstractVaultConfiguration implements ApplicationContextAw SecretLeaseContainer secretLeaseContainer = new SecretLeaseContainer( getBeanFactory().getBean("vaultTemplate", VaultTemplate.class), getVaultThreadPoolTaskScheduler()); + SessionManager sessionManager = getBeanFactory().getBean("sessionManager", SessionManager.class); secretLeaseContainer.afterPropertiesSet(); + + if (sessionManager instanceof AuthenticationEventMulticaster multicaster) { + multicaster.addAuthenticationListener(secretLeaseContainer.getAuthenticationListener()); + multicaster.addErrorListener(secretLeaseContainer.getAuthenticationErrorListener()); + } + secretLeaseContainer.start(); return secretLeaseContainer; diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/lease/SecretLeaseContainer.java b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/SecretLeaseContainer.java index 532f95e7..44ee66ae 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/lease/SecretLeaseContainer.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/lease/SecretLeaseContainer.java @@ -15,17 +15,19 @@ */ package org.springframework.vault.core.lease; +import java.time.Clock; import java.time.Duration; import java.time.Instant; -import java.util.Date; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executor; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -38,6 +40,7 @@ import org.apache.commons.logging.LogFactory; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.SmartLifecycle; import org.springframework.http.HttpStatus; import org.springframework.lang.Nullable; import org.springframework.scheduling.TaskScheduler; @@ -46,7 +49,16 @@ import org.springframework.scheduling.TriggerContext; import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.util.Assert; import org.springframework.util.StringUtils; +import org.springframework.util.backoff.BackOff; +import org.springframework.util.backoff.BackOffExecution; +import org.springframework.util.backoff.ExponentialBackOff; import org.springframework.vault.VaultException; +import org.springframework.vault.authentication.event.AuthenticationErrorEvent; +import org.springframework.vault.authentication.event.AuthenticationErrorListener; +import org.springframework.vault.authentication.event.AuthenticationEvent; +import org.springframework.vault.authentication.event.AuthenticationListener; +import org.springframework.vault.authentication.event.LoginTokenExpiredEvent; +import org.springframework.vault.authentication.event.LoginTokenRenewalFailedEvent; import org.springframework.vault.client.VaultResponses; import org.springframework.vault.core.VaultOperations; import org.springframework.vault.core.lease.domain.Lease; @@ -62,34 +74,23 @@ import org.springframework.web.client.HttpStatusCodeException; /** * Event-based container to request secrets from Vault and renew the associated * {@link Lease}. Secrets can be rotated, depending on the requested - * {@link RequestedSecret#getMode()}. - * - * Usage example: - * - *
+ * {@link RequestedSecret#getMode()}. Usage example: 
  * 
  * SecretLeaseContainer container = new SecretLeaseContainer(vaultOperations,
  * 		taskScheduler);
- *
  * RequestedSecret requestedSecret = container
  * 		.requestRotatingSecret("mysql/creds/my-role");
  * container.addLeaseListener(new LeaseListenerAdapter() {
  * 	@Override
  * 	public void onLeaseEvent(SecretLeaseEvent secretLeaseEvent) {
- *
  * 		if (requestedSecret == secretLeaseEvent.getSource()) {
- *
  * 			if (secretLeaseEvent instanceof SecretLeaseCreatedEvent) {
- *
- * 			}
- *
+ *            }
  * 			if (secretLeaseEvent instanceof SecretLeaseExpiredEvent) {
- *
- * 			}
- * 		}
- * 	}
+ *            }
+ *        }
+ *    }
  * });
- *
  * container.afterPropertiesSet();
  * container.start(); // events are triggered after starting the container
  *  
@@ -120,7 +121,8 @@ import org.springframework.web.client.HttpStatusCodeException; * @see LeaseEndpoints * @see LeaseStrategy */ -public class SecretLeaseContainer extends SecretLeaseEventPublisher implements InitializingBean, DisposableBean { +public class SecretLeaseContainer extends SecretLeaseEventPublisher + implements InitializingBean, DisposableBean, SmartLifecycle { private static final AtomicIntegerFieldUpdater UPDATER = AtomicIntegerFieldUpdater .newUpdater(SecretLeaseContainer.class, "status"); @@ -136,6 +138,10 @@ public class SecretLeaseContainer extends SecretLeaseEventPublisher implements I @SuppressWarnings("FieldMayBeFinal") // allow setting via reflection. private static Log logger = LogFactory.getLog(SecretLeaseContainer.class); + private final Clock clock = Clock.systemDefaultZone(); + + private final LeaseAuthenticationEventListener authenticationListener = new LeaseAuthenticationEventListener(); + private final List requestedSecrets = new CopyOnWriteArrayList<>(); private final Map renewals = new ConcurrentHashMap<>(); @@ -189,13 +195,31 @@ public class SecretLeaseContainer extends SecretLeaseEventPublisher implements I setTaskScheduler(taskScheduler); } + /** + * Returns the {@link AuthenticationListener} to listen for login token events. + * @return the {@link AuthenticationListener} to listen for login token events. + * @since 3.1 + */ + public AuthenticationListener getAuthenticationListener() { + return this.authenticationListener; + } + + /** + * Returns the {@link AuthenticationListener} to listen for login token error events. + * @return the {@link AuthenticationListener} to listen for login token error events + * @since 3.1 + */ + public AuthenticationErrorListener getAuthenticationErrorListener() { + return this.authenticationListener; + } + /** * Set the {@link LeaseEndpoints} to delegate renewal/revocation calls to. * {@link LeaseEndpoints} encapsulates differences between Vault versions that affect * the location of renewal/revocation endpoints. * @param leaseEndpoints must not be {@literal null}. - * @since 2.1 * @see LeaseEndpoints + * @since 2.1 */ public void setLeaseEndpoints(LeaseEndpoints leaseEndpoints) { @@ -336,6 +360,7 @@ public class SecretLeaseContainer extends SecretLeaseEventPublisher implements I * @see #afterPropertiesSet() * @see #stop() */ + @Override public void start() { Assert.state(this.initialized, "Container is not initialized"); @@ -409,6 +434,7 @@ public class SecretLeaseContainer extends SecretLeaseEventPublisher implements I * * @see #start() */ + @Override public void stop() { if (UPDATER.compareAndSet(this, STATUS_STARTED, STATUS_INITIAL)) { @@ -419,30 +445,41 @@ public class SecretLeaseContainer extends SecretLeaseEventPublisher implements I } } + @Override + public boolean isRunning() { + return UPDATER.get(this) == STATUS_STARTED; + } + + @Override + public int getPhase() { + return 200; + } + @Override public void afterPropertiesSet() { - if (!this.initialized) { + if (this.initialized) { + return; + } - super.afterPropertiesSet(); + super.afterPropertiesSet(); - this.initialized = true; + this.initialized = true; - if (this.taskScheduler == null) { + if (this.taskScheduler == null) { - ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); - scheduler.setDaemon(true); - scheduler - .setThreadNamePrefix(String.format("%s-%d-", getClass().getSimpleName(), poolId.incrementAndGet())); - scheduler.afterPropertiesSet(); + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setDaemon(true); + scheduler + .setThreadNamePrefix(String.format("%s-%d-", getClass().getSimpleName(), poolId.incrementAndGet())); + scheduler.afterPropertiesSet(); - this.taskScheduler = scheduler; - this.manageTaskScheduler = true; - } + this.taskScheduler = scheduler; + this.manageTaskScheduler = true; + } - for (RequestedSecret requestedSecret : this.requestedSecrets) { - this.renewals.put(requestedSecret, new LeaseRenewalScheduler(this.taskScheduler)); - } + for (RequestedSecret requestedSecret : this.requestedSecrets) { + this.renewals.put(requestedSecret, new LeaseRenewalScheduler(this.taskScheduler)); } } @@ -472,6 +509,7 @@ public class SecretLeaseContainer extends SecretLeaseEventPublisher implements I doRevokeLease(entry.getKey(), lease); } } + this.renewals.clear(); if (this.manageTaskScheduler) { @@ -484,6 +522,51 @@ public class SecretLeaseContainer extends SecretLeaseEventPublisher implements I } } + void restartSecrets() { + + int status = this.status; + if (status == STATUS_STARTED) { + + logger.debug("Restarting all secrets after token expiry/rotation"); + + try { + + Map previousLeases = new LinkedHashMap<>(this.renewals); + this.renewals.clear(); + previousLeases.values().forEach(LeaseRenewalScheduler::disableScheduleRenewal); + + for (RequestedSecret requestedSecret : this.requestedSecrets) { + + LeaseRenewalScheduler renewalScheduler = new LeaseRenewalScheduler(this.taskScheduler); + Lease previousLease = getPreviousLease(previousLeases, requestedSecret); + + try { + doStart(requestedSecret, renewalScheduler, (secrets, lease) -> { + onSecretsRotated(requestedSecret, previousLease, lease, secrets.getRequiredData()); + }, () -> { + }); + + } + catch (Exception e) { + onError(requestedSecret, previousLease, e); + } + } + } + catch (Exception e) { + logger.error("Cannot restart secrets", e); + } + } + } + + private static Lease getPreviousLease(Map previousLeases, + RequestedSecret requestedSecret) { + + LeaseRenewalScheduler leaseRenewalScheduler = previousLeases.get(requestedSecret); + Lease previousLease = leaseRenewalScheduler != null ? leaseRenewalScheduler.getLease() : null; + + return previousLease == null ? Lease.none() : previousLease; + } + /** * Renew a {@link RequestedSecret secret}. * @param secret the {@link RequestedSecret secret}' to renew. @@ -712,6 +795,7 @@ public class SecretLeaseContainer extends SecretLeaseEventPublisher implements I * @param requestedSecret must not be {@literal null}. * @param lease must not be {@literal null}. */ + @Override protected void onLeaseExpired(RequestedSecret requestedSecret, Lease lease) { if (requestedSecret.getMode() == Mode.ROTATE) { @@ -916,6 +1000,70 @@ public class SecretLeaseContainer extends SecretLeaseEventPublisher implements I } + private class LeaseAuthenticationEventListener implements AuthenticationListener, AuthenticationErrorListener { + + private final BackOff backOff = new ExponentialBackOff(500, 1.5); + + private final AtomicReference timeout = new AtomicReference<>(); + + @Override + public void onAuthenticationError(AuthenticationErrorEvent authenticationEvent) { + if (authenticationEvent instanceof LoginTokenRenewalFailedEvent) { + logger.debug("LoginTokenRenewalFailedEvent received"); + restartSecrets(); + } + } + + @Override + public void onAuthenticationEvent(AuthenticationEvent leaseEvent) { + if (leaseEvent instanceof LoginTokenExpiredEvent) { + logger.debug("LoginTokenExpiredEvent received"); + restartSecrets(); + } + } + + /** + * Restart secrets after a changed token. Either the token was rotated or it has + * expired. + */ + private void restartSecrets() { + + if (!isRunning()) { + logger.debug("Ignore token event as the container is not running"); + } + + Timeout timeout = this.timeout.get(); + if (timeout != null && !timeout.isExpired(clock)) { + logger.debug("Backoff timeout not reached. Dropping event"); + return; + } + + Timeout executionToSet = new Timeout(backOff.start(), clock); + + if (this.timeout.compareAndSet(timeout, executionToSet)) { + + if (taskScheduler instanceof Executor e) { + e.execute(SecretLeaseContainer.this::restartSecrets); + } + else { + taskScheduler.schedule(SecretLeaseContainer.this::restartSecrets, Instant.now()); + } + } + } + + record Timeout(BackOffExecution execution, long timeout) { + + public Timeout(BackOffExecution execution, Clock clock) { + this(execution, clock.millis() + execution.nextBackOff()); + } + + public boolean isExpired(Clock clock) { + return clock.millis() > timeout; + } + } + + } + /** * This one-shot trigger creates only one execution time to trigger an execution only * once. diff --git a/spring-vault-core/src/test/java/org/springframework/vault/annotation/VaultPropertySourceUnitTests.java b/spring-vault-core/src/test/java/org/springframework/vault/annotation/VaultPropertySourceUnitTests.java index b738332c..d7cc23ab 100644 --- a/spring-vault-core/src/test/java/org/springframework/vault/annotation/VaultPropertySourceUnitTests.java +++ b/spring-vault-core/src/test/java/org/springframework/vault/annotation/VaultPropertySourceUnitTests.java @@ -157,6 +157,7 @@ class VaultPropertySourceUnitTests { verify(leaseContainerMock).addLeaseListener(any()); verify(leaseContainerMock).addErrorListener(any()); verify(leaseContainerMock).addRequestedSecret(RequestedSecret.renewable("foo/renewable")); + verify(leaseContainerMock).isAutoStartup(); verifyNoMoreInteractions(leaseContainerMock); } diff --git a/spring-vault-core/src/test/java/org/springframework/vault/core/lease/TokenExpiryRotatingSecretsIntegrationTests.java b/spring-vault-core/src/test/java/org/springframework/vault/core/lease/TokenExpiryRotatingSecretsIntegrationTests.java new file mode 100644 index 00000000..65e3b6d4 --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/core/lease/TokenExpiryRotatingSecretsIntegrationTests.java @@ -0,0 +1,151 @@ +/* + * Copyright 2020-2022 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.vault.core.lease; + +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.util.Assert; +import org.springframework.vault.authentication.AuthenticationEventPublisher; +import org.springframework.vault.authentication.ClientAuthentication; +import org.springframework.vault.authentication.SessionManager; +import org.springframework.vault.authentication.UsernamePasswordAuthentication; +import org.springframework.vault.authentication.UsernamePasswordAuthenticationOptions; +import org.springframework.vault.authentication.event.AuthenticationEventMulticaster; +import org.springframework.vault.authentication.event.LoginTokenExpiredEvent; +import org.springframework.vault.core.VaultIntegrationTestConfiguration; +import org.springframework.vault.core.VaultKeyValueOperations; +import org.springframework.vault.core.VaultKeyValueOperationsSupport; +import org.springframework.vault.core.VaultOperations; +import org.springframework.vault.core.lease.TokenExpiryRotatingSecretsIntegrationTests.UserPassConfiguration; +import org.springframework.vault.core.lease.event.SecretLeaseEvent; +import org.springframework.vault.core.lease.event.SecretLeaseRotatedEvent; +import org.springframework.vault.support.Policy; +import org.springframework.vault.support.Policy.BuiltinCapabilities; +import org.springframework.vault.support.Policy.Capability; +import org.springframework.vault.support.Policy.Rule; +import org.springframework.vault.util.IntegrationTestSupport; +import org.springframework.vault.util.PrepareVault; +import org.springframework.vault.util.VaultInitializer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +/** + * Integration tests for rotating generic secrets. + * + * @author Mark Paluch + */ +@ExtendWith(SpringExtension.class) +@SpringJUnitConfig(classes = { UserPassConfiguration.class, RotatingGenericSecretsIntegrationTestConfiguration.class }) +class TokenExpiryRotatingSecretsIntegrationTests extends IntegrationTestSupport { + + @BeforeAll + static void beforeAll() { + + VaultInitializer initializer = new VaultInitializer(); + + initializer.initialize(); + PrepareVault prepare = initializer.prepare(); + + assumeThat(prepare.getVersion()).isGreaterThanOrEqualTo(VaultInitializer.VERSIONING_INTRODUCED_WITH); + + if (!prepare.hasAuth("userpass")) { + prepare.mountAuth("userpass"); + } + + VaultOperations vaultOperations = prepare.getVaultOperations(); + + Policy policy = Policy + .of(Rule.builder().capabilities(BuiltinCapabilities.crud().toArray(new Capability[0])).path("/*").build()); + vaultOperations.opsForSys().createOrUpdatePolicy("TokenExpiryRotatingSecretsIntegrationTests", policy); + + vaultOperations.write("auth/userpass/users/token-expiry", Map.of("password", "token-expiry", "token_ttl", 8, + "token_max_ttl", 8, "token_policies", "TokenExpiryRotatingSecretsIntegrationTests")); + + VaultKeyValueOperations versioned = prepare.getVaultOperations() + .opsForKeyValue("versioned", VaultKeyValueOperationsSupport.KeyValueBackend.KV_2); + + versioned.put("rotating", Collections.singletonMap("key", "value")); + } + + @Test + void shouldRenewSecretsOnTokenRenewalFailure( + @Autowired RotatingGenericSecretsIntegrationTestConfiguration.PropertySourceHolder holder, + @Autowired SessionManager sessionManager, @Autowired SecretLeaseContainer container) + throws InterruptedException { + + assertThat(holder.propertySource.getProperty("generic.rotating.key")).isEqualTo("value"); + + CountDownLatch latch = new CountDownLatch(1); + BlockingQueue events = new LinkedBlockingQueue<>(); + + ((AuthenticationEventMulticaster) sessionManager).addAuthenticationListener(leaseEvent -> { + if (leaseEvent instanceof LoginTokenExpiredEvent) { + latch.countDown(); + } + }); + + // for some reason, "failed to renew entry: policies have changed, not renewing" + // happens. + ((AuthenticationEventMulticaster) sessionManager).addErrorListener(leaseEvent -> { + latch.countDown(); + }); + + VaultKeyValueOperations versioned = prepare().getVaultOperations() + .opsForKeyValue("versioned", VaultKeyValueOperationsSupport.KeyValueBackend.KV_2); + + versioned.put("rotating", Collections.singletonMap("key", "updated-value")); + + container.addLeaseListener(events::add); + + Assert.isTrue(latch.await(5, TimeUnit.SECONDS), "Timeout waiting for AuthenticationEvent"); + + assertThat(events.poll(2, TimeUnit.SECONDS)).isInstanceOf(SecretLeaseRotatedEvent.class); + assertThat(holder.propertySource.getProperty("generic.rotating.key")).isEqualTo("updated-value"); + + versioned.put("rotating", Collections.singletonMap("key", "another-updated-value")); + + assertThat(events.poll(5, TimeUnit.SECONDS)).isInstanceOf(SecretLeaseRotatedEvent.class); + assertThat(holder.propertySource.getProperty("generic.rotating.key")).isEqualTo("another-updated-value"); + } + + @Configuration + static class UserPassConfiguration extends VaultIntegrationTestConfiguration { + + @Override + public ClientAuthentication clientAuthentication() { + return new UsernamePasswordAuthentication(UsernamePasswordAuthenticationOptions.builder() + .username("token-expiry") + .password("token-expiry") + .build(), getRestTemplateFactory().create()); + } + + } + +}