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