Create API controllers structure with JWT auth and sandbox fixtures
Controllers: - ApiAuthController: POST /api/auth/login with JWT generation (HS256, 24h TTL) - Validates email + password against DB - Returns JWT token with userId, email, roles, iat, exp - Static verifyJwt() for use by live/sandbox controllers - Only ROLE_ORGANIZER can authenticate - ApiLiveController: empty shell at /api/live (routes to implement) - ApiSandboxController: empty shell at /api/sandbox (routes to implement) Auth is shared: one /api/auth/login for both environments using real credentials. Sandbox fixtures (data/sandbox/fixtures.json): - 2 events (Brocante + Convention Cosplay) - 4 categories across events - 6 billets with varied types (billet, reservation_brocante) - 6 billet details with descriptions, images, categories, events - 4 scan results (2 accepted, 2 refused with different reasons) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
296
data/sandbox/fixtures.json
Normal file
296
data/sandbox/fixtures.json
Normal file
@@ -0,0 +1,296 @@
|
||||
{
|
||||
"events": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Brocante de Printemps 2026",
|
||||
"description": "Grande brocante annuelle en plein air avec plus de 200 exposants.",
|
||||
"startAt": "2026-06-15T08:00:00",
|
||||
"endAt": "2026-06-15T18:00:00",
|
||||
"address": "Place de la Republique",
|
||||
"zipcode": "75003",
|
||||
"city": "Paris",
|
||||
"isOnline": true,
|
||||
"isSecret": false,
|
||||
"imageUrl": null
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Convention Cosplay Ete",
|
||||
"description": "Convention cosplay avec concours, stands et animations.",
|
||||
"startAt": "2026-08-20T10:00:00",
|
||||
"endAt": "2026-08-21T19:00:00",
|
||||
"address": "Parc des Expositions",
|
||||
"zipcode": "02800",
|
||||
"city": "Beautor",
|
||||
"isOnline": true,
|
||||
"isSecret": false,
|
||||
"imageUrl": "https://ticket.e-cosplay.fr/demo/convention.jpg"
|
||||
}
|
||||
],
|
||||
"categories": {
|
||||
"1": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Entrees",
|
||||
"position": 0,
|
||||
"startAt": "2026-05-01T00:00:00",
|
||||
"endAt": "2026-06-15T18:00:00",
|
||||
"isHidden": false,
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Reservations Brocante",
|
||||
"position": 1,
|
||||
"startAt": "2026-04-01T00:00:00",
|
||||
"endAt": "2026-06-10T23:59:00",
|
||||
"isHidden": false,
|
||||
"isActive": true
|
||||
}
|
||||
],
|
||||
"2": [
|
||||
{
|
||||
"id": 3,
|
||||
"name": "General",
|
||||
"position": 0,
|
||||
"startAt": "2026-07-01T00:00:00",
|
||||
"endAt": "2026-08-21T19:00:00",
|
||||
"isHidden": false,
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "VIP",
|
||||
"position": 1,
|
||||
"startAt": "2026-07-01T00:00:00",
|
||||
"endAt": "2026-08-15T23:59:00",
|
||||
"isHidden": false,
|
||||
"isActive": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"billets": {
|
||||
"1": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Entree Adulte",
|
||||
"priceHT": 500,
|
||||
"quantity": 500,
|
||||
"sold": 312,
|
||||
"type": "billet",
|
||||
"isGeneratedBillet": true,
|
||||
"notBuyable": false,
|
||||
"position": 0
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Entree Enfant (-12 ans)",
|
||||
"priceHT": 0,
|
||||
"quantity": null,
|
||||
"sold": 145,
|
||||
"type": "billet",
|
||||
"isGeneratedBillet": true,
|
||||
"notBuyable": false,
|
||||
"position": 1
|
||||
}
|
||||
],
|
||||
"2": [
|
||||
{
|
||||
"id": 3,
|
||||
"name": "Emplacement 3m",
|
||||
"priceHT": 1500,
|
||||
"quantity": 80,
|
||||
"sold": 72,
|
||||
"type": "reservation_brocante",
|
||||
"isGeneratedBillet": false,
|
||||
"notBuyable": false,
|
||||
"position": 0
|
||||
}
|
||||
],
|
||||
"3": [
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Pass 2 jours",
|
||||
"priceHT": 2000,
|
||||
"quantity": 300,
|
||||
"sold": 189,
|
||||
"type": "billet",
|
||||
"isGeneratedBillet": true,
|
||||
"notBuyable": false,
|
||||
"position": 0
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Pass 1 jour Samedi",
|
||||
"priceHT": 1200,
|
||||
"quantity": 200,
|
||||
"sold": 156,
|
||||
"type": "billet",
|
||||
"isGeneratedBillet": true,
|
||||
"notBuyable": false,
|
||||
"position": 1
|
||||
}
|
||||
],
|
||||
"4": [
|
||||
{
|
||||
"id": 6,
|
||||
"name": "Pass VIP 2 jours",
|
||||
"priceHT": 5000,
|
||||
"quantity": 30,
|
||||
"sold": 28,
|
||||
"type": "billet",
|
||||
"isGeneratedBillet": true,
|
||||
"notBuyable": false,
|
||||
"position": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"billetDetails": {
|
||||
"1": {
|
||||
"id": 1,
|
||||
"name": "Entree Adulte",
|
||||
"description": "Entree standard pour les adultes. Billet electronique avec QR code.",
|
||||
"priceHT": 500,
|
||||
"quantity": 500,
|
||||
"sold": 312,
|
||||
"type": "billet",
|
||||
"isGeneratedBillet": true,
|
||||
"hasDefinedExit": false,
|
||||
"notBuyable": false,
|
||||
"position": 0,
|
||||
"imageUrl": null,
|
||||
"category": {"id": 1, "name": "Entrees"},
|
||||
"event": {"id": 1, "title": "Brocante de Printemps 2026"}
|
||||
},
|
||||
"2": {
|
||||
"id": 2,
|
||||
"name": "Entree Enfant (-12 ans)",
|
||||
"description": "Gratuit pour les enfants de moins de 12 ans.",
|
||||
"priceHT": 0,
|
||||
"quantity": null,
|
||||
"sold": 145,
|
||||
"type": "billet",
|
||||
"isGeneratedBillet": true,
|
||||
"hasDefinedExit": false,
|
||||
"notBuyable": false,
|
||||
"position": 1,
|
||||
"imageUrl": null,
|
||||
"category": {"id": 1, "name": "Entrees"},
|
||||
"event": {"id": 1, "title": "Brocante de Printemps 2026"}
|
||||
},
|
||||
"3": {
|
||||
"id": 3,
|
||||
"name": "Emplacement 3m",
|
||||
"description": "Emplacement de 3 metres lineaires pour exposant.",
|
||||
"priceHT": 1500,
|
||||
"quantity": 80,
|
||||
"sold": 72,
|
||||
"type": "reservation_brocante",
|
||||
"isGeneratedBillet": false,
|
||||
"hasDefinedExit": false,
|
||||
"notBuyable": false,
|
||||
"position": 0,
|
||||
"imageUrl": null,
|
||||
"category": {"id": 2, "name": "Reservations Brocante"},
|
||||
"event": {"id": 1, "title": "Brocante de Printemps 2026"}
|
||||
},
|
||||
"4": {
|
||||
"id": 4,
|
||||
"name": "Pass 2 jours",
|
||||
"description": "Acces aux 2 jours de la convention.",
|
||||
"priceHT": 2000,
|
||||
"quantity": 300,
|
||||
"sold": 189,
|
||||
"type": "billet",
|
||||
"isGeneratedBillet": true,
|
||||
"hasDefinedExit": true,
|
||||
"notBuyable": false,
|
||||
"position": 0,
|
||||
"imageUrl": "https://ticket.e-cosplay.fr/demo/pass-2j.jpg",
|
||||
"category": {"id": 3, "name": "General"},
|
||||
"event": {"id": 2, "title": "Convention Cosplay Ete"}
|
||||
},
|
||||
"5": {
|
||||
"id": 5,
|
||||
"name": "Pass 1 jour Samedi",
|
||||
"description": "Acces le samedi uniquement.",
|
||||
"priceHT": 1200,
|
||||
"quantity": 200,
|
||||
"sold": 156,
|
||||
"type": "billet",
|
||||
"isGeneratedBillet": true,
|
||||
"hasDefinedExit": true,
|
||||
"notBuyable": false,
|
||||
"position": 1,
|
||||
"imageUrl": null,
|
||||
"category": {"id": 3, "name": "General"},
|
||||
"event": {"id": 2, "title": "Convention Cosplay Ete"}
|
||||
},
|
||||
"6": {
|
||||
"id": 6,
|
||||
"name": "Pass VIP 2 jours",
|
||||
"description": "Acces VIP avec espace dedie, boissons et rencontre artistes.",
|
||||
"priceHT": 5000,
|
||||
"quantity": 30,
|
||||
"sold": 28,
|
||||
"type": "billet",
|
||||
"isGeneratedBillet": true,
|
||||
"hasDefinedExit": true,
|
||||
"notBuyable": false,
|
||||
"position": 0,
|
||||
"imageUrl": "https://ticket.e-cosplay.fr/demo/vip.jpg",
|
||||
"category": {"id": 4, "name": "VIP"},
|
||||
"event": {"id": 2, "title": "Convention Cosplay Ete"}
|
||||
}
|
||||
},
|
||||
"scan": {
|
||||
"ETICKET-DEMO-0001-AAAA": {
|
||||
"state": "accepted",
|
||||
"reason": null,
|
||||
"reference": "ETICKET-DEMO-0001-AAAA",
|
||||
"billetName": "Pass 2 jours",
|
||||
"buyerFirstName": "Jean",
|
||||
"buyerLastName": "Dupont",
|
||||
"isInvitation": false,
|
||||
"firstScannedAt": null,
|
||||
"hasDefinedExit": true,
|
||||
"details": {}
|
||||
},
|
||||
"ETICKET-DEMO-0002-BBBB": {
|
||||
"state": "refused",
|
||||
"reason": "already_scanned",
|
||||
"reference": "ETICKET-DEMO-0002-BBBB",
|
||||
"billetName": "Entree Adulte",
|
||||
"buyerFirstName": "Marie",
|
||||
"buyerLastName": "Martin",
|
||||
"isInvitation": false,
|
||||
"firstScannedAt": "2026-06-15T09:30:00",
|
||||
"hasDefinedExit": false,
|
||||
"details": null
|
||||
},
|
||||
"ETICKET-DEMO-0003-CCCC": {
|
||||
"state": "refused",
|
||||
"reason": "invalid",
|
||||
"reference": "ETICKET-DEMO-0003-CCCC",
|
||||
"billetName": "Pass VIP 2 jours",
|
||||
"buyerFirstName": "Pierre",
|
||||
"buyerLastName": "Durand",
|
||||
"isInvitation": false,
|
||||
"firstScannedAt": null,
|
||||
"hasDefinedExit": true,
|
||||
"details": null
|
||||
},
|
||||
"ETICKET-DEMO-0004-DDDD": {
|
||||
"state": "accepted",
|
||||
"reason": null,
|
||||
"reference": "ETICKET-DEMO-0004-DDDD",
|
||||
"billetName": "Pass 1 jour Samedi",
|
||||
"buyerFirstName": "Sophie",
|
||||
"buyerLastName": "Bernard",
|
||||
"isInvitation": true,
|
||||
"firstScannedAt": null,
|
||||
"hasDefinedExit": true,
|
||||
"details": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
118
src/Controller/Api/ApiAuthController.php
Normal file
118
src/Controller/Api/ApiAuthController.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Api;
|
||||
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
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\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/auth')]
|
||||
class ApiAuthController extends AbstractController
|
||||
{
|
||||
private const JWT_TTL = 86400; // 24h
|
||||
|
||||
#[Route('/login', name: 'app_api_auth_login', methods: ['POST'])]
|
||||
public function login(
|
||||
Request $request,
|
||||
EntityManagerInterface $em,
|
||||
UserPasswordHasherInterface $passwordHasher,
|
||||
#[Autowire('%kernel.secret%')] string $appSecret,
|
||||
): JsonResponse {
|
||||
$data = json_decode($request->getContent(), true);
|
||||
$email = $data['email'] ?? '';
|
||||
$password = $data['password'] ?? '';
|
||||
|
||||
if ('' === $email || '' === $password) {
|
||||
return $this->json(['success' => false, 'data' => null, 'error' => 'Email et mot de passe requis.'], 400);
|
||||
}
|
||||
|
||||
$user = $em->getRepository(User::class)->findOneBy(['email' => $email]);
|
||||
|
||||
if (!$user || !$passwordHasher->isPasswordValid($user, $password)) {
|
||||
return $this->json(['success' => false, 'data' => null, 'error' => 'Identifiants invalides.'], 401);
|
||||
}
|
||||
|
||||
if (!\in_array('ROLE_ORGANIZER', $user->getRoles(), true)) {
|
||||
return $this->json(['success' => false, 'data' => null, 'error' => 'Acces reserve aux organisateurs.'], 403);
|
||||
}
|
||||
|
||||
$token = $this->generateJwt($user, $appSecret);
|
||||
$expiresAt = (new \DateTimeImmutable())->modify('+'.self::JWT_TTL.' seconds');
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'token' => $token,
|
||||
'expiresAt' => $expiresAt->format(\DateTimeInterface::ATOM),
|
||||
],
|
||||
'error' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function verifyJwt(string $token, string $email, string $appSecret): ?int
|
||||
{
|
||||
$parts = explode('.', $token);
|
||||
if (3 !== \count($parts)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$headerB64, $payloadB64, $signatureB64] = $parts;
|
||||
|
||||
$expectedSignature = self::base64UrlEncode(
|
||||
hash_hmac('sha256', $headerB64.'.'.$payloadB64, $appSecret, true)
|
||||
);
|
||||
|
||||
if (!hash_equals($expectedSignature, $signatureB64)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = json_decode(self::base64UrlDecode($payloadB64), true);
|
||||
if (!$payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (($payload['exp'] ?? 0) < time()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (($payload['email'] ?? '') !== $email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $payload['userId'] ?? null;
|
||||
}
|
||||
|
||||
private function generateJwt(User $user, string $appSecret): string
|
||||
{
|
||||
$header = self::base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
|
||||
|
||||
$payload = self::base64UrlEncode(json_encode([
|
||||
'userId' => $user->getId(),
|
||||
'email' => $user->getEmail(),
|
||||
'roles' => $user->getRoles(),
|
||||
'iat' => time(),
|
||||
'exp' => time() + self::JWT_TTL,
|
||||
]));
|
||||
|
||||
$signature = self::base64UrlEncode(
|
||||
hash_hmac('sha256', $header.'.'.$payload, $appSecret, true)
|
||||
);
|
||||
|
||||
return $header.'.'.$payload.'.'.$signature;
|
||||
}
|
||||
|
||||
private static function base64UrlEncode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private static function base64UrlDecode(string $data): string
|
||||
{
|
||||
return base64_decode(strtr($data, '-_', '+/'), true) ?: '';
|
||||
}
|
||||
}
|
||||
11
src/Controller/Api/ApiLiveController.php
Normal file
11
src/Controller/Api/ApiLiveController.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Api;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/live')]
|
||||
class ApiLiveController extends AbstractController
|
||||
{
|
||||
}
|
||||
11
src/Controller/Api/ApiSandboxController.php
Normal file
11
src/Controller/Api/ApiSandboxController.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Api;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/sandbox')]
|
||||
class ApiSandboxController extends AbstractController
|
||||
{
|
||||
}
|
||||
Reference in New Issue
Block a user