Skip to content

Add Redis AUTH user/password support with updated docs and tests #178

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: 3.x
Choose a base branch
from
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
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <user> <pass> 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 <pass> behavior:

```php
// all forms are equivalent
Expand Down Expand Up @@ -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<mixed>` 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:
Expand Down
20 changes: 17 additions & 3 deletions src/Io/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
Expand Down Expand Up @@ -211,4 +225,4 @@ function (\Throwable $e) use ($redis, $uri) {
// variable assignment needed for legacy PHPStan on PHP 7.1 only
return $ret;
}
}
}
65 changes: 58 additions & 7 deletions tests/Io/FactoryStreamingClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand All @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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()],
Expand Down Expand Up @@ -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:[email protected]'
);

$this->assertTrue(is_callable($dataHandler));
$dataHandler("+OK\r\n");

$promise->then($this->expectCallableOnceWith(
$this->isInstanceOf(StreamingClient::class)
));
}
}