diff --git a/README.md b/README.md index 94416ff..45ce55c 100644 --- a/README.md +++ b/README.md @@ -336,9 +336,17 @@ $redis = new Clue\React\Redis\RedisClient('localhost'); $redis = new Clue\React\Redis\RedisClient('redis://localhost:6379'); ``` -Redis supports password-based authentication (`AUTH` command). Note that Redis' -authentication mechanism does not employ a username, so you can pass the -password `h@llo` URL-encoded (percent-encoded) as part of the URI like this: +Starting with Redis 6, you can use ACLs and authenticate with both a +username and a password. Any URI containing user:pass@ will now invoke +the AUTH command under the hood. For example: + +```php +// Authenticate with username and password (Redis 6+ ACL) +$redis = new Clue\React\Redis\RedisClient('redis://mauricio:mypass@localhost:6379'); +``` + +If you omit the username (or use an empty userinfo part), the client will fall back +to the legacy AUTH behavior: ```php // all forms are equivalent @@ -489,7 +497,15 @@ that eventually *fulfills* with its *results* on success or *rejects* with an #### callAsync() The `callAsync(string $command, string|int|float ...$args): PromiseInterface` method can be used to -invoke a Redis command. +invoke a Redis command. Note that the `AUTH` command now supports **two** arguments for Redis 6+ ACL mode: + +```php +// authenticate with username + password (Redis 6+) +$redis->callAsync('AUTH', 'alice', 'hunter2'); + +// legacy password-only mode +$redis->callAsync('AUTH', 'hunter2'); +``` For example, the [`GET` command](https://redis.io/commands/get) can be invoked like this: diff --git a/src/Io/Factory.php b/src/Io/Factory.php index 5a3d692..6602dca 100644 --- a/src/Io/Factory.php +++ b/src/Io/Factory.php @@ -96,9 +96,23 @@ public function createClient(string $uri): PromiseInterface // use `?password=secret` query or `user:secret@host` password form URL if (isset($args['password']) || isset($parts['pass'])) { $pass = $args['password'] ?? rawurldecode($parts['pass']); // @phpstan-ignore-line + + $user = null; + if (isset($args['username']) || isset($parts['user'])) { + $user = $args['username'] ?? rawurldecode($parts['user']); // @phpstan-ignore-line + } + \assert(\is_string($pass)); - $promise = $promise->then(function (StreamingClient $redis) use ($pass, $uri) { - return $redis->callAsync('auth', $pass)->then( + \assert($user === null || \is_string($user)); + + $promise = $promise->then(function (StreamingClient $redis) use ($user, $pass, $uri) { + if ($user !== null) { + $authPromise = $redis->callAsync('auth', $user, $pass); + } else { + $authPromise = $redis->callAsync('auth', $pass); + } + + return $authPromise->then( function () use ($redis) { return $redis; }, @@ -211,4 +225,4 @@ function (\Throwable $e) use ($redis, $uri) { // variable assignment needed for legacy PHPStan on PHP 7.1 only return $ret; } -} +} \ No newline at end of file diff --git a/tests/Io/FactoryStreamingClientTest.php b/tests/Io/FactoryStreamingClientTest.php index 4a5ee19..5c7bb3e 100644 --- a/tests/Io/FactoryStreamingClientTest.php +++ b/tests/Io/FactoryStreamingClientTest.php @@ -110,7 +110,7 @@ public function testWillWriteSelectCommandIfTargetContainsDbQueryParameter(): vo public function testWillWriteAuthCommandIfRedisUriContainsUserInfo(): void { $stream = $this->createMock(ConnectionInterface::class); - $stream->expects($this->once())->method('write')->with("*2\r\n$4\r\nauth\r\n$5\r\nworld\r\n"); + $stream->expects($this->once())->method('write')->with("*3\r\n$4\r\nauth\r\n$5\r\nhello\r\n$5\r\nworld\r\n"); $loop = $this->createMock(LoopInterface::class); $loop->expects($this->once())->method('addTimer'); @@ -124,7 +124,7 @@ public function testWillWriteAuthCommandIfRedisUriContainsUserInfo(): void public function testWillWriteAuthCommandIfRedisUriContainsEncodedUserInfo(): void { $stream = $this->createMock(ConnectionInterface::class); - $stream->expects($this->once())->method('write')->with("*2\r\n$4\r\nauth\r\n$5\r\nh@llo\r\n"); + $stream->expects($this->once())->method('write')->with("*3\r\n$4\r\nauth\r\n$0\r\n\r\n$5\r\nh@llo\r\n"); $loop = $this->createMock(LoopInterface::class); $loop->expects($this->once())->method('addTimer'); @@ -166,7 +166,7 @@ public function testWillWriteAuthCommandIfTargetContainsEncodedPasswordQueryPara public function testWillWriteAuthCommandIfRedissUriContainsUserInfo(): void { $stream = $this->createMock(ConnectionInterface::class); - $stream->expects($this->once())->method('write')->with("*2\r\n$4\r\nauth\r\n$5\r\nworld\r\n"); + $stream->expects($this->once())->method('write')->with("*3\r\n$4\r\nauth\r\n$5\r\nhello\r\n$5\r\nworld\r\n"); $loop = $this->createMock(LoopInterface::class); $loop->expects($this->once())->method('addTimer'); @@ -203,7 +203,7 @@ public function testWillNotWriteAnyCommandIfRedisUnixUriContainsNoPasswordOrDb() public function testWillWriteAuthCommandIfRedisUnixUriContainsUserInfo(): void { $stream = $this->createMock(ConnectionInterface::class); - $stream->expects($this->once())->method('write')->with("*2\r\n$4\r\nauth\r\n$5\r\nworld\r\n"); + $stream->expects($this->once())->method('write')->with("*3\r\n$4\r\nauth\r\n$5\r\nhello\r\n$5\r\nworld\r\n"); $loop = $this->createMock(LoopInterface::class); $loop->expects($this->once())->method('addTimer'); @@ -218,7 +218,7 @@ public function testWillResolveWhenAuthCommandReceivesOkResponseIfRedisUriContai { $dataHandler = null; $stream = $this->createMock(ConnectionInterface::class); - $stream->expects($this->once())->method('write')->with("*2\r\n$4\r\nauth\r\n$5\r\nworld\r\n"); + $stream->expects($this->once())->method('write')->with("*3\r\n$4\r\nauth\r\n$0\r\n\r\n$5\r\nworld\r\n"); $stream->expects($this->exactly(2))->method('on')->withConsecutive( ['data', $this->callback(function ($arg) use (&$dataHandler) { $dataHandler = $arg; @@ -240,7 +240,7 @@ public function testWillRejectAndCloseAutomaticallyWhenAuthCommandReceivesErrorR { $dataHandler = null; $stream = $this->createMock(ConnectionInterface::class); - $stream->expects($this->once())->method('write')->with("*2\r\n$4\r\nauth\r\n$5\r\nworld\r\n"); + $stream->expects($this->once())->method('write')->with("*3\r\n$4\r\nauth\r\n$0\r\n\r\n$5\r\nworld\r\n"); $stream->expects($this->once())->method('close'); $stream->expects($this->exactly(2))->method('on')->withConsecutive( ['data', $this->callback(function ($arg) use (&$dataHandler) { @@ -277,7 +277,7 @@ public function testWillRejectAndCloseAutomaticallyWhenConnectionIsClosedWhileWa { $closeHandler = null; $stream = $this->createMock(ConnectionInterface::class); - $stream->expects($this->once())->method('write')->with("*2\r\n$4\r\nauth\r\n$5\r\nworld\r\n"); + $stream->expects($this->once())->method('write')->with("*3\r\n$4\r\nauth\r\n$0\r\n\r\n$5\r\nworld\r\n"); $stream->expects($this->once())->method('close'); $stream->expects($this->exactly(2))->method('on')->withConsecutive( ['data', $this->anything()], @@ -708,4 +708,55 @@ public function testCreateClientWillCancelTimerWhenConnectionRejects(): void $deferred->reject(new \RuntimeException()); } + + public function testWillWriteAuthCommandIfTargetContainsUsernameAndPasswordQueryParameter(): void + { + $stream = $this->createMock(ConnectionInterface::class); + $stream->expects($this->once())->method('write')->with("*3\r\n$4\r\nauth\r\n$5\r\nhello\r\n$5\r\nworld\r\n"); + + $loop = $this->createMock(LoopInterface::class); + $loop->expects($this->once())->method('addTimer'); + assert($loop instanceof LoopInterface); + Loop::set($loop); + + $this->connector + ->expects($this->once()) + ->method('connect') + ->with('example.com:6379') + ->willReturn(resolve($stream)); + + $this->factory->createClient( + 'redis://example.com?username=hello&password=world' + ); + } + + public function testWillResolveWhenAuthCommandReceivesOkResponseWithUsernameAndPassword(): void + { + $dataHandler = null; + $stream = $this->createMock(ConnectionInterface::class); + $stream->expects($this->once())->method('write')->with("*3\r\n$4\r\nauth\r\n$4\r\nuser\r\n$4\r\npass\r\n"); + $stream->expects($this->exactly(2))->method('on')->withConsecutive( + ['data', $this->callback(function ($cb) use (&$dataHandler) { + $dataHandler = $cb; + return true; + })], + ['close', $this->anything()] + ); + + $this->connector + ->expects($this->once()) + ->method('connect') + ->willReturn(resolve($stream)); + + $promise = $this->factory->createClient( + 'redis://user:pass@example.com' + ); + + $this->assertTrue(is_callable($dataHandler)); + $dataHandler("+OK\r\n"); + + $promise->then($this->expectCallableOnceWith( + $this->isInstanceOf(StreamingClient::class) + )); + } }