瀏覽代碼

Swap user credentials for an OAuth token from GitHub

Jordi Boggiano 12 年之前
父節點
當前提交
3b01d26d67

+ 7 - 5
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)

+ 11 - 0
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;
+    }
 }

+ 128 - 62
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 (<info>'.$this->url.'</info>):');
+                    $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('<error>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</error>');
+                            throw $e;
+                        }
+
+                        $this->io->write('API limit exhausted. Enter your GitHub credentials to get a larger API limit (<info>'.$this->url.'</info>):');
+                        $this->authorizeOauth();
+
+                        return parent::getContents($url);
+                    }
+
+                    if ($rateLimited) {
+                        $this->io->write('<error>GitHub API limit exhausted. You are already authorized so you will have to wait a while before doing more requests</error>');
+                    }
+
+                    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;
+
+        $repoData = JsonFile::parseJson($this->getContents($repoDataUrl, true), $repoDataUrl);
+        if (null === $repoData && null !== $this->gitDriver) {
+            return;
+        }
+
+        $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']);
+    }
+
+    protected function attemptCloneFallback()
+    {
+        $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();
+
+            return;
+        } catch (\RuntimeException $e) {
+            $this->gitDriver = null;
+
+            $this->io->write('<error>Failed to clone the '.$this->generateSshUrl().' repository, try running in interactive mode so that you can enter your GitHub credentials</error>');
+            throw $e;
+        }
+    }
+
+    protected function authorizeOAuth()
+    {
         $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)");
-            }
+
+        $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 {
-                $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']);
+                $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) {
-                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();
-
-                            return;
-                        } catch (\RuntimeException $e) {
-                            $this->gitDriver = null;
-                            if (!$this->io->isInteractive()) {
-                                $this->io->write('<error>Failed to clone the '.$this->generateSshUrl().' repository, try running in interactive mode so that you can enter your username and password</error>');
-                                throw $e;
-                            }
-                        }
-                        $this->io->write('Authentication required (<info>'.$this->url.'</info>):');
-                        $username = $this->io->ask('Username: ');
-                        $password = $this->io->askAndHideAnswer('Password: ');
-                        $this->io->setAuthorization($this->originUrl, $username, $password);
-                        break;
-
-                    case 403:
-                        if (!$this->io->hasAuthorization($this->originUrl)) {
-                            if (!$this->io->isInteractive()) {
-                                $this->io->write('<error>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</error>');
-                                throw $e;
-                            }
-                            $this->io->write('API limit exhausted. Authentication required for larger API limit (<info>'.$this->url.'</info>):');
-                            $username = $this->io->ask('Username: ');
-                            $password = $this->io->askAndHideAnswer('Password: ');
-                            $this->io->setAuthorization($this->originUrl, $username, $password);
-                        }
-                        break;
-
-                    default:
-                        throw $e;
-                        break;
+                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.");
     }
 }

+ 34 - 16
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;
     }