diff --git a/doc/03-cli.md b/doc/03-cli.md index 042a4a4f5..a4f7f1f48 100644 --- a/doc/03-cli.md +++ b/doc/03-cli.md @@ -778,7 +778,7 @@ file to be used during SSL/TLS peer verification. The `COMPOSER_AUTH` var allows you to set up authentication as an environment variable. The contents of the variable should be a JSON formatted object containing http-basic, -github-oauth, ... objects as needed, and following the +github-oauth, bitbucket-oauth, ... objects as needed, and following the [spec from the config](06-config.md#gitlab-oauth). ### COMPOSER_DISCARD_CHANGES diff --git a/doc/06-config.md b/doc/06-config.md index 0dfd50bd0..fe4be2b76 100644 --- a/doc/06-config.md +++ b/doc/06-config.md @@ -81,6 +81,12 @@ downloaded via Composer. If you really absolutely need HTTP access to something then you can disable it, but using [Let's Encrypt](https://letsencrypt.org/) to get a free SSL certificate is generally a better alternative. +## bitbucket-oauth + +A list of domain names and consumers. For example using `{"bitbucket.org": +{"consumer-key": "myKey", "consumer-secret": "mySecret"}}`. [Read](https://confluence.atlassian.com/bitbucket/oauth-on-bitbucket-cloud-238027431.html) +how to set up a consumer on Bitbucket. + ## cafile Location of Certificate Authority file on local filesystem. In PHP 5.6+ you diff --git a/src/Composer/Command/ConfigCommand.php b/src/Composer/Command/ConfigCommand.php index a3e590dc2..b83f599de 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('Expected 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 39c539b7e..4c16913cf 100644 --- a/src/Composer/Config.php +++ b/src/Composer/Config.php @@ -46,6 +46,7 @@ class Config 'classmap-authoritative' => false, 'prepend-autoloader' => true, 'github-domains' => array('github.com'), + 'bitbucket-expose-hostname' => true, 'disable-tls' => false, 'secure-http' => true, 'cafile' => null, @@ -60,6 +61,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..ec777100b 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-oauth)\.}', $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..8246b3cb1 --- /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 +{ + private $io; + private $config; + private $process; + private $remoteFilesystem; + private $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 ($originUrl !== 'bitbucket.org') { + return false; + } + + // if available use token from git config + if (0 === $this->process->execute('git config bitbucket.accesstoken', $output)) { + $this->io->setAuthentication($originUrl, 'x-token-auth', trim($output)); + + return true; + } + + return false; + } + + /** + * @param string $originUrl + * @return bool + */ + private function requestAccessToken($originUrl) + { + try { + $apiUrl = 'https://bitbucket.org/site/oauth2/access_token'; + + $json = $this->remoteFilesystem->getContents($originUrl, $apiUrl, false, array( + 'retry-auth-failure' => false, + 'http' => array( + 'method' => 'POST', + 'content' => array( + 'grant_type' => 'client_credentials' + ) + ) + )); + + $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); + } + + $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 57c6b4122..2df7161ed 100644 --- a/src/Composer/Util/Git.php +++ b/src/Composer/Util/Git.php @@ -107,7 +107,37 @@ 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://(bitbucket.org)/(.*)}', $url, $match)) { //bitbucket oauth + $bitbucketUtil = new Bitbucket($this->io, $this->config, $this->process); + + if (!$this->io->hasAuthentication($match[1])) { + $message = '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]); + + //We already have an access_token from a previous request. + if($auth['username'] !== 'x-token-auth') { + $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; diff --git a/src/Composer/Util/StreamContextFactory.php b/src/Composer/Util/StreamContextFactory.php index 484145f45..426eb0273 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']) && (is_array($options['http']['content']) || is_object($options['http']['content']))) { + $options['http']['content'] = http_build_query($options['http']['content']); + } + if (defined('HHVM_VERSION')) { $phpVersion = 'HHVM ' . HHVM_VERSION; } else { diff --git a/tests/Composer/Test/Util/BitbucketTest.php b/tests/Composer/Test/Util/BitbucketTest.php new file mode 100644 index 000000000..5be6da7b0 --- /dev/null +++ b/tests/Composer/Test/Util/BitbucketTest.php @@ -0,0 +1,136 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Util; + +use Composer\Util\Bitbucket; + +/** + * @author Paul Wenke + */ +class BitbucketTest extends \PHPUnit_Framework_TestCase +{ + private $username = 'username'; + private $password = 'password'; + private $authcode = 'authcode'; + private $message = 'mymessage'; + private $origin = 'bitbucket.org'; + private $token = 'bitbuckettoken'; + + public function testUsernamePasswordAuthenticationFlow() + { + $io = $this->getIOMock(); + $io + ->expects($this->at(0)) + ->method('writeError') + ->with($this->message) + ; + + $io->expects($this->exactly(2)) + ->method('askAndHideAnswer') + ->withConsecutive( + array('Consumer Key (hidden): '), + array('Consumer Secret (hidden): ') + ) + ->willReturnOnConsecutiveCalls($this->username, $this->password); + + $rfs = $this->getRemoteFilesystemMock(); + $rfs + ->expects($this->once()) + ->method('getContents') + ->with( + $this->equalTo($this->origin), + $this->equalTo(sprintf('https://%s/site/oauth2/access_token', $this->origin)), + $this->isFalse(), + $this->anything() + ) + ->willReturn(sprintf('{}', $this->token)) + ; + + $config = $this->getConfigMock(); + $config + ->expects($this->exactly(2)) + ->method('getAuthConfigSource') + ->willReturn($this->getAuthJsonMock()) + ; + $config + ->expects($this->once()) + ->method('getConfigSource') + ->willReturn($this->getConfJsonMock()) + ; + + $bitbucket = new Bitbucket($io, $config, null, $rfs); + + $this->assertTrue($bitbucket->authorizeOAuthInteractively($this->origin, $this->message)); + } + + private function getIOMock() + { + $io = $this + ->getMockBuilder('Composer\IO\ConsoleIO') + ->disableOriginalConstructor() + ->getMock() + ; + + return $io; + } + + private function getConfigMock() + { + $config = $this->getMock('Composer\Config'); + + return $config; + } + + private function getRemoteFilesystemMock() + { + $rfs = $this + ->getMockBuilder('Composer\Util\RemoteFilesystem') + ->disableOriginalConstructor() + ->getMock() + ; + + return $rfs; + } + + private function getAuthJsonMock() + { + $authjson = $this + ->getMockBuilder('Composer\Config\JsonConfigSource') + ->disableOriginalConstructor() + ->getMock() + ; + $authjson + ->expects($this->atLeastOnce()) + ->method('getName') + ->willReturn('auth.json') + ; + + return $authjson; + } + + private function getConfJsonMock() + { + $confjson = $this + ->getMockBuilder('Composer\Config\JsonConfigSource') + ->disableOriginalConstructor() + ->getMock() + ; + $confjson + ->expects($this->atLeastOnce()) + ->method('removeConfigSetting') + ->with('bitbucket-oauth.'.$this->origin) + ; + + return $confjson; + } +}