getContent(); $secret = (string) config('services.stripe.webhook_secret'); if (blank($secret)) { // Fail closed: an unconfigured secret must not authorize provisioning // outside local/testing. abort_unless(app()->environment('local', 'testing'), 400, 'Stripe webhook secret not configured'); } elseif (! $this->signatureValid($payload, (string) $request->header('Stripe-Signature'), $secret)) { abort(400, 'invalid signature'); } $event = json_decode($payload, true) ?: []; $object = $event['data']['object'] ?? []; // Handle exactly ONE canonical event (a card checkout also emits // payment_intent.succeeded with a different id) and only when actually // paid — async methods complete the session while still unpaid. if (($event['type'] ?? '') !== 'checkout.session.completed' || ($object['payment_status'] ?? null) !== 'paid') { return response()->json(['ignored' => true]); } $meta = $object['metadata'] ?? []; // A real email is required — never merge unrelated customers under a // manufactured address or send credentials into the void. $email = $object['customer_details']['email'] ?? $object['customer_email'] ?? ($meta['email'] ?? null); if (blank($email)) { return response()->json(['ignored' => 'no_customer_email']); } $action->fromStripeEvent([ 'id' => (string) ($event['id'] ?? ''), 'email' => $email, 'name' => $object['customer_details']['name'] ?? ($meta['name'] ?? null), 'stripe_customer_id' => $object['customer'] ?? null, 'plan' => $meta['plan'] ?? 'start', 'datacenter' => $meta['datacenter'] ?? 'fsn', 'amount_cents' => (int) ($object['amount_total'] ?? $object['amount'] ?? 0), 'currency' => strtoupper($object['currency'] ?? 'eur'), ]); return response()->json(['ok' => true]); } /** Stripe's default replay tolerance, in seconds. */ private const SIGNATURE_TOLERANCE = 300; /** Verify Stripe's `t=…,v1=…` signature: HMAC-SHA256 of "{t}.{payload}". */ private function signatureValid(string $payload, string $header, string $secret): bool { if (blank($header)) { return false; } $timestamp = null; $signatures = []; foreach (explode(',', $header) as $segment) { [$key, $value] = array_pad(explode('=', $segment, 2), 2, ''); if ($key === 't') { $timestamp = $value; } elseif ($key === 'v1') { $signatures[] = $value; // keep every v1 (secret rotation sends several) } } if ($timestamp === null || $signatures === []) { return false; } // Reject replays outside the tolerance window. if (abs(time() - (int) $timestamp) > self::SIGNATURE_TOLERANCE) { return false; } $expected = hash_hmac('sha256', $timestamp.'.'.$payload, $secret); foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) { return true; } } return false; } }