diff --git a/src/Commands/AuthenticateCommand.php b/src/Commands/AuthenticateCommand.php index b48351f..1fac31c 100644 --- a/src/Commands/AuthenticateCommand.php +++ b/src/Commands/AuthenticateCommand.php @@ -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 { @@ -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 | @@ -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; } } diff --git a/src/Io/Parser.php b/src/Io/Parser.php index c3006e9..4c8af03 100644 --- a/src/Io/Parser.php +++ b/src/Io/Parser.php @@ -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; @@ -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 @@ -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()); diff --git a/tests/Commands/AuthenticateCommandTest.php b/tests/Commands/AuthenticateCommandTest.php index a1b9077..be2fd81 100644 --- a/tests/Commands/AuthenticateCommandTest.php +++ b/tests/Commands/AuthenticateCommandTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use React\Mysql\Commands\AuthenticateCommand; +use React\Mysql\Io\Buffer; class AuthenticateCommandTest extends TestCase { @@ -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()); + } } diff --git a/tests/Io/ParserTest.php b/tests/Io/ParserTest.php index 971ef35..3218534 100644 --- a/tests/Io/ParserTest.php +++ b/tests/Io/ParserTest.php @@ -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; @@ -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();