diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -81,6 +81,7 @@ 'PhutilAWSEC2Future' => 'future/aws/PhutilAWSEC2Future.php', 'PhutilAWSException' => 'future/aws/PhutilAWSException.php', 'PhutilAWSFuture' => 'future/aws/PhutilAWSFuture.php', + 'PhutilAWSS3Client' => 'future/aws/PhutilAWSS3Client.php', 'PhutilAWSS3Future' => 'future/aws/PhutilAWSS3Future.php', 'PhutilAggregateException' => 'error/PhutilAggregateException.php', 'PhutilAmazonAuthAdapter' => 'auth/PhutilAmazonAuthAdapter.php', diff --git a/src/future/aws/PhutilAWSFuture.php b/src/future/aws/PhutilAWSFuture.php --- a/src/future/aws/PhutilAWSFuture.php +++ b/src/future/aws/PhutilAWSFuture.php @@ -5,9 +5,13 @@ private $future; private $awsAccessKey; private $awsPrivateKey; + private $awsSessionToken; + private $awsSessionExpiry; private $awsRegion; private $builtRequest; private $params; + private $useIAM = false; + private $useSSL = false; abstract public function getServiceName(); @@ -15,41 +19,96 @@ parent::__construct(null); } - public function setAWSKeys($access, $private) { - $this->awsAccessKey = $access; - $this->awsPrivateKey = $private; - return $this; - } + public final function getAWSAccessKey() { + if ($this->useIAM) { + $this->refreshInstanceProfileCredentials(); + } - public function getAWSAccessKey() { return $this->awsAccessKey; } - public function getAWSPrivateKey() { + public final function getAWSPrivateKey() { + if ($this->useIAM) { + $this->refreshInstanceProfileCredentials(); + } + return $this->awsPrivateKey; } - public function getAWSRegion() { + public final function getAWSSessionToken() { + return $this->awsSessionToken; + } + + public final function getAWSSessionExpiry() { + return $this->awsSessionExpiry; + } + + public final function setAWSKeys($access, $private) { + $this->awsAccessKey = $access; + $this->awsPrivateKey = $private; + $this->awsSessionToken = null; + $this->awsSessionExpiry = null; + return $this; + } + + public final function getAWSRegion() { return $this->awsRegion; } - public function setAWSRegion($region) { + public final function setAWSRegion($region) { $this->awsRegion = $region; return $this; } public function getHost() { - $host = $this->getServiceName().'.'.$this->awsRegion.'.amazonaws.com'; - return $host; + $parts = array(); + + $parts[] = $this->getServiceName(); + + if ($this->awsRegion) { + $parts[] = $this->awsRegion; + } + + $parts[] = 'amazonaws.com'; + + return implode('.', $parts); + } + + public function getPath() { + return ''; } - public function setRawAWSQuery($action, array $params = array()) { + public final function setRawAWSQuery($action, array $params = array()) { $this->params = $params; $this->params['Action'] = $action; return $this; } - protected function getProxiedFuture() { + /** + * Set whether to use instance profile credentials for authentication. See + * http://docs.aws.amazon.com/IAM/latest/UserGuide/instance-profiles.html for + * more information + * + * @param bool True to use instance profile credentials, otherwise false. + * @return this + */ + public final function setUseIAM($use_iam) { + $this->useIAM = $use_iam; + + if ($this->useIAM) { + $this->awsAccessKey = null; + $this->awsPrivateKey = null; + } + + return $this; + } + + public final function setUseSSL($use_ssl) { + $this->useSSL = $use_ssl; + return $this; + } + + protected final function getProxiedFuture() { if (!$this->future) { $params = $this->params; @@ -57,8 +116,13 @@ throw new Exception('You must setRawAWSQuery()!'); } - if (!$this->getAWSAccessKey()) { - throw new Exception('You must setAWSKeys()!'); + if (!$this->getAWSAccessKey() && !$this->useIAM) { + throw new Exception('You must setAWSKeys() or setUseIAM(true)!'); + } + + if ($this->useIAM) { + $this->refreshInstanceProfileCredentials(); + $params['Expires'] = $this->awsSessionExpiry; } $params['AWSAccessKeyId'] = $this->getAWSAccessKey(); @@ -67,16 +131,25 @@ $params = $this->sign($params); - $uri = new PhutilURI('http://'.$this->getHost().'/'); - $uri->setQueryParams($params); + $uri = id(new PhutilURI('')) + ->setDomain($this->getHost()) + ->setQueryParams($params); + + if ($this->useSSL) { + $this->future = new HTTPSFuture($uri->setProtocol('https')); + } else { + $this->future = new HTTPFuture($uri->setProtocol('http')); + } - $this->future = new HTTPFuture($uri); + if ($this->useIAM) { + $this->future->addHeader('x-amz-security-token', $this->awsSessionToken); + } } return $this->future; } - protected function didReceiveResult($result) { + protected final function didReceiveResult($result) { list($status, $body, $headers) = $result; try { @@ -109,11 +182,14 @@ /** * http://bit.ly/wU0JFh */ - private function sign(array $params) { - - $params['SignatureMethod'] = 'HmacSHA256'; - $params['SignatureVersion'] = '2'; - + private function sign(BaseHTTPFuture $future) { + $uri = new PhutilURI($future->getURI()); + + $params = $uri + ->setQueryParam('Signature', null) + ->setQueryParam('SignatureMethod', 'HmacSHA256') + ->setQueryParam('SignatureVersion', '2') + ->getQueryParams(); ksort($params); $pstr = array(); @@ -122,20 +198,94 @@ } $pstr = implode('&', $pstr); - $sign = "GET"."\n". - strtolower($this->getHost())."\n". - "/"."\n". - $pstr; + $amz_headers = array(); + + foreach ($future->getHeaders() as $header) { + list($key, $value) = $header; + + $headers[$key] = $value; - $hash = hash_hmac( - 'sha256', - $sign, - $this->getAWSPrivateKey(), - $raw_ouput = true); + if (substr(strtolower($key), 0, 6) == 'x-amz-') { + if (idx($amz_headers, $key, null) === null) { + $amz_headers[$key] = array(); + } + $amz_headers[$key][] = $value; + } + } + + $sign = implode("\n", array( + $future->getMethod(), + implode(',', $future->getHeaders('Content-MD5')), + implode(',', $future->getHeaders('Content-Type')), + $uri->getHost(), + $uri->getPath, + $pstr, + )); + + $hash = hash_hmac('sha256', $sign, $this->getAWSPrivateKey(), true); $params['Signature'] = base64_encode($hash); return $params; } + /** + * Returns a URI which can be used to retrieve instance metadata from an AWS + * EC2 instance. See + * http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html + * for more information. + * + * @return PhutilURI + */ + protected final function getInstanceMetadataURL() { + return new PhutilURI('http://169.254.169.254/latest/meta-data/'); + } + + /** + * Refresh credentials from the instance profile. + * + * @return this + */ + private function refreshInstanceProfileCredentials() { + if (time() > $this->awsSessionExpiry) { + // Instance profile credentials have expired, retrieve a new token. + $this->setInstanceProfileCredentials(); + } + + return $this; + } + + /** + * Set credentials from the instance profile. + * + * http://bit.ly/1mLDoQ3 + * + * @return this + */ + private function setInstanceProfileCredentials() { + $url = $this->getInstanceMetadataURL() + ->appendPath('/iam/security-credentials/'); + + // Get role. + $future = new HTTPFuture($url); + list($response, $headers) = $future->resolvex(); + $credentials = trim($response); + + // Get credentials. + $future = new HTTPFuture($url->appendPath($credentials)); + list($response, $headers) = $future->resolvex(); + $response = phutil_json_decode($response); + + if ($response['Code'] !== 'Success') { + throw new RuntimeException( + pht('Unexpected response code: "%s"', $response['Code'])); + } + + $this->awsAccessKey = $response['AccessKeyId']; + $this->awsPrivateKey = $response['SecretAccessKey']; + $this->awsSessionToken = $response['Token']; + $this->awsSessionExpiry = strtotime($response['Expiration']); + return $this; + } + } diff --git a/src/future/aws/PhutilAWSS3Client.php b/src/future/aws/PhutilAWSS3Client.php new file mode 100644 --- /dev/null +++ b/src/future/aws/PhutilAWSS3Client.php @@ -0,0 +1,71 @@ +future = new PhutilAWSS3Future(); + } + + public function setAWSKeys($access, $private) { + $this->future->setAWSKeys($access, $private); + return $this; + } + + public function setUseIAM($use_iam) { + $this->future->setUseIAM($use_iam); + return $this; + } + + private function getFuture() { + $future = clone $this->future; + return $future; + } + + public function deleteBucket($bucket) { + return $this->getFuture() + ->setBucket($bucket) + ->setMethod('DELETE') + ->resolvex(); + } + + public function deleteObject($bucket, $key) { + return $this->getFuture() + ->setBucket($bucket) + ->setKey($key) + ->setMethod('DELETE') + ->resolvex(); + } + + public function getObject($bucket, $key) { + return $this->getFuture() + ->setBucket($bucket) + ->setKey($key) + ->setMethod('GET') + ->resolvex(); + } + + public function listBucket($bucket) { + return $this->getFuture() + ->setBucket($bucket) + ->getProxiedFuture()->setMethod('GET') + ->setRawAWSQuery('LIST', array()) + ->resolvex(); + } + + public function putObject($data, $bucket, $key) { + $params = array( + 'data' => $data, + 'size' => strlen($data), + 'md5sum' => base64_encode(md5($data, true)), + ); + + return $this->getFuture() + ->setBucket($bucket) + ->setKey($key) + ->setMethod('PUT') + ->resolvex(); + } + +} diff --git a/src/future/aws/PhutilAWSS3Future.php b/src/future/aws/PhutilAWSS3Future.php --- a/src/future/aws/PhutilAWSS3Future.php +++ b/src/future/aws/PhutilAWSS3Future.php @@ -2,8 +2,37 @@ final class PhutilAWSS3Future extends PhutilAWSFuture { + private $bucket; + private $key; + public function getServiceName() { return 's3'; } + public function setBucket($bucket) { + $this->bucket = $bucket; + return $this; + } + + public function getBucket() { + return $this->bucket; + } + + public function setKey($key) { + $this->key = $key; + return $this; + } + + public function getKey() { + return $this->key; + } + + public final function getHost() { + return $this->getBucket().'.'.parent::getHost(); + } + + public function getPath() { + return $this->key; + } + }