Add security key to BilletOrder, QR code helper text

- securityKey: HMAC-SHA256(reference, APP_SECRET) truncated to 16 hex chars
- Generated automatically at ticket creation via BilletOrderService
- Deterministic: same reference + secret = same key, verifiable server-side
- Cannot be forged without knowing APP_SECRET
- PDF: "Presentez ce QR code pour valider votre ticket" under QR code
- PDF: "Cle de securite" displayed with letter-spacing
- Tests: generateSecurityKey determinism, uniqueness, format

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-21 16:43:59 +01:00
parent 6cd91a7c8e
commit b0dead8120
5 changed files with 76 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260321230000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add security_key to billet_order';
}
public function up(Schema $schema): void
{
$this->addSql("ALTER TABLE billet_order ADD COLUMN IF NOT EXISTS security_key VARCHAR(16) DEFAULT '' NOT NULL");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE billet_order DROP COLUMN IF EXISTS security_key');
}
}

View File

@@ -27,6 +27,9 @@ class BilletOrder
#[ORM\Column(length: 255)]
private ?string $billetName = null;
#[ORM\Column(length: 16)]
private string $securityKey = '';
#[ORM\Column]
private int $unitPriceHT = 0;
@@ -98,6 +101,23 @@ class BilletOrder
return $this;
}
public function getSecurityKey(): string
{
return $this->securityKey;
}
public function setSecurityKey(string $securityKey): static
{
$this->securityKey = $securityKey;
return $this;
}
public static function generateSecurityKey(string $reference, string $appSecret): string
{
return strtoupper(substr(hash_hmac('sha256', $reference, $appSecret), 0, 16));
}
public function getUnitPriceHT(): int
{
return $this->unitPriceHT;

View File

@@ -25,6 +25,7 @@ class BilletOrderService
private MailerService $mailer,
private UrlGeneratorInterface $urlGenerator,
#[Autowire('%kernel.project_dir%')] private string $projectDir,
#[Autowire('%kernel.secret%')] private string $appSecret,
) {
}
@@ -37,6 +38,7 @@ class BilletOrderService
$ticket->setBillet($item->getBillet());
$ticket->setBilletName($item->getBilletName());
$ticket->setUnitPriceHT($item->getUnitPriceHT());
$ticket->setSecurityKey(BilletOrder::generateSecurityKey($ticket->getReference(), $this->appSecret));
$this->em->persist($ticket);
}

View File

@@ -306,6 +306,7 @@
<tr>
<td style="width: 140px;">
<img src="{{ qrBase64 }}" alt="QR" class="qr-img">
<div style="font-size: 6px; font-weight: bold; text-align: center; color: #999; margin-top: 4px; text-transform: uppercase;">Presentez ce QR code<br>pour valider votre ticket</div>
</td>
<td style="text-align: right;">
<div class="ref-lbl">Reference billet</div>
@@ -313,6 +314,9 @@
<br>
<div class="ref-lbl">Commande</div>
<div class="ref-val">{{ order.orderNumber }}</div>
<br>
<div class="ref-lbl">Cle de securite</div>
<div class="ref-val" style="letter-spacing: 2px;">{{ ticket.securityKey }}</div>
</td>
</tr>
</table>

View File

@@ -21,6 +21,7 @@ class BilletOrderTest extends TestCase
self::assertSame(0.0, $ticket->getUnitPriceHTDecimal());
self::assertSame(BilletOrder::STATE_VALID, $ticket->getState());
self::assertTrue($ticket->isValid());
self::assertSame('', $ticket->getSecurityKey());
self::assertNull($ticket->isInvitation());
self::assertNull($ticket->getFirstScannedAt());
self::assertMatchesRegularExpression('/^ETICKET-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/', $ticket->getReference());
@@ -83,6 +84,29 @@ class BilletOrderTest extends TestCase
self::assertTrue($ticket->isValid());
}
public function testSetAndGetSecurityKey(): void
{
$ticket = new BilletOrder();
$result = $ticket->setSecurityKey('ABC123');
self::assertSame('ABC123', $ticket->getSecurityKey());
self::assertSame($ticket, $result);
}
public function testGenerateSecurityKey(): void
{
$key = BilletOrder::generateSecurityKey('ETICKET-ABCD-1234-EFGH', 'my-secret');
self::assertSame(16, \strlen($key));
self::assertMatchesRegularExpression('/^[A-F0-9]{16}$/', $key);
$key2 = BilletOrder::generateSecurityKey('ETICKET-ABCD-1234-EFGH', 'my-secret');
self::assertSame($key, $key2);
$key3 = BilletOrder::generateSecurityKey('ETICKET-XXXX-YYYY-ZZZZ', 'my-secret');
self::assertNotSame($key, $key3);
}
public function testSetAndGetIsInvitation(): void
{
$ticket = new BilletOrder();