AssumeRoleCredentialProvider.php
11 months ago
AssumeRoleWithWebIdentityCredentialProvider.php
11 months ago
CredentialProvider.php
11 months ago
Credentials.php
11 months ago
CredentialsInterface.php
11 months ago
EcsCredentialProvider.php
11 months ago
InstanceProfileProvider.php
11 months ago
InstanceProfileProvider.php
333 lines
| 1 | <?php |
| 2 | namespace Aws\Credentials; |
| 3 | |
| 4 | use Aws\Configuration\ConfigurationResolver; |
| 5 | use Aws\Exception\CredentialsException; |
| 6 | use Aws\Exception\InvalidJsonException; |
| 7 | use Aws\Sdk; |
| 8 | use GuzzleHttp\Exception\TransferException; |
| 9 | use GuzzleHttp\Promise; |
| 10 | use GuzzleHttp\Psr7\Request; |
| 11 | use GuzzleHttp\Promise\PromiseInterface; |
| 12 | use Psr\Http\Message\ResponseInterface; |
| 13 | |
| 14 | /** |
| 15 | * Credential provider that provides credentials from the EC2 metadata service. |
| 16 | */ |
| 17 | class InstanceProfileProvider |
| 18 | { |
| 19 | const SERVER_URI = 'http://169.254.169.254/latest/'; |
| 20 | const CRED_PATH = 'meta-data/iam/security-credentials/'; |
| 21 | const TOKEN_PATH = 'api/token'; |
| 22 | const ENV_DISABLE = 'AWS_EC2_METADATA_DISABLED'; |
| 23 | const ENV_TIMEOUT = 'AWS_METADATA_SERVICE_TIMEOUT'; |
| 24 | const ENV_RETRIES = 'AWS_METADATA_SERVICE_NUM_ATTEMPTS'; |
| 25 | const CFG_EC2_METADATA_V1_DISABLED = 'ec2_metadata_v1_disabled'; |
| 26 | const DEFAULT_TIMEOUT = 1.0; |
| 27 | const DEFAULT_RETRIES = 3; |
| 28 | const DEFAULT_TOKEN_TTL_SECONDS = 21600; |
| 29 | const DEFAULT_AWS_EC2_METADATA_V1_DISABLED = false; |
| 30 | |
| 31 | /** @var string */ |
| 32 | private $profile; |
| 33 | |
| 34 | /** @var callable */ |
| 35 | private $client; |
| 36 | |
| 37 | /** @var int */ |
| 38 | private $retries; |
| 39 | |
| 40 | /** @var int */ |
| 41 | private $attempts; |
| 42 | |
| 43 | /** @var float|mixed */ |
| 44 | private $timeout; |
| 45 | |
| 46 | /** @var bool */ |
| 47 | private $secureMode = true; |
| 48 | |
| 49 | /** @var bool|null */ |
| 50 | private $ec2MetadataV1Disabled; |
| 51 | |
| 52 | /** |
| 53 | * The constructor accepts the following options: |
| 54 | * |
| 55 | * - timeout: Connection timeout, in seconds. |
| 56 | * - profile: Optional EC2 profile name, if known. |
| 57 | * - retries: Optional number of retries to be attempted. |
| 58 | * - ec2_metadata_v1_disabled: Optional for disabling the fallback to IMDSv1. |
| 59 | * |
| 60 | * @param array $config Configuration options. |
| 61 | */ |
| 62 | public function __construct(array $config = []) |
| 63 | { |
| 64 | $this->timeout = (float) getenv(self::ENV_TIMEOUT) ?: ($config['timeout'] ?? self::DEFAULT_TIMEOUT); |
| 65 | $this->profile = $config['profile'] ?? null; |
| 66 | $this->retries = (int) getenv(self::ENV_RETRIES) ?: ($config['retries'] ?? self::DEFAULT_RETRIES); |
| 67 | $this->client = $config['client'] ?? \Aws\default_http_handler(); |
| 68 | $this->ec2MetadataV1Disabled = $config[self::CFG_EC2_METADATA_V1_DISABLED] ?? null; |
| 69 | } |
| 70 | |
| 71 | /** |
| 72 | * Loads instance profile credentials. |
| 73 | * |
| 74 | * @return PromiseInterface |
| 75 | */ |
| 76 | public function __invoke($previousCredentials = null) |
| 77 | { |
| 78 | $this->attempts = 0; |
| 79 | return Promise\Coroutine::of(function () use ($previousCredentials) { |
| 80 | |
| 81 | // Retrieve token or switch out of secure mode |
| 82 | $token = null; |
| 83 | while ($this->secureMode && is_null($token)) { |
| 84 | try { |
| 85 | $token = (yield $this->request( |
| 86 | self::TOKEN_PATH, |
| 87 | 'PUT', |
| 88 | [ |
| 89 | 'x-aws-ec2-metadata-token-ttl-seconds' => self::DEFAULT_TOKEN_TTL_SECONDS |
| 90 | ] |
| 91 | )); |
| 92 | } catch (TransferException $e) { |
| 93 | if ($this->getExceptionStatusCode($e) === 500 |
| 94 | && $previousCredentials instanceof Credentials |
| 95 | ) { |
| 96 | goto generateCredentials; |
| 97 | } elseif ($this->shouldFallbackToIMDSv1() |
| 98 | && (!method_exists($e, 'getResponse') |
| 99 | || empty($e->getResponse()) |
| 100 | || !in_array( |
| 101 | $e->getResponse()->getStatusCode(), |
| 102 | [400, 500, 502, 503, 504] |
| 103 | )) |
| 104 | ) { |
| 105 | $this->secureMode = false; |
| 106 | } else { |
| 107 | $this->handleRetryableException( |
| 108 | $e, |
| 109 | [], |
| 110 | $this->createErrorMessage( |
| 111 | 'Error retrieving metadata token' |
| 112 | ) |
| 113 | ); |
| 114 | } |
| 115 | } |
| 116 | $this->attempts++; |
| 117 | } |
| 118 | |
| 119 | // Set token header only for secure mode |
| 120 | $headers = []; |
| 121 | if ($this->secureMode) { |
| 122 | $headers = [ |
| 123 | 'x-aws-ec2-metadata-token' => $token |
| 124 | ]; |
| 125 | } |
| 126 | |
| 127 | // Retrieve profile |
| 128 | while (!$this->profile) { |
| 129 | try { |
| 130 | $this->profile = (yield $this->request( |
| 131 | self::CRED_PATH, |
| 132 | 'GET', |
| 133 | $headers |
| 134 | )); |
| 135 | } catch (TransferException $e) { |
| 136 | // 401 indicates insecure flow not supported, switch to |
| 137 | // attempting secure mode for subsequent calls |
| 138 | if (!empty($this->getExceptionStatusCode($e)) |
| 139 | && $this->getExceptionStatusCode($e) === 401 |
| 140 | ) { |
| 141 | $this->secureMode = true; |
| 142 | } |
| 143 | $this->handleRetryableException( |
| 144 | $e, |
| 145 | [ 'blacklist' => [401, 403] ], |
| 146 | $this->createErrorMessage($e->getMessage()) |
| 147 | ); |
| 148 | } |
| 149 | |
| 150 | $this->attempts++; |
| 151 | } |
| 152 | |
| 153 | // Retrieve credentials |
| 154 | $result = null; |
| 155 | while ($result == null) { |
| 156 | try { |
| 157 | $json = (yield $this->request( |
| 158 | self::CRED_PATH . $this->profile, |
| 159 | 'GET', |
| 160 | $headers |
| 161 | )); |
| 162 | $result = $this->decodeResult($json); |
| 163 | } catch (InvalidJsonException $e) { |
| 164 | $this->handleRetryableException( |
| 165 | $e, |
| 166 | [ 'blacklist' => [401, 403] ], |
| 167 | $this->createErrorMessage( |
| 168 | 'Invalid JSON response, retries exhausted' |
| 169 | ) |
| 170 | ); |
| 171 | } catch (TransferException $e) { |
| 172 | // 401 indicates insecure flow not supported, switch to |
| 173 | // attempting secure mode for subsequent calls |
| 174 | if (($this->getExceptionStatusCode($e) === 500 |
| 175 | || strpos($e->getMessage(), "cURL error 28") !== false) |
| 176 | && $previousCredentials instanceof Credentials |
| 177 | ) { |
| 178 | goto generateCredentials; |
| 179 | } elseif (!empty($this->getExceptionStatusCode($e)) |
| 180 | && $this->getExceptionStatusCode($e) === 401 |
| 181 | ) { |
| 182 | $this->secureMode = true; |
| 183 | } |
| 184 | $this->handleRetryableException( |
| 185 | $e, |
| 186 | [ 'blacklist' => [401, 403] ], |
| 187 | $this->createErrorMessage($e->getMessage()) |
| 188 | ); |
| 189 | } |
| 190 | $this->attempts++; |
| 191 | } |
| 192 | generateCredentials: |
| 193 | |
| 194 | if (!isset($result)) { |
| 195 | $credentials = $previousCredentials; |
| 196 | } else { |
| 197 | $credentials = new Credentials( |
| 198 | $result['AccessKeyId'], |
| 199 | $result['SecretAccessKey'], |
| 200 | $result['Token'], |
| 201 | strtotime($result['Expiration']) |
| 202 | ); |
| 203 | } |
| 204 | |
| 205 | if ($credentials->isExpired()) { |
| 206 | $credentials->extendExpiration(); |
| 207 | } |
| 208 | |
| 209 | yield $credentials; |
| 210 | }); |
| 211 | } |
| 212 | |
| 213 | /** |
| 214 | * @param string $url |
| 215 | * @param string $method |
| 216 | * @param array $headers |
| 217 | * @return PromiseInterface Returns a promise that is fulfilled with the |
| 218 | * body of the response as a string. |
| 219 | */ |
| 220 | private function request($url, $method = 'GET', $headers = []) |
| 221 | { |
| 222 | $disabled = getenv(self::ENV_DISABLE) ?: false; |
| 223 | if (strcasecmp($disabled, 'true') === 0) { |
| 224 | throw new CredentialsException( |
| 225 | $this->createErrorMessage('EC2 metadata service access disabled') |
| 226 | ); |
| 227 | } |
| 228 | |
| 229 | $fn = $this->client; |
| 230 | $request = new Request($method, self::SERVER_URI . $url); |
| 231 | $userAgent = 'aws-sdk-php/' . Sdk::VERSION; |
| 232 | if (defined('HHVM_VERSION')) { |
| 233 | $userAgent .= ' HHVM/' . HHVM_VERSION; |
| 234 | } |
| 235 | $userAgent .= ' ' . \Aws\default_user_agent(); |
| 236 | $request = $request->withHeader('User-Agent', $userAgent); |
| 237 | foreach ($headers as $key => $value) { |
| 238 | $request = $request->withHeader($key, $value); |
| 239 | } |
| 240 | |
| 241 | return $fn($request, ['timeout' => $this->timeout]) |
| 242 | ->then(function (ResponseInterface $response) { |
| 243 | return (string) $response->getBody(); |
| 244 | })->otherwise(function (array $reason) { |
| 245 | $reason = $reason['exception']; |
| 246 | if ($reason instanceof TransferException) { |
| 247 | throw $reason; |
| 248 | } |
| 249 | $msg = $reason->getMessage(); |
| 250 | throw new CredentialsException( |
| 251 | $this->createErrorMessage($msg) |
| 252 | ); |
| 253 | }); |
| 254 | } |
| 255 | |
| 256 | private function handleRetryableException( |
| 257 | \Exception $e, |
| 258 | $retryOptions, |
| 259 | $message |
| 260 | ) { |
| 261 | $isRetryable = true; |
| 262 | if (!empty($status = $this->getExceptionStatusCode($e)) |
| 263 | && isset($retryOptions['blacklist']) |
| 264 | && in_array($status, $retryOptions['blacklist']) |
| 265 | ) { |
| 266 | $isRetryable = false; |
| 267 | } |
| 268 | if ($isRetryable && $this->attempts < $this->retries) { |
| 269 | sleep((int) pow(1.2, $this->attempts)); |
| 270 | } else { |
| 271 | throw new CredentialsException($message); |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | private function getExceptionStatusCode(\Exception $e) |
| 276 | { |
| 277 | if (method_exists($e, 'getResponse') |
| 278 | && !empty($e->getResponse()) |
| 279 | ) { |
| 280 | return $e->getResponse()->getStatusCode(); |
| 281 | } |
| 282 | return null; |
| 283 | } |
| 284 | |
| 285 | private function createErrorMessage($previous) |
| 286 | { |
| 287 | return "Error retrieving credentials from the instance profile " |
| 288 | . "metadata service. ({$previous})"; |
| 289 | } |
| 290 | |
| 291 | private function decodeResult($response) |
| 292 | { |
| 293 | $result = json_decode($response, true); |
| 294 | |
| 295 | if (json_last_error() > 0) { |
| 296 | throw new InvalidJsonException(); |
| 297 | } |
| 298 | |
| 299 | if ($result['Code'] !== 'Success') { |
| 300 | throw new CredentialsException('Unexpected instance profile ' |
| 301 | . 'response code: ' . $result['Code']); |
| 302 | } |
| 303 | |
| 304 | return $result; |
| 305 | } |
| 306 | |
| 307 | /** |
| 308 | * This functions checks for whether we should fall back to IMDSv1 or not. |
| 309 | * If $ec2MetadataV1Disabled is null then we will try to resolve this value from |
| 310 | * the following sources: |
| 311 | * - From environment: "AWS_EC2_METADATA_V1_DISABLED". |
| 312 | * - From config file: aws_ec2_metadata_v1_disabled |
| 313 | * - Defaulted to false |
| 314 | * |
| 315 | * @return bool |
| 316 | */ |
| 317 | private function shouldFallbackToIMDSv1(): bool |
| 318 | { |
| 319 | $isImdsV1Disabled = \Aws\boolean_value($this->ec2MetadataV1Disabled) |
| 320 | ?? \Aws\boolean_value( |
| 321 | ConfigurationResolver::resolve( |
| 322 | self::CFG_EC2_METADATA_V1_DISABLED, |
| 323 | self::DEFAULT_AWS_EC2_METADATA_V1_DISABLED, |
| 324 | 'bool', |
| 325 | ['use_aws_shared_config_files' => true] |
| 326 | ) |
| 327 | ) |
| 328 | ?? self::DEFAULT_AWS_EC2_METADATA_V1_DISABLED; |
| 329 | |
| 330 | return !$isImdsV1Disabled; |
| 331 | } |
| 332 | } |
| 333 |