Skip to content

Commit 6e3248b

Browse files
author
khanh.nguyen
committed
Update request validator to support new webhook signature
1 parent 128c781 commit 6e3248b

8 files changed

+653
-96
lines changed

composer.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"require": {
1919
"php": ">=7.3|^8.0",
2020
"ext-curl": "*",
21-
"ext-json": "*"
21+
"ext-json": "*",
22+
"firebase/php-jwt": "^5.4"
2223
},
2324
"require-dev": {
2425
"phpunit/phpunit": "^8.0|^9.0",
+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
require_once(__DIR__ . '/../autoload.php');
4+
5+
// Create the validator for incoming requests.
6+
$requestValidator = new \MessageBird\RequestValidator('YOUR_SIGNING_KEY');
7+
8+
// Verify the incoming request from the PHP global variables.
9+
try {
10+
$request = $requestValidator->validateRequestFromGlobals();
11+
} catch (\MessageBird\Exceptions\ValidationException $e) {
12+
// The request was invalid, so respond accordingly.
13+
http_response_code(412);
14+
}
15+
16+
// Or directly verify the signature of the incoming request
17+
$signature = 'JWT_TOKEN_STRING';
18+
$url = 'https://yourdomain.com/path';
19+
$body = 'REQUEST_BODY';
20+
21+
try {
22+
$request = $requestValidator->validateSignature($signature, $url, $body);
23+
} catch (\MessageBird\Exceptions\ValidationException $e) {
24+
// The request was invalid, so respond accordingly.
25+
http_response_code(412);
26+
}

examples/signedrequest-verification.php

-19
This file was deleted.

src/MessageBird/Objects/SignedRequest.php

+4
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
namespace MessageBird\Objects;
44

55
use MessageBird\Exceptions\ValidationException;
6+
use MessageBird\RequestValidator;
67

78
/**
89
* Class SignedRequest
910
*
1011
* @package MessageBird\Objects
1112
*
1213
* @link https://developers.messagebird.com/docs/verify-http-requests
14+
* @deprecated Use {@link RequestValidator} instead.
1315
*/
1416
class SignedRequest extends Base
1517
{
@@ -46,6 +48,7 @@ class SignedRequest extends Base
4648
*
4749
* @return SignedRequest
4850
* @throws ValidationException when a required parameter is missing.
51+
* @deprecated Use {@link RequestValidator::validateRequestFromGlobals()} instead.
4952
*/
5053
public static function createFromGlobals()
5154
{
@@ -70,6 +73,7 @@ public static function createFromGlobals()
7073
* @param string $body The request body
7174
* @return SignedRequest
7275
* @throws ValidationException when a required parameter is missing.
76+
* @deprecated Use {@link RequestValidator::validateSignature()} instead.
7377
*/
7478
public static function create($query, $signature, $requestTimestamp, $body)
7579
{

src/MessageBird/RequestValidator.php

+98-4
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,22 @@
22

33
namespace MessageBird;
44

5+
use Firebase\JWT\JWT;
6+
use Firebase\JWT\SignatureInvalidException;
7+
use MessageBird\Exceptions\ValidationException;
58
use MessageBird\Objects\SignedRequest;
69

710
/**
8-
* Class RequestValidator
11+
* Class RequestValidator validates request signature signed by MessageBird services.
912
*
1013
* @package MessageBird
14+
* @see https://developers.messagebird.com/docs/verify-http-requests
1115
*/
1216
class RequestValidator
1317
{
1418
const BODY_HASH_ALGO = 'sha256';
1519
const HMAC_HASH_ALGO = 'sha256';
20+
const ALLOWED_ALGOS = array('HS256', 'HS384', 'HS512');
1621

1722
/**
1823
* The key with which requests will be signed by MessageBird.
@@ -21,21 +26,36 @@ class RequestValidator
2126
*/
2227
private $signingKey;
2328

29+
/**
30+
* This field instructs Validator to not validate url_hash claim.
31+
* It is recommended to not skip URL validation to ensure high security.
32+
* but the ability to skip URL validation is necessary in some cases, e.g.
33+
* your service is behind proxy or when you want to validate it yourself.
34+
* Note that when true, no query parameters should be trusted.
35+
* Defaults to false.
36+
*
37+
* @var bool
38+
*/
39+
private $skipURLValidation;
40+
2441
/**
2542
* RequestValidator constructor.
2643
*
27-
* @param string $signingKey
44+
* @param string $signingKey customer signature key. Can be retrieved through <a href="https://dashboard.messagebird.com/developers/settings">Developer Settings</a>. This is NOT your API key.
45+
* @param bool $skipURLValidation whether url_hash claim validation should be skipped. Note that when true, no query parameters should be trusted.
2846
*/
29-
public function __construct($signingKey)
47+
public function __construct(string $signingKey, bool $skipURLValidation = false)
3048
{
3149
$this->signingKey = $signingKey;
50+
$this->skipURLValidation = $skipURLValidation;
3251
}
3352

3453
/**
3554
* Verify that the signed request was submitted from MessageBird using the known key.
3655
*
3756
* @param SignedRequest $request
3857
* @return bool
58+
* @deprecated Use {@link RequestValidator::validateSignature()} instead.
3959
*/
4060
public function verify(SignedRequest $request)
4161
{
@@ -47,6 +67,9 @@ public function verify(SignedRequest $request)
4767
return \hash_equals($expectedSignature, $calculatedSignature);
4868
}
4969

70+
/**
71+
* @deprecated Use {@link RequestValidator::validateSignature()} instead.
72+
*/
5073
private function buildPayloadFromRequest(SignedRequest $request): string
5174
{
5275
$parts = [];
@@ -71,9 +94,80 @@ private function buildPayloadFromRequest(SignedRequest $request): string
7194
* @param SignedRequest $request The signed request object.
7295
* @param int $offset The maximum number of seconds that is allowed to consider the request recent
7396
* @return bool
97+
* @deprecated Use {@link RequestValidator::validateSignature()} instead.
7498
*/
7599
public function isRecent(SignedRequest $request, $offset = 10)
76100
{
77-
return (\time() - (int) $request->requestTimestamp) < $offset;
101+
return (\time() - (int)$request->requestTimestamp) < $offset;
102+
}
103+
104+
/**
105+
* Validate JWT signature.
106+
* This JWT is signed with a MessageBird account unique secret key, ensuring the request is from MessageBird and a specific account.
107+
* The JWT contains the following claims:
108+
* - "url_hash" - the raw URL hashed with SHA256 ensuring the URL wasn't altered.
109+
* - "payload_hash" - the raw payload hashed with SHA256 ensuring the payload wasn't altered.
110+
* - "jti" - a unique token ID to implement an optional non-replay check (NOT validated by default).
111+
* - "nbf" - the not before timestamp.
112+
* - "exp" - the expiration timestamp is ensuring that a request isn't captured and used at a later time.
113+
* - "iss" - the issuer name, always MessageBird.
114+
*
115+
* @param string $signature the actual signature taken from request header "MessageBird-Signature-JWT".
116+
* @param string $url the raw url including the protocol, hostname and query string, {@code https://example.com/?example=42}.
117+
* @param string $body the raw request body.
118+
* @return object JWT token payload
119+
* @throws ValidationException if signature validation fails.
120+
*
121+
* @see https://developers.messagebird.com/docs/verify-http-requests
122+
*/
123+
public function validateSignature(string $signature, string $url, string $body)
124+
{
125+
if (empty($signature)) {
126+
throw new ValidationException("Signature cannot be empty.");
127+
}
128+
if (!$this->skipURLValidation && empty($url)) {
129+
throw new ValidationException("URL cannot be empty");
130+
}
131+
132+
JWT::$leeway = 1;
133+
try {
134+
$decoded = JWT::decode($signature, $this->signingKey, self::ALLOWED_ALGOS);
135+
} catch (\InvalidArgumentException | \UnexpectedValueException | SignatureInvalidException $e) {
136+
throw new ValidationException($e->getMessage(), $e->getCode(), $e);
137+
}
138+
139+
if (empty($decoded->iss) || $decoded->iss !== 'MessageBird') {
140+
throw new ValidationException('invalid jwt: claim iss has wrong value');
141+
}
142+
143+
if (!$this->skipURLValidation && !hash_equals(hash(self::HMAC_HASH_ALGO, $url), $decoded->url_hash)) {
144+
throw new ValidationException('invalid jwt: claim url_hash is invalid');
145+
}
146+
147+
switch (true) {
148+
case empty($body) && !empty($decoded->payload_hash):
149+
throw new ValidationException('invalid jwt: claim payload_hash is set but actual payload is missing');
150+
case !empty($body) && empty($decoded->payload_hash):
151+
throw new ValidationException('invalid jwt: claim payload_hash is not set but payload is present');
152+
case !empty($body) && !hash_equals(hash(self::HMAC_HASH_ALGO, $body), $decoded->payload_hash):
153+
throw new ValidationException('invalid jwt: claim payload_hash is invalid');
154+
}
155+
156+
return $decoded;
157+
}
158+
159+
/**
160+
* Validate request signature from PHP globals.
161+
*
162+
* @return object JWT token payload
163+
* @throws ValidationException if signature validation fails.
164+
*/
165+
public function validateRequestFromGlobals()
166+
{
167+
$signature = $_SERVER['MessageBird-Signature-JWT'] ?? null;
168+
$url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]";
169+
$body = file_get_contents('php://input');
170+
171+
return $this->validateSignature($signature, $url, $body);
78172
}
79173
}

0 commit comments

Comments
 (0)