diff --git a/.github/workflows/java-server-sdk-redis-store.yml b/.github/workflows/java-server-sdk-redis-store.yml index 6e4a3d7..ec6b107 100644 --- a/.github/workflows/java-server-sdk-redis-store.yml +++ b/.github/workflows/java-server-sdk-redis-store.yml @@ -14,7 +14,8 @@ jobs: build-test-java-server-sdk-redis-store: strategy: matrix: - jedis-version: [2.9.0, 3.0.0] + # Username/password (Redis ACL) support requires Jedis 3.6.0+ + jedis-version: [3.6.0, 7.1.0] runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -23,7 +24,7 @@ jobs: shell: bash run: | cd lib/java-server-sdk-redis-store - sed -i.bak 's#"jedis":.*"[0-9.]*"#"jedis":"${{ matrix.jedis-version }}"#' build.gradle + sed -i.bak -E 's/"jedis":[[:space:]]*"[0-9.]+"/"jedis": "${{ matrix.jedis-version }}"/' build.gradle - name: Shared CI Steps uses: ./.github/actions/ci @@ -34,7 +35,8 @@ jobs: build-test-java-server-sdk-windows: strategy: matrix: - jedis-version: [2.9.0, 3.0.0] + # Username/password (Redis ACL) support requires Jedis 3.6.0+ + jedis-version: [3.6.0, 7.1.0] runs-on: windows-latest steps: - uses: actions/checkout@v3 @@ -54,7 +56,7 @@ jobs: shell: bash run: | cd lib/java-server-sdk-redis-store - sed -i.bak 's#"jedis":.*"[0-9.]*"#"jedis":"${{ matrix.jedis-version }}"#' build.gradle + sed -i.bak -E 's/"jedis":[[:space:]]*"[0-9.]+"/"jedis": "${{ matrix.jedis-version }}"/' build.gradle - name: Setup Java uses: actions/setup-java@v4 diff --git a/lib/java-server-sdk-redis-store/README.md b/lib/java-server-sdk-redis-store/README.md index d2d2820..26204f3 100644 --- a/lib/java-server-sdk-redis-store/README.md +++ b/lib/java-server-sdk-redis-store/README.md @@ -23,10 +23,10 @@ This assumes that you have already installed the LaunchDarkly Java SDK. redis.clients jedis - 2.9.0 + 7.1.0 - This library is compatible with Jedis 2.x versions greater than or equal to 2.9.0, and also with Jedis 3.x. + This library uses Jedis 7.1.0 by default and requires Jedis 3.6.0 or later for username/password (ACL) authentication support. 3. Import the LaunchDarkly package and the package for this library: @@ -45,6 +45,44 @@ This assumes that you have already installed the LaunchDarkly Java SDK. By default, the store will try to connect to a local Redis instance on port 6379. +## Authentication + +### Password-only authentication (legacy) + +For Redis servers using simple password authentication: + + LDConfig config = new LDConfig.Builder() + .dataStore( + Components.persistentDataStore( + Redis.dataStore().password("my-redis-password") + ) + ) + .build(); + +Or include it in the URI: + + Redis.dataStore().url("redis://:my-redis-password@my-redis-host:6379") + +### Username/password authentication (Redis 6.0+ ACL) + +For Redis 6.0+ servers using ACL with username and password: + + LDConfig config = new LDConfig.Builder() + .dataStore( + Components.persistentDataStore( + Redis.dataStore() + .username("my-username") + .password("my-password") + ) + ) + .build(); + +Or include both in the URI: + + Redis.dataStore().url("redis://my-username:my-password@my-redis-host:6379") + +**Note:** Username/password authentication requires Jedis 3.6.0 or later. + ## Caching behavior The LaunchDarkly SDK has a standard caching mechanism for any persistent data store, to reduce database traffic. This is configured through the SDK's `PersistentDataStoreBuilder` class as described the SDK documentation. For instance, to specify a cache TTL of 5 minutes: diff --git a/lib/java-server-sdk-redis-store/build.gradle b/lib/java-server-sdk-redis-store/build.gradle index 1d8e62c..5d1eaa2 100644 --- a/lib/java-server-sdk-redis-store/build.gradle +++ b/lib/java-server-sdk-redis-store/build.gradle @@ -42,7 +42,7 @@ ext { ext.versions = [ "sdk": "6.3.0", // the *lowest* version we're compatible with - "jedis": "2.9.0" + "jedis": "7.1.0" // 3.6.0+ required for username/password (ACL) authentication ] ext.libraries = [:] diff --git a/lib/java-server-sdk-redis-store/src/main/java/com/launchdarkly/sdk/server/integrations/RedisStoreBuilder.java b/lib/java-server-sdk-redis-store/src/main/java/com/launchdarkly/sdk/server/integrations/RedisStoreBuilder.java index d8df2ec..1a7dbff 100644 --- a/lib/java-server-sdk-redis-store/src/main/java/com/launchdarkly/sdk/server/integrations/RedisStoreBuilder.java +++ b/lib/java-server-sdk-redis-store/src/main/java/com/launchdarkly/sdk/server/integrations/RedisStoreBuilder.java @@ -76,6 +76,7 @@ public abstract class RedisStoreBuilder implements ComponentConfigurer, Di Duration connectTimeout = Duration.ofMillis(Protocol.DEFAULT_TIMEOUT); Duration socketTimeout = Duration.ofMillis(Protocol.DEFAULT_TIMEOUT); Integer database = null; + String username = null; String password = null; boolean tls = false; JedisPoolConfig poolConfig = null; @@ -98,11 +99,30 @@ public RedisStoreBuilder database(Integer database) { return this; } + /** + * Specifies a username for Redis ACL authentication. + *

+ * Redis 6.0+ supports Access Control Lists (ACL) with username/password authentication. + * It is also possible to include a username in the Redis URI, in the form {@code redis://USERNAME:PASSWORD@host:port}. + * Any username that you set with {@link #username(String)} will override the URI. + *

+ * Note: Using this feature requires Jedis 3.6.0 or later. + * + * @param username the username for ACL authentication + * @return the builder + * @since 2.2.0 + */ + public RedisStoreBuilder username(String username) { + this.username = username; + return this; + } + /** * Specifies a password that will be sent to Redis in an AUTH command. *

- * It is also possible to include a password in the Redis URI, in the form {@code redis://:PASSWORD@host:port}. Any - * password that you set with {@link #password(String)} will override the URI. + * It is also possible to include a password in the Redis URI, in the form {@code redis://:PASSWORD@host:port} + * or {@code redis://USERNAME:PASSWORD@host:port} for ACL authentication. Any password that you set with + * {@link #password(String)} will override the URI. * * @param password the password * @return the builder diff --git a/lib/java-server-sdk-redis-store/src/main/java/com/launchdarkly/sdk/server/integrations/RedisStoreImplBase.java b/lib/java-server-sdk-redis-store/src/main/java/com/launchdarkly/sdk/server/integrations/RedisStoreImplBase.java index fa1ff5c..2241675 100644 --- a/lib/java-server-sdk-redis-store/src/main/java/com/launchdarkly/sdk/server/integrations/RedisStoreImplBase.java +++ b/lib/java-server-sdk-redis-store/src/main/java/com/launchdarkly/sdk/server/integrations/RedisStoreImplBase.java @@ -21,11 +21,15 @@ protected RedisStoreImplBase(RedisStoreBuilder builder, LDLogger logger) { // to decompose the URI. String host = builder.uri.getHost(); int port = builder.uri.getPort(); + String username = builder.username == null ? RedisURIComponents.getUsername(builder.uri) : builder.username; String password = builder.password == null ? RedisURIComponents.getPassword(builder.uri) : builder.password; int database = builder.database == null ? RedisURIComponents.getDBIndex(builder.uri) : builder.database; boolean tls = builder.tls || builder.uri.getScheme().equals("rediss"); String extra = tls ? " with TLS" : ""; + if (username != null) { + extra = extra + (extra.isEmpty() ? " with" : " and") + " username"; + } if (password != null) { extra = extra + (extra.isEmpty() ? " with" : " and") + " password"; } @@ -41,6 +45,7 @@ protected RedisStoreImplBase(RedisStoreBuilder builder, LDLogger logger) { port, (int) builder.connectTimeout.toMillis(), (int) builder.socketTimeout.toMillis(), + username, password, database, null, // clientName diff --git a/lib/java-server-sdk-redis-store/src/main/java/com/launchdarkly/sdk/server/integrations/RedisURIComponents.java b/lib/java-server-sdk-redis-store/src/main/java/com/launchdarkly/sdk/server/integrations/RedisURIComponents.java index 3c39ccc..3c44a74 100644 --- a/lib/java-server-sdk-redis-store/src/main/java/com/launchdarkly/sdk/server/integrations/RedisURIComponents.java +++ b/lib/java-server-sdk-redis-store/src/main/java/com/launchdarkly/sdk/server/integrations/RedisURIComponents.java @@ -8,6 +8,39 @@ * that class doesn't exist in the same location in both versions. */ abstract class RedisURIComponents { + /** + * Extracts the username from a Redis URI. + *

+ * Supports both formats: + *

    + *
  • {@code redis://USERNAME:PASSWORD@host:port} - returns USERNAME
  • + *
  • {@code redis://:PASSWORD@host:port} - returns null (password-only, legacy format)
  • + *
+ * + * @param uri the Redis URI + * @return the username, or null if not specified or empty + */ + static String getUsername(URI uri) { + if (uri.getUserInfo() == null) { + return null; + } + String[] parts = uri.getUserInfo().split(":", 2); + // If the username part is empty (e.g., ":password"), return null + return (parts.length > 0 && !parts[0].isEmpty()) ? parts[0] : null; + } + + /** + * Extracts the password from a Redis URI. + *

+ * Supports both formats: + *

    + *
  • {@code redis://USERNAME:PASSWORD@host:port} - returns PASSWORD
  • + *
  • {@code redis://:PASSWORD@host:port} - returns PASSWORD (legacy format)
  • + *
+ * + * @param uri the Redis URI + * @return the password, or null if not specified + */ static String getPassword(URI uri) { if (uri.getUserInfo() == null) { return null; @@ -16,6 +49,12 @@ static String getPassword(URI uri) { return parts.length < 2 ? null : parts[1]; } + /** + * Extracts the database index from a Redis URI. + * + * @param uri the Redis URI (e.g., {@code redis://host:port/2}) + * @return the database index, or 0 if not specified + */ static int getDBIndex(URI uri) { String[] parts = uri.getPath().split("/", 2); if (parts.length < 2 || parts[1].isEmpty()) { diff --git a/lib/java-server-sdk-redis-store/src/test/java/com/launchdarkly/sdk/server/integrations/RedisBigSegmentStoreImplTest.java b/lib/java-server-sdk-redis-store/src/test/java/com/launchdarkly/sdk/server/integrations/RedisBigSegmentStoreImplTest.java index 0ece4be..eff31e3 100644 --- a/lib/java-server-sdk-redis-store/src/test/java/com/launchdarkly/sdk/server/integrations/RedisBigSegmentStoreImplTest.java +++ b/lib/java-server-sdk-redis-store/src/test/java/com/launchdarkly/sdk/server/integrations/RedisBigSegmentStoreImplTest.java @@ -17,7 +17,7 @@ protected ComponentConfigurer makeStore(String prefix) { @Override protected void clearData(String prefix) { prefix = prefix == null || prefix.isEmpty() ? RedisStoreBuilder.DEFAULT_PREFIX : prefix; - try (Jedis client = new Jedis("localhost")) { + try (Jedis client = new Jedis("localhost", 6379)) { for (String key : client.keys(prefix + ":*")) { client.del(key); } @@ -26,7 +26,7 @@ protected void clearData(String prefix) { @Override protected void setMetadata(String prefix, BigSegmentStoreTypes.StoreMetadata storeMetadata) { - try (Jedis client = new Jedis("localhost")) { + try (Jedis client = new Jedis("localhost", 6379)) { client.set(prefix + ":big_segments_synchronized_on", storeMetadata != null ? Long.toString(storeMetadata.getLastUpToDate()) : ""); } @@ -37,7 +37,7 @@ protected void setSegments(String prefix, String userHashKey, Iterable includedSegmentRefs, Iterable excludedSegmentRefs) { - try (Jedis client = new Jedis("localhost")) { + try (Jedis client = new Jedis("localhost", 6379)) { String includeKey = prefix + ":big_segment_include:" + userHashKey; String excludeKey = prefix + ":big_segment_exclude:" + userHashKey; for (String includedSegmentRef : includedSegmentRefs) { diff --git a/lib/java-server-sdk-redis-store/src/test/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImplTest.java b/lib/java-server-sdk-redis-store/src/test/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImplTest.java index 63e7f08..ef82b77 100644 --- a/lib/java-server-sdk-redis-store/src/test/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImplTest.java +++ b/lib/java-server-sdk-redis-store/src/test/java/com/launchdarkly/sdk/server/integrations/RedisDataStoreImplTest.java @@ -20,7 +20,7 @@ protected ComponentConfigurer buildStore(String prefix) { @Override protected void clearAllData() { - try (Jedis client = new Jedis("localhost")) { + try (Jedis client = new Jedis("localhost", 6379)) { client.flushDB(); } } diff --git a/lib/java-server-sdk-redis-store/src/test/java/com/launchdarkly/sdk/server/integrations/RedisURIComponentsTest.java b/lib/java-server-sdk-redis-store/src/test/java/com/launchdarkly/sdk/server/integrations/RedisURIComponentsTest.java index daaca7f..d316b28 100644 --- a/lib/java-server-sdk-redis-store/src/test/java/com/launchdarkly/sdk/server/integrations/RedisURIComponentsTest.java +++ b/lib/java-server-sdk-redis-store/src/test/java/com/launchdarkly/sdk/server/integrations/RedisURIComponentsTest.java @@ -8,6 +8,26 @@ import static org.junit.Assert.assertNull; public class RedisURIComponentsTest { + @Test + public void getUsernameForURIWithoutUserInfo() { + assertNull(RedisURIComponents.getUsername(URI.create("redis://hostname:6379"))); + } + + @Test + public void getUsernameForURIWithUsernameAndNoPassword() { + assertEquals("username", RedisURIComponents.getUsername(URI.create("redis://username@hostname:6379"))); + } + + @Test + public void getUsernameForURIWithUsernameAndPassword() { + assertEquals("username", RedisURIComponents.getUsername(URI.create("redis://username:secret@hostname:6379"))); + } + + @Test + public void getUsernameForURIWithPasswordAndNoUsername() { + assertNull(RedisURIComponents.getUsername(URI.create("redis://:secret@hostname:6379"))); + } + @Test public void getPasswordForURIWithoutUserInfo() { assertNull(RedisURIComponents.getPassword(URI.create("redis://hostname:6379")));