AuthHelper.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. <?php
  2. /*
  3. * This file is part of Composer.
  4. *
  5. * (c) Nils Adermann <naderman@naderman.de>
  6. * Jordi Boggiano <j.boggiano@seld.be>
  7. *
  8. * For the full copyright and license information, please view the LICENSE
  9. * file that was distributed with this source code.
  10. */
  11. namespace Composer\Util;
  12. use Composer\Config;
  13. use Composer\IO\IOInterface;
  14. use Composer\Downloader\TransportException;
  15. /**
  16. * @author Jordi Boggiano <j.boggiano@seld.be>
  17. */
  18. class AuthHelper
  19. {
  20. protected $io;
  21. protected $config;
  22. public function __construct(IOInterface $io, Config $config)
  23. {
  24. $this->io = $io;
  25. $this->config = $config;
  26. }
  27. /**
  28. * @param string $origin
  29. * @param string|bool $storeAuth
  30. */
  31. public function storeAuth($origin, $storeAuth)
  32. {
  33. $store = false;
  34. $configSource = $this->config->getAuthConfigSource();
  35. if ($storeAuth === true) {
  36. $store = $configSource;
  37. } elseif ($storeAuth === 'prompt') {
  38. $answer = $this->io->askAndValidate(
  39. 'Do you want to store credentials for '.$origin.' in '.$configSource->getName().' ? [Yn] ',
  40. function ($value) {
  41. $input = strtolower(substr(trim($value), 0, 1));
  42. if (in_array($input, array('y','n'))) {
  43. return $input;
  44. }
  45. throw new \RuntimeException('Please answer (y)es or (n)o');
  46. },
  47. null,
  48. 'y'
  49. );
  50. if ($answer === 'y') {
  51. $store = $configSource;
  52. }
  53. }
  54. if ($store) {
  55. $store->addConfigSetting(
  56. 'http-basic.'.$origin,
  57. $this->io->getAuthentication($origin)
  58. );
  59. }
  60. }
  61. /**
  62. * @param string $url
  63. * @param string $origin
  64. * @param int $statusCode HTTP status code that triggered this call
  65. * @param string|null $reason a message/description explaining why this was called
  66. * @param string[] $headers
  67. * @return array|null containing retry (bool) and storeAuth (string|bool) keys, if retry is true the request should be
  68. * retried, if storeAuth is true then on a successful retry the authentication should be persisted to auth.json
  69. */
  70. public function promptAuthIfNeeded($url, $origin, $statusCode, $reason = null, $headers = array())
  71. {
  72. $storeAuth = false;
  73. $retry = false;
  74. if (in_array($origin, $this->config->get('github-domains'), true)) {
  75. $gitHubUtil = new GitHub($this->io, $this->config, null);
  76. $message = "\n";
  77. $rateLimited = $gitHubUtil->isRateLimited($headers);
  78. if ($rateLimited) {
  79. $rateLimit = $gitHubUtil->getRateLimit($headers);
  80. if ($this->io->hasAuthentication($origin)) {
  81. $message = 'Review your configured GitHub OAuth token or enter a new one to go over the API rate limit.';
  82. } else {
  83. $message = 'Create a GitHub OAuth token to go over the API rate limit.';
  84. }
  85. $message = sprintf(
  86. 'GitHub API limit (%d calls/hr) is exhausted, could not fetch '.$url.'. '.$message.' You can also wait until %s for the rate limit to reset.',
  87. $rateLimit['limit'],
  88. $rateLimit['reset']
  89. )."\n";
  90. } else {
  91. $message .= 'Could not fetch '.$url.', please ';
  92. if ($this->io->hasAuthentication($origin)) {
  93. $message .= 'review your configured GitHub OAuth token or enter a new one to access private repos';
  94. } else {
  95. $message .= 'create a GitHub OAuth token to access private repos';
  96. }
  97. }
  98. if (!$gitHubUtil->authorizeOAuth($origin)
  99. && (!$this->io->isInteractive() || !$gitHubUtil->authorizeOAuthInteractively($origin, $message))
  100. ) {
  101. throw new TransportException('Could not authenticate against '.$origin, 401);
  102. }
  103. } elseif (in_array($origin, $this->config->get('gitlab-domains'), true)) {
  104. $message = "\n".'Could not fetch '.$url.', enter your ' . $origin . ' credentials ' .($statusCode === 401 ? 'to access private repos' : 'to go over the API rate limit');
  105. $gitLabUtil = new GitLab($this->io, $this->config, null);
  106. if ($this->io->hasAuthentication($origin) && ($auth = $this->io->getAuthentication($origin)) && $auth['password'] === 'private-token') {
  107. throw new TransportException("Invalid credentials for '" . $url . "', aborting.", $statusCode);
  108. }
  109. if (!$gitLabUtil->authorizeOAuth($origin)
  110. && (!$this->io->isInteractive() || !$gitLabUtil->authorizeOAuthInteractively(parse_url($url, PHP_URL_SCHEME), $origin, $message))
  111. ) {
  112. throw new TransportException('Could not authenticate against '.$origin, 401);
  113. }
  114. } elseif ($origin === 'bitbucket.org') {
  115. $askForOAuthToken = true;
  116. if ($this->io->hasAuthentication($origin)) {
  117. $auth = $this->io->getAuthentication($origin);
  118. if ($auth['username'] !== 'x-token-auth') {
  119. $bitbucketUtil = new Bitbucket($this->io, $this->config);
  120. $accessToken = $bitbucketUtil->requestToken($origin, $auth['username'], $auth['password']);
  121. if (!empty($accessToken)) {
  122. $this->io->setAuthentication($origin, 'x-token-auth', $accessToken);
  123. $askForOAuthToken = false;
  124. }
  125. } else {
  126. throw new TransportException('Could not authenticate against ' . $origin, 401);
  127. }
  128. }
  129. if ($askForOAuthToken) {
  130. $message = "\n".'Could not fetch ' . $url . ', please create a bitbucket OAuth token to ' . (($statusCode === 401 || $statusCode === 403) ? 'access private repos' : 'go over the API rate limit');
  131. $bitBucketUtil = new Bitbucket($this->io, $this->config);
  132. if (! $bitBucketUtil->authorizeOAuth($origin)
  133. && (! $this->io->isInteractive() || !$bitBucketUtil->authorizeOAuthInteractively($origin, $message))
  134. ) {
  135. throw new TransportException('Could not authenticate against ' . $origin, 401);
  136. }
  137. }
  138. } else {
  139. // 404s are only handled for github
  140. if ($statusCode === 404) {
  141. return;
  142. }
  143. // fail if the console is not interactive
  144. if (!$this->io->isInteractive()) {
  145. if ($statusCode === 401) {
  146. $message = "The '" . $url . "' URL required authentication.\nYou must be using the interactive console to authenticate";
  147. }
  148. if ($statusCode === 403) {
  149. $message = "The '" . $url . "' URL could not be accessed: " . $reason;
  150. }
  151. throw new TransportException($message, $statusCode);
  152. }
  153. // fail if we already have auth
  154. if ($this->io->hasAuthentication($origin)) {
  155. throw new TransportException("Invalid credentials for '" . $url . "', aborting.", $statusCode);
  156. }
  157. $this->io->writeError(' Authentication required (<info>'.parse_url($url, PHP_URL_HOST).'</info>):');
  158. $username = $this->io->ask(' Username: ');
  159. $password = $this->io->askAndHideAnswer(' Password: ');
  160. $this->io->setAuthentication($origin, $username, $password);
  161. $storeAuth = $this->config->get('store-auths');
  162. }
  163. $retry = true;
  164. return array('retry' => $retry, 'storeAuth' => $storeAuth);
  165. }
  166. /**
  167. * @param array $headers
  168. * @param string $origin
  169. * @param string $url
  170. * @return array updated headers array
  171. */
  172. public function addAuthenticationHeader(array $headers, $origin, $url)
  173. {
  174. if ($this->io->hasAuthentication($origin)) {
  175. $auth = $this->io->getAuthentication($origin);
  176. if ('github.com' === $origin && 'x-oauth-basic' === $auth['password']) {
  177. $headers[] = 'Authorization: token '.$auth['username'];
  178. } elseif (in_array($origin, $this->config->get('gitlab-domains'), true)) {
  179. if ($auth['password'] === 'oauth2') {
  180. $headers[] = 'Authorization: Bearer '.$auth['username'];
  181. } elseif ($auth['password'] === 'private-token') {
  182. $headers[] = 'PRIVATE-TOKEN: '.$auth['username'];
  183. }
  184. } elseif (
  185. 'bitbucket.org' === $origin
  186. && $url !== Bitbucket::OAUTH2_ACCESS_TOKEN_URL
  187. && 'x-token-auth' === $auth['username']
  188. ) {
  189. if (!$this->isPublicBitBucketDownload($url)) {
  190. $headers[] = 'Authorization: Bearer ' . $auth['password'];
  191. }
  192. } else {
  193. $authStr = base64_encode($auth['username'] . ':' . $auth['password']);
  194. $headers[] = 'Authorization: Basic '.$authStr;
  195. }
  196. }
  197. return $headers;
  198. }
  199. /**
  200. * @link https://github.com/composer/composer/issues/5584
  201. *
  202. * @param string $urlToBitBucketFile URL to a file at bitbucket.org.
  203. *
  204. * @return bool Whether the given URL is a public BitBucket download which requires no authentication.
  205. */
  206. public function isPublicBitBucketDownload($urlToBitBucketFile)
  207. {
  208. $domain = parse_url($urlToBitBucketFile, PHP_URL_HOST);
  209. if (strpos($domain, 'bitbucket.org') === false) {
  210. // Bitbucket downloads are hosted on amazonaws.
  211. // We do not need to authenticate there at all
  212. return true;
  213. }
  214. $path = parse_url($urlToBitBucketFile, PHP_URL_PATH);
  215. // Path for a public download follows this pattern /{user}/{repo}/downloads/{whatever}
  216. // {@link https://blog.bitbucket.org/2009/04/12/new-feature-downloads/}
  217. $pathParts = explode('/', $path);
  218. return count($pathParts) >= 4 && $pathParts[3] == 'downloads';
  219. }
  220. }