From 75439328c3062db1a8655d567ea88d91ee2ff302 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Wed, 9 Sep 2026 14:40:18 +0100 Subject: [PATCH] paddel setup new verifier --- .../Api/LicenseActivationController.php | 157 +++++++++++++++--- routes/api.stage4.php | 1 + tests/Feature/LicenseActivationTest.php | 62 +++++++ 3 files changed, 197 insertions(+), 23 deletions(-) diff --git a/app/Http/Controllers/Api/LicenseActivationController.php b/app/Http/Controllers/Api/LicenseActivationController.php index 4bd5b7d..d4e1aba 100644 --- a/app/Http/Controllers/Api/LicenseActivationController.php +++ b/app/Http/Controllers/Api/LicenseActivationController.php @@ -1,4 +1,5 @@ validate([ - 'licenseKey'=>['required','string','max:128'], - 'installationID'=>['required','uuid'], - 'appVersion'=>['required','string','max:32'], - 'platform'=>['required','string','max:32'], + 'licenseKey' => ['required', 'string', 'max:128'], + 'installationID' => ['required', 'uuid'], + 'appVersion' => ['required', 'string', 'max:32'], + 'platform' => ['required', 'string', 'max:32'], ]); - return DB::transaction(function () use ($data,$signer) { - $license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey']))->lockForUpdate()->first(); - if (!$license) throw ValidationException::withMessages(['licenseKey'=>'The licence key is not valid.']); - if (!$license->is_usable) return response()->json(['message'=>'This licence is expired or has been revoked.'], 403); + return DB::transaction(function () use ($data, $signer) { + $license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey'])) + ->lockForUpdate() + ->first(); - $activation = LicenseActivation::where('license_id',$license->id)->where('installation_id',strtolower($data['installationID']))->first(); - if ($activation && $activation->deactivated_at === null) { - $activation->update(['last_seen_at'=>now(),'platform'=>$data['platform'],'app_version'=>$data['appVersion']]); - } else { - $activeCount = LicenseActivation::where('license_id',$license->id)->whereNull('deactivated_at')->count(); - if ($activeCount >= $license->activation_limit) return response()->json(['message'=>'This licence has reached its activation limit. Deactivate another Mac first.'], 409); - if ($activation) $activation->update(['deactivated_at'=>null,'activated_at'=>now(),'last_seen_at'=>now(),'platform'=>$data['platform'],'app_version'=>$data['appVersion']]); - else LicenseActivation::create(['license_id'=>$license->id,'installation_id'=>strtolower($data['installationID']),'platform'=>$data['platform'],'app_version'=>$data['appVersion'],'activated_at'=>now(),'last_seen_at'=>now()]); + if (! $license) { + throw ValidationException::withMessages(['licenseKey' => 'The licence key is not valid.']); } - return response()->json(['licenseToken'=>$signer->sign($license, $data['installationID'])]); + + if (! $license->is_usable) { + return response()->json(['message' => 'This licence is expired or has been revoked.'], 403); + } + + $installationID = strtolower($data['installationID']); + $activation = LicenseActivation::where('license_id', $license->id) + ->where('installation_id', $installationID) + ->first(); + + if ($activation && $activation->deactivated_at === null) { + $activation->update([ + 'last_seen_at' => now(), + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + ]); + } else { + $activeCount = LicenseActivation::where('license_id', $license->id) + ->whereNull('deactivated_at') + ->count(); + + if ($activeCount >= $license->activation_limit) { + return response()->json(['message' => 'This licence has reached its activation limit. Deactivate another Mac first.'], 409); + } + + if ($activation) { + $activation->update([ + 'deactivated_at' => null, + 'activated_at' => now(), + 'last_seen_at' => now(), + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + ]); + } else { + LicenseActivation::create([ + 'license_id' => $license->id, + 'installation_id' => $installationID, + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + 'activated_at' => now(), + 'last_seen_at' => now(), + ]); + } + } + + return response()->json([ + 'licenseToken' => $signer->sign($license, $data['installationID']), + ]); + }); + } + + public function validateInstallation( + Request $request, + LicenseTokenVerifier $verifier, + LicenseSigner $signer, + ): JsonResponse { + $data = $request->validate([ + 'licenseToken' => ['required', 'string', 'max:8192'], + 'installationID' => ['required', 'uuid'], + 'appVersion' => ['required', 'string', 'max:32'], + 'platform' => ['required', 'string', 'max:32'], + ]); + + try { + $payload = $verifier->verify($data['licenseToken']); + } catch (\Throwable) { + return response()->json(['message' => 'The licence token is invalid.'], 400); + } + + if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) { + return response()->json(['message' => 'The installation does not match this licence token.'], 400); + } + + return DB::transaction(function () use ($data, $payload, $signer) { + $license = License::whereKey((string) ($payload['licenseID'] ?? '')) + ->lockForUpdate() + ->first(); + + if (! $license || ! $license->is_usable) { + return response()->json(['message' => 'This licence is expired or has been revoked.'], 403); + } + + $activation = LicenseActivation::where('license_id', $license->id) + ->where('installation_id', strtolower($data['installationID'])) + ->first(); + + if (! $activation || $activation->deactivated_at !== null) { + return response()->json(['message' => 'This Mac has been deactivated from the licence portal.'], 403); + } + + $activation->update([ + 'last_seen_at' => now(), + 'platform' => $data['platform'], + 'app_version' => $data['appVersion'], + ]); + + return response()->json([ + 'active' => true, + // Refreshing the signed token also applies subscription edition/expiry changes + // without requiring the user to reactivate manually. + 'licenseToken' => $signer->sign($license, $data['installationID']), + ]); }); } public function deactivate(Request $request, LicenseTokenVerifier $verifier): JsonResponse { - $data = $request->validate(['licenseToken'=>['required','string','max:8192'],'installationID'=>['required','uuid']]); - try { $payload = $verifier->verify($data['licenseToken']); } - catch (\Throwable) { return response()->json(['message'=>'The licence token is invalid.'], 400); } - if (strcasecmp((string)($payload['installationID'] ?? ''), $data['installationID']) !== 0) return response()->json(['message'=>'The installation does not match this licence token.'], 400); - LicenseActivation::where('license_id',(string)($payload['licenseID'] ?? ''))->where('installation_id',strtolower($data['installationID']))->whereNull('deactivated_at')->update(['deactivated_at'=>now(),'last_seen_at'=>now()]); - return response()->json((object)[]); + $data = $request->validate([ + 'licenseToken' => ['required', 'string', 'max:8192'], + 'installationID' => ['required', 'uuid'], + ]); + + try { + $payload = $verifier->verify($data['licenseToken']); + } catch (\Throwable) { + return response()->json(['message' => 'The licence token is invalid.'], 400); + } + + if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) { + return response()->json(['message' => 'The installation does not match this licence token.'], 400); + } + + LicenseActivation::where('license_id', (string) ($payload['licenseID'] ?? '')) + ->where('installation_id', strtolower($data['installationID'])) + ->whereNull('deactivated_at') + ->update(['deactivated_at' => now(), 'last_seen_at' => now()]); + + return response()->json((object) []); } } diff --git a/routes/api.stage4.php b/routes/api.stage4.php index 2f3994a..7adcab0 100644 --- a/routes/api.stage4.php +++ b/routes/api.stage4.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Route; Route::prefix('v1')->group(function () { Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation'); + Route::post('/validate', [LicenseActivationController::class, 'validateInstallation'])->middleware('throttle:license-activation'); Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation'); Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle','lemon-squeezy']); }); diff --git a/tests/Feature/LicenseActivationTest.php b/tests/Feature/LicenseActivationTest.php index 4db0d4f..4615edb 100644 --- a/tests/Feature/LicenseActivationTest.php +++ b/tests/Feature/LicenseActivationTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; use App\Models\LicenseProduct; +use App\Models\LicenseActivation; use App\Services\Licensing\LicenseIssuer; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -18,4 +19,65 @@ class LicenseActivationTest extends TestCase $this->postJson('/api/v1/activate',$one)->assertOk(); $this->postJson('/api/v1/activate',array_merge($one,['installationID'=>'22222222-2222-4222-8222-222222222222']))->assertStatus(409); } + public function test_validation_refreshes_an_active_installation_without_calling_paddle(): void + { + $product = LicenseProduct::create([ + 'code' => 'validate-active', + 'name' => 'Validate Active', + 'edition' => 'Professional', + 'activation_limit' => 1, + 'max_major_version' => 1, + 'active' => true, + ]); + [, $key] = app(LicenseIssuer::class)->issue($product, 'Test', 'test@example.com'); + $request = [ + 'licenseKey' => $key, + 'installationID' => '11111111-1111-4111-8111-111111111111', + 'appVersion' => '1.0.0', + 'platform' => 'macOS', + ]; + + $token = $this->postJson('/api/v1/activate', $request)->assertOk()->json('licenseToken'); + + $this->postJson('/api/v1/validate', [ + 'licenseToken' => $token, + 'installationID' => $request['installationID'], + 'appVersion' => '1.0.1', + 'platform' => 'macOS', + ])->assertOk()->assertJson([ + 'active' => true, + ])->assertJsonStructure(['licenseToken']); + } + + public function test_validation_rejects_an_installation_deactivated_from_the_portal(): void + { + $product = LicenseProduct::create([ + 'code' => 'validate-deactivated', + 'name' => 'Validate Deactivated', + 'edition' => 'Personal', + 'activation_limit' => 1, + 'max_major_version' => 1, + 'active' => true, + ]); + [, $key] = app(LicenseIssuer::class)->issue($product, 'Test', 'test@example.com'); + $request = [ + 'licenseKey' => $key, + 'installationID' => '22222222-2222-4222-8222-222222222222', + 'appVersion' => '1.0.0', + 'platform' => 'macOS', + ]; + + $token = $this->postJson('/api/v1/activate', $request)->assertOk()->json('licenseToken'); + LicenseActivation::query()->where('installation_id', strtolower($request['installationID']))->update(['deactivated_at' => now()]); + + $this->postJson('/api/v1/validate', [ + 'licenseToken' => $token, + 'installationID' => $request['installationID'], + 'appVersion' => '1.0.0', + 'platform' => 'macOS', + ])->assertStatus(403)->assertJson([ + 'message' => 'This Mac has been deactivated from the licence portal.', + ]); + } + }