Merge branch 'master' of github.com:composer/composer into tls-config
Conflicts: src/Composer/Util/RemoteFilesystem.phppull/2745/head
commit
81b86acc53
|
@ -684,8 +684,8 @@ The following options are supported:
|
||||||
`{"github.com": "oauthtoken"}` as the value of this option will use `oauthtoken`
|
`{"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
|
to access private repositories on github and to circumvent the low IP-based
|
||||||
rate limiting of their API.
|
rate limiting of their API.
|
||||||
[Read more](articles/troubleshooting.md#api-rate-limit-and-two-factor-authentication)
|
[Read more](articles/troubleshooting.md#api-rate-limit-and-oauth-tokens)
|
||||||
on how to get an oauth token for GitHub.
|
on how to get an OAuth token for GitHub.
|
||||||
* **vendor-dir:** Defaults to `vendor`. You can install dependencies into a
|
* **vendor-dir:** Defaults to `vendor`. You can install dependencies into a
|
||||||
different directory if you want to.
|
different directory if you want to.
|
||||||
* **bin-dir:** Defaults to `vendor/bin`. If a project includes binaries, they
|
* **bin-dir:** Defaults to `vendor/bin`. If a project includes binaries, they
|
||||||
|
|
|
@ -105,14 +105,15 @@ Or, you can increase the limit with a command-line argument:
|
||||||
or ```HKEY_CURRENT_USER\Software\Microsoft\Command Processor```.
|
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.
|
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
|
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.
|
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.
|
[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 <oauthtoken>`
|
2. Add it to the configuration running `composer config -g github-oauth.github.com <oauthtoken>`
|
||||||
|
|
|
@ -18,6 +18,7 @@ namespace Composer\Downloader;
|
||||||
class TransportException extends \RuntimeException
|
class TransportException extends \RuntimeException
|
||||||
{
|
{
|
||||||
protected $headers;
|
protected $headers;
|
||||||
|
protected $response;
|
||||||
|
|
||||||
public function setHeaders($headers)
|
public function setHeaders($headers)
|
||||||
{
|
{
|
||||||
|
@ -28,4 +29,14 @@ class TransportException extends \RuntimeException
|
||||||
{
|
{
|
||||||
return $this->headers;
|
return $this->headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setResponse($response)
|
||||||
|
{
|
||||||
|
$this->response = $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getResponse()
|
||||||
|
{
|
||||||
|
return $this->response;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -297,6 +297,11 @@ class Installer
|
||||||
$eventName = $this->update ? ScriptEvents::POST_UPDATE_CMD : ScriptEvents::POST_INSTALL_CMD;
|
$eventName = $this->update ? ScriptEvents::POST_UPDATE_CMD : ScriptEvents::POST_INSTALL_CMD;
|
||||||
$this->eventDispatcher->dispatchCommandEvent($eventName, $this->devMode);
|
$this->eventDispatcher->dispatchCommandEvent($eventName, $this->devMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$vendorDir = $this->config->get('vendor-dir');
|
||||||
|
if (is_dir($vendorDir)) {
|
||||||
|
touch($vendorDir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
|
|
|
@ -87,9 +87,13 @@ class GitHub
|
||||||
$this->io->write('To revoke access to this token you can visit https://github.com/settings/applications');
|
$this->io->write('To revoke access to this token you can visit https://github.com/settings/applications');
|
||||||
while ($attemptCounter++ < 5) {
|
while ($attemptCounter++ < 5) {
|
||||||
try {
|
try {
|
||||||
$username = $this->io->ask('Username: ');
|
if (empty($otp) || !$this->io->hasAuthentication($originUrl)) {
|
||||||
$password = $this->io->askAndHideAnswer('Password: ');
|
$username = $this->io->ask('Username: ');
|
||||||
$this->io->setAuthentication($originUrl, $username, $password);
|
$password = $this->io->askAndHideAnswer('Password: ');
|
||||||
|
$otp = null;
|
||||||
|
|
||||||
|
$this->io->setAuthentication($originUrl, $username, $password);
|
||||||
|
}
|
||||||
|
|
||||||
// build up OAuth app name
|
// build up OAuth app name
|
||||||
$appName = 'Composer';
|
$appName = 'Composer';
|
||||||
|
@ -97,20 +101,81 @@ class GitHub
|
||||||
$appName .= ' on ' . trim($output);
|
$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(
|
'http' => array(
|
||||||
'method' => 'POST',
|
'header' => $headers
|
||||||
'follow_location' => false,
|
|
||||||
'header' => "Content-Type: application/json\r\n",
|
|
||||||
'content' => json_encode(array(
|
|
||||||
'scopes' => array('repo'),
|
|
||||||
'note' => $appName,
|
|
||||||
'note_url' => 'https://getcomposer.org/',
|
|
||||||
)),
|
|
||||||
)
|
)
|
||||||
)));
|
)));
|
||||||
|
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) {
|
} catch (TransportException $e) {
|
||||||
if (in_array($e->getCode(), array(403, 401))) {
|
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.');
|
$this->io->write('Invalid credentials.');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,6 +35,7 @@ class RemoteFilesystem
|
||||||
private $options;
|
private $options;
|
||||||
private $disableTls = false;
|
private $disableTls = false;
|
||||||
private $retryTls = true;
|
private $retryTls = true;
|
||||||
|
private $retryAuthFailure;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor.
|
* Constructor.
|
||||||
|
@ -128,12 +129,19 @@ class RemoteFilesystem
|
||||||
$this->fileName = $fileName;
|
$this->fileName = $fileName;
|
||||||
$this->progress = $progress;
|
$this->progress = $progress;
|
||||||
$this->lastProgress = null;
|
$this->lastProgress = null;
|
||||||
|
$this->retryAuthFailure = true;
|
||||||
|
|
||||||
// capture username/password from URL if there is one
|
// capture username/password from URL if there is one
|
||||||
if (preg_match('{^https?://(.+):(.+)@([^/]+)}i', $fileUrl, $match)) {
|
if (preg_match('{^https?://(.+):(.+)@([^/]+)}i', $fileUrl, $match)) {
|
||||||
$this->io->setAuthentication($originUrl, urldecode($match[1]), urldecode($match[2]));
|
$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);
|
$options = $this->getOptionsForUrl($originUrl, $additionalOptions, $expectedCommonName);
|
||||||
|
|
||||||
if ($this->io->isDebug()) {
|
if ($this->io->isDebug()) {
|
||||||
|
@ -143,6 +151,9 @@ class RemoteFilesystem
|
||||||
$fileUrl .= (false === strpos($fileUrl, '?') ? '?' : '&') . 'access_token='.$options['github-token'];
|
$fileUrl .= (false === strpos($fileUrl, '?') ? '?' : '&') . 'access_token='.$options['github-token'];
|
||||||
unset($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')));
|
$ctx = StreamContextFactory::getContext($fileUrl, $options, array('notification' => array($this, 'callbackGet')));
|
||||||
|
|
||||||
if ($this->progress) {
|
if ($this->progress) {
|
||||||
|
@ -164,6 +175,10 @@ class RemoteFilesystem
|
||||||
if ($e instanceof TransportException && !empty($http_response_header[0])) {
|
if ($e instanceof TransportException && !empty($http_response_header[0])) {
|
||||||
$e->setHeaders($http_response_header);
|
$e->setHeaders($http_response_header);
|
||||||
}
|
}
|
||||||
|
if ($e instanceof TransportException && $result !== false) {
|
||||||
|
$e->setResponse($result);
|
||||||
|
}
|
||||||
|
$result = false;
|
||||||
}
|
}
|
||||||
if ($errorMessage && !ini_get('allow_url_fopen')) {
|
if ($errorMessage && !ini_get('allow_url_fopen')) {
|
||||||
$errorMessage = 'allow_url_fopen must be enabled in php.ini ('.$errorMessage.')';
|
$errorMessage = 'allow_url_fopen must be enabled in php.ini ('.$errorMessage.')';
|
||||||
|
@ -173,10 +188,16 @@ class RemoteFilesystem
|
||||||
throw $e;
|
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)) {
|
if (!empty($http_response_header[0]) && preg_match('{^HTTP/\S+ ([45]\d\d)}i', $http_response_header[0], $match)) {
|
||||||
$result = false;
|
|
||||||
$errorCode = $match[1];
|
$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
|
// decode gzip
|
||||||
|
@ -272,6 +293,11 @@ class RemoteFilesystem
|
||||||
case STREAM_NOTIFY_FAILURE:
|
case STREAM_NOTIFY_FAILURE:
|
||||||
case STREAM_NOTIFY_AUTH_REQUIRED:
|
case STREAM_NOTIFY_AUTH_REQUIRED:
|
||||||
if (401 === $messageCode) {
|
if (401 === $messageCode) {
|
||||||
|
// Bail if the caller is going to handle authentication failures itself.
|
||||||
|
if (!$this->retryAuthFailure) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (!$this->io->isInteractive()) {
|
if (!$this->io->isInteractive()) {
|
||||||
$message = "The '" . $this->fileUrl . "' URL required authentication.\nYou must be using the interactive console";
|
$message = "The '" . $this->fileUrl . "' URL required authentication.\nYou must be using the interactive console";
|
||||||
|
|
||||||
|
@ -281,12 +307,7 @@ class RemoteFilesystem
|
||||||
$this->promptAuthAndRetry();
|
$this->promptAuthAndRetry();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
if ($notificationCode === STREAM_NOTIFY_AUTH_REQUIRED) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new TransportException('The "'.$this->fileUrl.'" file could not be downloaded ('.trim($message).')', $messageCode);
|
|
||||||
|
|
||||||
case STREAM_NOTIFY_AUTH_RESULT:
|
case STREAM_NOTIFY_AUTH_RESULT:
|
||||||
if (403 === $messageCode) {
|
if (403 === $messageCode) {
|
||||||
|
|
|
@ -81,9 +81,14 @@ class GitHubDriverTest extends \PHPUnit_Framework_TestCase
|
||||||
$remoteFilesystem->expects($this->at(1))
|
$remoteFilesystem->expects($this->at(1))
|
||||||
->method('getContents')
|
->method('getContents')
|
||||||
->with($this->equalTo('github.com'), $this->equalTo('https://api.github.com/authorizations'), $this->equalTo(false))
|
->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))
|
$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')
|
->method('getContents')
|
||||||
->with($this->equalTo('github.com'), $this->equalTo($repoApiUrl), $this->equalTo(false))
|
->with($this->equalTo('github.com'), $this->equalTo($repoApiUrl), $this->equalTo(false))
|
||||||
->will($this->returnValue('{"master_branch": "test_master", "private": true}'));
|
->will($this->returnValue('{"master_branch": "test_master", "private": true}'));
|
||||||
|
|
|
@ -130,18 +130,11 @@ class RemoteFilesystemTest extends \PHPUnit_Framework_TestCase
|
||||||
$this->assertAttributeEquals(50, 'lastProgress', $fs);
|
$this->assertAttributeEquals(50, 'lastProgress', $fs);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCallbackGetNotifyFailure404()
|
public function testCallbackGetPassesThrough404()
|
||||||
{
|
{
|
||||||
$fs = new RemoteFilesystem($this->getMock('Composer\IO\IOInterface'));
|
$fs = new RemoteFilesystem($this->getMock('Composer\IO\IOInterface'));
|
||||||
|
|
||||||
try {
|
$this->assertNull($this->callCallbackGet($fs, STREAM_NOTIFY_FAILURE, 0, 'HTTP/1.1 404 Not Found', 404, 0, 0));
|
||||||
$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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCaptureAuthenticationParamsFromUrl()
|
public function testCaptureAuthenticationParamsFromUrl()
|
||||||
|
|
Loading…
Reference in New Issue