diff --git a/packages/queue/docker-compose.yml b/packages/queue/docker-compose.yml index 6e42310bd..c135c3f89 100644 --- a/packages/queue/docker-compose.yml +++ b/packages/queue/docker-compose.yml @@ -9,6 +9,22 @@ services: timeout: 3s retries: 15 + redis-auth: + image: redis:alpine + command: + - sh + - -c + - | + printf 'requirepass secretpw\nuser worker on >workerpw ~* &* +@all\n' > /tmp/redis.conf + exec redis-server /tmp/redis.conf + ports: + - "16380:6379" + healthcheck: + test: ["CMD", "redis-cli", "-a", "secretpw", "--no-auth-warning", "ping"] + interval: 2s + timeout: 3s + retries: 15 + nats: image: nats:2.11-alpine command: ["-js", "-m", "8222"] diff --git a/packages/queue/phpunit.xml b/packages/queue/phpunit.xml index ec50e1bee..8f620047b 100644 --- a/packages/queue/phpunit.xml +++ b/packages/queue/phpunit.xml @@ -22,6 +22,7 @@ ./tests/Queue/E2E/Adapter/NatsPoolTest.php ./tests/Queue/E2E/Adapter/KedaTest.php ./tests/Queue/E2E/Adapter/PoolTest.php + ./tests/Queue/E2E/Adapter/RedisAuthTest.php ./tests/Queue/E2E/Adapter/RedisConnectionRecoveryTest.php ./tests/Queue/E2E/Adapter/SwooleTest.php ./tests/Queue/E2E/Adapter/SwooleRestartTest.php diff --git a/packages/queue/src/Queue/Connection/Redis.php b/packages/queue/src/Queue/Connection/Redis.php index 68cbf4945..9ec680c5b 100644 --- a/packages/queue/src/Queue/Connection/Redis.php +++ b/packages/queue/src/Queue/Connection/Redis.php @@ -252,6 +252,11 @@ protected function getRedis(): \Redis try { $redis->connect($this->host, $this->port, $connectTimeout); + if ($this->password !== null && $this->password !== '') { + $hasUser = $this->user !== null && $this->user !== ''; + $redis->auth($hasUser ? [$this->user, $this->password] : $this->password); + } + if ($this->readTimeout >= 0) { $redis->setOption(\Redis::OPT_READ_TIMEOUT, $this->readTimeout); } diff --git a/packages/queue/src/Queue/Connection/RedisCluster.php b/packages/queue/src/Queue/Connection/RedisCluster.php index b539d663a..6ccc11ac5 100644 --- a/packages/queue/src/Queue/Connection/RedisCluster.php +++ b/packages/queue/src/Queue/Connection/RedisCluster.php @@ -11,7 +11,7 @@ class RedisCluster implements Connection protected const int CONNECT_MAX_BACKOFF_MS = 3_000; protected ?\RedisCluster $redis = null; - public function __construct(protected array $seeds, protected float $connectTimeout = -1, protected float $readTimeout = -1) {} + public function __construct(protected array $seeds, protected float $connectTimeout = -1, protected float $readTimeout = -1, protected ?string $user = null, protected ?string $password = null) {} public function rightPopLeftPushArray(string $queue, string $destination, int $timeout): array|false { @@ -201,7 +201,12 @@ protected function getRedis(): \RedisCluster for ($attempt = 1; $attempt <= self::CONNECT_MAX_ATTEMPTS; $attempt++) { try { - $this->redis = new \RedisCluster(null, $this->seeds, $connectTimeout, $readTimeout); + $auth = match (true) { + $this->password === null || $this->password === '' => null, + $this->user !== null && $this->user !== '' => [$this->user, $this->password], + default => $this->password, + }; + $this->redis = new \RedisCluster(null, $this->seeds, $connectTimeout, $readTimeout, false, $auth); return $this->redis; } catch (\RedisClusterException $e) { if ($attempt === self::CONNECT_MAX_ATTEMPTS) { diff --git a/packages/queue/tests/Queue/E2E/Adapter/RedisAuthTest.php b/packages/queue/tests/Queue/E2E/Adapter/RedisAuthTest.php new file mode 100644 index 000000000..4df5768a7 --- /dev/null +++ b/packages/queue/tests/Queue/E2E/Adapter/RedisAuthTest.php @@ -0,0 +1,73 @@ +assertRoundTrip(new Redis(self::HOST, self::PORT, null, self::PASSWORD)); + } + + public function testUserAndPasswordAuthenticateAclUser(): void + { + $this->assertRoundTrip(new Redis(self::HOST, self::PORT, self::USER, self::USER_PASSWORD)); + } + + public function testMissingCredentialsAreRejected(): void + { + $this->assertRejected(new Redis(self::HOST, self::PORT)); + } + + public function testWrongPasswordIsRejected(): void + { + $this->assertRejected(new Redis(self::HOST, self::PORT, self::USER, 'not-the-password')); + } + + /** + * A credentialed connection can write a job and read it back. + */ + private function assertRoundTrip(Redis $connection): void + { + $key = 'auth-test-' . uniqid(); + + $this->assertTrue($connection->rightPush($key, 'payload')); + $this->assertSame('payload', $connection->rightPop($key, 1)); + } + + /** + * The observable contract of rejected credentials: the push throws, and the + * server holds nothing for that key when a trusted connection looks. + */ + private function assertRejected(Redis $rejected): void + { + $key = 'auth-test-' . uniqid(); + $thrown = null; + + try { + $rejected->rightPush($key, 'payload'); + } catch (\RedisException $e) { + $thrown = $e; + } + + $trusted = new Redis(self::HOST, self::PORT, null, self::PASSWORD); + + $this->assertInstanceOf(\RedisException::class, $thrown, 'Push with rejected credentials must throw'); + $this->assertSame(0, $trusted->listSize($key), 'Rejected push must not reach the server'); + } +}