diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d71aaa..c4763ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Changelog +Unreleased +- Added a Redis username setting for ACL authentication, used by the stand-alone, Sentinel and Cluster backends +- Updated the bundled credis library + 6.0.1 - 2026-09-21 - Fixed the Redis cluster backend reporting a fatal error instead of the configuration problem when the configured hosts and ports do not match, or when the cluster cannot be reached diff --git a/Queue/Backend/Redis.php b/Queue/Backend/Redis.php index b64e6a3..f8ac02a 100644 --- a/Queue/Backend/Redis.php +++ b/Queue/Backend/Redis.php @@ -263,8 +263,9 @@ protected function connect() $this->redis = new \Redis(); $success = $this->redis->connect($this->host, $this->port, $this->timeout, null, 100); + $auth = $this->username ? [$this->username, $this->password] : $this->password; if ($success && !empty($this->password)) { - $success = $this->redis->auth([$this->username, $this->password]); + $success = $this->redis->auth($auth); } if (!empty($this->database) || 0 === $this->database) { diff --git a/Queue/Backend/RedisCluster.php b/Queue/Backend/RedisCluster.php index 738aa5e..128d8d3 100644 --- a/Queue/Backend/RedisCluster.php +++ b/Queue/Backend/RedisCluster.php @@ -308,8 +308,10 @@ protected function connect() $hostsPorts = array_map(fn($host, $port): string => "$host:$port", $hosts, $ports); + $auth = $this->username ? [$this->username, $this->password] : $this->password; + try { - $this->redis = new \RedisCluster(null, $hostsPorts, $this->timeout, $this->timeout, true, $this->password); + $this->redis = new \RedisCluster(null, $hostsPorts, $this->timeout, $this->timeout, true, $auth); return true; } catch (Exception $e) { throw new Exception('Could not connect to redis cluster: ' . $e->getMessage()); @@ -322,7 +324,7 @@ public function setConfig( $timeout, #[\SensitiveParameter] $password, - $username = null, + $username = null ) { $this->disconnect(); diff --git a/Queue/Backend/Sentinel.php b/Queue/Backend/Sentinel.php index 778ac89..e3976da 100644 --- a/Queue/Backend/Sentinel.php +++ b/Queue/Backend/Sentinel.php @@ -42,7 +42,7 @@ protected function connect() $configuredClient->forceStandalone(); $configuredClient->connect(); if ($this->usePasswordForSentinelInstances && !empty($this->password)) { - $configuredClient->auth($this->password); + $configuredClient->auth($this->password, $this->username); } $configuredSentinel = new \Credis_Sentinel($configuredClient); $master = $configuredSentinel->getMasterAddressByName($this->masterName); diff --git a/lang/en.json b/lang/en.json index 9cc70fe..2693cdb 100644 --- a/lang/en.json +++ b/lang/en.json @@ -16,7 +16,7 @@ "AvailableRedisBackendTypeStandAlone": "Stand-alone", "AvailableRedisBackendTypeSentinel": "Sentinel", "AvailableRedisBackendTypeCluster": "Cluster", - "RedisUsernameFieldTitle": "Redis Username", + "RedisUsernameFieldTitle": "Redis username", "RedisUsernameFieldHelp": "Username for Redis ACL authentication. Leave empty if not used.", "RedisPasswordFieldTitle": "Redis password", "RedisPasswordFieldHelp": "Password set on the Redis server, if any. Redis can be instructed to require a password before allowing clients to execute commands.", diff --git a/tests/Integration/Queue/Backend/RedisTest.php b/tests/Integration/Queue/Backend/RedisTest.php index 5066248..1fc4b4c 100644 --- a/tests/Integration/Queue/Backend/RedisTest.php +++ b/tests/Integration/Queue/Backend/RedisTest.php @@ -347,29 +347,31 @@ public function test_checkConnectionWithUsernameShouldConnectIfCorrect(): void private function createRedisPassword($password, $username = null) { if (!empty($username)) { - $this->createAdminConnection()->rawcommand('ACL', 'SETUSER', $username, 'on', '>' . $password, '~*', '&*', '+@all'); + $this->createAdminConnection()->rawCommand('ACL', 'SETUSER', $username, 'on', '>' . $password, '~*', '&*', '+@all'); } else { - $this->createAdminConnection()->rawcommand('CONFIG', 'SET', 'requirepass', $password); + $this->createAdminConnection()->rawCommand('CONFIG', 'SET', 'requirepass', $password); } } private function removeRedisPassword($username = null, $requirepass = null): void { if (empty($username)) { - $this->createAdminConnection($requirepass)->rawcommand('CONFIG', 'SET', 'requirepass', ''); + $this->createAdminConnection($requirepass)->rawCommand('CONFIG', 'SET', 'requirepass', ''); } else { $this->createAdminConnection()->rawCommand('ACL', 'DELUSER', $username); } } /** - * Admin connection used only to set up/tear down auth state for these tests. This must - * always target the real Redis master directly (127.0.0.1:6379) and not sentinel. + * Admin connection used only to set up/tear down auth state for these tests. It must + * always target the real Redis master directly, never a sentinel. */ private function createAdminConnection($requirepass = null) { + [$host, $port] = $this->getRedisMasterHostAndPort(); + $connection = new \Redis(); - $connection->connect('127.0.0.1', 6379, 0.2); + $connection->connect($host, $port, 0.2); if (!empty($requirepass)) { $connection->auth($requirepass); @@ -378,6 +380,16 @@ private function createAdminConnection($requirepass = null) return $connection; } + /** + * @return array{0: string, 1: int} + */ + protected function getRedisMasterHostAndPort(): array + { + $settings = Factory::getSettings(); + + return [$settings->redisHost->getValue(), (int) $settings->redisPort->getValue()]; + } + public function test_checkConnectionDetails_shouldNotFailIfConnectionDataIsCorrect() { $success = $this->createRedisBackend()->testConnection(); diff --git a/tests/Integration/Queue/Backend/SentinelTest.php b/tests/Integration/Queue/Backend/SentinelTest.php index a7f3ca1..4071ce0 100644 --- a/tests/Integration/Queue/Backend/SentinelTest.php +++ b/tests/Integration/Queue/Backend/SentinelTest.php @@ -22,6 +22,11 @@ */ class SentinelTest extends RedisTest { + private const SENTINEL_HOST = '127.0.0.1'; + private const SENTINEL_PORT = 26379; + private const ACL_USERNAME = 'MyUser'; + private const ACL_PASSWORD = 'MySecret'; + public function tearDown(): void { Config::getInstance()->QueuedTracking = []; @@ -74,4 +79,119 @@ public function test_connect_ShouldThrowException_IfNotExactSameHostAndPortNumbe $sentinel = Factory::makeBackendFromSettings($settings); $sentinel->get('test'); } + + public function test_connect_shouldAuthenticateWithUsername_OnMaster() + { + $master = $this->createAdminClientForMaster(); + $this->createAclUser($master); + + try { + $backend = $this->makeBackendWithCredentials(self::ACL_USERNAME, self::ACL_PASSWORD, false); + + $this->assertTrue($backend->testConnection()); + $this->assertSame(self::ACL_USERNAME, $backend->getConnection()->rawCommand('ACL', ['WHOAMI'])); + } finally { + $this->deleteAclUser($master); + } + } + + public function test_connect_shouldFail_IfUsernameIsWrong_OnMaster() + { + $master = $this->createAdminClientForMaster(); + $this->createAclUser($master); + + try { + $backend = $this->makeBackendWithCredentials('OtherUser', self::ACL_PASSWORD, false); + + $this->assertFalse($backend->testConnection()); + } finally { + $this->deleteAclUser($master); + } + } + + public function test_connect_shouldAuthenticateWithUsername_OnSentinelAndMaster() + { + $master = $this->createAdminClientForMaster(); + $sentinel = $this->createAdminClientForSentinel(); + $this->createAclUser($master); + $this->createAclUser($sentinel); + // Only MyUser can talk to the sentinel now, so a connect without the username must fail + $sentinel->rawCommand('ACL', ['SETUSER', 'default', 'off']); + + try { + $backend = $this->makeBackendWithCredentials(self::ACL_USERNAME, self::ACL_PASSWORD, true); + + $this->assertTrue($backend->testConnection()); + $this->assertSame(self::ACL_USERNAME, $backend->getConnection()->rawCommand('ACL', ['WHOAMI'])); + } finally { + $sentinel->rawCommand('ACL', ['SETUSER', 'default', 'on']); + $this->deleteAclUser($sentinel); + $this->deleteAclUser($master); + } + } + + public function test_connect_shouldFail_IfUsernameIsWrong_OnSentinel() + { + $master = $this->createAdminClientForMaster(); + $sentinel = $this->createAdminClientForSentinel(); + $this->createAclUser($master); + $this->createAclUser($sentinel); + $sentinel->rawCommand('ACL', ['SETUSER', 'default', 'off']); + + try { + $backend = $this->makeBackendWithCredentials('OtherUser', self::ACL_PASSWORD, true); + + $this->assertFalse($backend->testConnection()); + } finally { + $sentinel->rawCommand('ACL', ['SETUSER', 'default', 'on']); + $this->deleteAclUser($sentinel); + $this->deleteAclUser($master); + } + } + + private function makeBackendWithCredentials(string $username, string $password, bool $authOnSentinel): Sentinel + { + $settings = Factory::getSettings(); + + $this->enableRedisSentinel(); + $settings->redisHost->setValue(self::SENTINEL_HOST); + $settings->redisPort->setValue((string) self::SENTINEL_PORT); + $settings->redisUsername->setValue($username); + $settings->redisPassword->setValue($password); + $settings->usePasswordForSentinelInstances->setValue($authOnSentinel); + + return Factory::makeBackendFromSettings($settings); + } + + private function createAdminClientForSentinel(): \Credis_Client + { + $client = new \Credis_Client(self::SENTINEL_HOST, self::SENTINEL_PORT); + $client->forceStandalone(); + + return $client; + } + + private function createAdminClientForMaster(): \Credis_Client + { + [$host, $port] = $this->getRedisMasterHostAndPort(); + + return new \Credis_Client($host, $port); + } + + protected function getRedisMasterHostAndPort(): array + { + $address = (new \Credis_Sentinel($this->createAdminClientForSentinel()))->getMasterAddressByName('mymaster'); + + return [$address[0], (int) $address[1]]; + } + + private function createAclUser(\Credis_Client $client): void + { + $client->rawCommand('ACL', ['SETUSER', self::ACL_USERNAME, 'reset', 'on', '>' . self::ACL_PASSWORD, '~*', '&*', '+@all']); + } + + private function deleteAclUser(\Credis_Client $client): void + { + $client->rawCommand('ACL', ['DELUSER', self::ACL_USERNAME]); + } }