From 9f25287a669ae1905e197349a37e090ad77404ef Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Wed, 6 Dec 2023 23:20:12 +0100 Subject: [PATCH] pwa: conserve VAPID headers in cache for 12 hours (#70987) The JSON webtoken is valid for 24 hours but only kept for 23 hours, to prevent any use after expiration. Also factorize webpush implementation from signal handling and remove unused legacy settings support. --- combo/apps/pwa/signals.py | 96 ++++++++++++++++++++++++++------------- debian/control | 1 + setup.py | 1 + tests/test_pwa.py | 76 +++++++++++++++++++++++-------- 4 files changed, 122 insertions(+), 52 deletions(-) diff --git a/combo/apps/pwa/signals.py b/combo/apps/pwa/signals.py index d2ad83af..ab5330d2 100644 --- a/combo/apps/pwa/signals.py +++ b/combo/apps/pwa/signals.py @@ -15,11 +15,14 @@ # along with this program. If not, see . import datetime +import hashlib import json import logging +import urllib.parse import pywebpush from django.conf import settings +from django.core.cache import cache from django.db.models.signals import post_save from django.dispatch import receiver from py_vapid import Vapid @@ -28,47 +31,76 @@ from combo.apps.notifications.models import Notification from .models import PushSubscription, PwaSettings +logger = logging.getLogger(__name__) + + +def get_vapid_headers(private_key, subscription_info): + url = urllib.parse.urlparse(subscription_info['endpoint']) + aud = f'{url.scheme}://{url.netloc}' + + key_bytes = private_key.encode('ascii') + + cache_key = 'vapid-headers-' + hashlib.sha256(aud.encode() + key_bytes).hexdigest() + headers = cache.get(cache_key) + if headers: + return headers + + pwa_vapid_private_key = Vapid.from_pem(key_bytes) + + headers = pwa_vapid_private_key.sign( + { + 'aud': aud, + 'sub': 'mailto:%s' % settings.DEFAULT_FROM_EMAIL, + 'exp': int(datetime.datetime.now().timestamp() + 3600 * 24), # expire after 24 hours + } + ) + + cache.set(cache_key, headers, 23 * 3600) # but keep it 23 hours + return headers + + +class DeadSubscription(Exception): + pass + + +def send_webpush(private_key, subscription_info, **kwargs): + message = json.dumps(kwargs) + + headers = get_vapid_headers(private_key, subscription_info) + webpusher = pywebpush.WebPusher(subscription_info) + response = webpusher.send( + data=message, + headers=headers, + ttl=86400 * 30, + ) + if response.status_code in (404, 410): + raise DeadSubscription + response.raise_for_status() + @receiver(post_save, sender=Notification) def notification(sender, instance=None, created=False, **kwargs): if not created: return + pwa_settings = PwaSettings.singleton() if not pwa_settings.push_notifications: return - if settings.PWA_VAPID_PRIVATE_KEY: # legacy - pwa_vapid_private_key = settings.PWA_VAPID_PRIVATE_KEY - else: - pwa_vapid_private_key = Vapid.from_pem( - pwa_settings.push_notifications_infos['private_key'].encode('ascii') - ) - if settings.PWA_VAPID_CLAIMS: # legacy - claims = settings.PWA_VAPID_CLAIMS - else: - claims = { - 'sub': 'mailto:%s' % settings.DEFAULT_FROM_EMAIL, - 'exp': int(datetime.datetime.now().timestamp() + 3600 * 3), - } - message = json.dumps( - { - 'summary': instance.summary, - 'body': instance.body, - 'url': instance.url, - } - ) - for subscription in PushSubscription.objects.filter(user_id=instance.user_id): + private_key = pwa_settings.push_notifications_infos['private_key'] + + for subscription in PushSubscription.objects.filter(user=instance.user): try: - pywebpush.webpush( + send_webpush( + private_key=private_key, subscription_info=subscription.subscription_info, - data=message, - vapid_private_key=pwa_vapid_private_key, - vapid_claims=claims, - ttl=86400 * 30, + summary=instance.summary, + body=instance.body, + url=instance.url, ) - except pywebpush.WebPushException as e: - if 'Push failed: 410 Gone' in str(e): - subscription.delete() - continue - logger = logging.getLogger(__name__) - logger.exception('webpush error (%r)', e) + logger.info('webpush: notification sent') + except DeadSubscription: + subscription.delete() + logger.info('webpush: deleting dead subscription') + except Exception: + logger.exception('webpush: request failed') diff --git a/debian/control b/debian/control index 77cda838..afc5c70f 100644 --- a/debian/control +++ b/debian/control @@ -23,6 +23,7 @@ Depends: python3-distutils, python3-lxml, python3-pil, python3-publik-django-templatetags, + python3-py-vapid (>= 1.8.2), python3-pycryptodome, python3-pyexcel-ods, python3-pygal, diff --git a/setup.py b/setup.py index 3345d01b..1171630e 100644 --- a/setup.py +++ b/setup.py @@ -178,6 +178,7 @@ setup( 'pyproj', 'pyquery', 'pywebpush', + 'py-vapid>=1.8.2', 'pygal', 'pyexcel-ods', 'lxml', diff --git a/tests/test_pwa.py b/tests/test_pwa.py index b4faca80..a1a89cbf 100644 --- a/tests/test_pwa.py +++ b/tests/test_pwa.py @@ -4,7 +4,6 @@ from io import BytesIO from unittest import mock import pytest -from django.conf import settings from django.core.files import File from django.template import Context, Template from django.test import override_settings @@ -71,37 +70,74 @@ def test_webpush_subscription(app, john_doe, jane_doe): assert 'bad json' in resp.text -def test_webpush_notification(app, john_doe): +def test_webpush_notification(app, john_doe, caplog): + caplog.set_level('INFO') PushSubscription.objects.all().delete() app = login(app, john_doe.username, john_doe.username) - app.post_json(reverse('pwa-subscribe-push'), params={'sample': 'content'}, status=200) + app.post_json( + reverse('pwa-subscribe-push'), + params={'endpoint': 'https://push.example.com:1000/', 'sample': 'content'}, + status=200, + ) pwa_settings = PwaSettings.singleton() pwa_settings.push_notifications = False pwa_settings.save() - with mock.patch('pywebpush.webpush') as webpush: + with mock.patch('pywebpush.WebPusher') as webpusher: + webpusher.return_value.send.return_value.status_code = 200 Notification.notify(john_doe, 'test', body='hello world') - assert webpush.call_count == 0 + assert webpusher.call_count == 0 pwa_settings.push_notifications = True pwa_settings.save() - with mock.patch('pywebpush.webpush') as webpush: + with mock.patch('pywebpush.WebPusher') as webpusher: + webpusher.return_value.send.return_value.status_code = 200 Notification.notify(john_doe, 'test', body='hello world') - assert webpush.call_count == 1 - assert webpush.call_args[1]['subscription_info'] == {'sample': 'content'} + assert webpusher.mock_calls == [ + mock.call({'sample': 'content', 'endpoint': 'https://push.example.com:1000/'}), + mock.call().send( + data='{"summary": "test", "body": "hello world", "url": ""}', + headers={'Authorization': mock.ANY}, + ttl=2592000, + ), + mock.call().send().raise_for_status(), + ] + assert caplog.messages == ['webpush: notification sent'] - # check legacy settings are still supported - with override_settings( - PWA_VAPID_PUBLIK_KEY='BFzvUdXB...', - PWA_VAPID_PRIVATE_KEY='4WbCnBF...', - PWA_VAPID_CLAIMS={'sub': 'mailto:admin@entrouvert.com'}, - ): - with mock.patch('pywebpush.webpush') as webpush: - Notification.notify(john_doe, 'test', body='hello world') - assert webpush.call_count == 1 - assert webpush.call_args[1]['subscription_info'] == {'sample': 'content'} - assert webpush.call_args[1]['vapid_private_key'] == settings.PWA_VAPID_PRIVATE_KEY - assert webpush.call_args[1]['vapid_claims'] == settings.PWA_VAPID_CLAIMS + # check key is reused + with mock.patch('pywebpush.WebPusher') as webpusher, mock.patch('combo.apps.pwa.signals.Vapid') as vapid: + webpusher.return_value.send.return_value.status_code = 200 + Notification.notify(john_doe, 'test', body='hello world') + assert vapid.mock_calls == [] + assert webpusher.mock_calls == [ + mock.call({'sample': 'content', 'endpoint': 'https://push.example.com:1000/'}), + mock.call().send( + data='{"summary": "test", "body": "hello world", "url": ""}', + headers={'Authorization': mock.ANY}, + ttl=2592000, + ), + mock.call().send().raise_for_status(), + ] + + # check subscription is deleted on status 410... + caplog.clear() + with mock.patch('pywebpush.WebPusher') as webpusher: + webpusher.return_value.send.return_value.status_code = 410 + Notification.notify(john_doe, 'test', body='hello world') + assert PushSubscription.objects.count() == 0 + assert caplog.messages == ['webpush: deleting dead subscription'] + + # on any other error + caplog.clear() + app.post_json( + reverse('pwa-subscribe-push'), + params={'endpoint': 'https://push.example.com:1000/', 'sample': 'content'}, + status=200, + ) + with mock.patch('pywebpush.WebPusher') as webpusher: + webpusher.return_value.send.side_effect = Exception('Boom!') + Notification.notify(john_doe, 'test', body='hello world') + assert caplog.messages == ['webpush: request failed'] def test_no_pwa_manager(app, admin_user):