diff --git a/src/Composer/IO/BaseIO.php b/src/Composer/IO/BaseIO.php index 1b6b0e94e..02c3b6a2d 100644 --- a/src/Composer/IO/BaseIO.php +++ b/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'); } } diff --git a/src/Composer/Repository/Vcs/GitLabDriver.php b/src/Composer/Repository/Vcs/GitLabDriver.php index ae66ab53d..29ff16138 100644 --- a/src/Composer/Repository/Vcs/GitLabDriver.php +++ b/src/Composer/Repository/Vcs/GitLabDriver.php @@ -18,7 +18,7 @@ use Composer\IO\IOInterface; use Composer\Json\JsonFile; use Composer\Downloader\TransportException; use Composer\Util\RemoteFilesystem; - +use Composer\Util\GitLab; /** * Driver for GitLab API, use the Git driver for local checkouts. * @@ -54,6 +54,13 @@ class GitLabDriver extends VcsDriver */ private $branches; + /** + * Git Driver + * + * @var GitDriver + */ + protected $gitDriver; + /** * Extracts information from the repository url. * SSH urls are not supported in order to know the HTTP sheme to use. @@ -248,10 +255,86 @@ class GitLabDriver extends VcsDriver { // we need to fetch the default branch from the api $resource = $this->getApiUrl(); + $this->project = JsonFile::parseJson($this->getContents($resource, true), $resource); + } + + protected function attemptCloneFallback() + { - $this->project = JsonFile::parseJson($this->getContents($resource), $resource); + try { + // If this repository may be private and we + // cannot ask for authentication credentials (because we + // are not interactive) then we fallback to GitDriver. + $this->setupGitDriver($this->generateSshUrl()); + + return; + } catch (\RuntimeException $e) { + $this->gitDriver = null; + + $this->io->writeError('Failed to clone the '.$this->generateSshUrl().' repository, try running in interactive mode so that you can enter your credentials'); + throw $e; + } } + protected function setupGitDriver($url) + { + $this->gitDriver = new GitDriver( + array('url' => $url), + $this->io, + $this->config, + $this->process, + $this->remoteFilesystem + ); + $this->gitDriver->initialize(); + } + + /** + * {@inheritDoc} + */ + protected function getContents($url, $fetchingRepoData = false) + { + try { + return parent::getContents($url); + } catch (TransportException $e) { + $gitLabUtil = new GitLab($this->io, $this->config, $this->process, $this->remoteFilesystem); + + switch ($e->getCode()) { + case 401: + case 404: + // try to authorize only if we are fetching the main /repos/foo/bar data, otherwise it must be a real 404 + if (!$fetchingRepoData) { + throw $e; + } + + if ($gitLabUtil->authorizeOAuth($this->originUrl)) { + return parent::getContents($url); + } + + if (!$this->io->isInteractive()) { + return $this->attemptCloneFallback(); + } + $this->io->writeError('Failed to download ' . $this->owner . '/' . $this->repository . ':' . $e->getMessage() . ''); + $gitLabUtil->authorizeOAuthInteractively($this->originUrl, 'Your credentials are required to fetch private repository metadata ('.$this->url.')'); + + return parent::getContents($url); + + case 403: + if (!$this->io->hasAuthentication($this->originUrl) && $gitLabUtil->authorizeOAuth($this->originUrl)) { + return parent::getContents($url); + } + + if (!$this->io->isInteractive() && $fetchingRepoData) { + return $this->attemptCloneFallback(); + } + + throw $e; + + default: + throw $e; + } + } + } + /** * Uses the config `gitlab-domains` to see if the driver supports the url for the * repository given. diff --git a/src/Composer/Util/Git.php b/src/Composer/Util/Git.php index e965384e7..0e18d8fbc 100644 --- a/src/Composer/Util/Git.php +++ b/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); diff --git a/src/Composer/Util/GitLab.php b/src/Composer/Util/GitLab.php new file mode 100644 index 000000000..8496476b4 --- /dev/null +++ b/src/Composer/Util/GitLab.php @@ -0,0 +1,163 @@ + + * + * + * 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 + */ +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'); + + $attemptCounter = 0; + + while ($attemptCounter++ < 5) { + try { + $response = $this->createToken($originUrl); + } catch (TransportException $e) { + // 401 is bad credentials, + // 403 is max login attempts exceeded + if (in_array($e->getCode(), array(403, 401))) { + + 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] . ' "'); + + 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) + { + if (!$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'); + + $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); + } +} diff --git a/src/Composer/Util/RemoteFilesystem.php b/src/Composer/Util/RemoteFilesystem.php index 159143c1b..cc4b9e583 100644 --- a/src/Composer/Util/RemoteFilesystem.php +++ b/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: connection...", 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 ('.parse_url($this->fileUrl, PHP_URL_HOST).'):'); - $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; } }