Эх сурвалжийг харах

Merge remote-tracking branch 'wenkepaul/master'

Jordi Boggiano 9 жил өмнө
parent
commit
2c9326bacb

+ 1 - 1
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 `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,
 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).
 [spec from the config](06-config.md#gitlab-oauth).
 
 
 ### COMPOSER_DISCARD_CHANGES
 ### COMPOSER_DISCARD_CHANGES

+ 6 - 0
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
 then you can disable it, but using [Let's Encrypt](https://letsencrypt.org/) to
 get a free SSL certificate is generally a better alternative.
 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
 ## cafile
 
 
 Location of Certificate Authority file on local filesystem. In PHP 5.6+ you
 Location of Certificate Authority file on local filesystem. In PHP 5.6+ you

+ 11 - 0
src/Composer/Command/ConfigCommand.php

@@ -491,6 +491,17 @@ EOT
             return;
             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');
         throw new \InvalidArgumentException('Setting '.$settingKey.' does not exist or is not supported by this command');
     }
     }
 
 

+ 2 - 0
src/Composer/Config.php

@@ -46,6 +46,7 @@ class Config
         'classmap-authoritative' => false,
         'classmap-authoritative' => false,
         'prepend-autoloader' => true,
         'prepend-autoloader' => true,
         'github-domains' => array('github.com'),
         'github-domains' => array('github.com'),
+        'bitbucket-expose-hostname' => true,
         'disable-tls' => false,
         'disable-tls' => false,
         'secure-http' => true,
         'secure-http' => true,
         'cafile' => null,
         'cafile' => null,
@@ -60,6 +61,7 @@ class Config
         // github-oauth
         // github-oauth
         // gitlab-oauth
         // gitlab-oauth
         // http-basic
         // http-basic
+        // bitbucket-oauth
     );
     );
 
 
     public static $defaultRepositories = array(
     public static $defaultRepositories = array(

+ 1 - 1
src/Composer/Config/JsonConfigSource.php

@@ -81,7 +81,7 @@ class JsonConfigSource implements ConfigSourceInterface
     {
     {
         $authConfig = $this->authConfig;
         $authConfig = $this->authConfig;
         $this->manipulateJson('addConfigSetting', $name, $value, function (&$config, $key, $val) use ($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);
                 list($key, $host) = explode('.', $key, 2);
                 if ($authConfig) {
                 if ($authConfig) {
                     $config[$key][$host] = $val;
                     $config[$key][$host] = $val;

+ 5 - 0
src/Composer/IO/BaseIO.php

@@ -88,6 +88,7 @@ abstract class BaseIO implements IOInterface
         $githubOauth = $config->get('github-oauth') ?: array();
         $githubOauth = $config->get('github-oauth') ?: array();
         $gitlabOauth = $config->get('gitlab-oauth') ?: array();
         $gitlabOauth = $config->get('gitlab-oauth') ?: array();
         $httpBasic = $config->get('http-basic') ?: array();
         $httpBasic = $config->get('http-basic') ?: array();
+        $bitbucketOauth = $config->get('bitbucket-oauth') ?: array();
 
 
         // reload oauth token from config if available
         // reload oauth token from config if available
         foreach ($githubOauth as $domain => $token) {
         foreach ($githubOauth as $domain => $token) {
@@ -106,6 +107,10 @@ abstract class BaseIO implements IOInterface
             $this->checkAndSetAuthentication($domain, $cred['username'], $cred['password']);
             $this->checkAndSetAuthentication($domain, $cred['username'], $cred['password']);
         }
         }
 
 
+        foreach ($bitbucketOauth as $domain => $cred) {
+            $this->checkAndSetAuthentication($domain, $cred['consumer-key'], $cred['consumer-secret']);
+        }
+
         // setup process timeout
         // setup process timeout
         ProcessExecutor::setTimeout((int) $config->get('process-timeout'));
         ProcessExecutor::setTimeout((int) $config->get('process-timeout'));
     }
     }

+ 183 - 0
src/Composer/Util/Bitbucket.php

@@ -0,0 +1,183 @@
+<?php
+
+/*
+ * This file is part of Composer.
+ *
+ * (c) Nils Adermann <naderman@naderman.de>
+ *     Jordi Boggiano <j.boggiano@seld.be>
+ *
+ * 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 <wenke.paul@gmail.com>
+ */
+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('<error>Invalid consumer provided.</error>');
+                $this->io->writeError('You can also add it manually later by using "composer config bitbucket-oauth.bitbucket.org <consumer-key> <consumer-secret>"');
+
+                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('<warning>No consumer key given, aborting.</warning>');
+            $this->io->writeError('You can also add it manually later by using "composer config bitbucket-oauth.bitbucket.org <consumer-key> <consumer-secret>"');
+
+            return false;
+        }
+
+        $consumerSecret = trim($this->io->askAndHideAnswer('Consumer Secret (hidden): '));
+
+        if (!$consumerSecret) {
+            $this->io->writeError('<warning>No consumer secret given, aborting.</warning>');
+            $this->io->writeError('You can also add it manually later by using "composer config bitbucket-oauth.bitbucket.org <consumer-key> <consumer-secret>"');
+
+            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('<info>Consumer stored successfully.</info>');
+
+        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;
+    }
+}

+ 31 - 1
src/Composer/Util/Git.php

@@ -107,7 +107,37 @@ 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]);
-                    $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);
                     $command = call_user_func($commandCallable, $authUrl);
                     if (0 === $this->process->execute($command, $ignoredOutput, $cwd)) {
                     if (0 === $this->process->execute($command, $ignoredOutput, $cwd)) {
                         return;
                         return;

+ 4 - 0
src/Composer/Util/StreamContextFactory.php

@@ -129,6 +129,10 @@ final class StreamContextFactory
             $options['http']['header'] = self::fixHttpHeaderField($options['http']['header']);
             $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')) {
         if (defined('HHVM_VERSION')) {
             $phpVersion = 'HHVM ' . HHVM_VERSION;
             $phpVersion = 'HHVM ' . HHVM_VERSION;
         } else {
         } else {

+ 136 - 0
tests/Composer/Test/Util/BitbucketTest.php

@@ -0,0 +1,136 @@
+<?php
+
+/*
+ * This file is part of Composer.
+ *
+ * (c) Nils Adermann <naderman@naderman.de>
+ *     Jordi Boggiano <j.boggiano@seld.be>
+ *
+ * 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 <wenke.paul@gmail.com>
+ */
+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;
+    }
+}