-
Notifications
You must be signed in to change notification settings - Fork 2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Stripe webhook validator and test #17
Merged
Merged
Changes from 1 commit
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
32a583f
webhook validator and test
lohanidamodar e3a1aab
use env for test webhook secret
lohanidamodar 02daf39
webhook secret in action
lohanidamodar 71b6946
rearrange test
lohanidamodar 6cc333c
fix
lohanidamodar 07041f8
reset test
lohanidamodar 45c31a6
update flaky test
lohanidamodar 6e35377
fix
lohanidamodar File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,147 @@ | ||
<?php | ||
|
||
namespace Utopia\Pay\Validator\Stripe; | ||
|
||
// header | ||
// t=1723597289,v1=f53b5765cc9847786d33f8f96d9e22c0d08967271a734b1a69327e22ecf1bc73,v0=353c23cbcfc17f983e3089a339d2004174ee472df39e61d7e52805008ffad044 | ||
// secret | ||
// whsec_2FMR5OjJa6Czcj3G07HvMGjLsw8uw3dQ | ||
class Webhook | ||
{ | ||
public const DEFAULT_TOLERANCE = 300; | ||
|
||
public const EXPECTED_SCHEME = 'v1'; | ||
|
||
private static $isHashEqualsAvailable = null; | ||
|
||
/** | ||
* Verifies the signature header sent by Stripe. Throws an | ||
* Exception\SignatureVerificationException 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 | ||
* @return bool | ||
*/ | ||
public function isValid($payload, $header, $secret, $tolerance = null) | ||
{ | ||
// Extract timestamp and signatures from header | ||
$timestamp = $this->getTimestamp($header); | ||
$signatures = $this->getSignatures($header, self::EXPECTED_SCHEME); | ||
if (-1 === $timestamp) { | ||
return false; | ||
} | ||
if (empty($signatures)) { | ||
return false; | ||
} | ||
|
||
// Check if expected signature is found in list of signatures from | ||
// header | ||
$signedPayload = "{$timestamp}.{$payload}"; | ||
$expectedSignature = $this->computeSignature($signedPayload, $secret); | ||
$signatureFound = false; | ||
foreach ($signatures as $signature) { | ||
if ($this->secureCompare($expectedSignature, $signature)) { | ||
$signatureFound = true; | ||
|
||
break; | ||
} | ||
} | ||
if (! $signatureFound) { | ||
return false; | ||
} | ||
|
||
// Check if timestamp is within tolerance | ||
if (($tolerance > 0) && (\abs(\time() - $timestamp) > $tolerance)) { | ||
return false; | ||
} | ||
|
||
return true; | ||
} | ||
|
||
public function secureCompare($a, $b) | ||
{ | ||
if (null === self::$isHashEqualsAvailable) { | ||
self::$isHashEqualsAvailable = \function_exists('hash_equals'); | ||
} | ||
|
||
if (self::$isHashEqualsAvailable) { | ||
return \hash_equals($a, $b); | ||
} | ||
if (\strlen($a) !== \strlen($b)) { | ||
return false; | ||
} | ||
|
||
$result = 0; | ||
for ($i = 0; $i < \strlen($a); $i++) { | ||
$result |= \ord($a[$i]) ^ \ord($b[$i]); | ||
} | ||
|
||
return 0 === $result; | ||
} | ||
|
||
/** | ||
* 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 function getTimestamp($header) | ||
{ | ||
$items = \explode(',', $header); | ||
|
||
foreach ($items as $item) { | ||
$itemParts = \explode('=', $item, 2); | ||
if ('t' === $itemParts[0]) { | ||
if (! \is_numeric($itemParts[1])) { | ||
return -1; | ||
} | ||
|
||
return (int) ($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 function getSignatures($header, $scheme) | ||
{ | ||
$signatures = []; | ||
$items = \explode(',', $header); | ||
|
||
foreach ($items as $item) { | ||
$itemParts = \explode('=', $item, 2); | ||
if (\trim($itemParts[0]) === $scheme) { | ||
$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 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,33 @@ | ||
<?php | ||
|
||
namespace Utopia\Tests; | ||
|
||
use PHPUnit\Framework\TestCase; | ||
use Utopia\Pay\Validator\Stripe\Webhook; | ||
|
||
class WebhookTest extends TestCase | ||
{ | ||
public function testValid() | ||
{ | ||
$header = 't=1723597289,v1=ca18f2c5b48c347b26f2d862f29d93dc1c9c6b319ba2cd934db54333acef1492'; | ||
$secret = 'whsec_2FMR5OjJa6Czcj3G07HvMGjLsw8uw3dQ'; | ||
|
||
$validator = new Webhook(); | ||
|
||
// test valid (Tolerance set to high) | ||
$isValid = $validator->isValid('{"id": "pi_abcdefg"}', $header, $secret, PHP_INT_MAX); | ||
$this->assertTrue($isValid); | ||
|
||
// Test time tolerance low | ||
$isValid = $validator->isValid('{"id": "pi_abcdefg"}', $header, $secret, 10); | ||
$this->assertFalse($isValid); | ||
|
||
// payload doesn't match | ||
$isValid = $validator->isValid('{"id": "pi_abcdef"}', $header, $secret, PHP_INT_MAX); | ||
$this->assertFalse($isValid); | ||
|
||
// Secret doesn't match | ||
$isValid = $validator->isValid('{"id": "pi_abcdefg"}', $header, $secret.'ef', PHP_INT_MAX); | ||
$this->assertFalse($isValid); | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Has this key been invalidated?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes