From 90d1b6e08a3135db3edef44e12478ee34f33f933 Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Tue, 27 May 2014 13:50:47 +0200 Subject: [PATCH] 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 --- doc/04-schema.md | 11 ++++ res/composer-schema.json | 11 +++- src/Composer/Command/ConfigCommand.php | 50 +++++++++++++++--- src/Composer/Config.php | 17 +++++- src/Composer/Config/ConfigSourceInterface.php | 7 +++ src/Composer/Config/JsonConfigSource.php | 52 +++++++++++++++++-- src/Composer/Factory.php | 51 ++++++------------ src/Composer/IO/BaseIO.php | 7 +++ src/Composer/Util/GitHub.php | 7 ++- src/Composer/Util/RemoteFilesystem.php | 37 ++++++++++++- .../Test/Repository/Vcs/GitHubDriverTest.php | 2 + 11 files changed, 202 insertions(+), 50 deletions(-) diff --git a/doc/04-schema.md b/doc/04-schema.md index 0ce4d4fdb..8ff2db7f2 100644 --- a/doc/04-schema.md +++ b/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 (root-only) Composer allows you to hook into various parts of the installation process diff --git a/res/composer-schema.json b/res/composer-schema.json index 69d4dc5a1..f41f2fb0b 100644 --- a/res/composer-schema.json +++ b/res/composer-schema.json @@ -136,6 +136,15 @@ "description": "A hash of domain name => github API oauth tokens, typically {\"github.com\":\"\"}.", "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", diff --git a/src/Composer/Command/ConfigCommand.php b/src/Composer/Command/ConfigCommand.php index 48647512f..214f0341f 100644 --- a/src/Composer/Command/ConfigCommand.php +++ b/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; }), diff --git a/src/Composer/Config.php b/src/Composer/Config.php index 89f535725..cfc042465 100644 --- a/src/Composer/Config.php +++ b/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; diff --git a/src/Composer/Config/ConfigSourceInterface.php b/src/Composer/Config/ConfigSourceInterface.php index e1478dbbb..edd3dff8a 100644 --- a/src/Composer/Config/ConfigSourceInterface.php +++ b/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(); } diff --git a/src/Composer/Config/JsonConfigSource.php b/src/Composer/Config/JsonConfigSource.php index 1c12aadcf..a4d97e344 100644 --- a/src/Composer/Config/JsonConfigSource.php +++ b/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()); diff --git a/src/Composer/Factory.php b/src/Composer/Factory.php index 3ab683780..6bfdaf98b 100644 --- a/src/Composer/Factory.php +++ b/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'); diff --git a/src/Composer/IO/BaseIO.php b/src/Composer/IO/BaseIO.php index 29cae4f07..8d684833e 100644 --- a/src/Composer/IO/BaseIO.php +++ b/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']); + } + } } } diff --git a/src/Composer/Util/GitHub.php b/src/Composer/Util/GitHub.php index 69e3b46bf..a0b803db7 100644 --- a/src/Composer/Util/GitHub.php +++ b/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; } diff --git a/src/Composer/Util/RemoteFilesystem.php b/src/Composer/Util/RemoteFilesystem.php index 5e007b894..0eed1b223 100644 --- a/src/Composer/Util/RemoteFilesystem.php +++ b/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; diff --git a/tests/Composer/Test/Repository/Vcs/GitHubDriverTest.php b/tests/Composer/Test/Repository/Vcs/GitHubDriverTest.php index f452f224d..c0d55eeca 100644 --- a/tests/Composer/Test/Repository/Vcs/GitHubDriverTest.php +++ b/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,