diff --git a/composer.json b/composer.json index f160c801..84f36eae 100644 --- a/composer.json +++ b/composer.json @@ -17,12 +17,14 @@ ], "require": { "php": "^8.1", + "ext-openssl": "*", "doctrine/doctrine-bundle": "^2.8.0", "doctrine/orm": "^2.14|^3.0", "league/oauth2-server": "^8.3", "nyholm/psr7": "^1.4", "psr/http-factory": "^1.0", "symfony/event-dispatcher": "^5.4|^6.2|^7.0", + "symfony/filesystem": "^5.4|^6.0|^7.0", "symfony/framework-bundle": "^5.4|^6.2|^7.0", "symfony/polyfill-php81": "^1.22", "symfony/psr-http-message-bridge": "^2.0|^6|^7", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e075eb9f..2d369183 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -22,6 +22,9 @@ ./tests/Acceptance + + ./tests/Functional + ./tests/Integration diff --git a/src/Command/GenerateKeyPairCommand.php b/src/Command/GenerateKeyPairCommand.php new file mode 100644 index 00000000..4938478b --- /dev/null +++ b/src/Command/GenerateKeyPairCommand.php @@ -0,0 +1,222 @@ + + */ +#[AsCommand(name: 'league:oauth2-server:generate-keypair', description: 'Generate public/private keys for use in your application.')] +final class GenerateKeyPairCommand extends Command +{ + private const ACCEPTED_ALGORITHMS = [ + 'RS256', + 'RS384', + 'RS512', + 'HS256', + 'HS384', + 'HS512', + 'ES256', + 'ES384', + 'ES512', + ]; + + /** + * @deprecated + */ + protected static $defaultName = 'league:oauth2-server:generate-keypair'; + + private Filesystem $filesystem; + + private string $secretKey; + + private string $publicKey; + + private ?string $passphrase; + + private string $algorithm; + + public function __construct(Filesystem $filesystem, string $secretKey, string $publicKey, ?string $passphrase, string $algorithm) + { + parent::__construct(); + $this->filesystem = $filesystem; + $this->secretKey = $secretKey; + $this->publicKey = $publicKey; + $this->passphrase = $passphrase; + $this->algorithm = $algorithm; + } + + protected function configure(): void + { + $this->setDescription('Generate public/private keys for use in your application.'); + $this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do not update key files.'); + $this->addOption('skip-if-exists', null, InputOption::VALUE_NONE, 'Do not update key files if they already exist.'); + $this->addOption('overwrite', null, InputOption::VALUE_NONE, 'Overwrite key files if they already exist.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + if (!\in_array($this->algorithm, self::ACCEPTED_ALGORITHMS, true)) { + $io->error(sprintf('Cannot generate key pair with the provided algorithm `%s`.', $this->algorithm)); + + return Command::FAILURE; + } + + [$secretKey, $publicKey] = $this->generateKeyPair($this->passphrase); + + if ($input->getOption('dry-run')) { + $io->success('Your keys have been generated!'); + $io->newLine(); + $io->writeln(sprintf('Update your private key in %s:', $this->secretKey)); + $io->writeln($secretKey); + $io->newLine(); + $io->writeln(sprintf('Update your public key in %s:', $this->publicKey)); + $io->writeln($publicKey); + + return Command::SUCCESS; + } + + $alreadyExists = $this->filesystem->exists($this->secretKey) || $this->filesystem->exists($this->publicKey); + + if ($alreadyExists) { + try { + $this->handleExistingKeys($input); + } catch (\RuntimeException $e) { + if (0 === $e->getCode()) { + $io->comment($e->getMessage()); + + return Command::SUCCESS; + } + + $io->error($e->getMessage()); + + return Command::FAILURE; + } + + if (!$io->confirm('You are about to replace your existing keys. Are you sure you wish to continue?')) { + $io->comment('Your action was canceled.'); + + return Command::SUCCESS; + } + } + + $this->filesystem->dumpFile($this->secretKey, $secretKey); + $this->filesystem->dumpFile($this->publicKey, $publicKey); + + $io->success('Done!'); + + return Command::SUCCESS; + } + + private function handleExistingKeys(InputInterface $input): void + { + if ($input->getOption('skip-if-exists') && $input->getOption('overwrite')) { + throw new \RuntimeException('Both options `--skip-if-exists` and `--overwrite` cannot be combined.', 1); + } + + if ($input->getOption('skip-if-exists')) { + throw new \RuntimeException('Your key files already exist, they won\'t be overridden.', 0); + } + + if (!$input->getOption('overwrite')) { + throw new \RuntimeException('Your keys already exist. Use the `--overwrite` option to force regeneration.', 1); + } + } + + /** + * @return array{0: string, 1: string} + */ + private function generateKeyPair(?string $passphrase): array + { + $config = $this->buildOpenSSLConfiguration(); + + $resource = openssl_pkey_new($config); + if (false === $resource) { + throw new \RuntimeException(openssl_error_string()); + } + + $success = openssl_pkey_export($resource, $privateKey, $passphrase); + + if (false === $success) { + throw new \RuntimeException(openssl_error_string()); + } + + $publicKeyData = openssl_pkey_get_details($resource); + + if (!\is_array($publicKeyData)) { + throw new \RuntimeException(openssl_error_string()); + } + + if (!\array_key_exists('key', $publicKeyData) || !\is_string($publicKeyData['key'])) { + throw new \RuntimeException('Invalid public key type.'); + } + + return [$privateKey, $publicKeyData['key']]; + } + + private function buildOpenSSLConfiguration(): array + { + $digestAlgorithms = [ + 'RS256' => 'sha256', + 'RS384' => 'sha384', + 'RS512' => 'sha512', + 'HS256' => 'sha256', + 'HS384' => 'sha384', + 'HS512' => 'sha512', + 'ES256' => 'sha256', + 'ES384' => 'sha384', + 'ES512' => 'sha512', + ]; + $privateKeyBits = [ + 'RS256' => 2048, + 'RS384' => 2048, + 'RS512' => 4096, + 'HS256' => 512, + 'HS384' => 512, + 'HS512' => 512, + 'ES256' => 384, + 'ES384' => 512, + 'ES512' => 1024, + ]; + $privateKeyTypes = [ + 'RS256' => \OPENSSL_KEYTYPE_RSA, + 'RS384' => \OPENSSL_KEYTYPE_RSA, + 'RS512' => \OPENSSL_KEYTYPE_RSA, + 'HS256' => \OPENSSL_KEYTYPE_DH, + 'HS384' => \OPENSSL_KEYTYPE_DH, + 'HS512' => \OPENSSL_KEYTYPE_DH, + 'ES256' => \OPENSSL_KEYTYPE_EC, + 'ES384' => \OPENSSL_KEYTYPE_EC, + 'ES512' => \OPENSSL_KEYTYPE_EC, + ]; + + $curves = [ + 'ES256' => 'secp256k1', + 'ES384' => 'secp384r1', + 'ES512' => 'secp521r1', + ]; + + $config = [ + 'digest_alg' => $digestAlgorithms[$this->algorithm], + 'private_key_type' => $privateKeyTypes[$this->algorithm], + 'private_key_bits' => $privateKeyBits[$this->algorithm], + ]; + + if (isset($curves[$this->algorithm])) { + $config['curve_name'] = $curves[$this->algorithm]; + } + + return $config; + } +} diff --git a/src/DependencyInjection/LeagueOAuth2ServerExtension.php b/src/DependencyInjection/LeagueOAuth2ServerExtension.php index 780c3e1b..25e76df3 100644 --- a/src/DependencyInjection/LeagueOAuth2ServerExtension.php +++ b/src/DependencyInjection/LeagueOAuth2ServerExtension.php @@ -7,6 +7,7 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use League\Bundle\OAuth2ServerBundle\AuthorizationServer\GrantTypeInterface; use League\Bundle\OAuth2ServerBundle\Command\CreateClientCommand; +use League\Bundle\OAuth2ServerBundle\Command\GenerateKeyPairCommand; use League\Bundle\OAuth2ServerBundle\DBAL\Type\Grant as GrantType; use League\Bundle\OAuth2ServerBundle\DBAL\Type\RedirectUri as RedirectUriType; use League\Bundle\OAuth2ServerBundle\DBAL\Type\Scope as ScopeType; @@ -74,6 +75,13 @@ public function load(array $configs, ContainerBuilder $container) ->findDefinition(CreateClientCommand::class) ->replaceArgument(1, $config['client']['classname']) ; + + $container + ->findDefinition(GenerateKeyPairCommand::class) + ->replaceArgument(1, $config['authorization_server']['private_key']) + ->replaceArgument(2, $config['resource_server']['public_key']) + ->replaceArgument(3, $config['authorization_server']['private_key_passphrase']) + ; } public function getAlias(): string diff --git a/src/Resources/config/services.php b/src/Resources/config/services.php index 21977b43..7de150bb 100644 --- a/src/Resources/config/services.php +++ b/src/Resources/config/services.php @@ -11,6 +11,7 @@ use League\Bundle\OAuth2ServerBundle\Command\ClearExpiredTokensCommand; use League\Bundle\OAuth2ServerBundle\Command\CreateClientCommand; use League\Bundle\OAuth2ServerBundle\Command\DeleteClientCommand; +use League\Bundle\OAuth2ServerBundle\Command\GenerateKeyPairCommand; use League\Bundle\OAuth2ServerBundle\Command\ListClientsCommand; use League\Bundle\OAuth2ServerBundle\Command\UpdateClientCommand; use League\Bundle\OAuth2ServerBundle\Controller\AuthorizationController; @@ -268,6 +269,16 @@ ->tag('console.command', ['command' => 'league:oauth2-server:clear-expired-tokens']) ->alias(ClearExpiredTokensCommand::class, 'league.oauth2_server.command.clear_expired_tokens') + ->set('league.oauth2_server.command.generate_keypair', GenerateKeyPairCommand::class) + ->args([ + service('filesystem'), + abstract_arg('Private key'), + abstract_arg('Public key'), + abstract_arg('Private key passphrase'), + ]) + ->tag('consome.command', ['command' => 'league:oauth2-server:generate-keypair']) + ->alias(GenerateKeyPairCommand::class, 'league.oauth2_server.command.generate_keypair') + // Utility services ->set('league.oauth2_server.converter.user', UserConverter::class) ->alias(UserConverterInterface::class, 'league.oauth2_server.converter.user') diff --git a/tests/Functional/Command/GenerateKeyPairCommandTest.php b/tests/Functional/Command/GenerateKeyPairCommandTest.php new file mode 100644 index 00000000..22a8dc5d --- /dev/null +++ b/tests/Functional/Command/GenerateKeyPairCommandTest.php @@ -0,0 +1,221 @@ +execute([], ['interactive' => false]); + $this->assertSame(0, $returnCode); + + $privateKey = file_get_contents($privateKeyFile); + $publicKey = file_get_contents($publicKeyFile); + $this->assertStringContainsString('Done!', $tester->getDisplay(true)); + $this->assertNotFalse($privateKey); + $this->assertNotFalse($publicKey); + $this->assertStringContainsString('PRIVATE KEY', $privateKey); + $this->assertStringContainsString('PUBLIC KEY', $publicKey); + + // Encryption / decryption test + /*$payload = 'Despite the constant negative press covfefe'; + \openssl_public_encrypt($payload, $encryptedData, \openssl_pkey_get_public($publicKey)); + \openssl_private_decrypt($encryptedData, $decryptedData, \openssl_pkey_get_private($privateKey, $passphrase)); + $this->assertSame($payload, $decryptedData);*/ + } + + public function providePassphrase() + { + yield ['RS256', null]; + yield ['RS384', null]; + yield ['RS512', null]; + yield ['HS256', null]; + yield ['HS384', null]; + yield ['HS512', null]; + yield ['ES256', null]; + yield ['ES384', null]; + yield ['ES512', null]; + yield ['RS256', 'dummy']; + yield ['RS384', 'dummy']; + yield ['RS512', 'dummy']; + yield ['HS256', 'dummy']; + yield ['HS384', 'dummy']; + yield ['HS512', 'dummy']; + yield ['ES256', 'dummy']; + yield ['ES384', 'dummy']; + yield ['ES512', 'dummy']; + } + + public function testOverwriteAndSkipCannotBeCombined() + { + $privateKeyFile = tempnam(sys_get_temp_dir(), 'private_'); + $publicKeyFile = tempnam(sys_get_temp_dir(), 'public_'); + + file_put_contents($privateKeyFile, 'foobar'); + file_put_contents($publicKeyFile, 'foobar'); + + $tester = new CommandTester( + new GenerateKeyPairCommand( + new Filesystem(), + $privateKeyFile, + $publicKeyFile, + null, + 'RS256' + ) + ); + $input = ['--overwrite' => true, '--skip-if-exists' => true]; + $returnCode = $tester->execute($input, ['interactive' => false]); + $this->assertSame(1, $returnCode); + $this->assertStringContainsString( + 'Both options `--skip-if-exists` and `--overwrite` cannot be combined.', + $tester->getDisplay(true) + ); + + $privateKey = file_get_contents($privateKeyFile); + $publicKey = file_get_contents($publicKeyFile); + $this->assertStringContainsString('foobar', $privateKey); + $this->assertStringContainsString('foobar', $publicKey); + } + + public function testNoOverwriteDoesNotOverwrite() + { + $privateKeyFile = tempnam(sys_get_temp_dir(), 'private_'); + $publicKeyFile = tempnam(sys_get_temp_dir(), 'public_'); + + file_put_contents($privateKeyFile, 'foobar'); + file_put_contents($publicKeyFile, 'foobar'); + + $tester = new CommandTester( + new GenerateKeyPairCommand( + new Filesystem(), + $privateKeyFile, + $publicKeyFile, + null, + 'RS256' + ) + ); + + $returnCode = $tester->execute([], ['interactive' => false]); + $this->assertSame(1, $returnCode); + $this->assertStringContainsString( + 'Your keys already exist. Use the `--overwrite` option to force regeneration.', + preg_replace('/\s+/', ' ', $tester->getDisplay(true)) + ); + + $privateKey = file_get_contents($privateKeyFile); + $publicKey = file_get_contents($publicKeyFile); + $this->assertStringContainsString('foobar', $privateKey); + $this->assertStringContainsString('foobar', $publicKey); + } + + public function testOverwriteActuallyOverwrites() + { + $privateKeyFile = tempnam(sys_get_temp_dir(), 'private_'); + $publicKeyFile = tempnam(sys_get_temp_dir(), 'public_'); + + file_put_contents($privateKeyFile, 'foobar'); + file_put_contents($publicKeyFile, 'foobar'); + + $tester = new CommandTester( + new GenerateKeyPairCommand( + new Filesystem(), + $privateKeyFile, + $publicKeyFile, + null, + 'RS256' + ) + ); + + $returnCode = $tester->execute(['--overwrite' => true], ['interactive' => false]); + $privateKey = file_get_contents($privateKeyFile); + $publicKey = file_get_contents($publicKeyFile); + + $this->assertSame(0, $returnCode); + $this->assertStringContainsString('PRIVATE KEY', $privateKey); + $this->assertStringContainsString('PUBLIC KEY', $publicKey); + } + + public function testSkipIfExistsWritesIfNotExists() + { + $privateKeyFile = tempnam(sys_get_temp_dir(), 'private_'); + $publicKeyFile = tempnam(sys_get_temp_dir(), 'public_'); + + // tempnam() actually create the files, but we have to simulate they don't exist + unlink($privateKeyFile); + unlink($publicKeyFile); + + $tester = new CommandTester( + new GenerateKeyPairCommand( + new Filesystem(), + $privateKeyFile, + $publicKeyFile, + null, + 'RS256' + ) + ); + + $this->assertSame(0, $tester->execute(['--skip-if-exists' => true], ['interactive' => false])); + $this->assertStringContainsString('Done!', $tester->getDisplay(true)); + $privateKey = file_get_contents($privateKeyFile); + $publicKey = file_get_contents($publicKeyFile); + $this->assertStringContainsString('PRIVATE KEY', $privateKey); + $this->assertStringContainsString('PUBLIC KEY', $publicKey); + } + + public function testSkipIfExistsDoesNothingIfExists() + { + $privateKeyFile = tempnam(sys_get_temp_dir(), 'private_'); + $publicKeyFile = tempnam(sys_get_temp_dir(), 'public_'); + + file_put_contents($privateKeyFile, 'foobar'); + file_put_contents($publicKeyFile, 'foobar'); + + $tester = new CommandTester( + new GenerateKeyPairCommand( + new Filesystem(), + $privateKeyFile, + $publicKeyFile, + null, + 'RS256' + ) + ); + + $this->assertSame(0, $tester->execute(['--skip-if-exists' => true], ['interactive' => false])); + $this->assertStringContainsString( + 'Your key files already exist, they won\'t be overridden.', + $tester->getDisplay(true) + ); + + $privateKey = file_get_contents($privateKeyFile); + $publicKey = file_get_contents($publicKeyFile); + $this->assertStringContainsString('foobar', $privateKey); + $this->assertStringContainsString('foobar', $publicKey); + } +}