Creare un sistema di gestione delle licenze software con Laravel

Creare un sistema di gestione delle licenze software con Laravel

Ogni prodotto software distribuito al di fuori di un modello puramente SaaS prima o poi si scontra con lo stesso problema: stabilire chi ha il diritto di eseguire l'applicazione, su quante macchine, e fino a quando. Un sistema di gestione delle licenze non è un semplice generatore di stringhe alfanumeriche: è un servizio che deve emettere credenziali crittograficamente verificabili, tracciare le attivazioni, gestire revoche e rinnovi, resistere agli abusi e continuare a funzionare quando il client non ha connettività.

In questo articolo costruiremo un license server completo con Laravel, partendo dal modello dei dati fino alla validazione offline tramite firme Ed25519, passando per il livello di servizio, l'API HTTP, l'autenticazione delle richieste, l'audit trail e i test automatici. Il codice è pensato per Laravel 11 e versioni successive, che adottano la struttura applicativa basata su bootstrap/app.php e routes/console.php.

Architettura e concetti fondamentali

Prima di scrivere codice conviene fissare il vocabolario del dominio, perché la maggior parte degli errori di progettazione in questo ambito nasce da concetti sovrapposti.

  • Prodotto: l'unità commerciale licenziabile. Ogni prodotto ha una propria coppia di chiavi di firma e politiche di default (numero di attivazioni, periodo di grazia).
  • Licenza: il diritto d'uso concesso a un cliente per un prodotto. Ha un tipo (perpetua, a sottoscrizione, trial), uno stato e una data di scadenza opzionale.
  • Chiave di licenza: la stringa leggibile dall'utente che identifica la licenza. È un segreto e non va mai memorizzata in chiaro.
  • Attivazione: il legame tra una licenza e una specifica installazione, identificata da un fingerprint della macchina. Il numero di attivazioni simultanee è il vero limite commerciale.
  • Token di licenza: un artefatto firmato dal server, con validità breve, che il client conserva e verifica offline con la chiave pubblica del prodotto.

La distinzione fra chiave e token è il punto centrale dell'intero progetto. La chiave serve una sola volta, al momento dell'attivazione; da quel momento in poi il client lavora con il token firmato, che può essere validato senza rete e che scade da solo. Questo modello evita sia il controllo online a ogni avvio, fragile e invasivo, sia la licenza perpetua non revocabile.

Il modello dei dati

Quattro tabelle sono sufficienti per un sistema robusto: i prodotti, le licenze, le attivazioni e un registro di eventi che funge da audit trail.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('products', function (Blueprint $table) {
            $table->id();
            $table->string('slug')->unique();
            $table->string('name');
            // prefisso stampato all'inizio di ogni chiave, per riconoscere il prodotto a colpo d'occhio
            $table->string('key_prefix', 8);
            $table->unsignedSmallInteger('default_max_activations')->default(1);
            // giorni di tolleranza dopo la scadenza del token, per gestire client offline
            $table->unsignedSmallInteger('grace_days')->default(7);
            $table->boolean('is_active')->default(true);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('products');
    }
};

La tabella delle licenze non contiene la chiave, ma il suo hash. Conserviamo inoltre il prefisso e le ultime quattro cifre, che servono a mostrare la licenza in un pannello di amministrazione senza esporla.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('licenses', function (Blueprint $table) {
            $table->id();
            $table->uuid('uuid')->unique();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();

            // hash HMAC-SHA256 della chiave normalizzata: è il campo su cui si effettua la ricerca
            $table->char('key_hash', 64)->unique();
            $table->string('key_prefix', 8);
            $table->char('key_last_four', 4);

            $table->string('customer_email');
            $table->string('customer_name')->nullable();

            $table->enum('type', ['perpetual', 'subscription', 'trial'])->default('subscription');
            $table->enum('status', ['active', 'suspended', 'revoked', 'expired'])->default('active');

            $table->unsignedSmallInteger('max_activations')->default(1);
            $table->timestamp('issued_at');
            // null significa licenza senza scadenza (tipicamente perpetua)
            $table->timestamp('expires_at')->nullable();
            $table->timestamp('revoked_at')->nullable();
            $table->string('revocation_reason')->nullable();

            // campo libero per dati commerciali: numero d'ordine, piano, feature flag
            $table->json('metadata')->nullable();

            $table->timestamps();
            $table->softDeletes();

            $table->index(['product_id', 'status']);
            $table->index('expires_at');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('licenses');
    }
};

Le attivazioni sono la tabella su cui si concentrerà la maggior parte della concorrenza. Il vincolo di unicità sulla coppia licenza/fingerprint impedisce che la stessa installazione occupi due postazioni.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('license_activations', function (Blueprint $table) {
            $table->id();
            $table->foreignId('license_id')->constrained()->cascadeOnDelete();

            // hash del fingerprint hardware inviato dal client: non memorizziamo il dato grezzo
            $table->char('fingerprint_hash', 64);

            $table->string('hostname')->nullable();
            $table->string('os')->nullable();
            $table->string('app_version', 32)->nullable();
            $table->ipAddress('ip_address')->nullable();

            $table->timestamp('activated_at');
            $table->timestamp('last_seen_at')->nullable();
            $table->timestamp('deactivated_at')->nullable();

            $table->timestamps();

            // una sola attivazione attiva per fingerprint: le disattivate hanno deactivated_at valorizzato
            $table->unique(['license_id', 'fingerprint_hash']);
            $table->index('last_seen_at');
        });

        Schema::create('license_events', function (Blueprint $table) {
            $table->id();
            $table->foreignId('license_id')->nullable()->constrained()->nullOnDelete();
            $table->string('type', 40);
            $table->json('context')->nullable();
            $table->ipAddress('ip_address')->nullable();
            $table->timestamp('created_at')->useCurrent();

            $table->index(['license_id', 'type']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('license_events');
        Schema::dropIfExists('license_activations');
    }
};

I modelli Eloquent

I modelli restano volutamente sottili: contengono relazioni, cast, scope e predicati di dominio, mentre ogni operazione che modifica lo stato passerà dal livello di servizio.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;

class License extends Model
{
    use HasFactory;
    use SoftDeletes;

    protected $guarded = [];

    protected function casts(): array
    {
        return [
            'issued_at'  => 'immutable_datetime',
            'expires_at' => 'immutable_datetime',
            'revoked_at' => 'immutable_datetime',
            'metadata'   => 'array',
        ];
    }

    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }

    public function activations(): HasMany
    {
        return $this->hasMany(LicenseActivation::class);
    }

    /**
     * Attivazioni ancora valide, cioè non disattivate esplicitamente.
     */
    public function liveActivations(): HasMany
    {
        return $this->activations()->whereNull('deactivated_at');
    }

    public function isExpired(): bool
    {
        return $this->expires_at !== null && $this->expires_at->isPast();
    }

    public function isUsable(): bool
    {
        return $this->status === 'active' && ! $this->isExpired();
    }

    public function maskedKey(): string
    {
        // formato mostrato nel pannello: PREFIX-XXXX-XXXX-XXXX-1A2B
        return sprintf('%s-XXXX-XXXX-XXXX-%s', $this->key_prefix, $this->key_last_four);
    }

    public function scopeExpiringWithin(Builder $query, int $days): Builder
    {
        return $query->where('status', 'active')
            ->whereNotNull('expires_at')
            ->whereBetween('expires_at', [now(), now()->addDays($days)]);
    }
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class LicenseActivation extends Model
{
    use HasFactory;

    protected $guarded = [];

    protected function casts(): array
    {
        return [
            'activated_at'   => 'immutable_datetime',
            'last_seen_at'   => 'immutable_datetime',
            'deactivated_at' => 'immutable_datetime',
        ];
    }

    public function license(): BelongsTo
    {
        return $this->belongsTo(License::class);
    }
}

Generazione delle chiavi di licenza

Una buona chiave deve essere trascrivibile a mano senza ambiguità, sufficientemente lunga da rendere impossibile l'enumerazione e dotata di un checksum che permetta di scartare gli errori di digitazione prima ancora di interrogare il database. L'alfabeto Crockford Base32 risolve il primo requisito eliminando i caratteri I, L, O e U.

<?php

namespace App\Licensing;

use InvalidArgumentException;

final class LicenseKeyGenerator
{
    // alfabeto Crockford Base32: niente I, L, O, U per evitare ambiguità di lettura
    private const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';

    // 16 caratteri di entropia più 4 di checksum, suddivisi in gruppi da 4
    private const ENTROPY_LENGTH = 16;

    public function generate(string $prefix): string
    {
        $body = '';

        for ($i = 0; $i < self::ENTROPY_LENGTH; $i++) {
            // random_int usa il CSPRNG del sistema: mai usare rand() o mt_rand() qui
            $body .= self::ALPHABET[random_int(0, strlen(self::ALPHABET) - 1)];
        }

        $checksum = $this->checksum($prefix . $body);
        $full     = $body . $checksum;

        return strtoupper($prefix) . '-' . implode('-', str_split($full, 4));
    }

    /**
     * Normalizza una chiave inserita dall'utente: rimuove i separatori,
     * uniforma il maiuscolo e corregge le confusioni tipiche di lettura.
     */
    public function normalize(string $key): string
    {
        $key = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $key) ?? '');

        // O e I vengono interpretate come 0 e 1, secondo la specifica Crockford
        return strtr($key, ['O' => '0', 'I' => '1', 'L' => '1', 'U' => 'V']);
    }

    public function hasValidChecksum(string $normalizedKey, string $prefix): bool
    {
        $prefix = strtoupper($prefix);

        if (! str_starts_with($normalizedKey, $prefix)) {
            return false;
        }

        $payload  = substr($normalizedKey, strlen($prefix));
        $body     = substr($payload, 0, self::ENTROPY_LENGTH);
        $checksum = substr($payload, self::ENTROPY_LENGTH);

        if (strlen($body) !== self::ENTROPY_LENGTH || strlen($checksum) !== 4) {
            return false;
        }

        // confronto a tempo costante anche sul checksum, per abitudine difensiva
        return hash_equals($this->checksum($prefix . $body), $checksum);
    }

    private function checksum(string $input): string
    {
        $secret = config('licensing.checksum_secret');

        if (blank($secret)) {
            throw new InvalidArgumentException('The licensing checksum secret is not configured.');
        }

        $digest = hash_hmac('sha256', $input, $secret, true);
        $out    = '';

        // convertiamo i primi byte del digest in caratteri dell'alfabeto Crockford
        for ($i = 0; $i < 4; $i++) {
            $out .= self::ALPHABET[ord($digest[$i]) % strlen(self::ALPHABET)];
        }

        return $out;
    }
}

Il checksum non è una misura di sicurezza: serve a rispondere immediatamente a chi digita male la chiave, senza esporre il database a query inutili. La sicurezza sta nell'entropia, che con sedici caratteri Base32 supera gli ottanta bit.

Archiviazione sicura delle chiavi

La chiave di licenza è una credenziale a tutti gli effetti e va trattata come una password, con una differenza sostanziale: dobbiamo poterla cercare. Un algoritmo lento come bcrypt renderebbe impossibile la ricerca per chiave, perché ogni riga richiederebbe un confronto separato. La soluzione corretta è un HMAC con un segreto applicativo (un pepper): deterministico, quindi indicizzabile, ma inutilizzabile per chi ottiene solo il dump del database.

<?php

return [
    // segreto usato per il checksum delle chiavi
    'checksum_secret' => env('LICENSING_CHECKSUM_SECRET'),

    // pepper per l'HMAC con cui si indicizzano le chiavi nel database
    'key_pepper' => env('LICENSING_KEY_PEPPER'),

    // durata del token di licenza firmato consegnato al client
    'token_ttl_days' => (int) env('LICENSING_TOKEN_TTL_DAYS', 14),

    // finestra oltre la quale un'attivazione senza heartbeat è considerata abbandonata
    'stale_activation_days' => (int) env('LICENSING_STALE_ACTIVATION_DAYS', 45),

    'signing' => [
        // chiavi Ed25519 codificate in base64, generate per prodotto e conservate fuori dal repository
        'secret_keys' => [
            'my-product' => env('LICENSING_SIGNING_SECRET_MY_PRODUCT'),
        ],
        'public_keys' => [
            'my-product' => env('LICENSING_SIGNING_PUBLIC_MY_PRODUCT'),
        ],
    ],
];
<?php

namespace App\Licensing;

final class LicenseKeyHasher
{
    public function __construct(private readonly LicenseKeyGenerator $generator)
    {
    }

    /**
     * Calcola l'hash indicizzabile della chiave: HMAC-SHA256 con pepper applicativo.
     */
    public function hash(string $key): string
    {
        return hash_hmac(
            'sha256',
            $this->generator->normalize($key),
            (string) config('licensing.key_pepper')
        );
    }
}

Il pepper vive nelle variabili d'ambiente e non nel database: se qualcuno esfiltra la tabella licenses senza avere accesso al file .env, non può risalire alle chiavi né generarne di valide.

Il token firmato e la validazione offline

Il token è un piccolo documento JSON firmato con Ed25519, disponibile in PHP tramite l'estensione sodium presente nel core dal linguaggio 7.2. La firma è detached e il payload resta leggibile: non serve nascondere il contenuto, serve renderlo non falsificabile.

<?php

namespace App\Licensing;

use App\Models\License;
use App\Models\LicenseActivation;
use RuntimeException;

final class LicenseTokenIssuer
{
    public function issue(License $license, LicenseActivation $activation): string
    {
        $ttl = (int) config('licensing.token_ttl_days');

        $payload = [
            'v'   => 1,
            'lic' => $license->uuid,
            'prd' => $license->product->slug,
            'fpr' => $activation->fingerprint_hash,
            'typ' => $license->type,
            'sub' => $license->customer_email,
            'fea' => $license->metadata['features'] ?? [],
            // scadenza commerciale della licenza, null per le perpetue
            'lex' => $license->expires_at?->getTimestamp(),
            // scadenza tecnica del token: obbliga il client a ricontattare il server
            'exp' => now()->addDays($ttl)->getTimestamp(),
            'iat' => now()->getTimestamp(),
            'grc' => $license->product->grace_days,
        ];

        $json      = json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
        $signature = sodium_crypto_sign_detached($json, $this->secretKey($license->product->slug));

        return $this->base64UrlEncode($json) . '.' . $this->base64UrlEncode($signature);
    }

    private function secretKey(string $productSlug): string
    {
        $encoded = config("licensing.signing.secret_keys.{$productSlug}");

        if (blank($encoded)) {
            throw new RuntimeException("Missing signing key for product [{$productSlug}].");
        }

        return sodium_base642bin($encoded, SODIUM_BASE64_VARIANT_ORIGINAL);
    }

    private function base64UrlEncode(string $value): string
    {
        return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
    }
}

La coppia di chiavi si genera una sola volta per prodotto, ad esempio con un comando Artisan dedicato; la chiave segreta resta sul server, quella pubblica viene compilata dentro l'applicazione client.

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;

class GenerateSigningKeypair extends Command
{
    protected $signature = 'licensing:keypair {product : The product slug}';

    protected $description = 'Generate an Ed25519 keypair for signing license tokens';

    public function handle(): int
    {
        $keypair = sodium_crypto_sign_keypair();

        $secret = sodium_bin2base64(sodium_crypto_sign_secretkey($keypair), SODIUM_BASE64_VARIANT_ORIGINAL);
        $public = sodium_bin2base64(sodium_crypto_sign_publickey($keypair), SODIUM_BASE64_VARIANT_ORIGINAL);

        $slug = str($this->argument('product'))->upper()->replace('-', '_')->value();

        // le chiavi non vengono salvate automaticamente: vanno copiate nel gestore dei segreti
        $this->line("LICENSING_SIGNING_SECRET_{$slug}={$secret}");
        $this->line("LICENSING_SIGNING_PUBLIC_{$slug}={$public}");

        $this->newLine();
        $this->warn('Store the secret key in your secret manager and never commit it.');

        return self::SUCCESS;
    }
}

Lato client la verifica non richiede alcuna connessione. Questo è l'equivalente PHP del controllo che implementerete nel linguaggio dell'applicazione distribuita.

<?php

function verifyLicenseToken(string $token, string $publicKeyBase64, string $fingerprint): array
{
    [$encodedPayload, $encodedSignature] = explode('.', $token, 2) + [null, null];

    $decode  = static fn (string $v): string => base64_decode(strtr($v, '-_', '+/'), true) ?: '';
    $payload = $decode($encodedPayload);

    $valid = sodium_crypto_sign_verify_detached(
        $decode($encodedSignature),
        $payload,
        sodium_base642bin($publicKeyBase64, SODIUM_BASE64_VARIANT_ORIGINAL)
    );

    if (! $valid) {
        throw new RuntimeException('Invalid license signature.');
    }

    $claims = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);

    // il token deve appartenere a questa macchina, altrimenti è stato copiato altrove
    if (! hash_equals($claims['fpr'], $fingerprint)) {
        throw new RuntimeException('License token does not belong to this machine.');
    }

    // periodo di grazia: il client resta operativo anche se non riesce a rinnovare il token
    $hardDeadline = $claims['exp'] + ($claims['grc'] * 86400);

    if (time() > $hardDeadline) {
        throw new RuntimeException('License token expired.');
    }

    return $claims;
}

Il periodo di grazia merita attenzione. Senza di esso, un'interruzione del license server o una rete aziendale isolata bloccherebbero il software di clienti perfettamente in regola. Con esso, il costo di una revoca diventa al massimo la durata del token più i giorni di tolleranza: un compromesso accettabile, che va documentato nel contratto di licenza.

Le eccezioni di dominio

Modellare gli errori come eccezioni tipizzate permette di tradurli in risposte HTTP coerenti senza sparpagliare codici di stato nei controller.

<?php

namespace App\Licensing\Exceptions;

use Exception;

abstract class LicensingException extends Exception
{
    abstract public function errorCode(): string;

    abstract public function statusCode(): int;
}
<?php

namespace App\Licensing\Exceptions;

class LicenseNotFound extends LicensingException
{
    protected $message = 'The provided license key does not exist.';

    public function errorCode(): string
    {
        return 'license_not_found';
    }

    public function statusCode(): int
    {
        return 404;
    }
}

class LicenseNotUsable extends LicensingException
{
    public static function because(string $reason): self
    {
        return new self("The license cannot be used: {$reason}.");
    }

    public function errorCode(): string
    {
        return 'license_not_usable';
    }

    public function statusCode(): int
    {
        return 403;
    }
}

class ActivationLimitReached extends LicensingException
{
    protected $message = 'The maximum number of activations has been reached.';

    public function errorCode(): string
    {
        return 'activation_limit_reached';
    }

    public function statusCode(): int
    {
        return 409;
    }
}

Il livello di servizio

Il servizio è il cuore del sistema: emette le licenze, gestisce attivazioni e disattivazioni, rinnova i token e revoca. Ogni operazione che tocca il conteggio delle postazioni avviene in transazione con un lock pessimistico sulla riga della licenza, perché due client che si attivano nello stesso istante rappresentano lo scenario di corsa più realistico.

<?php

namespace App\Licensing;

use App\Licensing\Exceptions\ActivationLimitReached;
use App\Licensing\Exceptions\LicenseNotFound;
use App\Licensing\Exceptions\LicenseNotUsable;
use App\Models\License;
use App\Models\LicenseActivation;
use App\Models\Product;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

final class LicenseService
{
    public function __construct(
        private readonly LicenseKeyGenerator $generator,
        private readonly LicenseKeyHasher $hasher,
        private readonly LicenseTokenIssuer $issuer,
        private readonly LicenseEventRecorder $recorder,
    ) {
    }

    /**
     * Emette una nuova licenza e restituisce la chiave in chiaro:
     * è l'unico momento in cui il sistema la conosce.
     */
    public function issue(
        Product $product,
        string $customerEmail,
        string $type = 'subscription',
        ?CarbonImmutable $expiresAt = null,
        ?int $maxActivations = null,
        array $metadata = [],
    ): array {
        $plainKey = $this->generator->generate($product->key_prefix);
        $normal   = $this->generator->normalize($plainKey);

        $license = License::create([
            'uuid'            => (string) Str::uuid7(),
            'product_id'      => $product->id,
            'key_hash'        => $this->hasher->hash($plainKey),
            'key_prefix'      => $product->key_prefix,
            'key_last_four'   => substr($normal, -4),
            'customer_email'  => $customerEmail,
            'type'            => $type,
            'status'          => 'active',
            'max_activations' => $maxActivations ?? $product->default_max_activations,
            'issued_at'       => now(),
            'expires_at'      => $expiresAt,
            'metadata'        => $metadata,
        ]);

        $this->recorder->record($license, 'license.issued', ['type' => $type]);

        return ['license' => $license, 'key' => $plainKey];
    }

    public function activate(string $key, string $fingerprint, array $context = []): array
    {
        $license = $this->findByKey($key);

        return DB::transaction(function () use ($license, $fingerprint, $context) {
            // blocco pessimistico: impedisce che due attivazioni simultanee superino il limite
            $license = License::whereKey($license->id)->lockForUpdate()->firstOrFail();

            $this->assertUsable($license);

            $fingerprintHash = $this->hashFingerprint($fingerprint);

            $activation = $license->activations()
                ->where('fingerprint_hash', $fingerprintHash)
                ->first();

            if ($activation === null) {
                $used = $license->liveActivations()->count();

                if ($used >= $license->max_activations) {
                    throw new ActivationLimitReached();
                }

                $activation = $license->activations()->create([
                    'fingerprint_hash' => $fingerprintHash,
                    'hostname'         => $context['hostname'] ?? null,
                    'os'               => $context['os'] ?? null,
                    'app_version'      => $context['app_version'] ?? null,
                    'ip_address'       => $context['ip'] ?? null,
                    'activated_at'     => now(),
                    'last_seen_at'     => now(),
                ]);
            } else {
                // riattivazione della stessa macchina: non consuma una nuova postazione
                $activation->forceFill([
                    'deactivated_at' => null,
                    'last_seen_at'   => now(),
                    'app_version'    => $context['app_version'] ?? $activation->app_version,
                    'ip_address'     => $context['ip'] ?? $activation->ip_address,
                ])->save();
            }

            $this->recorder->record($license, 'license.activated', [
                'fingerprint' => substr($fingerprintHash, 0, 12),
            ]);

            return [
                'license'    => $license->load('product'),
                'activation' => $activation,
                'token'      => $this->issuer->issue($license->load('product'), $activation),
            ];
        });
    }

    /**
     * Rinnova il token di una macchina già attivata, senza consumare postazioni.
     */
    public function refresh(string $key, string $fingerprint): array
    {
        $license = $this->findByKey($key)->load('product');

        $this->assertUsable($license);

        $activation = $license->liveActivations()
            ->where('fingerprint_hash', $this->hashFingerprint($fingerprint))
            ->first();

        if ($activation === null) {
            throw LicenseNotUsable::because('this machine is not activated');
        }

        $activation->forceFill(['last_seen_at' => now()])->save();

        return [
            'license' => $license,
            'token'   => $this->issuer->issue($license, $activation),
        ];
    }

    public function deactivate(string $key, string $fingerprint): void
    {
        $license = $this->findByKey($key);

        $activation = $license->liveActivations()
            ->where('fingerprint_hash', $this->hashFingerprint($fingerprint))
            ->first();

        if ($activation === null) {
            return;
        }

        $activation->forceFill(['deactivated_at' => now()])->save();

        $this->recorder->record($license, 'license.deactivated');
    }

    public function revoke(License $license, string $reason): void
    {
        DB::transaction(function () use ($license, $reason) {
            $license->forceFill([
                'status'            => 'revoked',
                'revoked_at'        => now(),
                'revocation_reason' => $reason,
            ])->save();

            // tutte le installazioni vengono liberate: i token esistenti scadranno da soli
            $license->liveActivations()->update(['deactivated_at' => now()]);

            $this->recorder->record($license, 'license.revoked', ['reason' => $reason]);
        });
    }

    public function renew(License $license, CarbonImmutable $newExpiry): void
    {
        $license->forceFill([
            'expires_at' => $newExpiry,
            'status'     => $license->status === 'expired' ? 'active' : $license->status,
        ])->save();

        $this->recorder->record($license, 'license.renewed', [
            'expires_at' => $newExpiry->toIso8601String(),
        ]);
    }

    private function findByKey(string $key): License
    {
        $license = License::with('product')
            ->where('key_hash', $this->hasher->hash($key))
            ->first();

        if ($license === null) {
            throw new LicenseNotFound();
        }

        return $license;
    }

    private function assertUsable(License $license): void
    {
        if ($license->status === 'revoked') {
            throw LicenseNotUsable::because('it has been revoked');
        }

        if ($license->status === 'suspended') {
            throw LicenseNotUsable::because('it is suspended');
        }

        if ($license->isExpired()) {
            throw LicenseNotUsable::because('it has expired');
        }
    }

    private function hashFingerprint(string $fingerprint): string
    {
        // anche il fingerprint viene conservato solo come HMAC, mai in chiaro
        return hash_hmac('sha256', trim($fingerprint), (string) config('licensing.key_pepper'));
    }
}

Il registratore di eventi è una classe minuscola ma preziosa: fornisce l'audit trail che serve nelle controversie commerciali e nelle analisi di abuso.

<?php

namespace App\Licensing;

use App\Models\License;
use App\Models\LicenseEvent;
use Illuminate\Support\Facades\Request;

final class LicenseEventRecorder
{
    public function record(?License $license, string $type, array $context = []): void
    {
        LicenseEvent::create([
            'license_id' => $license?->id,
            'type'       => $type,
            'context'    => $context,
            'ip_address' => Request::ip(),
        ]);
    }
}

L'API HTTP

L'API espone quattro operazioni per il client distribuito e resta deliberatamente povera: attivazione, rinnovo del token, disattivazione e heartbeat. Tutto ciò che riguarda l'emissione e la revoca appartiene al backoffice e non deve essere raggiungibile con la sola chiave di licenza.

<?php

use App\Http\Controllers\Api\ActivationController;
use Illuminate\Support\Facades\Route;

Route::prefix('v1')
    ->middleware(['product.key', 'throttle:licensing'])
    ->group(function () {
        Route::post('/licenses/activate', [ActivationController::class, 'activate']);
        Route::post('/licenses/refresh', [ActivationController::class, 'refresh']);
        Route::post('/licenses/deactivate', [ActivationController::class, 'deactivate']);
        Route::post('/licenses/heartbeat', [ActivationController::class, 'heartbeat']);
    });

La validazione dell'input avviene in una Form Request dedicata, che normalizza la chiave prima ancora che arrivi al controller.

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ActivateLicenseRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'license_key' => ['required', 'string', 'max:64'],
            // il fingerprint è calcolato dal client come hash di dati hardware stabili
            'fingerprint' => ['required', 'string', 'min:16', 'max:128'],
            'hostname'    => ['nullable', 'string', 'max:255'],
            'os'          => ['nullable', 'string', 'max:64'],
            'app_version' => ['nullable', 'string', 'max:32'],
        ];
    }

    protected function prepareForValidation(): void
    {
        if ($this->has('license_key')) {
            $this->merge([
                'license_key' => trim((string) $this->input('license_key')),
            ]);
        }
    }
}
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\ActivateLicenseRequest;
use App\Licensing\LicenseService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class ActivationController extends Controller
{
    public function __construct(private readonly LicenseService $licenses)
    {
    }

    public function activate(ActivateLicenseRequest $request): JsonResponse
    {
        $result = $this->licenses->activate(
            $request->string('license_key')->value(),
            $request->string('fingerprint')->value(),
            [
                'hostname'    => $request->input('hostname'),
                'os'          => $request->input('os'),
                'app_version' => $request->input('app_version'),
                'ip'          => $request->ip(),
            ],
        );

        return response()->json([
            'token'   => $result['token'],
            'license' => [
                'uuid'       => $result['license']->uuid,
                'type'       => $result['license']->type,
                'expires_at' => $result['license']->expires_at?->toIso8601String(),
                'seats'      => [
                    'used'  => $result['license']->liveActivations()->count(),
                    'total' => $result['license']->max_activations,
                ],
            ],
        ], 201);
    }

    public function refresh(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'license_key' => ['required', 'string', 'max:64'],
            'fingerprint' => ['required', 'string', 'max:128'],
        ]);

        $result = $this->licenses->refresh($validated['license_key'], $validated['fingerprint']);

        return response()->json(['token' => $result['token']]);
    }

    public function deactivate(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'license_key' => ['required', 'string', 'max:64'],
            'fingerprint' => ['required', 'string', 'max:128'],
        ]);

        $this->licenses->deactivate($validated['license_key'], $validated['fingerprint']);

        // risposta idempotente: disattivare due volte non è un errore
        return response()->json(['status' => 'deactivated']);
    }

    public function heartbeat(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'license_key' => ['required', 'string', 'max:64'],
            'fingerprint' => ['required', 'string', 'max:128'],
        ]);

        $this->licenses->refresh($validated['license_key'], $validated['fingerprint']);

        return response()->json(['status' => 'ok']);
    }
}

Traduzione delle eccezioni in risposte JSON

Nella struttura applicativa moderna di Laravel la gestione degli errori si registra in bootstrap/app.php, dove possiamo mappare l'intera gerarchia di eccezioni di dominio con poche righe.

<?php

use App\Http\Middleware\VerifyProductApiKey;
use App\Licensing\Exceptions\LicensingException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__ . '/../routes/web.php',
        api: __DIR__ . '/../routes/api.php',
        commands: __DIR__ . '/../routes/console.php',
    )
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->alias([
            'product.key' => VerifyProductApiKey::class,
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions) {
        $exceptions->render(function (LicensingException $e, $request) {
            // ogni eccezione di dominio conosce il proprio codice e stato HTTP
            return response()->json([
                'error'   => $e->errorCode(),
                'message' => $e->getMessage(),
            ], $e->statusCode());
        });
    })->create();

Autenticazione delle richieste del client

La chiave di licenza identifica il cliente, non l'applicazione. Per impedire che chiunque interroghi il license server serve una credenziale di prodotto, compilata nel binario distribuito e verificata da un middleware. Non è un segreto inviolabile, perché risiede sul computer dell'utente, ma alza sensibilmente il costo dell'analisi automatizzata.

<?php

namespace App\Http\Middleware;

use App\Models\Product;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VerifyProductApiKey
{
    public function handle(Request $request, Closure $next): Response
    {
        $slug      = (string) $request->header('X-Product');
        $providedKey = (string) $request->header('X-Product-Key');

        $expectedKey = config("licensing.product_api_keys.{$slug}");

        // hash_equals evita di far trapelare informazioni tramite il tempo di confronto
        if (blank($expectedKey) || ! hash_equals($expectedKey, $providedKey)) {
            return response()->json(['error' => 'invalid_product_credentials'], 401);
        }

        $product = Product::where('slug', $slug)->where('is_active', true)->first();

        if ($product === null) {
            return response()->json(['error' => 'unknown_product'], 401);
        }

        // rendiamo il prodotto disponibile al resto della richiesta
        $request->attributes->set('product', $product);

        return $next($request);
    }
}

Rate limiting mirato

Il tentativo di indovinare chiavi valide è l'attacco più banale contro un license server. Un rate limiter che combina indirizzo IP e prodotto rende l'enumerazione impraticabile molto prima che diventi statisticamente interessante.

<?php

namespace App\Providers;

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        RateLimiter::for('licensing', function (Request $request) {
            return [
                // limite generoso per gli heartbeat legittimi
                Limit::perMinute(30)->by($request->ip()),
                // limite stretto per prodotto e ora, contro l'enumerazione delle chiavi
                Limit::perHour(200)->by($request->header('X-Product', 'unknown') . '|' . $request->ip()),
            ];
        });
    }
}

A questo va affiancata una regola applicativa: dopo un certo numero di chiavi inesistenti provenienti dallo stesso IP, conviene registrare l'evento e allungare progressivamente il tempo di risposta oppure bloccare l'indirizzo.

Manutenzione automatica

Due processi periodici mantengono coerente lo stato del sistema: la marcatura delle licenze scadute e la liberazione delle postazioni abbandonate, cioè quelle che non inviano un heartbeat da settimane perché la macchina è stata dismessa senza disattivazione.

<?php

namespace App\Console\Commands;

use App\Models\License;
use App\Models\LicenseActivation;
use Illuminate\Console\Command;

class SweepLicenses extends Command
{
    protected $signature = 'licenses:sweep';

    protected $description = 'Expire outdated licenses and release stale activations';

    public function handle(): int
    {
        $expired = License::query()
            ->where('status', 'active')
            ->whereNotNull('expires_at')
            ->where('expires_at', '<', now())
            ->update(['status' => 'expired']);

        $threshold = now()->subDays((int) config('licensing.stale_activation_days'));

        // le installazioni silenti da troppo tempo liberano la postazione occupata
        $released = LicenseActivation::query()
            ->whereNull('deactivated_at')
            ->where('last_seen_at', '<', $threshold)
            ->update(['deactivated_at' => now()]);

        $this->info("Expired licenses: {$expired}. Released activations: {$released}.");

        return self::SUCCESS;
    }
}
<?php

use App\Console\Commands\SweepLicenses;
use App\Models\License;
use App\Notifications\LicenseExpiringNotification;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Schedule;

Schedule::command(SweepLicenses::class)->hourly();

Schedule::call(function () {
    // avviso ai clienti la cui licenza scade entro due settimane
    License::expiringWithin(14)->with('product')->chunkById(200, function ($licenses) {
        foreach ($licenses as $license) {
            Notification::route('mail', $license->customer_email)
                ->notify(new LicenseExpiringNotification($license));
        }
    });
})->dailyAt('07:00')->name('notify-expiring-licenses')->withoutOverlapping();

Per evitare invii ripetuti conviene registrare l'avviso nella tabella degli eventi e filtrare le licenze già notificate: la logica è banale, ma dimenticarla produce l'effetto peggiore possibile, cioè una mail al giorno per due settimane.

Test automatici

Le regole che meritano copertura sono poche e ben identificabili: il limite di postazioni, l'idempotenza della riattivazione, il rifiuto delle licenze revocate o scadute e la verificabilità del token emesso.

<?php

use App\Licensing\Exceptions\ActivationLimitReached;
use App\Licensing\LicenseService;
use App\Models\Product;

beforeEach(function () {
    $this->service = app(LicenseService::class);
    $this->product = Product::factory()->create([
        'key_prefix'              => 'ACME',
        'default_max_activations' => 2,
    ]);
});

it('issues a license and returns a readable key', function () {
    $result = $this->service->issue($this->product, 'buyer@example.com');

    expect($result['key'])->toStartWith('ACME-')
        ->and($result['license']->key_hash)->not->toContain($result['key']);
});

it('does not consume a seat when the same machine activates twice', function () {
    $key = $this->service->issue($this->product, 'buyer@example.com')['key'];

    $this->service->activate($key, 'machine-fingerprint-one');
    $second = $this->service->activate($key, 'machine-fingerprint-one');

    expect($second['license']->liveActivations()->count())->toBe(1);
});

it('rejects activations beyond the seat limit', function () {
    $key = $this->service->issue($this->product, 'buyer@example.com')['key'];

    $this->service->activate($key, 'fingerprint-one');
    $this->service->activate($key, 'fingerprint-two');

    // la terza macchina supera il limite di due postazioni del prodotto
    $this->service->activate($key, 'fingerprint-three');
})->throws(ActivationLimitReached::class);

it('issues a token that verifies against the public key', function () {
    $key    = $this->service->issue($this->product, 'buyer@example.com')['key'];
    $result = $this->service->activate($key, 'fingerprint-one');

    [$payload, $signature] = explode('.', $result['token']);

    $decode = static fn (string $v): string => base64_decode(strtr($v, '-_', '+/'), true);

    $verified = sodium_crypto_sign_verify_detached(
        $decode($signature),
        $decode($payload),
        sodium_base642bin(
            config("licensing.signing.public_keys.{$this->product->slug}"),
            SODIUM_BASE64_VARIANT_ORIGINAL
        )
    );

    expect($verified)->toBeTrue();
});

Considerazioni di sicurezza

Un license server non impedisce la pirateria: nessun sistema lato client può farlo, perché il codice eseguito sulla macchina dell'utente è, prima o poi, modificabile. L'obiettivo realistico è diverso e più utile: rendere l'uso non conforme più costoso e meno comodo dell'acquisto, e dare all'azienda dati affidabili su chi usa cosa.

  • Nessun segreto nel repository: chiavi di firma, pepper e credenziali di prodotto vivono in un gestore di segreti, non in config versionato.
  • Rotazione delle chiavi di firma: prevedere fin dall'inizio un identificatore di versione nel token permette di introdurre una nuova coppia di chiavi senza invalidare le installazioni esistenti.
  • Confronti a tempo costante: ogni verifica di credenziale usa hash_equals.
  • Minimizzazione dei dati: il fingerprint hardware non viene mai salvato in chiaro, e nel payload del token compare solo il suo hash.
  • Trasporto: l'API va esposta esclusivamente su HTTPS, con HSTS attivo; il pinning del certificato nel client è un'ulteriore barriera contro le intercettazioni.
  • Audit: gli eventi di attivazione e revoca vanno conservati anche dopo la cancellazione logica della licenza, perché sono l'unica prova disponibile in caso di contestazione.
  • Osservabilità: un picco di attivazioni fallite sulla stessa chiave o su chiavi inesistenti è il segnale più affidabile di condivisione illecita o di attacco in corso.

Conclusioni

Il sistema costruito in queste pagine unisce due meccanismi complementari. La chiave di licenza, protetta da HMAC con pepper e validata da un checksum, serve a stabilire l'identità del cliente una sola volta. Il token firmato con Ed25519 e dotato di scadenza breve e periodo di grazia consente al client di funzionare offline pur restando revocabile in tempi controllati. Intorno a questi due elementi ruotano il conteggio delle postazioni protetto da lock pessimistico, l'audit trail, la manutenzione pianificata e un'API volutamente minima.

Le estensioni naturali sono altrettanto chiare: un pannello di amministrazione per l'emissione manuale e la revoca, l'integrazione con il gateway di pagamento affinché il rinnovo dell'abbonamento aggiorni automaticamente la data di scadenza, licenze floating con lease a tempo per gli ambienti aziendali, e feature flag inseriti nel payload del token per distinguere le edizioni del prodotto senza cambiare binario. La struttura del dominio, però, resta quella descritta qui: prodotti, licenze, attivazioni ed eventi.