1
0
Fork 0

Add oauth2 support for gitlab

pull/3765/head
Roshan Gautam 2015-04-10 21:45:24 +00:00
parent e635a2730c
commit f870396568
5 changed files with 263 additions and 16 deletions

View File

@ -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) { foreach ($tokens as $domain => $token) {
$this->setAuthentication($domain, $token, 'gitlab-private-token'); $this->setAuthentication($domain, $token, 'oauth2');
} }
} }

View File

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

View File

@ -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); $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); $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); $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 (preg_match('{^git@'.self::getGitHubDomainsRegex($this->config).':(.+?)\.git$}i', $url, $match)) {
if (!$this->io->hasAuthentication($match[1])) { if (!$this->io->hasAuthentication($match[1])) {
$gitHubUtil = new GitHub($this->io, $this->config, $this->process); $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'; $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])) { if ($this->io->hasAuthentication($match[1])) {
$auth = $this->io->getAuthentication($match[1]); $auth = $this->io->getAuthentication($match[1]);
$url = 'https://'.rawurlencode($auth['username']) . ':' . rawurlencode($auth['password']) . '@'.$match[1].'/'.$match[2].'.git'; $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); $command = call_user_func($commandCallable, $url);
if (0 === $this->process->execute($command, $ignoredOutput, $cwd)) { if (0 === $this->process->execute($command, $ignoredOutput, $cwd)) {
return; return;
@ -183,6 +203,11 @@ class Git
return '('.implode('|', array_map('preg_quote', $config->get('github-domains'))).')'; 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) public static function sanitizeUrl($message)
{ {
return preg_replace('{://([^@]+?):.+?@}', '://$1:***@', $message); return preg_replace('{://([^@]+?):.+?@}', '://$1:***@', $message);

View File

@ -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;
}
}

View File

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