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:
parent
05e615ddc9
commit
9f25287a66
|
@ -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')
|
||||
|
|
|
@ -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,
|
||||
|
|
1
setup.py
1
setup.py
|
@ -178,6 +178,7 @@ setup(
|
|||
'pyproj',
|
||||
'pyquery',
|
||||
'pywebpush',
|
||||
'py-vapid>=1.8.2',
|
||||
'pygal',
|
||||
'pyexcel-ods',
|
||||
'lxml',
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Reference in New Issue