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