Fix SonarQube: deduplicate scan response, inject appSecret via constructor, reduce returns

ApiLiveController:
- Extract buildScanResponse() method (was duplicated 4x for invalid/expired/exit/accepted)
- Use reasonMap pattern for state→reason mapping

ApiAuthController:
- Inject appSecret via constructor (was duplicated '%kernel.secret%' 3x in method params)
- Extract tokenResponse() helper (was duplicated " seconds" pattern 3x)
- Reduce verifyJwt from 6 returns to 3 using INVALID_JWT constant and merged conditions
- tokenResponse(user, includeEmail) handles both login and SSO responses

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-23 20:19:58 +01:00
parent cd3df224e5
commit 6d179eadd4
2 changed files with 56 additions and 105 deletions

View File

@@ -19,12 +19,16 @@ class ApiAuthController extends AbstractController
{
private const JWT_TTL = 86400; // 24h
public function __construct(
#[Autowire('%kernel.secret%')] private string $appSecret,
) {
}
#[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'] ?? '';
@@ -40,60 +44,40 @@ class ApiAuthController extends AbstractController
return $this->json(['success' => false, 'data' => null, 'error' => !$user || !$passwordHasher->isPasswordValid($user, $password) ? 'Identifiants invalides.' : 'Acces reserve aux organisateurs.'], 401);
}
$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,
]);
return $this->tokenResponse($user);
}
/**
* @return array{userId: int|null, expired: bool}
*/
private const INVALID_JWT = ['userId' => null, 'expired' => false];
public static function verifyJwt(string $token, string $email, string $appSecret): array
{
$parts = explode('.', $token);
if (3 !== \count($parts)) {
return ['userId' => null, 'expired' => false];
return self::INVALID_JWT;
}
[$headerB64, $payloadB64, $signatureB64] = $parts;
$expectedSignature = self::base64UrlEncode(
hash_hmac('sha256', $headerB64.'.'.$payloadB64, $appSecret, true)
);
$expectedSignature = self::base64UrlEncode(hash_hmac('sha256', $headerB64.'.'.$payloadB64, $appSecret, true));
$payload = hash_equals($expectedSignature, $signatureB64) ? json_decode(self::base64UrlDecode($payloadB64), true) : null;
if (!hash_equals($expectedSignature, $signatureB64)) {
return ['userId' => null, 'expired' => false];
if (!$payload || ($payload['email'] ?? '') !== $email) {
return self::INVALID_JWT;
}
$payload = json_decode(self::base64UrlDecode($payloadB64), true);
if (!$payload) {
return ['userId' => null, 'expired' => false];
}
if (($payload['email'] ?? '') !== $email) {
return ['userId' => null, 'expired' => false];
}
if (($payload['exp'] ?? 0) < time()) {
return ['userId' => $payload['userId'] ?? null, 'expired' => true];
}
return ['userId' => $payload['userId'] ?? null, 'expired' => false];
return [
'userId' => $payload['userId'] ?? null,
'expired' => ($payload['exp'] ?? 0) < time(),
];
}
#[Route('/refresh', name: 'app_api_auth_refresh', methods: ['POST'])]
public function refresh(
Request $request,
EntityManagerInterface $em,
#[Autowire('%kernel.secret%')] string $appSecret,
): JsonResponse {
$email = $request->headers->get('ETicket-Email', '');
$jwt = $request->headers->get('ETicket-JWT', '');
@@ -102,7 +86,7 @@ class ApiAuthController extends AbstractController
return $this->json(['success' => false, 'data' => null, 'error' => 'Headers ETicket-Email et ETicket-JWT requis.'], 401);
}
$result = self::verifyJwt($jwt, $email, $appSecret);
$result = self::verifyJwt($jwt, $email, $this->appSecret);
if (null === $result['userId'] || !$result['expired']) {
$error = null === $result['userId'] ? 'Token invalide.' : 'Token encore valide, pas besoin de refresh.';
@@ -115,17 +99,7 @@ class ApiAuthController extends AbstractController
return $this->json(['success' => false, 'data' => null, 'error' => 'Utilisateur introuvable.'], 401);
}
$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,
]);
return $this->tokenResponse($user);
}
#[Route('/login/sso', name: 'app_api_auth_sso', methods: ['GET'])]
@@ -144,7 +118,6 @@ class ApiAuthController extends AbstractController
public function ssoValidate(
ClientRegistry $clientRegistry,
EntityManagerInterface $em,
#[Autowire('%kernel.secret%')] string $appSecret,
): JsonResponse {
try {
$client = $clientRegistry->getClient('keycloak');
@@ -171,21 +144,23 @@ class ApiAuthController extends AbstractController
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),
'email' => $user->getEmail(),
],
'error' => null,
]);
return $this->tokenResponse($user, true);
}
private function generateJwt(User $user, string $appSecret): string
private function tokenResponse(User $user, bool $includeEmail = false): JsonResponse
{
$token = $this->generateJwt($user);
$expiresAt = (new \DateTimeImmutable())->modify('+'.self::JWT_TTL.' seconds');
$data = ['token' => $token, 'expiresAt' => $expiresAt->format(\DateTimeInterface::ATOM)];
if ($includeEmail) {
$data['email'] = $user->getEmail();
}
return $this->json(['success' => true, 'data' => $data, 'error' => null]);
}
private function generateJwt(User $user): string
{
$header = self::base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
@@ -198,7 +173,7 @@ class ApiAuthController extends AbstractController
]));
$signature = self::base64UrlEncode(
hash_hmac('sha256', $header.'.'.$payload, $appSecret, true)
hash_hmac('sha256', $header.'.'.$payload, $this->appSecret, true)
);
return $header.'.'.$payload.'.'.$signature;

View File

@@ -210,51 +210,19 @@ class ApiLiveController extends AbstractController
return $this->error('Billet introuvable.', 404);
}
if (BilletOrder::STATE_INVALID === $ticket->getState()) {
return $this->success([
'state' => 'refused',
'reason' => 'invalid',
'reference' => $ticket->getReference(),
'billetName' => $ticket->getBilletName(),
'buyerFirstName' => $ticket->getBilletBuyer()->getFirstName(),
'buyerLastName' => $ticket->getBilletBuyer()->getLastName(),
'isInvitation' => (bool) $ticket->isInvitation(),
'firstScannedAt' => $ticket->getFirstScannedAt()?->format(\DateTimeInterface::ATOM),
'hasDefinedExit' => $ticket->getBillet()?->hasDefinedExit() ?? false,
'details' => null,
]);
}
$reasonMap = [
BilletOrder::STATE_INVALID => 'invalid',
BilletOrder::STATE_EXPIRED => 'expired',
];
if (BilletOrder::STATE_EXPIRED === $ticket->getState()) {
return $this->success([
'state' => 'refused',
'reason' => 'expired',
'reference' => $ticket->getReference(),
'billetName' => $ticket->getBilletName(),
'buyerFirstName' => $ticket->getBilletBuyer()->getFirstName(),
'buyerLastName' => $ticket->getBilletBuyer()->getLastName(),
'isInvitation' => (bool) $ticket->isInvitation(),
'firstScannedAt' => $ticket->getFirstScannedAt()?->format(\DateTimeInterface::ATOM),
'hasDefinedExit' => $ticket->getBillet()?->hasDefinedExit() ?? false,
'details' => null,
]);
if (isset($reasonMap[$ticket->getState()])) {
return $this->success($this->buildScanResponse('refused', $reasonMap[$ticket->getState()], $ticket));
}
$hasDefinedExit = $ticket->getBillet()?->hasDefinedExit() ?? false;
if (null !== $ticket->getFirstScannedAt() && $hasDefinedExit) {
return $this->success([
'state' => 'refused',
'reason' => 'exit_definitive',
'reference' => $ticket->getReference(),
'billetName' => $ticket->getBilletName(),
'buyerFirstName' => $ticket->getBilletBuyer()->getFirstName(),
'buyerLastName' => $ticket->getBilletBuyer()->getLastName(),
'isInvitation' => (bool) $ticket->isInvitation(),
'firstScannedAt' => $ticket->getFirstScannedAt()->format(\DateTimeInterface::ATOM),
'hasDefinedExit' => true,
'details' => null,
]);
return $this->success($this->buildScanResponse('refused', 'exit_definitive', $ticket));
}
if (null === $ticket->getFirstScannedAt()) {
@@ -262,17 +230,25 @@ class ApiLiveController extends AbstractController
$em->flush();
}
return $this->success([
'state' => 'accepted',
'reason' => null,
return $this->success($this->buildScanResponse('accepted', null, $ticket));
}
/**
* @return array<string, mixed>
*/
private function buildScanResponse(string $state, ?string $reason, BilletOrder $ticket): array
{
return [
'state' => $state,
'reason' => $reason,
'reference' => $ticket->getReference(),
'billetName' => $ticket->getBilletName(),
'buyerFirstName' => $ticket->getBilletBuyer()->getFirstName(),
'buyerLastName' => $ticket->getBilletBuyer()->getLastName(),
'isInvitation' => (bool) $ticket->isInvitation(),
'firstScannedAt' => $ticket->getFirstScannedAt()?->format(\DateTimeInterface::ATOM),
'hasDefinedExit' => $hasDefinedExit,
'details' => [],
]);
'hasDefinedExit' => $ticket->getBillet()?->hasDefinedExit() ?? false,
'details' => 'accepted' === $state ? [] : null,
];
}
}