Skip to content

Commit

Permalink
Generate keys commands
Browse files Browse the repository at this point in the history
  • Loading branch information
fbourigault authored and chalasr committed Apr 9, 2024
1 parent 044a0f0 commit 79141d2
Show file tree
Hide file tree
Showing 6 changed files with 467 additions and 0 deletions.
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
<testsuite name="acceptance">
<directory>./tests/Acceptance</directory>
</testsuite>
<testsuite name="functional">
<directory>./tests/Functional</directory>
</testsuite>
<testsuite name="integration">
<directory>./tests/Integration</directory>
</testsuite>
Expand Down
222 changes: 222 additions & 0 deletions src/Command/GenerateKeyPairCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
<?php

declare(strict_types=1);

namespace League\Bundle\OAuth2ServerBundle\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;

/**
* @author Beno!t POLASZEK <bpolaszek@gmail.com>
*/
#[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 <info>%s</info>:', $this->secretKey));
$io->writeln($secretKey);
$io->newLine();
$io->writeln(sprintf('Update your public key in <info>%s</info>:', $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;
}
}
8 changes: 8 additions & 0 deletions src/DependencyInjection/LeagueOAuth2ServerExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/Resources/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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')
Expand Down
Loading

0 comments on commit 79141d2

Please sign in to comment.