From 8385a9e3e2ca9421135b6e3474261e8d588fd0ef Mon Sep 17 00:00:00 2001 From: kdeterme Date: Fri, 4 Sep 2026 19:30:31 +0200 Subject: [PATCH] fix(webpush): conform RFC8292 headers, Crypto-Key fallback and exact delivery feedback --- .../static/notifications/service-worker.js | 22 +++--- loko/notifications/views.py | 18 ++++- loko/notifications/webpush.py | 74 +++++++++++++------ 3 files changed, 81 insertions(+), 33 deletions(-) diff --git a/loko/notifications/static/notifications/service-worker.js b/loko/notifications/static/notifications/service-worker.js index e095d21..d418e81 100644 --- a/loko/notifications/static/notifications/service-worker.js +++ b/loko/notifications/static/notifications/service-worker.js @@ -11,17 +11,20 @@ self.addEventListener('activate', function (event) { }); self.addEventListener('push', function (event) { - if (!event.data) { - return; - } - let payload = {}; - try { - payload = event.data.json(); - } catch (e) { + if (event.data) { + try { + payload = event.data.json(); + } catch (e) { + payload = { + title: 'Loko', + body: event.data.text() + }; + } + } else { payload = { title: 'Loko', - body: event.data.text() + body: 'Nouvelle notification' }; } @@ -31,8 +34,9 @@ self.addEventListener('push', function (event) { body: payload.body || '', icon: payload.icon || '/static/common/android-192.png', badge: payload.badge || '/static/common/favicon-48.png', - tag: payload.tag || 'loko-notification', + tag: payload.tag || ('loko-' + Date.now()), renotify: true, + vibrate: [200, 100, 200], data: { url: actionUrl, ...(payload.data || {}) diff --git a/loko/notifications/views.py b/loko/notifications/views.py index 45c11d2..cefc29b 100644 --- a/loko/notifications/views.py +++ b/loko/notifications/views.py @@ -806,14 +806,26 @@ def api_webpush_test(request): return JsonResponse({'error': _("Aucun appareil actif n'est enregistré pour votre compte.")}, status=400) from .webpush import send_webpush_to_user - send_webpush_to_user( + res = send_webpush_to_user( user=request.user, title=_("Test de notification Loko"), body=_("Les notifications instantanées sur votre appareil fonctionnent correctement !"), action_url='/notifications/settings/', - async_send=True, + async_send=False, ) - return JsonResponse({'status': 'success', 'message': _("Notification de test envoyée !")}) + + if res.get('success', 0) > 0: + return JsonResponse({ + 'status': 'success', + 'message': _("Notification de test envoyée avec succès sur %(count)d appareil(s) !") % {'count': res['success']}, + 'details': res + }) + else: + err_msg = ", ".join(res.get('errors', [])) or _("Impossible de délivrer la notification au service push.") + return JsonResponse({ + 'error': _("Échec de distribution : %(error)s") % {'error': err_msg}, + 'details': res + }, status=502) @login_required diff --git a/loko/notifications/webpush.py b/loko/notifications/webpush.py index 297c9e7..f4f7fed 100644 --- a/loko/notifications/webpush.py +++ b/loko/notifications/webpush.py @@ -13,7 +13,7 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.kdf.hkdf import HKDF from django.conf import settings from django.utils import timezone -from py_vapid import Vapid +from py_vapid import Vapid, sign as vapid_sign import requests from .models import WebPushSubscription @@ -34,8 +34,7 @@ def b64_urlsafe_decode(data) -> bytes: def encrypt_webpush_rfc8291(plaintext: bytes, receiver_pub_bytes: bytes, auth_secret: bytes, salt: bytes = None) -> bytes: """ Chiffre une charge utile selon la norme Web Push RFC 8291 / RFC 8188 (aes128gcm). - Utilise directement la bibliothèque standard `cryptography` pour une fiabilité absolue - sans dépendance C tierce instable. + Utilise directement la bibliothèque standard `cryptography` pour une fiabilité absolue. """ if salt is None: salt = os.urandom(16) @@ -176,6 +175,32 @@ def get_vapid_claims(): return {'sub': admin_email} +def get_vapid_headers(endpoint: str, vapid_obj) -> dict: + """ + Génère les en-têtes d'authentification VAPID compatibles avec tous les fournisseurs Web Push + (Google FCM, Mozilla, Apple, Microsoft). + """ + parsed_url = urlparse(endpoint) + aud = f"{parsed_url.scheme}://{parsed_url.netloc}" + + claims = get_vapid_claims() + claims['aud'] = aud + claims['exp'] = int(time.time()) + (12 * 3600) + + pub_bytes = vapid_obj.public_key.public_bytes( + serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint, + ) + pub_b64 = base64.urlsafe_b64encode(pub_bytes).decode('ascii').rstrip('=') + + jwt_token = vapid_sign(vapid_obj._base_sign(claims), vapid_obj.private_key) + + return { + 'Authorization': f'vapid t={jwt_token}, k={pub_b64}', + 'Crypto-Key': f'p256ecdsa={pub_b64}', + } + + def _send_single_push(subscription: WebPushSubscription, payload: dict, vapid_obj): """Envoie un message push chiffré à un abonnement individuel.""" try: @@ -185,7 +210,7 @@ def _send_single_push(subscription: WebPushSubscription, payload: dict, vapid_ob if not endpoint or not p256dh or not auth: logger.warning("Abonnement WebPush incomplet pour %s (ID %s)", subscription.user.username, subscription.pk) - return + return False, "Abonnement incomplet" receiver_pub_bytes = b64_urlsafe_decode(p256dh) auth_secret = b64_urlsafe_decode(auth) @@ -193,14 +218,7 @@ def _send_single_push(subscription: WebPushSubscription, payload: dict, vapid_ob payload_bytes = json.dumps(payload).encode('utf-8') encrypted_body = encrypt_webpush_rfc8291(payload_bytes, receiver_pub_bytes, auth_secret) - parsed_url = urlparse(endpoint) - aud = f"{parsed_url.scheme}://{parsed_url.netloc}" - - claims = get_vapid_claims() - claims['aud'] = aud - claims['exp'] = int(time.time()) + (12 * 3600) - - vapid_headers = vapid_obj.sign(claims) + vapid_headers = get_vapid_headers(endpoint, vapid_obj) headers = { **vapid_headers, 'Content-Type': 'application/octet-stream', @@ -209,10 +227,11 @@ def _send_single_push(subscription: WebPushSubscription, payload: dict, vapid_ob 'Urgency': 'high', } - resp = requests.post(endpoint, data=encrypted_body, headers=headers, timeout=10.0) + resp = requests.post(endpoint, data=encrypted_body, headers=headers, timeout=8.0) if resp.status_code in (200, 201, 202): logger.info("Notification push envoyée avec succès à %s (%s)", subscription.user.username, subscription.device_name) + return True, f"HTTP {resp.status_code}" elif resp.status_code in (404, 410): # L'abonnement a expiré ou a été révoqué par le navigateur logger.info("Suppression de l'abonnement push expiré pour %s (HTTP %s)", subscription.user.username, resp.status_code) @@ -220,10 +239,13 @@ def _send_single_push(subscription: WebPushSubscription, payload: dict, vapid_ob subscription.delete() except Exception: pass + return False, f"Abonnement expiré (HTTP {resp.status_code})" else: logger.warning("Erreur envoi WebPush pour %s (HTTP %s): %s", subscription.user.username, resp.status_code, resp.text[:200]) + return False, f"HTTP {resp.status_code}: {resp.text[:100]}" except Exception as exc: logger.warning("Erreur inattendue envoi WebPush pour %s: %s", subscription.user.username, exc) + return False, str(exc) def send_webpush_to_user( @@ -239,18 +261,19 @@ def send_webpush_to_user( ): """ Envoie une notification push à tous les appareils enregistrés pour un utilisateur. + Retourne un dictionnaire de résultat : {'success': int, 'failed': int, 'errors': list}. """ if not user or not user.is_authenticated: - return + return {'success': 0, 'failed': 0, 'errors': ['Utilisateur non authentifié']} subscriptions = list(WebPushSubscription.objects.filter(user=user)) if not subscriptions: - return + return {'success': 0, 'failed': 0, 'errors': ['Aucun abonnement trouvé']} vapid_obj = get_vapid_key_obj() if not vapid_obj: logger.warning("Envoi push ignoré : clé VAPID non configurée.") - return + return {'success': 0, 'failed': 0, 'errors': ['Clé VAPID non configurée']} payload = { 'title': title, @@ -265,15 +288,24 @@ def send_webpush_to_user( }, } + results = {'success': 0, 'failed': 0, 'errors': []} + def _worker(): for sub in subscriptions: - _send_single_push(sub, payload, vapid_obj) + ok, msg = _send_single_push(sub, payload, vapid_obj) + if ok: + results['success'] += 1 + else: + results['failed'] += 1 + results['errors'].append(f"{sub.device_name or 'Appareil'}: {msg}") + return results if async_send: t = Thread(target=_worker, daemon=True) t.start() + return {'status': 'dispatched', 'count': len(subscriptions)} else: - _worker() + return _worker() def send_webpush_to_users( @@ -291,17 +323,17 @@ def send_webpush_to_users( Envoie une notification push à une liste d'utilisateurs. """ if not users: - return + return {'success': 0, 'failed': 0, 'errors': []} user_ids = [u.pk if hasattr(u, 'pk') else u for u in users if u] subscriptions = list(WebPushSubscription.objects.filter(user_id__in=user_ids).select_related('user')) if not subscriptions: - return + return {'success': 0, 'failed': 0, 'errors': []} vapid_obj = get_vapid_key_obj() if not vapid_obj: logger.warning("Envoi push ignoré : clé VAPID non configurée.") - return + return {'success': 0, 'failed': 0, 'errors': ['Clé VAPID non configurée']} payload = { 'title': title,