Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
3 changes: 2 additions & 1 deletion Queue/Backend/Redis.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions Queue/Backend/RedisCluster.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -322,7 +324,7 @@ public function setConfig(
$timeout,
#[\SensitiveParameter]
$password,
$username = null,
$username = null
) {
$this->disconnect();

Expand Down
2 changes: 1 addition & 1 deletion Queue/Backend/Sentinel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
24 changes: 18 additions & 6 deletions tests/Integration/Queue/Backend/RedisTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
Expand Down
120 changes: 120 additions & 0 deletions tests/Integration/Queue/Backend/SentinelTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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]);
}
}
Loading