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,12 @@ private $future; private $awsAccessKey; private $awsPrivateKey; + private $awsSessionToken; + private $awsSessionExpiry; private $awsRegion; private $builtRequest; private $params; + private $useIAM = false; abstract public function getServiceName(); @@ -15,41 +18,77 @@ 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; + public final function getHost() { + return $this->getServiceName().'.'.$this->awsRegion.'.amazonaws.com'; } - 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; + } + + protected final function getProxiedFuture() { if (!$this->future) { $params = $this->params; @@ -57,14 +96,19 @@ 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)!'); } $params['AWSAccessKeyId'] = $this->getAWSAccessKey(); $params['Version'] = '2011-12-15'; $params['Timestamp'] = date('c'); + if ($this->useIAM) { + $this->refreshInstanceProfileCredentials(); + $params['SecurityToken'] = $this->awsSessionToken; + } + $params = $this->sign($params); $uri = new PhutilURI('http://'.$this->getHost().'/'); @@ -76,7 +120,7 @@ return $this->future; } - protected function didReceiveResult($result) { + protected final function didReceiveResult(array $result) { list($status, $body, $headers) = $result; try { @@ -110,7 +154,6 @@ * http://bit.ly/wU0JFh */ private function sign(array $params) { - $params['SignatureMethod'] = 'HmacSHA256'; $params['SignatureVersion'] = '2'; @@ -122,20 +165,81 @@ } $pstr = implode('&', $pstr); - $sign = "GET"."\n". - strtolower($this->getHost())."\n". - "/"."\n". - $pstr; - - $hash = hash_hmac( - 'sha256', - $sign, - $this->getAWSPrivateKey(), - $raw_ouput = true); + $sign = implode("\n", array( + 'GET', + strtolower($this->getHost()), + '/', + $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 + */ + public function refreshInstanceProfileCredentials() { + if ($this->useIAM) { + $this->awsAccessKey = null; + $this->awsPrivateKey = null; + $this->awsSessionToken = null; + $this->awsSessionExpiry = null; + + 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 + */ + 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; + } + }