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.
This commit is contained in:
Benjamin Dauvergne 2023-12-06 23:20:12 +01:00
parent 05e615ddc9
commit 9f25287a66
4 changed files with 122 additions and 52 deletions

View File

@ -15,11 +15,14 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import datetime import datetime
import hashlib
import json import json
import logging import logging
import urllib.parse
import pywebpush import pywebpush
from django.conf import settings from django.conf import settings
from django.core.cache import cache
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from py_vapid import Vapid from py_vapid import Vapid
@ -28,47 +31,76 @@ from combo.apps.notifications.models import Notification
from .models import PushSubscription, PwaSettings 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) @receiver(post_save, sender=Notification)
def notification(sender, instance=None, created=False, **kwargs): def notification(sender, instance=None, created=False, **kwargs):
if not created: if not created:
return return
pwa_settings = PwaSettings.singleton() pwa_settings = PwaSettings.singleton()
if not pwa_settings.push_notifications: if not pwa_settings.push_notifications:
return 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: try:
pywebpush.webpush( send_webpush(
private_key=private_key,
subscription_info=subscription.subscription_info, subscription_info=subscription.subscription_info,
data=message, summary=instance.summary,
vapid_private_key=pwa_vapid_private_key, body=instance.body,
vapid_claims=claims, url=instance.url,
ttl=86400 * 30,
) )
except pywebpush.WebPushException as e: logger.info('webpush: notification sent')
if 'Push failed: 410 Gone' in str(e): except DeadSubscription:
subscription.delete() subscription.delete()
continue logger.info('webpush: deleting dead subscription')
logger = logging.getLogger(__name__) except Exception:
logger.exception('webpush error (%r)', e) logger.exception('webpush: request failed')

1
debian/control vendored
View File

@ -23,6 +23,7 @@ Depends: python3-distutils,
python3-lxml, python3-lxml,
python3-pil, python3-pil,
python3-publik-django-templatetags, python3-publik-django-templatetags,
python3-py-vapid (>= 1.8.2),
python3-pycryptodome, python3-pycryptodome,
python3-pyexcel-ods, python3-pyexcel-ods,
python3-pygal, python3-pygal,

View File

@ -178,6 +178,7 @@ setup(
'pyproj', 'pyproj',
'pyquery', 'pyquery',
'pywebpush', 'pywebpush',
'py-vapid>=1.8.2',
'pygal', 'pygal',
'pyexcel-ods', 'pyexcel-ods',
'lxml', 'lxml',

View File

@ -4,7 +4,6 @@ from io import BytesIO
from unittest import mock from unittest import mock
import pytest import pytest
from django.conf import settings
from django.core.files import File from django.core.files import File
from django.template import Context, Template from django.template import Context, Template
from django.test import override_settings 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 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() PushSubscription.objects.all().delete()
app = login(app, john_doe.username, john_doe.username) 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 = PwaSettings.singleton()
pwa_settings.push_notifications = False pwa_settings.push_notifications = False
pwa_settings.save() 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') 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.push_notifications = True
pwa_settings.save() 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') Notification.notify(john_doe, 'test', body='hello world')
assert webpush.call_count == 1 assert webpusher.mock_calls == [
assert webpush.call_args[1]['subscription_info'] == {'sample': 'content'} 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 # check key is reused
with override_settings( with mock.patch('pywebpush.WebPusher') as webpusher, mock.patch('combo.apps.pwa.signals.Vapid') as vapid:
PWA_VAPID_PUBLIK_KEY='BFzvUdXB...', webpusher.return_value.send.return_value.status_code = 200
PWA_VAPID_PRIVATE_KEY='4WbCnBF...', Notification.notify(john_doe, 'test', body='hello world')
PWA_VAPID_CLAIMS={'sub': 'mailto:admin@entrouvert.com'}, assert vapid.mock_calls == []
): assert webpusher.mock_calls == [
with mock.patch('pywebpush.webpush') as webpush: mock.call({'sample': 'content', 'endpoint': 'https://push.example.com:1000/'}),
Notification.notify(john_doe, 'test', body='hello world') mock.call().send(
assert webpush.call_count == 1 data='{"summary": "test", "body": "hello world", "url": ""}',
assert webpush.call_args[1]['subscription_info'] == {'sample': 'content'} headers={'Authorization': mock.ANY},
assert webpush.call_args[1]['vapid_private_key'] == settings.PWA_VAPID_PRIVATE_KEY ttl=2592000,
assert webpush.call_args[1]['vapid_claims'] == settings.PWA_VAPID_CLAIMS ),
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): def test_no_pwa_manager(app, admin_user):