Skip to content
Merged
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
37 changes: 28 additions & 9 deletions src/Commands/AuthenticateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

/**
* @internal
* @link https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::HandshakeResponse
* @link https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase_packets_protocol_handshake_response.html#sect_protocol_connection_phase_packets_protocol_handshake_response41
*/
class AuthenticateCommand extends AbstractCommand
{
Expand Down Expand Up @@ -73,8 +73,19 @@ public function getId()
return 0;
}

public function authenticatePacket($scramble, Buffer $buffer)
/**
* @param string $scramble
* @param ?string $authPlugin
* @param Buffer $buffer
* @return string
* @throws \UnexpectedValueException for unsupported authentication plugin
*/
public function authenticatePacket($scramble, $authPlugin, Buffer $buffer)
{
if ($authPlugin !== null && $authPlugin !== 'mysql_native_password') {
throw new \UnexpectedValueException('Unknown authentication plugin "' . addslashes($authPlugin) . '" requested by server');
}

$clientFlags = Constants::CLIENT_LONG_PASSWORD |
Constants::CLIENT_LONG_FLAG |
Constants::CLIENT_LOCAL_FILES |
Expand All @@ -84,20 +95,28 @@ public function authenticatePacket($scramble, Buffer $buffer)
Constants::CLIENT_SECURE_CONNECTION |
Constants::CLIENT_CONNECT_WITH_DB;

if ($authPlugin !== null) {
$clientFlags |= Constants::CLIENT_PLUGIN_AUTH;
}

return pack('VVc', $clientFlags, $this->maxPacketSize, $this->charsetNumber)
. "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
. $this->user . "\x00"
. $this->getAuthToken($scramble, $this->passwd, $buffer)
. $this->dbname . "\x00";
. $buffer->buildStringLen($this->authMysqlNativePassword($scramble))
. $this->dbname . "\x00"
. ($authPlugin !== null ? $authPlugin . "\0" : '');
}

public function getAuthToken($scramble, $password, Buffer $buffer)
/**
* @param string $scramble
* @return string
*/
private function authMysqlNativePassword($scramble)
{
if ($password === '') {
return "\x00";
if ($this->passwd === '') {
return '';
}
$token = \sha1($scramble . \sha1($hash1 = \sha1($password, true), true), true) ^ $hash1;

return $buffer->buildStringLen($token);
return \sha1($scramble . \sha1($hash1 = \sha1($this->passwd, true), true), true) ^ $hash1;
}
}
16 changes: 14 additions & 2 deletions src/Io/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ class Parser
*/
protected $executor;

/**
* @var ?string authentication plugin name, set if server capabilities include CLIENT_PLUGIN_AUTH
* @link https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase_authentication_methods.html
*/
private $authPlugin;

public function __construct(DuplexStreamInterface $stream, Executor $executor)
{
$this->stream = $stream;
Expand Down Expand Up @@ -227,7 +233,8 @@ private function parsePacket(Buffer $packet)
$packet->skip(1);

if ($this->connectOptions['ServerCaps'] & Constants::CLIENT_PLUGIN_AUTH) {
$packet->readStringNull(); // skip authentication plugin name
$this->authPlugin = $packet->readStringNull();
$this->debug('Authentication plugin: ' . $this->authPlugin);
}

// init completed, continue with sending AuthenticateCommand
Expand Down Expand Up @@ -403,7 +410,12 @@ protected function nextRequest($isHandshake = false)

if ($command instanceof AuthenticateCommand) {
$this->phase = self::PHASE_AUTH_SENT;
$this->sendPacket($command->authenticatePacket($this->scramble, $this->buffer));
try {
$this->sendPacket($command->authenticatePacket($this->scramble, $this->authPlugin, $this->buffer));
} catch (\UnexpectedValueException $e) {
$this->onError($e);
$this->stream->close();
}
} else {
$this->seq = 0;
$this->sendPacket($this->buffer->buildInt1($command->getId()) . $command->getSql());
Expand Down
42 changes: 42 additions & 0 deletions tests/Commands/AuthenticateCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use PHPUnit\Framework\TestCase;
use React\Mysql\Commands\AuthenticateCommand;
use React\Mysql\Io\Buffer;

class AuthenticateCommandTest extends TestCase
{
Expand All @@ -25,4 +26,45 @@ public function testCtorWithUnknownCharsetThrows()
}
new AuthenticateCommand('Alice', 'secret', '', 'utf16');
}

public function testAuthenticatePacketWithEmptyPassword()
{
$command = new AuthenticateCommand('root', '', 'test', 'utf8mb4');

$data = $command->authenticatePacket('scramble', null, new Buffer());

$this->assertEquals("\x8d\xa6\0\0\0\0\0\x01\x2d" . str_repeat("\0", 23) . "root\0" . "\0" . "test\0", $data);
}

public function testAuthenticatePacketWithMysqlNativePasswordAuthPluginAndEmptyPassword()
{
$command = new AuthenticateCommand('root', '', 'test', 'utf8mb4');

$data = $command->authenticatePacket('scramble', 'mysql_native_password', new Buffer());

$this->assertEquals("\x8d\xa6\x08\0\0\0\0\x01\x2d" . str_repeat("\0", 23) . "root\0" . "\0" . "test\0" . "mysql_native_password\0", $data);
}

public function testAuthenticatePacketWithSecretPassword()
{
$command = new AuthenticateCommand('root', 'secret', 'test', 'utf8mb4');

$data = $command->authenticatePacket('scramble', null, new Buffer());

$this->assertEquals("\x8d\xa6\0\0\0\0\0\x01\x2d" . str_repeat("\0", 23) . "root\0" . "\x14\x18\xd8\x8d\x74\x77\x2c\x27\x22\x89\xe1\xcd\xbc\x4b\x5f\x77\x08\x18\x3c\x6e\xba" . "test\0", $data);
}

public function testAuthenticatePacketWithUnknownAuthPluginThrows()
{
$command = new AuthenticateCommand('root', 'secret', 'test', 'utf8mb4');

if (method_exists($this, 'expectException')) {
$this->expectException('UnexpectedValueException');
$this->expectExceptionMessage('Unknown authentication plugin "mysql_old_password" requested by server');
} else {
// legacy PHPUnit < 5.2
$this->setExpectedException('UnexpectedValueException', 'Unknown authentication plugin "mysql_old_password" requested by server');
}
$command->authenticatePacket('scramble', 'mysql_old_password', new Buffer());
}
}
18 changes: 18 additions & 0 deletions tests/Io/ParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace React\Tests\Mysql\Io;

use React\Mysql\Commands\AuthenticateCommand;
use React\Mysql\Commands\QueryCommand;
use React\Mysql\Exception;
use React\Mysql\Io\Executor;
Expand Down Expand Up @@ -42,6 +43,23 @@ public function testClosingStreamEmitsErrorForCurrentCommand()
$this->assertEquals(defined('SOCKET_ECONNABORTED') ? SOCKET_ECONNABORTED : 103, $error->getCode());
}

public function testUnexpectedAuthPluginShouldEmitErrorOnAuthenticateCommandAndCloseStream()
{
$stream = new ThroughStream();
$stream->on('close', $this->expectCallableOnce());

$command = new AuthenticateCommand('root', '', 'test', 'utf8mb4');
$command->on('error', $this->expectCallableOnceWith(new \UnexpectedValueException('Unknown authentication plugin "caching_sha2_password" requested by server')));

$executor = new Executor();
$executor->enqueue($command);

$parser = new Parser($stream, $executor);
$parser->start();

$stream->write("\x49\0\0\0\x0a\x38\x2e\x34\x2e\x35\0\x5e\0\0\0\x08\x0c\x41\x44\x12\x5e\x69\x59\0\xff\xff\xff\x02\0\xff\xdf\x15\0\0\0\0\0\0\0\0\0\0\x3c\x2c\x5e\x54\x06\x04\x01\x61\x01\x20\x79\x1b\0\x63\x61\x63\x68\x69\x6e\x67\x5f\x73\x68\x61\x32\x5f\x70\x61\x73\x73\x77\x6f\x72\x64\0");
}

public function testUnexpectedErrorWithoutCurrentCommandWillBeIgnored()
{
$stream = new ThroughStream();
Expand Down