diff options
author | Magnus Hagander | 2023-06-18 15:48:16 +0000 |
---|---|---|
committer | Magnus Hagander | 2023-06-18 16:14:07 +0000 |
commit | 989a343a64ab9b372c606ce0cb716dc43f7574c0 (patch) | |
tree | 67e4f74040e203261679a736a3d28d3ccf3be3a7 /postgresqleu/plaid/views.py | |
parent | 7f204cf595c5fb5a0daae20fe1f9eb809b173acb (diff) |
Implement support for Plaid banktransactions
This module will use the plaid.com service to download bank transaction
lists from any supported bank. If available, it will also respond to
webhooks sent by plaid whenever transactions show up, but failing that
will just poll twice per day.
Diffstat (limited to 'postgresqleu/plaid/views.py')
-rw-r--r-- | postgresqleu/plaid/views.py | 106 |
1 files changed, 106 insertions, 0 deletions
diff --git a/postgresqleu/plaid/views.py b/postgresqleu/plaid/views.py new file mode 100644 index 00000000..aa16766e --- /dev/null +++ b/postgresqleu/plaid/views.py @@ -0,0 +1,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") |