Skip to content

Commit 58d4b11

Browse files
committed
feature #108 Generate keys commands (fbourigault)
This PR was squashed before being merged into the 0.4-dev branch. Discussion ---------- Generate keys commands Here is my proposal to implement #106. The first commit is an import of the command and the test from `LexikJWTAuthenticationBundle` to make review of change easier. # To do - [x] Make `$algorithm` a command option as `LeagueOAuth2ServerBundle` doesn't have such configuration option. - [x] Register the command as a service. Commits ------- 79141d2 Generate keys commands
2 parents 044a0f0 + 79141d2 commit 58d4b11

File tree

6 files changed

+467
-0
lines changed

6 files changed

+467
-0
lines changed

composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,14 @@
1717
],
1818
"require": {
1919
"php": "^8.1",
20+
"ext-openssl": "*",
2021
"doctrine/doctrine-bundle": "^2.8.0",
2122
"doctrine/orm": "^2.14|^3.0",
2223
"league/oauth2-server": "^8.3",
2324
"nyholm/psr7": "^1.4",
2425
"psr/http-factory": "^1.0",
2526
"symfony/event-dispatcher": "^5.4|^6.2|^7.0",
27+
"symfony/filesystem": "^5.4|^6.0|^7.0",
2628
"symfony/framework-bundle": "^5.4|^6.2|^7.0",
2729
"symfony/polyfill-php81": "^1.22",
2830
"symfony/psr-http-message-bridge": "^2.0|^6|^7",

phpunit.xml.dist

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
<testsuite name="acceptance">
2323
<directory>./tests/Acceptance</directory>
2424
</testsuite>
25+
<testsuite name="functional">
26+
<directory>./tests/Functional</directory>
27+
</testsuite>
2528
<testsuite name="integration">
2629
<directory>./tests/Integration</directory>
2730
</testsuite>
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace League\Bundle\OAuth2ServerBundle\Command;
6+
7+
use Symfony\Component\Console\Attribute\AsCommand;
8+
use Symfony\Component\Console\Command\Command;
9+
use Symfony\Component\Console\Input\InputInterface;
10+
use Symfony\Component\Console\Input\InputOption;
11+
use Symfony\Component\Console\Output\OutputInterface;
12+
use Symfony\Component\Console\Style\SymfonyStyle;
13+
use Symfony\Component\Filesystem\Filesystem;
14+
15+
/**
16+
* @author Beno!t POLASZEK <bpolaszek@gmail.com>
17+
*/
18+
#[AsCommand(name: 'league:oauth2-server:generate-keypair', description: 'Generate public/private keys for use in your application.')]
19+
final class GenerateKeyPairCommand extends Command
20+
{
21+
private const ACCEPTED_ALGORITHMS = [
22+
'RS256',
23+
'RS384',
24+
'RS512',
25+
'HS256',
26+
'HS384',
27+
'HS512',
28+
'ES256',
29+
'ES384',
30+
'ES512',
31+
];
32+
33+
/**
34+
* @deprecated
35+
*/
36+
protected static $defaultName = 'league:oauth2-server:generate-keypair';
37+
38+
private Filesystem $filesystem;
39+
40+
private string $secretKey;
41+
42+
private string $publicKey;
43+
44+
private ?string $passphrase;
45+
46+
private string $algorithm;
47+
48+
public function __construct(Filesystem $filesystem, string $secretKey, string $publicKey, ?string $passphrase, string $algorithm)
49+
{
50+
parent::__construct();
51+
$this->filesystem = $filesystem;
52+
$this->secretKey = $secretKey;
53+
$this->publicKey = $publicKey;
54+
$this->passphrase = $passphrase;
55+
$this->algorithm = $algorithm;
56+
}
57+
58+
protected function configure(): void
59+
{
60+
$this->setDescription('Generate public/private keys for use in your application.');
61+
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Do not update key files.');
62+
$this->addOption('skip-if-exists', null, InputOption::VALUE_NONE, 'Do not update key files if they already exist.');
63+
$this->addOption('overwrite', null, InputOption::VALUE_NONE, 'Overwrite key files if they already exist.');
64+
}
65+
66+
protected function execute(InputInterface $input, OutputInterface $output): int
67+
{
68+
$io = new SymfonyStyle($input, $output);
69+
70+
if (!\in_array($this->algorithm, self::ACCEPTED_ALGORITHMS, true)) {
71+
$io->error(sprintf('Cannot generate key pair with the provided algorithm `%s`.', $this->algorithm));
72+
73+
return Command::FAILURE;
74+
}
75+
76+
[$secretKey, $publicKey] = $this->generateKeyPair($this->passphrase);
77+
78+
if ($input->getOption('dry-run')) {
79+
$io->success('Your keys have been generated!');
80+
$io->newLine();
81+
$io->writeln(sprintf('Update your private key in <info>%s</info>:', $this->secretKey));
82+
$io->writeln($secretKey);
83+
$io->newLine();
84+
$io->writeln(sprintf('Update your public key in <info>%s</info>:', $this->publicKey));
85+
$io->writeln($publicKey);
86+
87+
return Command::SUCCESS;
88+
}
89+
90+
$alreadyExists = $this->filesystem->exists($this->secretKey) || $this->filesystem->exists($this->publicKey);
91+
92+
if ($alreadyExists) {
93+
try {
94+
$this->handleExistingKeys($input);
95+
} catch (\RuntimeException $e) {
96+
if (0 === $e->getCode()) {
97+
$io->comment($e->getMessage());
98+
99+
return Command::SUCCESS;
100+
}
101+
102+
$io->error($e->getMessage());
103+
104+
return Command::FAILURE;
105+
}
106+
107+
if (!$io->confirm('You are about to replace your existing keys. Are you sure you wish to continue?')) {
108+
$io->comment('Your action was canceled.');
109+
110+
return Command::SUCCESS;
111+
}
112+
}
113+
114+
$this->filesystem->dumpFile($this->secretKey, $secretKey);
115+
$this->filesystem->dumpFile($this->publicKey, $publicKey);
116+
117+
$io->success('Done!');
118+
119+
return Command::SUCCESS;
120+
}
121+
122+
private function handleExistingKeys(InputInterface $input): void
123+
{
124+
if ($input->getOption('skip-if-exists') && $input->getOption('overwrite')) {
125+
throw new \RuntimeException('Both options `--skip-if-exists` and `--overwrite` cannot be combined.', 1);
126+
}
127+
128+
if ($input->getOption('skip-if-exists')) {
129+
throw new \RuntimeException('Your key files already exist, they won\'t be overridden.', 0);
130+
}
131+
132+
if (!$input->getOption('overwrite')) {
133+
throw new \RuntimeException('Your keys already exist. Use the `--overwrite` option to force regeneration.', 1);
134+
}
135+
}
136+
137+
/**
138+
* @return array{0: string, 1: string}
139+
*/
140+
private function generateKeyPair(?string $passphrase): array
141+
{
142+
$config = $this->buildOpenSSLConfiguration();
143+
144+
$resource = openssl_pkey_new($config);
145+
if (false === $resource) {
146+
throw new \RuntimeException(openssl_error_string());
147+
}
148+
149+
$success = openssl_pkey_export($resource, $privateKey, $passphrase);
150+
151+
if (false === $success) {
152+
throw new \RuntimeException(openssl_error_string());
153+
}
154+
155+
$publicKeyData = openssl_pkey_get_details($resource);
156+
157+
if (!\is_array($publicKeyData)) {
158+
throw new \RuntimeException(openssl_error_string());
159+
}
160+
161+
if (!\array_key_exists('key', $publicKeyData) || !\is_string($publicKeyData['key'])) {
162+
throw new \RuntimeException('Invalid public key type.');
163+
}
164+
165+
return [$privateKey, $publicKeyData['key']];
166+
}
167+
168+
private function buildOpenSSLConfiguration(): array
169+
{
170+
$digestAlgorithms = [
171+
'RS256' => 'sha256',
172+
'RS384' => 'sha384',
173+
'RS512' => 'sha512',
174+
'HS256' => 'sha256',
175+
'HS384' => 'sha384',
176+
'HS512' => 'sha512',
177+
'ES256' => 'sha256',
178+
'ES384' => 'sha384',
179+
'ES512' => 'sha512',
180+
];
181+
$privateKeyBits = [
182+
'RS256' => 2048,
183+
'RS384' => 2048,
184+
'RS512' => 4096,
185+
'HS256' => 512,
186+
'HS384' => 512,
187+
'HS512' => 512,
188+
'ES256' => 384,
189+
'ES384' => 512,
190+
'ES512' => 1024,
191+
];
192+
$privateKeyTypes = [
193+
'RS256' => \OPENSSL_KEYTYPE_RSA,
194+
'RS384' => \OPENSSL_KEYTYPE_RSA,
195+
'RS512' => \OPENSSL_KEYTYPE_RSA,
196+
'HS256' => \OPENSSL_KEYTYPE_DH,
197+
'HS384' => \OPENSSL_KEYTYPE_DH,
198+
'HS512' => \OPENSSL_KEYTYPE_DH,
199+
'ES256' => \OPENSSL_KEYTYPE_EC,
200+
'ES384' => \OPENSSL_KEYTYPE_EC,
201+
'ES512' => \OPENSSL_KEYTYPE_EC,
202+
];
203+
204+
$curves = [
205+
'ES256' => 'secp256k1',
206+
'ES384' => 'secp384r1',
207+
'ES512' => 'secp521r1',
208+
];
209+
210+
$config = [
211+
'digest_alg' => $digestAlgorithms[$this->algorithm],
212+
'private_key_type' => $privateKeyTypes[$this->algorithm],
213+
'private_key_bits' => $privateKeyBits[$this->algorithm],
214+
];
215+
216+
if (isset($curves[$this->algorithm])) {
217+
$config['curve_name'] = $curves[$this->algorithm];
218+
}
219+
220+
return $config;
221+
}
222+
}

src/DependencyInjection/LeagueOAuth2ServerExtension.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
88
use League\Bundle\OAuth2ServerBundle\AuthorizationServer\GrantTypeInterface;
99
use League\Bundle\OAuth2ServerBundle\Command\CreateClientCommand;
10+
use League\Bundle\OAuth2ServerBundle\Command\GenerateKeyPairCommand;
1011
use League\Bundle\OAuth2ServerBundle\DBAL\Type\Grant as GrantType;
1112
use League\Bundle\OAuth2ServerBundle\DBAL\Type\RedirectUri as RedirectUriType;
1213
use League\Bundle\OAuth2ServerBundle\DBAL\Type\Scope as ScopeType;
@@ -74,6 +75,13 @@ public function load(array $configs, ContainerBuilder $container)
7475
->findDefinition(CreateClientCommand::class)
7576
->replaceArgument(1, $config['client']['classname'])
7677
;
78+
79+
$container
80+
->findDefinition(GenerateKeyPairCommand::class)
81+
->replaceArgument(1, $config['authorization_server']['private_key'])
82+
->replaceArgument(2, $config['resource_server']['public_key'])
83+
->replaceArgument(3, $config['authorization_server']['private_key_passphrase'])
84+
;
7785
}
7886

7987
public function getAlias(): string

src/Resources/config/services.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use League\Bundle\OAuth2ServerBundle\Command\ClearExpiredTokensCommand;
1212
use League\Bundle\OAuth2ServerBundle\Command\CreateClientCommand;
1313
use League\Bundle\OAuth2ServerBundle\Command\DeleteClientCommand;
14+
use League\Bundle\OAuth2ServerBundle\Command\GenerateKeyPairCommand;
1415
use League\Bundle\OAuth2ServerBundle\Command\ListClientsCommand;
1516
use League\Bundle\OAuth2ServerBundle\Command\UpdateClientCommand;
1617
use League\Bundle\OAuth2ServerBundle\Controller\AuthorizationController;
@@ -268,6 +269,16 @@
268269
->tag('console.command', ['command' => 'league:oauth2-server:clear-expired-tokens'])
269270
->alias(ClearExpiredTokensCommand::class, 'league.oauth2_server.command.clear_expired_tokens')
270271

272+
->set('league.oauth2_server.command.generate_keypair', GenerateKeyPairCommand::class)
273+
->args([
274+
service('filesystem'),
275+
abstract_arg('Private key'),
276+
abstract_arg('Public key'),
277+
abstract_arg('Private key passphrase'),
278+
])
279+
->tag('consome.command', ['command' => 'league:oauth2-server:generate-keypair'])
280+
->alias(GenerateKeyPairCommand::class, 'league.oauth2_server.command.generate_keypair')
281+
271282
// Utility services
272283
->set('league.oauth2_server.converter.user', UserConverter::class)
273284
->alias(UserConverterInterface::class, 'league.oauth2_server.converter.user')

0 commit comments

Comments
 (0)