Ver Fonte

Add oauth2 support for gitlab

Roshan Gautam há 10 anos atrás
pai
commit
f870396568

+ 2 - 2
src/Composer/IO/BaseIO.php

@@ -69,9 +69,9 @@ abstract class BaseIO implements IOInterface
             }
         }
 
-        if ($tokens = $config->get('gitlab-tokens')) {
+        if ($tokens = $config->get('gitlab-oauth')) {
             foreach ($tokens as $domain => $token) {
-                $this->setAuthentication($domain, $token, 'gitlab-private-token');
+                $this->setAuthentication($domain, $token, 'oauth2');
             }
         }
 

+ 0 - 1
src/Composer/Repository/Vcs/GitLabDriver.php

@@ -70,7 +70,6 @@ class GitLabDriver extends VcsDriver
         $this->originUrl = $match[2];
         $this->owner = $match[3];
         $this->repository = preg_replace('#(\.git)$#', '', $match[4]);
-
         $this->cache = new Cache($this->io, $this->config->get('cache-repo-dir').'/'.$this->originUrl.'/'.$this->owner.'/'.$this->repository);
 
         $this->fetchProject();

+ 28 - 3
src/Composer/Util/Git.php

@@ -80,13 +80,16 @@ class Git
             $this->throwException('Failed to clone ' . self::sanitizeUrl($url) .' via '.implode(', ', $protocols).' protocols, aborting.' . "\n\n" . implode("\n", $messages), $url);
         }
 
-        // if we have a private github url and the ssh protocol is disabled then we skip it and directly fallback to https
+        // if we have a private github/gitlab url and the ssh protocol is disabled then we skip it and directly fallback to https
         $bypassSshForGitHub = preg_match('{^git@'.self::getGitHubDomainsRegex($this->config).':(.+?)\.git$}i', $url) && !in_array('ssh', $protocols, true);
+        $bypassSshForGitLab = preg_match('{^git@'.self::getGitLabDomainsRegex($this->config).':(.+?)\.git$}i', $url) && !in_array('ssh', $protocols, true);
 
         $command = call_user_func($commandCallable, $url);
-        if ($bypassSshForGitHub || 0 !== $this->process->execute($command, $ignoredOutput, $cwd)) {
-            // private github repository without git access, try https with auth
+
+        if ($bypassSshForGitHub || $bypassSshForGitLab || 0 !== $this->process->execute($command, $ignoredOutput, $cwd)) {
+            // private github/gitlab repository without git access, try https with auth
             if (preg_match('{^git@'.self::getGitHubDomainsRegex($this->config).':(.+?)\.git$}i', $url, $match)) {
+   
                 if (!$this->io->hasAuthentication($match[1])) {
                     $gitHubUtil = new GitHub($this->io, $this->config, $this->process);
                     $message = 'Cloning failed using an ssh key for authentication, enter your GitHub credentials to access private repos';
@@ -99,7 +102,24 @@ class Git
                 if ($this->io->hasAuthentication($match[1])) {
                     $auth = $this->io->getAuthentication($match[1]);
                     $url = 'https://'.rawurlencode($auth['username']) . ':' . rawurlencode($auth['password']) . '@'.$match[1].'/'.$match[2].'.git';
+                    $command = call_user_func($commandCallable, $url);
+                    if (0 === $this->process->execute($command, $ignoredOutput, $cwd)) {
+                        return;
+                    }
+                }
+            } elseif (preg_match('{^git@'.self::getGitLabDomainsRegex($this->config).':(.+?)\.git$}i', $url, $match)) {
+                if (!$this->io->hasAuthentication($match[1])) {
+                    $gitLabUtil = new GitLab($this->io, $this->config, $this->process);
+                    $message = 'Cloning failed using an ssh key for authentication, enter your GitHub credentials to access private repos';
 
+                    if (!$gitLabUtil->authorizeOAuth($match[1]) && $this->io->isInteractive()) {
+                        $gitLabUtil->authorizeOAuthInteractively($match[1], $message);
+                    }
+                }
+
+                if ($this->io->hasAuthentication($match[1])) {
+                    $auth = $this->io->getAuthentication($match[1]);
+                    $url = 'http://oauth2:' . rawurlencode($auth['username']) . '@'.$match[1].'/'.$match[2].'.git';
                     $command = call_user_func($commandCallable, $url);
                     if (0 === $this->process->execute($command, $ignoredOutput, $cwd)) {
                         return;
@@ -183,6 +203,11 @@ class Git
         return '('.implode('|', array_map('preg_quote', $config->get('github-domains'))).')';
     }
 
+    public static function getGitLabDomainsRegex(Config $config)
+    {
+        return '('.implode('|', array_map('preg_quote', $config->get('gitlab-domains'))).')';
+    }
+
     public static function sanitizeUrl($message)
     {
         return preg_replace('{://([^@]+?):.+?@}', '://$1:***@', $message);

+ 207 - 0
src/Composer/Util/GitLab.php

@@ -0,0 +1,207 @@
+<?php
+
+/*
+ * This file is part of Composer.
+ *
+ * (c) Roshan Gautam <roshan.gautam@hotmail.com>
+ *     
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Composer\Util;
+
+use Composer\IO\IOInterface;
+use Composer\Config;
+use Composer\Downloader\TransportException;
+use Composer\Json\JsonFile;
+
+/**
+ * @author Roshan Gautam <roshan.gautam@hotmail.com>
+ */
+class GitLab
+{
+    protected $io;
+    protected $config;
+    protected $process;
+    protected $remoteFilesystem;
+
+    /**
+     * Constructor.
+     *
+     * @param IOInterface      $io               The IO instance
+     * @param Config           $config           The composer configuration
+     * @param ProcessExecutor  $process          Process instance, injectable for mocking
+     * @param RemoteFilesystem $remoteFilesystem Remote Filesystem, injectable for mocking
+     */
+    public function __construct(IOInterface $io, Config $config, ProcessExecutor $process = null, RemoteFilesystem $remoteFilesystem = null)
+    {
+        $this->io = $io;
+        $this->config = $config;
+        $this->process = $process ?: new ProcessExecutor;
+        $this->remoteFilesystem = $remoteFilesystem ?: new RemoteFilesystem($io, $config);
+    }
+
+    /**
+     * Attempts to authorize a GitLab domain via OAuth
+     *
+     * @param  string $originUrl The host this GitLab instance is located at
+     * @return bool   true on success
+     */
+    public function authorizeOAuth($originUrl)
+    {
+        if (!in_array($originUrl, $this->config->get('gitlab-domains'))) {
+            return false;
+        }
+
+        // if available use token from git config
+        if (0 === $this->process->execute('git config gitlab.accesstoken', $output)) {
+            $this->io->setAuthentication($originUrl, trim($output), 'oauth2');
+
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Authorizes a GitLab domain interactively via OAuth
+     *
+     * @param  string                        $originUrl The host this GitLab instance is located at
+     * @param  string                        $message   The reason this authorization is required
+     * @throws \RuntimeException
+     * @throws TransportException|\Exception
+     * @return bool                          true on success
+     */
+    public function authorizeOAuthInteractively($originUrl, $message = null)
+    {
+        if ($message) {
+            $this->io->writeError($message);
+        }
+
+
+        $this->io->writeError(sprintf('A token will be created and stored in "%s", your password will never be stored', $this->config->getAuthConfigSource()->getName()));
+        $this->io->writeError('To revoke access to this token you can visit ' . $this->config->get('gitlab-domains')[0] . '/profile/applications');
+
+        $otp = null;
+        $attemptCounter = 0;
+
+        while ($attemptCounter++ < 5) {
+            try {
+                $response = $this->createToken($originUrl, $otp);
+            } catch (TransportException $e) {
+                // https://developer.GitLab.com/v3/#authentication && https://developer.GitLab.com/v3/auth/#working-with-two-factor-authentication
+                // 401 is bad credentials, or missing otp code
+                // 403 is max login attempts exceeded
+                if (in_array($e->getCode(), array(403, 401))) {
+                    // in case of a 401, and authentication was previously provided
+                    if (401 === $e->getCode() && $this->io->hasAuthentication($originUrl)) {
+                        // check for the presence of otp headers and get otp code from user
+                        $otp = $this->checkTwoFactorAuthentication($e->getHeaders());
+                        // if given, retry creating a token using the user provided code
+                        if (null !== $otp) {
+                            continue;
+                        }
+                    }
+
+                    if (401 === $e->getCode()) {
+                        $this->io->writeError('Bad credentials.');
+                    } else {
+                        $this->io->writeError('Maximum number of login attempts exceeded. Please try again later.');
+                    }
+
+                    $this->io->writeError('You can also manually create a personal token at ' . $this->config->get('gitlab-domains')[0] . '/profile/applications');
+                    $this->io->writeError('Add it using "composer config gitlab-oauth.' . $this->config->get('gitlab-domains')[0] . ' <token>"');
+
+                    continue;
+                }
+
+                throw $e;
+            }
+
+            $this->io->setAuthentication($originUrl, $response['access_token'], 'oauth2');
+            $this->config->getConfigSource()->removeConfigSetting('gitlab-oauth.'.$originUrl);
+            // store value in user config
+            $this->config->getAuthConfigSource()->addConfigSetting('gitlab-oauth.'.$originUrl, $response['access_token']);
+
+            return true;
+        }
+
+        throw new \RuntimeException("Invalid GitLab credentials 5 times in a row, aborting.");
+    }
+
+    private function createToken($originUrl, $otp = null)
+    {
+        if (null === $otp || !$this->io->hasAuthentication($originUrl)) {
+            $username = $this->io->ask('Username: ');
+            $password = $this->io->askAndHideAnswer('Password: ');
+
+            $this->io->setAuthentication($originUrl, $username, $password);
+        }
+
+
+        $headers = array('Content-Type: application/x-www-form-urlencoded');
+        if ($otp) {
+            $headers[] = 'X-GitLab-OTP: ' . $otp;
+        }
+
+        $note = 'Composer';
+        if ($this->config->get('GitLab-expose-hostname') === true && 0 === $this->process->execute('hostname', $output)) {
+            $note .= ' on ' . trim($output);
+        }
+        $note .= ' [' . date('YmdHis') . ']';
+
+        $apiUrl = $originUrl ;
+        $data = http_build_query(
+            array(
+                'username'  => $username,
+                'password'  => $password,
+                'grant_type' => 'password',            
+                )
+            );
+        $options = array(
+            'retry-auth-failure' => false,
+            'http' => array(
+                'method' => 'POST',
+                'header' => $headers,
+                'content' => $data
+            ));
+
+        $json = $this->remoteFilesystem->getContents($originUrl, 'http://'. $apiUrl . '/oauth/token', false, $options);
+
+        $this->io->writeError('Token successfully created');
+
+        return JsonFile::parseJson($json);
+    }
+
+    private function checkTwoFactorAuthentication(array $headers)
+    {
+        $headerNames = array_map(
+            function ($header) {
+                return strtolower(strstr($header, ':', true));
+            },
+            $headers
+        );
+
+        if (false !== ($key = array_search('x-GitLab-otp', $headerNames))) {
+            list($required, $method) = array_map('trim', explode(';', substr(strstr($headers[$key], ':'), 1)));
+
+            if ('required' === $required) {
+                $this->io->writeError('Two-factor Authentication');
+
+                if ('app' === $method) {
+                    $this->io->writeError('Open the two-factor authentication app on your device to view your authentication code and verify your identity.');
+                }
+
+                if ('sms' === $method) {
+                    $this->io->writeError('You have been sent an SMS message with an authentication code to verify your identity.');
+                }
+
+                return $this->io->ask('Authentication Code: ');
+            }
+        }
+
+        return null;
+    }
+}

+ 26 - 10
src/Composer/Util/RemoteFilesystem.php

@@ -145,6 +145,12 @@ class RemoteFilesystem
 
         $options = $this->getOptionsForUrl($originUrl, $additionalOptions);
 
+        if (isset($options['retry-auth-failure'])) {
+            $this->retryAuthFailure = (bool) $options['retry-auth-failure'];
+
+            unset($options['retry-auth-failure']);
+        }
+
         if ($this->io->isDebug()) {
             $this->io->writeError((substr($fileUrl, 0, 4) === 'http' ? 'Downloading ' : 'Reading ') . $fileUrl);
         }
@@ -154,15 +160,20 @@ class RemoteFilesystem
             unset($options['github-token']);
         }
 
+        if (isset($options['gitlab-token'])) {
+            $fileUrl .= (false === strpos($fileUrl, '?') ? '?' : '&') . 'access_token='.$options['gitlab-token'];
+            unset($options['gitlab-token']);
+        }
+
         if (isset($options['http'])) {
             $options['http']['ignore_errors'] = true;
         }
+ 
         $ctx = StreamContextFactory::getContext($fileUrl, $options, array('notification' => array($this, 'callbackGet')));
 
         if ($this->progress) {
             $this->io->writeError("    Downloading: <comment>connection...</comment>", false);
         }
-
         $errorMessage = '';
         $errorCode = 0;
         $result = false;
@@ -352,13 +363,12 @@ class RemoteFilesystem
                 throw new TransportException('Could not authenticate against '.$this->originUrl, 401);
             }
         } else if ($this->config && in_array($this->originUrl, $this->config->get('gitlab-domains'), true)) {
-            if ($this->io->isInteractive()) {
-                $this->io->overwrite('Enter your GitLab private token to access API (<info>'.parse_url($this->fileUrl, PHP_URL_HOST).'</info>):');
-                $token = $this->io->askAndHideAnswer('      Private-Token: ');
-                $this->io->setAuthentication($this->originUrl, $token, 'gitlab-private-token');
-                $this->config->getAuthConfigSource()->addConfigSetting('gitlab-tokens.'.$this->originUrl, $token);
-            } else {
-                throw new TransportException("The GitLab URL requires authentication.\nYou must be using the interactive console to authenticate", $httpStatus);
+            $message = "\n".'Could not fetch '.$this->fileUrl.', enter your ' . $this->config->get('gitlab-domains')[0] . ' credentials ' .($httpStatus === 401 ? 'to access private repos' : 'to go over the API rate limit');
+            $gitLabUtil = new GitLab($this->io, $this->config, null);
+            if (!$gitLabUtil->authorizeOAuth($this->originUrl)
+                && (!$this->io->isInteractive() || !$gitLabUtil->authorizeOAuthInteractively($this->originUrl, $message))
+            ) {
+                throw new TransportException('Could not authenticate against '.$this->originUrl, 401);
             }
         } else {
             // 404s are only handled for github
@@ -417,12 +427,15 @@ class RemoteFilesystem
 
         $options = array_replace_recursive($this->options, $additionalOptions);
 
+
         if ($this->io->hasAuthentication($originUrl)) {
             $auth = $this->io->getAuthentication($originUrl);
             if ('github.com' === $originUrl && 'x-oauth-basic' === $auth['password']) {
                 $options['github-token'] = $auth['username'];
-            } elseif ($auth['password'] === 'gitlab-private-token') {
-                $headers[] = 'Private-Token: '.$auth['username'];
+            } elseif ($originUrl === $this->config->get('gitlab-domains')[0]) {
+                if($auth['password'] === 'oauth2') {
+                    $headers[] = 'Authorization: Bearer '.$auth['username'];    
+                }
             } else {
                 $authStr = base64_encode($auth['username'] . ':' . $auth['password']);
                 $headers[] = 'Authorization: Basic '.$authStr;
@@ -436,6 +449,9 @@ class RemoteFilesystem
             $options['http']['header'][] = $header;
         }
 
+        if($this->config && $this->config->get('gitlab-domains') && $originUrl == $this->config->get('gitlab-domains')[0]) {
+            $options['retry-auth-failure'] = false;
+        }
         return $options;
     }
 }