Better app password support for bitbucket (#12103)
* Fix support for app passwords better, plus better handling of bitbucket repositories stored with sshpull/12143/head
parent
31d83b2c0f
commit
a01ab9bbca
|
@ -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
|
||||
|
||||
-
|
||||
|
|
|
@ -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\\<int, array\\<string, int\\|string\\>\\>\\|false\\.$#"
|
||||
count: 1
|
||||
count: 3
|
||||
path: ../tests/Composer/Test/Util/GitTest.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)
|
||||
|
|
|
@ -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 [
|
||||
|
|
Loading…
Reference in New Issue