From 8f37abe9504e4f630a69efffa5f613dcd6bcd9df Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Wed, 24 Aug 2022 10:34:52 +0200 Subject: [PATCH] Add support for Lettuce's 6.2 `RedisCredentialsProvider`. We now support construction of RedisCredentialsProvider through LettuceClientConfiguration and RedisCredentialsProviderFactory. The default implementation adapts credentials configured in RedisConfiguration objects. Closes: #2376 Original Pull Request: #2387 --- .../DefaultLettuceClientConfiguration.java | 10 +- ...aultLettucePoolingClientConfiguration.java | 5 + .../lettuce/LettuceClientConfiguration.java | 29 ++++- .../lettuce/LettuceConnectionFactory.java | 19 +++ .../LettucePoolingClientConfiguration.java | 7 ++ .../RedisCredentialsProviderFactory.java | 111 ++++++++++++++++++ .../LettuceConnectionFactoryUnitTests.java | 89 ++++++++++++++ 7 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/springframework/data/redis/connection/lettuce/RedisCredentialsProviderFactory.java diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/DefaultLettuceClientConfiguration.java b/src/main/java/org/springframework/data/redis/connection/lettuce/DefaultLettuceClientConfiguration.java index def236526..23ae40a6c 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/DefaultLettuceClientConfiguration.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/DefaultLettuceClientConfiguration.java @@ -41,13 +41,15 @@ class DefaultLettuceClientConfiguration implements LettuceClientConfiguration { private final Optional clientOptions; private final Optional clientName; private final Optional readFrom; + private final Optional redisCredentialsProviderFactory; private final Duration timeout; private final Duration shutdownTimeout; private final Duration shutdownQuietPeriod; DefaultLettuceClientConfiguration(boolean useSsl, boolean verifyPeer, boolean startTls, @Nullable ClientResources clientResources, @Nullable ClientOptions clientOptions, @Nullable String clientName, - @Nullable ReadFrom readFrom, Duration timeout, Duration shutdownTimeout, @Nullable Duration shutdownQuietPeriod) { + @Nullable ReadFrom readFrom, @Nullable RedisCredentialsProviderFactory redisCredentialsProviderFactory, + Duration timeout, Duration shutdownTimeout, @Nullable Duration shutdownQuietPeriod) { this.useSsl = useSsl; this.verifyPeer = verifyPeer; @@ -56,6 +58,7 @@ class DefaultLettuceClientConfiguration implements LettuceClientConfiguration { this.clientOptions = Optional.ofNullable(clientOptions); this.clientName = Optional.ofNullable(clientName); this.readFrom = Optional.ofNullable(readFrom); + this.redisCredentialsProviderFactory = Optional.ofNullable(redisCredentialsProviderFactory); this.timeout = timeout; this.shutdownTimeout = shutdownTimeout; this.shutdownQuietPeriod = shutdownQuietPeriod != null ? shutdownQuietPeriod : shutdownTimeout; @@ -96,6 +99,11 @@ class DefaultLettuceClientConfiguration implements LettuceClientConfiguration { return readFrom; } + @Override + public Optional getRedisCredentialsProviderFactory() { + return redisCredentialsProviderFactory; + } + @Override public Duration getCommandTimeout() { return timeout; diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/DefaultLettucePoolingClientConfiguration.java b/src/main/java/org/springframework/data/redis/connection/lettuce/DefaultLettucePoolingClientConfiguration.java index c35fa221a..c2906d6c8 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/DefaultLettucePoolingClientConfiguration.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/DefaultLettucePoolingClientConfiguration.java @@ -79,6 +79,11 @@ class DefaultLettucePoolingClientConfiguration implements LettucePoolingClientCo return clientConfiguration.getReadFrom(); } + @Override + public Optional getRedisCredentialsProviderFactory() { + return clientConfiguration.getRedisCredentialsProviderFactory(); + } + @Override public Duration getCommandTimeout() { return clientConfiguration.getCommandTimeout(); diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceClientConfiguration.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceClientConfiguration.java index 9a192b1fb..3f9635b86 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceClientConfiguration.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceClientConfiguration.java @@ -94,6 +94,12 @@ public interface LettuceClientConfiguration { */ Optional getReadFrom(); + /** + * @return the optional {@link RedisCredentialsProviderFactory}. + * @since 3.0 + */ + Optional getRedisCredentialsProviderFactory(); + /** * @return the timeout. */ @@ -166,6 +172,7 @@ public interface LettuceClientConfiguration { ClientOptions clientOptions = ClientOptions.builder().timeoutOptions(TimeoutOptions.enabled()).build(); @Nullable String clientName; @Nullable ReadFrom readFrom; + @Nullable RedisCredentialsProviderFactory redisCredentialsProviderFactory; Duration timeout = Duration.ofSeconds(RedisURI.DEFAULT_TIMEOUT); Duration shutdownTimeout = Duration.ofMillis(100); @Nullable Duration shutdownQuietPeriod; @@ -242,7 +249,7 @@ public interface LettuceClientConfiguration { * * @param readFrom must not be {@literal null}. * @return {@literal this} builder. - * @throws IllegalArgumentException if clientOptions is {@literal null}. + * @throws IllegalArgumentException if readFrom is {@literal null}. * @since 2.1 */ public LettuceClientConfigurationBuilder readFrom(ReadFrom readFrom) { @@ -253,6 +260,24 @@ public interface LettuceClientConfiguration { return this; } + /** + * Configure a {@link RedisCredentialsProviderFactory} to obtain {@link io.lettuce.core.RedisCredentialsProvider} + * instances to support credential rotation. + * + * @param redisCredentialsProviderFactory must not be {@literal null}. + * @return {@literal this} builder. + * @throws IllegalArgumentException if redisCredentialsProviderFactory is {@literal null}. + * @since 3.0 + */ + public LettuceClientConfigurationBuilder redisCredentialsProviderFactory( + RedisCredentialsProviderFactory redisCredentialsProviderFactory) { + + Assert.notNull(redisCredentialsProviderFactory, "RedisCredentialsProviderFactory must not be null"); + + this.redisCredentialsProviderFactory = redisCredentialsProviderFactory; + return this; + } + /** * Configure a {@code clientName} to be set with {@code CLIENT SETNAME}. * @@ -323,7 +348,7 @@ public interface LettuceClientConfiguration { public LettuceClientConfiguration build() { return new DefaultLettuceClientConfiguration(useSsl, verifyPeer, startTls, clientResources, clientOptions, - clientName, readFrom, timeout, shutdownTimeout, shutdownQuietPeriod); + clientName, readFrom, redisCredentialsProviderFactory, timeout, shutdownTimeout, shutdownQuietPeriod); } } diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactory.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactory.java index 67c8df547..f010923ff 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactory.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactory.java @@ -22,6 +22,7 @@ import io.lettuce.core.ClientOptions; import io.lettuce.core.ReadFrom; import io.lettuce.core.RedisClient; import io.lettuce.core.RedisConnectionException; +import io.lettuce.core.RedisCredentialsProvider; import io.lettuce.core.RedisURI; import io.lettuce.core.api.StatefulConnection; import io.lettuce.core.api.StatefulRedisConnection; @@ -1216,6 +1217,15 @@ public class LettuceConnectionFactory redisUri.setDatabase(getDatabase()); + clientConfiguration.getRedisCredentialsProviderFactory().ifPresent(factory -> { + + redisUri.setCredentialsProvider(factory.createCredentialsProvider(configuration)); + + RedisCredentialsProvider sentinelCredentials = factory + .createSentinelCredentialsProvider((RedisSentinelConfiguration) configuration); + redisUri.getSentinels().forEach(it -> it.setCredentialsProvider(sentinelCredentials)); + }); + return redisUri; } @@ -1267,6 +1277,10 @@ public class LettuceConnectionFactory } else { getRedisPassword().toOptional().ifPresent(builder::withPassword); } + + clientConfiguration.getRedisCredentialsProviderFactory().ifPresent(factory -> { + builder.withAuthentication(factory.createCredentialsProvider(configuration)); + }); } @Override @@ -1456,6 +1470,11 @@ public class LettuceConnectionFactory return Optional.empty(); } + @Override + public Optional getRedisCredentialsProviderFactory() { + return Optional.empty(); + } + @Override public Optional getClientName() { return Optional.ofNullable(clientName); diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettucePoolingClientConfiguration.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettucePoolingClientConfiguration.java index 3b27d8a3f..bfaaaa814 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/LettucePoolingClientConfiguration.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettucePoolingClientConfiguration.java @@ -145,6 +145,13 @@ public interface LettucePoolingClientConfiguration extends LettuceClientConfigur return this; } + @Override + public LettucePoolingClientConfigurationBuilder redisCredentialsProviderFactory( + RedisCredentialsProviderFactory redisCredentialsProviderFactory) { + super.redisCredentialsProviderFactory(redisCredentialsProviderFactory); + return this; + } + @Override public LettucePoolingClientConfigurationBuilder clientName(String clientName) { super.clientName(clientName); diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/RedisCredentialsProviderFactory.java b/src/main/java/org/springframework/data/redis/connection/lettuce/RedisCredentialsProviderFactory.java new file mode 100644 index 000000000..43df81ae4 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/RedisCredentialsProviderFactory.java @@ -0,0 +1,111 @@ +/* + * Copyright 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.data.redis.connection.lettuce; + +import io.lettuce.core.RedisCredentials; +import io.lettuce.core.RedisCredentialsProvider; +import reactor.core.publisher.Mono; + +import org.springframework.data.redis.connection.RedisConfiguration; +import org.springframework.data.redis.connection.RedisSentinelConfiguration; +import org.springframework.lang.Nullable; + +/** + * Factory interface to create {@link RedisCredentialsProvider} from a {@link RedisConfiguration}. Credentials can be + * associated with {@link RedisCredentials#hasUsername() username} and/or {@link RedisCredentials#hasPassword() + * password}. + *

+ * Credentials are based off the given {@link RedisConfiguration} objects. Changing the credentials in the actual object + * affects the constructed {@link RedisCredentials} object. Credentials are requested by the Lettuce client after + * connecting to the host. Therefore, credential retrieval is subject to complete within the configured connection + * creation timeout to avoid connection failures. + * + * @author Mark Paluch + * @since 3.0 + */ +public interface RedisCredentialsProviderFactory { + + /** + * Create a {@link RedisCredentialsProvider} for data node authentication given {@link RedisConfiguration}. + * + * @param redisConfiguration the {@link RedisConfiguration} object. + * @return a {@link RedisCredentialsProvider} that emits {@link RedisCredentials} for data node authentication. + */ + @Nullable + default RedisCredentialsProvider createCredentialsProvider(RedisConfiguration redisConfiguration) { + + if (redisConfiguration instanceof RedisConfiguration.WithAuthentication + && ((RedisConfiguration.WithAuthentication) redisConfiguration).getPassword().isPresent()) { + + return RedisCredentialsProvider.from(() -> { + + RedisConfiguration.WithAuthentication withAuthentication = (RedisConfiguration.WithAuthentication) redisConfiguration; + + return RedisCredentials.just(withAuthentication.getUsername(), withAuthentication.getPassword().get()); + }); + } + + return () -> Mono.just(AbsentRedisCredentials.ANONYMOUS); + } + + /** + * Create a {@link RedisCredentialsProvider} for Sentinel node authentication given + * {@link RedisSentinelConfiguration}. + * + * @param redisConfiguration the {@link RedisSentinelConfiguration} object. + * @return a {@link RedisCredentialsProvider} that emits {@link RedisCredentials} for sentinel authentication. + */ + default RedisCredentialsProvider createSentinelCredentialsProvider(RedisSentinelConfiguration redisConfiguration) { + + if (redisConfiguration.getSentinelPassword().isPresent()) { + + return RedisCredentialsProvider.from(() -> RedisCredentials.just(redisConfiguration.getSentinelUsername(), + redisConfiguration.getSentinelPassword().get())); + } + + return () -> Mono.just(AbsentRedisCredentials.ANONYMOUS); + } + + /** + * Default anonymous {@link RedisCredentials} without username/password. + */ + enum AbsentRedisCredentials implements RedisCredentials { + + ANONYMOUS; + + @Override + @Nullable + public String getUsername() { + return null; + } + + @Override + public boolean hasUsername() { + return false; + } + + @Override + @Nullable + public char[] getPassword() { + return null; + } + + @Override + public boolean hasPassword() { + return false; + } + } +} diff --git a/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactoryUnitTests.java b/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactoryUnitTests.java index 60f3ddfa5..777186352 100644 --- a/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactoryUnitTests.java +++ b/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactoryUnitTests.java @@ -37,6 +37,7 @@ import io.lettuce.core.codec.RedisCodec; import io.lettuce.core.resource.ClientResources; import lombok.AllArgsConstructor; import lombok.Data; +import reactor.test.StepVerifier; import java.time.Duration; import java.util.Collections; @@ -187,6 +188,59 @@ class LettuceConnectionFactoryUnitTests { } } + @Test // GH-2376 + @SuppressWarnings("unchecked") + void credentialsProviderShouldBeSetCorrectlyOnClusterClient() { + + clusterConfig.setUsername("foo"); + clusterConfig.setPassword("bar"); + + LettuceClientConfiguration clientConfiguration = LettuceClientConfiguration.builder() + .clientResources(getSharedClientResources()) + .redisCredentialsProviderFactory(new RedisCredentialsProviderFactory() {}).build(); + LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory(clusterConfig, clientConfiguration); + connectionFactory.afterPropertiesSet(); + ConnectionFactoryTracker.add(connectionFactory); + + AbstractRedisClient client = (AbstractRedisClient) getField(connectionFactory, "client"); + assertThat(client).isInstanceOf(RedisClusterClient.class); + + Iterable initialUris = (Iterable) getField(client, "initialUris"); + + for (RedisURI uri : initialUris) { + + uri.getCredentialsProvider().resolveCredentials().as(StepVerifier::create).consumeNextWith(actual -> { + assertThat(actual.getUsername()).isEqualTo("foo"); + assertThat(new String(actual.getPassword())).isEqualTo("bar"); + }).verifyComplete(); + } + } + + @Test // GH-2376 + void credentialsProviderShouldBeSetCorrectlyOnStandaloneClient() { + + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration("localhost"); + config.setUsername("foo"); + config.setPassword("bar"); + + LettuceClientConfiguration clientConfiguration = LettuceClientConfiguration.builder() + .clientResources(getSharedClientResources()) + .redisCredentialsProviderFactory(new RedisCredentialsProviderFactory() {}).build(); + LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory(config, clientConfiguration); + connectionFactory.afterPropertiesSet(); + ConnectionFactoryTracker.add(connectionFactory); + + AbstractRedisClient client = (AbstractRedisClient) getField(connectionFactory, "client"); + assertThat(client).isInstanceOf(RedisClient.class); + + RedisURI uri = (RedisURI) getField(client, "redisURI"); + + uri.getCredentialsProvider().resolveCredentials().as(StepVerifier::create).consumeNextWith(actual -> { + assertThat(actual.getUsername()).isEqualTo("foo"); + assertThat(new String(actual.getPassword())).isEqualTo("bar"); + }).verifyComplete(); + } + @Test // DATAREDIS-524, DATAREDIS-1045, DATAREDIS-1060 void passwordShouldNotBeSetOnSentinelClient() { @@ -233,6 +287,41 @@ class LettuceConnectionFactoryUnitTests { } } + @Test // GH-2376 + void sentinelCredentialsProviderShouldBeSetOnSentinelClient() { + + RedisSentinelConfiguration config = new RedisSentinelConfiguration("mymaster", Collections.singleton("host:1234")); + config.setUsername("data-user"); + config.setPassword("data-pwd"); + config.setSentinelPassword("sentinel-pwd"); + + LettuceClientConfiguration clientConfiguration = LettuceClientConfiguration.builder() + .clientResources(getSharedClientResources()) + .redisCredentialsProviderFactory(new RedisCredentialsProviderFactory() {}).build(); + + LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory(config, clientConfiguration); + connectionFactory.afterPropertiesSet(); + ConnectionFactoryTracker.add(connectionFactory); + + AbstractRedisClient client = (AbstractRedisClient) getField(connectionFactory, "client"); + assertThat(client).isInstanceOf(RedisClient.class); + + RedisURI redisUri = (RedisURI) getField(client, "redisURI"); + + redisUri.getCredentialsProvider().resolveCredentials().as(StepVerifier::create).consumeNextWith(actual -> { + assertThat(actual.getUsername()).isEqualTo("data-user"); + assertThat(new String(actual.getPassword())).isEqualTo("data-pwd"); + }).verifyComplete(); + + for (RedisURI sentinelUri : redisUri.getSentinels()) { + + sentinelUri.getCredentialsProvider().resolveCredentials().as(StepVerifier::create).consumeNextWith(actual -> { + assertThat(actual.getUsername()).isNull(); + assertThat(new String(actual.getPassword())).isEqualTo("sentinel-pwd"); + }).verifyComplete(); + } + } + @Test // DATAREDIS-1060 void sentinelPasswordShouldNotLeakIntoDataNodeClient() {