diff --git a/doc/articles/authentication-for-private-packages.md b/doc/articles/authentication-for-private-packages.md index 36560a762..5f5abc20c 100644 --- a/doc/articles/authentication-for-private-packages.md +++ b/doc/articles/authentication-for-private-packages.md @@ -371,10 +371,10 @@ To create a new access token, go to your [applications section on Forgejo](https When creating a Forgejo access token, make sure it has the `read:repository` scope. -### Command line gitlab-token +### Command line forgejo-token ```shell -php composer.phar config [--global] forgejo-token.gitlab.example.org username access-token +php composer.phar config [--global] forgejo-token.forgejo.example.org username access-token ``` In the above command, the config key `forgejo-token.forgejo.example.org` consists of two parts: diff --git a/src/Composer/Repository/Vcs/ForgejoDriver.php b/src/Composer/Repository/Vcs/ForgejoDriver.php index 555727f72..82c89ac81 100644 --- a/src/Composer/Repository/Vcs/ForgejoDriver.php +++ b/src/Composer/Repository/Vcs/ForgejoDriver.php @@ -1,5 +1,15 @@ + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Composer\Repository\Vcs; use Composer\Cache; @@ -8,6 +18,7 @@ use Composer\Downloader\TransportException; use Composer\IO\IOInterface; use Composer\Json\JsonFile; use Composer\Pcre\Preg; +use Composer\Util\Forgejo; use Composer\Util\ForgejoRepositoryData; use Composer\Util\ForgejoUrl; use Composer\Util\Http\Response; @@ -274,12 +285,16 @@ class ForgejoDriver extends VcsDriver protected function getContents(string $url, bool $fetchingRepoData = false): Response { + $forgejo = new Forgejo($this->io, $this->config, $this->httpDownloader); + try { return parent::getContents($url); } catch (TransportException $e) { switch ($e->getCode()) { + case 401: + case 403: case 404: - // try to authorize only if we are fetching the main /repos/foo/bar data, otherwise it must be a real 404 + case 429: if (!$fetchingRepoData) { throw $e; } @@ -289,6 +304,15 @@ class ForgejoDriver extends VcsDriver return new Response(['url' => 'dummy'], 200, [], 'null'); } + + if ( + !$this->io->hasAuthentication($this->originUrl) && + $forgejo->authorizeOAuthInteractively($this->forgejoUrl->originUrl, $e->getCode() === 429 ? 'API limit exhausted. Enter your Forgejo credentials to get a larger API limit ('.$this->url.')' : null) + ) { + return parent::getContents($url); + } + + throw $e; default: throw $e; } diff --git a/src/Composer/Util/Forgejo.php b/src/Composer/Util/Forgejo.php new file mode 100644 index 000000000..cb526e047 --- /dev/null +++ b/src/Composer/Util/Forgejo.php @@ -0,0 +1,108 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Util; + +use Composer\Config; +use Composer\Downloader\TransportException; +use Composer\IO\IOInterface; + +/** + * @internal + * @readonly + */ +final class Forgejo +{ + /** @var IOInterface */ + private $io; + /** @var Config */ + private $config; + /** @var HttpDownloader */ + private $httpDownloader; + + /** + * @param IOInterface $io + * @param Config $config + * @param HttpDownloader $httpDownloader + */ + public function __construct(IOInterface $io, Config $config, HttpDownloader $httpDownloader) + { + $this->io = $io; + $this->config = $config; + $this->httpDownloader = $httpDownloader; + } + + /** + * Authorizes a Forgejo domain interactively + * + * @param string $originUrl The host this Forgejo instance is located at + * @param string $message The reason this authorization is required + * @throws \RuntimeException + * @throws TransportException|\Exception + * @return bool true on success + */ + public function authorizeOAuthInteractively(string $originUrl, ?string $message = null): bool + { + if ($message !== null) { + $this->io->writeError($message); + } + + $url = 'https://'.$originUrl.'/user/settings/applications'; + $this->io->writeError('Setup a personal access token with repository:read permissions on:'); + $this->io->writeError($url); + $localAuthConfig = $this->config->getLocalAuthConfigSource(); + $this->io->writeError(sprintf('Tokens will be stored in plain text in "%s" for future use by Composer.', ($localAuthConfig !== null ? $localAuthConfig->getName() . ' OR ' : '') . $this->config->getAuthConfigSource()->getName())); + $this->io->writeError('For additional information, check https://getcomposer.org/doc/articles/authentication-for-private-packages.md#forgejo-token'); + + $storeInLocalAuthConfig = false; + if ($localAuthConfig !== null) { + $storeInLocalAuthConfig = $this->io->askConfirmation('A local auth config source was found, do you want to store the token there?', true); + } + + $username = trim((string) $this->io->ask('Username: ')); + $token = trim((string) $this->io->askAndHideAnswer('Token (hidden): ')); + + $addTokenManually = sprintf('You can also add it manually later by using "composer config --global --auth forgejo-token.%s "', $originUrl); + if ($token === '' || $username === '') { + $this->io->writeError('No username/token given, aborting.'); + $this->io->writeError($addTokenManually); + + return false; + } + + $this->io->setAuthentication($originUrl, $username, $token); + + try { + $this->httpDownloader->get('https://'. $originUrl . '/api/v1/version', [ + 'retry-auth-failure' => false, + ]); + } catch (TransportException $e) { + if (in_array($e->getCode(), [403, 401, 404], true)) { + $this->io->writeError('Invalid access token provided.'); + $this->io->writeError($addTokenManually); + + return false; + } + + throw $e; + } + + // store value in local/user config + $authConfigSource = $storeInLocalAuthConfig && $localAuthConfig !== null ? $localAuthConfig : $this->config->getAuthConfigSource(); + $this->config->getConfigSource()->removeConfigSetting('forgejo-token.'.$originUrl); + $authConfigSource->addConfigSetting('forgejo-token.'.$originUrl, ['username' => $username, 'token' => $token]); + + $this->io->writeError('Token stored successfully.'); + + return true; + } +} diff --git a/src/Composer/Util/ForgejoRepositoryData.php b/src/Composer/Util/ForgejoRepositoryData.php index 2a45e238d..6a442898f 100644 --- a/src/Composer/Util/ForgejoRepositoryData.php +++ b/src/Composer/Util/ForgejoRepositoryData.php @@ -1,5 +1,15 @@ + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Composer\Util; /** diff --git a/src/Composer/Util/ForgejoUrl.php b/src/Composer/Util/ForgejoUrl.php index 263adada5..fc67bff1b 100644 --- a/src/Composer/Util/ForgejoUrl.php +++ b/src/Composer/Util/ForgejoUrl.php @@ -1,5 +1,15 @@ + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Composer\Util; use Composer\Pcre\Preg; diff --git a/tests/Composer/Test/ConfigTest.php b/tests/Composer/Test/ConfigTest.php index 1f35fbd6e..e298ee547 100644 --- a/tests/Composer/Test/ConfigTest.php +++ b/tests/Composer/Test/ConfigTest.php @@ -416,6 +416,7 @@ class ConfigTest extends TestCase 'github-oauth', 'gitlab-oauth', 'gitlab-token', + 'forgejo-token', 'http-basic', 'bearer', ]; diff --git a/tests/Composer/Test/Repository/Vcs/ForgejoDriverTest.php b/tests/Composer/Test/Repository/Vcs/ForgejoDriverTest.php index a107c11a3..06312f197 100644 --- a/tests/Composer/Test/Repository/Vcs/ForgejoDriverTest.php +++ b/tests/Composer/Test/Repository/Vcs/ForgejoDriverTest.php @@ -1,5 +1,15 @@ + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Composer\Test\Repository\Vcs; use Composer\Config; diff --git a/tests/Composer/Test/Util/ForgejoTest.php b/tests/Composer/Test/Util/ForgejoTest.php new file mode 100644 index 000000000..b0ce9086b --- /dev/null +++ b/tests/Composer/Test/Util/ForgejoTest.php @@ -0,0 +1,133 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Util; + +use Composer\Test\TestCase; +use Composer\Util\Forgejo; +use Composer\Util\GitHub; + +class ForgejoTest extends TestCase +{ + /** @var string */ + private $username = 'username'; + /** @var string */ + private $accessToken = 'access-token'; + /** @var string */ + private $message = 'mymessage'; + /** @var string */ + private $origin = 'codeberg.org'; + + public function testUsernamePasswordAuthenticationFlow(): void + { + $io = $this->getIOMock(); + $io->expects([ + ['text' => $this->message], + ['ask' => 'Username: ', 'reply' => $this->username], + ['ask' => 'Token (hidden): ', 'reply' => $this->accessToken], + ]); + + $httpDownloader = $this->getHttpDownloaderMock(); + $httpDownloader->expects( + [['url' => sprintf('https://%s/api/v1/version', $this->origin), 'body' => '{}']], + true + ); + + $config = $this->getConfigMock(); + $config + ->expects($this->exactly(2)) + ->method('getAuthConfigSource') + ->willReturn($this->getAuthJsonMock()) + ; + $config + ->expects($this->once()) + ->method('getConfigSource') + ->willReturn($this->getConfJsonMock()) + ; + + $forgejo = new Forgejo($io, $config, $httpDownloader); + + self::assertTrue($forgejo->authorizeOAuthInteractively($this->origin, $this->message)); + } + + public function testUsernamePasswordFailure(): void + { + $io = $this->getIOMock(); + $io->expects([ + ['ask' => 'Username: ', 'reply' => $this->username], + ['ask' => 'Token (hidden): ', 'reply' => $this->accessToken], + ]); + + $httpDownloader = $this->getHttpDownloaderMock(); + $httpDownloader->expects( + [['url' => sprintf('https://%s/api/v1/version', $this->origin), 'status' => 404]], + true + ); + + $config = $this->getConfigMock(); + $config + ->expects($this->exactly(1)) + ->method('getAuthConfigSource') + ->willReturn($this->getAuthJsonMock()) + ; + + $forgejo = new Forgejo($io, $config, $httpDownloader); + + self::assertFalse($forgejo->authorizeOAuthInteractively($this->origin)); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject&\Composer\Config + */ + private function getConfigMock() + { + return $this->getMockBuilder('Composer\Config')->getMock(); + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject&\Composer\Config\JsonConfigSource + */ + private function getAuthJsonMock() + { + $authjson = $this + ->getMockBuilder('Composer\Config\JsonConfigSource') + ->disableOriginalConstructor() + ->getMock() + ; + $authjson + ->expects($this->atLeastOnce()) + ->method('getName') + ->willReturn('auth.json') + ; + + return $authjson; + } + + /** + * @return \PHPUnit\Framework\MockObject\MockObject&\Composer\Config\JsonConfigSource + */ + private function getConfJsonMock() + { + $confjson = $this + ->getMockBuilder('Composer\Config\JsonConfigSource') + ->disableOriginalConstructor() + ->getMock() + ; + $confjson + ->expects($this->atLeastOnce()) + ->method('removeConfigSetting') + ->with('forgejo-token.'.$this->origin) + ; + + return $confjson; + } +} diff --git a/tests/Composer/Test/Util/ForgejoUrlTest.php b/tests/Composer/Test/Util/ForgejoUrlTest.php index 2fc4889a0..be466be6d 100644 --- a/tests/Composer/Test/Util/ForgejoUrlTest.php +++ b/tests/Composer/Test/Util/ForgejoUrlTest.php @@ -1,5 +1,15 @@ + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Composer\Test\Util; use Composer\Test\TestCase;