diff --git a/doc/04-schema.md b/doc/04-schema.md index 6ebe378e4..effec5386 100644 --- a/doc/04-schema.md +++ b/doc/04-schema.md @@ -684,8 +684,8 @@ The following options are supported: `{"github.com": "oauthtoken"}` as the value of this option will use `oauthtoken` to access private repositories on github and to circumvent the low IP-based rate limiting of their API. - [Read more](articles/troubleshooting.md#api-rate-limit-and-two-factor-authentication) - on how to get an oauth token for GitHub. + [Read more](articles/troubleshooting.md#api-rate-limit-and-oauth-tokens) + on how to get an OAuth token for GitHub. * **vendor-dir:** Defaults to `vendor`. You can install dependencies into a different directory if you want to. * **bin-dir:** Defaults to `vendor/bin`. If a project includes binaries, they diff --git a/doc/articles/troubleshooting.md b/doc/articles/troubleshooting.md index fc9faa8d5..c1c5e2d61 100644 --- a/doc/articles/troubleshooting.md +++ b/doc/articles/troubleshooting.md @@ -105,14 +105,15 @@ Or, you can increase the limit with a command-line argument: or ```HKEY_CURRENT_USER\Software\Microsoft\Command Processor```. 3. Check if it contains any path to non-existent file, if it's the case, just remove them. -## API rate limit and two factor authentication +## API rate limit and OAuth tokens Because of GitHub's rate limits on their API it can happen that Composer prompts for authentication asking your username and password so it can go ahead with its work. -Unfortunately this will not work if you enabled two factor authentication on -your GitHub account and to solve this issue you need to: -1. [Create](https://github.com/settings/applications) an oauth token on GitHub. +If you would prefer not to provide your GitHub credentials to Composer you can +manually create a token using the following procedure: + +1. [Create](https://github.com/settings/applications) an OAuth token on GitHub. [Read more](https://github.com/blog/1509-personal-api-tokens) on this. 2. Add it to the configuration running `composer config -g github-oauth.github.com ` diff --git a/src/Composer/Downloader/TransportException.php b/src/Composer/Downloader/TransportException.php index b28f8d470..2e4b42f01 100644 --- a/src/Composer/Downloader/TransportException.php +++ b/src/Composer/Downloader/TransportException.php @@ -18,6 +18,7 @@ namespace Composer\Downloader; class TransportException extends \RuntimeException { protected $headers; + protected $response; public function setHeaders($headers) { @@ -28,4 +29,14 @@ class TransportException extends \RuntimeException { return $this->headers; } + + public function setResponse($response) + { + $this->response = $response; + } + + public function getResponse() + { + return $this->response; + } } diff --git a/src/Composer/Installer.php b/src/Composer/Installer.php index 4e4502dfe..d373beb43 100644 --- a/src/Composer/Installer.php +++ b/src/Composer/Installer.php @@ -297,6 +297,11 @@ class Installer $eventName = $this->update ? ScriptEvents::POST_UPDATE_CMD : ScriptEvents::POST_INSTALL_CMD; $this->eventDispatcher->dispatchCommandEvent($eventName, $this->devMode); } + + $vendorDir = $this->config->get('vendor-dir'); + if (is_dir($vendorDir)) { + touch($vendorDir); + } } return 0; diff --git a/src/Composer/Util/GitHub.php b/src/Composer/Util/GitHub.php index 49e56f8c9..b69073d13 100644 --- a/src/Composer/Util/GitHub.php +++ b/src/Composer/Util/GitHub.php @@ -87,9 +87,13 @@ class GitHub $this->io->write('To revoke access to this token you can visit https://github.com/settings/applications'); while ($attemptCounter++ < 5) { try { - $username = $this->io->ask('Username: '); - $password = $this->io->askAndHideAnswer('Password: '); - $this->io->setAuthentication($originUrl, $username, $password); + if (empty($otp) || !$this->io->hasAuthentication($originUrl)) { + $username = $this->io->ask('Username: '); + $password = $this->io->askAndHideAnswer('Password: '); + $otp = null; + + $this->io->setAuthentication($originUrl, $username, $password); + } // build up OAuth app name $appName = 'Composer'; @@ -97,20 +101,81 @@ class GitHub $appName .= ' on ' . trim($output); } - $contents = JsonFile::parseJson($this->remoteFilesystem->getContents($originUrl, 'https://'. $apiUrl . '/authorizations', false, array( + $headers = array(); + if ($otp) { + $headers = array('X-GitHub-OTP: ' . $otp); + } + + // try retrieving an existing token with the same name + $contents = null; + $auths = JsonFile::parseJson($this->remoteFilesystem->getContents($originUrl, 'https://'. $apiUrl . '/authorizations', false, array( + 'retry-auth-failure' => false, 'http' => array( - 'method' => 'POST', - 'follow_location' => false, - 'header' => "Content-Type: application/json\r\n", - 'content' => json_encode(array( - 'scopes' => array('repo'), - 'note' => $appName, - 'note_url' => 'https://getcomposer.org/', - )), + 'header' => $headers ) ))); + foreach ($auths as $auth) { + if ( + isset($auth['app']['name']) + && 0 === strpos($auth['app']['name'], $appName) + && $auth['app']['url'] === 'https://getcomposer.org/' + ) { + $this->io->write('An existing OAuth token for Composer is present and will be reused'); + + $contents['token'] = $auth['token']; + break; + } + } + + // no existing token, create one + if (empty($contents['token'])) { + $headers[] = array('Content-Type: application/json'); + + $contents = JsonFile::parseJson($this->remoteFilesystem->getContents($originUrl, 'https://'. $apiUrl . '/authorizations', false, array( + 'retry-auth-failure' => false, + 'http' => array( + 'method' => 'POST', + 'follow_location' => false, + 'header' => $headers, + 'content' => json_encode(array( + 'scopes' => array('repo'), + 'note' => $appName, + 'note_url' => 'https://getcomposer.org/', + )), + ) + ))); + $this->io->write('Token successfully created'); + } } catch (TransportException $e) { if (in_array($e->getCode(), array(403, 401))) { + // 401 when authentication was supplied, handle 2FA if required. + if ($this->io->hasAuthentication($originUrl)) { + $headerNames = array_map(function($header) { + return strtolower(strstr($header, ':', true)); + }, $e->getHeaders()); + + if ($key = array_search('x-github-otp', $headerNames)) { + $headers = $e->getHeaders(); + list($required, $method) = array_map('trim', explode(';', substr(strstr($headers[$key], ':'), 1))); + + if ('required' === $required) { + $this->io->write('Two-factor Authentication'); + + if ('app' === $method) { + $this->io->write('Open the two-factor authentication app on your device to view your authentication code and verify your identity.'); + } + + if ('sms' === $method) { + $this->io->write('You have been sent an SMS message with an authentication code to verify your identity.'); + } + + $otp = $this->io->ask('Authentication Code: '); + + continue; + } + } + } + $this->io->write('Invalid credentials.'); continue; } diff --git a/src/Composer/Util/RemoteFilesystem.php b/src/Composer/Util/RemoteFilesystem.php index 0c0f853f9..1bbdf2f83 100644 --- a/src/Composer/Util/RemoteFilesystem.php +++ b/src/Composer/Util/RemoteFilesystem.php @@ -35,6 +35,7 @@ class RemoteFilesystem private $options; private $disableTls = false; private $retryTls = true; + private $retryAuthFailure; /** * Constructor. @@ -128,12 +129,19 @@ class RemoteFilesystem $this->fileName = $fileName; $this->progress = $progress; $this->lastProgress = null; + $this->retryAuthFailure = true; // capture username/password from URL if there is one if (preg_match('{^https?://(.+):(.+)@([^/]+)}i', $fileUrl, $match)) { $this->io->setAuthentication($originUrl, urldecode($match[1]), urldecode($match[2])); } + if (isset($additionalOptions['retry-auth-failure'])) { + $this->retryAuthFailure = (bool) $additionalOptions['retry-auth-failure']; + + unset($additionalOptions['retry-auth-failure']); + } + $options = $this->getOptionsForUrl($originUrl, $additionalOptions, $expectedCommonName); if ($this->io->isDebug()) { @@ -143,6 +151,9 @@ class RemoteFilesystem $fileUrl .= (false === strpos($fileUrl, '?') ? '?' : '&') . 'access_token='.$options['github-token']; unset($options['github-token']); } + if (isset($options['http'])) { + $options['http']['ignore_errors'] = true; + } $ctx = StreamContextFactory::getContext($fileUrl, $options, array('notification' => array($this, 'callbackGet'))); if ($this->progress) { @@ -164,6 +175,10 @@ class RemoteFilesystem if ($e instanceof TransportException && !empty($http_response_header[0])) { $e->setHeaders($http_response_header); } + if ($e instanceof TransportException && $result !== false) { + $e->setResponse($result); + } + $result = false; } if ($errorMessage && !ini_get('allow_url_fopen')) { $errorMessage = 'allow_url_fopen must be enabled in php.ini ('.$errorMessage.')'; @@ -173,10 +188,16 @@ class RemoteFilesystem throw $e; } - // fix for 5.4.0 https://bugs.php.net/bug.php?id=61336 + // fail 4xx and 5xx responses and capture the response if (!empty($http_response_header[0]) && preg_match('{^HTTP/\S+ ([45]\d\d)}i', $http_response_header[0], $match)) { - $result = false; $errorCode = $match[1]; + if (!$this->retry) { + $e = new TransportException('The "'.$this->fileUrl.'" file could not be downloaded ('.$http_response_header[0].')', $errorCode); + $e->setHeaders($http_response_header); + $e->setResponse($result); + throw $e; + } + $result = false; } // decode gzip @@ -272,6 +293,11 @@ class RemoteFilesystem case STREAM_NOTIFY_FAILURE: case STREAM_NOTIFY_AUTH_REQUIRED: if (401 === $messageCode) { + // Bail if the caller is going to handle authentication failures itself. + if (!$this->retryAuthFailure) { + break; + } + if (!$this->io->isInteractive()) { $message = "The '" . $this->fileUrl . "' URL required authentication.\nYou must be using the interactive console"; @@ -281,12 +307,7 @@ class RemoteFilesystem $this->promptAuthAndRetry(); break; } - - if ($notificationCode === STREAM_NOTIFY_AUTH_REQUIRED) { - break; - } - - throw new TransportException('The "'.$this->fileUrl.'" file could not be downloaded ('.trim($message).')', $messageCode); + break; case STREAM_NOTIFY_AUTH_RESULT: if (403 === $messageCode) { diff --git a/tests/Composer/Test/Repository/Vcs/GitHubDriverTest.php b/tests/Composer/Test/Repository/Vcs/GitHubDriverTest.php index 906c20071..f452f224d 100644 --- a/tests/Composer/Test/Repository/Vcs/GitHubDriverTest.php +++ b/tests/Composer/Test/Repository/Vcs/GitHubDriverTest.php @@ -81,9 +81,14 @@ class GitHubDriverTest extends \PHPUnit_Framework_TestCase $remoteFilesystem->expects($this->at(1)) ->method('getContents') ->with($this->equalTo('github.com'), $this->equalTo('https://api.github.com/authorizations'), $this->equalTo(false)) - ->will($this->returnValue('{"token": "abcdef"}')); + ->will($this->returnValue('[]')); $remoteFilesystem->expects($this->at(2)) + ->method('getContents') + ->with($this->equalTo('github.com'), $this->equalTo('https://api.github.com/authorizations'), $this->equalTo(false)) + ->will($this->returnValue('{"token": "abcdef"}')); + + $remoteFilesystem->expects($this->at(3)) ->method('getContents') ->with($this->equalTo('github.com'), $this->equalTo($repoApiUrl), $this->equalTo(false)) ->will($this->returnValue('{"master_branch": "test_master", "private": true}')); diff --git a/tests/Composer/Test/Util/RemoteFilesystemTest.php b/tests/Composer/Test/Util/RemoteFilesystemTest.php index ffb744327..a904667cb 100644 --- a/tests/Composer/Test/Util/RemoteFilesystemTest.php +++ b/tests/Composer/Test/Util/RemoteFilesystemTest.php @@ -130,18 +130,11 @@ class RemoteFilesystemTest extends \PHPUnit_Framework_TestCase $this->assertAttributeEquals(50, 'lastProgress', $fs); } - public function testCallbackGetNotifyFailure404() + public function testCallbackGetPassesThrough404() { $fs = new RemoteFilesystem($this->getMock('Composer\IO\IOInterface')); - try { - $this->callCallbackGet($fs, STREAM_NOTIFY_FAILURE, 0, 'HTTP/1.1 404 Not Found', 404, 0, 0); - $this->fail(); - } catch (\Exception $e) { - $this->assertInstanceOf('Composer\Downloader\TransportException', $e); - $this->assertEquals(404, $e->getCode()); - $this->assertContains('HTTP/1.1 404 Not Found', $e->getMessage()); - } + $this->assertNull($this->callCallbackGet($fs, STREAM_NOTIFY_FAILURE, 0, 'HTTP/1.1 404 Not Found', 404, 0, 0)); } public function testCaptureAuthenticationParamsFromUrl()