Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Support for deserializing webhook events and verifying signatures
- Loading branch information
Showing
6 changed files
with
334 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
<?php | ||
|
||
namespace Stripe\Error; | ||
|
||
class SignatureVerification extends Base | ||
{ | ||
public function __construct( | ||
$message, | ||
$sigHeader, | ||
$httpBody = null | ||
) { | ||
parent::__construct($message, null, $httpBody, null, null); | ||
$this->sigHeader = $sigHeader; | ||
} | ||
|
||
public function getSigHeader() | ||
{ | ||
return $this->sigHeader; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
<?php | ||
|
||
namespace Stripe; | ||
|
||
abstract class Webhook | ||
{ | ||
const DEFAULT_TOLERANCE = 300; | ||
|
||
/** | ||
* Returns an Event instance using the provided JSON payload. Throws a | ||
* JsonSyntaxException if the payload is not valid JSON, and a | ||
* SignatureVerificationException if the signature verification fails for | ||
* any reason. | ||
* | ||
* @param string $payload the payload sent by Stripe. | ||
* @param string $sigHeader the contents of the signature header sent by | ||
* Stripe. | ||
* @param string $secret secret used to generate the signature. | ||
* @param int $tolerance maximum difference allowed between the header's | ||
* timestamp and the current time | ||
* @return \Stripe\Event the Event instance | ||
* @throws SignatureVerification if the verification fails. | ||
*/ | ||
public static function createEventFromPayload($payload, $sigHeader, $secret, $tolerance = self::DEFAULT_TOLERANCE) | ||
{ | ||
$data = json_decode($payload, true); | ||
$jsonError = json_last_error(); | ||
if ($data === null && $jsonError !== JSON_ERROR_NONE) { | ||
$msg = "Invalid payload: $payload " | ||
. "(json_last_error() was $jsonError)"; | ||
throw new \UnexpectedValueException($msg); | ||
} | ||
$event = Event::constructFrom($data, null); | ||
|
||
WebhookSignature::verifyHeader($payload, $sigHeader, $secret, $tolerance); | ||
|
||
return $event; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
<?php | ||
|
||
namespace Stripe; | ||
|
||
abstract class WebhookSignature | ||
{ | ||
const EXPECTED_SCHEME = "v1"; | ||
|
||
/** | ||
* Verifies the signature header sent by Stripe. Throws a | ||
* SignatureVerification exception if the verification fails for any | ||
* reason. | ||
* | ||
* @param string $payload the payload sent by Stripe. | ||
* @param string $header the contents of the signature header sent by | ||
* Stripe. | ||
* @param string $secret secret used to generate the signature. | ||
* @param int $tolerance maximum difference allowed between the header's | ||
* timestamp and the current time | ||
* @throws SignatureVerification if the verification fails. | ||
*/ | ||
public static function verifyHeader($payload, $header, $secret, $tolerance = null) | ||
{ | ||
// Extract timestamp and signatures from header | ||
$timestamp = self::getTimestamp($header); | ||
$signatures = self::getSignatures($header, self::EXPECTED_SCHEME); | ||
if ($timestamp == -1) { | ||
throw new Error\SignatureVerification( | ||
"Unable to extract timestamp and signatures from header", | ||
$header, | ||
$payload | ||
); | ||
} | ||
if (empty($signatures)) { | ||
throw new Error\SignatureVerification( | ||
"No signatures found with expected scheme", | ||
$header, | ||
$payload | ||
); | ||
} | ||
|
||
// Check if expected signature is found in list of signatures from | ||
// header | ||
$signedPayload = "$timestamp.$payload"; | ||
$expectedSignature = self::computeSignature($signedPayload, $secret); | ||
$signatureFound = false; | ||
foreach ($signatures as $signature) { | ||
if (Util\Util::secureCompare($expectedSignature, $signature)) { | ||
$signatureFound = true; | ||
break; | ||
} | ||
} | ||
if (!$signatureFound) { | ||
throw new Error\SignatureVerification( | ||
"No signatures found matching the expected signature for payload", | ||
$header, | ||
$payload | ||
); | ||
} | ||
|
||
// Check if timestamp is within tolerance | ||
if (($tolerance > 0) && ((time() - $timestamp) > $tolerance)) { | ||
throw new Error\SignatureVerification( | ||
"Timestamp outside the tolerance zone", | ||
$header, | ||
$payload | ||
); | ||
} | ||
|
||
return true; | ||
} | ||
|
||
/** | ||
* Extracts the timestamp in a signature header. | ||
* | ||
* @param string $header the signature header | ||
* @return int the timestamp contained in the header, or -1 if no valid | ||
* timestamp is found | ||
*/ | ||
private static function getTimestamp($header) | ||
{ | ||
$items = explode(",", $header); | ||
|
||
foreach ($items as $item) { | ||
$itemParts = explode("=", $item, 2); | ||
if ($itemParts[0] == "t") { | ||
if (!is_numeric($itemParts[1])) { | ||
return -1; | ||
} | ||
return intval($itemParts[1]); | ||
} | ||
} | ||
|
||
return -1; | ||
} | ||
|
||
/** | ||
* Extracts the signatures matching a given scheme in a signature header. | ||
* | ||
* @param string $header the signature header | ||
* @param string $scheme the signature scheme to look for. | ||
* @return array the list of signatures matching the provided scheme. | ||
*/ | ||
private static function getSignatures($header, $scheme) | ||
{ | ||
$signatures = array(); | ||
$items = explode(",", $header); | ||
|
||
foreach ($items as $item) { | ||
$itemParts = explode("=", $item, 2); | ||
if ($itemParts[0] == $scheme) { | ||
array_push($signatures, $itemParts[1]); | ||
} | ||
} | ||
|
||
return $signatures; | ||
} | ||
|
||
/** | ||
* Computes the signature for a given payload and secret. | ||
* | ||
* The current scheme used by Stripe ("v1") is HMAC/SHA-256. | ||
* | ||
* @param string $payload the payload to sign. | ||
* @param string $secret the secret used to generate the signature. | ||
* @return string the signature as a string. | ||
*/ | ||
private static function computeSignature($payload, $secret) | ||
{ | ||
return hash_hmac("sha256", $payload, $secret); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,110 @@ | ||
<?php | ||
|
||
namespace Stripe; | ||
|
||
class WebhookTest extends TestCase | ||
{ | ||
const EVENT_PAYLOAD = "{ | ||
\"id\": \"evt_test_webhook\", | ||
\"object\": \"event\" | ||
}"; | ||
const SECRET = "whsec_test_secret"; | ||
|
||
private function generateHeader($opts = array()) | ||
{ | ||
$timestamp = array_key_exists('timestamp', $opts) ? $opts['timestamp'] : time(); | ||
$payload = array_key_exists('payload', $opts) ? $opts['payload'] : self::EVENT_PAYLOAD; | ||
$secret = array_key_exists('secret', $opts) ? $opts['secret'] : self::SECRET; | ||
$scheme = array_key_exists('scheme', $opts) ? $opts['scheme'] : WebhookSignature::EXPECTED_SCHEME; | ||
$signature = array_key_exists('signature', $opts) ? $opts['signature'] : null; | ||
if ($signature === null) { | ||
$signedPayload = "$timestamp.$payload"; | ||
$signature = hash_hmac("sha256", $signedPayload, $secret); | ||
} | ||
return "t=$timestamp,$scheme=$signature"; | ||
} | ||
|
||
public function testValidJsonAndHeader() | ||
{ | ||
$sigHeader = $this->generateHeader(); | ||
$event = Webhook::createEventFromPayload(self::EVENT_PAYLOAD, $sigHeader, self::SECRET); | ||
$this->assertEquals("evt_test_webhook", $event->id); | ||
} | ||
|
||
/** | ||
* @expectedException \UnexpectedValueException | ||
*/ | ||
public function testInvalidJson() | ||
{ | ||
$payload = "this is not valid JSON"; | ||
$sigHeader = $this->generateHeader(array("payload" => $payload)); | ||
Webhook::createEventFromPayload($payload, $sigHeader, self::SECRET); | ||
} | ||
|
||
/** | ||
* @expectedException \Stripe\Error\SignatureVerification | ||
*/ | ||
public function testValidJsonAndInvalidHeader() | ||
{ | ||
$sigHeader = "bad_header"; | ||
Webhook::createEventFromPayload(self::EVENT_PAYLOAD, $sigHeader, self::SECRET); | ||
} | ||
|
||
/** | ||
* @expectedException \Stripe\Error\SignatureVerification | ||
* @expectedExceptionMessage Unable to extract timestamp and signatures from header | ||
*/ | ||
public function testMalformedHeader() | ||
{ | ||
$sigHeader = "i'm not even a real signature header"; | ||
WebhookSignature::verifyHeader(self::EVENT_PAYLOAD, $sigHeader, self::SECRET); | ||
} | ||
|
||
/** | ||
* @expectedException \Stripe\Error\SignatureVerification | ||
* @expectedExceptionMessage No signatures found with expected scheme | ||
*/ | ||
public function testNoSignaturesWithExpectedScheme() | ||
{ | ||
$sigHeader = $this->generateHeader(array("scheme" => "v0")); | ||
WebhookSignature::verifyHeader(self::EVENT_PAYLOAD, $sigHeader, self::SECRET); | ||
} | ||
|
||
/** | ||
* @expectedException \Stripe\Error\SignatureVerification | ||
* @expectedExceptionMessage No signatures found matching the expected signature for payload | ||
*/ | ||
public function testNoValidSignatureForPayload() | ||
{ | ||
$sigHeader = $this->generateHeader(array("signature" => "bad_signature")); | ||
WebhookSignature::verifyHeader(self::EVENT_PAYLOAD, $sigHeader, self::SECRET); | ||
} | ||
|
||
/** | ||
* @expectedException \Stripe\Error\SignatureVerification | ||
* @expectedExceptionMessage Timestamp outside the tolerance zone | ||
*/ | ||
public function testTimestampOutsideTolerance() | ||
{ | ||
$sigHeader = $this->generateHeader(array("timestamp" => time() - 15)); | ||
WebhookSignature::verifyHeader(self::EVENT_PAYLOAD, $sigHeader, self::SECRET, 10); | ||
} | ||
|
||
public function testValidHeaderAndSignature() | ||
{ | ||
$sigHeader = $this->generateHeader(); | ||
$this->assertTrue(WebhookSignature::verifyHeader(self::EVENT_PAYLOAD, $sigHeader, self::SECRET, 10)); | ||
} | ||
|
||
public function testHeaderContainsValidSignature() | ||
{ | ||
$sigHeader = $this->generateHeader() . ",v1=bad_signature"; | ||
$this->assertTrue(WebhookSignature::verifyHeader(self::EVENT_PAYLOAD, $sigHeader, self::SECRET, 10)); | ||
} | ||
|
||
public function testTimestampOffButNoTolerance() | ||
{ | ||
$sigHeader = $this->generateHeader(array("timestamp" => 12345)); | ||
$this->assertTrue(WebhookSignature::verifyHeader(self::EVENT_PAYLOAD, $sigHeader, self::SECRET)); | ||
} | ||
} |