Secure /ma-commande URLs with accessToken to prevent brute force

- Add accessToken (32 hex chars) to BilletBuyer, generated at creation
- URLs now: /ma-commande/{orderNumber}/{token} and /ma-commande/{orderNumber}/{token}/billet/{ref}
- Both orderNumber AND token must match to access order page
- Token is random, unpredictable, unique per order
- Migration generates tokens for existing rows

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-21 16:48:24 +01:00
parent a18e6d4414
commit efe7f75994
7 changed files with 48 additions and 10 deletions

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260321240000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add access_token to billet_buyer';
}
public function up(Schema $schema): void
{
$this->addSql("ALTER TABLE billet_buyer ADD COLUMN IF NOT EXISTS access_token VARCHAR(32) DEFAULT '' NOT NULL");
$this->addSql("UPDATE billet_buyer SET access_token = md5(random()::text) WHERE access_token = ''");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE billet_buyer DROP COLUMN IF EXISTS access_token');
}
}

View File

@@ -211,10 +211,10 @@ class OrderController extends AbstractController
]);
}
#[Route('/ma-commande/{reference}', name: 'app_order_public', requirements: ['reference' => self::REF_PATTERN], methods: ['GET'])]
public function publicOrder(string $reference, EntityManagerInterface $em): Response
#[Route('/ma-commande/{orderNumber}/{token}', name: 'app_order_public', methods: ['GET'])]
public function publicOrder(string $orderNumber, string $token, EntityManagerInterface $em): Response
{
$order = $em->getRepository(BilletBuyer::class)->findOneBy(['reference' => $reference]);
$order = $em->getRepository(BilletBuyer::class)->findOneBy(['orderNumber' => $orderNumber, 'accessToken' => $token]);
if (!$order) {
throw $this->createNotFoundException();
}
@@ -226,15 +226,15 @@ class OrderController extends AbstractController
'tickets' => $tickets,
'breadcrumbs' => [
['name' => 'Accueil', 'url' => '/'],
['name' => 'Commande '.$order->getReference()],
['name' => 'Commande '.$order->getOrderNumber()],
],
]);
}
#[Route('/ma-commande/{reference}/billet/{ticketReference}', name: 'app_order_download_ticket', requirements: ['reference' => self::REF_PATTERN, 'ticketReference' => self::REF_PATTERN], methods: ['GET'])]
public function downloadTicket(string $reference, string $ticketReference, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response
#[Route('/ma-commande/{orderNumber}/{token}/billet/{ticketReference}', name: 'app_order_download_ticket', requirements: ['ticketReference' => self::REF_PATTERN], methods: ['GET'])]
public function downloadTicket(string $orderNumber, string $token, string $ticketReference, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response
{
$order = $em->getRepository(BilletBuyer::class)->findOneBy(['reference' => $reference]);
$order = $em->getRepository(BilletBuyer::class)->findOneBy(['orderNumber' => $orderNumber, 'accessToken' => $token]);
if (!$order) {
throw $this->createNotFoundException();
}

View File

@@ -42,6 +42,9 @@ class BilletBuyer
#[ORM\Column(length: 20, unique: true, nullable: true)]
private ?string $orderNumber = null;
#[ORM\Column(length: 32)]
private string $accessToken;
#[ORM\Column]
private int $totalHT = 0;
@@ -64,6 +67,7 @@ class BilletBuyer
public function __construct()
{
$this->reference = self::generateReference();
$this->accessToken = bin2hex(random_bytes(16));
$this->createdAt = new \DateTimeImmutable();
$this->items = new ArrayCollection();
}
@@ -158,6 +162,11 @@ class BilletBuyer
return $this;
}
public function getAccessToken(): string
{
return $this->accessToken;
}
public function getTotalHT(): int
{
return $this->totalHT;

View File

@@ -151,7 +151,8 @@ class BilletOrderService
}
$orderUrl = $this->urlGenerator->generate('app_order_public', [
'reference' => $order->getReference(),
'orderNumber' => $order->getOrderNumber(),
'token' => $order->getAccessToken(),
], UrlGeneratorInterface::ABSOLUTE_URL);
$html = $this->twig->render('email/order_confirmation.html.twig', [

View File

@@ -62,7 +62,7 @@
{% else %}
<span class="badge-red text-[10px] font-black uppercase">Invalide</span>
{% endif %}
<a href="{{ path('app_order_download_ticket', {reference: order.reference, ticketReference: ticket.reference}) }}" class="px-3 py-1 border-2 border-gray-900 bg-white text-xs font-black uppercase hover:bg-indigo-600 hover:text-white transition-all" target="_blank">
<a href="{{ path('app_order_download_ticket', {orderNumber: order.orderNumber, token: order.accessToken, ticketReference: ticket.reference}) }}" class="px-3 py-1 border-2 border-gray-900 bg-white text-xs font-black uppercase hover:bg-indigo-600 hover:text-white transition-all" target="_blank">
Telecharger PDF
</a>
</div>

View File

@@ -36,7 +36,7 @@
<p class="text-xs font-bold text-gray-400 mb-6">Vos billets ont ete envoyes a {{ order.email }}</p>
<div class="flex flex-col gap-3">
<a href="{{ path('app_order_public', {reference: order.reference}) }}" class="btn-brutal font-black uppercase text-sm tracking-widest hover:bg-indigo-600 hover:text-white transition-all">
<a href="{{ path('app_order_public', {orderNumber: order.orderNumber, token: order.accessToken}) }}" class="btn-brutal font-black uppercase text-sm tracking-widest hover:bg-indigo-600 hover:text-white transition-all">
Voir ma commande
</a>
<a href="{{ path('app_home') }}" class="text-sm font-bold text-gray-500 hover:text-gray-900 transition-colors">

View File

@@ -26,6 +26,7 @@ class BilletBuyerTest extends TestCase
self::assertNull($buyer->getStripeSessionId());
self::assertNull($buyer->getPaidAt());
self::assertMatchesRegularExpression('/^ETICKET-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/', $buyer->getReference());
self::assertSame(32, \strlen($buyer->getAccessToken()));
self::assertInstanceOf(\DateTimeImmutable::class, $buyer->getCreatedAt());
self::assertCount(0, $buyer->getItems());
}