Creare un sistema di gestione delle licenze software con Java Spring Boot
Ogni prodotto software distribuito al di fuori di un contesto SaaS prima o poi si scontra con lo stesso problema: come si stabilisce se una determinata installazione è autorizzata a funzionare? La risposta commerciale è la licenza; la risposta tecnica è un servizio che sappia emetterla, verificarla, attivarla su un numero limitato di macchine, revocarla quando necessario e mantenere una traccia verificabile di tutto quello che è successo.
In questo articolo costruiamo un licensing server completo con Java 21 e Spring Boot 3, basato su PostgreSQL, con firma crittografica Ed25519 per la validazione offline, gestione delle attivazioni per macchina, revoca, rinnovo, audit log, rate limiting e autenticazione differenziata fra API amministrative e API client. Il codice è pensato per essere messo in produzione, non per una demo.
Requisiti funzionali e modello concettuale
Prima di scrivere una riga di codice conviene fissare il vocabolario, perché la maggior parte dei sistemi di licensing fallisce per ambiguità semantiche e non per problemi crittografici.
- Prodotto: l'unità vendibile, identificata da un codice stabile. Una licenza è sempre relativa a un prodotto e a una major version.
- Licenza: il diritto d'uso concesso a un cliente. Ha un tipo (perpetua, in abbonamento, di prova), uno stato, una data di scadenza opzionale, un numero massimo di attivazioni e un insieme di feature flag che descrivono cosa il cliente ha effettivamente comprato.
- Attivazione: l'associazione fra una licenza e una macchina fisica o virtuale, identificata da un fingerprint. È l'entità che consuma i posti disponibili.
- Heartbeat: il segnale periodico che l'installazione invia per confermare di essere ancora viva. Serve a liberare i posti occupati da macchine dismesse.
- Token di licenza: un documento firmato che il client conserva localmente e sa verificare da solo, senza rete. È ciò che permette al software di funzionare anche quando il server non è raggiungibile.
La distinzione fra validazione e attivazione è il punto più importante dell'intero progetto. La validazione è un'operazione idempotente e a sola lettura: dice se una chiave esiste, è attiva e non è scaduta. L'attivazione è un'operazione transazionale che modifica lo stato: occupa un posto e va protetta contro le corse critiche. Confondere le due porta inevitabilmente a licenze che vengono consumate a ogni avvio dell'applicazione.
Stati della licenza
Uno stato esplicito e finito evita il proliferare di flag booleani incoerenti.
| Stato | Significato | Transizioni ammesse |
|---|---|---|
| PENDING | Emessa ma non ancora consegnata al cliente | ACTIVE, REVOKED |
| ACTIVE | Utilizzabile, entro i limiti di scadenza e attivazioni | SUSPENDED, REVOKED, EXPIRED |
| SUSPENDED | Sospesa temporaneamente, ad esempio per un pagamento non riuscito | ACTIVE, REVOKED |
| EXPIRED | Data di scadenza superata, transizione automatica | ACTIVE (dopo rinnovo), REVOKED |
| REVOKED | Annullata in modo definitivo, stato terminale | nessuna |
Configurazione del progetto
Partiamo dalle dipendenze. Il progetto usa Spring Web, Spring Data JPA, Bean Validation, Spring Security, Flyway per le migrazioni e Bucket4j per il rate limiting. La firma crittografica non richiede librerie esterne: Ed25519 è supportato nativamente dalla JDK a partire dalla versione 15.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>licensing-server</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk17-core</artifactId>
<version>8.14.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
La configurazione applicativa isola tutti i parametri di dominio sotto un prefisso dedicato, in modo da poterli mappare su un record tipizzato.
spring:
application:
name: licensing-server
datasource:
url: jdbc:postgresql://localhost:5432/licensing
username: licensing
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 20
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
properties:
hibernate:
jdbc:
time_zone: UTC
flyway:
enabled: true
locations: classpath:db/migration
server:
error:
include-message: never
licensing:
signing:
private-key: ${LICENSE_SIGNING_PRIVATE_KEY}
public-key: ${LICENSE_SIGNING_PUBLIC_KEY}
token-validity-hours: 168
activation:
heartbeat-interval-hours: 24
stale-after-days: 30
grace-period-days: 7
client-api-key-hash: ${CLIENT_API_KEY_HASH}
logging:
level:
com.example.licensing: INFO
package com.example.licensing.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "licensing")
public record LicensingProperties(
Signing signing,
Activation activation,
String clientApiKeyHash) {
public record Signing(String privateKey, String publicKey, int tokenValidityHours) {
}
public record Activation(int heartbeatIntervalHours, int staleAfterDays, int gracePeriodDays) {
}
}
Schema del database
Lo schema viene gestito con Flyway e non con la generazione automatica di Hibernate: in un sistema che custodisce diritti d'uso la struttura dei dati deve essere versionata e riproducibile. I vincoli di unicità e le foreign key sono la nostra ultima linea di difesa contro gli errori applicativi.
-- V1__init.sql
CREATE TABLE products (
id UUID PRIMARY KEY,
code VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
major_version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE licenses (
id UUID PRIMARY KEY,
license_key VARCHAR(32) NOT NULL UNIQUE,
product_id UUID NOT NULL REFERENCES products (id),
customer_email VARCHAR(320) NOT NULL,
customer_name VARCHAR(255),
type VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
max_activations INTEGER NOT NULL DEFAULT 1,
issued_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
revocation_reason VARCHAR(500),
notes VARCHAR(1000),
version BIGINT NOT NULL DEFAULT 0,
CONSTRAINT chk_max_activations CHECK (max_activations > 0)
);
CREATE INDEX idx_licenses_customer_email ON licenses (customer_email);
CREATE INDEX idx_licenses_status_expires ON licenses (status, expires_at);
CREATE TABLE license_features (
license_id UUID NOT NULL REFERENCES licenses (id) ON DELETE CASCADE,
feature VARCHAR(64) NOT NULL,
PRIMARY KEY (license_id, feature)
);
CREATE TABLE activations (
id UUID PRIMARY KEY,
license_id UUID NOT NULL REFERENCES licenses (id) ON DELETE CASCADE,
machine_id VARCHAR(64) NOT NULL,
hostname VARCHAR(255),
operating_system VARCHAR(120),
app_version VARCHAR(40),
activated_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
released_at TIMESTAMPTZ,
CONSTRAINT uq_activation_machine UNIQUE (license_id, machine_id)
);
CREATE INDEX idx_activations_last_seen ON activations (last_seen_at) WHERE released_at IS NULL;
CREATE TABLE license_audit_events (
id BIGSERIAL PRIMARY KEY,
license_id UUID REFERENCES licenses (id) ON DELETE SET NULL,
license_key VARCHAR(32) NOT NULL,
event_type VARCHAR(40) NOT NULL,
actor VARCHAR(120) NOT NULL,
source_ip VARCHAR(45),
detail VARCHAR(1000),
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_audit_license_key ON license_audit_events (license_key, occurred_at DESC);
Il vincolo uq_activation_machine merita attenzione: garantisce che la stessa macchina non possa occupare due posti sulla stessa licenza, anche se due richieste concorrenti superassero i controlli applicativi. Il campo released_at implementa una cancellazione logica, così da conservare lo storico delle installazioni dismesse.
Le entità JPA
package com.example.licensing.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "products")
@Getter
@Setter
@NoArgsConstructor
public class Product {
@Id
private UUID id = UUID.randomUUID();
@Column(nullable = false, unique = true, length = 64)
private String code;
@Column(nullable = false)
private String name;
@Column(name = "major_version", nullable = false)
private int majorVersion = 1;
@Column(name = "created_at", nullable = false)
private Instant createdAt = Instant.now();
}
package com.example.licensing.domain;
public enum LicenseType {
PERPETUAL,
SUBSCRIPTION,
TRIAL
}
package com.example.licensing.domain;
public enum LicenseStatus {
PENDING,
ACTIVE,
SUSPENDED,
EXPIRED,
REVOKED;
// Solo gli stati "vivi" consentono la validazione e l'attivazione
public boolean isUsable() {
return this == ACTIVE;
}
// Uno stato terminale non ammette alcuna transizione successiva
public boolean isTerminal() {
return this == REVOKED;
}
}
L'entità License concentra le regole invariabili del dominio. Vale la pena esporre i controlli di scadenza come metodi dell'entità stessa, invece di disseminarli nei servizi: il modello resta la fonte autorevole di verità.
package com.example.licensing.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.Instant;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
@Entity
@Table(name = "licenses")
@Getter
@Setter
@NoArgsConstructor
public class License {
@Id
private UUID id = UUID.randomUUID();
@Column(name = "license_key", nullable = false, unique = true, length = 32)
private String licenseKey;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(name = "customer_email", nullable = false, length = 320)
private String customerEmail;
@Column(name = "customer_name")
private String customerName;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private LicenseType type;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private LicenseStatus status = LicenseStatus.PENDING;
@Column(name = "max_activations", nullable = false)
private int maxActivations = 1;
@Column(name = "issued_at", nullable = false)
private Instant issuedAt = Instant.now();
@Column(name = "expires_at")
private Instant expiresAt;
@Column(name = "revoked_at")
private Instant revokedAt;
@Column(name = "revocation_reason", length = 500)
private String revocationReason;
@Column(length = 1000)
private String notes;
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "license_features", joinColumns = @JoinColumn(name = "license_id"))
@Column(name = "feature", nullable = false, length = 64)
private Set<String> features = new HashSet<>();
@OneToMany(mappedBy = "license", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Activation> activations = new HashSet<>();
@Version
private long version;
// Una licenza perpetua non ha scadenza: expiresAt vale null per definizione
public boolean isExpired(Instant reference) {
return expiresAt != null && expiresAt.isBefore(reference);
}
// La licenza è spendibile solo se lo stato lo consente e la data non è superata
public boolean isValidAt(Instant reference) {
return status.isUsable() && !isExpired(reference);
}
public long activeSeats() {
return activations.stream()
.filter(activation -> activation.getReleasedAt() == null)
.count();
}
}
package com.example.licensing.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "activations",
uniqueConstraints = @UniqueConstraint(name = "uq_activation_machine",
columnNames = {"license_id", "machine_id"}))
@Getter
@Setter
@NoArgsConstructor
public class Activation {
@Id
private UUID id = UUID.randomUUID();
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "license_id", nullable = false)
private License license;
// Impronta della macchina: sul server arriva già in forma di hash
@Column(name = "machine_id", nullable = false, length = 64)
private String machineId;
private String hostname;
@Column(name = "operating_system", length = 120)
private String operatingSystem;
@Column(name = "app_version", length = 40)
private String appVersion;
@Column(name = "activated_at", nullable = false)
private Instant activatedAt = Instant.now();
@Column(name = "last_seen_at", nullable = false)
private Instant lastSeenAt = Instant.now();
// Valorizzato in caso di disattivazione: il posto torna disponibile
@Column(name = "released_at")
private Instant releasedAt;
}
Generazione delle chiavi di licenza
La chiave di licenza non deve essere un segreto crittografico, ma un identificatore leggibile, trascrivibile al telefono e verificabile localmente. Usiamo l'alfabeto Crockford Base32, che esclude i caratteri ambigui I, L, O e U, e aggiungiamo un carattere di controllo calcolato con una somma pesata, capace di intercettare sia i caratteri sbagliati sia le trasposizioni.
Il beneficio pratico è notevole: una chiave malformata viene scartata senza interrogare il database, il che elimina buona parte del traffico generato dagli errori di digitazione e dagli attacchi a forza bruta più ingenui.
package com.example.licensing.service;
import org.springframework.stereotype.Component;
import java.security.SecureRandom;
import java.util.Locale;
@Component
public class LicenseKeyGenerator {
// Alfabeto Crockford Base32: privo di I, L, O e U per evitare ambiguità di lettura
private static final String ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
private static final int PAYLOAD_LENGTH = 24;
private static final int GROUP_SIZE = 5;
private final SecureRandom random = new SecureRandom();
public String generate() {
StringBuilder payload = new StringBuilder(PAYLOAD_LENGTH);
for (int i = 0; i < PAYLOAD_LENGTH; i++) {
payload.append(ALPHABET.charAt(random.nextInt(ALPHABET.length())));
}
payload.append(checksum(payload));
return format(payload.toString());
}
// Somma pesata sulla posizione: intercetta anche lo scambio di due caratteri adiacenti
private static char checksum(CharSequence payload) {
int sum = 0;
for (int i = 0; i < payload.length(); i++) {
sum += ALPHABET.indexOf(payload.charAt(i)) * (i + 1);
}
return ALPHABET.charAt(Math.floorMod(sum, ALPHABET.length()));
}
// Riporta la chiave in forma canonica correggendo le confusioni tipiche di lettura
public static String normalize(String rawKey) {
if (rawKey == null) {
return "";
}
return rawKey.toUpperCase(Locale.ROOT)
.replace('I', '1')
.replace('L', '1')
.replace('O', '0')
.replaceAll("[^0-9A-Z]", "");
}
// Verifica puramente locale: nessun accesso al database
public static boolean isWellFormed(String rawKey) {
String normalized = normalize(rawKey);
if (normalized.length() != PAYLOAD_LENGTH + 1) {
return false;
}
for (int i = 0; i < normalized.length(); i++) {
if (ALPHABET.indexOf(normalized.charAt(i)) < 0) {
return false;
}
}
return normalized.charAt(PAYLOAD_LENGTH)
== checksum(normalized.subSequence(0, PAYLOAD_LENGTH));
}
private static String format(String normalized) {
StringBuilder formatted = new StringBuilder(normalized.length() + 4);
for (int i = 0; i < normalized.length(); i++) {
if (i > 0 && i % GROUP_SIZE == 0) {
formatted.append('-');
}
formatted.append(normalized.charAt(i));
}
return formatted.toString();
}
}
Il risultato è una chiave nella forma 7K2QP-M9XVR-3TDBH-5NZWC-JF4G8: venticinque caratteri, ventiquattro dei quali casuali, per uno spazio di ricerca di 3224 combinazioni. Non è un segreto da difendere con la crittografia, ma è ampiamente sufficiente a rendere impraticabile l'enumerazione.
Firma crittografica con Ed25519
Qui si trova il cuore del sistema. Il server possiede una chiave privata con cui firma un documento che descrive la licenza; il client incorpora nel proprio binario soltanto la chiave pubblica e verifica la firma senza contattare nessuno. Nessuno può forgiare una licenza valida senza la chiave privata, e la validazione funziona anche su macchine isolate dalla rete.
Ed25519 è preferibile a RSA in questo scenario: le firme occupano 64 byte, le chiavi 32, la verifica è rapida e l'algoritmo non presenta i parametri configurabili che rendono RSA facile da usare male. Cominciamo con un piccolo comando per generare la coppia di chiavi.
package com.example.licensing.tools;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.Base64;
public final class SigningKeyGenerator {
public static void main(String[] args) throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("Ed25519");
KeyPair keyPair = generator.generateKeyPair();
Base64.Encoder encoder = Base64.getEncoder();
// La chiave privata resta esclusivamente sul server di licensing
System.out.println("LICENSE_SIGNING_PRIVATE_KEY="
+ encoder.encodeToString(keyPair.getPrivate().getEncoded()));
// La chiave pubblica viene compilata dentro l'applicazione client
System.out.println("LICENSE_SIGNING_PUBLIC_KEY="
+ encoder.encodeToString(keyPair.getPublic().getEncoded()));
}
}
Il servizio di firma carica le chiavi una sola volta all'avvio. Il formato del token è volutamente semplice, ispirato a JWS ma senza le sue insidie: due segmenti Base64 URL-safe separati da un punto, il primo con il payload JSON, il secondo con la firma. Non esiste alcun campo che dichiari l'algoritmo, quindi non esiste nemmeno la classe di attacchi che consiste nel manipolarlo.
package com.example.licensing.service;
import com.example.licensing.config.LicensingProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
@Service
public class LicenseTokenService {
private static final String ALGORITHM = "Ed25519";
private final ObjectMapper objectMapper;
private final PrivateKey privateKey;
private final PublicKey publicKey;
public LicenseTokenService(LicensingProperties properties, ObjectMapper objectMapper) throws Exception {
this.objectMapper = objectMapper;
KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM);
Base64.Decoder decoder = Base64.getDecoder();
this.privateKey = keyFactory.generatePrivate(
new PKCS8EncodedKeySpec(decoder.decode(properties.signing().privateKey())));
this.publicKey = keyFactory.generatePublic(
new X509EncodedKeySpec(decoder.decode(properties.signing().publicKey())));
}
public String sign(LicensePayload payload) {
try {
byte[] json = objectMapper.writeValueAsBytes(payload);
String encodedPayload = base64Url(json);
Signature signature = Signature.getInstance(ALGORITHM);
signature.initSign(privateKey);
signature.update(encodedPayload.getBytes(StandardCharsets.US_ASCII));
return encodedPayload + "." + base64Url(signature.sign());
} catch (Exception ex) {
throw new IllegalStateException("Firma del token di licenza non riuscita", ex);
}
}
// Usata dai test e dagli strumenti diagnostici: in produzione la verifica avviene sul client
public LicensePayload verify(String token) {
try {
int separator = token.indexOf('.');
if (separator < 0) {
throw new IllegalArgumentException("Token malformato");
}
String encodedPayload = token.substring(0, separator);
byte[] rawSignature = Base64.getUrlDecoder().decode(token.substring(separator + 1));
Signature signature = Signature.getInstance(ALGORITHM);
signature.initVerify(publicKey);
signature.update(encodedPayload.getBytes(StandardCharsets.US_ASCII));
if (!signature.verify(rawSignature)) {
throw new IllegalArgumentException("Firma non valida");
}
return objectMapper.readValue(
Base64.getUrlDecoder().decode(encodedPayload), LicensePayload.class);
} catch (Exception ex) {
throw new IllegalArgumentException("Token di licenza non verificabile", ex);
}
}
private static String base64Url(byte[] data) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
}
}
Il payload contiene tutto ciò che serve al client per decidere autonomamente, inclusa una scadenza propria del token, molto più breve di quella della licenza. Questo è il meccanismo che costringe l'installazione a ricontattare periodicamente il server: se una licenza viene revocata, il client se ne accorge alla prima riemissione del token, cioè entro pochi giorni.
package com.example.licensing.service;
import java.time.Instant;
import java.util.Set;
public record LicensePayload(
String licenseKey,
String productCode,
int majorVersion,
String customerEmail,
String type,
Set<String> features,
int maxActivations,
String machineId,
Instant issuedAt,
Instant licenseExpiresAt,
Instant tokenExpiresAt) {
}
Repository e blocco pessimistico
L'attivazione è l'unica operazione realmente contesa del sistema. Se due processi partono simultaneamente sulla stessa licenza con un solo posto libero, un semplice conteggio seguito da un inserimento produce due attivazioni. Il rimedio è acquisire un blocco pessimistico sulla riga della licenza prima di contare i posti occupati.
package com.example.licensing.repository;
import com.example.licensing.domain.License;
import com.example.licensing.domain.LicenseStatus;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface LicenseRepository extends JpaRepository<License, UUID> {
Optional<License> findByLicenseKey(String licenseKey);
boolean existsByLicenseKey(String licenseKey);
List<License> findByCustomerEmailIgnoreCase(String customerEmail);
// Blocco pessimistico: serializza le attivazioni concorrenti sulla stessa licenza
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT l FROM License l WHERE l.licenseKey = :licenseKey")
Optional<License> findByLicenseKeyForUpdate(@Param("licenseKey") String licenseKey);
// Transizione massiva verso EXPIRED, eseguita dallo scheduler notturno
@Modifying
@Query("""
UPDATE License l
SET l.status = com.example.licensing.domain.LicenseStatus.EXPIRED
WHERE l.status = com.example.licensing.domain.LicenseStatus.ACTIVE
AND l.expiresAt IS NOT NULL
AND l.expiresAt < :reference
""")
int markExpired(@Param("reference") Instant reference);
long countByStatus(LicenseStatus status);
}
package com.example.licensing.repository;
import com.example.licensing.domain.Activation;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface ActivationRepository extends JpaRepository<Activation, UUID> {
Optional<Activation> findByLicenseIdAndMachineId(UUID licenseId, String machineId);
@Query("""
SELECT COUNT(a) FROM Activation a
WHERE a.license.id = :licenseId
AND a.releasedAt IS NULL
""")
long countActiveSeats(@Param("licenseId") UUID licenseId);
@Query("""
SELECT a FROM Activation a
WHERE a.releasedAt IS NULL
AND a.lastSeenAt < :threshold
""")
List<Activation> findStale(@Param("threshold") Instant threshold);
}
DTO e contratto delle API
I record di Java rendono i DTO immutabili e concisi. Le annotazioni di Bean Validation spostano il grosso della validazione sintattica fuori dai servizi.
package com.example.licensing.api.dto;
import com.example.licensing.domain.LicenseType;
import jakarta.validation.constraints.*;
import java.time.Instant;
import java.util.Set;
public record IssueLicenseRequest(
@NotBlank @Size(max = 64) String productCode,
@NotBlank @Email @Size(max = 320) String customerEmail,
@Size(max = 255) String customerName,
@NotNull LicenseType type,
@Min(1) @Max(1000) int maxActivations,
Instant expiresAt,
Set<String> features,
@Size(max = 1000) String notes) {
}
package com.example.licensing.api.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record ActivationRequest(
@NotBlank @Size(max = 40) String licenseKey,
@NotBlank @Size(min = 16, max = 64) String machineId,
@Size(max = 255) String hostname,
@Size(max = 120) String operatingSystem,
@Size(max = 40) String appVersion) {
}
package com.example.licensing.api.dto;
import java.time.Instant;
import java.util.Set;
public record LicenseResponse(
String licenseKey,
String productCode,
String customerEmail,
String type,
String status,
int maxActivations,
long usedActivations,
Set<String> features,
Instant issuedAt,
Instant expiresAt) {
}
public record ValidationResponse(
boolean valid,
String reason,
LicenseResponse license,
String token) {
}
Il servizio di licensing
Il servizio implementa le operazioni amministrative: emissione, revoca, sospensione e rinnovo. Ogni transizione di stato passa da un metodo dedicato che verifica la legittimità del passaggio e registra un evento di audit.
package com.example.licensing.service;
import com.example.licensing.api.dto.*;
import com.example.licensing.domain.*;
import com.example.licensing.error.ConflictException;
import com.example.licensing.error.NotFoundException;
import com.example.licensing.repository.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.HashSet;
import java.util.List;
@Service
public class LicenseService {
private static final int MAX_KEY_ATTEMPTS = 5;
private final LicenseRepository licenseRepository;
private final ProductRepository productRepository;
private final ActivationRepository activationRepository;
private final LicenseKeyGenerator keyGenerator;
private final AuditService auditService;
public LicenseService(LicenseRepository licenseRepository,
ProductRepository productRepository,
ActivationRepository activationRepository,
LicenseKeyGenerator keyGenerator,
AuditService auditService) {
this.licenseRepository = licenseRepository;
this.productRepository = productRepository;
this.activationRepository = activationRepository;
this.keyGenerator = keyGenerator;
this.auditService = auditService;
}
@Transactional
public LicenseResponse issue(IssueLicenseRequest request, String actor) {
Product product = productRepository.findByCode(request.productCode())
.orElseThrow(() -> new NotFoundException(
"Prodotto non trovato: " + request.productCode()));
if (request.type() != LicenseType.PERPETUAL && request.expiresAt() == null) {
throw new ConflictException("Le licenze non perpetue richiedono una data di scadenza");
}
if (request.expiresAt() != null && request.expiresAt().isBefore(Instant.now())) {
throw new ConflictException("La data di scadenza è già trascorsa");
}
License license = new License();
license.setLicenseKey(allocateUniqueKey());
license.setProduct(product);
license.setCustomerEmail(request.customerEmail().toLowerCase());
license.setCustomerName(request.customerName());
license.setType(request.type());
license.setStatus(LicenseStatus.ACTIVE);
license.setMaxActivations(request.maxActivations());
license.setIssuedAt(Instant.now());
license.setExpiresAt(request.type() == LicenseType.PERPETUAL ? null : request.expiresAt());
license.setFeatures(request.features() == null ? new HashSet<>() : new HashSet<>(request.features()));
license.setNotes(request.notes());
licenseRepository.save(license);
auditService.record(license, AuditEventType.ISSUED, actor,
"Licenza emessa per " + product.getCode());
return toResponse(license);
}
@Transactional
public LicenseResponse revoke(String licenseKey, String reason, String actor) {
License license = requireLicense(licenseKey);
if (license.getStatus().isTerminal()) {
throw new ConflictException("La licenza è già stata revocata");
}
license.setStatus(LicenseStatus.REVOKED);
license.setRevokedAt(Instant.now());
license.setRevocationReason(reason);
// La revoca libera immediatamente tutti i posti occupati
license.getActivations().stream()
.filter(activation -> activation.getReleasedAt() == null)
.forEach(activation -> activation.setReleasedAt(Instant.now()));
auditService.record(license, AuditEventType.REVOKED, actor, reason);
return toResponse(license);
}
@Transactional
public LicenseResponse suspend(String licenseKey, String reason, String actor) {
License license = requireLicense(licenseKey);
if (license.getStatus() != LicenseStatus.ACTIVE) {
throw new ConflictException("Solo una licenza attiva può essere sospesa");
}
license.setStatus(LicenseStatus.SUSPENDED);
auditService.record(license, AuditEventType.SUSPENDED, actor, reason);
return toResponse(license);
}
@Transactional
public LicenseResponse resume(String licenseKey, String actor) {
License license = requireLicense(licenseKey);
if (license.getStatus() != LicenseStatus.SUSPENDED) {
throw new ConflictException("La licenza non risulta sospesa");
}
// Se nel frattempo la data è scaduta la licenza torna in EXPIRED, non in ACTIVE
license.setStatus(license.isExpired(Instant.now())
? LicenseStatus.EXPIRED
: LicenseStatus.ACTIVE);
auditService.record(license, AuditEventType.RESUMED, actor, null);
return toResponse(license);
}
@Transactional
public LicenseResponse renew(String licenseKey, Instant newExpiry, String actor) {
License license = requireLicense(licenseKey);
if (license.getStatus().isTerminal()) {
throw new ConflictException("Una licenza revocata non può essere rinnovata");
}
if (license.getType() == LicenseType.PERPETUAL) {
throw new ConflictException("Una licenza perpetua non richiede rinnovo");
}
if (newExpiry.isBefore(Instant.now())) {
throw new ConflictException("La nuova scadenza deve essere futura");
}
license.setExpiresAt(newExpiry);
if (license.getStatus() == LicenseStatus.EXPIRED) {
license.setStatus(LicenseStatus.ACTIVE);
}
auditService.record(license, AuditEventType.RENEWED, actor, "Nuova scadenza: " + newExpiry);
return toResponse(license);
}
@Transactional(readOnly = true)
public List<LicenseResponse> findByCustomer(String email) {
return licenseRepository.findByCustomerEmailIgnoreCase(email).stream()
.map(this::toResponse)
.toList();
}
private License requireLicense(String licenseKey) {
String normalized = LicenseKeyGenerator.normalize(licenseKey);
return licenseRepository.findByLicenseKey(formatted(normalized))
.orElseThrow(() -> new NotFoundException("Licenza non trovata"));
}
// Il database conserva la forma con i trattini: la ricostruiamo dopo la normalizzazione
private String formatted(String normalized) {
StringBuilder builder = new StringBuilder();
for (int i = 0; i < normalized.length(); i++) {
if (i > 0 && i % 5 == 0) {
builder.append('-');
}
builder.append(normalized.charAt(i));
}
return builder.toString();
}
private String allocateUniqueKey() {
for (int attempt = 0; attempt < MAX_KEY_ATTEMPTS; attempt++) {
String candidate = keyGenerator.generate();
if (!licenseRepository.existsByLicenseKey(candidate)) {
return candidate;
}
}
throw new IllegalStateException("Impossibile generare una chiave univoca");
}
LicenseResponse toResponse(License license) {
return new LicenseResponse(
license.getLicenseKey(),
license.getProduct().getCode(),
license.getCustomerEmail(),
license.getType().name(),
license.getStatus().name(),
license.getMaxActivations(),
activationRepository.countActiveSeats(license.getId()),
license.getFeatures(),
license.getIssuedAt(),
license.getExpiresAt());
}
}
Attivazione, heartbeat e rilascio dei posti
Il servizio di attivazione è il punto di contatto con le installazioni. Tre comportamenti sono essenziali. Primo: riattivare una macchina già registrata non consuma un nuovo posto, ma aggiorna quello esistente, altrimenti una reinstallazione esaurirebbe la licenza. Secondo: il conteggio dei posti avviene dopo aver acquisito il blocco sulla licenza. Terzo: ogni risposta positiva restituisce un token firmato, che diventa la prova d'uso offline.
package com.example.licensing.service;
import com.example.licensing.api.dto.*;
import com.example.licensing.config.LicensingProperties;
import com.example.licensing.domain.*;
import com.example.licensing.error.ConflictException;
import com.example.licensing.error.NotFoundException;
import com.example.licensing.repository.ActivationRepository;
import com.example.licensing.repository.LicenseRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
@Service
public class ActivationService {
private final LicenseRepository licenseRepository;
private final ActivationRepository activationRepository;
private final LicenseTokenService tokenService;
private final LicenseService licenseService;
private final AuditService auditService;
private final LicensingProperties properties;
public ActivationService(LicenseRepository licenseRepository,
ActivationRepository activationRepository,
LicenseTokenService tokenService,
LicenseService licenseService,
AuditService auditService,
LicensingProperties properties) {
this.licenseRepository = licenseRepository;
this.activationRepository = activationRepository;
this.tokenService = tokenService;
this.licenseService = licenseService;
this.auditService = auditService;
this.properties = properties;
}
@Transactional
public ValidationResponse activate(ActivationRequest request, String actor) {
// Scarto immediato delle chiavi sintatticamente errate: nessuna query inutile
if (!LicenseKeyGenerator.isWellFormed(request.licenseKey())) {
return new ValidationResponse(false, "MALFORMED_KEY", null, null);
}
License license = licenseRepository
.findByLicenseKeyForUpdate(canonical(request.licenseKey()))
.orElseThrow(() -> new NotFoundException("Licenza non trovata"));
Instant now = Instant.now();
String rejection = rejectionReason(license, now);
if (rejection != null) {
auditService.record(license, AuditEventType.ACTIVATION_DENIED, actor, rejection);
return new ValidationResponse(false, rejection, null, null);
}
Optional<Activation> existing = activationRepository
.findByLicenseIdAndMachineId(license.getId(), request.machineId());
Activation activation;
if (existing.isPresent()) {
// Reinstallazione sulla stessa macchina: il posto è già stato conteggiato
activation = existing.get();
activation.setReleasedAt(null);
activation.setLastSeenAt(now);
activation.setAppVersion(request.appVersion());
} else {
long used = activationRepository.countActiveSeats(license.getId());
if (used >= license.getMaxActivations()) {
auditService.record(license, AuditEventType.ACTIVATION_DENIED, actor,
"Posti esauriti: " + used + "/" + license.getMaxActivations());
return new ValidationResponse(false, "ACTIVATION_LIMIT_REACHED", null, null);
}
activation = new Activation();
activation.setLicense(license);
activation.setMachineId(request.machineId());
activation.setHostname(request.hostname());
activation.setOperatingSystem(request.operatingSystem());
activation.setAppVersion(request.appVersion());
activation.setActivatedAt(now);
activation.setLastSeenAt(now);
activationRepository.save(activation);
}
auditService.record(license, AuditEventType.ACTIVATED, actor,
"Macchina " + request.machineId());
return new ValidationResponse(true, null,
licenseService.toResponse(license),
issueToken(license, request.machineId(), now));
}
@Transactional
public ValidationResponse heartbeat(ActivationRequest request, String actor) {
License license = licenseRepository.findByLicenseKey(canonical(request.licenseKey()))
.orElseThrow(() -> new NotFoundException("Licenza non trovata"));
Instant now = Instant.now();
String rejection = rejectionReason(license, now);
if (rejection != null) {
return new ValidationResponse(false, rejection, null, null);
}
Activation activation = activationRepository
.findByLicenseIdAndMachineId(license.getId(), request.machineId())
.filter(candidate -> candidate.getReleasedAt() == null)
.orElseThrow(() -> new ConflictException("Macchina non attivata"));
activation.setLastSeenAt(now);
activation.setAppVersion(request.appVersion());
return new ValidationResponse(true, null,
licenseService.toResponse(license),
issueToken(license, request.machineId(), now));
}
@Transactional
public void deactivate(String licenseKey, String machineId, String actor) {
License license = licenseRepository.findByLicenseKeyForUpdate(canonical(licenseKey))
.orElseThrow(() -> new NotFoundException("Licenza non trovata"));
Activation activation = activationRepository
.findByLicenseIdAndMachineId(license.getId(), machineId)
.filter(candidate -> candidate.getReleasedAt() == null)
.orElseThrow(() -> new NotFoundException("Attivazione non trovata"));
activation.setReleasedAt(Instant.now());
auditService.record(license, AuditEventType.DEACTIVATED, actor, "Macchina " + machineId);
}
// Validazione a sola lettura: non consuma posti e non modifica nulla
@Transactional(readOnly = true)
public ValidationResponse validate(String licenseKey) {
if (!LicenseKeyGenerator.isWellFormed(licenseKey)) {
return new ValidationResponse(false, "MALFORMED_KEY", null, null);
}
return licenseRepository.findByLicenseKey(canonical(licenseKey))
.map(license -> {
String rejection = rejectionReason(license, Instant.now());
return rejection == null
? new ValidationResponse(true, null, licenseService.toResponse(license), null)
: new ValidationResponse(false, rejection, null, null);
})
.orElse(new ValidationResponse(false, "NOT_FOUND", null, null));
}
private String rejectionReason(License license, Instant now) {
if (license.getStatus() == LicenseStatus.REVOKED) {
return "REVOKED";
}
if (license.getStatus() == LicenseStatus.SUSPENDED) {
return "SUSPENDED";
}
if (license.getStatus() == LicenseStatus.PENDING) {
return "NOT_DELIVERED";
}
// Il periodo di tolleranza evita blocchi bruschi al rinnovo di un abbonamento
Instant limit = now.minus(properties.activation().gracePeriodDays(), ChronoUnit.DAYS);
if (license.isExpired(limit)) {
return "EXPIRED";
}
return null;
}
private String issueToken(License license, String machineId, Instant now) {
LicensePayload payload = new LicensePayload(
license.getLicenseKey(),
license.getProduct().getCode(),
license.getProduct().getMajorVersion(),
license.getCustomerEmail(),
license.getType().name(),
license.getFeatures(),
license.getMaxActivations(),
machineId,
now,
license.getExpiresAt(),
now.plus(properties.signing().tokenValidityHours(), ChronoUnit.HOURS));
return tokenService.sign(payload);
}
private String canonical(String rawKey) {
String normalized = LicenseKeyGenerator.normalize(rawKey);
StringBuilder builder = new StringBuilder();
for (int i = 0; i < normalized.length(); i++) {
if (i > 0 && i % 5 == 0) {
builder.append('-');
}
builder.append(normalized.charAt(i));
}
return builder.toString();
}
}
Audit log
La tracciabilità non è un requisito accessorio. Quando un cliente contesta un blocco, l'audit log è l'unico documento che permette di ricostruire cosa è realmente accaduto. Va scritto in una transazione separata, perché un fallimento nella registrazione dell'evento non deve mai annullare l'operazione di business.
package com.example.licensing.service;
import com.example.licensing.domain.AuditEventType;
import com.example.licensing.domain.License;
import com.example.licensing.domain.LicenseAuditEvent;
import com.example.licensing.repository.LicenseAuditEventRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.time.Instant;
@Service
public class AuditService {
private final LicenseAuditEventRepository repository;
public AuditService(LicenseAuditEventRepository repository) {
this.repository = repository;
}
// REQUIRES_NEW: l'evento resta registrato anche se la transazione chiamante viene annullata
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(License license, AuditEventType type, String actor, String detail) {
LicenseAuditEvent event = new LicenseAuditEvent();
event.setLicenseId(license.getId());
event.setLicenseKey(license.getLicenseKey());
event.setEventType(type);
event.setActor(actor);
event.setSourceIp(currentIpAddress());
event.setDetail(detail);
event.setOccurredAt(Instant.now());
repository.save(event);
}
private String currentIpAddress() {
if (RequestContextHolder.getRequestAttributes()
instanceof ServletRequestAttributes attributes) {
String forwarded = attributes.getRequest().getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
return forwarded.split(",")[0].trim();
}
return attributes.getRequest().getRemoteAddr();
}
return null;
}
}
I controller REST
Le API si dividono in due famiglie con requisiti di sicurezza diversi: quelle amministrative, riservate al back office, e quelle client, chiamate dalle installazioni sul campo.
package com.example.licensing.api;
import com.example.licensing.api.dto.*;
import com.example.licensing.service.LicenseService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v1/admin/licenses")
@PreAuthorize("hasRole('LICENSE_ADMIN')")
public class LicenseAdminController {
private final LicenseService licenseService;
public LicenseAdminController(LicenseService licenseService) {
this.licenseService = licenseService;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public LicenseResponse issue(@Valid @RequestBody IssueLicenseRequest request,
@AuthenticationPrincipal UserDetails principal) {
return licenseService.issue(request, principal.getUsername());
}
@GetMapping
public List<LicenseResponse> search(@RequestParam String customerEmail) {
return licenseService.findByCustomer(customerEmail);
}
@PostMapping("/{licenseKey}/revoke")
public LicenseResponse revoke(@PathVariable String licenseKey,
@Valid @RequestBody ReasonRequest request,
@AuthenticationPrincipal UserDetails principal) {
return licenseService.revoke(licenseKey, request.reason(), principal.getUsername());
}
@PostMapping("/{licenseKey}/suspend")
public LicenseResponse suspend(@PathVariable String licenseKey,
@Valid @RequestBody ReasonRequest request,
@AuthenticationPrincipal UserDetails principal) {
return licenseService.suspend(licenseKey, request.reason(), principal.getUsername());
}
@PostMapping("/{licenseKey}/resume")
public LicenseResponse resume(@PathVariable String licenseKey,
@AuthenticationPrincipal UserDetails principal) {
return licenseService.resume(licenseKey, principal.getUsername());
}
@PostMapping("/{licenseKey}/renew")
public LicenseResponse renew(@PathVariable String licenseKey,
@Valid @RequestBody RenewRequest request,
@AuthenticationPrincipal UserDetails principal) {
return licenseService.renew(licenseKey, request.expiresAt(), principal.getUsername());
}
@DeleteMapping("/{licenseKey}/activations/{machineId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public ResponseEntity<Void> forceDeactivate(@PathVariable String licenseKey,
@PathVariable String machineId,
@AuthenticationPrincipal UserDetails principal) {
licenseService.forceDeactivate(licenseKey, machineId, principal.getUsername());
return ResponseEntity.noContent().build();
}
}
package com.example.licensing.api;
import com.example.licensing.api.dto.ActivationRequest;
import com.example.licensing.api.dto.ValidationResponse;
import com.example.licensing.service.ActivationService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/licenses")
@Validated
public class LicenseClientController {
private final ActivationService activationService;
public LicenseClientController(ActivationService activationService) {
this.activationService = activationService;
}
// Controllo leggero, senza effetti collaterali: utile nella schermata di inserimento chiave
@GetMapping("/{licenseKey}/validate")
public ValidationResponse validate(@PathVariable @NotBlank String licenseKey) {
return activationService.validate(licenseKey);
}
@PostMapping("/activate")
public ValidationResponse activate(@Valid @RequestBody ActivationRequest request) {
return activationService.activate(request, "client");
}
@PostMapping("/heartbeat")
public ValidationResponse heartbeat(@Valid @RequestBody ActivationRequest request) {
return activationService.heartbeat(request, "client");
}
@PostMapping("/deactivate")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deactivate(@Valid @RequestBody ActivationRequest request) {
activationService.deactivate(request.licenseKey(), request.machineId(), "client");
}
}
Sicurezza: due catene di filtri distinte
Le API client non possono usare le credenziali degli amministratori, ma non possono nemmeno restare aperte. La soluzione più solida per un'applicazione distribuita è una chiave d'API condivisa, memorizzata sul server soltanto come hash e confrontata a tempo costante. È bene ricordare che questa chiave viaggia dentro un binario distribuito ai clienti: la si consideri un deterrente contro l'abuso automatizzato, non un segreto inviolabile. La vera barriera resta la firma Ed25519.
package com.example.licensing.security;
import com.example.licensing.config.LicensingProperties;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
public class ApiKeyAuthenticationFilter extends OncePerRequestFilter {
private static final String HEADER = "X-License-Api-Key";
private final byte[] expectedHash;
public ApiKeyAuthenticationFilter(LicensingProperties properties) {
this.expectedHash = HexFormat.of().parseHex(properties.clientApiKeyHash());
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String provided = request.getHeader(HEADER);
if (provided != null && matches(provided)) {
var authentication = new UsernamePasswordAuthenticationToken(
"client", null, List.of(new SimpleGrantedAuthority("ROLE_LICENSE_CLIENT")));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
// Confronto a tempo costante: evita di rivelare informazioni tramite la durata della risposta
private boolean matches(String provided) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(provided.getBytes(StandardCharsets.UTF_8));
return MessageDigest.isEqual(digest, expectedHash);
} catch (Exception ex) {
return false;
}
}
}
package com.example.licensing.security;
import com.example.licensing.config.LicensingProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
// Prima catena: API client protette dalla sola chiave d'API
@Bean
@Order(1)
public SecurityFilterChain clientChain(HttpSecurity http,
LicensingProperties properties) throws Exception {
return http
.securityMatcher("/api/v1/licenses/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(new ApiKeyAuthenticationFilter(properties),
UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth
.anyRequest().hasRole("LICENSE_CLIENT"))
.exceptionHandling(handling -> handling
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
.build();
}
// Seconda catena: back office amministrativo con credenziali nominali
@Bean
@Order(2)
public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/v1/admin/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.httpBasic(Customizer.withDefaults())
.authorizeHttpRequests(auth -> auth
.anyRequest().hasRole("LICENSE_ADMIN"))
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}
Rate limiting sulle chiamate di validazione
L'endpoint di validazione è il bersaglio naturale di chi tenta di indovinare chiavi valide. Anche con uno spazio di ricerca enorme, un limite per indirizzo IP riduce il rumore e protegge il database.
package com.example.licensing.security;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class RateLimitFilter extends OncePerRequestFilter {
// In un deployment con più istanze questa mappa va sostituita da Bucket4j su Redis
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/api/v1/licenses");
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
Bucket bucket = buckets.computeIfAbsent(clientKey(request), key -> newBucket());
if (bucket.tryConsume(1)) {
chain.doFilter(request, response);
return;
}
response.setStatus(HttpServletResponse.SC_TOO_MANY_REQUESTS);
response.setHeader("Retry-After", "60");
response.setContentType("application/problem+json");
response.getWriter().write("""
{"type":"about:blank","title":"Too Many Requests","status":429}
""");
}
// Trenta richieste al minuto per indirizzo: generoso per un client, stretto per uno scanner
private Bucket newBucket() {
return Bucket.builder()
.addLimit(Bandwidth.builder()
.capacity(30)
.refillIntervally(30, Duration.ofMinutes(1))
.build())
.build();
}
private String clientKey(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
return (forwarded != null && !forwarded.isBlank())
? forwarded.split(",")[0].trim()
: request.getRemoteAddr();
}
}
Manutenzione automatica
Due attività periodiche mantengono coerente lo stato del sistema: la transizione delle licenze scadute e il rilascio dei posti occupati da macchine che non danno più segni di vita. Senza la seconda, un cliente che sostituisce l'hardware senza disattivare esaurisce i posti nel giro di qualche anno e apre un ticket.
package com.example.licensing.scheduler;
import com.example.licensing.config.LicensingProperties;
import com.example.licensing.domain.Activation;
import com.example.licensing.repository.ActivationRepository;
import com.example.licensing.repository.LicenseRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
@Component
public class LicenseMaintenanceJob {
private static final Logger log = LoggerFactory.getLogger(LicenseMaintenanceJob.class);
private final LicenseRepository licenseRepository;
private final ActivationRepository activationRepository;
private final LicensingProperties properties;
public LicenseMaintenanceJob(LicenseRepository licenseRepository,
ActivationRepository activationRepository,
LicensingProperties properties) {
this.licenseRepository = licenseRepository;
this.activationRepository = activationRepository;
this.properties = properties;
}
// Ogni notte alle 03:15, dopo il periodo di tolleranza già applicato in validazione
@Scheduled(cron = "0 15 3 * * *")
@Transactional
public void expireLicenses() {
Instant threshold = Instant.now()
.minus(properties.activation().gracePeriodDays(), ChronoUnit.DAYS);
int updated = licenseRepository.markExpired(threshold);
if (updated > 0) {
log.info("Licenze portate in stato EXPIRED: {}", updated);
}
}
// Libera i posti delle installazioni che non inviano heartbeat da troppo tempo
@Scheduled(cron = "0 45 3 * * *")
@Transactional
public void releaseStaleActivations() {
Instant threshold = Instant.now()
.minus(properties.activation().staleAfterDays(), ChronoUnit.DAYS);
List<Activation> stale = activationRepository.findStale(threshold);
stale.forEach(activation -> activation.setReleasedAt(Instant.now()));
if (!stale.isEmpty()) {
log.info("Attivazioni rilasciate per inattività: {}", stale.size());
}
}
}
Gestione degli errori
Le risposte di errore seguono la RFC 7807, supportata nativamente da Spring Boot 3 tramite ProblemDetail. Il principio guida è non rivelare più del necessario: un client non autorizzato non deve poter distinguere una chiave inesistente da una revocata.
package com.example.licensing.error;
import jakarta.validation.ConstraintViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.net.URI;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ProblemDetail handleNotFound(NotFoundException ex) {
return problem(HttpStatus.NOT_FOUND, "Risorsa non trovata", ex.getMessage(), "not-found");
}
@ExceptionHandler(ConflictException.class)
public ProblemDetail handleConflict(ConflictException ex) {
return problem(HttpStatus.CONFLICT, "Operazione non consentita", ex.getMessage(), "conflict");
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
String detail = ex.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining("; "));
return problem(HttpStatus.BAD_REQUEST, "Richiesta non valida", detail, "validation");
}
@ExceptionHandler(ConstraintViolationException.class)
public ProblemDetail handleConstraint(ConstraintViolationException ex) {
return problem(HttpStatus.BAD_REQUEST, "Richiesta non valida", ex.getMessage(), "validation");
}
// Ultima rete di sicurezza: nessun dettaglio interno finisce nella risposta
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex) {
return problem(HttpStatus.INTERNAL_SERVER_ERROR, "Errore interno",
"Si è verificato un errore imprevisto", "internal");
}
private ProblemDetail problem(HttpStatus status, String title, String detail, String slug) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(status, detail);
problemDetail.setTitle(title);
problemDetail.setType(URI.create("https://errors.example.com/licensing/" + slug));
return problemDetail;
}
}
Il lato client: fingerprint e verifica offline
Il fingerprint della macchina deve essere stabile fra i riavvii ma non deve trasmettere dati personali. La ricetta migliore combina più identificatori hardware e li riduce a un hash, in modo che il server non riceva mai il dato grezzo.
package com.example.client;
import java.net.NetworkInterface;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HexFormat;
import java.util.List;
public final class MachineFingerprint {
public static String compute(String productCode) throws Exception {
List<String> components = new ArrayList<>();
components.add(System.getProperty("os.name"));
components.add(System.getProperty("os.arch"));
components.add(String.valueOf(Runtime.getRuntime().availableProcessors()));
components.add(primaryMacAddress());
// Il codice prodotto entra nell'hash: lo stesso PC produce impronte diverse per prodotti diversi
components.add(productCode);
Collections.sort(components);
MessageDigest digest = MessageDigest.getInstance("SHA-256");
digest.update(String.join("|", components).getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest.digest());
}
private static String primaryMacAddress() throws Exception {
return Collections.list(NetworkInterface.getNetworkInterfaces()).stream()
.filter(MachineFingerprint::isPhysical)
.map(MachineFingerprint::hardwareAddress)
.filter(address -> address != null)
.sorted()
.findFirst()
.orElse("no-mac");
}
private static boolean isPhysical(NetworkInterface networkInterface) {
try {
return !networkInterface.isLoopback() && !networkInterface.isVirtual();
} catch (Exception ex) {
return false;
}
}
private static String hardwareAddress(NetworkInterface networkInterface) {
try {
byte[] address = networkInterface.getHardwareAddress();
return address == null ? null : HexFormat.of().formatHex(address);
} catch (Exception ex) {
return null;
}
}
}
La verifica del token sul client non richiede né Spring né dipendenze esterne. Questo è un vantaggio concreto: la classe può essere incorporata in qualunque applicazione desktop o servizio, anche molto leggero.
package com.example.client;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
public final class OfflineLicenseVerifier {
// Chiave pubblica compilata nel binario: la privata non lascia mai il server
private static final String PUBLIC_KEY_BASE64 =
"MCowBQYDK2VwAyEA...";
private final ObjectMapper objectMapper = new ObjectMapper();
public JsonNode verify(String token, String expectedMachineId) throws Exception {
int separator = token.indexOf('.');
if (separator < 0) {
throw new SecurityException("Token malformato");
}
String encodedPayload = token.substring(0, separator);
byte[] rawSignature = Base64.getUrlDecoder().decode(token.substring(separator + 1));
KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
PublicKey publicKey = keyFactory.generatePublic(
new X509EncodedKeySpec(Base64.getDecoder().decode(PUBLIC_KEY_BASE64)));
Signature signature = Signature.getInstance("Ed25519");
signature.initVerify(publicKey);
signature.update(encodedPayload.getBytes(StandardCharsets.US_ASCII));
if (!signature.verify(rawSignature)) {
throw new SecurityException("Firma della licenza non valida");
}
JsonNode payload = objectMapper.readTree(Base64.getUrlDecoder().decode(encodedPayload));
// Il token è legato alla macchina: copiarlo altrove non serve a nulla
if (!expectedMachineId.equals(payload.path("machineId").asText())) {
throw new SecurityException("Token emesso per un'altra macchina");
}
Instant tokenExpiry = Instant.parse(payload.path("tokenExpiresAt").asText());
if (tokenExpiry.isBefore(Instant.now())) {
throw new SecurityException("Token scaduto: è necessario un nuovo contatto con il server");
}
return payload;
}
public boolean hasFeature(JsonNode payload, String feature) {
for (JsonNode node : payload.path("features")) {
if (feature.equals(node.asText())) {
return true;
}
}
return false;
}
}
Un dettaglio spesso trascurato: il controllo della scadenza del token va fatto rispetto a un orologio di cui ci si possa fidare almeno un poco. Conviene memorizzare in una preferenza locale l'ultimo istante osservato e rifiutare i token quando l'orologio di sistema risulta arretrato rispetto a esso, così da rendere inefficace la manipolazione della data.
Test
Il generatore di chiavi si presta a un test puramente unitario, veloce e privo di contesto Spring.
package com.example.licensing.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.RepeatedTest;
import static org.assertj.core.api.Assertions.assertThat;
class LicenseKeyGeneratorTest {
private final LicenseKeyGenerator generator = new LicenseKeyGenerator();
@RepeatedTest(200)
void generatedKeysAreWellFormed() {
String key = generator.generate();
assertThat(key).matches("[0-9A-Z]{5}(-[0-9A-Z]{5}){4}");
assertThat(LicenseKeyGenerator.isWellFormed(key)).isTrue();
}
@Test
void detectsSingleCharacterCorruption() {
String key = generator.generate();
char[] characters = key.toCharArray();
// Sostituiamo il primo carattere con uno diverso appartenente allo stesso alfabeto
characters[0] = characters[0] == '2' ? '3' : '2';
assertThat(LicenseKeyGenerator.isWellFormed(new String(characters))).isFalse();
}
@Test
void detectsTransposition() {
String key = "7K2QP-M9XVR-3TDBH-5NZWC-JF4G8";
String swapped = "K72QP-M9XVR-3TDBH-5NZWC-JF4G8";
assertThat(LicenseKeyGenerator.isWellFormed(key))
.isNotEqualTo(LicenseKeyGenerator.isWellFormed(swapped));
}
@Test
void normalizesAmbiguousCharacters() {
assertThat(LicenseKeyGenerator.normalize("abc-io1 l")).isEqualTo("ABC1011");
}
}
Il test più importante dell'intero progetto verifica che il limite di attivazioni regga sotto concorrenza. Senza questo test, il blocco pessimistico è solo una speranza.
package com.example.licensing.service;
import com.example.licensing.api.dto.ActivationRequest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class ActivationConcurrencyTest {
@Autowired
private ActivationService activationService;
@Autowired
private TestFixtures fixtures;
@Test
void doesNotExceedSeatLimitUnderConcurrency() throws Exception {
// Licenza con due soli posti, aggredita da venti richieste simultanee
String licenseKey = fixtures.createActiveLicense(2);
int threads = 20;
ExecutorService executor = Executors.newFixedThreadPool(threads);
CountDownLatch startGate = new CountDownLatch(1);
AtomicInteger granted = new AtomicInteger();
for (int i = 0; i < threads; i++) {
String machineId = "machine-" + i + "-0000000000000000";
executor.submit(() -> {
startGate.await();
var response = activationService.activate(
new ActivationRequest(licenseKey, machineId, "host", "Linux", "1.0.0"),
"test");
if (response.valid()) {
granted.incrementAndGet();
}
return null;
});
}
startGate.countDown();
executor.shutdown();
assertThat(executor.awaitTermination(30, TimeUnit.SECONDS)).isTrue();
assertThat(granted.get()).isEqualTo(2);
}
}
package com.example.licensing.api;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
class LicenseClientControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void rejectsRequestsWithoutApiKey() throws Exception {
mockMvc.perform(get("/api/v1/licenses/7K2QP-M9XVR-3TDBH-5NZWC-JF4G8/validate"))
.andExpect(status().isUnauthorized());
}
@Test
void reportsMalformedKeyWithoutTouchingTheDatabase() throws Exception {
mockMvc.perform(get("/api/v1/licenses/AAAAA-BBBBB/validate")
.header("X-License-Api-Key", "test-client-key"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.valid").value(false))
.andExpect(jsonPath("$.reason").value("MALFORMED_KEY"));
}
}
Deployment e gestione delle chiavi
La chiave privata di firma è l'unico vero segreto del sistema. Se viene compromessa, chiunque può emettere licenze valide per tutte le installazioni esistenti e l'unico rimedio è distribuire una nuova versione del client con una nuova chiave pubblica. Alcune precauzioni sono quindi obbligatorie.
- La chiave privata va conservata in un gestore di segreti (Vault, AWS Secrets Manager, o quantomeno una variabile d'ambiente iniettata dal sistema di orchestrazione), mai nel repository né nel file
application.ymlversionato. - Il client dovrebbe conoscere due chiavi pubbliche, quella corrente e quella di riserva, accettando le firme prodotte da entrambe. Questo rende possibile una rotazione senza rompere le installazioni in campo.
- Va incluso un identificatore di chiave nel payload, in modo che il client sappia con quale verificare senza doverle provare tutte.
- I backup del database vanno cifrati: contengono le anagrafiche dei clienti e la mappa completa delle installazioni.
Sul piano operativo, il servizio è senza stato e si presta a girare in più repliche dietro un bilanciatore. Le due sole attenzioni sono il rate limiting, che come già osservato va spostato su Redis quando le istanze sono più d'una, e i job schedulati, che vanno protetti con un lock distribuito (ShedLock è la soluzione più diretta in ambito Spring) per evitare che ogni replica esegua la stessa manutenzione notturna.
Cosa questo sistema non fa
Vale la pena essere espliciti sui limiti, perché un sistema di licensing venduto come inviolabile è un sistema di licensing che verrà violato. Il codice descritto qui protegge in modo robusto contro la contraffazione delle licenze: senza la chiave privata non si può produrre un token che il client accetti. Non protegge invece contro la modifica del client stesso: chiunque abbia accesso al binario può rimuovere la chiamata al verificatore.
Questa è una proprietà strutturale di qualunque protezione lato client, non un difetto dell'implementazione. La strategia corretta consiste nell'accettarla e nel puntare su ciò che funziona davvero: rendere la licenza legittima più comoda dell'alternativa, legare le funzionalità di maggior valore a servizi lato server che non si possono replicare, e usare l'audit log come strumento commerciale per individuare gli usi anomali e trasformarli in una conversazione con il cliente invece che in un blocco automatico.
Conclusioni
Il sistema che abbiamo costruito copre l'intero ciclo di vita di una licenza: emissione con chiavi leggibili e autoverificanti, attivazione per macchina protetta contro le corse critiche, validazione offline tramite firma Ed25519, revoca e sospensione con effetto entro la durata del token, rinnovo, manutenzione automatica dei posti inattivi e audit completo di ogni operazione.
I punti su cui vale la pena tornare quando si adatta il codice a un contesto reale sono tre: la durata del token, che regola il compromesso fra autonomia offline e prontezza della revoca; il periodo di tolleranza sulle scadenze, che determina quanto il sistema è indulgente verso i ritardi di pagamento; e il tempo dopo il quale un'attivazione viene considerata abbandonata, che stabilisce quanto spesso i clienti dovranno aprire un ticket per riavere i propri posti. Sono tre parametri di configurazione, ma sono soprattutto tre decisioni di prodotto.