Skip to content

Commit 2905d95

Browse files
author
marcel corso gonzalez
authored
Merge pull request #69 from nqkdev/master
Add support for new webhook signature method
2 parents 5f4d9ae + 925b1e4 commit 2905d95

File tree

8 files changed

+619
-24
lines changed

8 files changed

+619
-24
lines changed

examples/signed_request.py

Lines changed: 0 additions & 22 deletions
This file was deleted.

examples/signed_request_validation.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!/usr/bin/env python
2+
import argparse
3+
import messagebird
4+
from messagebird.error import ValidationError
5+
6+
parser = argparse.ArgumentParser()
7+
parser.add_argument('--signingKey', help='access key for MessageBird API', type=str, required=True)
8+
parser.add_argument('--signature', help='the signature', type=str, required=True)
9+
parser.add_argument('--requestURL', help='the full request url', type=str, required=True)
10+
parser.add_argument('--requestBody', help='the request body', type=str, required=False)
11+
args = vars(parser.parse_args())
12+
13+
request_validator = messagebird.RequestValidator(args['signingKey'])
14+
15+
try:
16+
request_validator.validate_signature(args['signature'], args['requestURL'], bytearray(args['requestBody'].encode()))
17+
except ValidationError as err:
18+
print("The signed request cannot be verified: ", str(err))

messagebird/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
from messagebird.client import Client, ErrorException
22
from messagebird.signed_request import SignedRequest
3+
from messagebird.request_validator import RequestValidator

messagebird/request_validator.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import hashlib
2+
import hmac
3+
import jwt
4+
5+
from typing import Union, Dict
6+
from messagebird.error import ValidationError
7+
8+
9+
class RequestValidator:
10+
"""
11+
RequestValidator validates request signature signed by MessageBird services.
12+
13+
See https://developers.messagebird.com/docs/verify-http-requests
14+
"""
15+
16+
ALLOWED_ALGOS = ["HS256", "HS384", "HS512"]
17+
18+
def __init__(self, signature_key: str, skip_url_validation: bool = False):
19+
"""
20+
:param signature_key: customer signature key. Can be retrieved through
21+
<a href="https://dashboard.messagebird.com/developers/settings">Developer Settings</a>. This is NOT your API key.
22+
:param skip_url_validation: whether url_hash claim validation should be skipped.
23+
Note that when true, no query parameters should be trusted.
24+
"""
25+
super().__init__()
26+
self._signature_key = signature_key
27+
self._skip_url_validation = skip_url_validation
28+
29+
def __str__(self) -> str:
30+
return super().__str__()
31+
32+
def validate_signature(self, signature: str, url: str, request_body: Union[bytes, bytearray]) -> Dict[str, str]:
33+
"""
34+
This method validates provided request signature, which is a JWT token.
35+
This JWT is signed with a MessageBird account unique secret key, ensuring the request is from MessageBird and
36+
a specific account.
37+
38+
The JWT contains the following claims:
39+
40+
* "url_hash" - the raw URL hashed with SHA256 ensuring the URL wasn't altered.
41+
* "payload_hash" - the raw payload hashed with SHA256 ensuring the payload wasn't altered.
42+
* "jti" - a unique token ID to implement an optional non-replay check (NOT validated by default).
43+
* "nbf" - the not before timestamp.
44+
* "exp" - the expiration timestamp is ensuring that a request isn't captured and used at a later time.
45+
* "iss" - the issuer name, always MessageBird.
46+
47+
:param signature: the actual signature taken from request header "MessageBird-Signature-JWT".
48+
:param url: the raw url including the protocol, hostname and query string, e.g. "https://example.com/?example=42".
49+
:param request_body: the raw request body.
50+
:returns: raw signature payload.
51+
:raises: ValidationError if signature is invalid.
52+
"""
53+
if not signature:
54+
raise ValidationError("Signature is empty")
55+
if not self._skip_url_validation and not url:
56+
raise ValidationError("URL is empty")
57+
58+
try:
59+
claims = jwt.decode(
60+
jwt=signature,
61+
key=self._signature_key,
62+
algorithms=RequestValidator.ALLOWED_ALGOS,
63+
options={
64+
"require": ["iss", "nbf", "exp"],
65+
"verify_iat": False,
66+
},
67+
issuer="MessageBird",
68+
leeway=1
69+
)
70+
except jwt.InvalidTokenError as err:
71+
raise ValidationError(str(err)) from err
72+
73+
if not self._skip_url_validation:
74+
expected_url_hash = hashlib.sha256(url.encode("utf-8")).hexdigest()
75+
if not hmac.compare_digest(expected_url_hash, claims["url_hash"]):
76+
raise ValidationError("invalid jwt: claim url_hash is invalid")
77+
78+
payload_hash = claims.get("payload_hash")
79+
if not request_body and payload_hash:
80+
raise ValidationError("invalid jwt: claim payload_hash is set but actual payload is missing")
81+
if request_body and not payload_hash:
82+
raise ValidationError("invalid jwt: claim payload_hash is not set but payload is present")
83+
if request_body and not hmac.compare_digest(hashlib.sha256(request_body).hexdigest(), payload_hash):
84+
raise ValidationError("invalid jwt: claim payload_hash is invalid")
85+
86+
return claims

messagebird/signed_request.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,48 @@
22
import hmac
33
import base64
44
import time
5+
from warnings import warn
56
from collections import OrderedDict
6-
77
from urllib.parse import urlencode
88

99

1010
class SignedRequest:
1111

1212
def __init__(self, requestSignature, requestTimestamp, requestBody, requestParameters):
13+
"""
14+
DEPRECATED
15+
"""
16+
warn("signed_request module is deprecated, "
17+
"use request_validator module instead",
18+
DeprecationWarning, stacklevel=2)
19+
1320
self._requestSignature = requestSignature
1421
self._requestTimestamp = str(requestTimestamp)
1522
self._requestBody = requestBody
1623
self._requestParameters = requestParameters
1724

1825
def verify(self, signing_key):
26+
"""
27+
DEPRECATED
28+
"""
29+
warn("signed_request.verify is deprecated, "
30+
"use request_validator.validate_signature instead",
31+
DeprecationWarning, stacklevel=2)
32+
1933
payload = self._build_payload()
2034
expected_signature = base64.b64decode(self._requestSignature)
2135
calculated_signature = hmac.new(signing_key.encode('latin-1'), payload.encode('latin-1'),
2236
hashlib.sha256).digest()
2337
return expected_signature == calculated_signature
2438

2539
def is_recent(self, offset=10):
40+
"""
41+
DEPRECATED
42+
"""
43+
warn("signed_request.is_recent is deprecated, "
44+
"use request_validator package instead",
45+
DeprecationWarning, stacklevel=2)
46+
2647
return int(time.time()) - int(self._requestTimestamp) < offset
2748

2849
def _build_payload(self):

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
url = 'https://github.com/messagebird/python-rest-api',
1818
download_url = 'https://github.com/messagebird/python-rest-api/tarball/2.0.0',
1919
keywords = ['messagebird', 'sms'],
20-
install_requires = ['requests>=2.4.1', 'python-dateutil>=2.6.0'],
20+
install_requires = ['requests>=2.4.1', 'python-dateutil>=2.6.0', 'pyjwt>=2.1.0'],
2121
extras_require = {
2222
'dev': [
2323
'pytest',

tests/test_request_validator.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import json
2+
import re
3+
from unittest import mock
4+
from pathlib import Path
5+
6+
import pytest as pytest
7+
8+
from messagebird.base import Base
9+
from messagebird.error import ValidationError
10+
from messagebird.request_validator import RequestValidator
11+
12+
ERROR_MAP = {
13+
"invalid jwt: claim nbf is in the future": "The token is not yet valid (nbf)",
14+
"invalid jwt: claim exp is in the past": "Signature has expired",
15+
"invalid jwt: signature is invalid": "Signature verification failed",
16+
"invalid jwt: signing method none is invalid": "The specified alg value is not allowed"
17+
}
18+
19+
20+
def load_test_cases():
21+
test_data_file = Path(__file__).parent / 'test_request_validator/webhook_signature_test_data.json'
22+
with open(test_data_file) as f:
23+
tc_data = json.loads(f.read())
24+
25+
return tc_data
26+
27+
28+
@mock.patch('jwt.api_jwt.datetime')
29+
@pytest.mark.parametrize('test_case', load_test_cases(), ids=lambda args: args['name'])
30+
def test_validate_signature(mock_dt, test_case):
31+
mock_dt.utcnow = mock.Mock(return_value=Base.value_to_time(test_case['timestamp']))
32+
33+
validator = RequestValidator(test_case.get('secret'))
34+
35+
payload = test_case.get('payload')
36+
if payload:
37+
payload = bytes(payload.encode())
38+
39+
def run_decode():
40+
return validator.validate_signature(test_case['token'], test_case['url'], payload)
41+
42+
if not test_case['valid']:
43+
err = ERROR_MAP.get(test_case["reason"]) or test_case["reason"]
44+
pytest.raises(ValidationError, run_decode).match(re.escape(err))
45+
return
46+
47+
decoded = run_decode()
48+
49+
assert decoded is not None

0 commit comments

Comments
 (0)