Skip to content

Commit

Permalink
Add SignatureV4 handler (#63)
Browse files Browse the repository at this point in the history
* Add SignatureV4 handler

Signed-off-by: Jonathan Eskew <[email protected]>

* Remove legacy ES client compat from SigV4 test and scrub and restore environment around test

Signed-off-by: Jonathan Eskew <[email protected]>

* Integrate SigV4 signer into ClientBuilder

Signed-off-by: Jonathan Eskew <[email protected]>

* Run php-cs-fixer

Signed-off-by: Jonathan Eskew <[email protected]>

* Use the correct param name in docblock

Co-authored-by: Shyim <[email protected]>
Signed-off-by: Jonathan Eskew <[email protected]>

* Add SigV4 signer usage example to user guide

Signed-off-by: Jonathan Eskew <[email protected]>

* Fix PHPStan reported issues

Signed-off-by: Jonathan Eskew <[email protected]>

Co-authored-by: Shyim <[email protected]>
  • Loading branch information
jeskew and shyim authored Jun 30, 2022
1 parent 14dd76a commit 839c78e
Show file tree
Hide file tree
Showing 5 changed files with 355 additions and 1 deletion.
3 changes: 3 additions & 0 deletions USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
83 changes: 83 additions & 0 deletions src/OpenSearch/ClientBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -115,6 +119,16 @@ class ClientBuilder
*/
private $retries;

/**
* @var null|callable
*/
private $sigV4CredentialProvider;

/**
* @var null|string
*/
private $sigV4Region;

/**
* @var bool
*/
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.');
}
}
119 changes: 119 additions & 0 deletions src/OpenSearch/Handlers/SigV4Handler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php

declare(strict_types=1);

namespace OpenSearch\Handlers;

use Aws\Credentials\CredentialProvider;
use Aws\Signature\SignatureV4;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Uri;
use OpenSearch\ClientBuilder;
use Psr\Http\Message\RequestInterface;
use RuntimeException;

class SigV4Handler
{
private $signer;
private $credentialProvider;
private $wrappedHandler;

/**
* A handler that applies an AWS V4 signature before dispatching requests.
*
* @param string $region The region of your Amazon
* Elasticsearch Service domain
* @param callable|null $credentialProvider A callable that returns a
* promise that is fulfilled
* with an instance of
* Aws\Credentials\Credentials
* @param callable|null $wrappedHandler A RingPHP handler
*/
public function __construct(
string $region,
callable $credentialProvider = null,
callable $wrappedHandler = null
) {
self::assertDependenciesInstalled();

$this->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;
}
}
Loading

0 comments on commit 839c78e

Please sign in to comment.