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,11 +11,8 @@ self.addEventListener('activate', function (event) {
});
self.addEventListener('push', function (event) {
if (!event.data) {
return;
}
let payload = {};
if (event.data) {
try {
payload = event.data.json();
} catch (e) {
@ -24,6 +21,12 @@ self.addEventListener('push', function (event) {
body: event.data.text()
};
}
} else {
payload = {
title: 'Loko',
body: 'Nouvelle notification'
};
}
const title = payload.title || 'Loko';
const actionUrl = payload.action_url || (payload.data && payload.data.url) || '/notifications/';
@ -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 || {})

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)
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

View file

@ -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,