feat(payments/mollie): Next-Gen-Webhooks (JSON, HMAC-SHA256 X-Mollie-Signature) neben klassischem id=tr_; fix: Bestellbestaetigung aus Webhook-/CLI-Kontext (Layout-Objekt im Bootstrap); markCancelled idempotent
This commit is contained in:
@@ -68,8 +68,9 @@ final class Provider extends AbstractProvider
|
||||
public function secretFields(): array
|
||||
{
|
||||
return [
|
||||
'test_api_key' => 'Test-API-Key (test_...)',
|
||||
'live_api_key' => 'Live-API-Key (live_...)',
|
||||
'test_api_key' => 'Test-API-Key (test_...)',
|
||||
'live_api_key' => 'Live-API-Key (live_...)',
|
||||
'webhook_secret' => 'Webhook-Signing-Secret (nur fuer Next-Gen-Webhooks aus Dashboard > Entwickler > Webhooks; leer = klassische Webhooks)',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -278,15 +279,48 @@ final class Provider extends AbstractProvider
|
||||
// ---------------------------------------------------------------- webhook
|
||||
|
||||
/**
|
||||
* Mollie schickt application/x-www-form-urlencoded mit genau einem Feld: id=tr_...
|
||||
* Keine Signatur -- die Wahrheit kommt per GET von Mollie. Antwort 200 = angenommen,
|
||||
* alles andere wird bis zu 10x in 26 h wiederholt.
|
||||
* Zwei Webhook-Formen, ein Endpunkt (27.09.2026, docs.mollie.com/reference/webhooks-new):
|
||||
*
|
||||
* klassisch application/x-www-form-urlencoded, genau ein Feld id=tr_... , unsigniert.
|
||||
* Haengt an jeder Zahlung (webhookUrl). Mollie empfiehlt diese Form fuer
|
||||
* Zahlungsstatus weiterhin.
|
||||
* next-gen JSON {resource:"event", type:"payment.paid", entityId:"tr_..."}, signiert
|
||||
* mit HMAC-SHA256 ueber den rohen Body im Header X-Mollie-Signature
|
||||
* ("sha256=<hex>"), Schluessel = Signing-Secret der Subscription (Dashboard >
|
||||
* Entwickler > Webhooks). Ist ein Secret hinterlegt, MUSS die Signatur passen;
|
||||
* ohne Secret werden JSON-Events abgelehnt (kein blindes Vertrauen).
|
||||
*
|
||||
* In beiden Faellen ist die Wahrheit der GET auf die Zahlung -- der Webhook ist nur der
|
||||
* Anstoss. Antwort 200 = angenommen, alles andere wiederholt Mollie bis zu 10x in 26 h.
|
||||
*/
|
||||
public function onWebhook(array $headers, string $rawBody): int
|
||||
{
|
||||
$params = [];
|
||||
\parse_str($rawBody, $params);
|
||||
$id = (string) ($params['id'] ?? ($_POST['id'] ?? ''));
|
||||
$id = '';
|
||||
$body = \ltrim($rawBody);
|
||||
if ($body !== '' && $body[0] === '{') {
|
||||
$event = \json_decode($body, true);
|
||||
if (!\is_array($event) || ($event['resource'] ?? '') !== 'event') {
|
||||
Log::write($this->module(), 'webhook', null, null, null, 'rejected', null, ['body' => \mb_substr($rawBody, 0, 500)], 'JSON ohne resource=event');
|
||||
return 400;
|
||||
}
|
||||
$check = $this->verifySignature($headers, $rawBody);
|
||||
if ($check !== 'ok') {
|
||||
Log::write($this->module(), 'webhook', null, null, (string) ($event['entityId'] ?? ''), 'rejected', null,
|
||||
['type' => $event['type'] ?? '', 'header' => $headers['x-mollie-signature'] ?? ''], 'Signatur: ' . $check);
|
||||
return 400;
|
||||
}
|
||||
$type = (string) ($event['type'] ?? '');
|
||||
$id = (string) ($event['entityId'] ?? '');
|
||||
if (!\str_starts_with($type, 'payment.')) {
|
||||
// andere Ereignisse (Auszahlungen, Rechnungen ...) nur protokollieren
|
||||
Log::write($this->module(), 'webhook', null, null, $id, 'ignored', null, ['type' => $type], 'kein Zahlungsereignis');
|
||||
return 200;
|
||||
}
|
||||
} else {
|
||||
$params = [];
|
||||
\parse_str($rawBody, $params);
|
||||
$id = (string) ($params['id'] ?? ($_POST['id'] ?? ''));
|
||||
}
|
||||
if (!\preg_match('/^tr_[A-Za-z0-9]{6,40}$/', $id)) {
|
||||
Log::write($this->module(), 'webhook', null, null, $id, 'rejected', null, ['body' => \mb_substr($rawBody, 0, 500)], 'keine Zahlungs-ID');
|
||||
return 400;
|
||||
@@ -310,6 +344,34 @@ final class Provider extends AbstractProvider
|
||||
return 200;
|
||||
}
|
||||
|
||||
/**
|
||||
* HMAC-SHA256 ueber den unveraenderten Body mit dem Signing-Secret. Bei Secret-Rotation
|
||||
* schickt Mollie 24 h lang zwei Signaturen (kommagetrennt) -- eine muss passen.
|
||||
* @param array<string,string> $headers
|
||||
*/
|
||||
private function verifySignature(array $headers, string $rawBody): string
|
||||
{
|
||||
$secret = \Payments\Secrets::get($this->db, $this->module(), 'webhook_secret') ?? '';
|
||||
if ($secret === '') {
|
||||
return 'kein webhook_secret hinterlegt';
|
||||
}
|
||||
$header = (string) ($headers['x-mollie-signature'] ?? '');
|
||||
if ($header === '') {
|
||||
return 'Header X-Mollie-Signature fehlt';
|
||||
}
|
||||
$expected = \hash_hmac('sha256', $rawBody, $secret);
|
||||
foreach (\explode(',', $header) as $sig) {
|
||||
$sig = \strtolower(\trim($sig));
|
||||
if (\str_starts_with($sig, 'sha256=')) {
|
||||
$sig = \substr($sig, 7);
|
||||
}
|
||||
if ($sig !== '' && \hash_equals($expected, $sig)) {
|
||||
return 'ok';
|
||||
}
|
||||
}
|
||||
return 'Signatur passt nicht';
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- reconcile
|
||||
|
||||
public function reconcile(bool $dryRun = true): array
|
||||
|
||||
Reference in New Issue
Block a user