fix(webpush): conform RFC8292 headers, Crypto-Key fallback and exact delivery feedback

This commit is contained in:
kdeterme 2026-09-04 19:30:31 +02:00
parent 5757b28907
commit 8385a9e3e2
3 changed files with 81 additions and 33 deletions

View file

@ -11,17 +11,20 @@ self.addEventListener('activate', function (event) {
}); });
self.addEventListener('push', function (event) { self.addEventListener('push', function (event) {
if (!event.data) {
return;
}
let payload = {}; let payload = {};
try { if (event.data) {
payload = event.data.json(); try {
} catch (e) { payload = event.data.json();
} catch (e) {
payload = {
title: 'Loko',
body: event.data.text()
};
}
} else {
payload = { payload = {
title: 'Loko', title: 'Loko',
body: event.data.text() body: 'Nouvelle notification'
}; };
} }
@ -31,8 +34,9 @@ self.addEventListener('push', function (event) {
body: payload.body || '', body: payload.body || '',
icon: payload.icon || '/static/common/android-192.png', icon: payload.icon || '/static/common/android-192.png',
badge: payload.badge || '/static/common/favicon-48.png', badge: payload.badge || '/static/common/favicon-48.png',
tag: payload.tag || 'loko-notification', tag: payload.tag || ('loko-' + Date.now()),
renotify: true, renotify: true,
vibrate: [200, 100, 200],
data: { data: {
url: actionUrl, url: actionUrl,
...(payload.data || {}) ...(payload.data || {})

View file

@ -806,14 +806,26 @@ def api_webpush_test(request):
return JsonResponse({'error': _("Aucun appareil actif n'est enregistré pour votre compte.")}, status=400) return JsonResponse({'error': _("Aucun appareil actif n'est enregistré pour votre compte.")}, status=400)
from .webpush import send_webpush_to_user from .webpush import send_webpush_to_user
send_webpush_to_user( res = send_webpush_to_user(
user=request.user, user=request.user,
title=_("Test de notification Loko"), title=_("Test de notification Loko"),
body=_("Les notifications instantanées sur votre appareil fonctionnent correctement !"), body=_("Les notifications instantanées sur votre appareil fonctionnent correctement !"),
action_url='/notifications/settings/', 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 @login_required

View file

@ -13,7 +13,7 @@ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from py_vapid import Vapid from py_vapid import Vapid, sign as vapid_sign
import requests import requests
from .models import WebPushSubscription 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: 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). 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 Utilise directement la bibliothèque standard `cryptography` pour une fiabilité absolue.
sans dépendance C tierce instable.
""" """
if salt is None: if salt is None:
salt = os.urandom(16) salt = os.urandom(16)
@ -176,6 +175,32 @@ def get_vapid_claims():
return {'sub': admin_email} 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): def _send_single_push(subscription: WebPushSubscription, payload: dict, vapid_obj):
"""Envoie un message push chiffré à un abonnement individuel.""" """Envoie un message push chiffré à un abonnement individuel."""
try: try:
@ -185,7 +210,7 @@ def _send_single_push(subscription: WebPushSubscription, payload: dict, vapid_ob
if not endpoint or not p256dh or not auth: if not endpoint or not p256dh or not auth:
logger.warning("Abonnement WebPush incomplet pour %s (ID %s)", subscription.user.username, subscription.pk) 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) receiver_pub_bytes = b64_urlsafe_decode(p256dh)
auth_secret = b64_urlsafe_decode(auth) 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') payload_bytes = json.dumps(payload).encode('utf-8')
encrypted_body = encrypt_webpush_rfc8291(payload_bytes, receiver_pub_bytes, auth_secret) encrypted_body = encrypt_webpush_rfc8291(payload_bytes, receiver_pub_bytes, auth_secret)
parsed_url = urlparse(endpoint) vapid_headers = get_vapid_headers(endpoint, vapid_obj)
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)
headers = { headers = {
**vapid_headers, **vapid_headers,
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
@ -209,10 +227,11 @@ def _send_single_push(subscription: WebPushSubscription, payload: dict, vapid_ob
'Urgency': 'high', '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): 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) 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): elif resp.status_code in (404, 410):
# L'abonnement a expiré ou a été révoqué par le navigateur # 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) 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() subscription.delete()
except Exception: except Exception:
pass pass
return False, f"Abonnement expiré (HTTP {resp.status_code})"
else: else:
logger.warning("Erreur envoi WebPush pour %s (HTTP %s): %s", subscription.user.username, resp.status_code, resp.text[:200]) 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: except Exception as exc:
logger.warning("Erreur inattendue envoi WebPush pour %s: %s", subscription.user.username, exc) logger.warning("Erreur inattendue envoi WebPush pour %s: %s", subscription.user.username, exc)
return False, str(exc)
def send_webpush_to_user( 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. 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: 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)) subscriptions = list(WebPushSubscription.objects.filter(user=user))
if not subscriptions: if not subscriptions:
return return {'success': 0, 'failed': 0, 'errors': ['Aucun abonnement trouvé']}
vapid_obj = get_vapid_key_obj() vapid_obj = get_vapid_key_obj()
if not vapid_obj: if not vapid_obj:
logger.warning("Envoi push ignoré : clé VAPID non configurée.") logger.warning("Envoi push ignoré : clé VAPID non configurée.")
return return {'success': 0, 'failed': 0, 'errors': ['Clé VAPID non configurée']}
payload = { payload = {
'title': title, 'title': title,
@ -265,15 +288,24 @@ def send_webpush_to_user(
}, },
} }
results = {'success': 0, 'failed': 0, 'errors': []}
def _worker(): def _worker():
for sub in subscriptions: 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: if async_send:
t = Thread(target=_worker, daemon=True) t = Thread(target=_worker, daemon=True)
t.start() t.start()
return {'status': 'dispatched', 'count': len(subscriptions)}
else: else:
_worker() return _worker()
def send_webpush_to_users( def send_webpush_to_users(
@ -291,17 +323,17 @@ def send_webpush_to_users(
Envoie une notification push à une liste d'utilisateurs. Envoie une notification push à une liste d'utilisateurs.
""" """
if not users: 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] 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')) subscriptions = list(WebPushSubscription.objects.filter(user_id__in=user_ids).select_related('user'))
if not subscriptions: if not subscriptions:
return return {'success': 0, 'failed': 0, 'errors': []}
vapid_obj = get_vapid_key_obj() vapid_obj = get_vapid_key_obj()
if not vapid_obj: if not vapid_obj:
logger.warning("Envoi push ignoré : clé VAPID non configurée.") logger.warning("Envoi push ignoré : clé VAPID non configurée.")
return return {'success': 0, 'failed': 0, 'errors': ['Clé VAPID non configurée']}
payload = { payload = {
'title': title, 'title': title,