tutorial/token-api-securise.md

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, sinon doctrine:schema:validate échouera. La contrainte unique garantit 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(), pas rand(). random_bytes() est un générateur cryptographiquement sûr. rand() et mt_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 exemple Bearer) 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.

Assistant documentaire

Posez une question sur la documentation. Les réponses citent leurs sources — un clic ouvre le document à gauche.