diff --git a/data/sandbox/fixtures.json b/data/sandbox/fixtures.json new file mode 100644 index 0000000..2275a9a --- /dev/null +++ b/data/sandbox/fixtures.json @@ -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": {} + } + } +} diff --git a/src/Controller/Api/ApiAuthController.php b/src/Controller/Api/ApiAuthController.php new file mode 100644 index 0000000..6de8a0b --- /dev/null +++ b/src/Controller/Api/ApiAuthController.php @@ -0,0 +1,118 @@ +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) ?: ''; + } +} diff --git a/src/Controller/Api/ApiLiveController.php b/src/Controller/Api/ApiLiveController.php new file mode 100644 index 0000000..8fd9d7b --- /dev/null +++ b/src/Controller/Api/ApiLiveController.php @@ -0,0 +1,11 @@ +