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:
27
migrations/Version20260321240000.php
Normal file
27
migrations/Version20260321240000.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -211,10 +211,10 @@ class OrderController extends AbstractController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/ma-commande/{reference}', name: 'app_order_public', requirements: ['reference' => self::REF_PATTERN], methods: ['GET'])]
|
#[Route('/ma-commande/{orderNumber}/{token}', name: 'app_order_public', methods: ['GET'])]
|
||||||
public function publicOrder(string $reference, EntityManagerInterface $em): Response
|
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) {
|
if (!$order) {
|
||||||
throw $this->createNotFoundException();
|
throw $this->createNotFoundException();
|
||||||
}
|
}
|
||||||
@@ -226,15 +226,15 @@ class OrderController extends AbstractController
|
|||||||
'tickets' => $tickets,
|
'tickets' => $tickets,
|
||||||
'breadcrumbs' => [
|
'breadcrumbs' => [
|
||||||
['name' => 'Accueil', 'url' => '/'],
|
['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'])]
|
#[Route('/ma-commande/{orderNumber}/{token}/billet/{ticketReference}', name: 'app_order_download_ticket', requirements: ['ticketReference' => self::REF_PATTERN], methods: ['GET'])]
|
||||||
public function downloadTicket(string $reference, string $ticketReference, EntityManagerInterface $em, BilletOrderService $billetOrderService): Response
|
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) {
|
if (!$order) {
|
||||||
throw $this->createNotFoundException();
|
throw $this->createNotFoundException();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ class BilletBuyer
|
|||||||
#[ORM\Column(length: 20, unique: true, nullable: true)]
|
#[ORM\Column(length: 20, unique: true, nullable: true)]
|
||||||
private ?string $orderNumber = null;
|
private ?string $orderNumber = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 32)]
|
||||||
|
private string $accessToken;
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
private int $totalHT = 0;
|
private int $totalHT = 0;
|
||||||
|
|
||||||
@@ -64,6 +67,7 @@ class BilletBuyer
|
|||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->reference = self::generateReference();
|
$this->reference = self::generateReference();
|
||||||
|
$this->accessToken = bin2hex(random_bytes(16));
|
||||||
$this->createdAt = new \DateTimeImmutable();
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
$this->items = new ArrayCollection();
|
$this->items = new ArrayCollection();
|
||||||
}
|
}
|
||||||
@@ -158,6 +162,11 @@ class BilletBuyer
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getAccessToken(): string
|
||||||
|
{
|
||||||
|
return $this->accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
public function getTotalHT(): int
|
public function getTotalHT(): int
|
||||||
{
|
{
|
||||||
return $this->totalHT;
|
return $this->totalHT;
|
||||||
|
|||||||
@@ -151,7 +151,8 @@ class BilletOrderService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$orderUrl = $this->urlGenerator->generate('app_order_public', [
|
$orderUrl = $this->urlGenerator->generate('app_order_public', [
|
||||||
'reference' => $order->getReference(),
|
'orderNumber' => $order->getOrderNumber(),
|
||||||
|
'token' => $order->getAccessToken(),
|
||||||
], UrlGeneratorInterface::ABSOLUTE_URL);
|
], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||||
|
|
||||||
$html = $this->twig->render('email/order_confirmation.html.twig', [
|
$html = $this->twig->render('email/order_confirmation.html.twig', [
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge-red text-[10px] font-black uppercase">Invalide</span>
|
<span class="badge-red text-[10px] font-black uppercase">Invalide</span>
|
||||||
{% endif %}
|
{% 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
|
Telecharger PDF
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
<p class="text-xs font-bold text-gray-400 mb-6">Vos billets ont ete envoyes a {{ order.email }}</p>
|
<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">
|
<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
|
Voir ma commande
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ path('app_home') }}" class="text-sm font-bold text-gray-500 hover:text-gray-900 transition-colors">
|
<a href="{{ path('app_home') }}" class="text-sm font-bold text-gray-500 hover:text-gray-900 transition-colors">
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class BilletBuyerTest extends TestCase
|
|||||||
self::assertNull($buyer->getStripeSessionId());
|
self::assertNull($buyer->getStripeSessionId());
|
||||||
self::assertNull($buyer->getPaidAt());
|
self::assertNull($buyer->getPaidAt());
|
||||||
self::assertMatchesRegularExpression('/^ETICKET-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/', $buyer->getReference());
|
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::assertInstanceOf(\DateTimeImmutable::class, $buyer->getCreatedAt());
|
||||||
self::assertCount(0, $buyer->getItems());
|
self::assertCount(0, $buyer->getItems());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user