feat(EsyWeb): Ajoute gestion des licences et clés DMA pour sites web

Ajoute la gestion des licences pour les sites web EsyWeb, incluant
la génération, le renouvellement et la validation. Intègre aussi
la création et l'utilisation de clés DMA.
```
This commit is contained in:
Serreau Jovann
2025-11-12 14:21:16 +01:00
parent 1c5fe82c92
commit 4488c2ea5c
14 changed files with 546 additions and 7 deletions

2
.env
View File

@@ -72,7 +72,7 @@ AMAZON_SES_SECRET=BD63dADmgFJJPnjlT9utRDlvcOh8pRH3eOZXsyhNL/F3
# MAILER_DSN=ses://ACCESS_KEY:SECRET_KEY@default?region=eu-west-1
# MAILER_DSN=ses+smtp://ACCESS_KEY:SECRET_KEY@default?region=eu-west-1
###< symfony/amazon-mailer ###
CLOUDFLARE_TOKEN=4mqx9d7ynvoeCaXonJA07U19rH8gGhctqp7j2Lch
CLOUDFLARE_TOKEN=oSpqBIuiKc3waClbo3si4Y8dXZSVt8anijQiHY9N
MAILCOW_KEY=DF0E7E-0FD059-16226F-8ECFF1-E558B3
DEV_URL=https://086e682e904b.ngrok-free.app

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251112130852 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE website_license (id SERIAL NOT NULL, website_id INT DEFAULT NULL, type VARCHAR(255) NOT NULL, license_number VARCHAR(255) NOT NULL, public_key TEXT NOT NULL, private_key TEXT NOT NULL, create_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, expires_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_FB4688BD18F45C82 ON website_license (website_id)');
$this->addSql('COMMENT ON COLUMN website_license.create_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN website_license.expires_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE website_license ADD CONSTRAINT FK_FB4688BD18F45C82 FOREIGN KEY (website_id) REFERENCES website (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE website_license DROP CONSTRAINT FK_FB4688BD18F45C82');
$this->addSql('DROP TABLE website_license');
}
}

View File

@@ -5,9 +5,12 @@ namespace App\Controller\Artemis\EsyWeb;
use App\Entity\EsyWeb\Website;
use App\Entity\EsyWeb\WebsiteDns;
use App\Entity\EsyWeb\WebsiteKey;
use App\Entity\EsyWeb\WebsiteLicense;
use App\Form\Artemis\EsyWeb\WebsiteType;
use App\Repository\EsyWeb\WebsiteRepository;
use App\Repository\EsyWebTutoRepository;
use App\Service\Cloudflare\Client;
use App\Service\License\LicenseManager;
use App\Service\Logger\LoggerService;
use App\Service\Website\EventCancelWebsite;
use App\Service\Website\EventCreatedWebsite;
@@ -24,7 +27,10 @@ use Vich\UploaderBundle\Templating\Helper\UploaderHelper;
class EsyWebController extends AbstractController
{
#[Route(path: '/artemis/esyweb/website', name: 'artemis_esyweb', methods: ['GET', 'POST'])]
public function websites(LoggerService $loggerService,EventDispatcherInterface $eventDispatcher,Request $request,EntityManagerInterface $entityManager,WebsiteRepository $websiteRepository)
public function websites(
\App\Service\Dma\Client $clientDma,
LicenseManager $licenseManager,
Client $client,LoggerService $loggerService,EventDispatcherInterface $eventDispatcher,Request $request,EntityManagerInterface $entityManager,WebsiteRepository $websiteRepository)
{
$loggerService->log("VIEW","Affiche la page de site internet",$this->getUser());
@@ -47,7 +53,20 @@ class EsyWebController extends AbstractController
$website = $websiteRepository->find($request->query->get('idValidate'));
$website->setState("validate");
$entityManager->persist($website);
$slug = new Slugify();
$client->createEsyWebDev($slug->slugify($website->getTitle()).".esy-web.dev",$website->getServer()->getExternalIp());
$websiteKey = new WebsiteKey();
$websiteKey->setType("dma_key");
$websiteKey->setApiKey($clientDma->createDma($slug->slugify($website->getTitle())));
$websiteKey->setWebsitre($website);
$entityManager->persist($websiteKey);
$vd =$licenseManager->generateAndSaveLicense($website,'main_license');
$entityManager->persist($vd);
$entityManager->flush();
$loggerService->log("VALIDATE","Validation du site internet",$this->getUser());
return $this->redirectToRoute('artemis_esyweb');
}
@@ -56,13 +75,12 @@ class EsyWebController extends AbstractController
]);
}
#[Route(path: '/artemis/esyweb/website/{id}', name: 'artemis_esyweb_view', methods: ['GET', 'POST'])]
public function websiteView(?Website $website,LoggerService $loggerService,Request $request,EntityManagerInterface $entityManager,WebsiteRepository $websiteRepository)
public function websiteView(?Website $website,LicenseManager $licenseManager,LoggerService $loggerService,Request $request,EntityManagerInterface $entityManager,WebsiteRepository $websiteRepository)
{
if(is_null($website)) {
return $this->redirectToRoute('artemis_esyweb');
}
$loggerService->log("VIEW","Affiche la page de site internet - ".$website->getTitle(),$this->getUser());
return $this->render('artemis/esyweb/website_view.twig', [
'current' => $request->get('current','main'),
'website' => $website

View File

@@ -56,10 +56,17 @@ class Website
#[ORM\ManyToOne(inversedBy: 'websites')]
private ?Compute $server = null;
/**
* @var Collection<int, WebsiteLicense>
*/
#[ORM\OneToMany(targetEntity: WebsiteLicense::class, mappedBy: 'website')]
private Collection $websiteLicenses;
public function __construct()
{
$this->websiteDns = new ArrayCollection();
$this->websiteKeys = new ArrayCollection();
$this->websiteLicenses = new ArrayCollection();
}
public function getId(): ?int
@@ -228,4 +235,34 @@ class Website
return $this;
}
/**
* @return Collection<int, WebsiteLicense>
*/
public function getWebsiteLicenses(): Collection
{
return $this->websiteLicenses;
}
public function addWebsiteLicense(WebsiteLicense $websiteLicense): static
{
if (!$this->websiteLicenses->contains($websiteLicense)) {
$this->websiteLicenses->add($websiteLicense);
$websiteLicense->setWebsite($this);
}
return $this;
}
public function removeWebsiteLicense(WebsiteLicense $websiteLicense): static
{
if ($this->websiteLicenses->removeElement($websiteLicense)) {
// set the owning side to null (unless already changed)
if ($websiteLicense->getWebsite() === $this) {
$websiteLicense->setWebsite(null);
}
}
return $this;
}
}

View File

@@ -0,0 +1,126 @@
<?php
namespace App\Entity\EsyWeb;
use App\Repository\EsyWeb\WebsiteLicenseRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: WebsiteLicenseRepository::class)]
class WebsiteLicense
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'websiteLicenses')]
private ?Website $website = null;
#[ORM\Column(length: 255)]
private ?string $type = null;
#[ORM\Column(length: 255)]
private ?string $licenseNumber = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $publicKey = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $privateKey = null;
#[ORM\Column]
private ?\DateTimeImmutable $createAt = null;
#[ORM\Column]
private ?\DateTimeImmutable $expiresAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getWebsite(): ?Website
{
return $this->website;
}
public function setWebsite(?Website $website): static
{
$this->website = $website;
return $this;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(string $type): static
{
$this->type = $type;
return $this;
}
public function getLicenseNumber(): ?string
{
return $this->licenseNumber;
}
public function setLicenseNumber(string $licenseNumber): static
{
$this->licenseNumber = $licenseNumber;
return $this;
}
public function getPublicKey(): ?string
{
return $this->publicKey;
}
public function setPublicKey(string $publicKey): static
{
$this->publicKey = $publicKey;
return $this;
}
public function getPrivateKey(): ?string
{
return $this->privateKey;
}
public function setPrivateKey(string $privateKey): static
{
$this->privateKey = $privateKey;
return $this;
}
public function getCreateAt(): ?\DateTimeImmutable
{
return $this->createAt;
}
public function setCreateAt(\DateTimeImmutable $createAt): static
{
$this->createAt = $createAt;
return $this;
}
public function getExpiresAt(): ?\DateTimeImmutable
{
return $this->expiresAt;
}
public function setExpiresAt(\DateTimeImmutable $expiresAt): static
{
$this->expiresAt = $expiresAt;
return $this;
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Repository\EsyWeb;
use App\Entity\EsyWeb\WebsiteLicense;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<WebsiteLicense>
*/
class WebsiteLicenseRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, WebsiteLicense::class);
}
// /**
// * @return WebsiteLicense[] Returns an array of WebsiteLicense objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('w')
// ->andWhere('w.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('w.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?WebsiteLicense
// {
// return $this->createQueryBuilder('w')
// ->andWhere('w.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Service\Cloudflare;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class Client
{
public function __construct(private readonly HttpClientInterface $httpClient)
{
}
public function createEsyWebDev(string $entry,string $ip)
{
try {
$this->httpClient->request('POST', 'https://api.cloudflare.com/client/v4/zones/1032d39d4e3231e080db2b294b0065d7/dns_records', [
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer '.$_ENV['CLOUDFLARE_TOKEN'],
],
'body' => json_encode([
'name' => $entry,
'ttl' => 3600,
'type' => 'A',
'content' => $ip,
'proxied' => true,
])
]);
} catch (\Exception $e) {
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Service\Dma;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class Client
{
public function __construct(private readonly HttpClientInterface $httpClient)
{
}
public function createDma(string $title) : string
{
$response = $this->httpClient->request('POST','https://esydma.esy-web.dev/admin/apikey',[
'headers' => [
'Content-Type' => 'application/json',
'EsyDMA-ApiKey' => '8cfd81d231bdffeaf0648f52f70d92bd',
],
'body' => json_encode([
'owner' => $title,
'roles' => ['ROLE_ADMIN','ROLE_OWNER'],
])
]);
$content = json_encode($response->getContent());
$content = json_decode($content,true);
$content = json_decode($content,true);
return $content['apiKey'];
}
}

View File

@@ -0,0 +1,175 @@
<?php
namespace App\Service\License;
use App\Entity\EsyWeb\Website;
use App\Entity\EsyWeb\WebsiteLicense;
use App\Repository\EsyWeb\WebsiteLicenseRepository;
use App\Service\Vault\VaultClient;
use Doctrine\ORM\EntityManagerInterface;
use DateTimeImmutable;
class LicenseManager
{
public function __construct(
private WebsiteLicenseRepository $websiteLicenseRepository,
private EntityManagerInterface $entityManager,
private VaultClient $vaultClient
) {
}
// ----------------------------------------------------------------------
// 1. GÉNÉRATION ET SAUVEGARDE
// ----------------------------------------------------------------------
/**
* Génère une nouvelle paire de clés RSA, chiffre la clé privée et sauvegarde la licence.
*/
public function generateAndSaveLicense(Website $website,string $type): WebsiteLicense
{
// Génération des clés RSA
$config = ["private_key_bits" => 2048, "private_key_type" => OPENSSL_KEYTYPE_RSA];
$res = openssl_pkey_new($config);
openssl_pkey_export($res, $rawPrivateKey); // Clé privée brute
$details = openssl_pkey_get_details($res);
$publicKey = $details['key'];
// Chiffrement de la clé privée avec Vault
$encryptedPrivateKey = $this->vaultClient->encrypt("lc_private_key",$rawPrivateKey );
// Définition des dates
$createdAt = new DateTimeImmutable();
$expiresAt = $createdAt->modify('+1 year');
// Création de l'entité
$license = new WebsiteLicense();
$license->setLicenseNumber(uniqid('LIC-', true));
$license->setPublicKey($publicKey);
$license->setPrivateKey($encryptedPrivateKey); // Clé CHIFRÉE
$license->setCreateAt($createdAt);
$license->setWebsite($website);
$license->setExpiresAt($expiresAt);
$license->setType($type);
return $license;
}
/**
* Récupère et déchiffre la clé privée stockée pour l'utiliser dans la signature.
*/
public function getDecryptedPrivateKey(WebsiteLicense $license): string
{
return $this->vaultClient->decrypt($license->getPrivateKey(), "lc_private_key");
}
// ----------------------------------------------------------------------
// 2. VALIDATION (Expiration)
// ----------------------------------------------------------------------
/**
* Valide qu'une licence existe et n'est pas expirée.
*/
public function isLicenseValidAndNotExpired(string $licenseNumber): bool
{
/** @var WebsiteLicense|null $license */
$license = $this->websiteLicenseRepository->findOneBy(['licenseNumber' => $licenseNumber]);
if (!$license) {
return false; // Licence non trouvée
}
$currentDate = new DateTimeImmutable();
$expiresAt = $license->getExpiresAt();
// Si la date actuelle est APRÈS la date d'expiration, elle est expirée.
return $currentDate < $expiresAt;
}
// ----------------------------------------------------------------------
// 3. RENOUVELLEMENT
// ----------------------------------------------------------------------
/**
* Renouvelle une licence en prolongeant sa date d'expiration d'un an.
*/
public function renewLicense(string $licenseNumber): ?WebsiteLicense
{
/** @var WebsiteLicense|null $license */
$license = $this->websiteLicenseRepository->findOneBy(['licenseNumber' => $licenseNumber]);
if (!$license) {
return null;
}
$currentDate = new DateTimeImmutable();
$expiresAt = $license->getExpiresAt();
// Le renouvellement se base sur la date d'expiration actuelle si elle est future,
// sinon il se base sur la date du jour (si la licence est déjà expirée).
$startDate = ($currentDate > $expiresAt) ? $currentDate : $expiresAt;
// Calculer la nouvelle date (+1 an)
$newExpiresAt = $startDate->modify('+1 year');
// Mise à jour de l'entité et sauvegarde
$license->setExpiresAt($newExpiresAt);
$this->entityManager->flush();
return $license;
}
public function licenseValidAndVerified(string $licenseNumber, string $publicKey, string $signedToken): bool
{
/** @var WebsiteLicense|null $license */
$license = $this->websiteLicenseRepository->findOneBy(['licenseNumber' => $licenseNumber]);
if (!$license) {
// 1. La licence n'existe pas
return false;
}
// 2. Vérification de l'Expiration
if (!$this->isLicenseValidAndNotExpired($licenseNumber)) {
return false;
}
// 3. Vérification de la concordance de la clé publique
// La clé publique fournie doit correspondre à celle stockée (générée par vous).
$normalizedStoredKey = trim($license->getPublicKey());
$normalizedProvidedKey = trim($publicKey);
if ($normalizedStoredKey !== $normalizedProvidedKey) {
// Clé publique incorrecte. Tentative de falsification de la clé elle-même.
return false;
}
// 4. Vérification de la Signature (openssl_verify)
// Le contenu original signé (le payload) doit être récupéré.
// Ici, nous utilisons uniquement le numéro de licence comme "payload"
// pour simplifier l'exemple. Dans un système réel, ce serait un JSON
// encodé contenant toutes les données (numéro, date, etc.).
$payloadToVerify = $licenseNumber;
// Décoder le token de signature du Base64 (tel que transmis par le client)
$signatureBinary = base64_decode($signedToken);
// Exécuter openssl_verify
$verificationResult = openssl_verify(
$payloadToVerify,
$signatureBinary,
$publicKey,
OPENSSL_ALGO_SHA256
);
// openssl_verify retourne 1 pour succès, 0 pour échec (invalide), -1 pour erreur.
if ($verificationResult !== 1) {
// Signature invalide. Le token a été falsifié.
return false;
}
// Si tout est passé : non expiré, clé correcte, et signature valide.
return true;
}
}

View File

@@ -13,6 +13,7 @@ class VaultClient
private const KEYS = [
'mainframe_logger',
'mainframe_customer',
'lc_private_key',
];
public function __construct(private readonly HttpClientInterface $httpClient)
@@ -41,7 +42,9 @@ class VaultClient
$data = $response->toArray(false);
return $data['data']['ciphertext'] ?? null;
} catch (\Exception $exception) { return null;
} catch (\Exception $exception) {
return null;
}
}

View File

@@ -11,6 +11,7 @@ use App\Entity\CustomerOrder;
use App\Entity\CustomerSplit;
use App\Entity\EsyWeb\Website;
use App\Entity\EsyWeb\WebsiteKey;
use App\Entity\EsyWeb\WebsiteLicense;
use Cocur\Slugify\Slugify;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
@@ -26,10 +27,30 @@ class TwigOrderExtensions extends AbstractExtension
new TwigFilter('skFormat',[$this,'skFormat']),
new TwigFilter('noCompletedEch',[$this,'noCompletedEch']),
new TwigFilter('slugify',[$this,'slugify']),
new TwigFilter('dmaKey',[$this,'dmaKey']),
new TwigFilter('pubKey',[$this,'pubKey']),
new TwigFilter('licNumber',[$this,'licNumber']),
new TwigFilter('mainKey',[$this,'mainKey']),
];
}
public function licNumber(Website $website): ?string
{
/** @var WebsiteLicense $apiKey */
$apiKey = $website->getWebsiteLicenses()->filter(function (WebsiteLicense $websiteKey) {
return $websiteKey->getType() == "main_license";
})->first();
return $apiKey->getLicenseNumber();
}
public function pubKey(Website $website): ?string
{
/** @var WebsiteLicense $apiKey */
$apiKey = $website->getWebsiteLicenses()->filter(function (WebsiteLicense $websiteKey) {
return $websiteKey->getType() == "main_license";
})->first();
return base64_encode($apiKey->getPublicKey());
}
public function mainKey(Website $website): ?string
{
/** @var WebsiteKey $apiKey */
@@ -38,6 +59,15 @@ class TwigOrderExtensions extends AbstractExtension
})->first();
return $apiKey->getApiKey();
}
public function dmaKey(Website $website): ?string
{
/** @var WebsiteKey $apiKey */
$apiKey = $website->getWebsiteKeys()->filter(function (WebsiteKey $websiteKey) {
return $websiteKey->getType() == "dma_key";
})->first();
return $apiKey->getApiKey();
}
public function slugify(string $title): string
{
$s = new Slugify();

View File

@@ -1,4 +1,4 @@
[website_deploy]
{% for website in websites %}
{{ website.mainDns}} ansible_connection=ssh ansible_user=bot ansible_python_interpreter=/usr/bin/python3 path=/var/www/{{ website.title|slugify }} website_id={{ website.id }} api_key={{ website|mainKey }}
{{ website.mainDns}} ansible_connection=ssh ansible_user=bot ansible_python_interpreter=/usr/bin/python3 path=/var/www/{{ website.title|slugify }} website_id={{ website.id }} api_key={{ website|mainKey }} dma_key={{ website|dmaKey }} public_key={{ website|pubKey }} license_number={{ website|licNumber }}
{% endfor %}

View File

@@ -91,7 +91,7 @@
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-white">
<span>{{ website.title }}</span><br/>
<span>{{ website.uuid }}</span>
<span>{{ website.server.name }}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
<span>{{ website.customer.raisonSocial }}</span><br>

View File

@@ -24,6 +24,10 @@
<i class="fad fa-home"></i>
Options du site
</a>
<a href="{{ path('artemis_esyweb_view',{id:website.id,current:'key'}) }}" class="px-4 py-2 font-semibold {% if current == "ndd" %}{{ active }}{% else %}{{ desactive }}{% endif %}">
<i class="fad fa-home"></i>
Clé du site internet
</a>
</div>
{% include 'artemis/esyweb/website/'~current~".twig" %}