Bladeren bron

Rename basic-auth to http-basic, add docs/schema/config support, add local auth file support, add storage to auth.json, add store-auths config option, refs #1862

Jordi Boggiano 10 jaren geleden
bovenliggende
commit
90d1b6e08a

+ 11 - 0
doc/04-schema.md

@@ -743,6 +743,9 @@ The following options are supported:
 * **preferred-install:** Defaults to `auto` and can be any of `source`, `dist` or
   `auto`. This option allows you to set the install method Composer will prefer to
   use.
+* **store-auths:** What to do after prompting for authentication, one of:
+  `true` (always store), `false` (do not store) and `"prompt"` (ask every
+  time), defaults to `"prompt"`.
 * **github-protocols:** Defaults to `["git", "https", "ssh"]`. A list of protocols to
   use when cloning from github.com, in priority order. You can reconfigure it to
   for example prioritize the https protocol if you are behind a proxy or have somehow
@@ -753,6 +756,9 @@ The following options are supported:
   rate limiting of their API.
   [Read more](articles/troubleshooting.md#api-rate-limit-and-oauth-tokens)
   on how to get an OAuth token for GitHub.
+* **http-basic:** A list of domain names and username/passwords to authenticate
+  against them. For example using
+  `{"example.org": {"username": "alice", "password": "foo"}` as the value of this option will let composer authenticate against example.org.
 * **vendor-dir:** Defaults to `vendor`. You can install dependencies into a
   different directory if you want to.
 * **bin-dir:** Defaults to `vendor/bin`. If a project includes binaries, they
@@ -802,6 +808,11 @@ Example:
 }
 ```
 
+> **Note:** Authentication-related config options like `http-basic` and
+> `github-oauth` can also be specified inside a `auth.json` file that goes
+> besides your `composer.json`. That way you can gitignore it and every
+> developer can place their own credentials in there.
+
 ### scripts <span>(root-only)</span>
 
 Composer allows you to hook into various parts of the installation process

+ 10 - 1
res/composer-schema.json

@@ -136,6 +136,15 @@
                     "description": "A hash of domain name => github API oauth tokens, typically {\"github.com\":\"<token>\"}.",
                     "additionalProperties": true
                 },
+                "http-basic": {
+                    "type": "object",
+                    "description": "A hash of domain name => {\"username\": \"...\", \"password\": \"...\"}.",
+                    "additionalProperties": true
+                },
+                "store-auths": {
+                    "type": ["string", "boolean"],
+                    "description": "What to do after prompting for authentication, one of: true (store), false (do not store) or \"prompt\" (ask every time), defaults to prompt."
+                },
                 "vendor-dir": {
                     "type": "string",
                     "description": "The location where all packages are installed, defaults to \"vendor\"."
@@ -182,7 +191,7 @@
                 },
                 "optimize-autoloader": {
                     "type": "boolean",
-                    "description": "Always optimize when dumping the autoloader"
+                    "description": "Always optimize when dumping the autoloader."
                 },
                 "prepend-autoloader": {
                     "type": "boolean",

+ 44 - 6
src/Composer/Command/ConfigCommand.php

@@ -53,6 +53,7 @@ class ConfigCommand extends Command
             ->setDefinition(array(
                 new InputOption('global', 'g', InputOption::VALUE_NONE, 'Apply command to the global config file'),
                 new InputOption('editor', 'e', InputOption::VALUE_NONE, 'Open editor'),
+                new InputOption('auth', 'a', InputOption::VALUE_NONE, 'Affect auth config file (only used for --editor)'),
                 new InputOption('unset', null, InputOption::VALUE_NONE, 'Unset the given setting-key'),
                 new InputOption('list', 'l', InputOption::VALUE_NONE, 'List configuration settings'),
                 new InputOption('file', 'f', InputOption::VALUE_REQUIRED, 'If you want to choose a different composer.json or config.json', 'composer.json'),
@@ -113,12 +114,24 @@ EOT
         $this->configFile = new JsonFile($configFile);
         $this->configSource = new JsonConfigSource($this->configFile);
 
+        $authConfigFile = $input->getOption('global')
+            ? ($this->config->get('home') . '/auth.json')
+            : dirname(realpath($input->getOption('file'))) . '/auth.json';
+
+        $this->authConfigFile = new JsonFile($authConfigFile);
+        $this->authConfigSource = new JsonConfigSource($this->authConfigFile, true);
+
         // initialize the global file if it's not there
         if ($input->getOption('global') && !$this->configFile->exists()) {
             touch($this->configFile->getPath());
             $this->configFile->write(array('config' => new \ArrayObject));
             @chmod($this->configFile->getPath(), 0600);
         }
+        if ($input->getOption('global') && !$this->authConfigFile->exists()) {
+            touch($this->authConfigFile->getPath());
+            $this->authConfigFile->write(array('http-basic' => new \ArrayObject, 'github-oauth' => new \ArrayObject));
+            @chmod($this->authConfigFile->getPath(), 0600);
+        }
 
         if (!$this->configFile->exists()) {
             throw new \RuntimeException('No composer.json found in the current directory');
@@ -146,13 +159,15 @@ EOT
                 }
             }
 
-            system($editor . ' ' . $this->configFile->getPath() . (defined('PHP_WINDOWS_VERSION_BUILD') ? '':  ' > `tty`'));
+            $file = $input->getOption('auth') ? $this->authConfigFile->getPath() : $this->configFile->getPath();
+            system($editor . ' ' . $file . (defined('PHP_WINDOWS_VERSION_BUILD') ? '':  ' > `tty`'));
 
             return 0;
         }
 
         if (!$input->getOption('global')) {
             $this->config->merge($this->configFile->read());
+            $this->config->merge(array('config' => $this->authConfigFile->exists() ? $this->authConfigFile->read() : array()));
         }
 
         // List the configuration of the file settings
@@ -236,16 +251,29 @@ EOT
         }
 
         // handle github-oauth
-        if (preg_match('/^github-oauth\.(.+)/', $settingKey, $matches)) {
+        if (preg_match('/^(github-oauth|http-basic)\.(.+)/', $settingKey, $matches)) {
             if ($input->getOption('unset')) {
-                return $this->configSource->removeConfigSetting('github-oauth.'.$matches[1]);
+                $this->authConfigSource->removeConfigSetting($matches[1].'.'.$matches[2]);
+                $this->configSource->removeConfigSetting($matches[1].'.'.$matches[2]);
+
+                return;
             }
 
-            if (1 !== count($values)) {
-                throw new \RuntimeException('Too many arguments, expected only one token');
+            if ($matches[1] === 'github-oauth') {
+                if (1 !== count($values)) {
+                    throw new \RuntimeException('Too many arguments, expected only one token');
+                }
+                $this->configSource->removeConfigSetting($matches[1].'.'.$matches[2]);
+                $this->authConfigSource->addConfigSetting($matches[1].'.'.$matches[2], $values[0]);
+            } elseif ($matches[1] === 'http-basic') {
+                if (2 !== count($values)) {
+                    throw new \RuntimeException('Expected two arguments (username, password), got '.count($values));
+                }
+                $this->configSource->removeConfigSetting($matches[1].'.'.$matches[2]);
+                $this->authConfigSource->addConfigSetting($matches[1].'.'.$matches[2], array('username' => $values[0], 'password' => $values[1]));
             }
 
-            return $this->configSource->addConfigSetting('github-oauth.'.$matches[1], $values[0]);
+            return;
         }
 
         $booleanValidator = function ($val) { return in_array($val, array('true', 'false', '1', '0'), true); };
@@ -259,6 +287,16 @@ EOT
                 function ($val) { return in_array($val, array('auto', 'source', 'dist'), true); },
                 function ($val) { return $val; }
             ),
+            'store-auths' => array(
+                function ($val) { return in_array($val, array('true', 'false', 'prompt'), true); },
+                function ($val) {
+                    if ('prompt' === $val) {
+                        return 'prompt';
+                    }
+
+                    return $val !== 'false' && (bool) $val;
+                }
+            ),
             'notify-on-install' => array($booleanValidator, $booleanNormalizer),
             'vendor-dir' => array('is_string', function ($val) { return $val; }),
             'bin-dir' => array('is_string', function ($val) { return $val; }),

+ 16 - 1
src/Composer/Config.php

@@ -39,6 +39,10 @@ class Config
         'optimize-autoloader' => false,
         'prepend-autoloader' => true,
         'github-domains' => array('github.com'),
+        'store-auths' => 'prompt',
+        // valid keys without defaults (auth config stuff):
+        // github-oauth
+        // http-basic
     );
 
     public static $defaultRepositories = array(
@@ -52,6 +56,7 @@ class Config
     private $config;
     private $repositories;
     private $configSource;
+    private $authConfigSource;
 
     public function __construct()
     {
@@ -70,6 +75,16 @@ class Config
         return $this->configSource;
     }
 
+    public function setAuthConfigSource(ConfigSourceInterface $source)
+    {
+        $this->authConfigSource = $source;
+    }
+
+    public function getAuthConfigSource()
+    {
+        return $this->authConfigSource;
+    }
+
     /**
      * Merges new config values with the existing ones (overriding)
      *
@@ -80,7 +95,7 @@ class Config
         // override defaults with given config
         if (!empty($config['config']) && is_array($config['config'])) {
             foreach ($config['config'] as $key => $val) {
-                if (in_array($key, array('github-oauth')) && isset($this->config[$key])) {
+                if (in_array($key, array('github-oauth', 'http-basic')) && isset($this->config[$key])) {
                     $this->config[$key] = array_merge($this->config[$key], $val);
                 } else {
                     $this->config[$key] = $val;

+ 7 - 0
src/Composer/Config/ConfigSourceInterface.php

@@ -66,4 +66,11 @@ interface ConfigSourceInterface
      * @param string $name Name
      */
     public function removeLink($type, $name);
+
+    /**
+     * Gives a user-friendly name to this source (file path or so)
+     *
+     * @return string
+     */
+    public function getName();
 }

+ 49 - 3
src/Composer/Config/JsonConfigSource.php

@@ -28,14 +28,28 @@ class JsonConfigSource implements ConfigSourceInterface
      */
     private $file;
 
+    /**
+     * @var bool
+     */
+    private $authConfig;
+
     /**
      * Constructor
      *
      * @param JsonFile $file
      */
-    public function __construct(JsonFile $file)
+    public function __construct(JsonFile $file, $authConfig = false)
     {
         $this->file = $file;
+        $this->authConfig = $authConfig;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getName()
+    {
+        return $this->file->getPath();
     }
 
     /**
@@ -64,7 +78,16 @@ class JsonConfigSource implements ConfigSourceInterface
     public function addConfigSetting($name, $value)
     {
         $this->manipulateJson('addConfigSetting', $name, $value, function (&$config, $key, $val) {
-            $config['config'][$key] = $val;
+            if ($key === 'github-oauth' || $key === 'http-basic') {
+                list($key, $host) = explode('.', $key, 2);
+                if ($this->authConfig) {
+                    $config[$key][$host] = $val;
+                } else {
+                    $config['config'][$key][$host] = $val;
+                }
+            } else {
+                $config['config'][$key] = $val;
+            }
         });
     }
 
@@ -74,7 +97,16 @@ class JsonConfigSource implements ConfigSourceInterface
     public function removeConfigSetting($name)
     {
         $this->manipulateJson('removeConfigSetting', $name, function (&$config, $key) {
-            unset($config['config'][$key]);
+            if ($key === 'github-oauth' || $key === 'http-basic') {
+                list($key, $host) = explode('.', $key, 2);
+                if ($this->authConfig) {
+                    unset($config[$key][$host]);
+                } else {
+                    unset($config['config'][$key][$host]);
+                }
+            } else {
+                unset($config['config'][$key]);
+            }
         });
     }
 
@@ -107,13 +139,27 @@ class JsonConfigSource implements ConfigSourceInterface
 
         if ($this->file->exists()) {
             $contents = file_get_contents($this->file->getPath());
+        } elseif ($this->authConfig) {
+            $contents = "{\n}\n";
         } else {
             $contents = "{\n    \"config\": {\n    }\n}\n";
         }
+
         $manipulator = new JsonManipulator($contents);
 
         $newFile = !$this->file->exists();
 
+        // override manipulator method for auth config files
+        if ($this->authConfig && $method === 'addConfigSetting') {
+            $method = 'addSubNode';
+            list($mainNode, $name) = explode('.', $args[0], 2);
+            $args = array($mainNode, $name, $args[1]);
+        } elseif ($this->authConfig && $method === 'removeConfigSetting') {
+            $method = 'removeSubNode';
+            list($mainNode, $name) = explode('.', $args[0], 2);
+            $args = array($mainNode, $name);
+        }
+
         // try to update cleanly
         if (call_user_func_array(array($manipulator, $method), $args)) {
             file_put_contents($this->file->getPath(), $manipulator->getContents());

+ 17 - 34
src/Composer/Factory.php

@@ -106,12 +106,20 @@ class Factory
         // add dirs to the config
         $config->merge(array('config' => array('home' => $home, 'cache-dir' => $cacheDir)));
 
+        // load global config
         $file = new JsonFile($home.'/config.json');
         if ($file->exists()) {
             $config->merge($file->read());
         }
         $config->setConfigSource(new JsonConfigSource($file));
 
+        // load global auth file
+        $file = new JsonFile($config->get('home').'/auth.json');
+        if ($file->exists()) {
+            $config->merge(array('config' => $file->read()));
+        }
+        $config->setAuthConfigSource(new JsonConfigSource($file, true));
+
         // move old cache dirs to the new locations
         $legacyPaths = array(
             'cache-repo-dir' => array('/cache' => '/http*', '/cache.svn' => '/*', '/cache.github' => '/*'),
@@ -147,26 +155,6 @@ class Factory
         return $config;
     }
 
-    /**
-     * @return Config
-     */
-    protected static function createAuthConfig()
-    {
-        $home = self::getHomeDir();
-
-        $config = new Config();
-        // add dirs to the config
-        $config->merge(array('config' => array('home' => $home)));
-
-        $file = new JsonFile($home.'/auth.json');
-        if ($file->exists()) {
-            $config->merge($file->read());
-        }
-        $config->setConfigSource(new JsonConfigSource($file));
-
-        return $config;
-    }
-
     public static function getComposerFile()
     {
         return trim(getenv('COMPOSER')) ?: './composer.json';
@@ -248,25 +236,20 @@ class Factory
             $localConfig = $file->read();
         }
 
-        // Configuration defaults
+        // Load config and override with local config/auth config
         $config = static::createConfig();
         $config->merge($localConfig);
-        $io->loadConfiguration($config);
-
-        // load separate auth config
-        $authConfig = static::createAuthConfig();
-        if ($basicauth = $authConfig->get('basic-auth')) {
-            foreach ($basicauth as $domain => $credentials) {
-                if(!isset($credentials['username'])) {
-                    continue;
-                }
-                if(!isset($credentials['password'])) {
-                    $credentials['password'] = null;
-                }
-                $io->setAuthentication($domain, $credentials['username'], $credentials['password']);
+        if (isset($composerFile)) {
+            $localAuthFile = new JsonFile(dirname(realpath($composerFile)) . '/auth.json');
+            if ($localAuthFile->exists()) {
+                $config->merge(array('config' => $localAuthFile->read()));
+                $config->setAuthConfigSource(new JsonConfigSource($localAuthFile, true));
             }
         }
 
+        // load auth configs into the IO instance
+        $io->loadConfiguration($config);
+
         $vendorDir = $config->get('vendor-dir');
         $binDir = $config->get('bin-dir');
 

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

@@ -68,5 +68,12 @@ abstract class BaseIO implements IOInterface
                 $this->setAuthentication($domain, $token, 'x-oauth-basic');
             }
         }
+
+        // reload http basic credentials from config if available
+        if ($creds = $config->get('http-basic')) {
+            foreach ($creds as $domain => $cred) {
+                $this->setAuthentication($domain, $cred['username'], $cred['password']);
+            }
+        }
     }
 }

+ 3 - 4
src/Composer/Util/GitHub.php

@@ -83,7 +83,7 @@ class GitHub
         if ($message) {
             $this->io->write($message);
         }
-        $this->io->write('The credentials will be swapped for an OAuth token stored in '.$this->config->get('home').'/config.json, your password will not be stored');
+        $this->io->write('The credentials will be swapped for an OAuth token stored in '.$this->config->getAuthConfigSource()->getName().', your password will not be stored');
         $this->io->write('To revoke access to this token you can visit https://github.com/settings/applications');
         while ($attemptCounter++ < 5) {
             try {
@@ -186,9 +186,8 @@ class GitHub
             $this->io->setAuthentication($originUrl, $contents['token'], 'x-oauth-basic');
 
             // store value in user config
-            $githubTokens = $this->config->get('github-oauth') ?: array();
-            $githubTokens[$originUrl] = $contents['token'];
-            $this->config->getConfigSource()->addConfigSetting('github-oauth', $githubTokens);
+            $this->config->getConfigSource()->removeConfigSetting('github-oauth.'.$originUrl);
+            $this->config->getAuthConfigSource()->addConfigSetting('github-oauth.'.$originUrl, $contents['token']);
 
             return true;
         }

+ 36 - 1
src/Composer/Util/RemoteFilesystem.php

@@ -37,6 +37,7 @@ class RemoteFilesystem
     private $options;
     private $retryAuthFailure;
     private $lastHeaders;
+    private $storeAuth;
 
     /**
      * Constructor.
@@ -249,7 +250,40 @@ class RemoteFilesystem
         if ($this->retry) {
             $this->retry = false;
 
-            return $this->get($this->originUrl, $this->fileUrl, $additionalOptions, $this->fileName, $this->progress);
+            $result = $this->get($this->originUrl, $this->fileUrl, $additionalOptions, $this->fileName, $this->progress);
+
+            $store = false;
+            $configSource = $this->config->getAuthConfigSource();
+            if ($this->storeAuth === true) {
+                $store = $configSource;
+            } elseif ($this->storeAuth === 'prompt') {
+                $answer = $this->io->askAndValidate(
+                    'Do you want to store credentials for '.$this->originUrl.' in '.$configSource->getName().' ? [Yn] ',
+                    function ($value) {
+                        $input = strtolower(substr(trim($value), 0, 1));
+                        if (in_array($input, array('y','n'))) {
+                            return $input;
+                        }
+                        throw new \RuntimeException('Please answer (y)es or (n)o');
+                    },
+                    false,
+                    'y'
+                );
+
+                if ($answer === 'y') {
+                    $store = $configSource;
+                }
+            }
+            if ($store) {
+                $store->addConfigSetting(
+                    'http-basic.'.$this->originUrl,
+                    $this->io->getAuthentication($this->originUrl)
+                );
+            }
+
+            $this->storeAuth = false;
+
+            return $result;
         }
 
         if (false === $result) {
@@ -364,6 +398,7 @@ class RemoteFilesystem
             $username = $this->io->ask('      Username: ');
             $password = $this->io->askAndHideAnswer('      Password: ');
             $this->io->setAuthentication($this->originUrl, $username, $password);
+            $this->storeAuth = $this->config->get('store-auths');
         }
 
         $this->retry = true;

+ 2 - 0
tests/Composer/Test/Repository/Vcs/GitHubDriverTest.php

@@ -94,7 +94,9 @@ class GitHubDriverTest extends \PHPUnit_Framework_TestCase
             ->will($this->returnValue('{"master_branch": "test_master", "private": true}'));
 
         $configSource = $this->getMock('Composer\Config\ConfigSourceInterface');
+        $authConfigSource = $this->getMock('Composer\Config\ConfigSourceInterface');
         $this->config->setConfigSource($configSource);
+        $this->config->setAuthConfigSource($authConfigSource);
 
         $repoConfig = array(
             'url' => $repoUrl,