Aller au contenu

API — Gestion de la licence

Cette page documente LicenseController, monté sous /api/license. Il gère l'activation, la révocation et la consultation de la licence commerciale PerfShop, qui débloque les niveaux avancés du chaos pour l'usage autonome.

Contrôleur couvert

LicenseControllerGET /status, POST /activate, POST /revoke


Le modèle de licence PerfShop

PerfShop fonctionne en freemium : le parcours e-commerce complet (produits, panier, commandes, comptes, administration) est accessible en mode gratuit, mais les niveaux avancés du chaos en self-service étudiant (au-delà du niveau 1 pour la performance, au-delà du niveau 0 pour les autres familles) nécessitent une licence valide.

La frontière entre les deux modes est le code HTTP 402 Payment Required retourné par les endpoints étudiants quand un niveau dépasse la limite gratuite. Voir chaos-student.md.

Freemium vs pédagogique

Même sans licence, le formateur peut activer n'importe quel niveau de chaos via les endpoints /api/admin/chaos/* — il est identifié comme admin et n'est pas soumis au mur freemium. La licence protège uniquement le self-service étudiant et le pilotage des parcours pédagogiques (/pedagogique/activate, /pedagogique/join, /pedagogique/level).

Cette règle reflète l'usage réel de PerfShop : la licence est nécessaire pour les formateurs qui veulent que leurs étudiants s'entraînent en autonomie, pas pour les démonstrations ponctuelles où le formateur pilote lui-même.


Les trois plans commerciaux

PerfShop distingue trois plans qui débloquent différents ensembles de features :

Plan Identifiant interne Interfaces débloquées
Functional Testing 🧪 functional chaos-admin, admin, monitoring, scripts-ui
Performance Testing 🎯 performance Functional + jmeter-ui
Enterprise 🏢 enterprise Toutes les features y compris futures

Quand aucune licence valide n'est active, le plan interne est none et toutes les interfaces protégées retournent 402 Payment Required.


Format des clés de licence

Les clés de licence PerfShop suivent un format standardisé :

PFSH-<base64url_payload>.<base64url_signature>

La clé est composée de trois parties :

  1. Préfixe obligatoire : PFSH-
  2. Payload base64url : un objet JSON encodé décrivant la licence (licenseId, holder, plan, issuedAt, expiresAt)
  3. Signature base64url : séparée du payload par un point ., c'est une signature RSA-PSS 2048 bits avec SHA-256 calculée sur les octets ASCII du payload

Exemple de forme (pas une vraie clé) :

PFSH-eyJsaWNlbnNlSWQiOiJwc2gtMDAwMSIsImhvbGRlciI6...cGxhbiI6InBlcmZvcm1hbmNlIn0.qwertyABC...xyz987

La validité d'une clé est vérifiée par le service LicenseService.parseLicense() qui :

  1. Vérifie le préfixe PFSH-
  2. Sépare le payload et la signature sur le dernier point .
  3. Charge la clé publique RSA embarquée dans le JAR backend
  4. Vérifie la signature RSA-PSS (SHA-256 + MGF1 + salt 32 bytes)
  5. Décode le payload JSON et extrait les champs

Aucun appel réseau n'est nécessaire — la vérification est entièrement hors-ligne.

Protection d'intégrité de la clé publique

Le backend embarque la clé publique RSA officielle en constante source et vérifie son SHA-256 au démarrage (@PostConstruct). Si le hash ne correspond pas au hash attendu (EXPECTED_PUBLIC_KEY_HASH), la JVM refuse de démarrer avec une IllegalStateException. Cette vérification empêche un fork malveillant de PerfShop de substituer sa propre clé publique pour signer des licences frauduleuses — toute modification force la publication AGPL du fork.


Vue d'ensemble

Méthode Endpoint Auth Description
GET /api/license/status Aucune Statut de la licence courante
POST /api/license/activate Aucune 🔑 Active une clé de licence
POST /api/license/revoke Aucune 🔑 Révoque la licence active

Pourquoi /activate et /revoke sont publics

Les deux endpoints de mutation sont intentionnellement publics pour éviter un deadlock opérationnel :

  • /activate : sans licence valide, toutes les interfaces d'authentification protégées sont bloquées (HTTP 402). Si l'activation nécessitait une authentification, il serait impossible de s'authentifier pour activer sa première licence. L'endpoint est donc public, avec la sécurité assurée par la vérification RSA-PSS : une clé forgée est rejetée.
  • /revoke : avec une licence révoquée, l'auth admin est bloquée → deadlock si ce endpoint nécessitait une authentification. L'endpoint est public, et son impact est limité : sans licence valide, les interfaces sont simplement bloquées en 402. Une nouvelle activation nécessite une clé RSA valide.

GET /api/license/status

Retourne l'état courant de la licence. Endpoint fréquemment pollé par les interfaces chaos-admin, scripts-ui, jmeter-ui pour conditionner leur affichage avant toute tentative d'authentification.

Auth : aucune

Réponse — 200 OK (licence active)

{
  "valid": true,
  "plan": "performance",
  "planLabel": "🎯 Performance Testing",
  "holder": "Acme Training Corp",
  "issuedAt": "2025-01-15",
  "expiresAt": "2026-01-15",
  "unlimited": false,
  "daysRemaining": 282,
  "features": [
    "chaos-admin",
    "admin",
    "monitoring",
    "scripts-ui",
    "jmeter-ui"
  ]
}
Champ Description
valid true si une licence est active et non expirée
plan Identifiant interne du plan : functional, performance ou enterprise
planLabel Libellé lisible du plan avec emoji
holder Nom du bénéficiaire inscrit dans le payload signé
issuedAt Date d'émission au format YYYY-MM-DD
expiresAt Date d'expiration au format YYYY-MM-DD, ou null si licence illimitée
unlimited true si expiresAt == null
daysRemaining Nombre de jours restants jusqu'à l'expiration, ou null si illimitée. Plancher à 0 (pas de valeur négative)
features Liste des interfaces débloquées par ce plan

Réponse — 200 OK (aucune licence active)

{
  "valid": false,
  "plan": "none",
  "planLabel": "Aucune licence",
  "message": "Aucune licence valide. Activez votre licence via le panneau ci-dessous."
}

Les champs planLabel et message sont localisés via les clés license.status.none_label et license.status.none_message.

Cet endpoint ne retourne jamais d'erreur — même en cas de clé corrompue, d'expiration ou de problème interne, il répond 200 OK avec valid: false. C'est conçu pour être pollé en continu sans risque.


POST /api/license/activate

Active une clé de licence. La clé est vérifiée cryptographiquement par signature RSA-PSS, puis persistée dans la table perfshop_license de la base de données et chargée dans le cache mémoire.

Auth : aucune (voir justification plus haut)

Requête

{ "licenseKey": "PFSH-eyJsaWNlbnNlSWQiOi...cGxhbiI6InBlcmZvcm1hbmNlIn0.qwertyABC...xyz987" }
Champ Type Requis Description
licenseKey string oui Clé de licence complète au format PFSH-<payload>.<signature>

Réponse — 200 OK

{
  "success": true,
  "message": "Licence activée avec succès",
  "status": {
    "valid": true,
    "plan": "performance",
    "planLabel": "🎯 Performance Testing",
    "holder": "Acme Training Corp",
    "issuedAt": "2025-01-15",
    "expiresAt": "2026-01-15",
    "unlimited": false,
    "daysRemaining": 365,
    "features": ["chaos-admin", "admin", "monitoring", "scripts-ui", "jmeter-ui"]
  }
}

Le message de succès est localisé via license.activate.success. Le bloc status est identique à ce que retourne GET /status après activation.

Processus de validation

flowchart TB
    A[POST /license/activate] --> B{Champ licenseKey présent ?}
    B -->|non| E1[400 — license.error.key_empty]
    B -->|oui| C{Commence par PFSH- ?}
    C -->|non| E2[400 — license.error.key_format]
    C -->|oui| D[LicenseService.activateLicense]
    D --> F[Parse payload + vérif signature RSA-PSS]
    F -->|signature invalide| E3[422 — license.error.invalid_signature]
    F -->|date dépassée| E4[422 — license.error.expired]
    F -->|OK| G[UPSERT perfshop_license]
    G --> H[cachedLicense.set]
    H --> I[200 OK]

Codes d'erreur

Code Corps Cause
400 {"error": "Le champ 'licenseKey' est requis"} Champ absent ou vide (clé license.error.key_empty)
400 {"error": "Format invalide — la clé doit commencer par PFSH-"} Préfixe manquant (clé license.error.key_format)
422 {"error": "Clé invalide — signature RSA incorrecte ou format non reconnu"} Signature invalide, payload corrompu ou plan inconnu (clé license.error.invalid_signature)
422 {"error": "Licence expirée le 2024-12-31"} Date d'expiration dans le passé (clé license.error.expired)

Code 422 pour les erreurs cryptographiques

Les erreurs de format (400) et les erreurs de vérification cryptographique (422) sont distinctes. Le 400 indique que la requête elle-même est malformée (champ manquant ou mauvais préfixe), le 422 indique que la requête est bien formée mais que le contenu de la clé n'est pas valide (signature incorrecte ou expiration dépassée). Cette distinction permet à un client de différencier une erreur de saisie utilisateur d'un rejet cryptographique.

Persistance en base de données

L'activation effectue un upsert sur la table perfshop_license avec la clé fixe id='current' :

-- Tentative de mise à jour (si la ligne existe déjà)
UPDATE perfshop_license
SET license_key=?, plan=?, holder=?,
    issued_at=?, expires_at=?,
    activated_at=NOW(), revoked=0
WHERE id='current';

-- Si UPDATE n'a touché aucune ligne, INSERT
INSERT INTO perfshop_license (id, license_key, plan, holder, issued_at, expires_at)
VALUES ('current', ?, ?, ?, ?, ?);

Une seule licence active par instance — les activations successives écrasent la précédente.

Priorité de chargement au démarrage

Au démarrage du backend (@PostConstruct), LicenseService.loadLicense() cherche la licence dans deux sources par ordre de priorité :

  1. Variable d'environnement PERFSHOP_LICENSE_KEY (ou propriété Spring perfshop.license.key) — utilisée pour les déploiements avec .env
  2. Table perfshop_license en base (ligne id='current' non révoquée) — utilisée après activation via l'API

Si aucune source ne fournit de licence valide, le cache reste vide et toutes les interfaces protégées retournent 402.


POST /api/license/revoke

Révoque la licence active : marque revoked=1 dans la table perfshop_license et vide le cache mémoire.

Auth : aucune (voir justification plus haut)

Requête

Pas de corps requis.

POST /api/license/revoke HTTP/1.1

Réponse — 200 OK

{
  "success": true,
  "message": "Licence révoquée — interfaces protégées bloquées",
  "status": {
    "valid": false,
    "plan": "none",
    "planLabel": "Aucune licence",
    "message": "Aucune licence valide. Activez votre licence via le panneau ci-dessous."
  }
}

Le bloc status reflète l'état après révocation — identique à GET /status sans licence.

Codes d'erreur

Code Corps Cause
409 {"error": "Aucune licence active à révoquer"} Aucune licence valide à révoquer (clé license.error.no_active)

Effets de la révocation

  1. Tentative SQL : UPDATE perfshop_license SET revoked = 1 WHERE id = 'current'. Si la mise à jour échoue (erreur DB), le message est loggé en WARN mais l'opération continue
  2. Cache vidé : cachedLicense.set(null)isLicenseValid() retourne false immédiatement
  3. Les chaos en cours ne sont pas interrompus (un chaos déjà actif continue de tourner jusqu'à son reset manuel)
  4. Les étudiants reçoivent 402 Payment Required sur leur prochaine tentative d'activation de niveau > freemium

Verrouillage des features par licence

Le service expose une méthode hasFeature(String feature) appelée par les contrôleurs pour vérifier si le plan courant donne accès à une fonctionnalité. Les règles sont :

Plan functional performance enterprise
chaos-admin
admin
monitoring
scripts-ui
jmeter-ui
autres

Quand un endpoint refuse l'accès pour cause de plan insuffisant, il retourne 402 avec le message générique license.error.feature_denied :

{
  "error": "Votre plan ne donne pas accès à cette fonctionnalité (jmeter-ui)"
}

Le placeholder {0} est remplacé par l'identifiant de la feature refusée.


Exemple curl — activation et révocation

# 1. État initial — pas de licence
curl http://localhost:9080/api/license/status
# {
#   "valid": false,
#   "plan": "none",
#   "planLabel": "Aucune licence",
#   "message": "Aucune licence valide. ..."
# }

# 2. Tenter un niveau chaos étudiant → 402
curl -H "Content-Type: application/json" \
     -d '{"level":3}' \
     http://localhost:9080/api/chaos/student/performance
# { "error": "LICENSE_REQUIRED", "requested": 3, "maxFree": 1, ... }

# 3. Activer une licence Pro
curl -H "Content-Type: application/json" \
     -d '{"licenseKey":"PFSH-eyJ...signaturexyz"}' \
     http://localhost:9080/api/license/activate

# 4. Vérifier le statut après activation
curl http://localhost:9080/api/license/status
# { "valid": true, "plan": "performance", "daysRemaining": 365, ... }

# 5. Re-tenter le chaos niveau 3 → 200
curl -H "Content-Type: application/json" \
     -d '{"level":3}' \
     http://localhost:9080/api/chaos/student/performance

# 6. En fin de cours — révocation publique
curl -X POST http://localhost:9080/api/license/revoke

Sécurité de l'implémentation

PerfShop protège le mécanisme de licence contre plusieurs classes d'attaques :

Vérification hors-ligne par signature RSA-PSS

Aucun appel serveur externe n'est nécessaire pour valider une clé — la clé publique est embarquée dans le JAR backend. Cela garantit :

  • Fonctionnement hors-ligne (salle de formation sans Internet)
  • Aucune fuite d'IP des clients vers un serveur de licence externe
  • Aucune dépendance à un service tiers

Anti-substitution de la clé publique

Le hash SHA-256 de la clé publique embarquée est vérifié au démarrage. Un fork de PerfShop qui substituerait une autre clé publique pour signer ses propres licences verrait le backend refuser de démarrer. Combiné à la licence AGPL, cela force la publication de toute modification et rend les licences frauduleuses détectables.

Pas de révélation de la clé

La clé activée est stockée en base et dans le cache, mais n'est jamais retournée par l'API :

  • GET /status retourne le plan, le bénéficiaire, les dates — jamais la clé
  • Les logs n'incluent pas la clé en clair
  • Une erreur d'activation n'expose pas la valeur saisie

Protection contre le replay

Une clé déjà utilisée peut être réactivée autant de fois que nécessaire — il n'y a pas de protection anti-replay côté backend. C'est intentionnel : les instances de formation sont reconstruites fréquemment, et obliger une révocation systématique compliquerait inutilement les workflows pédagogiques. Le contrôle commercial se fait en amont (cycle de renouvellement, facturation par poste).


Points d'attention

Pas d'arrêt automatique des chaos à l'expiration

Quand une licence arrive à expiration, le backend la considère automatiquement comme invalide à la prochaine validation — mais les chaos déjà actifs au moment de l'expiration continuent de tourner. C'est un compromis pour éviter qu'un cours en cours soit brutalement interrompu. Le formateur verra néanmoins le badge « licence expirée » dans le monitoring et devra renouveler dès que possible.

Une seule licence par instance

La table perfshop_license utilise une clé primaire fixe id='current' — il ne peut y avoir qu'une seule licence active à la fois. Activer une nouvelle clé écrase la précédente (y compris si elle était d'un plan supérieur). Pour basculer temporairement entre plans, il faut conserver les clés originales en dehors du système.


Liens associés