ajouter du cache sur les entêtes d'authentification VAPID (#70987) #200
|
@ -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')
|
||||
|
|
|
@ -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, '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):
|
||||
|
|
Loading…
Reference in New Issue