Added license management module

This commit is contained in:
2026-05-25 22:45:23 +02:00
parent 93b7be7e2c
commit df24d48038
16 changed files with 861 additions and 9 deletions
@@ -0,0 +1,55 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class GenerateLicenseKeysCommand extends Command
{
protected $signature = 'license:generate-keys {--force : Overwrite existing key pair}';
protected $description = 'Generate a new application-wide license signing key pair.';
public function handle(): int
{
$directory = 'license-keys';
$privateKeyPath = $directory . '/private.pem';
$publicKeyPath = $directory . '/public.pem';
$disk = Storage::disk('local');
$privateExists = $disk->exists($privateKeyPath);
$publicExists = $disk->exists($publicKeyPath);
if (($privateExists || $publicExists) && !$this->option('force')) {
$this->error('Key pair already exists. Use --force to overwrite.');
return 1;
}
$keyPair = $this->generateKeyPair();
$disk->put($privateKeyPath, $keyPair['privateKey']);
$disk->put($publicKeyPath, $keyPair['publicKey']);
$this->info('Generated application license key pair.');
$this->line('Private key: ' . $privateKeyPath);
$this->line('Public key: ' . $publicKeyPath);
return 0;
}
protected function generateKeyPair(): array
{
$privateKey = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($privateKey, $privateKeyPem);
$publicKeyDetails = openssl_pkey_get_details($privateKey);
return [
'privateKey' => $privateKeyPem,
'publicKey' => $publicKeyDetails['key'],
];
}
}
+1
View File
@@ -15,6 +15,7 @@ class Kernel extends ConsoleKernel
protected $commands = [
\App\Console\Commands\CaldavSyncCommand::class,
\App\Console\Commands\CheckInvoiceDueDatesCommand::class,
\App\Console\Commands\GenerateLicenseKeysCommand::class,
];
/**
+1 -1
View File
@@ -541,7 +541,7 @@ public function store(Request $request)
}
}
public function update(Request $request, $id)
public function update(Request $request, int $id)
{
// Validate input data (expecting camelCase)
$validatedData = $request->validate([
+341
View File
@@ -0,0 +1,341 @@
<?php
namespace App\Http\Controllers;
use App\Models\License;
use App\Support\ApiDataTransformer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Inertia\Inertia;
class LicenseController extends Controller
{
/**
* Display a listing of the resource.
*
* Retrieves all licenses from the database, ordered by expiration date.
*
* @return array
*/
public function index()
{
$licenses = License::orderBy('expirationDate', 'asc')->get();
return ApiDataTransformer::snakeToCamel($licenses->toArray());
}
/**
* Display the specified resource.
*
* Retrieves a single license by its ID.
*
* @param int $id The ID of the license to retrieve
* @return array
*/
public function single($id)
{
$license = License::findOrFail($id);
return ApiDataTransformer::snakeToCamel($license->toArray());
}
/**
* Display the specified resource.
*
* Renders the Inertia view for licenses
*
* @return \Inertia\Response
*/
public function show()
{
return Inertia::render(
'Licenses',
['licenseData' => $this->index()]
);
}
/**
* Get the directory path for license keys.
*
* @return string
*/
protected function licenseKeyDirectory(): string
{
return 'license-keys';
}
/**
* Get the path for the private key file.
*
* @return string
*/
protected function privateKeyPath(): string
{
return $this->licenseKeyDirectory() . '/private.pem';
}
/**
* Get the path for the public key file.
*
* @return string
*/
protected function publicKeyPath(): string
{
return $this->licenseKeyDirectory() . '/public.pem';
}
/**
* Get the application's key pair.
*
* @return array
*/
protected function getApplicationKeyPair(): array
{
if (!Storage::disk('local')->exists($this->privateKeyPath()) || !Storage::disk('local')->exists($this->publicKeyPath())) {
abort(500, 'Application license key pair is missing. Generate it with `php artisan license:generate-keys`.');
}
return [
'privateKey' => Storage::disk('local')->get($this->privateKeyPath()),
'publicKey' => Storage::disk('local')->get($this->publicKeyPath()),
];
}
/**
* Generate a signature for the license data.
*
* Uses the private key to sign the data.
*
* @param array $licenseData The license data to sign
* @param string $privateKey The private key to use for signing
* @return string
*/
protected function generateSignature($licenseData, $privateKey)
{
$licenseJson = json_encode($licenseData);
if ($licenseJson === false) throw new \Exception('Fehler beim JSON-Encode der Lizenzdaten: ' . json_last_error_msg());
$success = openssl_sign($licenseJson, $signature, $privateKey, OPENSSL_ALGO_SHA256);
if ($success === false) throw new \Exception('Fehler beim Signieren der Lizenzdaten: ' . openssl_error_string());
return base64_encode($signature);
}
/**
* Store a newly created resource in storage.
*
* Validates the request data, generates a signature, and creates a new license.
*
* @param \Illuminate\Http\Request $request The HTTP request containing license data
* @return array
*/
public function store(Request $request)
{
// Validate the request data
$validatedData = $request->validate([
'name' => 'required|string',
'email' => 'required|email',
'product' => 'required|string',
'version' => 'required|string',
'isPerpetual' => 'required|boolean',
'expirationDate' => 'nullable|date',
]);
// Convert camelCase to snake_case
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
$keyPair = $this->getApplicationKeyPair();
$licenseData = [
'name' => $snakeCaseData['name'],
'email' => $snakeCaseData['email'],
'product' => $snakeCaseData['product'],
'version' => $snakeCaseData['version'],
'is_perpetual' => $snakeCaseData['is_perpetual'],
'expiration_date' => $snakeCaseData['expiration_date'],
];
$signature = $this->generateSignature($licenseData, $keyPair['privateKey']);
$license = License::create([
'name' => $snakeCaseData['name'],
'email' => $snakeCaseData['email'],
'product' => $snakeCaseData['product'],
'version' => $snakeCaseData['version'],
'is_perpetual' => $snakeCaseData['is_perpetual'],
'expiration_date' => $snakeCaseData['expiration_date'],
'signature' => $signature,
]);
return ApiDataTransformer::snakeToCamel($license->toArray());
}
/**
* Validate the license signature and expiration date.
*
* The validation logic should be portable to other languages.
*
* @param \Illuminate\Http\Request $request The HTTP request containing the license ID
* @return array
*/
public function validate(Request $request)
{
// Get the license ID from the request
$licenseId = $request->input('license_id');
// Find the license
$license = License::findOrFail($licenseId);
$publicKey = Storage::disk('local')->get($this->publicKeyPath());
$licenseData = [
'name' => $license->name,
'email' => $license->email,
'product' => $license->product,
'version' => $license->version,
'is_perpetual' => $license->is_perpetual,
'expiration_date' => $license->expiration_date,
];
// Convert the license data to JSON
$licenseJson = json_encode($licenseData);
// Verify the signature
$signature = base64_decode($license->signature);
$isSignatureValid = openssl_verify($licenseJson, $signature, $publicKey, OPENSSL_ALGO_SHA256);
// Check the expiration date if the license is not perpetual
$isExpired = false;
if (!$license->is_perpetual && $license->expiration_date) {
$isExpired = now()->gt($license->expiration_date);
}
// Return the validation result
return [
'is_valid' => $isSignatureValid === 1 && !$isExpired,
'is_signature_valid' => $isSignatureValid === 1,
'is_expired' => $isExpired,
];
}
/**
* Download the license as a JSON file.
*
* @param int $id The ID of the license to download
* @return \Symfony\Component\HttpFoundation\StreamedResponse
*/
public function download(int $id)
{
// Find the license
$license = License::findOrFail($id);
// Prepare the license data for download
$licenseData = [
'name' => $license->name,
'email' => $license->email,
'product' => $license->product,
'version' => $license->version,
'is_perpetual' => $license->is_perpetual,
'expiration_date' => $license->expiration_date,
'signature' => $license->signature,
];
// Convert the license data to JSON
$licenseJson = json_encode($licenseData, JSON_PRETTY_PRINT);
// Create a response with the JSON file
$response = response($licenseJson, 200, [
'Content-Type' => 'application/json',
'Content-Disposition' => 'attachment; filename="' . $licenseData['product'] . '.json"',
]);
return $response;
}
/**
* Update the specified resource in storage.
*
* Validates the request data, generates a new signature, and updates the license.
*
* @param \Illuminate\Http\Request $request The HTTP request containing updated license data
* @param int $id The ID of the license to update
* @return array|\Illuminate\Http\JsonResponse
*/
public function update(Request $request, int $id)
{
// Validate the request data
$validatedData = $request->validate([
'name' => 'required|string',
'email' => 'required|email',
'product' => 'required|string',
'version' => 'required|string',
'isPerpetual' => 'required|boolean',
'expirationDate' => 'nullable|date',
]);
// Convert camelCase to snake_case
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
DB::beginTransaction();
try {
// Find license
$license = License::findOrFail($id);
// Retrieve the private key for the license
$privateKey = $this->getApplicationKeyPair()['privateKey'];
// Generate the license data
$licenseData = [
'name' => $snakeCaseData['name'],
'email' => $snakeCaseData['email'],
'product' => $snakeCaseData['product'],
'version' => $snakeCaseData['version'],
'is_perpetual' => $snakeCaseData['is_perpetual'],
'expiration_date' => $snakeCaseData['expiration_date'],
];
// Generate the signature
$signature = $this->generateSignature($licenseData, $privateKey);
// Update the license
$license->update([
'name' => $snakeCaseData['name'],
'email' => $snakeCaseData['email'],
'product' => $snakeCaseData['product'],
'version' => $snakeCaseData['version'],
'is_perpetual' => $snakeCaseData['is_perpetual'],
'expiration_date' => $snakeCaseData['expiration_date'],
'signature' => $signature,
]);
DB::commit();
// Return the updated license
return ApiDataTransformer::snakeToCamel($license->toArray());
} catch (\Exception $e) {
DB::rollBack();
return response()->json([
'message' => 'Lizenz konnte nicht aktualisiert werden',
'error' => $e->getMessage()
], 500);
}
}
/**
* Remove the specified resource from storage.
*
* Deletes the specified license from the database.
*
* @param int $id The ID of the license to delete
* @return \Illuminate\Http\JsonResponse
*/
public function destroy(int $id)
{
$license = License::findOrFail($id);
$license->delete();
return response()->json([
'message' => 'Lizenz erfolgreich gelöscht'
]);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class License extends Model
{
/** @use HasFactory<\Database\Factories\LicenseFactory> */
use HasFactory;
protected $fillable = [
'name',
'email',
'product',
'version',
'is_perpetual',
'expiration_date',
'signature'
];
protected $casts = [
'is_perpetual' => 'boolean',
'expiration_date' => 'datetime'
];
}