mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-02 05:39:35 +00:00
Merge pull request #6 from anikeen-com/feature/webhooks
Verify Anikeen ID webhooks
This commit is contained in:
8 files changed
+202
-14
No files matched your search
@@ -183,6 +183,36 @@ Unconfirmed bookings are cancelled after 24 hours.
|
|||||||
`checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl`
|
`checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl`
|
||||||
points to the same page. Trusted apps keep checking out directly.
|
points to the same page. Trusted apps keep checking out directly.
|
||||||
|
|
||||||
|
### Webhooks
|
||||||
|
|
||||||
|
Anikeen ID sends `order.*` and `subscription.*` events to the webhook endpoints of your app (set up
|
||||||
|
in the Anikeen ID admin panel, which also shows the signing secret `whsec_…`). Verify each request
|
||||||
|
with the raw body and answer 2xx within 10 seconds; failed deliveries are retried for about three days.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Anikeen\Id\Exceptions\SignatureVerificationException;
|
||||||
|
use Anikeen\Id\Webhooks\Webhook;
|
||||||
|
|
||||||
|
Route::post('webhooks/anikeen', function (Request $request) {
|
||||||
|
try {
|
||||||
|
$event = Webhook::constructEvent($request->getContent(), $request->header('Anikeen-Signature'), config('services.anikeen.webhook_secret'));
|
||||||
|
} catch (SignatureVerificationException) {
|
||||||
|
abort(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
match ($event['type']) {
|
||||||
|
'order.completed' => Shop::fulfil($event['data']['object']['id']),
|
||||||
|
'subscription.cancelled' => Panel::suspend($event['data']['object']['id']),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
})->withoutMiddleware(VerifyCsrfToken::class);
|
||||||
|
```
|
||||||
|
|
||||||
|
Events can arrive more than once or out of order: use `$event['id']` to skip duplicates, and
|
||||||
|
reload the object through the API when the current state matters.
|
||||||
|
|
||||||
### Change the default access token / refresh token field name
|
### Change the default access token / refresh token field name
|
||||||
|
|
||||||
If you access / refresh token fields differs from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method:
|
If you access / refresh token fields differs from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method:
|
||||||
|
|||||||
+30
@@ -183,6 +183,36 @@ Unconfirmed bookings are cancelled after 24 hours.
|
|||||||
`checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl`
|
`checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl`
|
||||||
points to the same page. Trusted apps keep checking out directly.
|
points to the same page. Trusted apps keep checking out directly.
|
||||||
|
|
||||||
|
### Webhooks
|
||||||
|
|
||||||
|
Anikeen ID sends `order.*` and `subscription.*` events to the webhook endpoints of your app (set up
|
||||||
|
in the Anikeen ID admin panel, which also shows the signing secret `whsec_…`). Verify each request
|
||||||
|
with the raw body and answer 2xx within 10 seconds; failed deliveries are retried for about three days.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Anikeen\Id\Exceptions\SignatureVerificationException;
|
||||||
|
use Anikeen\Id\Webhooks\Webhook;
|
||||||
|
|
||||||
|
Route::post('webhooks/anikeen', function (Request $request) {
|
||||||
|
try {
|
||||||
|
$event = Webhook::constructEvent($request->getContent(), $request->header('Anikeen-Signature'), config('services.anikeen.webhook_secret'));
|
||||||
|
} catch (SignatureVerificationException) {
|
||||||
|
abort(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
match ($event['type']) {
|
||||||
|
'order.completed' => Shop::fulfil($event['data']['object']['id']),
|
||||||
|
'subscription.cancelled' => Panel::suspend($event['data']['object']['id']),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
})->withoutMiddleware(VerifyCsrfToken::class);
|
||||||
|
```
|
||||||
|
|
||||||
|
Events can arrive more than once or out of order: use `$event['id']` to skip duplicates, and
|
||||||
|
reload the object through the API when the current state matters.
|
||||||
|
|
||||||
### Change the default access token / refresh token field name
|
### Change the default access token / refresh token field name
|
||||||
|
|
||||||
If you access / refresh token fields differs from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method:
|
If you access / refresh token fields differs from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method:
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Anikeen\Id\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The webhook request was not signed by Anikeen ID with your endpoint's secret, or is too old.
|
||||||
|
*/
|
||||||
|
class SignatureVerificationException extends Exception
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
@@ -47,8 +47,6 @@ class AppSubscriptions
|
|||||||
* vat_rate?: float,
|
* vat_rate?: float,
|
||||||
* payload?: null|array,
|
* payload?: null|array,
|
||||||
* ends_at?: null|string,
|
* ends_at?: null|string,
|
||||||
* webhook_url?: null|string,
|
|
||||||
* webhook_secret?: null|string,
|
|
||||||
* idempotency_key?: null|string
|
* idempotency_key?: null|string
|
||||||
* } $attributes With an idempotency_key (max. 191 characters), a retried call returns the
|
* } $attributes With an idempotency_key (max. 191 characters), a retried call returns the
|
||||||
* subscription created by the first one instead of a duplicate.
|
* subscription created by the first one instead of a duplicate.
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ use Throwable;
|
|||||||
* @property float $vat_rate
|
* @property float $vat_rate
|
||||||
* @property array $payload
|
* @property array $payload
|
||||||
* @property string $ends_at
|
* @property string $ends_at
|
||||||
* @property string $webhook_url
|
|
||||||
* @property string $webhook_secret
|
|
||||||
* @property null|string $confirmation_url where the user confirms it (third-party apps)
|
* @property null|string $confirmation_url where the user confirms it (third-party apps)
|
||||||
*/
|
*/
|
||||||
class Subscription extends BaseResource
|
class Subscription extends BaseResource
|
||||||
@@ -36,9 +34,7 @@ class Subscription extends BaseResource
|
|||||||
* price: float,
|
* price: float,
|
||||||
* vat_rate: float,
|
* vat_rate: float,
|
||||||
* payload: null|array,
|
* payload: null|array,
|
||||||
* ends_at: null|string,
|
* ends_at: null|string
|
||||||
* webhook_url: null|string,
|
|
||||||
* webhook_secret: null|string
|
|
||||||
* } $attributes The subscription data:
|
* } $attributes The subscription data:
|
||||||
* - group: The group (optional)
|
* - group: The group (optional)
|
||||||
* - name: The name (required when set)
|
* - name: The name (required when set)
|
||||||
@@ -48,8 +44,6 @@ class Subscription extends BaseResource
|
|||||||
* - vat_rate: The VAT rate (required when set)
|
* - vat_rate: The VAT rate (required when set)
|
||||||
* - payload: The payload (optional)
|
* - payload: The payload (optional)
|
||||||
* - ends_at: The end date (optional)
|
* - ends_at: The end date (optional)
|
||||||
* - webhook_url: The webhook URL (optional)
|
|
||||||
* - webhook_secret: The webhook secret (optional)
|
|
||||||
* @throws Throwable
|
* @throws Throwable
|
||||||
*/
|
*/
|
||||||
public function update(array $attributes): self
|
public function update(array $attributes): self
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ class Subscriptions extends BaseCollection
|
|||||||
* price: float,
|
* price: float,
|
||||||
* vat_rate: float,
|
* vat_rate: float,
|
||||||
* payload: null|array,
|
* payload: null|array,
|
||||||
* ends_at: null|string,
|
* ends_at: null|string
|
||||||
* webhook_url: null|string,
|
|
||||||
* webhook_secret: null|string
|
|
||||||
* } $attributes The subscription data:
|
* } $attributes The subscription data:
|
||||||
* - group: The group (optional)
|
* - group: The group (optional)
|
||||||
* - name: The name
|
* - name: The name
|
||||||
@@ -34,8 +32,6 @@ class Subscriptions extends BaseCollection
|
|||||||
* - vat_rate: The VAT rate (required when set)
|
* - vat_rate: The VAT rate (required when set)
|
||||||
* - payload: The payload (optional)
|
* - payload: The payload (optional)
|
||||||
* - ends_at: The end date (optional)
|
* - ends_at: The end date (optional)
|
||||||
* - webhook_url: The webhook URL (optional)
|
|
||||||
* - webhook_secret: The webhook secret (optional)
|
|
||||||
* @throws Throwable
|
* @throws Throwable
|
||||||
*/
|
*/
|
||||||
public function create(array $attributes): Subscription
|
public function create(array $attributes): Subscription
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Anikeen\Id\Webhooks;
|
||||||
|
|
||||||
|
use Anikeen\Id\Exceptions\SignatureVerificationException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies webhooks from Anikeen ID, like Stripe's Webhook::constructEvent().
|
||||||
|
*
|
||||||
|
* Anikeen ID signs every request with your endpoint's signing secret (whsec_…):
|
||||||
|
* `Anikeen-Signature: t=<unix time>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`.
|
||||||
|
*/
|
||||||
|
final class Webhook
|
||||||
|
{
|
||||||
|
/** Requests older (or newer) than this many seconds are rejected to prevent replays. */
|
||||||
|
public const DEFAULT_TOLERANCE = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the signature and returns the event: ['id', 'type', 'created', 'data' => ['object' => …]].
|
||||||
|
*
|
||||||
|
* @param string $payload the raw request body (not re-encoded JSON)
|
||||||
|
* @param string|null $signatureHeader the Anikeen-Signature header
|
||||||
|
*
|
||||||
|
* @throws SignatureVerificationException
|
||||||
|
*/
|
||||||
|
public static function constructEvent(string $payload, ?string $signatureHeader, string $secret, int $tolerance = self::DEFAULT_TOLERANCE): array
|
||||||
|
{
|
||||||
|
self::verify($payload, $signatureHeader, $secret, $tolerance);
|
||||||
|
|
||||||
|
$event = json_decode($payload, true);
|
||||||
|
|
||||||
|
if (!is_array($event) || !isset($event['id'], $event['type'])) {
|
||||||
|
throw new SignatureVerificationException('The webhook payload is not a valid event.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws SignatureVerificationException
|
||||||
|
*/
|
||||||
|
public static function verify(string $payload, ?string $signatureHeader, string $secret, int $tolerance = self::DEFAULT_TOLERANCE): void
|
||||||
|
{
|
||||||
|
$timestamp = null;
|
||||||
|
$signatures = [];
|
||||||
|
|
||||||
|
foreach (explode(',', (string) $signatureHeader) as $part) {
|
||||||
|
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, null);
|
||||||
|
|
||||||
|
if ($key === 't' && ctype_digit((string) $value)) {
|
||||||
|
$timestamp = (int) $value;
|
||||||
|
} elseif ($key === 'v1' && $value) {
|
||||||
|
$signatures[] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($timestamp === null || $signatures === []) {
|
||||||
|
throw new SignatureVerificationException('The Anikeen-Signature header is missing or malformed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$expected = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret);
|
||||||
|
|
||||||
|
if (!array_filter($signatures, fn (string $signature) => hash_equals($expected, $signature))) {
|
||||||
|
throw new SignatureVerificationException('The webhook signature does not match. Check the signing secret.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($tolerance > 0 && abs(time() - $timestamp) > $tolerance) {
|
||||||
|
throw new SignatureVerificationException('The webhook timestamp is outside the tolerance.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Anikeen\Id\Tests;
|
||||||
|
|
||||||
|
use Anikeen\Id\Exceptions\SignatureVerificationException;
|
||||||
|
use Anikeen\Id\Webhooks\Webhook;
|
||||||
|
|
||||||
|
class WebhookTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $secret = 'whsec_test';
|
||||||
|
|
||||||
|
private string $payload = '{"id":"01JEVT","type":"order.completed","created":1790000000,"data":{"object":{"id":"01JORD"}}}';
|
||||||
|
|
||||||
|
private function header(?int $timestamp = null, ?string $secret = null): string
|
||||||
|
{
|
||||||
|
$timestamp ??= time();
|
||||||
|
|
||||||
|
return "t={$timestamp},v1=" . hash_hmac('sha256', "{$timestamp}.{$this->payload}", $secret ?? $this->secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_signed_event_is_returned(): void
|
||||||
|
{
|
||||||
|
$event = Webhook::constructEvent($this->payload, $this->header(), $this->secret);
|
||||||
|
|
||||||
|
$this->assertSame('order.completed', $event['type']);
|
||||||
|
$this->assertSame('01JORD', $event['data']['object']['id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_wrong_secret_is_rejected(): void
|
||||||
|
{
|
||||||
|
$this->expectException(SignatureVerificationException::class);
|
||||||
|
|
||||||
|
Webhook::constructEvent($this->payload, $this->header(secret: 'whsec_other'), $this->secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_changed_body_is_rejected(): void
|
||||||
|
{
|
||||||
|
$this->expectException(SignatureVerificationException::class);
|
||||||
|
|
||||||
|
Webhook::constructEvent(str_replace('01JORD', '01JXXX', $this->payload), $this->header(), $this->secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_old_requests_are_rejected(): void
|
||||||
|
{
|
||||||
|
$this->expectException(SignatureVerificationException::class);
|
||||||
|
|
||||||
|
Webhook::constructEvent($this->payload, $this->header(time() - 600), $this->secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_missing_header_is_rejected(): void
|
||||||
|
{
|
||||||
|
$this->expectException(SignatureVerificationException::class);
|
||||||
|
|
||||||
|
Webhook::constructEvent($this->payload, null, $this->secret);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user