Laravel 15 min de lecture

Webhooks idempotents dans Laravel: signature HMAC, horodatage et verrous atomiques

#laravel#vuejs

Apprenez à sécuriser des webhooks idempotents Laravel: signature HMAC, horodatage, verrou Redis et Job en file. Tests Pest pour éviter les doublons.

Webhooks idempotents dans Laravel: signature HMAC, horodatage et verrous atomiques

Les webhooks sont souvent à l’origine d’effets de bord coûteux quand ils sont rejoués, mal signés ou traités en double. Dans ce tutoriel, on construit un endpoint Laravel robuste, rapide et idempotent, qui valide une signature HMAC, contrôle un horodatage, s’appuie sur des verrous Redis atomiques, et délègue le travail à une Job unique. On termine par des tests end-to-end avec Pest, de l’observabilité et quelques astuces d’exploitation.

Objectif

L’objectif est de livrer un endpoint POST /webhooks/vendor qui répond en 202 (Accepted) en quelques millisecondes, vérifie la signature HMAC du provider, rejette les requêtes trop anciennes, empêche les doubles traitements avec un verrou Redis, puis dépose le traitement asynchrone dans une Job de queue. Le flux doit rester stateless, sécurisé et instrumenté, avec des tests garantissant la non-régression, y compris vis-à-vis des doublons.

Objectif et prérequis

La cible est un endpoint POST /webhooks/vendor minimaliste et non bloquant. Il accepte un JSON valide avec, au minimum, un type et un bloc data. Il vérifie deux en-têtes tiers: X-Signature contenant un HMAC-SHA256 hexadécimal et X-Timestamp exprimé en secondes epoch. Après validation, il tente un verrou Redis pour garantir l’idempotence et, s’il l’obtient, il dispatch une Job de traitement dans une file dédiée webhooks, puis répond 202 sans attendre la fin du travail.

Il faut Laravel 10 ou 11, Redis pour le cache et la queue, et éventuellement Horizon pour superviser la charge. On configure l’environnement avec une clé secrète de signature et on force Redis pour cache et file. Par exemple dans .env:

VENDOR_WEBHOOK_SECRET=super-secret-long-hex-ou-base64
# Secret précédent pour rotation (facultatif)
VENDOR_WEBHOOK_SECRET_PREVIOUS=

QUEUE_CONNECTION=redis
CACHE_DRIVER=redis
# Optionnel : canal dédié
HORIZON_PREFIX=webhooks

On déclare le secret côté application dans config/services.php afin d’accepter une rotation à chaud:

// config/services.php
return [
    // ...
    'vendor' => [
        'secrets' => array_values(array_filter([
            env('VENDOR_WEBHOOK_SECRET'),
            env('VENDOR_WEBHOOK_SECRET_PREVIOUS'),
        ])),
        // Window d’acceptation temporelle (secondes)
        'skew' => 300,
    ],
];

Route et contrôleur minimal: réponse 202 non bloquante

On part d’un contrôleur très simple qui ne fait aucun traitement métier inline. Il valide en surface la forme du JSON, tire un event_id, met en place l’idempotence, puis dispatch une Job. La réponse 202 est retournée rapidement, quelle que soit l’issue du dispatch, pour ne pas bloquer le sender.

Générez le contrôleur et la Job:

php artisan make:controller WebhookVendorController
php artisan make:job ProcessVendorWebhook

Déclarez la route dans routes/api.php, en ajoutant un throttling de base et, plus tard, un middleware de signature:

use App\Http\Controllers\WebhookVendorController;
use Illuminate\Support\Facades\Route;

Route::post('/webhooks/vendor', [WebhookVendorController::class, 'handle'])
    ->middleware(['throttle:60,1', 'verify.webhook.signature']);

Le contrôleur délègue quasiment tout: il récupère le payload normalisé et l’event_id déposés par le middleware, vérifie la structure, gère l’idempotence via Redis, puis dispatch la Job. Il ne touche pas la base de données.

<?php

namespace App\Http\Controllers;

use App\Jobs\ProcessVendorWebhook;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;

class WebhookVendorController extends Controller
{
    public function handle(Request $request)
    {
        // Récupérés et normalisés par le middleware
        $payload = $request->attributes->get('payload', []);
        $eventId = $request->attributes->get('event_id') ?: Str::uuid()->toString();

        // Validation JSON minimale
        $validated = validator($payload, [
            'type' => ['required', 'string', 'max:150'],
            'data' => ['required', 'array'],
        ])->validate();

        // Idempotence: si déjà traité, on accepte silencieusement
        $doneKey = "wh:vendor:done:{$eventId}";
        if (Cache::get($doneKey)) {
            Log::info('webhook.already_done', ['event_id' => $eventId, 'type' => $validated['type']]);
            return response()->json(['status' => 'accepted'], 202);
        }

        // Verrou atomique court pour éviter les dispatchs concurrents
        $lock = Cache::lock("wh:vendor:{$eventId}", 10); // TTL du lock: 10s
        if (! $lock->get()) {
            Log::info('webhook.lock_busy', ['event_id' => $eventId]);
            return response()->json(['status' => 'accepted'], 202);
        }

        try {
            // Dispatch non bloquant dans une file dédiée
            ProcessVendorWebhook::dispatch([
                'event_id' => $eventId,
                'type' => $validated['type'],
                'data' => $validated['data'],
                'received_at' => now()->toIso8601String(),
            ])->onQueue('webhooks');

            Log::info('webhook.accepted', ['event_id' => $eventId, 'type' => $validated['type']]);
        } finally {
            // On relâche le lock immédiatement; l’unicité et la clé "done" finaliseront l’idempotence
            optional($lock)->release();
        }

        return response()->json(['status' => 'accepted'], 202);
    }
}

L’intérêt de cette approche est double: aucune écriture en base tant que la signature n’est pas validée et le verrou obtenu, et aucune attente du traitement, ce qui maintient une latence de réponse très basse.

Vérifier la signature HMAC et l’horodatage

On retient une convention simple: X-Signature contient un HMAC-SHA256 hexadécimal, X-Timestamp donne l’epoch en secondes, et X-Event-Id est optionnel si le provider le fournit. La base de signature est la concaténation X-Timestamp . "." . rawBody. On calcule le HMAC côté serveur avec la clé partagée et on compare avec hash_equals pour éviter les attaques de timing.

Si l’horodatage diffère de plus de 300 secondes de l’heure serveur, on rejette la requête pour limiter la fenêtre d’attaque. On refuse aussi toute requête sans en-têtes requis, et l’API répond 400 (en-tête manquant, JSON invalide) ou 401 (signature invalide, horodatage trop ancien).

Isoler cette logique dans un middleware ou un petit service améliore la réutilisabilité. Par exemple, on accepte plusieurs secrets pour gérer la rotation: on tente la validation avec le secret actif puis avec l’ancien.

Idempotence avec verrous atomiques Redis

L’idempotence repose sur un identifiant d’événement stable. S’il existe un X-Event-Id fourni par le provider, on l’utilise; sinon, on dérive une clé depuis le timestamp et le corps brut, par exemple sha256("{$ts}.{$rawBody}"). On exploite ensuite Cache::lock pour créer un verrou court qui évite le double dispatch en cas de rafales ou de retries concurrentiels.

La stratégie pratique est la suivante: si la clé de “done” existe, on renvoie 202 sans agir; sinon, on tente d’acquérir un verrou non bloquant. Si le verrou est indisponible, on répond 202 en considérant que l’événement est déjà en cours. Si on obtient le verrou, on dispatch la Job, on relâche le verrou, puis on laisse la Job marquer l’événement comme traité en fin d’exécution. On garde ce marqueur en cache pendant 48 heures pour ignorer d’éventuels retries tardifs.

Cette séquence empêche les traitements en double sans faire reposer toute la mécanique sur un lock longue durée, et elle ne touche jamais la base de données tant que la signature n’est pas validée.

Déléguer le traitement à une Job unique et robuste

La Job doit être ShouldQueue et ShouldBeUnique. L’unicité repose sur l’event_id (ou la clé d’idempotence) via la méthode uniqueId, afin qu’un même événement ne soit jamais en cours plusieurs fois. On déclare un backoff progressif et, idéalement, retryUntil si un SLA impose une date limite. Les erreurs non récupérables (par exemple un payload structurellement invalide pour le métier) doivent faire échouer la Job immédiatement.

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Throwable;

class ProcessVendorWebhook implements ShouldQueue, ShouldBeUnique
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public array $payload;

    // Backoff progressif: 1s, 5s, 30s, 2min
    public function backoff(): array
    {
        return [1, 5, 30, 120];
    }

    // TTL d’unicité pour éviter un doublon tardif (en secondes)
    public $uniqueFor = 3600;

    public function __construct(array $payload)
    {
        $this->payload = $payload;
        $this->onQueue('webhooks');
    }

    public function uniqueId(): string
    {
        return $this->payload['event_id'] ?? sha1(json_encode($this->payload));
    }

    public function handle(): void
    {
        $eventId = $this->payload['event_id'] ?? null;
        $type = $this->payload['type'] ?? 'unknown';

        // Exemple: logs corrélés
        Log::info('job.start', ['event_id' => $eventId, 'type' => $type]);

        // TODO métier: appliquez ici vos effets (transactions DB, appels API, etc.)
        // Exemple factice: validation stricte + simulation de traitement
        validator($this->payload, [
            'event_id' => ['required', 'string'],
            'type'     => ['required', 'string'],
            'data'     => ['required', 'array'],
        ])->validate();

        // ... votre logique métier atomique (transactions DB, upserts, etc.)

        // Marqueur d’idempotence “traité” sur 48h
        Cache::put("wh:vendor:done:{$eventId}", true, now()->addHours(48));

        // Exemples de logs finaux
        Log::info('job.done', ['event_id' => $eventId, 'type' => $type]);
    }

    public function failed(Throwable $e): void
    {
        $eventId = $this->payload['event_id'] ?? null;
        Log::error('job.failed', ['event_id' => $eventId, 'error' => $e->getMessage()]);
    }

    // Optionnel: éviter les overlaps supplémentaires au sein d’un même worker
    public function middleware(): array
    {
        return [
            new WithoutOverlapping($this->uniqueId()),
        ];
    }
}

Avec Horizon, on isole la queue webhooks dans un workload dédié pour absorber les pics de trafic sans impacter les autres jobs. Un exemple de configuration Horizon peut définir une balance et une concurrence spécifiques pour la file webhooks.

Middleware VerifyWebhookSignature (séparation des préoccupations)

Le middleware prend en charge la lecture du corps brut, la validation de l’horodatage, le calcul HMAC avec rotation de secrets et la mise à disposition d’un payload et d’un event_id normalisés pour le contrôleur. Il doit retourner 400 si les en-têtes ou le JSON manquent, et 401 si l’horodatage est trop ancien ou la signature invalide.

Créez le middleware:

php artisan make:middleware VerifyWebhookSignature

Implémentez-le:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;

class VerifyWebhookSignature
{
    public function handle(Request $request, Closure $next)
    {
        $raw = $request->getContent() ?? '';
        $signature = $request->headers->get('X-Signature');
        $timestamp = $request->headers->get('X-Timestamp');
        $eventIdHeader = $request->headers->get('X-Event-Id');

        if ($signature === null || $timestamp === null) {
            Log::warning('webhook.missing_headers');
            return response()->json(['error' => 'missing headers'], 400);
        }

        if (!ctype_digit((string) $timestamp)) {
            Log::warning('webhook.bad_timestamp', ['ts' => $timestamp]);
            return response()->json(['error' => 'bad timestamp'], 400);
        }

        $ts = (int) $timestamp;
        $now = Carbon::now()->timestamp;
        $skew = (int) config('services.vendor.skew', 300);

        if (abs($now - $ts) > $skew) {
            $this->bumpMetric('webhooks.timestamp.skew');
            Log::warning('webhook.timestamp_skew', ['ts' => $ts, 'now' => $now]);
            return response()->json(['error' => 'timestamp skew'], 401);
        }

        $base = "{$ts}.{$raw}";
        $secrets = (array) config('services.vendor.secrets', []);
        $valid = false;

        foreach ($secrets as $secret) {
            $computed = hash_hmac('sha256', $base, (string) $secret);
            if (hash_equals($computed, (string) $signature)) {
                $valid = true;
                break;
            }
        }

        if (! $valid) {
            $this->bumpMetric('webhooks.signature.invalid');
            Log::warning('webhook.signature_invalid');
            return response()->json(['error' => 'invalid signature'], 401);
        }

        // JSON valide et normalisé
        try {
            $payload = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
        } catch (\JsonException $e) {
            Log::warning('webhook.bad_json', ['error' => $e->getMessage()]);
            return response()->json(['error' => 'invalid json'], 400);
        }

        // event_id fourni ou dérivé
        $derivedId = hash('sha256', "{$ts}.{$raw}");
        $eventId = $eventIdHeader ?: Arr::get($payload, 'id') ?: $derivedId;

        // Injection pour le contrôleur
        $request->attributes->set('payload', $payload);
        $request->attributes->set('event_id', $eventId);

        return $next($request);
    }

    private function bumpMetric(string $key): void
    {
        if (app()->bound('statsd')) {
            app('statsd')->increment($key);
        }
    }
}

Ajoutez l’alias dans app/Http/Kernel.php:

protected $routeMiddleware = [
    // ...
    'verify.webhook.signature' => \App\Http\Middleware\VerifyWebhookSignature::class,
];

Le middleware protège toutes les routes webhooks via l’alias verify.webhook.signature; la route donnée plus haut l’utilise déjà.

Tests end-to-end avec Pest

On souhaite vérifier le “cas heureux”, l’idempotence et les erreurs classiques de sécurité, ainsi que la latence de réponse. Les tests créent des requêtes avec des en-têtes cohérents, calculent la signature attendue et observent le dispatch de la Job.

Installez Pest si besoin:

php artisan pest:install

Créez tests/Feature/WebhooksVendorTest.php:

<?php

use App\Jobs\ProcessVendorWebhook;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Carbon;

function sign_payload(int $ts, string $raw, string $secret): string
{
    return hash_hmac('sha256', "{$ts}.{$raw}", $secret);
}

it('accepte un webhook signé et dispatch la job', function () {
    Queue::fake();

    $payload = [
        'type' => 'invoice.paid',
        'data' => ['id' => 123, 'amount' => 4999],
    ];

    $raw = json_encode($payload);
    $ts = Carbon::now()->timestamp;
    $secret = config('services.vendor.secrets')[0] ?? 'test-secret';
    $sig = sign_payload($ts, $raw, $secret);

    $headers = [
        'X-Timestamp' => (string) $ts,
        'X-Signature' => $sig,
        // Optionnel: forcer un event_id
        'X-Event-Id'  => 'evt_123',
    ];

    $started = microtime(true);
    $response = $this->withHeaders($headers)->postJson('/api/webhooks/vendor', $payload);
    $elapsedMs = (microtime(true) - $started) * 1000;

    $response->assertStatus(202);

    Queue::assertPushed(ProcessVendorWebhook::class, function ($job) {
        return ($job->payload['event_id'] ?? null) === 'evt_123'
            && ($job->payload['type'] ?? null) === 'invoice.paid';
    });

    expect($elapsedMs)->toBeLessThan(50.0);
});

it('est idempotent sur un même event_id', function () {
    Queue::fake();

    $payload = ['type' => 'user.updated', 'data' => ['id' => 42]];
    $raw = json_encode($payload);
    $ts = now()->timestamp;
    $secret = config('services.vendor.secrets')[0] ?? 'test-secret';
    $sig = sign_payload($ts, $raw, $secret);

    $headers = [
        'X-Timestamp' => (string) $ts,
        'X-Signature' => $sig,
        'X-Event-Id'  => 'evt_same',
    ];

    $this->withHeaders($headers)->postJson('/api/webhooks/vendor', $payload)->assertStatus(202);
    $this->withHeaders($headers)->postJson('/api/webhooks/vendor', $payload)->assertStatus(202);

    Queue::assertPushed(ProcessVendorWebhook::class, 1);
});

it('rejette une signature invalide', function () {
    $payload = ['type' => 'x', 'data' => []];
    $raw = json_encode($payload);
    $ts = now()->timestamp;

    $headers = [
        'X-Timestamp' => (string) $ts,
        'X-Signature' => 'deadbeef', // mauvaise signature
    ];

    $this->withHeaders($headers)->postJson('/api/webhooks/vendor', $payload)->assertStatus(401);
});

it('rejette un horodatage trop ancien', function () {
    $payload = ['type' => 'x', 'data' => []];
    $raw = json_encode($payload);
    $ts = now()->subMinutes(10)->timestamp; // > 300s

    $secret = config('services.vendor.secrets')[0] ?? 'test-secret';
    $sig = sign_payload($ts, $raw, $secret);

    $headers = [
        'X-Timestamp' => (string) $ts,
        'X-Signature' => $sig,
    ];

    $this->withHeaders($headers)->postJson('/api/webhooks/vendor', $payload)->assertStatus(401);
});

it('rejette si les en-têtes sont manquants', function () {
    $payload = ['type' => 'x', 'data' => []];
    $this->postJson('/api/webhooks/vendor', $payload)->assertStatus(400);
});

Ces tests supposent que Redis est disponible pour les locks pendant l’exécution. Si vos tests tournent en mémoire, forcez la configuration au début du test suite (par exemple dans pest.php) avec config(['cache.default' => 'redis', 'queue.default' => 'sync']); ou utilisez un store supportant les verrous.

Observabilité: logs structurés et métriques

L’observabilité commence par des logs structurés corrélés avec l’event_id. Dans le contrôleur, on a déjà des Log::info sur accepted, already_done et lock_busy. Dans la Job, on trace job.start, job.done et job.failed avec l’event_id. On peut ajouter un compteur pour les rejets de signature et les skews via un client StatsD ou Prometheus, en incrémentant des clés telles que webhooks.signature.invalid et webhooks.timestamp.skew dans le middleware.

Un exemple de “correlation id” homogène consiste à passer l’event_id dans chaque log lié à l’événement, y compris au sein d’appels vers d’autres services. On peut aussi exposer un petit endpoint de health dédié aux webhooks qui renvoie le dernier succès et quelques compteurs agrégés. Par exemple:

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;

Route::get('/webhooks/vendor/health', function () {
    return response()->json([
        'last_success' => Cache::get('wh:vendor:last_success'),
        'uptime' => now()->diffForHumans(now()->subHours(24), true),
    ]);
});

Pour alimenter last_success, mettez à jour la clé à la fin de la Job:

Cache::put('wh:vendor:last_success', now()->toIso8601String(), now()->addDay());

Si vous utilisez Horizon, surveillez la latence de la file webhooks et ajustez la concurrence en conséquence. Les logs d’erreur doivent inclure suffisamment de contexte pour rejouer un événement ou diagnostiquer un schéma inattendu.

Durcissement et exploitation

La rotation de secret se gère en acceptant deux secrets: l’actuel et le précédent. Le middleware tente la validation dans l’ordre; on retire le secret précédent une fois la bascule terminée. Cette approche permet une rotation sans interruption, tout en continuant à vérifier la signature HMAC côté serveur à l’aide de la même base de signature (timestamp.rawBody).

Limiter la taille du body protège votre application contre des payloads démesurés. Vous pouvez imposer une limite via nginx/Apache et ajouter un middleware applicatif si nécessaire. La validation du schéma JSON se fait via les règles Laravel: imposez des champs essentiels, des types stricts, et des règles personnalisées si l’API du vendor a des invariants forts (par exemple, amount integer positif, id UUID, etc.).

Pour un rate limiting dédié, déclarez un limiter vendor-webhooks et appliquez-le à la route:

// App\Providers\RouteServiceProvider::boot()
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('vendor-webhooks', function (Request $request) {
    return [
        Limit::perMinute(120)->by($request->ip()), // par IP
        // Vous pouvez aussi limiter par X-Event-Id si pertinent
    ];
});

Puis changez la route:

Route::post('/webhooks/vendor', [WebhookVendorController::class, 'handle'])
    ->middleware(['throttle:vendor-webhooks', 'verify.webhook.signature']);

Un endpoint de relecture interne facilite les reprises ciblées. Protégez-le (auth.basic, Sanctum, IP allowlist) et ne redéclenchez la Job que si l’événement n’a pas déjà été marqué comme done, afin d’éviter les effets de bord involontaires.

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;

// Exemple simple protégé par Basic Auth
Route::post('/webhooks/vendor/replay', function (Request $request) {
    abort_unless(Auth::onceBasic(), 401);

    $eventId = $request->query('event_id');
    abort_unless($eventId, 400, 'missing event_id');

    $doneKey = "wh:vendor:done:{$eventId}";
    if (Cache::get($doneKey)) {
        return response()->json(['status' => 'already_done'], 409);
    }

    // Si vous avez archivé le payload brut quelque part, rechargez-le ici.
    // À défaut d’archive, rejouez uniquement si vous savez reconstruire le payload.
    // Exemple minimal (payload fictif):
    $payload = [
        'event_id' => $eventId,
        'type' => 'manual.replay',
        'data' => ['note' => 'replay'],
    ];

    \App\Jobs\ProcessVendorWebhook::dispatch($payload)->onQueue('webhooks');

    return response()->json(['status' => 'queued'], 202);
})->middleware('auth.basic');

Pour terminer, documentez en interne le format de signature, les en-têtes requis et la liste des codes de retour, afin que vos équipes d’exploitation sachent interpréter rapidement les comportements en production. Une page succincte indiquant comment signer un payload côté vendor, comment rejouer un événement et comment lire les métriques accélère les diagnostics.

Checklist

Avant de livrer, relisez le code à la recherche des écritures en base hors de la Job et assurez-vous que le contrôleur ne fait rien de bloquant. Exécutez les tests Pest, y compris ceux de sécurité et d’idempotence, et vérifiez la latence de réponse locale pour rester sous quelques dizaines de millisecondes. Lancez une passe de test manuelle avec un curl signé pour valider la chaîne HMAC et les erreurs en cas d’en-têtes manquants. Préparez l’environnement de prod avec Redis opérationnel, une file dédiée webhooks, éventuellement Horizon pour l’observation, et renseignez correctement VENDOR_WEBHOOK_SECRET. Enfin, publiez la documentation interne expliquant le fonctionnement, les entêtes requis, les limites et le processus de relecture.

Conclusion

Vous disposez maintenant d’un endpoint de webhook Laravel fiable, rapide et idempotent. La signature HMAC et le contrôle d’horodatage réduisent la surface d’attaque, les verrous Redis et l’unicité de la Job évitent les doublons, et la délégation asynchrone maintient une latence très faible. Avec des tests end-to-end et une instrumentation de base, vous pouvez exploiter sereinement des volumes importants d’événements entrants tout en garantissant l’intégrité de votre système.

Ressources

Pour approfondir, consultez la documentation officielle de Laravel sur les queues et les Jobs ShouldBeUnique, la couche Cache et les verrous atomiques Redis, les middlewares et la validation des requêtes, ainsi que Pest pour les tests. Les pages Horizon pour la supervision des files, les guides sur hash_hmac et la comparaison constant-time hash_equals de PHP, et les bonnes pratiques autour des webhooks (idempotence, retries, sécurité des en-têtes) complètent utilement ce tutoriel. Vous pouvez démarrer par: Laravel Queues (jobs et ShouldBeUnique), Laravel Cache et locks Redis, Middleware et validation des requêtes (docs Laravel), Laravel Horizon (monitoring des queues), Pest PHP (tests), hash_hmac et hash_equals (manuel PHP).