diff --git a/src/main/php/com/mongodb/Authentication.class.php b/src/main/php/com/mongodb/Authentication.class.php index e056627..24bcf6e 100755 --- a/src/main/php/com/mongodb/Authentication.class.php +++ b/src/main/php/com/mongodb/Authentication.class.php @@ -3,7 +3,9 @@ use com\mongodb\auth\{Mechanism, ScramSHA1, ScramSHA256}; use lang\IllegalArgumentException; +/** @test com.mongodb.unittest.AuthenticationTest */ abstract class Authentication { + const MECHANISMS= ['SCRAM-SHA-256', 'SCRAM-SHA-1']; /** * Returns an authentication by a given mechanism name @@ -17,4 +19,29 @@ public static function mechanism(string $name): Mechanism { default: throw new IllegalArgumentException('Unknown authentication mechanism '.$name); } } + + /** + * Negotiates one of the supported authentication mechansim from a list + * of given mechanisms. + * + * If SCRAM-SHA-256 is present in the list of mechanism, then it MUST be used as + * the default; otherwise, SCRAM-SHA-1 MUST be used as the default, regardless of + * whether SCRAM-SHA-1 is in the list. If saslSupportedMechs is not present in the + * handshake response for mechanism negotiation, then SCRAM-SHA-1 MUST be used + * + * @see https://github.com/mongodb/specifications/blob/master/source/auth/auth.rst#defaults + * @throws lang.IllegalArgumentException if none are supported + */ + public static function negotiate(array $mechanisms): Mechanism { + if (empty($mechanisms)) { + return self::mechanism('SCRAM-SHA-1'); + } else if ($supported= array_intersect(self::MECHANISMS, $mechanisms)) { + return self::mechanism(current($supported)); + } + + throw new IllegalArgumentException(sprintf( + 'None of the authentication mechanisms %s is supported', + implode(', ', $mechanisms) + )); + } } \ No newline at end of file diff --git a/src/main/php/com/mongodb/auth/Mechanism.class.php b/src/main/php/com/mongodb/auth/Mechanism.class.php index 68bcd6e..6157b46 100755 --- a/src/main/php/com/mongodb/auth/Mechanism.class.php +++ b/src/main/php/com/mongodb/auth/Mechanism.class.php @@ -1,5 +1,7 @@ nonce= function() { return base64_encode(random_bytes(24)); }; } + /** @return string */ + public function name() { return static::MECHANISM; } + /** * Use the given function to generate nonce values * diff --git a/src/main/php/com/mongodb/io/Connection.class.php b/src/main/php/com/mongodb/io/Connection.class.php index 40c0ca0..e6fbaa3 100755 --- a/src/main/php/com/mongodb/io/Connection.class.php +++ b/src/main/php/com/mongodb/io/Connection.class.php @@ -1,6 +1,6 @@ 1, + 'client' => [ + 'application' => ['name' => $options['params']['appName'] ?? $_SERVER['argv'][0] ?? 'php'], + 'driver' => ['name' => 'XP MongoDB Connectivity', 'version' => '1.0.0'], + 'os' => ['name' => php_uname('s'), 'type' => PHP_OS, 'architecture' => php_uname('m'), 'version' => php_uname('r')] + ] + ]; + + // If the optional field saslSupportedMechs is specified, the command also returns + // an array of SASL mechanisms used to create the specified user's credentials. + if (isset($options['user'])) { + $user= urldecode($options['user']); + $pass= urldecode($options['pass']); + $authSource= $options['params']['authSource'] ?? (isset($options['path']) ? ltrim($options['path'], '/') : 'admin'); + $hello['saslSupportedMechs']= "{$authSource}.{$user}"; + } else { + $authSource= null; + } + try { $reply= $this->send( self::OP_QUERY, "\x00\x00\x00\x00admin.\$cmd\x00\x00\x00\x00\x00\x01\x00\x00\x00", - [ - 'hello' => 1, - 'client' => [ - 'application' => ['name' => $options['params']['appName'] ?? $_SERVER['argv'][0] ?? 'php'], - 'driver' => ['name' => 'XP MongoDB Connectivity', 'version' => '1.0.0'], - 'os' => ['name' => php_uname('s'), 'type' => PHP_OS, 'architecture' => php_uname('m'), 'version' => php_uname('r')] - ] - ] + $hello ); } catch (ProtocolException $e) { throw new ConnectException('Server handshake failed @ '.$this->address(), $e); @@ -134,15 +147,11 @@ public function establish($options= [], $auth= null) { $this->server= ['$kind' => $kind] + $document; // Optionally, perform authentication - if (null === $auth) return; + if (null === $authSource) return; try { - $conversation= $auth->conversation( - urldecode($options['user']), - urldecode($options['pass']), - $options['params']['authSource'] ?? (isset($options['path']) ? ltrim($options['path'], '/') : 'admin') - ); - + $auth ?? $auth= Authentication::negotiate($document['saslSupportedMechs'] ?? []); + $conversation= $auth->conversation($user, $pass, $authSource); do { $result= $this->message($conversation->current(), null); if (0 === (int)$result['body']['ok']) { diff --git a/src/main/php/com/mongodb/io/Protocol.class.php b/src/main/php/com/mongodb/io/Protocol.class.php index 6fce63a..9d79ef1 100755 --- a/src/main/php/com/mongodb/io/Protocol.class.php +++ b/src/main/php/com/mongodb/io/Protocol.class.php @@ -13,7 +13,8 @@ * @test com.mongodb.unittest.ReplicaSetTest */ class Protocol { - private $options, $auth; + private $options; + protected $auth= null; protected $conn= []; public $nodes= null; public $readPreference; @@ -91,10 +92,11 @@ public function __construct($arg, $options= [], $bson= null, $dns= null) { } $this->readPreference= ['mode' => $this->options['params']['readPreference'] ?? 'primary']; - $this->auth= isset($this->options['user']) - ? Authentication::mechanism($this->options['params']['authMechanism'] ?? 'SCRAM-SHA-1') - : null - ; + + // Check if an authentication mechanism was explicitely selected + if ($mechanism= $this->options['params']['authMechanism'] ?? null) { + $this->auth= Authentication::mechanism($mechanism); + } } /** @return [:var] */ diff --git a/src/test/php/com/mongodb/unittest/AuthenticationTest.class.php b/src/test/php/com/mongodb/unittest/AuthenticationTest.class.php index 5047204..e78ed8c 100755 --- a/src/test/php/com/mongodb/unittest/AuthenticationTest.class.php +++ b/src/test/php/com/mongodb/unittest/AuthenticationTest.class.php @@ -3,17 +3,47 @@ use com\mongodb\Authentication; use com\mongodb\auth\Mechanism; use lang\IllegalArgumentException; -use test\{Assert, Expect, Test}; +use test\{Assert, Expect, Test, Values}; class AuthenticationTest { #[Test] - public function cram_sha_1() { + public function scram_sha_1() { Assert::instance(Mechanism::class, Authentication::mechanism('SCRAM-SHA-1')); } + #[Test] + public function scram_sha_256() { + Assert::instance(Mechanism::class, Authentication::mechanism('SCRAM-SHA-256')); + } + #[Test, Expect(IllegalArgumentException::class)] public function unknown() { Authentication::mechanism('unknown'); } + + #[Test, Values([[['SCRAM-SHA-1']], [['SCRAM-SHA-256']]])] + public function negotiate_only_option($supplied) { + Assert::equals($supplied[0], Authentication::negotiate($supplied)->name()); + } + + #[Test, Values([[['SCRAM-SHA-256', 'SCRAM-SHA-1']], [['SCRAM-SHA-1', 'SCRAM-SHA-256']]])] + public function negotiate_sha256_is_default_if_contained($supplied) { + Assert::equals('SCRAM-SHA-256', Authentication::negotiate($supplied)->name()); + } + + #[Test] + public function negotiate_with_unsupported_plain() { + Assert::equals('SCRAM-SHA-1', Authentication::negotiate(['PLAIN', 'SCRAM-SHA-1'])->name()); + } + + #[Test] + public function negotiate_returns_sha1_if_empty() { + Assert::equals('SCRAM-SHA-1', Authentication::negotiate([])->name()); + } + + #[Test, Expect(IllegalArgumentException::class)] + public function negotiate_none_supported() { + Authentication::negotiate(['PLAIN', 'AWS'])->name(); + } } \ No newline at end of file