If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
There's one last teeny, tiny little detail we need to worry about with webhooks: replay attacks. These are a security concern but also a practical one.
We already know that nobody can send us, random, fake event data because we fetch a fresh event from Stripe:
... lines 1 - 8 | |
class WebhookController extends BaseController | |
{ | |
... lines 11 - 13 | |
public function stripeWebhookAction(Request $request) | |
{ | |
$data = json_decode($request->getContent(), true); | |
if ($data === null) { | |
throw new \Exception('Bad JSON body from Stripe!'); | |
} | |
$eventId = $data['id']; | |
if ($this->getParameter('verify_stripe_event')) { | |
$stripeEvent = $this->get('stripe_client') | |
->findEvent($eventId); | |
... lines 26 - 28 | |
} | |
... lines 30 - 69 | |
} | |
... lines 71 - 90 | |
} |
But, someone could intercept a real webhook, and send it to us multiple times. I don't know why they would do that, but weird things would happen.
And there's also the practical concern. Suppose Stripe sends us a webhook and
we process it. But somehow, there was a connection problem between our server and
Stripe, so Stripe never received our 200 status code. Then, thinking that the webhook
failed, Stripe tries to send the webhook again. If this were for an invoice.payment_succeeded
event, one user might get two subscription renewal emails. That's weird.
Let's prevent that. And it's simple: create a database table that records all the event ID's we've handled. Then, query that table before processing a webhook to make sure we haven't seen it before.
In the AppBundle/Entity
directory, create a new PHP Class called StripeEventLog
:
... lines 1 - 2 | |
namespace AppBundle\Entity; | |
... lines 4 - 10 | |
class StripeEventLog | |
{ | |
... lines 13 - 34 | |
} |
Give it a few properties: $id
, $stripeEventId
and a $handledAt
date field:
... lines 1 - 10 | |
class StripeEventLog | |
{ | |
... lines 13 - 17 | |
private $id; | |
... lines 19 - 22 | |
private $stripeEventId; | |
... lines 24 - 27 | |
private $handledAt; | |
... lines 29 - 34 | |
} |
Since this project uses Doctrine, I'll add a special use
statement on top and
then add some annotations, so that this new class will become a new table in the
database. Use the "Code"->"Generate" menu, or Command
+ N
on a Mac and select "ORM Class":
... lines 1 - 4 | |
use Doctrine\ORM\Mapping as ORM; | |
/** | |
* @ORM\Entity | |
* @ORM\Table(name="stripe_event_log") | |
*/ | |
class StripeEventLog | |
{ | |
... lines 13 - 34 | |
} |
Repeat that and select "ORM Annotations". Choose all the fields:
... lines 1 - 10 | |
class StripeEventLog | |
{ | |
/** | |
* @ORM\Id | |
* @ORM\GeneratedValue(strategy="AUTO") | |
* @ORM\Column(type="integer") | |
*/ | |
private $id; | |
/** | |
* @ORM\Column(type="string", unique=true) | |
*/ | |
private $stripeEventId; | |
/** | |
* @ORM\Column(type="datetime") | |
*/ | |
private $handledAt; | |
... lines 29 - 34 | |
} |
Update stripeEventId
to be a string
field - that'll translate to a varchar in MySQL:
... lines 1 - 10 | |
class StripeEventLog | |
{ | |
... lines 13 - 19 | |
/** | |
* @ORM\Column(type="string", unique=true) | |
*/ | |
private $stripeEventId; | |
... lines 24 - 34 | |
} |
To set the properties, create a new __construct()
method with a $stripeEventId
argument. Inside, set that on the property and also set $this->handledAt
to a new
\DateTime()
to set this field to "right now":
... lines 1 - 10 | |
class StripeEventLog | |
{ | |
... lines 13 - 29 | |
public function __construct($stripeEventId) | |
{ | |
$this->stripeEventId = $stripeEventId; | |
$this->handledAt = new \DateTime(); | |
} | |
} |
Brilliant! And now that we have the entity class, find your terminal and run:
./bin/console doctrine:migrations:diff
This generates a new file in the app/DoctrineMigrations
directory that contains
the raw SQL needed to create the new table:
... lines 1 - 2 | |
namespace Application\Migrations; | |
use Doctrine\DBAL\Migrations\AbstractMigration; | |
use Doctrine\DBAL\Schema\Schema; | |
/** | |
* Auto-generated Migration: Please modify to your needs! | |
*/ | |
class Version20160807113428 extends AbstractMigration | |
{ | |
/** | |
* @param Schema $schema | |
*/ | |
public function up(Schema $schema) | |
{ | |
// this up() migration is auto-generated, please modify it to your needs | |
$this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); | |
$this->addSql('CREATE TABLE stripe_event_log (id INT AUTO_INCREMENT NOT NULL, stripe_event_id VARCHAR(255) NOT NULL, handled_at DATETIME NOT NULL, UNIQUE INDEX UNIQ_217D8BDC2CB034B8 (stripe_event_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB'); | |
} | |
/** | |
* @param Schema $schema | |
*/ | |
public function down(Schema $schema) | |
{ | |
// this down() migration is auto-generated, please modify it to your needs | |
$this->abortIf($this->connection->getDatabasePlatform()->getName() != 'mysql', 'Migration can only be executed safely on \'mysql\'.'); | |
$this->addSql('DROP TABLE stripe_event_log'); | |
} | |
} |
Execute that query by running:
./bin/console doctrine:migrations:migrate
Finally, in WebhookController
, start by querying to see if this event has been
handled before. Fetch the EntityManager, and then add
$existingLog = $em->getRepository('AppBundle:StripeEventLog')
and call
findOneBy()
on it to query for stripeEventId
set to $eventId
.
... lines 1 - 9 | |
class WebhookController extends BaseController | |
{ | |
... lines 12 - 14 | |
public function stripeWebhookAction(Request $request) | |
{ | |
... lines 17 - 21 | |
$eventId = $data['id']; | |
$em = $this->getDoctrine()->getManager(); | |
$existingLog = $em->getRepository('AppBundle:StripeEventLog') | |
->findOneBy(['stripeEventId' => $eventId]); | |
... lines 27 - 81 | |
} | |
... lines 83 - 102 | |
} |
If an $existingLog
is found, then we don't want to handle this. Just return a
new Response()
that says "Event previously handled":
... lines 1 - 9 | |
class WebhookController extends BaseController | |
{ | |
... lines 12 - 14 | |
public function stripeWebhookAction(Request $request) | |
{ | |
... lines 17 - 21 | |
$eventId = $data['id']; | |
$em = $this->getDoctrine()->getManager(); | |
$existingLog = $em->getRepository('AppBundle:StripeEventLog') | |
->findOneBy(['stripeEventId' => $eventId]); | |
if ($existingLog) { | |
return new Response('Event previously handled'); | |
} | |
... lines 30 - 81 | |
} | |
... lines 83 - 102 | |
} |
If you also want to log a message so that you know when this happens, that's not a bad idea.
But if there is not an existing log, time to process this webhook! Create a new
StripeEventLog
and pass it $eventId
. Then, persist and flush just the log:
... lines 1 - 9 | |
class WebhookController extends BaseController | |
{ | |
... lines 12 - 14 | |
public function stripeWebhookAction(Request $request) | |
{ | |
... lines 17 - 21 | |
$eventId = $data['id']; | |
$em = $this->getDoctrine()->getManager(); | |
$existingLog = $em->getRepository('AppBundle:StripeEventLog') | |
->findOneBy(['stripeEventId' => $eventId]); | |
if ($existingLog) { | |
return new Response('Event previously handled'); | |
} | |
$log = new StripeEventLog($eventId); | |
$em->persist($log); | |
$em->flush($log); | |
... lines 34 - 81 | |
} | |
... lines 83 - 102 | |
} |
And yea, replay attacks are gone!
To make sure we didn't mess anything up, open WebhookControllerTest
and copy our
test method. Run that:
./vendor/bin/phpunit --filter testStripeCustomerSubscriptionDeleted
Bah! Of course... it failed for a silly reason: I need to update my test database - to add the new table. A shortcut to do that is:
./bin/console doctrine:schema:update --force --env=test
Try the test now:
./vendor/bin/phpunit --filter testStripeCustomerSubscriptionDeleted
It works! So hey, run it again!
./vendor/bin/phpunit --filter testStripeCustomerSubscriptionDeleted
It fails?!
Failed to assert that
true
isfalse
.
Well, that's not clear, but I know what the problem is: every event in the test has the same event ID:
... lines 1 - 9 | |
class WebhookControllerTest extends WebTestCase | |
{ | |
... lines 12 - 70 | |
private function getCustomerSubscriptionDeletedEvent($subscriptionId) | |
{ | |
$json = <<<EOF | |
{ | |
... lines 75 - 76 | |
"id": "evt_00000000000000", | |
... lines 78 - 121 | |
} | |
EOF; | |
... lines 124 - 125 | |
} | |
} |
So when you run the test the second time, this already exists in the StripeEventLog
table and the webhook is skipped. Well hey, at least we know the replay attack system
is working.
To fix this, we need to set a little bit of randomness to the event ID by adding
a %s
at the end and adding an mt_rand()
to the sprintf()
:
... lines 1 - 9 | |
class WebhookControllerTest extends WebTestCase | |
{ | |
... lines 12 - 70 | |
private function getCustomerSubscriptionDeletedEvent($subscriptionId) | |
{ | |
$json = <<<EOF | |
{ | |
... lines 75 - 76 | |
"id": "evt_00000000000000%s", | |
... lines 78 - 121 | |
} | |
EOF; | |
return sprintf($json, mt_rand(), $subscriptionId); | |
} | |
} |
Now, every event ID will be unique. Try the test again:
./vendor/bin/phpunit --filter testStripeCustomerSubscriptionDeleted
Green and happy!
Ok, enough webhooks. Let's do something fun, like making it possible for a user to upgrade from one subscription to another.
Hey Kiuega!
This is awesome - thanks for sharing! Indeed, because they put a timestamp in their signature, this prevents replay attacks also (unless they attacker is able to intercept the webhook and use it within 5 minutes... but that doesn't leave an attacker much room to do this :) ).
You've also kept the StripeEventLog, which I like. Even if you've mitigated reply attacks, you still want to make sure that you don't handle a webhook multiple times. This can happen (in the wild) if, for some reason, Stripe sends you a webhook, you handle it, but you fail to respond successfully (so Stripe tries the webhook again later).
Cheers!
// composer.json
{
"require": {
"php": ">=5.5.9, <7.4",
"symfony/symfony": "3.1.*", // v3.1.10
"doctrine/orm": "^2.5", // v2.7.2
"doctrine/doctrine-bundle": "^1.6", // 1.6.8
"doctrine/doctrine-cache-bundle": "^1.2", // 1.3.0
"symfony/swiftmailer-bundle": "^2.3", // v2.6.2
"symfony/monolog-bundle": "^2.8", // v2.12.1
"symfony/polyfill-apcu": "^1.0", // v1.3.0
"sensio/distribution-bundle": "^5.0", // v5.0.22
"sensio/framework-extra-bundle": "^3.0.2", // v3.0.26
"incenteev/composer-parameter-handler": "^2.0", // v2.1.2
"friendsofsymfony/user-bundle": "~2.0.1", // v2.0.1
"stof/doctrine-extensions-bundle": "^1.2", // v1.2.2
"stripe/stripe-php": "^3.15", // v3.23.0
"doctrine/doctrine-migrations-bundle": "^1.1", // v1.2.1
"phpunit/phpunit": "^5.5", // 5.7.20
"composer/package-versions-deprecated": "^1.11" // 1.11.99
},
"require-dev": {
"sensio/generator-bundle": "^3.0", // v3.1.4
"symfony/phpunit-bridge": "^3.0", // v3.3.0
"hautelook/alice-bundle": "^1.3", // v1.4.1
"doctrine/data-fixtures": "^1.2" // v1.2.2
}
}
Hi ! I don't know if this was already possible at the time you did the tutorial, but I changed the way of "checking" an event based on the documentation: https://stripe.com/docs/webhooks/signatures
I adapted this for Symfony, while keeping some of what we did in the training, which would look something like this:
There you go, I don't know if it's more secure, less secure, or if it's the same thing. But I hope it can help more than one person!