Sécuriser une API par token haché (SHA-512) en Symfony
Dans ce tutoriel, vous allez ajouter pas à pas un accès API par token à une application Symfony. On suit un seul chemin, celui qui marche : un token opaque (chaîne aléatoire), haché au moment de l'enregistrement, puis vérifié à chaque requête sans jamais conserver le clair.
À la fin, vous saurez générer un token, le stocker sous forme d'empreinte, authentifier une requête entrante et borner le débit d'appels.
Pourquoi hacher ? Si votre base de données fuite, un token stocké en clair est directement rejouable par l'attaquant. En ne stockant que son empreinte SHA-512, une fuite de la table ne livre aucun token utilisable : on ne peut pas remonter de l'empreinte au clair.
Prérequis : une application Symfony fonctionnelle avec une entité User gérée par Doctrine.
Le principe en une image
Génération (une fois) Vérification (chaque requête)
───────────────────── ─────────────────────────────
random_bytes(32) header X-Auth-Token: <clair>
│ bin2hex │ hash('sha512', …)
â–Ľ â–Ľ
<clair> (64 hex) ──affiché 1×──► <empreinte candidate> (128 hex)
│ hash('sha512', …) │
â–Ľ â–Ľ
<empreinte> (128 hex) ─stockée──► comparaison == User.apiToken
Le clair ne vit que le temps de l'afficher une fois à l'utilisateur ; seule l'empreinte est enregistrée.
Étape 1 — Ajouter le champ sur l'entité User
Une empreinte SHA-512 fait 128 caractères hexadécimaux. On dimensionne la colonne exactement à cette taille.
// src/Entity/User.php #[ORM\Column(length: 128, nullable: true, unique: true)] private ?string $apiToken = null; public function getApiToken(): ?string { return $this->apiToken; } public function setApiToken(?string $apiToken): static { $this->apiToken = $apiToken; return $this; }
Largeur exacte =
VARCHAR(128). Si une migration antérieure a créé une colonne plus large, alignez-la, sinondoctrine:schema:validateéchouera. La contrainteuniquegarantit que deux comptes ne partagent jamais la même empreinte.
Générez et appliquez la migration :
php bin/console make:migration php bin/console doctrine:migrations:migrate
Étape 2 — Écrire la commande de génération
Le token clair est affiché une seule fois ; on ne stocke que son empreinte. Impossible de le réafficher ensuite : l'utilisateur doit le recopier immédiatement, et en régénérer un en cas de perte.
// src/Command/UserGenerateTokenCommand.php #[AsCommand(name: 'app:user:generate-token', description: 'Génère un token API pour un utilisateur.')] final class UserGenerateTokenCommand { public function __construct(private EntityManagerInterface $em, private UserRepository $users) {} public function __invoke(SymfonyStyle $io, string $email): int { $user = $this->users->findOneBy(['email' => $email]); if (!$user) { $io->error("Utilisateur introuvable : {$email}"); return Command::FAILURE; } // 32 octets aléatoires → 64 caractères hex : le token EN CLAIR. $clear = bin2hex(random_bytes(32)); // On ne persiste que l'empreinte SHA-512 (128 hex). $user->setApiToken(hash('sha512', $clear)); $this->em->flush(); $io->success('Token généré. Copiez-le maintenant, il ne sera plus affiché :'); $io->writeln($clear); return Command::SUCCESS; } }
Utilisez
random_bytes(), pasrand().random_bytes()est un générateur cryptographiquement sûr.rand()etmt_rand()sont prédictibles et n'ont pas leur place dans la génération d'un secret.
Étape 3 — Brancher l'authenticator
À chaque appel API, on lit le token en clair envoyé par le client, on le re-hache en SHA-512, puis on compare à l'empreinte stockée. Aucune comparaison ne porte sur du clair.
// src/Security/APIAuthenticator.php final class APIAuthenticator extends AbstractAuthenticator { public function __construct(private UserRepository $users) {} public function supports(Request $request): ?bool { return $request->headers->has('X-Auth-Token'); } public function authenticate(Request $request): Passport { $clear = (string) $request->headers->get('X-Auth-Token'); if ($clear === '') { throw new CustomUserMessageAuthenticationException('Token manquant.'); } $hash = hash('sha512', $clear); return new SelfValidatingPassport( new UserBadge($hash, function (string $hash): UserInterface { $user = $this->users->findOneBy(['apiToken' => $hash]); if (!$user) { throw new CustomUserMessageAuthenticationException('Token invalide.'); } return $user; }) ); } public function onAuthenticationFailure(Request $request, AuthenticationException $e): ?Response { return new JsonResponse(['error' => 'Authentification requise.'], Response::HTTP_UNAUTHORIZED); } public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response { return null; // laisse la requĂŞte continuer } }
Un seul header, canonique. L'implémentation ne se déclenche que sur
X-Auth-Token:supports()ne réagit qu'à sa présence. Un choix explicite d'un unique point d'entrée d'authentification est plus simple à raisonner et à tester. Un support d'autres schémas (par exempleBearer) serait une évolution ultérieure, à ajouter volontairement.
Branchez ensuite l'authenticator sur le firewall dédié à l'API :
# config/packages/security.yaml security: firewalls: api: pattern: ^/api stateless: true custom_authenticators: - App\Security\APIAuthenticator
Étape 4 — Limiter le débit
Un endpoint authentifié par token reste exposé au bruteforce et à l'abus. On borne donc le nombre d'appels autorisés dans le temps.
# config/packages/rate_limiter.yaml framework: rate_limiter: api: policy: 'sliding_window' limit: 100 interval: '1 hour'
// dans le contrĂ´leur API (ou un EventSubscriber) $limiter = $this->apiLimiter->create($request->getClientIp()); if (!$limiter->consume()->isAccepted()) { throw new TooManyRequestsHttpException(); }
Ajustez limit et interval selon l'usage réel de votre API.
Étape 5 — Tester ce que vous avez construit
# 1. Générer un token php bin/console app:user:generate-token utilisateur@example.test # → affiche le clair une seule fois, ex. : 7f3a…(64 hex) # 2. Appeler l'API avec ce token curl -H "X-Auth-Token: 7f3a…" https://<VOTRE_DOMAINE>/api/me # 3. Un token inconnu → 401 curl -H "X-Auth-Token: nimporte-quoi" https://<VOTRE_DOMAINE>/api/me
Écrivez aussi un test automatisé de l'authenticator : token valide → 200, token inconnu → 401, header absent → 401. Il verrouille la non-régression de la comparaison d'empreintes.
Ce que vous avez appris
- Générer un token opaque aléatoire via
random_bytes(32)→bin2hex(64 hex). - Stocker uniquement son empreinte SHA-512 (
VARCHAR(128),unique) ; le clair n'est jamais persisté. - Afficher une seule fois le token à la génération ; en cas de perte, régénérer.
- Vérifier en re-hachant le header entrant et en comparant ; tout échec renvoie 401.
- Limiter le débit sur le firewall API pour contenir le bruteforce.
Pour aller plus loin, des schémas plus riches comme OAuth 2.1 ou les JWT sont des évolutions possibles, non requises pour ce modèle simple.