Vérification des Webhooks
Guide détaillé pour implémenter la vérification HMAC-SHA256 des webhooks MONCRENEAU.
Pourquoi Vérifier les Signatures ?
Sans vérification, n'importe qui peut envoyer de fausses requêtes à votre endpoint webhook:
# ❌ Attaque possible sans vérification
curl -X POST https://votre-app.com/webhooks/moncreneau \
-d '{"id":"evt_fake","type":"appointment.created","data":{"object":{"id":999}}}'
La signature HMAC garantit que la requête provient bien de MONCRENEAU.
Comment Ça Marche ?
1. MONCRENEAU Génère la Signature
signature = HMAC-SHA256(secret_webhook, payload_json)
2. Vous Vérifiez la Signature
signature_calculée = HMAC-SHA256(votre_secret, payload_reçu)
if (signature_calculée === signature_header) ✅
else ❌
Implémentation Pas à Pas
Node.js / Express
Étape 1: Configuration
// .env
WEBHOOK_SECRET=whsec_abc123xyz789...
// app.js
const express = require('express');
const crypto = require('crypto');
const app = express();
// ⚠️ IMPORTANT: Parser en raw pour webhooks
app.use('/webhooks', express.raw({ type: 'application/json' }));
app.use(express.json()); // Pour les autres routes
Étape 2: Fonction de Vérification
function verifyWebhookSignature(payload, signature, secret) {
// Vérifier que la signature existe
if (!signature) {
console.error('Missing signature header');
return false;
}
// Extraire le hash (format: "sha256=...")
const parts = signature.split('=');
if (parts[0] !== 'sha256') {
console.error('Invalid signature format');
return false;
}
const receivedHash = parts[1];
// Calculer la signature attendue
const expectedHash = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
// Comparaison timing-safe
try {
return crypto.timingSafeEqual(
Buffer.from(receivedHash, 'hex'),
Buffer.from(expectedHash, 'hex')
);
} catch (error) {
console.error('Signature comparison failed:', error);
return false;
}
}
Étape 3: Route Webhook
app.post('/webhooks/moncreneau', (req, res) => {
const signature = req.headers['x-moncreneau-signature'];
const payload = req.body; // Buffer grâce à express.raw()
const secret = process.env.WEBHOOK_SECRET;
// Vérifier la signature
if (!verifyWebhookSignature(payload, signature, secret)) {
console.error('Invalid signature');
return res.status(401).send('Invalid signature');
}
// Signature valide ✅
const event = JSON.parse(payload.toString());
console.log('✓ Valid webhook received:', event.type);
// Répondre immédiatement
res.status(200).send('OK');
// Traiter en asynchrone
processWebhookAsync(event);
});
Java / Spring Boot
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
@RestController
@RequestMapping("/webhooks")
public class WebhookController {
@Value("${webhook.secret}")
private String webhookSecret;
@PostMapping("/moncreneau")
public ResponseEntity<String> handleWebhook(
@RequestBody String payload,
@RequestHeader("X-Moncreneau-Signature") String signature
) {
if (!verifySignature(payload, signature)) {
return ResponseEntity.status(401).body("Invalid signature");
}
// Parse et traiter l'événement
ObjectMapper mapper = new ObjectMapper();
WebhookEvent event = mapper.readValue(payload, WebhookEvent.class);
processWebhookAsync(event);
return ResponseEntity.ok("OK");
}
private boolean verifySignature(String payload, String signature) {
if (signature == null || !signature.startsWith("sha256=")) {
return false;
}
String receivedHash = signature.substring(7);
try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(
webhookSecret.getBytes("UTF-8"),
"HmacSHA256"
);
mac.init(secretKey);
byte[] expectedBytes = mac.doFinal(payload.getBytes("UTF-8"));
String expectedHash = bytesToHex(expectedBytes);
// Timing-safe comparison
return MessageDigest.isEqual(
expectedHash.getBytes(),
receivedHash.getBytes()
);
} catch (Exception e) {
logger.error("Signature verification failed", e);
return false;
}
}
private String bytesToHex(byte[] bytes) {
StringBuilder result = new StringBuilder();
for (byte b : bytes) {
result.append(String.format("%02x", b));
}
return result.toString();
}
}
PHP / Laravel
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class WebhookController extends Controller
{
public function handleMoncreneauWebhook(Request $request)
{
$signature = $request->header('X-Moncreneau-Signature');
$payload = $request->getContent();
$secret = config('services.moncreneau.webhook_secret');
if (!$this->verifySignature($payload, $signature, $secret)) {
return response('Invalid signature', 401);
}
$event = json_decode($payload, true);
// Traiter en asynchrone (queue)
dispatch(new ProcessWebhook($event));
return response('OK', 200);
}
private function verifySignature($payload, $signature, $secret)
{
if (!$signature || !str_starts_with($signature, 'sha256=')) {
return false;
}
$receivedHash = substr($signature, 7);
$expectedHash = hash_hmac('sha256', $payload, $secret);
// Timing-safe comparison
return hash_equals($expectedHash, $receivedHash);
}
}
Python / Django
import hmac
import hashlib
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.conf import settings
@csrf_exempt
def webhook_handler(request):
if request.method != 'POST':
return JsonResponse({'error': 'Method not allowed'}, status=405)
signature = request.headers.get('X-Moncreneau-Signature')
payload = request.body
secret = settings.WEBHOOK_SECRET.encode('utf-8')
if not verify_signature(payload, signature, secret):
return JsonResponse({'error': 'Invalid signature'}, status=401)
import json
event = json.loads(payload)
# Traiter en asynchrone (Celery)
from .tasks import process_webhook
process_webhook.delay(event)
return JsonResponse({'status': 'ok'})
def verify_signature(payload, signature, secret):
if not signature or not signature.startswith('sha256='):
return False
received_hash = signature[7:]
expected_hash = hmac.new(
secret,
payload,
hashlib.sha256
).hexdigest()
# Timing-safe comparison
return hmac.compare_digest(expected_hash, received_hash)
Débogage
Vérifier le Payload Reçu
app.post('/webhooks/moncreneau', (req, res) => {
const payload = req.body;
console.log('Payload type:', typeof payload);
console.log('Payload content:', payload);
if (Buffer.isBuffer(payload)) {
console.log('✓ Payload is Buffer (correct)');
} else if (typeof payload === 'string') {
console.log('✓ Payload is string (correct)');
} else {
console.error('✗ Payload is object (WRONG - use express.raw)');
}
// ...
});
Vérifier le Secret
console.log('Secret length:', process.env.WEBHOOK_SECRET?.length);
console.log('Secret starts with:', process.env.WEBHOOK_SECRET?.substring(0, 10));
if (!process.env.WEBHOOK_SECRET) {
console.error('❌ WEBHOOK_SECRET not set!');
}
Comparer les Signatures
function debugSignature(payload, signature, secret) {
console.log('=== SIGNATURE DEBUG ===');
const receivedHash = signature.split('=')[1];
console.log('Received hash:', receivedHash);
const expectedHash = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
console.log('Expected hash:', expectedHash);
console.log('Match:', receivedHash === expectedHash ? '✓' : '✗');
if (receivedHash !== expectedHash) {
console.log('First 10 chars received:', receivedHash.substring(0, 10));
console.log('First 10 chars expected:', expectedHash.substring(0, 10));
}
}
Tester avec curl
# Générer une signature de test
SECRET="whsec_your_secret"
PAYLOAD='{"id":"evt_test","type":"appointment.created"}'
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/.* //')
echo "Signature: sha256=$SIGNATURE"
# Envoyer la requête
curl -X POST http://localhost:3000/webhooks/moncreneau \
-H "Content-Type: application/json" \
-H "X-Moncreneau-Signature: sha256=$SIGNATURE" \
-d "$PAYLOAD"
Erreurs Courantes
❌ Payload Parsé en JSON Avant Vérification
// MAUVAIS
app.use(express.json()); // Parse TOUT en JSON
app.post('/webhooks', (req, res) => {
const payload = req.body; // Déjà un objet !
// Impossible de calculer la signature sur un objet
});
// BON
app.use('/webhooks', express.raw({ type: 'application/json' }));
app.post('/webhooks', (req, res) => {
const payload = req.body; // Buffer
const signature = calculateSignature(payload); // ✓
});
❌ Secret Incorrect
// Secret hardcodé (MAUVAIS)
const secret = "my-secret";
// Secret depuis env (BON)
const secret = process.env.WEBHOOK_SECRET;
❌ Comparaison Non Timing-Safe
// VULNÉRABLE aux timing attacks
if (receivedHash === expectedHash) { }
// SÉCURISÉ
if (crypto.timingSafeEqual(
Buffer.from(receivedHash, 'hex'),
Buffer.from(expectedHash, 'hex')
)) { }
❌ Charset Encoding
// Peut causer des problèmes
.update(payload, 'utf-8')
// Plus fiable
.update(payload) // Laisse Node gérer l'encoding
Tests
Test Unitaire de la Vérification
const crypto = require('crypto');
const { verifyWebhookSignature } = require('../lib/webhooks');
describe('verifyWebhookSignature', () => {
const secret = 'test_secret';
const payload = '{"id":"evt_123","type":"appointment.created"}';
it('should verify valid signature', () => {
const signature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
expect(verifyWebhookSignature(payload, signature, secret)).toBe(true);
});
it('should reject invalid signature', () => {
expect(verifyWebhookSignature(payload, 'sha256=invalid', secret)).toBe(false);
});
it('should reject missing signature', () => {
expect(verifyWebhookSignature(payload, null, secret)).toBe(false);
});
it('should reject wrong format', () => {
expect(verifyWebhookSignature(payload, 'md5=abc123', secret)).toBe(false);
});
});
Test d'Intégration avec ngrok
# 1. Démarrer le serveur local
npm start
# 2. Exposer avec ngrok
ngrok http 3000
# 3. Configurer l'URL webhook dans MONCRENEAU
# https://abc123.ngrok.io/webhooks/moncreneau
# 4. Créer un rendez-vous de test
# Les webhooks arrivent dans votre terminal !
Checklist
- Signature HMAC-SHA256 vérifiée
- Payload en Buffer/String (pas parsé en JSON)
- Secret depuis variable d'environnement
- Comparaison timing-safe
- Logs de debug en développement
- Tests unitaires de la vérification
- Tests d'intégration avec ngrok
- Gestion des erreurs (401 si signature invalide)