03-comment-je-travaille/tutos/securite-token-api-symfony.md

Tuto — token API haché (SHA-512) en Symfony

Public visé : développeur back qui ajoute un accès API par token à une app Symfony (cf. specs/auth-compte.md). Objectif : un token opaque (chaîne aléatoire), haché à la persistance, vérifié à chaque requête sans jamais stocker le clair — le modèle simple et robuste retenu par codexia.

Pourquoi pas un simple champ texte ? Si la base fuite, un token stocké en clair est directement rejouable. En ne stockant que son empreinte SHA-512, une fuite de la table user ne livre aucun token utilisable : on ne peut pas remonter du hash au clair.


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×──►  <hash candidat> (128 hex)
   │ hash('sha512', …)                  │
   â–Ľ                                    â–Ľ
<hash> (128 hex) ──stocké────────►  comparaison == User.apiToken

Le clair ne vit que le temps de l'afficher à l'utilisateur ; seul le hash est persisté.


Étape 1 — Le champ sur l'entité User

Le hash SHA-512 fait 128 caractères hexadécimaux : la colonne est dimensionnée exactement.

// src/Entity/User.php
#[ORM\Column(length: 128, nullable: 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éé VARCHAR(255), alignez-la (sinon doctrine:schema:validate échoue). Pensez aussi à poser une contrainte unique sur la colonne — deux comptes ne doivent pas partager une empreinte.


Étape 2 — La commande de génération

Le token clair est affiché une seule fois ; on ne stocke que son hash. 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;
    }
}

random_bytes(), pas rand(). random_bytes() est un générateur cryptographiquement sûr ; rand()/mt_rand() sont prédictibles et n'ont rien à faire ici.


Étape 3 — L'authenticator

À chaque appel API, on lit le token en clair envoyé par le client, on le re-hache en SHA-512 et on compare au hash stocké. 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
    }
}

Nom du header — canonique et exclusif. L'implémentation lit X-Auth-Token et uniquement ce header : supports() ne se déclenche que sur sa présence, donc un header Bearer est rejeté (→ 401). C'est l'état figé au canon (auth-compte.md §8, confirmé Lead inbox codexia #47). Un support Bearer serait une évolution future, pas l'actuel.

Branchez l'authenticator sur le firewall api :

# config/packages/security.yaml
security:
    firewalls:
        api:
            pattern: ^/api
            stateless: true
            custom_authenticators:
                - App\Security\APIAuthenticator

Étape 4 — Rate limiting

Un endpoint authentifié par token reste exposé au bruteforce et à l'abus : on borne le débit.

# 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();
}

Tester

# 1. Générer un token
php bin/console app:user:generate-token admin@example.com
# → affiche le clair une seule fois, ex. : 7f3a…(64 hex)

# 2. Appeler l'API avec
curl -H "X-Auth-Token: 7f3a…" https://telaria.dev/api/me

# 3. Un token inconnu → 401
curl -H "X-Auth-Token: nimporte-quoi" https://telaria.dev/api/me

Côté tests automatisés, un test dédié de l'authenticator (token valide → 200, inconnu → 401, absent → 401) est recommandé : il verrouille la non-régression de la comparaison de hash.


Ă€ retenir

  • Token opaque alĂ©atoire via random_bytes(32) → bin2hex (64 hex).
  • Hash SHA-512 stockĂ© (VARCHAR(128), unique) ; le clair n'est jamais persistĂ©.
  • Affichage unique Ă  la gĂ©nĂ©ration ; perte ⇒ rĂ©gĂ©nĂ©ration.
  • VĂ©rification = re-hash du header + comparaison ; Ă©chec ⇒ 401.
  • Rate limiting sur le firewall API.
  • Évolutions possibles (non requises) : OAuth 2.1 / JWT — voir auth-compte.md §8.

Voir aussi

Assistant documentaire

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

Loading…
Loading the web debug toolbar…
Attempt #