From a01ab9bbca5c72b16e2a58bc324e9e5d4fb8f972 Mon Sep 17 00:00:00 2001 From: Eirik Stanghelle Morland Date: Wed, 2 Oct 2024 14:36:30 +0200 Subject: [PATCH] Better app password support for bitbucket (#12103) * Fix support for app passwords better, plus better handling of bitbucket repositories stored with ssh --- phpstan/baseline-8.3.neon | 2 +- phpstan/baseline.neon | 4 +- src/Composer/Util/Git.php | 72 ++++++++---- tests/Composer/Test/Util/GitTest.php | 158 +++++++++++++++++++++++++++ 4 files changed, 210 insertions(+), 26 deletions(-) diff --git a/phpstan/baseline-8.3.neon b/phpstan/baseline-8.3.neon index 68bb1e533..9a3a36933 100644 --- a/phpstan/baseline-8.3.neon +++ b/phpstan/baseline-8.3.neon @@ -182,7 +182,7 @@ parameters: - message: "#^Parameter \\#1 \\$string of function rawurlencode expects string, string\\|null given\\.$#" - count: 8 + count: 10 path: ../src/Composer/Util/Git.php - diff --git a/phpstan/baseline.neon b/phpstan/baseline.neon index dc754784e..865d93b72 100644 --- a/phpstan/baseline.neon +++ b/phpstan/baseline.neon @@ -3290,7 +3290,7 @@ parameters: - message: "#^Parameter \\#1 \\$str of function rawurlencode expects string, string\\|null given\\.$#" - count: 8 + count: 10 path: ../src/Composer/Util/Git.php - @@ -4488,7 +4488,7 @@ parameters: - message: "#^Cannot access an offset on array\\\\>\\|false\\.$#" - count: 1 + count: 3 path: ../tests/Composer/Test/Util/GitTest.php - diff --git a/src/Composer/Util/Git.php b/src/Composer/Util/Git.php index 5a949db70..58df0ad57 100644 --- a/src/Composer/Util/Git.php +++ b/src/Composer/Util/Git.php @@ -32,6 +32,8 @@ class Git protected $process; /** @var Filesystem */ protected $filesystem; + /** @var HttpDownloader */ + protected $httpDownloader; public function __construct(IOInterface $io, Config $config, ProcessExecutor $process, Filesystem $fs) { @@ -41,6 +43,11 @@ class Git $this->filesystem = $fs; } + public function setHttpDownloader(HttpDownloader $httpDownloader): void + { + $this->httpDownloader = $httpDownloader; + } + /** * @param mixed $commandOutput the output will be written into this var if passed by ref * if a callable is passed it will be used as output handler @@ -131,50 +138,69 @@ class Git $errorMsg = $this->process->getErrorOutput(); } // @phpstan-ignore composerPcre.maybeUnsafeStrictGroups - } elseif (Preg::isMatchStrictGroups('{^https://(bitbucket\.org)/(.*?)(?:\.git)?$}i', $url, $match)) { //bitbucket oauth - $bitbucketUtil = new Bitbucket($this->io, $this->config, $this->process); + } elseif ( + Preg::isMatchStrictGroups('{^(https?)://(bitbucket\.org)/(.*?)(?:\.git)?$}i', $url, $match) + || Preg::isMatchStrictGroups('{^(git)@(bitbucket\.org):(.+?\.git)$}i', $url, $match) + ) { //bitbucket either through oauth or app password, with fallback to ssh. + $bitbucketUtil = new Bitbucket($this->io, $this->config, $this->process, $this->httpDownloader); - if (!$this->io->hasAuthentication($match[1])) { + $domain = $match[2]; + $repo_with_git_part = $match[3]; + if (!str_ends_with($repo_with_git_part, '.git')) { + $repo_with_git_part .= '.git'; + } + if (!$this->io->hasAuthentication($domain)) { $message = 'Enter your Bitbucket credentials to access private repos'; - if (!$bitbucketUtil->authorizeOAuth($match[1]) && $this->io->isInteractive()) { + if (!$bitbucketUtil->authorizeOAuth($domain) && $this->io->isInteractive()) { $bitbucketUtil->authorizeOAuthInteractively($match[1], $message); $accessToken = $bitbucketUtil->getToken(); - $this->io->setAuthentication($match[1], 'x-token-auth', $accessToken); + $this->io->setAuthentication($domain, 'x-token-auth', $accessToken); + } + } + + // First we try to authenticate with whatever we have stored. + // This will be successful if there is for example an app + // password in there. + if ($this->io->hasAuthentication($domain)) { + $auth = $this->io->getAuthentication($domain); + $authUrl = 'https://' . rawurlencode($auth['username']) . ':' . rawurlencode($auth['password']) . '@' . $domain . '/' . $repo_with_git_part; + + $command = $commandCallable($authUrl); + if (0 === $this->process->execute($command, $commandOutput, $cwd)) { + // Well if that succeeded on our first try, let's just + // take the win. + return; } - } 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') { - $accessToken = $bitbucketUtil->requestToken($match[1], $auth['username'], $auth['password']); + $accessToken = $bitbucketUtil->requestToken($domain, $auth['username'], $auth['password']); if (!empty($accessToken)) { - $this->io->setAuthentication($match[1], 'x-token-auth', $accessToken); + $this->io->setAuthentication($domain, 'x-token-auth', $accessToken); } } } - 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'; - + if ($this->io->hasAuthentication($domain)) { + $auth = $this->io->getAuthentication($domain); + $authUrl = 'https://' . rawurlencode($auth['username']) . ':' . rawurlencode($auth['password']) . '@' . $domain . '/' . $repo_with_git_part; $command = $commandCallable($authUrl); if (0 === $this->process->execute($command, $commandOutput, $cwd)) { return; } $credentials = [rawurlencode($auth['username']), rawurlencode($auth['password'])]; - $errorMsg = $this->process->getErrorOutput(); - } else { // Falling back to ssh - $sshUrl = 'git@bitbucket.org:' . $match[2] . '.git'; - $this->io->writeError(' No bitbucket authentication configured. Falling back to ssh.'); - $command = $commandCallable($sshUrl); - if (0 === $this->process->execute($command, $commandOutput, $cwd)) { - return; - } - - $errorMsg = $this->process->getErrorOutput(); } + //Falling back to ssh + $sshUrl = 'git@bitbucket.org:' . $repo_with_git_part; + $this->io->writeError(' No bitbucket authentication configured. Falling back to ssh.'); + $command = $commandCallable($sshUrl); + if (0 === $this->process->execute($command, $commandOutput, $cwd)) { + return; + } + + $errorMsg = $this->process->getErrorOutput(); } elseif ( // @phpstan-ignore composerPcre.maybeUnsafeStrictGroups Preg::isMatchStrictGroups('{^(git)@' . self::getGitLabDomainsRegex($this->config) . ':(.+?\.git)$}i', $url, $match) diff --git a/tests/Composer/Test/Util/GitTest.php b/tests/Composer/Test/Util/GitTest.php index 71e064544..04e804d25 100644 --- a/tests/Composer/Test/Util/GitTest.php +++ b/tests/Composer/Test/Util/GitTest.php @@ -14,6 +14,7 @@ namespace Composer\Test\Util; use Composer\Config; use Composer\IO\IOInterface; +use Composer\Test\Mock\HttpDownloaderMock; use Composer\Util\Filesystem; use Composer\Util\Git; use Composer\Test\Mock\ProcessExecutorMock; @@ -126,6 +127,163 @@ class GitTest extends TestCase $this->git->runCommand($commandCallable, $gitUrl, null, true); } + /** + * @dataProvider privateBitbucketWithCredentialsProvider + */ + public function testRunCommandPrivateBitbucketRepositoryNotInitialCloneNotInteractiveWithAuthentication(string $gitUrl, ?string $bitbucketToken, string $expectedUrl, int $expectedFailuresBeforeSuccess, int $bitbucket_git_auth_calls = 0): void + { + $commandCallable = static function ($url) use ($expectedUrl): string { + if ($url !== $expectedUrl) { + return 'git command failing'; + } + + return 'git command ok'; + }; + + $this->config + ->method('get') + ->willReturnMap([ + ['gitlab-domains', 0, ['gitlab.com']], + ['github-domains', 0, ['github.com']], + ]); + + $expectedCalls = array_fill(0, $expectedFailuresBeforeSuccess, ['cmd' => 'git command failing', 'return' => 1]); + if ($bitbucket_git_auth_calls > 0) { + // When we are testing what happens without auth saved, and URLs + // with https, there will also be an attempt to find the token in + // the git config for the folder and repo, locally. + $additional_calls = array_fill(0, $bitbucket_git_auth_calls, ['cmd' => 'git config bitbucket.accesstoken', 'return' => 1]); + foreach ($additional_calls as $call) { + $expectedCalls[] = $call; + } + } + $expectedCalls[] = ['cmd' => 'git command ok', 'return' => 0]; + + $this->process->expects($expectedCalls, true); + + $this->io + ->method('isInteractive') + ->willReturn(false); + + if (null !== $bitbucketToken) { + $this->io + ->expects($this->atLeastOnce()) + ->method('hasAuthentication') + ->with($this->equalTo('bitbucket.org')) + ->willReturn(true); + $this->io + ->expects($this->atLeastOnce()) + ->method('getAuthentication') + ->with($this->equalTo('bitbucket.org')) + ->willReturn(['username' => 'token', 'password' => $bitbucketToken]); + } + $this->git->runCommand($commandCallable, $gitUrl, null, true); + } + + /** + * @dataProvider privateBitbucketWithOauthProvider + * + * @param string $gitUrl + * @param string $expectedUrl + * @param array{'username': string, 'password': string}[] $initial_config + */ + public function testRunCommandPrivateBitbucketRepositoryNotInitialCloneInteractiveWithOauth(string $gitUrl, string $expectedUrl, array $initial_config = []): void + { + $commandCallable = static function ($url) use ($expectedUrl): string { + if ($url !== $expectedUrl) { + return 'git command failing'; + } + + return 'git command ok'; + }; + + $expectedCalls = []; + $expectedCalls[] = ['cmd' => 'git command failing', 'return' => 1]; + if (count($initial_config) > 0) { + $expectedCalls[] = ['cmd' => 'git command failing', 'return' => 1]; + } else { + $expectedCalls[] = ['cmd' => 'git config bitbucket.accesstoken', 'return' => 1]; + } + $expectedCalls[] = ['cmd' => 'git command ok', 'return' => 0]; + $this->process->expects($expectedCalls, true); + + $this->config + ->method('get') + ->willReturnMap([ + ['gitlab-domains', 0, ['gitlab.com']], + ['github-domains', 0, ['github.com']], + ]); + + $this->io + ->method('isInteractive') + ->willReturn(true); + + $this->io + ->method('askConfirmation') + ->willReturnCallback(function () { + return true; + }); + $this->io->method('askAndHideAnswer') + ->willReturnCallback(function ($question) { + switch ($question) { + case 'Consumer Key (hidden): ': + return 'my-consumer-key'; + case 'Consumer Secret (hidden): ': + return 'my-consumer-secret'; + } + return ''; + }); + + $this->io + ->method('hasAuthentication') + ->with($this->equalTo('bitbucket.org')) + ->willReturnCallback(function ($repositoryName) use (&$initial_config) { + return isset($initial_config[$repositoryName]); + }); + $this->io + ->method('setAuthentication') + ->willReturnCallback(function (string $repositoryName, string $username, ?string $password = null) use (&$initial_config) { + $initial_config[$repositoryName] = ['username' => $username, 'password' => $password]; + }); + $this->io + ->method('getAuthentication') + ->willReturnCallback(function (string $repositoryName) use (&$initial_config) { + if (isset($initial_config[$repositoryName])) { + return $initial_config[$repositoryName]; + } + + return ['username' => null, 'password' => null]; + }); + $downloader_mock = $this->getHttpDownloaderMock(); + $downloader_mock->expects([ + ['url' => 'https://bitbucket.org/site/oauth2/access_token', 'status' => 200, 'body' => '{"expires_in": 600, "access_token": "my-access-token"}'] + ]); + $this->git->setHttpDownloader($downloader_mock); + $this->git->runCommand($commandCallable, $gitUrl, null, true); + } + + public static function privateBitbucketWithOauthProvider(): array + { + return [ + ['git@bitbucket.org:acme/repo.git', 'https://x-token-auth:my-access-token@bitbucket.org/acme/repo.git'], + ['https://bitbucket.org/acme/repo.git', 'https://x-token-auth:my-access-token@bitbucket.org/acme/repo.git'], + ['https://bitbucket.org/acme/repo', 'https://x-token-auth:my-access-token@bitbucket.org/acme/repo.git'], + ['git@bitbucket.org:acme/repo.git', 'https://x-token-auth:my-access-token@bitbucket.org/acme/repo.git', ['bitbucket.org' => ['username' => 'someuseralsoswappedfortoken', 'password' => 'little green men']]], + ]; + } + + public static function privateBitbucketWithCredentialsProvider(): array + { + return [ + ['git@bitbucket.org:acme/repo.git', 'MY_BITBUCKET_TOKEN', 'https://token:MY_BITBUCKET_TOKEN@bitbucket.org/acme/repo.git', 1], + ['https://bitbucket.org/acme/repo', 'MY_BITBUCKET_TOKEN', 'https://token:MY_BITBUCKET_TOKEN@bitbucket.org/acme/repo.git', 1], + ['https://bitbucket.org/acme/repo.git', 'MY_BITBUCKET_TOKEN', 'https://token:MY_BITBUCKET_TOKEN@bitbucket.org/acme/repo.git', 1], + ['git@bitbucket.org:acme/repo.git', null, 'git@bitbucket.org:acme/repo.git', 0], + ['https://bitbucket.org/acme/repo', null, 'git@bitbucket.org:acme/repo.git', 1, 1], + ['https://bitbucket.org/acme/repo.git', null, 'git@bitbucket.org:acme/repo.git', 1, 1], + ]; + } + public static function privateGithubWithCredentialsProvider(): array { return [