From 3b01d26d67f385874b9a50246630d2a97be6d5cd Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Thu, 18 Oct 2012 16:02:24 +0200 Subject: [PATCH] Swap user credentials for an OAuth token from GitHub --- src/Composer/Downloader/GitDownloader.php | 12 +- .../Downloader/TransportException.php | 11 + src/Composer/Repository/Vcs/GitHubDriver.php | 192 ++++++++++++------ src/Composer/Util/RemoteFilesystem.php | 50 +++-- 4 files changed, 181 insertions(+), 84 deletions(-) diff --git a/src/Composer/Downloader/GitDownloader.php b/src/Composer/Downloader/GitDownloader.php index 3162e62cb..c7a829f5e 100644 --- a/src/Composer/Downloader/GitDownloader.php +++ b/src/Composer/Downloader/GitDownloader.php @@ -51,10 +51,12 @@ class GitDownloader extends VcsDownloader $this->io->write(" Checking out ".$ref); $command = 'cd %s && git remote set-url composer %s && git fetch composer && git fetch --tags composer'; - // capture username/password from github URL if there is one - $this->process->execute(sprintf('cd %s && git remote -v', escapeshellarg($path)), $output); - if (preg_match('{^composer\s+https://(.+):(.+)@github.com/}im', $output, $match)) { - $this->io->setAuthorization('github.com', $match[1], $match[2]); + if (!$this->io->hasAuthorization('github.com')) { + // capture username/password from github URL if there is one + $this->process->execute(sprintf('cd %s && git remote -v', escapeshellarg($path)), $output); + if (preg_match('{^composer\s+https://(.+):(.+)@github.com/}im', $output, $match)) { + $this->io->setAuthorization('github.com', $match[1], $match[2]); + } } $commandCallable = function($url) use ($ref, $path, $command) { @@ -327,7 +329,7 @@ class GitDownloader extends VcsDownloader protected function sanitizeUrl($message) { - return preg_match('{://(.+?):.+?@}', '://$1:***@', $message); + return preg_replace('{://(.+?):.+?@}', '://$1:***@', $message); } protected function setPushUrl(PackageInterface $package, $path) diff --git a/src/Composer/Downloader/TransportException.php b/src/Composer/Downloader/TransportException.php index 61bd67d11..d157dde3c 100644 --- a/src/Composer/Downloader/TransportException.php +++ b/src/Composer/Downloader/TransportException.php @@ -17,4 +17,15 @@ namespace Composer\Downloader; */ class TransportException extends \Exception { + protected $headers; + + public function setHeaders($headers) + { + $this->headers = $headers; + } + + public function getHeaders() + { + return $this->headers; + } } diff --git a/src/Composer/Repository/Vcs/GitHubDriver.php b/src/Composer/Repository/Vcs/GitHubDriver.php index 5d1965d10..b5f8274bf 100755 --- a/src/Composer/Repository/Vcs/GitHubDriver.php +++ b/src/Composer/Repository/Vcs/GitHubDriver.php @@ -235,6 +235,62 @@ class GitHubDriver extends VcsDriver return 'git@github.com:'.$this->owner.'/'.$this->repository.'.git'; } + /** + * {@inheritDoc} + */ + protected function getContents($url, $tryClone = false) + { + try { + return parent::getContents($url); + } catch (TransportException $e) { + switch ($e->getCode()) { + case 401: + case 404: + if (!$this->io->isInteractive() && $tryClone) { + return $this->attemptCloneFallback($e); + } + + $this->io->write('Your GitHub credentials are required to fetch private repository metadata ('.$this->url.'):'); + $this->authorizeOauth(); + + return parent::getContents($url); + + case 403: + if (!$this->io->isInteractive() && $tryClone) { + return $this->attemptCloneFallback($e); + } + + $rateLimited = false; + foreach ($e->getHeaders() as $header) { + if (preg_match('{^X-RateLimit-Remaining: *0$}i', trim($header))) { + $rateLimited = true; + } + } + + if (!$this->io->hasAuthorization($this->originUrl)) { + if (!$this->io->isInteractive()) { + $this->io->write('GitHub API limit exhausted. Failed to get metadata for the '.$this->url.' repository, try running in interactive mode so that you can enter your GitHub credentials to increase the API limit'); + throw $e; + } + + $this->io->write('API limit exhausted. Enter your GitHub credentials to get a larger API limit ('.$this->url.'):'); + $this->authorizeOauth(); + + return parent::getContents($url); + } + + if ($rateLimited) { + $this->io->write('GitHub API limit exhausted. You are already authorized so you will have to wait a while before doing more requests'); + } + + throw $e; + + default: + throw $e; + } + } + } + /** * Fetch root identifier from GitHub * @@ -243,73 +299,83 @@ class GitHubDriver extends VcsDriver protected function fetchRootIdentifier() { $repoDataUrl = 'https://api.github.com/repos/'.$this->owner.'/'.$this->repository; - $attemptCounter = 0; - while (null === $this->rootIdentifier) { - if (5 == $attemptCounter++) { - throw new \RuntimeException("Either you have entered invalid credentials or this GitHub repository does not exists (404)"); - } - try { - $repoData = JsonFile::parseJson($this->getContents($repoDataUrl), $repoDataUrl); - if (isset($repoData['default_branch'])) { - $this->rootIdentifier = $repoData['default_branch']; - } elseif (isset($repoData['master_branch'])) { - $this->rootIdentifier = $repoData['master_branch']; - } else { - $this->rootIdentifier = 'master'; - } - $this->hasIssues = !empty($repoData['has_issues']); - } catch (TransportException $e) { - switch ($e->getCode()) { - case 401: - case 404: - $this->isPrivate = true; - try { - // If this repository may be private (hard to say for sure, - // GitHub returns 404 for private repositories) and we - // cannot ask for authentication credentials (because we - // are not interactive) then we fallback to GitDriver. - $this->gitDriver = new GitDriver( - $this->generateSshUrl(), - $this->io, - $this->config, - $this->process, - $this->remoteFilesystem - ); - $this->gitDriver->initialize(); + $repoData = JsonFile::parseJson($this->getContents($repoDataUrl, true), $repoDataUrl); + if (null === $repoData && null !== $this->gitDriver) { + return; + } - return; - } catch (\RuntimeException $e) { - $this->gitDriver = null; - if (!$this->io->isInteractive()) { - $this->io->write('Failed to clone the '.$this->generateSshUrl().' repository, try running in interactive mode so that you can enter your username and password'); - throw $e; - } - } - $this->io->write('Authentication required ('.$this->url.'):'); - $username = $this->io->ask('Username: '); - $password = $this->io->askAndHideAnswer('Password: '); - $this->io->setAuthorization($this->originUrl, $username, $password); - break; + $this->isPrivate = !empty($repoData['private']); + if (isset($repoData['default_branch'])) { + $this->rootIdentifier = $repoData['default_branch']; + } elseif (isset($repoData['master_branch'])) { + $this->rootIdentifier = $repoData['master_branch']; + } else { + $this->rootIdentifier = 'master'; + } + $this->hasIssues = !empty($repoData['has_issues']); + } - case 403: - if (!$this->io->hasAuthorization($this->originUrl)) { - if (!$this->io->isInteractive()) { - $this->io->write('API limit exhausted. Failed to clone the '.$this->generateSshUrl().' repository, try running in interactive mode so that you can enter your username and password to increase the API limit'); - throw $e; - } - $this->io->write('API limit exhausted. Authentication required for larger API limit ('.$this->url.'):'); - $username = $this->io->ask('Username: '); - $password = $this->io->askAndHideAnswer('Password: '); - $this->io->setAuthorization($this->originUrl, $username, $password); - } - break; + protected function attemptCloneFallback() + { + $this->isPrivate = true; - default: - throw $e; - break; - } - } + try { + // If this repository may be private (hard to say for sure, + // GitHub returns 404 for private repositories) and we + // cannot ask for authentication credentials (because we + // are not interactive) then we fallback to GitDriver. + $this->gitDriver = new GitDriver( + $this->generateSshUrl(), + $this->io, + $this->config, + $this->process, + $this->remoteFilesystem + ); + $this->gitDriver->initialize(); + + return; + } catch (\RuntimeException $e) { + $this->gitDriver = null; + + $this->io->write('Failed to clone the '.$this->generateSshUrl().' repository, try running in interactive mode so that you can enter your GitHub credentials'); + throw $e; } } + + protected function authorizeOAuth() + { + $attemptCounter = 0; + + $this->io->write('The credentials will be swapped for an OAuth token stored in '.$this->config->get('home').'/config.json, your password will not be stored'); + $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->setAuthorization($this->originUrl, $username, $password); + + $contents = JsonFile::parseJson($this->remoteFilesystem->getContents($this->originUrl, 'https://api.github.com/authorizations', false, array( + 'http' => array( + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\n", + 'content' => '{"scopes":["repo"],"note":"Composer","note_url":"https://getcomposer.org/"}', + ) + ))); + } catch (TransportException $e) { + if (401 === $e->getCode()) { + $this->io->write('Invalid credentials.'); + continue; + } + + throw $e; + } + + $this->io->setAuthorization($this->originUrl, $contents['token'], 'x-oauth-basic'); + + return; + } + + throw new \RuntimeException("Invalid GitHub credentials 5 times in a row, aborting."); + } } diff --git a/src/Composer/Util/RemoteFilesystem.php b/src/Composer/Util/RemoteFilesystem.php index 3555557ee..f7f7f93f9 100644 --- a/src/Composer/Util/RemoteFilesystem.php +++ b/src/Composer/Util/RemoteFilesystem.php @@ -50,12 +50,13 @@ class RemoteFilesystem * @param string $fileUrl The file URL * @param string $fileName the local filename * @param boolean $progress Display the progression + * @param array $options Additional context options * * @return bool true */ - public function copy($originUrl, $fileUrl, $fileName, $progress = true) + public function copy($originUrl, $fileUrl, $fileName, $progress = true, $options = array()) { - $this->get($originUrl, $fileUrl, $fileName, $progress); + $this->get($originUrl, $fileUrl, $options, $fileName, $progress); return $this->result; } @@ -66,12 +67,13 @@ class RemoteFilesystem * @param string $originUrl The origin URL * @param string $fileUrl The file URL * @param boolean $progress Display the progression + * @param array $options Additional context options * * @return string The content */ - public function getContents($originUrl, $fileUrl, $progress = true) + public function getContents($originUrl, $fileUrl, $progress = true, $options = array()) { - $this->get($originUrl, $fileUrl, null, $progress); + $this->get($originUrl, $fileUrl, $options, null, $progress); return $this->result; } @@ -79,14 +81,15 @@ class RemoteFilesystem /** * Get file content or copy action. * - * @param string $originUrl The origin URL - * @param string $fileUrl The file URL - * @param string $fileName the local filename - * @param boolean $progress Display the progression + * @param string $originUrl The origin URL + * @param string $fileUrl The file URL + * @param array $additionalOptions context options + * @param string $fileName the local filename + * @param boolean $progress Display the progression * * @throws TransportException When the file could not be downloaded */ - protected function get($originUrl, $fileUrl, $fileName = null, $progress = true) + protected function get($originUrl, $fileUrl, $additionalOptions = array(), $fileName = null, $progress = true) { $this->bytesMax = 0; $this->result = null; @@ -96,7 +99,7 @@ class RemoteFilesystem $this->progress = $progress; $this->lastProgress = null; - $options = $this->getOptionsForUrl($originUrl); + $options = $this->getOptionsForUrl($originUrl, $additionalOptions); $ctx = StreamContextFactory::getContext($options, array('notification' => array($this, 'callbackGet'))); if ($this->progress) { @@ -110,11 +113,20 @@ class RemoteFilesystem } $errorMessage .= preg_replace('{^file_get_contents\(.*?\): }', '', $msg); }); - $result = file_get_contents($fileUrl, false, $ctx); + try { + $result = file_get_contents($fileUrl, false, $ctx); + } catch (\Exception $e) { + if ($e instanceof TransportException && !empty($http_response_header[0])) { + $e->setHeaders($http_response_header); + } + } if ($errorMessage && !ini_get('allow_url_fopen')) { $errorMessage = 'allow_url_fopen must be enabled in php.ini ('.$errorMessage.')'; } restore_error_handler(); + if (isset($e)) { + throw $e; + } // fix for 5.4.0 https://bugs.php.net/bug.php?id=61336 if (!empty($http_response_header[0]) && preg_match('{^HTTP/\S+ 404}i', $http_response_header[0])) { @@ -239,9 +251,9 @@ class RemoteFilesystem } } - protected function getOptionsForUrl($originUrl) + protected function getOptionsForUrl($originUrl, $additionalOptions) { - $options['http']['header'] = sprintf( + $header = sprintf( "User-Agent: Composer/%s (%s; %s; PHP %s.%s.%s)\r\n", Composer::VERSION, php_uname('s'), @@ -251,16 +263,22 @@ class RemoteFilesystem PHP_RELEASE_VERSION ); if (extension_loaded('zlib')) { - $options['http']['header'] .= 'Accept-Encoding: gzip'."\r\n"; + $header .= 'Accept-Encoding: gzip'."\r\n"; } if ($this->io->hasAuthorization($originUrl)) { $auth = $this->io->getAuthorization($originUrl); $authStr = base64_encode($auth['username'] . ':' . $auth['password']); - $options['http']['header'] .= "Authorization: Basic $authStr\r\n"; + $header .= "Authorization: Basic $authStr\r\n"; } - $options = array_replace_recursive($options, $this->options); + $options = array_replace_recursive($this->options, $additionalOptions); + + if (isset($options['http']['header'])) { + $options['http']['header'] = rtrim($options['http']['header'], "\r\n") . "\r\n" . $header; + } else { + $options['http']['header'] = $header; + } return $options; }