1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
|
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt
from cryptography.hazmat.primitives.asymmetric.ec import SECP256R1
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicNumbers
import hashlib
import hmac
import json
import jwt
import time
from postgresqleu.invoices.models import InvoicePaymentMethod
from postgresqleu.scheduler.util import trigger_immediate_job_run
from postgresqleu.plaid.models import PlaidWebhookData
def _validate_signature(request, method):
signed_jwt = request.META.get('HTTP_PLAID_VERIFICATION', '')
current_key_id = jwt.get_unverified_header(signed_jwt)['kid']
impl = method.get_implementation()
key = impl.get_signing_key(current_key_id)
if not key:
print("Signing key {} not found".format(current_key_id))
return False
if key.get('expired_at', None) is not None:
print("Key expired")
return False
if key['kty'] != 'EC' or key['alg'] != 'ES256' or key['crv'] != 'P-256':
print("Unknown type of key")
return False
# This is included in newest versions of pyjwt, but not the ones currently deployed,
# so steal their implementation over here.
x = jwt.utils.base64url_decode(key.get("x"))
y = jwt.utils.base64url_decode(key.get("y"))
try:
curve_obj = SECP256R1()
public_numbers = EllipticCurvePublicNumbers(
x=int.from_bytes(x, byteorder="big"),
y=int.from_bytes(y, byteorder="big"),
curve=curve_obj,
)
claims = jwt.decode(signed_jwt, public_numbers.public_key(), algorithms=['ES256'])
except jwt.exceptions.PyJWTError as e:
print("Exception validating jwt: {}".format(e))
return False
except Exception as ee:
print("Exception processing jwt: {}".format(ee))
return False
if claims["iat"] < time.time() - 5 * 60:
print("Claim expired")
return False
m = hashlib.sha256()
m.update(request.body)
body_hash = m.hexdigest()
if not hmac.compare_digest(body_hash, claims['request_body_sha256']):
print("Hash of webhook did not validate")
return False
return True
@csrf_exempt
def webhook(request, methodid):
if request.method != 'POST':
raise Http404()
if 'application/json' not in request.META['CONTENT_TYPE']:
print(request.META['CONTENT_TYPE'])
return HttpResponse("Invalid content type", status=400)
try:
j = json.loads(request.body)
except json.decoder.JSONDecodeError:
return HttpResponse("Invalid json", status=400)
# Store a copy of the webhook, for tracing
PlaidWebhookData(
source=request.META['REMOTE_ADDR'],
signature=request.META.get('HTTP_PLAID_VERIFICATION', ''),
hook_code=j.get('webhook_code', None),
contents=j,
).save()
# Process any type of webhook we know what to do with
if j.get('webhook_type', None) == 'TRANSACTIONS' and j.get('webhook_code', None) == 'SYNC_UPDATES_AVAILABLE':
# Just ensure the object exists, and then throw it away, since we
# don't have a way to pass parameters to the job. We assume the
# number of plaid accounts to poll is never *that* big, and it's not
# like we expects several of these hooks to arrive per minute or so..
method = get_object_or_404(InvoicePaymentMethod, pk=methodid, classname="postgresqleu.util.payment.plaid.Plaid")
if not _validate_signature(request, method):
return HttpResponse("Invalid signature", status=400)
trigger_immediate_job_run('plaid_fetch_transactions')
return HttpResponse("OK", content_type="text/plain")
|