ajouter du cache sur les entêtes d'authentification VAPID (#70987) #200

Merged
bdauvergne merged 2 commits from wip/70987-pwa-utiliser-correctement-pywebp into main 2024-02-23 11:17:36 +01:00
4 changed files with 123 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,77 @@ 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)
headers['Urgency'] = 'low'
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, 'Urgency': 'low'},
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, 'Urgency': 'low'},
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):