Add JWT refresh route POST /api/auth/refresh

- verifyJwt now returns {userId, expired} instead of just userId
- Expired token with valid signature can be refreshed (new 24h token)
- POST /api/auth/refresh: send expired token in ETicket-JWT header → get new token
- Returns 400 if token is still valid (no need to refresh)
- Returns 401 if signature invalid or user not found
- API doc: refresh endpoint documented with statuses
- Insomnia: both login and refresh auto-store jwt_token via afterResponseScript

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-23 19:45:10 +01:00
parent 085967f57f
commit 8602d60655
2 changed files with 73 additions and 11 deletions

View File

@@ -54,11 +54,14 @@ class ApiAuthController extends AbstractController
]);
}
public static function verifyJwt(string $token, string $email, string $appSecret): ?int
/**
* @return array{userId: int|null, expired: bool}
*/
public static function verifyJwt(string $token, string $email, string $appSecret): array
{
$parts = explode('.', $token);
if (3 !== \count($parts)) {
return null;
return ['userId' => null, 'expired' => false];
}
[$headerB64, $payloadB64, $signatureB64] = $parts;
@@ -68,23 +71,64 @@ class ApiAuthController extends AbstractController
);
if (!hash_equals($expectedSignature, $signatureB64)) {
return null;
return ['userId' => null, 'expired' => false];
}
$payload = json_decode(self::base64UrlDecode($payloadB64), true);
if (!$payload) {
return null;
}
if (($payload['exp'] ?? 0) < time()) {
return null;
return ['userId' => null, 'expired' => false];
}
if (($payload['email'] ?? '') !== $email) {
return null;
return ['userId' => null, 'expired' => false];
}
return $payload['userId'] ?? null;
if (($payload['exp'] ?? 0) < time()) {
return ['userId' => $payload['userId'] ?? null, 'expired' => true];
}
return ['userId' => $payload['userId'] ?? null, 'expired' => false];
}
#[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', '');
if ('' === $email || '' === $jwt) {
return $this->json(['success' => false, 'data' => null, 'error' => 'Headers ETicket-Email et ETicket-JWT requis.'], 401);
}
$result = self::verifyJwt($jwt, $email, $appSecret);
if (null === $result['userId']) {
return $this->json(['success' => false, 'data' => null, 'error' => 'Token invalide.'], 401);
}
if (!$result['expired']) {
return $this->json(['success' => false, 'data' => null, 'error' => 'Token encore valide, pas besoin de refresh.'], 400);
}
$user = $em->getRepository(User::class)->find($result['userId']);
if (!$user || $user->getEmail() !== $email) {
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,
]);
}
private function generateJwt(User $user, string $appSecret): string

View File

@@ -110,7 +110,7 @@ class ApiDocController extends AbstractController
];
if ($isAuth) {
$req['afterResponseScript'] = "const res = insomnia.response.json();\nif (res.success && res.data && res.data.token) {\n insomnia.environment.set('jwt_token', res.data.token);\n}";
$req['afterResponseScript'] = "const res = insomnia.response.json();\nif (res && res.success && res.data && res.data.token) {\n insomnia.environment.set('jwt_token', res.data.token);\n}";
}
$resources[] = $req;
@@ -193,6 +193,24 @@ class ApiDocController extends AbstractController
429 => 'Trop de tentatives',
],
],
[
'method' => 'POST',
'path' => '/api/auth/refresh',
'summary' => 'Rafraichir un token expire',
'description' => 'Genere un nouveau token JWT a partir d\'un token expire. La signature doit etre valide. Si le token est encore valide, retourne une erreur 400.',
'headers' => $this->authHeaders(),
'params' => [],
'request' => null,
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'object', 'example' => '{"token": "eyJ...", "expiresAt": "2026-03-25T12:00:00+00:00"}'],
],
'statuses' => [
200 => 'Nouveau token genere',
400 => 'Token encore valide',
401 => 'Token invalide ou utilisateur introuvable',
],
],
],
],
[