diff --git a/Command/GenerateKeyPairCommand.php b/Command/GenerateKeyPairCommand.php new file mode 100644 index 00000000..fb232736 --- /dev/null +++ b/Command/GenerateKeyPairCommand.php @@ -0,0 +1,225 @@ + + */ +final class GenerateKeyPairCommand extends Command +{ + private const ACCEPTED_ALGORITHMS = [ + 'RS256', + 'RS384', + 'RS512', + 'HS256', + 'HS384', + 'HS512', + 'ES256', + 'ES384', + 'ES512', + ]; + + protected static $defaultName = 'lexik:jwt:generate-keypair'; + + /** + * @var Filesystem + */ + private $filesystem; + + /** + * @var string + */ + private $secretKey; + + /** + * @var string + */ + private $publicKey; + + /** + * @var string|null + */ + private $passphrase; + + /** + * @var string + */ + private $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 1; + } + + [$secretKey, $publicKey] = $this->generateKeyPair($this->passphrase); + + if (true === $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 0; + } + + $alreadyExists = $this->filesystem->exists($this->secretKey) || $this->filesystem->exists($this->publicKey); + + if (true === $alreadyExists) { + try { + $this->handleExistingKeys($input); + } catch (\RuntimeException $e) { + if (0 === $e->getCode()) { + $io->comment($e->getMessage()); + + return 0; + } + + $io->error($e->getMessage()); + + return 1; + } + + 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 0; + } + } + + $this->filesystem->dumpFile($this->secretKey, $secretKey); + $this->filesystem->dumpFile($this->publicKey, $publicKey); + + $io->success('Done!'); + + return 0; + } + + private function handleExistingKeys(InputInterface $input): void + { + if (true === $input->getOption('skip-if-exists') && true === $input->getOption('overwrite')) { + throw new \RuntimeException('Both options `--skip-if-exists` and `--overwrite` cannot be combined.', 1); + } + + if (true === $input->getOption('skip-if-exists')) { + throw new \RuntimeException('Your key files already exist, they won\'t be overriden.', 0); + } + + if (false === $input->getOption('overwrite')) { + throw new \RuntimeException('Your keys already exist. Use the `--overwrite` option to force regeneration.', 1); + } + } + + private function generateKeyPair($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 (false === $publicKeyData) { + throw new \RuntimeException(\openssl_error_string()); + } + + $publicKey = $publicKeyData['key']; + + return [$privateKey, $publicKey]; + } + + 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' => 384, + 'HS384' => 384, + '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/DependencyInjection/LexikJWTAuthenticationExtension.php b/DependencyInjection/LexikJWTAuthenticationExtension.php index a33ac360..afd17e16 100644 --- a/DependencyInjection/LexikJWTAuthenticationExtension.php +++ b/DependencyInjection/LexikJWTAuthenticationExtension.php @@ -120,6 +120,13 @@ public function load(array $configs, ContainerBuilder $container) ->getDefinition('lexik_jwt_authentication.handler.authentication_success') ->replaceArgument(2, new IteratorArgument($cookieProviders)); } + + $container + ->getDefinition('lexik_jwt_authentication.generate_keypair_command') + ->replaceArgument(1, $config['secret_key']) + ->replaceArgument(2, $config['public_key']) + ->replaceArgument(3, $config['pass_phrase']) + ->replaceArgument(4, $encoderConfig['signature_algorithm']); } private static function createTokenExtractors(ContainerBuilder $container, array $tokenExtractorsConfig) diff --git a/Resources/config/console.xml b/Resources/config/console.xml index f81e6e4e..87084477 100644 --- a/Resources/config/console.xml +++ b/Resources/config/console.xml @@ -16,6 +16,15 @@ + + + + + + + + + diff --git a/Resources/doc/index.md b/Resources/doc/index.md index 404993f7..a47d0944 100644 --- a/Resources/doc/index.md +++ b/Resources/doc/index.md @@ -42,11 +42,17 @@ return [ #### Generate the SSL keys: ``` bash -$ mkdir -p config/jwt -$ openssl genpkey -out config/jwt/private.pem -aes256 -algorithm rsa -pkeyopt rsa_keygen_bits:4096 -$ openssl pkey -in config/jwt/private.pem -out config/jwt/public.pem -pubout +$ php bin/console lexik:jwt:generate-keypair ``` +Your keys will land in `config/jwt/private.pem` and `config/jwt/public.pem` (unless you configured a different path). + +Available options: +- `--skip-if-exists` will silently do nothing if keys already exist. +- `--overwrite` will overwrite your keys if they already exist. + +Otherwise, an error will be raised to prevent you from overwriting your keys accidentally. + Configuration ------------- diff --git a/Tests/DependencyInjection/AutowiringTest.php b/Tests/DependencyInjection/AutowiringTest.php index 7a934f63..67400821 100644 --- a/Tests/DependencyInjection/AutowiringTest.php +++ b/Tests/DependencyInjection/AutowiringTest.php @@ -92,6 +92,9 @@ private static function createContainerBuilder(array $configs = []) 'kernel.runtime_environment' => 'test', 'env(base64:default::SYMFONY_DECRYPTION_SECRET)' => 'dummy', 'kernel.build_dir' => __DIR__, + 'env(default::resolve:JWT_SECRET_KEY)' => __DIR__, + 'env(default::resolve:JWT_PUBLIC_KEY)' => __DIR__, + 'env(default::JWT_PASSPHRASE)' => 'dummy', ])); $container->registerExtension(new SecurityExtension()); diff --git a/Tests/Functional/Command/GenerateKeyPairCommandTest.php b/Tests/Functional/Command/GenerateKeyPairCommandTest.php new file mode 100644 index 00000000..cc9cc81f --- /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 overriden.', + $tester->getDisplay(true) + ); + + $privateKey = \file_get_contents($privateKeyFile); + $publicKey = \file_get_contents($publicKeyFile); + $this->assertStringContainsString('foobar', $privateKey); + $this->assertStringContainsString('foobar', $publicKey); + } + +}