diff --git a/src/Composer/Command/ConfigCommand.php b/src/Composer/Command/ConfigCommand.php index a3e590dc2..66fca1550 100644 --- a/src/Composer/Command/ConfigCommand.php +++ b/src/Composer/Command/ConfigCommand.php @@ -491,6 +491,17 @@ EOT return; } + // handle bitbucket-oauth + if (preg_match('/^(bitbucket-oauth)\.(.+)/', $settingKey, $matches)) { + if (2 !== count($values)) { + throw new \RuntimeException('Excepted two arguments (consumer-key, consumer-secret), got '.count($values)); + } + $this->configSource->removeConfigSetting($matches[1].'.'.$matches[2]); + $this->authConfigSource->addConfigSetting($matches[1].'.'.$matches[2], array('consumer-key' => $values[0], 'consumer-secret' => $values[1])); + + return; + } + throw new \InvalidArgumentException('Setting '.$settingKey.' does not exist or is not supported by this command'); } diff --git a/src/Composer/Config.php b/src/Composer/Config.php index e326cda2e..982f70e62 100644 --- a/src/Composer/Config.php +++ b/src/Composer/Config.php @@ -27,6 +27,7 @@ class Config 'preferred-install' => 'auto', 'notify-on-install' => true, 'github-protocols' => array('https', 'ssh', 'git'), + 'bitbucket-protocols' => array('https'), 'vendor-dir' => 'vendor', 'bin-dir' => '{$vendor-dir}/bin', 'cache-dir' => '{$home}/cache', @@ -45,6 +46,8 @@ class Config 'classmap-authoritative' => false, 'prepend-autoloader' => true, 'github-domains' => array('github.com'), + 'bitbucket-domains' => array('bitbucket.org'), + 'bitbucket-expose-hostname' => true, 'disable-tls' => false, 'secure-http' => true, 'cafile' => null, @@ -59,6 +62,7 @@ class Config // github-oauth // gitlab-oauth // http-basic + // bitbucket-oauth ); public static $defaultRepositories = array( diff --git a/src/Composer/Config/JsonConfigSource.php b/src/Composer/Config/JsonConfigSource.php index 6a4682539..9da47d0c3 100644 --- a/src/Composer/Config/JsonConfigSource.php +++ b/src/Composer/Config/JsonConfigSource.php @@ -81,7 +81,7 @@ class JsonConfigSource implements ConfigSourceInterface { $authConfig = $this->authConfig; $this->manipulateJson('addConfigSetting', $name, $value, function (&$config, $key, $val) use ($authConfig) { - if (preg_match('{^(github-oauth|gitlab-oauth|http-basic|platform)\.}', $key)) { + if (preg_match('{^(github-oauth|gitlab-oauth|http-basic|platform|bitbucket)\.}', $key)) { list($key, $host) = explode('.', $key, 2); if ($authConfig) { $config[$key][$host] = $val; diff --git a/src/Composer/IO/BaseIO.php b/src/Composer/IO/BaseIO.php index 0a787029c..35cf10c42 100644 --- a/src/Composer/IO/BaseIO.php +++ b/src/Composer/IO/BaseIO.php @@ -88,6 +88,7 @@ abstract class BaseIO implements IOInterface $githubOauth = $config->get('github-oauth') ?: array(); $gitlabOauth = $config->get('gitlab-oauth') ?: array(); $httpBasic = $config->get('http-basic') ?: array(); + $bitbucketOauth = $config->get('bitbucket-oauth') ?: array(); // reload oauth token from config if available foreach ($githubOauth as $domain => $token) { @@ -106,6 +107,10 @@ abstract class BaseIO implements IOInterface $this->checkAndSetAuthentication($domain, $cred['username'], $cred['password']); } + foreach ($bitbucketOauth as $domain => $cred) { + $this->checkAndSetAuthentication($domain, $cred['consumer-key'], $cred['consumer-secret']); + } + // setup process timeout ProcessExecutor::setTimeout((int) $config->get('process-timeout')); } diff --git a/src/Composer/Util/Bitbucket.php b/src/Composer/Util/Bitbucket.php new file mode 100644 index 000000000..153a61285 --- /dev/null +++ b/src/Composer/Util/Bitbucket.php @@ -0,0 +1,183 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Util; + +use Composer\Factory; +use Composer\IO\IOInterface; +use Composer\Config; +use Composer\Downloader\TransportException; + +/** + * @author Paul Wenke + */ +class Bitbucket +{ + protected $io; + protected $config; + protected $process; + protected $remoteFilesystem; + protected $token = array(); + + /** + * 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 ?: Factory::createRemoteFilesystem($this->io, $config); + } + + /** + * @return array + */ + public function getToken() + { + return $this->token; + } + + /** + * Attempts to authorize a Bitbucket domain via OAuth + * + * @param string $originUrl The host this Bitbucket instance is located at + * @return bool true on success + */ + public function authorizeOAuth($originUrl) + { + if (!in_array($originUrl, $this->config->get('bitbucket-domains'))) { + return false; + } + + // if available use token from git config + if (0 === $this->process->execute('git config bitbucket.accesstoken', $output)) { + $this->io->setAuthentication($originUrl, trim($output), 'x-oauth-basic'); + + return true; + } + + return false; + } + + /** + * @param string $originUrl + * @return bool + */ + private function requestAccessToken($originUrl) + { + try { + $apiUrl = 'bitbucket.org/site/oauth2/access_token'; + + $json = $this->remoteFilesystem->getContents($originUrl, 'https://'.$apiUrl, false, array( + 'retry-auth-failure' => false, + )); + + $this->token = json_decode($json, true); + } catch (TransportException $e) { + if (in_array($e->getCode(), array(403, 401))) { + $this->io->writeError('Invalid consumer provided.'); + $this->io->writeError('You can also add it manually later by using "composer config bitbucket-oauth.bitbucket.org "'); + + return false; + } + + throw $e; + } + } + + /** + * Authorizes a Bitbucket domain interactively via OAuth + * + * @param string $originUrl The host this Bitbucket 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); + } + + $note = 'Composer'; + if ($this->config->get('bitbucket-expose-hostname') === true && 0 === $this->process->execute('hostname', $output)) { + $note .= ' on ' . trim($output); + } + $note .= ' ' . date('Y-m-d Hi'); + + $url = 'https://confluence.atlassian.com/bitbucket/oauth-on-bitbucket-cloud-238027431.html'; + $this->io->writeError(sprintf('Follow the instructions on %s', $url)); + $this->io->writeError(sprintf('to create a consumer. It will be stored in "%s" for future use by Composer.', $this->config->getAuthConfigSource()->getName())); + + $consumerKey = trim($this->io->askAndHideAnswer('Consumer Key (hidden): ')); + + if (!$consumerKey) { + $this->io->writeError('No consumer key given, aborting.'); + $this->io->writeError('You can also add it manually later by using "composer config bitbucket-oauth.bitbucket.org "'); + + return false; + } + + $consumerSecret = trim($this->io->askAndHideAnswer('Consumer Secret (hidden): ')); + + if (!$consumerSecret) { + $this->io->writeError('No consumer secret given, aborting.'); + $this->io->writeError('You can also add it manually later by using "composer config bitbucket-oauth.bitbucket.org "'); + + return false; + } + + $this->io->setAuthentication($originUrl, $consumerKey, $consumerSecret); + + $this->requestAccessToken($originUrl); + + // store value in user config + $this->config->getConfigSource()->removeConfigSetting('bitbucket-oauth.'.$originUrl); + + $consumer = array( + "consumer-key" => $consumerKey, + "consumer-secret" => $consumerSecret + ); + $this->config->getAuthConfigSource()->addConfigSetting('bitbucket-oauth.'.$originUrl, $consumer); + + $this->io->writeError('Consumer stored successfully.'); + + return true; + } + + /** + * Retrieves an access token from Bitbucket. + * + * @param string $originUrl + * @param string $consumerKey + * @param string $consumerSecret + * @return array + */ + public function requestToken($originUrl, $consumerKey, $consumerSecret) + { + if (!empty($this->token)) { + return $this->token; + } + + $this->io->setAuthentication($originUrl, $consumerKey, $consumerSecret); + $this->requestAccessToken($originUrl); + + return $this->token; + } +} diff --git a/src/Composer/Util/Git.php b/src/Composer/Util/Git.php index 13ee9aecf..d63bdd468 100644 --- a/src/Composer/Util/Git.php +++ b/src/Composer/Util/Git.php @@ -108,7 +108,33 @@ class Git if ($this->io->hasAuthentication($match[1])) { $auth = $this->io->getAuthentication($match[1]); - $authUrl = 'https://'.rawurlencode($auth['username']) . ':' . rawurlencode($auth['password']) . '@'.$match[1].'/'.$match[2].'.git'; + $authUrl = 'https://' . rawurlencode($auth['username']) . ':' . rawurlencode($auth['password']) . '@' . $match[1] . '/' . $match[2] . '.git'; + $command = call_user_func($commandCallable, $authUrl); + if (0 === $this->process->execute($command, $ignoredOutput, $cwd)) { + return; + } + } + } elseif (preg_match('{^(?:https?|git)://'.self::getBitbucketDomainsRegex($this->config).'/(.*)\.git}', $url, $match)) { //bitbucket oauth + $bitbucketUtil = new Bitbucket($this->io, $this->config, $this->process); + + if (!$this->io->hasAuthentication($match[1])) { + $message = 'Cloning failed using an ssh key for authentication, enter your Bitbucket credentials to access private repos'; + + if (!$bitbucketUtil->authorizeOAuth($match[1]) && $this->io->isInteractive()) { + $bitbucketUtil->authorizeOAuthInteractively($match[1], $message); + $token = $bitbucketUtil->getToken(); + $this->io->setAuthentication($match[1], 'x-token-auth', $token['access_token']); + } + } else { //We're authenticating with a locally stored consumer. + $auth = $this->io->getAuthentication($match[1]); + $token = $bitbucketUtil->requestToken($match[1], $auth['username'], $auth['password']); + $this->io->setAuthentication($match[1], 'x-token-auth', $token['access_token']); + } + + if ($this->io->hasAuthentication($match[1])) { + $auth = $this->io->getAuthentication($match[1]); + $authUrl = 'https://' . rawurlencode($auth['username']) . ':' . rawurlencode($auth['password']) . '@' . $match[1] . '/' . $match[2] . '.git'; + $command = call_user_func($commandCallable, $authUrl); if (0 === $this->process->execute($command, $ignoredOutput, $cwd)) { return; @@ -214,6 +240,11 @@ class Git return '('.implode('|', array_map('preg_quote', $config->get('github-domains'))).')'; } + public static function getBitbucketDomainsRegex(Config $config) + { + return '('.implode('|', array_map('preg_quote', $config->get('bitbucket-domains'))).')'; + } + public static function sanitizeUrl($message) { return preg_replace('{://([^@]+?):.+?@}', '://$1:***@', $message); diff --git a/src/Composer/Util/RemoteFilesystem.php b/src/Composer/Util/RemoteFilesystem.php index 7d37b5df3..cfd25aaf5 100644 --- a/src/Composer/Util/RemoteFilesystem.php +++ b/src/Composer/Util/RemoteFilesystem.php @@ -672,6 +672,11 @@ class RemoteFilesystem $authStr = base64_encode($auth['username'] . ':' . $auth['password']); $headers[] = 'Authorization: Basic '.$authStr; } + + if ('bitbucket.org' === $originUrl) { + $options['http']['method'] = 'POST'; + $options['http']['content']['grant_type'] = 'client_credentials'; + } } if (isset($options['http']['header']) && !is_array($options['http']['header'])) { diff --git a/src/Composer/Util/StreamContextFactory.php b/src/Composer/Util/StreamContextFactory.php index 484145f45..ad1f3b956 100644 --- a/src/Composer/Util/StreamContextFactory.php +++ b/src/Composer/Util/StreamContextFactory.php @@ -129,6 +129,10 @@ final class StreamContextFactory $options['http']['header'] = self::fixHttpHeaderField($options['http']['header']); } + if (isset($options['http']['content'])) { + $options['http']['content'] = http_build_query($options['http']['content']); + } + if (defined('HHVM_VERSION')) { $phpVersion = 'HHVM ' . HHVM_VERSION; } else {