refactor: stocker les secrets webhook Stripe en BDD au lieu de .env.local

src/Entity/StripeWebhookSecret.php (nouveau):
- Constantes TYPE_MAIN_LIGHT, TYPE_MAIN_INSTANT, TYPE_CONNECT_LIGHT,
  TYPE_CONNECT_INSTANT pour les 4 types de webhook
- type: string(30) unique, identifie le webhook (main_light, etc.)
- secret: string(255), le signing secret retourne par Stripe (whsec_xxx)
- endpointId: string nullable, l'ID de l'endpoint Stripe (we_xxx)
- createdAt: DateTimeImmutable

src/Repository/StripeWebhookSecretRepository.php (nouveau):
- findByType(): trouve un secret par type
- getSecret(): retourne directement la valeur du secret ou null

src/Controller/WebhookStripeController.php (reecrit):
- Les 4 routes lisent le secret depuis la BDD via
  StripeWebhookSecretRepository::getSecret() au lieu de variables d'env
- Retourne HTTP 503 si le secret n'est pas encore configure
- Plus besoin des variables STRIPE_WH_*_SECRET dans .env

src/Controller/Admin/SyncController.php:
- syncStripeWebhooks(): sauvegarde les secrets en BDD
  (cree ou met a jour StripeWebhookSecret par type)
- Suppression de saveSecretsToEnvLocal() (plus de modification .env.local)
- URL de base lue depuis WEBHOOK_BASE_URL (env)

.env:
- Suppression des 4 variables STRIPE_WH_*_SECRET (stockees en BDD)
- Ajout WEBHOOK_BASE_URL (vide par defaut)

docker/ngrok/sync.sh:
- Ecrit aussi WEBHOOK_BASE_URL en plus de OUTSIDE_URL

ansible/env.local.j2:
- WEBHOOK_BASE_URL=https://stripe.siteconseil.fr pour la prod

migrations/Version20260402205935.php:
- Table stripe_webhook_secret avec type unique, secret, endpoint_id

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-02 22:59:51 +02:00
parent 0ab2c8d0aa
commit bec008bdc1
9 changed files with 206 additions and 36 deletions

4
.env
View File

@@ -102,6 +102,10 @@ MAILCOW_URL=https://mail.esy-web.dev
MAILCOW_API_KEY=
###< mailcow ###
###> webhooks ###
WEBHOOK_BASE_URL=
###< webhooks ###
###> docuseal ###
DOCUSEAL_URL=https://signature.esy-web.dev
DOCUSEAL_API=

View File

@@ -33,6 +33,7 @@ AWS_REGION=eu-west-3
CLOUDFLARE_KEY={{ cloudflare_key }}
MAILCOW_URL=https://mail.esy-web.dev
MAILCOW_API_KEY={{ mailcow_api_key }}
WEBHOOK_BASE_URL=https://stripe.siteconseil.fr
DOCUSEAL_URL=https://signature.esy-web.dev
DOCUSEAL_API={{ docuseal_api }}
DOCUSEAL_WEBHOOKS_SECRET_HEADER=X-Sign

View File

@@ -23,7 +23,9 @@ fi
touch /app/.env.local
sed -i '/^OUTSIDE_URL=/d' /app/.env.local
sed -i '/^WEBHOOK_BASE_URL=/d' /app/.env.local
echo "OUTSIDE_URL=$NGROK_URL" >> /app/.env.local
echo "WEBHOOK_BASE_URL=$NGROK_URL" >> /app/.env.local
echo "Ngrok URL: $NGROK_URL"
echo "Written to .env.local"
echo "Written OUTSIDE_URL and WEBHOOK_BASE_URL to .env.local"

View File

@@ -0,0 +1,32 @@
<?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 Version20260402205935 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 stripe_webhook_secret (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, type VARCHAR(30) NOT NULL, secret VARCHAR(255) NOT NULL, endpoint_id VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_D9BDCBA48CDE5729 ON stripe_webhook_secret (type)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE stripe_webhook_secret');
}
}

View File

@@ -2,13 +2,17 @@
namespace App\Controller\Admin;
use App\Entity\StripeWebhookSecret;
use App\Repository\CustomerRepository;
use App\Repository\PriceAutomaticRepository;
use App\Repository\RevendeurRepository;
use App\Repository\StripeWebhookSecretRepository;
use Doctrine\ORM\EntityManagerInterface;
use App\Service\MeilisearchService;
use App\Service\StripePriceService;
use App\Service\StripeWebhookService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -80,17 +84,26 @@ class SyncController extends AbstractController
}
#[Route('/stripe/webhooks', name: 'stripe_webhooks', methods: ['POST'])]
public function syncStripeWebhooks(Request $request, StripeWebhookService $webhookService): Response
{
$baseUrl = trim($request->request->getString('base_url'));
if ('' === $baseUrl) {
$this->addFlash('error', 'URL de base requise.');
public function syncStripeWebhooks(
StripeWebhookService $webhookService,
StripeWebhookSecretRepository $secretRepository,
EntityManagerInterface $em,
#[Autowire(env: 'WEBHOOK_BASE_URL')] string $webhookBaseUrl,
): Response {
if ('' === $webhookBaseUrl) {
$this->addFlash('error', 'WEBHOOK_BASE_URL non configuree dans .env.local');
return $this->redirectToRoute('app_admin_sync_index');
}
$result = $webhookService->createAllWebhooks(rtrim($baseUrl, '/'));
$result = $webhookService->createAllWebhooks(rtrim($webhookBaseUrl, '/'));
$typeMap = [
'Main Light' => StripeWebhookSecret::TYPE_MAIN_LIGHT,
'Main Instant' => StripeWebhookSecret::TYPE_MAIN_INSTANT,
'Connect Light' => StripeWebhookSecret::TYPE_CONNECT_LIGHT,
'Connect Instant' => StripeWebhookSecret::TYPE_CONNECT_INSTANT,
];
foreach ($result['created'] as $wh) {
$status = $wh['status'] ?? 'created';
@@ -98,9 +111,24 @@ class SyncController extends AbstractController
$this->addFlash('success', $wh['type'].' : deja configure ('.$wh['id'].')');
} else {
$this->addFlash('success', $wh['type'].' : cree ('.$wh['id'].')');
if (isset($wh['secret'], $typeMap[$wh['type']])) {
$dbType = $typeMap[$wh['type']];
$existing = $secretRepository->findByType($dbType);
if (null !== $existing) {
$existing->setSecret($wh['secret']);
$existing->setEndpointId($wh['id']);
} else {
$entity = new StripeWebhookSecret($dbType, $wh['secret'], $wh['id']);
$em->persist($entity);
}
}
}
}
$em->flush();
foreach ($result['errors'] as $error) {
$this->addFlash('error', 'Stripe Webhook : '.$error);
}
@@ -153,4 +181,5 @@ class SyncController extends AbstractController
return $this->redirectToRoute('app_admin_sync_index');
}
}

View File

@@ -2,9 +2,10 @@
namespace App\Controller;
use App\Entity\StripeWebhookSecret;
use App\Repository\StripeWebhookSecretRepository;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -14,43 +15,44 @@ class WebhookStripeController extends AbstractController
{
public function __construct(
private LoggerInterface $logger,
private StripeWebhookSecretRepository $secretRepository,
) {
}
#[Route('/webhooks/stripe/main/light', name: 'app_webhook_stripe_main_light', methods: ['POST'])]
public function mainLight(
Request $request,
#[Autowire(env: 'STRIPE_WEBHOOK_SECRET')] string $secret,
): Response {
return $this->handleWebhook($request, $secret, 'main_light');
public function mainLight(Request $request): Response
{
return $this->handleWebhook($request, StripeWebhookSecret::TYPE_MAIN_LIGHT);
}
#[Route('/webhooks/stripe/main/instant', name: 'app_webhook_stripe_main_instant', methods: ['POST'])]
public function mainInstant(
Request $request,
#[Autowire(env: 'STRIPE_WEBHOOK_SECRET')] string $secret,
): Response {
return $this->handleWebhook($request, $secret, 'main_instant');
public function mainInstant(Request $request): Response
{
return $this->handleWebhook($request, StripeWebhookSecret::TYPE_MAIN_INSTANT);
}
#[Route('/webhooks/stripe/connect/light', name: 'app_webhook_stripe_connect_light', methods: ['POST'])]
public function connectLight(
Request $request,
#[Autowire(env: 'STRIPE_WEBHOOK_SECRET_CONNECT')] string $secret,
): Response {
return $this->handleWebhook($request, $secret, 'connect_light');
public function connectLight(Request $request): Response
{
return $this->handleWebhook($request, StripeWebhookSecret::TYPE_CONNECT_LIGHT);
}
#[Route('/webhooks/stripe/connect/instant', name: 'app_webhook_stripe_connect_instant', methods: ['POST'])]
public function connectInstant(
Request $request,
#[Autowire(env: 'STRIPE_WEBHOOK_SECRET_CONNECT')] string $secret,
): Response {
return $this->handleWebhook($request, $secret, 'connect_instant');
public function connectInstant(Request $request): Response
{
return $this->handleWebhook($request, StripeWebhookSecret::TYPE_CONNECT_INSTANT);
}
private function handleWebhook(Request $request, string $secret, string $channel): Response
private function handleWebhook(Request $request, string $channel): Response
{
$secret = $this->secretRepository->getSecret($channel);
if (null === $secret) {
$this->logger->warning('Stripe webhook ['.$channel.']: secret non configure');
return new JsonResponse(['error' => 'Webhook not configured'], Response::HTTP_SERVICE_UNAVAILABLE);
}
$payload = $request->getContent();
$sigHeader = $request->headers->get('Stripe-Signature', '');
@@ -69,7 +71,6 @@ class WebhookStripeController extends AbstractController
]);
// TODO: dispatcher les evenements vers les handlers specifiques
// Ex: match ($event->type) { 'invoice.paid' => ..., }
return new JsonResponse(['status' => 'ok', 'channel' => $channel, 'event' => $event->type]);
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Entity;
use App\Repository\StripeWebhookSecretRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: StripeWebhookSecretRepository::class)]
class StripeWebhookSecret
{
public const TYPE_MAIN_LIGHT = 'main_light';
public const TYPE_MAIN_INSTANT = 'main_instant';
public const TYPE_CONNECT_LIGHT = 'connect_light';
public const TYPE_CONNECT_INSTANT = 'connect_instant';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 30, unique: true)]
private string $type;
#[ORM\Column(length: 255)]
private string $secret;
#[ORM\Column(length: 255, nullable: true)]
private ?string $endpointId = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct(string $type, string $secret, ?string $endpointId = null)
{
$this->type = $type;
$this->secret = $secret;
$this->endpointId = $endpointId;
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getType(): string
{
return $this->type;
}
public function getSecret(): string
{
return $this->secret;
}
public function setSecret(string $secret): void
{
$this->secret = $secret;
}
public function getEndpointId(): ?string
{
return $this->endpointId;
}
public function setEndpointId(?string $endpointId): void
{
$this->endpointId = $endpointId;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Repository;
use App\Entity\StripeWebhookSecret;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<StripeWebhookSecret>
*/
class StripeWebhookSecretRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, StripeWebhookSecret::class);
}
public function findByType(string $type): ?StripeWebhookSecret
{
return $this->findOneBy(['type' => $type]);
}
public function getSecret(string $type): ?string
{
$entity = $this->findByType($type);
return $entity?->getSecret();
}
}

View File

@@ -131,11 +131,7 @@
<p class="text-xs text-gray-500">Cree les 4 endpoints : main light/instant + connect light/instant</p>
</div>
</div>
<form method="post" action="{{ path('app_admin_sync_stripe_webhooks') }}" class="flex items-end gap-3" data-confirm="Creer les webhooks Stripe ? Les endpoints existants ne seront pas dupliques.">
<div>
<label class="block text-xs font-bold uppercase tracking-wider mb-1 text-gray-600">URL de base</label>
<input type="text" name="base_url" value="{{ app.request.schemeAndHttpHost }}" required class="input-glass px-3 py-2 text-sm font-medium font-mono w-64" placeholder="https://crm.siteconseil.fr">
</div>
<form method="post" action="{{ path('app_admin_sync_stripe_webhooks') }}" data-confirm="Creer les webhooks Stripe ? Les secrets seront sauvegardes dans .env.local">
<button type="submit" class="px-4 py-2 btn-glass text-blue-600 font-bold uppercase text-[10px] tracking-wider">
Creer les webhooks
</button>