Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Mapapi/Send_mails.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
import logging
logger = logging.getLogger(__name__)

Expand All @@ -11,7 +12,8 @@ def send_email(subject, template_name, context, to_email):
try:
html_content = render_to_string(template_name, context)
text_content = strip_tags(html_content)
msg = EmailMultiAlternatives(subject, text_content, 'Map Action <contact@map-action.com>', [to_email])
from_email = settings.DEFAULT_FROM_EMAIL or 'Map Action <contact@map-action.com>'
msg = EmailMultiAlternatives(subject, text_content, from_email, [to_email])
msg.attach_alternative(html_content, "text/html")
msg.send()
logger.info(f"Email envoyé avec succès à {to_email}.")
Expand Down
47 changes: 47 additions & 0 deletions Mapapi/authentication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Authentification JWT par cookie httpOnly (avec repli Bearer).

Le frontend stocke le JWT dans un cookie **httpOnly** (illisible par JavaScript →
protégé contre le vol par XSS) au lieu du sessionStorage/localStorage. L'API
reste utilisable par le mobile via l'en-tête ``Authorization: Bearer`` (essayé
en premier). Quand l'authentification vient du cookie, on applique la protection
CSRF sur les méthodes non sûres (le cookie étant envoyé automatiquement par le
navigateur, c'est la contre-mesure indispensable).
"""
from django.conf import settings
from rest_framework import exceptions
from rest_framework.authentication import CSRFCheck
from rest_framework_simplejwt.authentication import JWTAuthentication

SAFE_METHODS = ('GET', 'HEAD', 'OPTIONS', 'TRACE')


def _enforce_csrf(request):
"""Rejoue la vérification CSRF de Django pour une requête authentifiée par cookie."""
def _dummy_get_response(_request): # pragma: no cover - jamais appelé
return None

check = CSRFCheck(_dummy_get_response)
check.process_request(request)
reason = check.process_view(request, None, (), {})
if reason:
raise exceptions.PermissionDenied(f'CSRF Failed: {reason}')


class CookieJWTAuthentication(JWTAuthentication):
"""Bearer d'abord (mobile + transition), puis cookie httpOnly."""

def authenticate(self, request):
# 1. En-tête Authorization: Bearer — comportement historique inchangé.
header_result = super().authenticate(request)
if header_result is not None:
return header_result

# 2. Cookie httpOnly.
raw_token = request.COOKIES.get(settings.AUTH_COOKIE_ACCESS)
if not raw_token:
return None
validated_token = self.get_validated_token(raw_token)
# CSRF requis quand on s'appuie sur le cookie pour une méthode non sûre.
if request.method not in SAFE_METHODS:
_enforce_csrf(request)
return self.get_user(validated_token), validated_token
92 changes: 92 additions & 0 deletions Mapapi/consumers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Consumers WebSocket temps réel.

- NotificationConsumer : /ws/notifications/ — flux des notifications de l'utilisateur
connecté (qui a fait quoi). Groupe ``notifications_<user_id>``.
- DiscussionConsumer : /ws/incidents/<id>/discussion/ — messages de discussion
d'un incident en temps réel. Groupe ``discussion_<incident_id>``.
- TaskConsumer : /ws/incidents/<id>/tasks/ — créations/màj de tâches en temps réel.
Groupe ``tasks_<incident_id>``.

Les serveurs (signals) poussent via channel_layer.group_send(group, {'type': 'broadcast', 'payload': {...}}).
"""
import json

from channels.generic.websocket import AsyncJsonWebsocketConsumer
from django.core.serializers.json import DjangoJSONEncoder


class _GroupConsumer(AsyncJsonWebsocketConsumer):
"""Base : rejoint un groupe si l'utilisateur est authentifié, relaie les
messages 'broadcast' tels quels au client."""
group_name = None

@classmethod
async def encode_json(cls, content):
# Les payloads poussés par les signaux contiennent des UUID et des datetime ;
# l'encodeur par défaut de channels (json.dumps brut) lèverait une exception
# « Object of type UUID is not JSON serializable » → fermeture WebSocket 1011.
# DjangoJSONEncoder sérialise UUID/datetime/Decimal proprement.
return json.dumps(content, cls=DjangoJSONEncoder)

async def connect(self):
user = self.scope.get('user')
if user is None or not getattr(user, 'is_authenticated', False):
await self.close(code=4401) # non authentifié
return
self.group_name = await self.resolve_group()
if not self.group_name:
await self.close(code=4403) # non autorisé / cible invalide
return
await self.channel_layer.group_add(self.group_name, self.channel_name)
await self.accept()

async def disconnect(self, code):
if self.group_name:
await self.channel_layer.group_discard(self.group_name, self.channel_name)

async def resolve_group(self):
raise NotImplementedError

async def broadcast(self, event):
# event = {'type': 'broadcast', 'payload': {...}}
await self.send_json(event['payload'])


class NotificationConsumer(_GroupConsumer):
async def resolve_group(self):
return f"notifications_{self.scope['user'].id}"


class DiscussionConsumer(_GroupConsumer):
async def resolve_group(self):
incident_id = self.scope['url_route']['kwargs'].get('incident_id')
return f"discussion_{incident_id}" if incident_id else None


class TaskConsumer(_GroupConsumer):
async def resolve_group(self):
incident_id = self.scope['url_route']['kwargs'].get('incident_id')
return f"tasks_{incident_id}" if incident_id else None


class CollaborationConsumer(_GroupConsumer):
"""/ws/collaborations/ — collaborations de l'utilisateur connecté en temps réel
(onglet collaboration + demandes). Groupe ``collaborations_<user_id>``."""
async def resolve_group(self):
return f"collaborations_{self.scope['user'].id}"


class ActivityFeedConsumer(_GroupConsumer):
"""/ws/activity-feed/ — flux d'activité de la plateforme en temps réel.
Groupe global ``activity_feed``. Comme le REST /activity-feed/, on n'affiche
PAS au viewer l'activité de sa PROPRE organisation : on filtre chaque broadcast
selon l'organisation du viewer (connue via ``scope['user']``)."""
async def resolve_group(self):
return "activity_feed"

async def broadcast(self, event):
payload = event['payload']
my_org = getattr(self.scope.get('user'), 'organisation_member_id', None)
if my_org is not None and str(payload.get('organisation_id')) == str(my_org):
return # activité de ma propre org -> ignorée (cohérent avec le REST)
await self.send_json(payload)
Loading