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);
+ }
+
+}