stage 1 of oauth removal, switch to sanctum pat tokens
This commit is contained in:
@@ -1,69 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class OAuthListKeysCommand extends Command
|
||||
{
|
||||
protected $signature = 'oauth:list-keys {--json : Output as JSON for scripting}';
|
||||
|
||||
protected $description = 'List available JWT signing key directories and their status.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$storage = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
|
||||
$currentKid = config('oauth.keys.current_kid', 'fotospiel-jwt');
|
||||
|
||||
if (! File::exists($storage)) {
|
||||
$this->error("Key store path does not exist: {$storage}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$directories = collect(File::directories($storage))
|
||||
->filter(fn ($path) => Str::lower(basename($path)) !== 'archive')
|
||||
->values()
|
||||
->map(function (string $path) use ($currentKid) {
|
||||
$kid = basename($path);
|
||||
$publicKey = $path.DIRECTORY_SEPARATOR.'public.key';
|
||||
$privateKey = $path.DIRECTORY_SEPARATOR.'private.key';
|
||||
|
||||
return [
|
||||
'kid' => $kid,
|
||||
'status' => $kid === $currentKid ? 'current' : 'legacy',
|
||||
'public' => File::exists($publicKey),
|
||||
'private' => File::exists($privateKey),
|
||||
'updated_at' => File::exists($path) ? date('c', File::lastModified($path)) : null,
|
||||
'path' => $path,
|
||||
];
|
||||
})
|
||||
->sortBy(fn ($entry) => ($entry['status'] === 'current' ? '0-' : '1-').$entry['kid'])
|
||||
->values();
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line($directories->toJson(JSON_PRETTY_PRINT));
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($directories->isEmpty()) {
|
||||
$this->warn('No signing key directories found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['KID', 'Status', 'Public.key', 'Private.key', 'Updated At', 'Path'],
|
||||
$directories->map(fn ($entry) => [
|
||||
$entry['kid'],
|
||||
$entry['status'],
|
||||
$entry['public'] ? 'yes' : 'no',
|
||||
$entry['private'] ? 'yes' : 'no',
|
||||
$entry['updated_at'] ?? 'n/a',
|
||||
$entry['path'],
|
||||
])
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class OAuthPruneKeysCommand extends Command
|
||||
{
|
||||
protected $signature = 'oauth:prune-keys
|
||||
{--days=90 : Prune keys whose directories were last modified before this many days ago}
|
||||
{--dry-run : Show which keys would be removed without deleting}
|
||||
{--force : Skip confirmation prompt}';
|
||||
|
||||
protected $description = 'Remove legacy JWT signing keys older than the configured threshold.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$storage = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
|
||||
$currentKid = config('oauth.keys.current_kid', 'fotospiel-jwt');
|
||||
|
||||
if (! File::exists($storage)) {
|
||||
$this->error("Key store path does not exist: {$storage}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$days = (int) $this->option('days');
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$candidates = collect(File::directories($storage))
|
||||
->reject(fn ($path) => Str::lower(basename($path)) === 'archive')
|
||||
->filter(function (string $path) use ($currentKid, $cutoff) {
|
||||
$kid = basename($path);
|
||||
if ($kid === $currentKid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lastModified = File::lastModified($path);
|
||||
|
||||
return $lastModified !== false && $cutoff->greaterThan(\Carbon\Carbon::createFromTimestamp($lastModified));
|
||||
})
|
||||
->values();
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
$this->info("No legacy key directories older than {$days} days were found.");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['KID', 'Last Modified', 'Path'],
|
||||
$candidates->map(fn ($path) => [
|
||||
basename($path),
|
||||
date('c', File::lastModified($path)),
|
||||
$path,
|
||||
])
|
||||
);
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info('Dry run complete. No keys were removed.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->option('force') && ! $this->confirm('Remove the listed legacy key directories?', false)) {
|
||||
$this->warn('Prune cancelled.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($candidates as $path) {
|
||||
File::deleteDirectory($path);
|
||||
}
|
||||
|
||||
$this->info('Legacy key directories pruned.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class OAuthRotateKeysCommand extends Command
|
||||
{
|
||||
protected $signature = 'oauth:rotate-keys {--kid=} {--force : Do not prompt for confirmation}';
|
||||
|
||||
protected $description = 'Generate a new JWT signing key pair for tenant OAuth tokens.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$storage = rtrim(config('oauth.keys.storage_path', storage_path('app/oauth-keys')), DIRECTORY_SEPARATOR);
|
||||
$currentKid = config('oauth.keys.current_kid', 'fotospiel-jwt');
|
||||
$newKid = $this->option('kid') ?: 'kid-'.now()->format('YmdHis');
|
||||
|
||||
if (! $this->option('force') &&
|
||||
! $this->confirm("Rotate JWT keys? Current kid: {$currentKid}. New kid: {$newKid}", true)
|
||||
) {
|
||||
$this->info('Rotation cancelled.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
File::ensureDirectoryExists($storage);
|
||||
|
||||
$archiveDir = $this->archiveExistingKeys($storage, $currentKid);
|
||||
|
||||
$newDirectory = $storage.DIRECTORY_SEPARATOR.$newKid;
|
||||
if (File::exists($newDirectory)) {
|
||||
$this->error("Target directory already exists: {$newDirectory}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
File::makeDirectory($newDirectory, 0700, true);
|
||||
$this->generateKeyPair($newDirectory);
|
||||
|
||||
$this->info('New signing keys generated.');
|
||||
$this->line("Path: {$newDirectory}");
|
||||
|
||||
if ($archiveDir) {
|
||||
$this->line("Previous keys archived at: {$archiveDir}");
|
||||
$this->line('Existing key remains available for token verification until you prune it.');
|
||||
}
|
||||
|
||||
$this->warn("Update OAUTH_JWT_KID in your environment configuration to: {$newKid}");
|
||||
$this->info('Run `php artisan oauth:list-keys` to verify active signing directories.');
|
||||
$this->info('Once legacy tokens expire, run `php artisan oauth:prune-keys` to remove retired keys.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function archiveExistingKeys(string $storage, string $kid): ?string
|
||||
{
|
||||
$existingDir = $storage.DIRECTORY_SEPARATOR.$kid;
|
||||
$legacyPublic = storage_path('app/public.key');
|
||||
$legacyPrivate = storage_path('app/private.key');
|
||||
|
||||
if (File::exists($existingDir)) {
|
||||
$archiveDir = $storage.DIRECTORY_SEPARATOR.'archive'.DIRECTORY_SEPARATOR.$kid.'-'.now()->format('YmdHis');
|
||||
File::ensureDirectoryExists(dirname($archiveDir));
|
||||
File::copyDirectory($existingDir, $archiveDir);
|
||||
return $archiveDir;
|
||||
}
|
||||
|
||||
if (File::exists($legacyPublic) || File::exists($legacyPrivate)) {
|
||||
$archiveDir = $storage.DIRECTORY_SEPARATOR.'archive'.DIRECTORY_SEPARATOR.'legacy-'.now()->format('YmdHis');
|
||||
File::ensureDirectoryExists($archiveDir);
|
||||
|
||||
if (File::exists($legacyPublic)) {
|
||||
File::copy($legacyPublic, $archiveDir.DIRECTORY_SEPARATOR.'public.key');
|
||||
}
|
||||
|
||||
if (File::exists($legacyPrivate)) {
|
||||
File::copy($legacyPrivate, $archiveDir.DIRECTORY_SEPARATOR.'private.key');
|
||||
}
|
||||
|
||||
return $archiveDir;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function generateKeyPair(string $directory): void
|
||||
{
|
||||
$config = [
|
||||
'digest_alg' => OPENSSL_ALGO_SHA256,
|
||||
'private_key_bits' => 4096,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
];
|
||||
|
||||
$resource = openssl_pkey_new($config);
|
||||
if (! $resource) {
|
||||
throw new \RuntimeException('Failed to generate key pair');
|
||||
}
|
||||
|
||||
openssl_pkey_export($resource, $privateKey);
|
||||
$details = openssl_pkey_get_details($resource);
|
||||
$publicKey = $details['key'] ?? null;
|
||||
|
||||
if (! $publicKey) {
|
||||
throw new \RuntimeException('Unable to extract public key');
|
||||
}
|
||||
|
||||
File::put($directory.DIRECTORY_SEPARATOR.'private.key', $privateKey);
|
||||
File::chmod($directory.DIRECTORY_SEPARATOR.'private.key', 0600);
|
||||
|
||||
File::put($directory.DIRECTORY_SEPARATOR.'public.key', $publicKey);
|
||||
File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user