feat: Securisation du Backend

This commit is contained in:
Luc SORIGNET
2026-02-27 10:45:36 +01:00
parent 2fef6d61a4
commit fa843097ba
55 changed files with 2898 additions and 910 deletions

View File

@ -1,4 +1,4 @@
# Generated by Django 5.1.3 on 2025-11-30 11:02
# Generated by Django 5.1.3 on 2026-03-14 13:23
import django.db.models.deletion
import django.utils.timezone

View File

@ -0,0 +1,130 @@
"""
Tests unitaires pour le module GestionMessagerie.
Vérifie que les endpoints (conversations, messages, upload) requièrent une
authentification JWT.
"""
import json
from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from Auth.models import Profile
def create_user(email="messagerie_test@example.com", password="testpassword123"):
return Profile.objects.create_user(username=email, email=email, password=password)
def get_jwt_token(user):
refresh = RefreshToken.for_user(user)
return str(refresh.access_token)
TEST_REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
}
TEST_CACHES = {'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
OVERRIDE = dict(
CACHES=TEST_CACHES,
SESSION_ENGINE='django.contrib.sessions.backends.db',
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
CHANNEL_LAYERS={'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}},
)
@override_settings(**OVERRIDE)
class ConversationListEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints de conversation."""
def setUp(self):
self.client = APIClient()
self.user = create_user()
def test_get_conversations_par_user_sans_auth_retourne_401(self):
"""GET /GestionMessagerie/conversations/user/{id}/ sans token doit retourner 401."""
url = reverse("GestionMessagerie:conversations_by_user", kwargs={"user_id": 1})
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_post_create_conversation_sans_auth_retourne_401(self):
"""POST /GestionMessagerie/create-conversation/ sans token doit retourner 401."""
url = reverse("GestionMessagerie:create_conversation")
response = self.client.post(
url,
data=json.dumps({"participants": [1, 2]}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_post_send_message_sans_auth_retourne_401(self):
"""POST /GestionMessagerie/send-message/ sans token doit retourner 401."""
url = reverse("GestionMessagerie:send_message")
response = self.client.post(
url,
data=json.dumps({"content": "Bonjour"}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_post_mark_as_read_sans_auth_retourne_401(self):
"""POST /GestionMessagerie/conversations/mark-as-read/ sans token doit retourner 401."""
url = reverse("GestionMessagerie:mark_as_read")
response = self.client.post(
url,
data=json.dumps({}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_search_recipients_sans_auth_retourne_401(self):
"""GET /GestionMessagerie/search-recipients/ sans token doit retourner 401."""
url = reverse("GestionMessagerie:search_recipients")
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_post_upload_file_sans_auth_retourne_401(self):
"""POST /GestionMessagerie/upload-file/ sans token doit retourner 401."""
url = reverse("GestionMessagerie:upload_file")
response = self.client.post(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_delete_conversation_sans_auth_retourne_401(self):
"""DELETE /GestionMessagerie/conversations/{uuid}/ sans token doit retourner 401."""
import uuid as uuid_lib
conversation_id = uuid_lib.uuid4()
url = reverse(
"GestionMessagerie:delete_conversation",
kwargs={"conversation_id": conversation_id},
)
response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_conversation_messages_sans_auth_retourne_401(self):
"""GET /GestionMessagerie/conversations/{uuid}/messages/ sans token doit retourner 401."""
import uuid as uuid_lib
conversation_id = uuid_lib.uuid4()
url = reverse(
"GestionMessagerie:conversation_messages",
kwargs={"conversation_id": conversation_id},
)
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_conversations_avec_auth_retourne_non_403(self):
"""GET avec token valide ne doit pas retourner 401/403."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
url = reverse("GestionMessagerie:conversations_by_user", kwargs={"user_id": self.user.id})
response = self.client.get(url)
self.assertNotIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN])

View File

@ -0,0 +1,274 @@
"""
Tests de sécurité — GestionMessagerie
Vérifie :
- Protection IDOR : un utilisateur ne peut pas lire/écrire au nom d'un autre
- Authentification requise sur tous les endpoints
- L'expéditeur d'un message est toujours l'utilisateur authentifié
- Le mark-as-read utilise request.user (pas user_id du body)
- L'upload de fichier utilise request.user (pas sender_id du body)
"""
import json
import uuid
from django.test import TestCase, override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from Auth.models import Profile, ProfileRole
from Establishment.models import Establishment
from GestionMessagerie.models import (
Conversation, ConversationParticipant, Message
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def create_establishment(name="Ecole Sécurité"):
return Establishment.objects.create(
name=name,
address="1 rue des Tests",
total_capacity=50,
establishment_type=[1],
)
def create_user(email, password="TestPass!123"):
user = Profile.objects.create_user(
username=email,
email=email,
password=password,
)
return user
def create_active_user(email, password="TestPass!123"):
user = create_user(email, password)
establishment = create_establishment(name=f"Ecole de {email}")
ProfileRole.objects.create(
profile=user,
role_type=ProfileRole.RoleType.PROFIL_ECOLE,
establishment=establishment,
is_active=True,
)
return user
def get_token(user):
return str(RefreshToken.for_user(user).access_token)
def create_conversation_with_participant(user1, user2):
"""Crée une conversation privée entre deux utilisateurs."""
conv = Conversation.objects.create(conversation_type='private')
ConversationParticipant.objects.create(
conversation=conv, participant=user1, is_active=True
)
ConversationParticipant.objects.create(
conversation=conv, participant=user2, is_active=True
)
return conv
# ---------------------------------------------------------------------------
# Configuration commune
# ---------------------------------------------------------------------------
OVERRIDE = dict(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
CHANNEL_LAYERS={'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}},
)
# ---------------------------------------------------------------------------
# Tests : authentification requise
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE)
class MessagerieAuthRequiredTest(TestCase):
"""Tous les endpoints de messagerie doivent rejeter les requêtes non authentifiées."""
def setUp(self):
self.client = APIClient()
def test_conversations_sans_auth_retourne_401(self):
response = self.client.get(reverse('GestionMessagerie:conversations'))
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_send_message_sans_auth_retourne_401(self):
response = self.client.post(
reverse('GestionMessagerie:send_message'),
data=json.dumps({'conversation_id': str(uuid.uuid4()), 'content': 'Hello'}),
content_type='application/json',
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_mark_as_read_sans_auth_retourne_401(self):
response = self.client.post(
reverse('GestionMessagerie:mark_as_read'),
data=json.dumps({'conversation_id': str(uuid.uuid4())}),
content_type='application/json',
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_upload_file_sans_auth_retourne_401(self):
response = self.client.post(reverse('GestionMessagerie:upload_file'))
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
# ---------------------------------------------------------------------------
# Tests IDOR : liste des conversations (request.user ignorant l'URL user_id)
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE)
class ConversationListIDORTest(TestCase):
"""
GET conversations/user/<user_id>/ doit retourner les conversations de
request.user, pas celles de l'utilisateur dont l'ID est dans l'URL.
"""
def setUp(self):
self.client = APIClient()
self.alice = create_active_user('alice@test.com')
self.bob = create_active_user('bob@test.com')
self.carol = create_active_user('carol@test.com')
# Conversation entre Alice et Bob (Carol ne doit pas la voir)
self.conv_alice_bob = create_conversation_with_participant(self.alice, self.bob)
def test_carol_ne_voit_pas_les_conversations_de_alice(self):
"""
Carol s'authentifie mais passe alice.id dans l'URL.
Elle doit voir ses propres conversations (vides), pas celles d'Alice.
"""
token = get_token(self.carol)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
url = reverse('GestionMessagerie:conversations_by_user', kwargs={'user_id': self.alice.id})
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
# Carol n'a aucune conversation : la liste doit être vide
self.assertEqual(len(data), 0, "Carol ne doit pas voir les conversations d'Alice (IDOR)")
def test_alice_voit_ses_propres_conversations(self):
"""Alice voit bien sa conversation avec Bob."""
token = get_token(self.alice)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
url = reverse('GestionMessagerie:conversations_by_user', kwargs={'user_id': self.alice.id})
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertEqual(len(data), 1)
self.assertEqual(data[0]['id'], str(self.conv_alice_bob.id))
# ---------------------------------------------------------------------------
# Tests IDOR : envoi de message (sender = request.user)
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE)
class SendMessageIDORTest(TestCase):
"""
POST send-message/ doit utiliser request.user comme expéditeur,
indépendamment du sender_id fourni dans le body.
"""
def setUp(self):
self.client = APIClient()
self.alice = create_active_user('alice_msg@test.com')
self.bob = create_active_user('bob_msg@test.com')
self.conv = create_conversation_with_participant(self.alice, self.bob)
def test_sender_id_dans_body_est_ignore(self):
"""
Bob envoie un message en mettant alice.id comme sender_id dans le body.
Le message doit avoir bob comme expéditeur.
"""
token = get_token(self.bob)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
payload = {
'conversation_id': str(self.conv.id),
'sender_id': self.alice.id, # tentative d'impersonation
'content': 'Message imposteur',
}
response = self.client.post(
reverse('GestionMessagerie:send_message'),
data=json.dumps(payload),
content_type='application/json',
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
# Vérifier que l'expéditeur est bien Bob, pas Alice
message = Message.objects.get(conversation=self.conv, content='Message imposteur')
self.assertEqual(message.sender.id, self.bob.id,
"L'expéditeur doit être request.user (Bob), pas le sender_id du body (Alice)")
def test_non_participant_ne_peut_pas_envoyer(self):
"""
Carol (non participante) ne peut pas envoyer dans la conv Alice-Bob.
"""
carol = create_active_user('carol_msg@test.com')
token = get_token(carol)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
payload = {
'conversation_id': str(self.conv.id),
'content': 'Message intrus',
}
response = self.client.post(
reverse('GestionMessagerie:send_message'),
data=json.dumps(payload),
content_type='application/json',
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
# ---------------------------------------------------------------------------
# Tests IDOR : mark-as-read (request.user, pas user_id du body)
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE)
class MarkAsReadIDORTest(TestCase):
"""
POST mark-as-read doit utiliser request.user, pas user_id du body.
Carol ne peut pas marquer comme lue une conversation d'Alice.
"""
def setUp(self):
self.client = APIClient()
self.alice = create_active_user('alice_read@test.com')
self.bob = create_active_user('bob_read@test.com')
self.carol = create_active_user('carol_read@test.com')
self.conv = create_conversation_with_participant(self.alice, self.bob)
def test_carol_ne_peut_pas_marquer_conversation_alice_comme_lue(self):
"""
Carol passe alice.id dans le body mais n'est pas participante.
Elle doit recevoir 404 (pas de ConversationParticipant trouvé pour Carol).
"""
token = get_token(self.carol)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
payload = {'user_id': self.alice.id} # tentative IDOR
url = reverse('GestionMessagerie:mark_as_read') + f'?conversation_id={self.conv.id}'
response = self.client.post(
reverse('GestionMessagerie:mark_as_read'),
data=json.dumps(payload),
content_type='application/json',
)
# Doit échouer car on cherche un participant pour request.user (Carol), qui n'est pas là
self.assertIn(response.status_code, [status.HTTP_404_NOT_FOUND, status.HTTP_400_BAD_REQUEST])
def test_alice_peut_marquer_sa_propre_conversation(self):
"""Alice peut marquer sa conversation comme lue."""
token = get_token(self.alice)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
response = self.client.post(
reverse('GestionMessagerie:mark_as_read'),
data=json.dumps({}),
content_type='application/json',
)
# Sans conversation_id : 404 attendu, mais pas 403 (accès autorisé à la vue)
self.assertNotIn(response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN])

View File

@ -2,6 +2,7 @@ from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.permissions import IsAuthenticated
from django.db import models
from .models import Conversation, ConversationParticipant, Message, UserPresence
from Auth.models import Profile, ProfileRole
@ -25,6 +26,8 @@ logger = logging.getLogger(__name__)
# ====================== MESSAGERIE INSTANTANÉE ======================
class InstantConversationListView(APIView):
permission_classes = [IsAuthenticated]
"""
API pour lister les conversations instantanées d'un utilisateur
"""
@ -34,7 +37,8 @@ class InstantConversationListView(APIView):
)
def get(self, request, user_id=None):
try:
user = Profile.objects.get(id=user_id)
# Utiliser l'utilisateur authentifié — ignorer user_id de l'URL (protection IDOR)
user = request.user
conversations = Conversation.objects.filter(
participants__participant=user,
@ -50,6 +54,8 @@ class InstantConversationListView(APIView):
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class InstantConversationCreateView(APIView):
permission_classes = [IsAuthenticated]
"""
API pour créer une nouvelle conversation instantanée
"""
@ -67,6 +73,8 @@ class InstantConversationCreateView(APIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class InstantMessageListView(APIView):
permission_classes = [IsAuthenticated]
"""
API pour lister les messages d'une conversation
"""
@ -79,23 +87,19 @@ class InstantMessageListView(APIView):
conversation = Conversation.objects.get(id=conversation_id)
messages = conversation.messages.filter(is_deleted=False).order_by('created_at')
# Récupérer l'utilisateur actuel depuis les paramètres de requête
user_id = request.GET.get('user_id')
user = None
if user_id:
try:
user = Profile.objects.get(id=user_id)
except Profile.DoesNotExist:
pass
# Utiliser l'utilisateur authentifié — ignorer user_id du paramètre (protection IDOR)
user = request.user
serializer = MessageSerializer(messages, many=True, context={'user': user})
return Response(serializer.data, status=status.HTTP_200_OK)
except Conversation.DoesNotExist:
return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Exception:
return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class InstantMessageCreateView(APIView):
permission_classes = [IsAuthenticated]
"""
API pour envoyer un nouveau message instantané
"""
@ -116,21 +120,20 @@ class InstantMessageCreateView(APIView):
def post(self, request):
try:
conversation_id = request.data.get('conversation_id')
sender_id = request.data.get('sender_id')
content = request.data.get('content', '').strip()
message_type = request.data.get('message_type', 'text')
if not all([conversation_id, sender_id, content]):
if not all([conversation_id, content]):
return Response(
{'error': 'conversation_id, sender_id, and content are required'},
{'error': 'conversation_id and content are required'},
status=status.HTTP_400_BAD_REQUEST
)
# Vérifier que la conversation existe
conversation = Conversation.objects.get(id=conversation_id)
# Vérifier que l'expéditeur existe et peut envoyer dans cette conversation
sender = Profile.objects.get(id=sender_id)
# L'expéditeur est toujours l'utilisateur authentifié (protection IDOR)
sender = request.user
participant = ConversationParticipant.objects.filter(
conversation=conversation,
participant=sender,
@ -172,10 +175,12 @@ class InstantMessageCreateView(APIView):
return Response({'error': 'Conversation not found'}, status=status.HTTP_404_NOT_FOUND)
except Profile.DoesNotExist:
return Response({'error': 'Sender not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Exception:
return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class InstantMarkAsReadView(APIView):
permission_classes = [IsAuthenticated]
"""
API pour marquer une conversation comme lue
"""
@ -190,15 +195,16 @@ class InstantMarkAsReadView(APIView):
),
responses={200: openapi.Response('Success')}
)
def post(self, request, conversation_id):
def post(self, request):
try:
user_id = request.data.get('user_id')
if not user_id:
return Response({'error': 'user_id is required'}, status=status.HTTP_400_BAD_REQUEST)
# Utiliser l'utilisateur authentifié — ignorer user_id du body (protection IDOR)
# conversation_id est lu depuis le body (pas depuis l'URL)
conversation_id = request.data.get('conversation_id')
if not conversation_id:
return Response({'error': 'conversation_id requis'}, status=status.HTTP_400_BAD_REQUEST)
participant = ConversationParticipant.objects.get(
conversation_id=conversation_id,
participant_id=user_id,
participant=request.user,
is_active=True
)
@ -209,10 +215,12 @@ class InstantMarkAsReadView(APIView):
except ConversationParticipant.DoesNotExist:
return Response({'error': 'Participant not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Exception:
return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class UserPresenceView(APIView):
permission_classes = [IsAuthenticated]
"""
API pour gérer la présence des utilisateurs
"""
@ -245,8 +253,8 @@ class UserPresenceView(APIView):
except Profile.DoesNotExist:
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Exception:
return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@swagger_auto_schema(
operation_description="Récupère le statut de présence d'un utilisateur",
@ -266,10 +274,12 @@ class UserPresenceView(APIView):
except Profile.DoesNotExist:
return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Exception:
return Response({'error': 'Erreur interne du serveur'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class FileUploadView(APIView):
permission_classes = [IsAuthenticated]
"""
API pour l'upload de fichiers dans la messagerie instantanée
"""
@ -301,18 +311,17 @@ class FileUploadView(APIView):
try:
file = request.FILES.get('file')
conversation_id = request.data.get('conversation_id')
sender_id = request.data.get('sender_id')
if not file:
return Response({'error': 'Aucun fichier fourni'}, status=status.HTTP_400_BAD_REQUEST)
if not conversation_id or not sender_id:
return Response({'error': 'conversation_id et sender_id requis'}, status=status.HTTP_400_BAD_REQUEST)
if not conversation_id:
return Response({'error': 'conversation_id requis'}, status=status.HTTP_400_BAD_REQUEST)
# Vérifier que la conversation existe et que l'utilisateur y participe
# Vérifier que la conversation existe et que l'utilisateur authentifié y participe (protection IDOR)
try:
conversation = Conversation.objects.get(id=conversation_id)
sender = Profile.objects.get(id=sender_id)
sender = request.user
# Vérifier que l'expéditeur participe à la conversation
if not ConversationParticipant.objects.filter(
@ -368,10 +377,12 @@ class FileUploadView(APIView):
'filePath': file_path
}, status=status.HTTP_200_OK)
except Exception as e:
return Response({'error': f'Erreur lors de l\'upload: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Exception:
return Response({'error': "Erreur lors de l'upload"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class InstantRecipientSearchView(APIView):
permission_classes = [IsAuthenticated]
"""
API pour rechercher des destinataires pour la messagerie instantanée
"""
@ -419,6 +430,8 @@ class InstantRecipientSearchView(APIView):
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class InstantConversationDeleteView(APIView):
permission_classes = [IsAuthenticated]
"""
API pour supprimer (désactiver) une conversation instantanée
"""