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/>.
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')

1
debian/control vendored
View File

@ -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,

View File

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

View File

@ -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):