fix(webpush): conform RFC8292 headers, Crypto-Key fallback and exact delivery feedback
This commit is contained in:
parent
5757b28907
commit
8385a9e3e2
3 changed files with 81 additions and 33 deletions
|
|
@ -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 || {})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue