diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 9985119d7..1a7c137e7 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -14,6 +14,9 @@ require __DIR__ . '/vendor/autoload.php'; $client = (new \OpenSearch\ClientBuilder()) ->setHosts(['https://localhost:9200']) ->setBasicAuthentication('admin', 'admin') // For testing only. Don't store credentials in code. + // or, if using AWS SigV4 authentication: + ->setSigV4Region('us-east-2') + ->setSigV4CredentialProvider(true) ->setSSLVerification(false) // For testing only. Use certificate for validation ->build(); diff --git a/composer.json b/composer.json index 5ab539c31..931c64fa8 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ }, "require-dev": { "ext-zip": "*", + "aws/aws-sdk-php": "^3.0", "friendsofphp/php-cs-fixer": "^3.0", "mockery/mockery": "^1.2", "phpstan/phpstan": "^1.7.15", @@ -29,7 +30,8 @@ "symfony/finder": "~4.0" }, "suggest": { - "monolog/monolog": "Allows for client-level logging and tracing" + "monolog/monolog": "Allows for client-level logging and tracing", + "aws/aws-sdk-php": "Required (^3.0.0) in order to use the SigV4 handler" }, "autoload": { "psr-4": { diff --git a/src/OpenSearch/ClientBuilder.php b/src/OpenSearch/ClientBuilder.php index a7028e475..3a0be0ba7 100644 --- a/src/OpenSearch/ClientBuilder.php +++ b/src/OpenSearch/ClientBuilder.php @@ -21,6 +21,9 @@ namespace OpenSearch; +use Aws\Credentials\CredentialProvider; +use Aws\Credentials\Credentials; +use Aws\Credentials\CredentialsInterface; use OpenSearch\Common\Exceptions\InvalidArgumentException; use OpenSearch\Common\Exceptions\RuntimeException; use OpenSearch\Common\Exceptions\AuthenticationConfigException; @@ -31,6 +34,7 @@ use OpenSearch\Connections\ConnectionFactory; use OpenSearch\Connections\ConnectionFactoryInterface; use OpenSearch\Connections\ConnectionInterface; +use OpenSearch\Handlers\SigV4Handler; use OpenSearch\Namespaces\NamespaceBuilderInterface; use OpenSearch\Serializers\SerializerInterface; use OpenSearch\Serializers\SmartSerializer; @@ -115,6 +119,16 @@ class ClientBuilder */ private $retries; + /** + * @var null|callable + */ + private $sigV4CredentialProvider; + + /** + * @var null|string + */ + private $sigV4Region; + /** * @var bool */ @@ -454,6 +468,33 @@ public function setSelector($selector): ClientBuilder return $this; } + /** + * Set the credential provider for SigV4 request signing. The value provider should be a + * callable object that will return + * + * @param callable|bool|array|CredentialsInterface|null $credentialProvider + */ + public function setSigV4CredentialProvider($credentialProvider): ClientBuilder + { + if ($credentialProvider !== null && $credentialProvider !== false) { + $this->sigV4CredentialProvider = $this->normalizeCredentialProvider($credentialProvider); + } + + return $this; + } + + /** + * Set the region for SigV4 signing. + * + * @param string|null $region + */ + public function setSigV4Region($region): ClientBuilder + { + $this->sigV4Region = $region; + + return $this; + } + /** * Set sniff on start * @@ -528,6 +569,14 @@ public function build(): Client $this->handler = ClientBuilder::defaultHandler(); } + if (!is_null($this->sigV4CredentialProvider)) { + if (is_null($this->sigV4Region)) { + throw new RuntimeException("A region must be supplied for SigV4 request signing."); + } + + $this->handler = new SigV4Handler($this->sigV4Region, $this->sigV4CredentialProvider, $this->handler); + } + $sslOptions = null; if (isset($this->sslKey)) { $sslOptions['ssl_key'] = $this->sslKey; @@ -761,4 +810,38 @@ private function prependMissingScheme(string $host): string return $host; } + + private function normalizeCredentialProvider($provider): ?callable + { + if ($provider === null || $provider === false) { + return null; + } + + if (is_callable($provider)) { + return $provider; + } + + SigV4Handler::assertDependenciesInstalled(); + + if ($provider === true) { + return CredentialProvider::defaultProvider(); + } + + if ($provider instanceof CredentialsInterface) { + return CredentialProvider::fromCredentials($provider); + } elseif (is_array($provider) && isset($provider['key']) && isset($provider['secret'])) { + return CredentialProvider::fromCredentials( + new Credentials( + $provider['key'], + $provider['secret'], + isset($provider['token']) ? $provider['token'] : null, + isset($provider['expires']) ? $provider['expires'] : null + ) + ); + } + + throw new InvalidArgumentException('Credentials must be an instance of Aws\Credentials\CredentialsInterface, an' + . ' associative array that contains "key", "secret", and an optional "token" key-value pairs, a credentials' + . ' provider function, or true.'); + } } diff --git a/src/OpenSearch/Handlers/SigV4Handler.php b/src/OpenSearch/Handlers/SigV4Handler.php new file mode 100644 index 000000000..9928539b3 --- /dev/null +++ b/src/OpenSearch/Handlers/SigV4Handler.php @@ -0,0 +1,119 @@ +signer = new SignatureV4('es', $region); + $this->wrappedHandler = $wrappedHandler + ?: ClientBuilder::defaultHandler(); + $this->credentialProvider = $credentialProvider + ?: CredentialProvider::defaultProvider(); + } + + public function __invoke(array $request) + { + $creds = call_user_func($this->credentialProvider)->wait(); + $psr7Request = $this->createPsr7Request($request); + $signedRequest = $this->signer + ->signRequest($psr7Request, $creds); + + return call_user_func($this->wrappedHandler, $this->createRingRequest($signedRequest)); + } + + public static function assertDependenciesInstalled(): void + { + if (!class_exists(SignatureV4::class)) { + throw new RuntimeException( + 'The AWS SDK for PHP must be installed in order to use the SigV4 signing handler' + ); + } + } + + private function createPsr7Request(array $ringPhpRequest): Request + { + // fix for uppercase 'Host' array key in elasticsearch-php 5.3.1 and backward compatible + // https://github.com/aws/aws-sdk-php/issues/1225 + $hostKey = isset($ringPhpRequest['headers']['Host']) ? 'Host' : 'host'; + + // Amazon ES/OS listens on standard ports (443 for HTTPS, 80 for HTTP). + // Consequently, the port should be stripped from the host header. + $parsedUrl = parse_url($ringPhpRequest['headers'][$hostKey][0]); + if (isset($parsedUrl['host'])) { + $ringPhpRequest['headers'][$hostKey][0] = $parsedUrl['host']; + } + + // Create a PSR-7 URI from the array passed to the handler + $uri = (new Uri($ringPhpRequest['uri'])) + ->withScheme($ringPhpRequest['scheme']) + ->withHost($ringPhpRequest['headers'][$hostKey][0]); + if (isset($ringPhpRequest['query_string'])) { + $uri = $uri->withQuery($ringPhpRequest['query_string']); + } + + // Create a PSR-7 request from the array passed to the handler + return new Request( + $ringPhpRequest['http_method'], + $uri, + $ringPhpRequest['headers'], + $ringPhpRequest['body'] + ); + } + + private function createRingRequest(RequestInterface $request): array + { + $uri = $request->getUri(); + $body = (string) $request->getBody(); + + // RingPHP currently expects empty message bodies to be null: + // https://github.com/guzzle/RingPHP/blob/4c8fe4c48a0fb7cc5e41ef529e43fecd6da4d539/src/Client/CurlFactory.php#L202 + if (empty($body)) { + $body = null; + } + + $ringRequest = [ + 'http_method' => $request->getMethod(), + 'scheme' => $uri->getScheme(), + 'uri' => $uri->getPath(), + 'body' => $body, + 'headers' => $request->getHeaders(), + ]; + if ($uri->getQuery()) { + $ringRequest['query_string'] = $uri->getQuery(); + } + + return $ringRequest; + } +} diff --git a/tests/Handlers/SigV4HandlerTest.php b/tests/Handlers/SigV4HandlerTest.php new file mode 100644 index 000000000..4b32118cd --- /dev/null +++ b/tests/Handlers/SigV4HandlerTest.php @@ -0,0 +1,147 @@ +envTemp = array_combine(self::ENV_KEYS_USED, array_map( + function ($envVarName) { + $current = getenv($envVarName); + putenv($envVarName); + return $current; + }, + self::ENV_KEYS_USED + )); + } + + protected function tearDown(): void + { + foreach ($this->envTemp as $key => $value) { + putenv("$key=$value"); + } + $this->envTemp = []; + } + + public function testSignsRequestsTheSdkDefaultCredentialProviderChain() + { + $key = 'foo'; + $toWrap = function (array $ringRequest) use ($key) { + $this->assertArrayHasKey('X-Amz-Date', $ringRequest['headers']); + $this->assertArrayHasKey('Authorization', $ringRequest['headers']); + $this->assertMatchesRegularExpression( + "~^AWS4-HMAC-SHA256 Credential=$key/\\d{8}/us-west-2/es/aws4_request~", + $ringRequest['headers']['Authorization'][0] + ); + + return $this->getGenericResponse(); + }; + putenv(CredentialProvider::ENV_KEY . "=$key"); + putenv(CredentialProvider::ENV_SECRET . '=bar'); + $client = ClientBuilder::create() + ->setHandler($toWrap) + ->setSigV4Region('us-west-2') + ->setSigV4CredentialProvider(true) + ->build(); + + $client->search([ + 'index' => 'index', + 'body' => [ + 'query' => [ 'match_all' => (object)[] ], + ], + ]); + } + + public function testSignsWithProvidedCredentials() + { + $provider = CredentialProvider::fromCredentials( + new Credentials('foo', 'bar', 'baz') + ); + $toWrap = function (array $ringRequest) { + $this->assertArrayHasKey('X-Amz-Security-Token', $ringRequest['headers']); + $this->assertSame('baz', $ringRequest['headers']['X-Amz-Security-Token'][0]); + $this->assertMatchesRegularExpression( + '~^AWS4-HMAC-SHA256 Credential=foo/\d{8}/us-west-2/es/aws4_request~', + $ringRequest['headers']['Authorization'][0] + ); + + return $this->getGenericResponse(); + }; + + $client = ClientBuilder::create() + ->setHandler($toWrap) + ->setSigV4Region('us-west-2') + ->setSigV4CredentialProvider(new Credentials('foo', 'bar', 'baz')) + ->build(); + + $client->search([ + 'index' => 'index', + 'body' => [ + 'query' => [ 'match_all' => (object)[] ], + ], + ]); + } + + public function testEmptyRequestBodiesShouldBeNull() + { + $toWrap = function (array $ringRequest) { + $this->assertNull($ringRequest['body']); + + return $this->getGenericResponse(); + }; + + $client = ClientBuilder::create() + ->setHandler($toWrap) + ->setSigV4Region('us-west-2') + ->setSigV4CredentialProvider(new Credentials('foo', 'bar', 'baz')) + ->build(); + + $client->indices()->exists(['index' => 'index']); + } + + public function testNonEmptyRequestBodiesShouldNotBeNull() + { + $toWrap = function (array $ringRequest) { + $this->assertNotNull($ringRequest['body']); + + return $this->getGenericResponse(); + }; + + $client = ClientBuilder::create() + ->setHandler($toWrap) + ->setSigV4Region('us-west-2') + ->setSigV4CredentialProvider(new Credentials('foo', 'bar', 'baz')) + ->build(); + + $client->search([ + 'index' => 'index', + 'body' => [ + 'query' => [ 'match_all' => (object)[] ], + ], + ]); + } + + private function getGenericResponse() + { + return new CompletedFutureArray([ + 'status' => 200, + 'body' => fopen('php://memory', 'r'), + 'transfer_stats' => ['total_time' => 0], + 'effective_url' => 'https://www.example.com', + ]); + } +}