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), $resource);
+ $this->project = JsonFile::parseJson($this->getContents($resource, true), $resource);
}
+ protected function attemptCloneFallback()
+ {
+
+ 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;
}
}