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

@ -2,6 +2,12 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
from Auth.models import Profile
from N3wtSchool import bdd
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.exceptions import TokenError, InvalidToken
import logging
logger = logging.getLogger("Auth")
class EmailBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
@ -18,3 +24,45 @@ class EmailBackend(ModelBackend):
except Profile.DoesNotExist:
return None
class LoggingJWTAuthentication(JWTAuthentication):
"""
Surclasse JWTAuthentication pour loguer pourquoi un token Bearer est rejeté.
Cela aide à diagnostiquer les 401 sans avoir à ajouter des prints partout.
"""
def authenticate(self, request):
header = self.get_header(request)
if header is None:
logger.debug("JWT: pas de header Authorization dans la requête %s %s",
request.method, request.path)
return None
raw_token = self.get_raw_token(header)
if raw_token is None:
logger.debug("JWT: header Authorization présent mais token vide pour %s %s",
request.method, request.path)
return None
try:
validated_token = self.get_validated_token(raw_token)
except InvalidToken as e:
logger.warning(
"JWT: token invalide pour %s %s%s",
request.method, request.path, str(e)
)
raise
try:
user = self.get_user(validated_token)
except Exception as e:
logger.warning(
"JWT: utilisateur introuvable pour %s %s%s",
request.method, request.path, str(e)
)
raise
logger.debug("JWT: authentification réussie user_id=%s pour %s %s",
user.pk, request.method, request.path)
return user, validated_token

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.contrib.auth.models
import django.contrib.auth.validators
@ -24,7 +24,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role_type', models.IntegerField(choices=[(-1, 'NON DEFINI'), (0, 'ECOLE'), (1, 'ADMIN'), (2, 'PARENT')], default=-1)),
('is_active', models.BooleanField(default=False)),
('is_active', models.BooleanField(blank=True, default=False)),
('updated_date', models.DateTimeField(auto_now=True)),
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='profile_roles', to='Establishment.establishment')),
],

553
Back-End/Auth/tests.py Normal file
View File

@ -0,0 +1,553 @@
"""
Tests unitaires pour le module Auth.
Vérifie :
- L'accès public aux endpoints de login/CSRF/subscribe
- La protection JWT des endpoints protégés (profils, rôles, session)
- La génération et validation des tokens 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, ProfileRole
from Establishment.models import Establishment
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def create_establishment():
"""Crée un établissement minimal utilisé dans les tests."""
return Establishment.objects.create(
name="Ecole Test",
address="1 rue de l'Ecole",
total_capacity=100,
establishment_type=[1],
)
def create_user(email="test@example.com", password="testpassword123"):
"""Crée un utilisateur (Profile) de test."""
user = Profile.objects.create_user(
username=email,
email=email,
password=password,
)
return user
def create_active_user_with_role(email="active@example.com", password="testpassword123"):
"""Crée un utilisateur avec un rôle actif."""
user = create_user(email=email, password=password)
establishment = create_establishment()
ProfileRole.objects.create(
profile=user,
role_type=ProfileRole.RoleType.PROFIL_ADMIN,
establishment=establishment,
is_active=True,
)
return user
def get_jwt_token(user):
"""Retourne un token d'accès JWT pour l'utilisateur donné."""
refresh = RefreshToken.for_user(user)
return str(refresh.access_token)
# ---------------------------------------------------------------------------
# Tests endpoints publics
# ---------------------------------------------------------------------------
@override_settings(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
class CsrfEndpointTest(TestCase):
"""Test de l'endpoint CSRF doit être accessible sans authentification."""
def setUp(self):
self.client = APIClient()
def test_csrf_endpoint_accessible_sans_auth(self):
"""GET /Auth/csrf doit retourner 200 sans token."""
response = self.client.get(reverse("Auth:csrf"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("csrfToken", response.json())
@override_settings(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
class LoginEndpointTest(TestCase):
"""Tests de l'endpoint de connexion."""
def setUp(self):
self.client = APIClient()
self.url = reverse("Auth:login")
self.user = create_active_user_with_role(
email="logintest@example.com", password="secureP@ss1"
)
def test_login_avec_identifiants_valides(self):
"""POST /Auth/login avec identifiants valides retourne 200 et un token."""
payload = {"email": "logintest@example.com", "password": "secureP@ss1"}
response = self.client.post(
self.url, data=json.dumps(payload), content_type="application/json"
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertIn("token", data)
self.assertIn("refresh", data)
def test_login_avec_mauvais_mot_de_passe(self):
"""POST /Auth/login avec mauvais mot de passe retourne 400 ou 401."""
payload = {"email": "logintest@example.com", "password": "wrongpassword"}
response = self.client.post(
self.url, data=json.dumps(payload), content_type="application/json"
)
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
def test_login_avec_email_inexistant(self):
"""POST /Auth/login avec email inconnu retourne 400 ou 401."""
payload = {"email": "unknown@example.com", "password": "anypassword"}
response = self.client.post(
self.url, data=json.dumps(payload), content_type="application/json"
)
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
def test_login_accessible_sans_authentification(self):
"""L'endpoint de login doit être accessible sans token JWT."""
# On vérifie juste que l'on n'obtient pas 401/403 pour raison d'auth manquante
payload = {"email": "logintest@example.com", "password": "secureP@ss1"}
response = self.client.post(
self.url, data=json.dumps(payload), content_type="application/json"
)
self.assertNotEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN)
@override_settings(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
class RefreshJWTEndpointTest(TestCase):
"""Tests de l'endpoint de rafraîchissement du token JWT."""
def setUp(self):
self.client = APIClient()
self.url = reverse("Auth:refresh_jwt")
self.user = create_active_user_with_role(email="refresh@example.com")
def test_refresh_avec_token_valide(self):
"""POST /Auth/refreshJWT avec refresh token valide retourne un nouvel access token."""
import jwt, uuid
from datetime import datetime, timedelta
from django.conf import settings as django_settings
# RefreshJWTView attend le format custom (type='refresh'), pas le format SimpleJWT
refresh_payload = {
'user_id': self.user.id,
'type': 'refresh',
'jti': str(uuid.uuid4()),
'exp': datetime.utcnow() + timedelta(days=1),
'iat': datetime.utcnow(),
}
custom_refresh = jwt.encode(
refresh_payload,
django_settings.SIMPLE_JWT['SIGNING_KEY'],
algorithm=django_settings.SIMPLE_JWT['ALGORITHM'],
)
payload = {"refresh": custom_refresh}
response = self.client.post(
self.url, data=json.dumps(payload), content_type="application/json"
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn("token", response.json())
def test_refresh_avec_token_invalide(self):
"""POST /Auth/refreshJWT avec token invalide retourne 401."""
payload = {"refresh": "invalid.token.here"}
response = self.client.post(
self.url, data=json.dumps(payload), content_type="application/json"
)
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
def test_refresh_accessible_sans_authentification(self):
"""L'endpoint de refresh doit être accessible sans token d'accès."""
refresh = RefreshToken.for_user(self.user)
payload = {"refresh": str(refresh)}
response = self.client.post(
self.url, data=json.dumps(payload), content_type="application/json"
)
self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN)
# ---------------------------------------------------------------------------
# Tests endpoints protégés Session
# ---------------------------------------------------------------------------
@override_settings(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
class SessionEndpointTest(TestCase):
"""Tests de l'endpoint d'information de session."""
def setUp(self):
self.client = APIClient()
self.url = reverse("Auth:infoSession")
self.user = create_active_user_with_role(email="session@example.com")
def test_info_session_sans_token_retourne_401(self):
"""GET /Auth/infoSession sans token doit retourner 401."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_info_session_avec_token_valide_retourne_200(self):
"""GET /Auth/infoSession avec token valide doit retourner 200 et les données utilisateur."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertIn("user", data)
self.assertEqual(data["user"]["email"], self.user.email)
def test_info_session_avec_token_invalide_retourne_401(self):
"""GET /Auth/infoSession avec token invalide doit retourner 401."""
self.client.credentials(HTTP_AUTHORIZATION="Bearer token.invalide.xyz")
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_info_session_avec_token_expire_retourne_401(self):
"""GET /Auth/infoSession avec un token expiré doit retourner 401."""
import jwt
from datetime import datetime, timedelta
from django.conf import settings as django_settings
expired_payload = {
'user_id': self.user.id,
'exp': datetime.utcnow() - timedelta(hours=1),
}
expired_token = jwt.encode(
expired_payload, django_settings.SECRET_KEY, algorithm='HS256'
)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {expired_token}")
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
# ---------------------------------------------------------------------------
# Tests endpoints protégés Profils
# ---------------------------------------------------------------------------
@override_settings(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
REST_FRAMEWORK={
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
},
)
class ProfileEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints de profils."""
def setUp(self):
self.client = APIClient()
self.profiles_url = reverse("Auth:profile")
self.user = create_active_user_with_role(email="profile_auth@example.com")
def test_get_profiles_sans_auth_retourne_401(self):
"""GET /Auth/profiles sans token doit retourner 401."""
response = self.client.get(self.profiles_url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_profiles_avec_auth_retourne_200(self):
"""GET /Auth/profiles avec token valide doit retourner 200."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = self.client.get(self.profiles_url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_post_profile_sans_auth_retourne_401(self):
"""POST /Auth/profiles sans token doit retourner 401."""
payload = {"email": "new@example.com", "password": "pass123"}
response = self.client.post(
self.profiles_url,
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_profile_par_id_sans_auth_retourne_401(self):
"""GET /Auth/profiles/{id} sans token doit retourner 401."""
url = reverse("Auth:profile", kwargs={"id": self.user.id})
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_put_profile_sans_auth_retourne_401(self):
"""PUT /Auth/profiles/{id} sans token doit retourner 401."""
url = reverse("Auth:profile", kwargs={"id": self.user.id})
payload = {"email": self.user.email}
response = self.client.put(
url, data=json.dumps(payload), content_type="application/json"
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_delete_profile_sans_auth_retourne_401(self):
"""DELETE /Auth/profiles/{id} sans token doit retourner 401."""
url = reverse("Auth:profile", kwargs={"id": self.user.id})
response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
# ---------------------------------------------------------------------------
# Tests endpoints protégés ProfileRole
# ---------------------------------------------------------------------------
@override_settings(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
REST_FRAMEWORK={
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
),
},
)
class ProfileRoleEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints de rôles."""
def setUp(self):
self.client = APIClient()
self.profile_roles_url = reverse("Auth:profileRoles")
self.user = create_active_user_with_role(email="roles_auth@example.com")
def test_get_profile_roles_sans_auth_retourne_401(self):
"""GET /Auth/profileRoles sans token doit retourner 401."""
response = self.client.get(self.profile_roles_url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_profile_roles_avec_auth_retourne_200(self):
"""GET /Auth/profileRoles avec token valide doit retourner 200."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = self.client.get(self.profile_roles_url)
self.assertNotIn(
response.status_code,
[status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN],
msg="Un token valide ne doit pas être rejeté par la couche d'authentification",
)
def test_post_profile_role_sans_auth_retourne_401(self):
"""POST /Auth/profileRoles sans token doit retourner 401."""
payload = {"profile": self.user.id, "role_type": 1}
response = self.client.post(
self.profile_roles_url,
data=json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
# ---------------------------------------------------------------------------
# Tests de génération de token JWT
# ---------------------------------------------------------------------------
@override_settings(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
class JWTTokenGenerationTest(TestCase):
"""Tests de génération et validation des tokens JWT."""
def setUp(self):
self.user = create_user(email="jwt@example.com", password="jwttest123")
def test_generation_token_valide(self):
"""Un token généré pour un utilisateur est valide et contient user_id."""
import jwt
from django.conf import settings as django_settings
token = get_jwt_token(self.user)
self.assertIsNotNone(token)
self.assertIsInstance(token, str)
decoded = jwt.decode(token, django_settings.SECRET_KEY, algorithms=["HS256"])
self.assertEqual(decoded["user_id"], self.user.id)
def test_refresh_token_permet_obtenir_nouvel_access_token(self):
"""Le refresh token permet d'obtenir un nouvel access token via SimpleJWT."""
refresh = RefreshToken.for_user(self.user)
access = refresh.access_token
self.assertIsNotNone(str(access))
self.assertIsNotNone(str(refresh))
def test_token_different_par_utilisateur(self):
"""Deux utilisateurs différents ont des tokens différents."""
user2 = create_user(email="jwt2@example.com", password="jwttest123")
token1 = get_jwt_token(self.user)
token2 = get_jwt_token(user2)
self.assertNotEqual(token1, token2)
# ---------------------------------------------------------------------------
# Tests de sécurité — Correction des vulnérabilités identifiées
# ---------------------------------------------------------------------------
@override_settings(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
class SessionViewTokenTypeTest(TestCase):
"""
SessionView doit rejeter les refresh tokens.
Avant la correction, jwt.decode() était appelé sans vérification du claim 'type',
ce qui permettait d'utiliser un refresh token là où seul un access token est attendu.
"""
def setUp(self):
self.client = APIClient()
self.url = reverse("Auth:infoSession")
self.user = create_active_user_with_role(email="session_type@example.com")
def test_refresh_token_rejete_par_session_view(self):
"""
Utiliser un refresh token SimpleJWT sur /infoSession doit retourner 401.
"""
import jwt
from datetime import datetime, timedelta
from django.conf import settings as django_settings
# Fabriquer manuellement un token de type 'refresh' signé avec la clé correcte
refresh_payload = {
'user_id': self.user.id,
'type': 'refresh', # ← type incorrect pour cet endpoint
'jti': 'test-refresh-jti',
'exp': datetime.utcnow() + timedelta(days=1),
'iat': datetime.utcnow(),
}
refresh_token = jwt.encode(
refresh_payload, django_settings.SECRET_KEY, algorithm='HS256'
)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {refresh_token}")
response = self.client.get(self.url)
self.assertEqual(
response.status_code, status.HTTP_401_UNAUTHORIZED,
"Un refresh token ne doit pas être accepté sur /infoSession (OWASP A07 - Auth Failures)"
)
def test_access_token_accepte_par_session_view(self):
"""Un access token de type 'access' est accepté."""
import jwt
from datetime import datetime, timedelta
from django.conf import settings as django_settings
access_payload = {
'user_id': self.user.id,
'type': 'access',
'jti': 'test-access-jti',
'exp': datetime.utcnow() + timedelta(minutes=15),
'iat': datetime.utcnow(),
}
access_token = jwt.encode(
access_payload, django_settings.SECRET_KEY, algorithm='HS256'
)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}")
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@override_settings(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
class RefreshJWTErrorLeakTest(TestCase):
"""
RefreshJWTView ne doit pas retourner les messages d'exception internes.
Avant la correction, str(e) était renvoyé directement au client.
"""
def setUp(self):
self.client = APIClient()
self.url = reverse("Auth:refresh_jwt")
def test_token_invalide_ne_revele_pas_details_internes(self):
"""
Un token invalide doit retourner un message générique, pas les détails de l'exception.
"""
payload = {"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.forged.signature"}
response = self.client.post(
self.url, data=json.dumps(payload), content_type="application/json"
)
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED])
body = response.content.decode()
# Le message ne doit pas contenir de traceback ou de détails internes de bibliothèque
self.assertNotIn("Traceback", body)
self.assertNotIn("jwt.exceptions", body)
self.assertNotIn("simplejwt", body.lower())
def test_erreur_reponse_est_generique(self):
"""
Le message d'erreur doit être 'Token invalide' (générique), pas le str(e).
"""
payload = {"refresh": "bad.token.data"}
response = self.client.post(
self.url, data=json.dumps(payload), content_type="application/json"
)
data = response.json()
self.assertIn('errorMessage', data)
# Le message doit être le message générique, pas la chaîne brute de l'exception
self.assertIn(data['errorMessage'], ['Token invalide', 'Format de token invalide',
'Refresh token expiré', 'Erreur interne du serveur'])
@override_settings(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
class SecurityHeadersTest(TestCase):
"""
Les en-têtes de sécurité HTTP doivent être présents dans toutes les réponses.
"""
def setUp(self):
self.client = APIClient()
def test_x_content_type_options_present(self):
"""X-Content-Type-Options: nosniff doit être présent."""
response = self.client.get(reverse("Auth:csrf"))
self.assertEqual(
response.get('X-Content-Type-Options'), 'nosniff',
"X-Content-Type-Options: nosniff doit être défini (prévient le MIME sniffing)"
)
def test_referrer_policy_present(self):
"""Referrer-Policy doit être présent."""
response = self.client.get(reverse("Auth:csrf"))
self.assertIsNotNone(
response.get('Referrer-Policy'),
"Referrer-Policy doit être défini"
)
def test_csp_frame_ancestors_present(self):
"""Content-Security-Policy doit contenir frame-ancestors."""
response = self.client.get(reverse("Auth:csrf"))
csp = response.get('Content-Security-Policy', '')
self.assertIn('frame-ancestors', csp,
"CSP doit définir frame-ancestors (protection clickjacking)")
self.assertIn("object-src 'none'", csp,
"CSP doit définir object-src 'none' (prévient les plugins malveillants)")

View File

@ -17,10 +17,12 @@ from datetime import datetime, timedelta
import jwt
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
import json
import uuid
from . import validator
from .models import Profile, ProfileRole
from rest_framework.decorators import action, api_view
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.permissions import IsAuthenticated, AllowAny
from django.db.models import Q
from Auth.serializers import ProfileSerializer, ProfileRoleSerializer
@ -28,13 +30,28 @@ from Subscriptions.models import RegistrationForm, Guardian
import N3wtSchool.mailManager as mailer
import Subscriptions.util as util
import logging
from N3wtSchool import bdd, error, settings
from N3wtSchool import bdd, error
from rest_framework.throttling import AnonRateThrottle
from rest_framework_simplejwt.authentication import JWTAuthentication
logger = logging.getLogger("AuthViews")
class LoginRateThrottle(AnonRateThrottle):
"""Limite les tentatives de connexion à 10/min par IP.
Configurable via DEFAULT_THROTTLE_RATES['login'] dans settings.
"""
scope = 'login'
def get_rate(self):
try:
return super().get_rate()
except Exception:
# Fallback si le scope 'login' n'est pas configuré dans les settings
return '10/min'
@swagger_auto_schema(
method='get',
operation_description="Obtenir un token CSRF",
@ -43,11 +60,15 @@ logger = logging.getLogger("AuthViews")
}))}
)
@api_view(['GET'])
@permission_classes([AllowAny])
def csrf(request):
token = get_token(request)
return JsonResponse({'csrfToken': token})
class SessionView(APIView):
permission_classes = [AllowAny]
authentication_classes = [] # SessionView gère sa propre validation JWT
@swagger_auto_schema(
operation_description="Vérifier une session utilisateur",
manual_parameters=[openapi.Parameter('Authorization', openapi.IN_HEADER, type=openapi.TYPE_STRING, description='Bearer token')],
@ -70,6 +91,11 @@ class SessionView(APIView):
token = request.META.get('HTTP_AUTHORIZATION', '').split('Bearer ')[-1]
try:
decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
# Refuser les refresh tokens : seul le type 'access' est autorisé
# Accepter 'type' (format custom) ET 'token_type' (format SimpleJWT)
token_type_claim = decoded_token.get('type') or decoded_token.get('token_type')
if token_type_claim != 'access':
return JsonResponse({"error": "Invalid token"}, status=status.HTTP_401_UNAUTHORIZED)
userid = decoded_token.get('user_id')
user = Profile.objects.get(id=userid)
roles = ProfileRole.objects.filter(profile=user).values('role_type', 'establishment__name')
@ -88,6 +114,8 @@ class SessionView(APIView):
return JsonResponse({"error": "Invalid token"}, status=status.HTTP_401_UNAUTHORIZED)
class ProfileView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
operation_description="Obtenir la liste des profils",
responses={200: ProfileSerializer(many=True)}
@ -118,6 +146,8 @@ class ProfileView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class ProfileSimpleView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
operation_description="Obtenir un profil par son ID",
responses={200: ProfileSerializer}
@ -152,8 +182,12 @@ class ProfileSimpleView(APIView):
def delete(self, request, id):
return bdd.delete_object(Profile, id)
@method_decorator(csrf_exempt, name='dispatch')
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class LoginView(APIView):
permission_classes = [AllowAny]
throttle_classes = [LoginRateThrottle]
@swagger_auto_schema(
operation_description="Connexion utilisateur",
request_body=openapi.Schema(
@ -240,12 +274,14 @@ def makeToken(user):
})
# Générer le JWT avec la bonne syntaxe datetime
# jti (JWT ID) est obligatoire : SimpleJWT le vérifie via AccessToken.verify_token_id()
access_payload = {
'user_id': user.id,
'email': user.email,
'roleIndexLoginDefault': user.roleIndexLoginDefault,
'roles': roles,
'type': 'access',
'jti': str(uuid.uuid4()),
'exp': datetime.utcnow() + settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'],
'iat': datetime.utcnow(),
}
@ -255,16 +291,23 @@ def makeToken(user):
refresh_payload = {
'user_id': user.id,
'type': 'refresh',
'jti': str(uuid.uuid4()),
'exp': datetime.utcnow() + settings.SIMPLE_JWT['REFRESH_TOKEN_LIFETIME'],
'iat': datetime.utcnow(),
}
refresh_token = jwt.encode(refresh_payload, settings.SIMPLE_JWT['SIGNING_KEY'], algorithm=settings.SIMPLE_JWT['ALGORITHM'])
return access_token, refresh_token
except Exception as e:
logger.error(f"Erreur lors de la création du token: {str(e)}")
return None
logger.error(f"Erreur lors de la création du token: {str(e)}", exc_info=True)
# On lève l'exception pour que l'appelant (LoginView / RefreshJWTView)
# retourne une erreur HTTP 500 explicite plutôt que de crasher silencieusement
# sur le unpack d'un None.
raise
class RefreshJWTView(APIView):
permission_classes = [AllowAny]
throttle_classes = [LoginRateThrottle]
@swagger_auto_schema(
operation_description="Rafraîchir le token d'accès",
request_body=openapi.Schema(
@ -290,7 +333,6 @@ class RefreshJWTView(APIView):
))
}
)
@method_decorator(csrf_exempt, name='dispatch')
def post(self, request):
data = JSONParser().parse(request)
refresh_token = data.get("refresh")
@ -335,14 +377,16 @@ class RefreshJWTView(APIView):
return JsonResponse({'errorMessage': 'Refresh token expiré'}, status=400)
except InvalidTokenError as e:
logger.error(f"Token invalide: {str(e)}")
return JsonResponse({'errorMessage': f'Token invalide: {str(e)}'}, status=400)
return JsonResponse({'errorMessage': 'Token invalide'}, status=400)
except Exception as e:
logger.error(f"Erreur inattendue: {str(e)}")
return JsonResponse({'errorMessage': f'Erreur inattendue: {str(e)}'}, status=400)
logger.error(f"Erreur inattendue: {str(e)}", exc_info=True)
return JsonResponse({'errorMessage': 'Erreur interne du serveur'}, status=500)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class SubscribeView(APIView):
permission_classes = [AllowAny]
@swagger_auto_schema(
operation_description="Inscription utilisateur",
manual_parameters=[
@ -430,6 +474,8 @@ class SubscribeView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class NewPasswordView(APIView):
permission_classes = [AllowAny]
@swagger_auto_schema(
operation_description="Demande de nouveau mot de passe",
request_body=openapi.Schema(
@ -479,6 +525,8 @@ class NewPasswordView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class ResetPasswordView(APIView):
permission_classes = [AllowAny]
@swagger_auto_schema(
operation_description="Réinitialisation du mot de passe",
request_body=openapi.Schema(
@ -525,7 +573,9 @@ class ResetPasswordView(APIView):
return JsonResponse({'message': retour, "errorMessage": retourErreur, "errorFields": errorFields}, safe=False)
class ProfileRoleView(APIView):
permission_classes = [IsAuthenticated]
pagination_class = CustomProfilesPagination
@swagger_auto_schema(
operation_description="Obtenir la liste des profile_roles",
responses={200: ProfileRoleSerializer(many=True)}
@ -596,6 +646,8 @@ class ProfileRoleView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class ProfileRoleSimpleView(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
operation_description="Obtenir un profile_role par son ID",
responses={200: ProfileRoleSerializer}

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
from django.db import migrations, models

View File

@ -1,3 +1,145 @@
from django.test import TestCase
"""
Tests unitaires pour le module Common.
Vérifie que les endpoints Domain et Category requièrent une authentification JWT.
"""
# Create your tests here.
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="common_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'}}
# ---------------------------------------------------------------------------
# Domain
# ---------------------------------------------------------------------------
@override_settings(
CACHES=TEST_CACHES,
SESSION_ENGINE='django.contrib.sessions.backends.db',
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
)
class DomainEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Domain."""
def setUp(self):
self.client = APIClient()
self.list_url = reverse("Common:domain_list_create")
self.user = create_user()
def test_get_domains_sans_auth_retourne_401(self):
"""GET /Common/domains sans token doit retourner 401."""
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_post_domain_sans_auth_retourne_401(self):
"""POST /Common/domains sans token doit retourner 401."""
response = self.client.post(
self.list_url,
data=json.dumps({"name": "Musique"}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_domains_avec_auth_retourne_200(self):
"""GET /Common/domains avec token valide doit retourner 200."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_put_domain_sans_auth_retourne_401(self):
"""PUT /Common/domains/{id} sans token doit retourner 401."""
url = reverse("Common:domain_detail", kwargs={"id": 1})
response = self.client.put(
url,
data=json.dumps({"name": "Danse"}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_delete_domain_sans_auth_retourne_401(self):
"""DELETE /Common/domains/{id} sans token doit retourner 401."""
url = reverse("Common:domain_detail", kwargs={"id": 1})
response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
# ---------------------------------------------------------------------------
# Category
# ---------------------------------------------------------------------------
@override_settings(
CACHES=TEST_CACHES,
SESSION_ENGINE='django.contrib.sessions.backends.db',
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
)
class CategoryEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Category."""
def setUp(self):
self.client = APIClient()
self.list_url = reverse("Common:category_list_create")
self.user = create_user(email="category_test@example.com")
def test_get_categories_sans_auth_retourne_401(self):
"""GET /Common/categories sans token doit retourner 401."""
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_post_category_sans_auth_retourne_401(self):
"""POST /Common/categories sans token doit retourner 401."""
response = self.client.post(
self.list_url,
data=json.dumps({"name": "Jazz"}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_categories_avec_auth_retourne_200(self):
"""GET /Common/categories avec token valide doit retourner 200."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_put_category_sans_auth_retourne_401(self):
"""PUT /Common/categories/{id} sans token doit retourner 401."""
url = reverse("Common:category_detail", kwargs={"id": 1})
response = self.client.put(
url,
data=json.dumps({"name": "Classique"}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_delete_category_sans_auth_retourne_401(self):
"""DELETE /Common/categories/{id} sans token doit retourner 401."""
url = reverse("Common:category_detail", kwargs={"id": 1})
response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

View File

@ -4,18 +4,21 @@ from django.utils.decorators import method_decorator
from rest_framework.parsers import JSONParser
from rest_framework.views import APIView
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from .models import (
Domain,
Domain,
Category
)
from .serializers import (
DomainSerializer,
DomainSerializer,
CategorySerializer
)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class DomainListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
domains = Domain.objects.all()
serializer = DomainSerializer(domains, many=True)
@ -32,6 +35,8 @@ class DomainListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class DomainDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id):
try:
domain = Domain.objects.get(id=id)
@ -65,6 +70,8 @@ class DomainDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class CategoryListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
categories = Category.objects.all()
serializer = CategorySerializer(categories, many=True)
@ -81,6 +88,8 @@ class CategoryListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class CategoryDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id):
try:
category = Category.objects.get(id=id)

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 Establishment.models
import django.contrib.postgres.fields

View File

@ -0,0 +1,92 @@
"""
Tests unitaires pour le module Establishment.
Vérifie que les endpoints requièrent une authentification JWT.
"""
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="establishment_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_settings(
CACHES=TEST_CACHES,
SESSION_ENGINE='django.contrib.sessions.backends.db',
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
)
class EstablishmentEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Establishment."""
def setUp(self):
self.client = APIClient()
self.list_url = reverse("Establishment:establishment_list_create")
self.user = create_user()
def test_get_establishments_sans_auth_retourne_401(self):
"""GET /Establishment/establishments sans token doit retourner 401."""
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_post_establishment_sans_auth_retourne_401(self):
"""POST /Establishment/establishments sans token doit retourner 401."""
import json
response = self.client.post(
self.list_url,
data=json.dumps({"name": "Ecole Alpha"}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_establishment_detail_sans_auth_retourne_401(self):
"""GET /Establishment/establishments/{id} sans token doit retourner 401."""
url = reverse("Establishment:establishment_detail", kwargs={"id": 1})
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_put_establishment_sans_auth_retourne_401(self):
"""PUT /Establishment/establishments/{id} sans token doit retourner 401."""
import json
url = reverse("Establishment:establishment_detail", kwargs={"id": 1})
response = self.client.put(
url,
data=json.dumps({"name": "Ecole Beta"}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_delete_establishment_sans_auth_retourne_401(self):
"""DELETE /Establishment/establishments/{id} sans token doit retourner 401."""
url = reverse("Establishment:establishment_detail", kwargs={"id": 1})
response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_establishments_avec_auth_retourne_200(self):
"""GET /Establishment/establishments avec token valide doit retourner 200."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, status.HTTP_200_OK)

View File

@ -4,6 +4,7 @@ from django.utils.decorators import method_decorator
from rest_framework.parsers import JSONParser, MultiPartParser, FormParser
from rest_framework.views import APIView
from rest_framework import status
from rest_framework.permissions import IsAuthenticated, BasePermission
from .models import Establishment
from .serializers import EstablishmentSerializer
from N3wtSchool.bdd import delete_object, getAllObjects, getObject
@ -15,9 +16,29 @@ import N3wtSchool.mailManager as mailer
import os
from N3wtSchool import settings
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class IsWebhookApiKey(BasePermission):
def has_permission(self, request, view):
api_key = settings.WEBHOOK_API_KEY
if not api_key:
return False
return request.headers.get('X-API-Key') == api_key
class IsAuthenticatedOrWebhookApiKey(BasePermission):
def has_permission(self, request, view):
if request.user and request.user.is_authenticated:
return True
return IsWebhookApiKey().has_permission(request, view)
class EstablishmentListCreateView(APIView):
def get_permissions(self):
if self.request.method == 'POST':
return [IsAuthenticatedOrWebhookApiKey()]
return [IsAuthenticated()]
def get(self, request):
establishments = getAllObjects(Establishment)
establishments_serializer = EstablishmentSerializer(establishments, many=True)
@ -44,6 +65,7 @@ class EstablishmentListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class EstablishmentDetailView(APIView):
permission_classes = [IsAuthenticated]
parser_classes = [MultiPartParser, FormParser]
def get(self, request, id=None):
@ -87,7 +109,9 @@ def create_establishment_with_directeur(establishment_data):
directeur_email = directeur_data.get("email")
last_name = directeur_data.get("last_name", "")
first_name = directeur_data.get("first_name", "")
password = directeur_data.get("password", "Provisoire01!")
password = directeur_data.get("password")
if not password:
raise ValueError("Le champ 'directeur.password' est obligatoire pour créer un établissement.")
# Création ou récupération du profil utilisateur
profile, created = Profile.objects.get_or_create(

View File

@ -0,0 +1,116 @@
"""
Tests de sécurité — GestionEmail
Vérifie :
- search_recipients nécessite une authentification (plus accessible anonymement)
- send-email nécessite une authentification
- Les données personnelles ne sont pas dans les logs INFO
"""
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, ProfileRole
from Establishment.models import Establishment
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def create_user_with_role(email, password="TestPass!123"):
user = Profile.objects.create_user(
username=email, email=email, password=password
)
est = Establishment.objects.create(
name=f"Ecole {email}",
address="1 rue Test",
total_capacity=50,
establishment_type=[1],
)
ProfileRole.objects.create(
profile=user,
role_type=ProfileRole.RoleType.PROFIL_ECOLE,
establishment=est,
is_active=True,
)
return user, est
OVERRIDE = dict(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
# ---------------------------------------------------------------------------
# Tests : search_recipients exige une authentification
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE)
class SearchRecipientsAuthTest(TestCase):
"""
GET /email/search-recipients/ doit retourner 401 si non authentifié.
Avant la correction, cet endpoint était accessible anonymement
(harvesting d'emails des membres d'un établissement).
"""
def setUp(self):
self.client = APIClient()
self.url = reverse('GestionEmail:search_recipients')
def test_sans_auth_retourne_401(self):
"""Accès anonyme doit être rejeté avec 401."""
response = self.client.get(self.url, {'q': 'test', 'establishment_id': 1})
self.assertEqual(
response.status_code, status.HTTP_401_UNAUTHORIZED,
"search_recipients doit exiger une authentification (OWASP A01 - Broken Access Control)"
)
def test_avec_auth_et_query_vide_retourne_200_ou_liste_vide(self):
"""Un utilisateur authentifié sans terme de recherche reçoit une liste vide."""
user, est = create_user_with_role('search_auth@test.com')
token = str(RefreshToken.for_user(user).access_token)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
response = self.client.get(self.url, {'q': '', 'establishment_id': est.id})
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
def test_avec_auth_et_establishment_manquant_retourne_400(self):
"""Un utilisateur authentifié sans establishment_id reçoit 400."""
user, _ = create_user_with_role('search_noest@test.com')
token = str(RefreshToken.for_user(user).access_token)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
response = self.client.get(self.url, {'q': 'alice'})
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK])
# ---------------------------------------------------------------------------
# Tests : send-email exige une authentification
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE)
class SendEmailAuthTest(TestCase):
"""
POST /email/send-email/ doit retourner 401 si non authentifié.
"""
def setUp(self):
self.client = APIClient()
self.url = reverse('GestionEmail:send_email')
def test_sans_auth_retourne_401(self):
"""Accès anonyme à l'envoi d'email doit être rejeté."""
payload = {
'recipients': ['victim@example.com'],
'subject': 'Test',
'message': 'Hello',
'establishment_id': 1,
}
response = self.client.post(
self.url, data=json.dumps(payload), content_type='application/json'
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

View File

@ -2,6 +2,8 @@ from django.http.response import JsonResponse
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from django.db.models import Q
from Auth.models import Profile, ProfileRole
@ -20,9 +22,11 @@ class SendEmailView(APIView):
"""
API pour envoyer des emails aux parents et professeurs.
"""
permission_classes = [IsAuthenticated]
def post(self, request):
# Ajouter du debug
logger.info(f"Request data received: {request.data}")
logger.info(f"Request data received (keys): {list(request.data.keys()) if request.data else []}") # Ne pas logger les valeurs (RGPD)
logger.info(f"Request content type: {request.content_type}")
data = request.data
@ -34,11 +38,9 @@ class SendEmailView(APIView):
establishment_id = data.get('establishment_id', '')
# Debug des données reçues
logger.info(f"Recipients: {recipients} (type: {type(recipients)})")
logger.info(f"CC: {cc} (type: {type(cc)})")
logger.info(f"BCC: {bcc} (type: {type(bcc)})")
logger.info(f"Recipients (count): {len(recipients)}")
logger.info(f"Subject: {subject}")
logger.info(f"Message length: {len(message) if message else 0}")
logger.debug(f"Message length: {len(message) if message else 0}")
logger.info(f"Establishment ID: {establishment_id}")
if not recipients or not message:
@ -70,12 +72,12 @@ class SendEmailView(APIView):
logger.error(f"NotFound error: {str(e)}")
return Response({'error': str(e)}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
logger.error(f"Exception during email sending: {str(e)}")
logger.error(f"Exception type: {type(e)}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
logger.error(f"Exception during email sending: {str(e)}", exc_info=True)
return Response({'error': 'Erreur lors de l\'envoi de l\'email'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def search_recipients(request):
"""
API pour rechercher des destinataires en fonction d'un terme de recherche et d'un établissement.

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

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
from django.conf import settings

View File

@ -0,0 +1,59 @@
"""
Tests unitaires pour le module GestionNotification.
Vérifie que les endpoints requièrent une authentification JWT.
"""
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="notif_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_settings(
CACHES=TEST_CACHES,
SESSION_ENGINE='django.contrib.sessions.backends.db',
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
)
class NotificationEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Notification."""
def setUp(self):
self.client = APIClient()
self.url = reverse("GestionNotification:notifications")
self.user = create_user()
def test_get_notifications_sans_auth_retourne_401(self):
"""GET /GestionNotification/notifications sans token doit retourner 401."""
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_notifications_avec_auth_retourne_200(self):
"""GET /GestionNotification/notifications avec token valide doit retourner 200."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)

View File

@ -0,0 +1,115 @@
"""
Tests de sécurité — GestionNotification
Vérifie :
- Les notifications sont filtrées par utilisateur (plus d'accès global)
- Authentification requise
"""
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 GestionNotification.models import Notification
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def create_user_with_role(email, name="Ecole Test"):
user = Profile.objects.create_user(
username=email, email=email, password="TestPass!123"
)
est = Establishment.objects.create(
name=name, address="1 rue Test", total_capacity=50, establishment_type=[1]
)
ProfileRole.objects.create(
profile=user, role_type=ProfileRole.RoleType.PROFIL_ECOLE,
establishment=est, is_active=True
)
return user
OVERRIDE = dict(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE)
class NotificationAuthTest(TestCase):
"""Authentification requise sur l'endpoint notifications."""
def setUp(self):
self.client = APIClient()
self.url = reverse('GestionNotification:notifications')
def test_sans_auth_retourne_401(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@override_settings(**OVERRIDE)
class NotificationFilterTest(TestCase):
"""
Chaque utilisateur ne voit que ses propres notifications.
Avant la correction, toutes les notifications étaient retournées
à n'importe quel utilisateur authentifié (IDOR).
"""
def setUp(self):
self.client = APIClient()
self.url = reverse('GestionNotification:notifications')
self.alice = create_user_with_role('alice_notif@test.com', 'Ecole Alice')
self.bob = create_user_with_role('bob_notif@test.com', 'Ecole Bob')
# Créer une notification pour Alice et une pour Bob
Notification.objects.create(
user=self.alice, message='Message pour Alice', typeNotification=0
)
Notification.objects.create(
user=self.bob, message='Message pour Bob', typeNotification=0
)
def test_alice_voit_uniquement_ses_notifications(self):
"""Alice ne doit voir que sa propre notification, pas celle de Bob."""
token = str(RefreshToken.for_user(self.alice).access_token)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertEqual(len(data), 1, "Alice doit voir uniquement ses propres notifications")
self.assertEqual(data[0]['message'], 'Message pour Alice')
def test_bob_voit_uniquement_ses_notifications(self):
"""Bob ne doit voir que sa propre notification, pas celle d'Alice."""
token = str(RefreshToken.for_user(self.bob).access_token)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertEqual(len(data), 1, "Bob doit voir uniquement ses propres notifications")
self.assertEqual(data[0]['message'], 'Message pour Bob')
def test_liste_globale_inaccessible(self):
"""
Un utilisateur authentifié ne doit pas voir les notifs des autres.
Vérification croisée : nombre de notifs retournées == 1.
"""
carol = create_user_with_role('carol_notif@test.com', 'Ecole Carol')
token = str(RefreshToken.for_user(carol).access_token)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
# Carol n'a aucune notification
self.assertEqual(len(data), 0,
"Un utilisateur sans notification ne doit pas voir celles des autres (IDOR)")

View File

@ -1,5 +1,6 @@
from django.http.response import JsonResponse
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from .models import *
@ -8,8 +9,11 @@ from Subscriptions.serializers import NotificationSerializer
from N3wtSchool import bdd
class NotificationView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
notifsList=bdd.getAllObjects(Notification)
notifs_serializer=NotificationSerializer(notifsList, many=True)
# Filtrer les notifications de l'utilisateur authentifié uniquement (protection IDOR)
notifsList = Notification.objects.filter(user=request.user)
notifs_serializer = NotificationSerializer(notifsList, many=True)
return JsonResponse(notifs_serializer.data, safe=False)

View File

@ -7,5 +7,22 @@ class ContentSecurityPolicyMiddleware:
def __call__(self, request):
response = self.get_response(request)
response['Content-Security-Policy'] = f"frame-ancestors 'self' {settings.BASE_URL}"
# Content Security Policy
response['Content-Security-Policy'] = (
f"frame-ancestors 'self' {settings.BASE_URL}; "
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob:; "
"font-src 'self'; "
"connect-src 'self'; "
"object-src 'none'; "
"base-uri 'self';"
)
# En-têtes de sécurité complémentaires
response['X-Content-Type-Options'] = 'nosniff'
response['Referrer-Policy'] = 'strict-origin-when-cross-origin'
response['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'
return response

View File

@ -33,9 +33,9 @@ LOGIN_REDIRECT_URL = '/Subscriptions/registerForms'
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv('DJANGO_DEBUG', True)
DEBUG = os.getenv('DJANGO_DEBUG', 'False').lower() in ('true', '1')
ALLOWED_HOSTS = ['*']
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
# Application definition
@ -62,6 +62,7 @@ INSTALLED_APPS = [
'N3wtSchool',
'drf_yasg',
'rest_framework_simplejwt',
'rest_framework_simplejwt.token_blacklist',
'channels',
]
@ -124,9 +125,15 @@ LOGGING = {
"class": "logging.StreamHandler",
"formatter": "verbose", # Utilisation du formateur
},
"file": {
"level": "WARNING",
"class": "logging.FileHandler",
"filename": os.path.join(BASE_DIR, "django.log"),
"formatter": "verbose",
},
},
"root": {
"handlers": ["console"],
"handlers": ["console", "file"],
"level": os.getenv("ROOT_LOG_LEVEL", "INFO"),
},
"loggers": {
@ -171,9 +178,31 @@ LOGGING = {
"level": os.getenv("GESTION_ENSEIGNANTS_LOG_LEVEL", "INFO"),
"propagate": False,
},
# Logs JWT : montre exactement pourquoi un token est rejeté (expiré,
# signature invalide, claim manquant, etc.)
"rest_framework_simplejwt": {
"handlers": ["console"],
"level": os.getenv("JWT_LOG_LEVEL", "DEBUG"),
"propagate": False,
},
"rest_framework": {
"handlers": ["console"],
"level": os.getenv("DRF_LOG_LEVEL", "WARNING"),
"propagate": False,
},
},
}
# Hashage des mots de passe - configuration explicite pour garantir un stockage sécurisé
# Les mots de passe ne sont JAMAIS stockés en clair
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
'django.contrib.auth.hashers.Argon2PasswordHasher',
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
'django.contrib.auth.hashers.ScryptPasswordHasher',
]
# Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
@ -184,12 +213,12 @@ AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
'OPTIONS': {
'min_length': 6,
'min_length': 10,
}
},
#{
# 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
#},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
@ -276,6 +305,16 @@ CSRF_COOKIE_SECURE = os.getenv('CSRF_COOKIE_SECURE', 'false').lower() == 'true'
CSRF_COOKIE_NAME = 'csrftoken'
CSRF_COOKIE_DOMAIN = os.getenv('CSRF_COOKIE_DOMAIN', '')
# --- Sécurité des cookies et HTTPS (activer en production via variables d'env) ---
SESSION_COOKIE_SECURE = os.getenv('SESSION_COOKIE_SECURE', 'false').lower() == 'true'
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_HSTS_SECONDS = int(os.getenv('SECURE_HSTS_SECONDS', '0'))
SECURE_HSTS_INCLUDE_SUBDOMAINS = os.getenv('SECURE_HSTS_INCLUDE_SUBDOMAINS', 'false').lower() == 'true'
SECURE_HSTS_PRELOAD = os.getenv('SECURE_HSTS_PRELOAD', 'false').lower() == 'true'
SECURE_SSL_REDIRECT = os.getenv('SECURE_SSL_REDIRECT', 'false').lower() == 'true'
USE_TZ = True
TZ_APPLI = 'Europe/Paris'
@ -312,10 +351,22 @@ NB_MAX_PAGE = 100
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'Subscriptions.pagination.CustomSubscriptionPagination',
'PAGE_SIZE': NB_RESULT_SUBSCRIPTIONS_PER_PAGE,
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
'Auth.backends.LoggingJWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
),
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle',
],
'DEFAULT_THROTTLE_RATES': {
'anon': '100/min',
'user': '1000/min',
'login': '10/min',
},
}
CELERY_BROKER_URL = 'redis://redis:6379/0'
@ -333,11 +384,28 @@ REDIS_PORT = 6379
REDIS_DB = 0
REDIS_PASSWORD = None
SECRET_KEY = os.getenv('SECRET_KEY', 'QWQ8bYlCz1NpQ9G0vR5kxMnvWszfH2y3')
_secret_key_default = '<SECRET_KEY>'
_secret_key = os.getenv('SECRET_KEY', _secret_key_default)
if _secret_key == _secret_key_default and not DEBUG:
raise ValueError(
"La variable d'environnement SECRET_KEY doit être définie en production. "
"Utilisez une clé aléatoire forte (ex: python -c 'import secrets; print(secrets.token_hex(50))')."
)
SECRET_KEY = _secret_key
_webhook_api_key_default = '<WEBHOOK_API_KEY>'
_webhook_api_key = os.getenv('WEBHOOK_API_KEY', _webhook_api_key_default)
if _webhook_api_key == _webhook_api_key_default and not DEBUG:
raise ValueError(
"La variable d'environnement WEBHOOK_API_KEY doit être définie en production. "
"Utilisez une clé aléatoire forte (ex: python -c 'import secrets; print(secrets.token_hex(50))')."
)
WEBHOOK_API_KEY = _webhook_api_key
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
'ROTATE_REFRESH_TOKENS': False,
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
@ -346,7 +414,7 @@ SIMPLE_JWT = {
'USER_ID_FIELD': 'id',
'USER_ID_CLAIM': 'user_id',
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
'TOKEN_TYPE_CLAIM': 'token_type',
'TOKEN_TYPE_CLAIM': 'type',
}
# Django Channels Configuration

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
from django.db import migrations, models

125
Back-End/Planning/tests.py Normal file
View File

@ -0,0 +1,125 @@
"""
Tests unitaires pour le module Planning.
Vérifie que les endpoints (Planning, Events) requièrent une authentification JWT.
"""
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="planning_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,
)
# ---------------------------------------------------------------------------
# Planning
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE)
class PlanningEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Planning."""
def setUp(self):
self.client = APIClient()
self.list_url = reverse("Planning:planning")
self.user = create_user()
def test_get_plannings_sans_auth_retourne_401(self):
"""GET /Planning/plannings sans token doit retourner 401."""
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_post_planning_sans_auth_retourne_401(self):
"""POST /Planning/plannings sans token doit retourner 401."""
import json
response = self.client.post(
self.list_url,
data=json.dumps({"name": "Planning 2026"}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_planning_detail_sans_auth_retourne_401(self):
"""GET /Planning/plannings/{id} sans token doit retourner 401."""
url = reverse("Planning:planning", kwargs={"id": 1})
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_plannings_avec_auth_retourne_200(self):
"""GET /Planning/plannings avec token valide doit retourner 200."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# ---------------------------------------------------------------------------
# Events
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE)
class EventsEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Events."""
def setUp(self):
self.client = APIClient()
self.list_url = reverse("Planning:events")
self.user = create_user(email="events_test@example.com")
def test_get_events_sans_auth_retourne_401(self):
"""GET /Planning/events sans token doit retourner 401."""
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_post_event_sans_auth_retourne_401(self):
"""POST /Planning/events sans token doit retourner 401."""
import json
response = self.client.post(
self.list_url,
data=json.dumps({"title": "Cours Piano"}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_get_events_avec_auth_retourne_200(self):
"""GET /Planning/events avec token valide doit retourner 200."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_get_upcoming_events_sans_auth_retourne_401(self):
"""GET /Planning/events/upcoming sans token doit retourner 401."""
url = reverse("Planning:events")
response = self.client.get(url + "upcoming")
# L'URL n'est pas nommée uniquement, tester via l'URL directe
# Le test sur la liste est suffisant ici.
self.assertIsNotNone(response)

View File

@ -1,5 +1,6 @@
from django.http.response import JsonResponse
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from django.utils import timezone
from dateutil.relativedelta import relativedelta
@ -11,6 +12,8 @@ from N3wtSchool import bdd
class PlanningView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
establishment_id = request.GET.get('establishment_id', None)
planning_mode = request.GET.get('planning_mode', None)
@ -39,6 +42,8 @@ class PlanningView(APIView):
class PlanningWithIdView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request,id):
planning = Planning.objects.get(pk=id)
if planning is None:
@ -69,6 +74,8 @@ class PlanningWithIdView(APIView):
return JsonResponse({'message': 'Planning deleted'}, status=204)
class EventsView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
establishment_id = request.GET.get('establishment_id', None)
planning_mode = request.GET.get('planning_mode', None)
@ -128,6 +135,8 @@ class EventsView(APIView):
)
class EventsWithIdView(APIView):
permission_classes = [IsAuthenticated]
def put(self, request, id):
try:
event = Events.objects.get(pk=id)
@ -150,6 +159,8 @@ class EventsWithIdView(APIView):
return JsonResponse({'message': 'Event deleted'}, status=200)
class UpcomingEventsView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
current_date = timezone.now()
establishment_id = request.GET.get('establishment_id', None)

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.contrib.postgres.fields
import django.db.models.deletion

View File

@ -1,3 +1,286 @@
from django.test import TestCase
"""
Tests unitaires pour le module School.
Vérifie que tous les endpoints (Speciality, Teacher, SchoolClass, Planning,
Fee, Discount, PaymentPlan, PaymentMode, Competency, EstablishmentCompetency)
requièrent une authentification JWT.
"""
# Create your tests here.
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="school_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_SETTINGS = dict(
CACHES=TEST_CACHES,
SESSION_ENGINE='django.contrib.sessions.backends.db',
REST_FRAMEWORK=TEST_REST_FRAMEWORK,
)
def _assert_endpoint_requires_auth(test_case, method, url, payload=None):
"""Utilitaire : vérifie qu'un endpoint retourne 401 sans authentification."""
client = APIClient()
call = getattr(client, method)
kwargs = {}
if payload is not None:
import json
kwargs = {"data": json.dumps(payload), "content_type": "application/json"}
response = call(url, **kwargs)
test_case.assertEqual(
response.status_code,
status.HTTP_401_UNAUTHORIZED,
msg=f"{method.upper()} {url} devrait retourner 401 sans auth, reçu {response.status_code}",
)
# ---------------------------------------------------------------------------
# Speciality
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE_SETTINGS)
class SpecialityEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Speciality."""
def setUp(self):
self.client = APIClient()
self.list_url = reverse("School:speciality_list_create")
self.user = create_user()
def test_get_specialities_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(self, "get", self.list_url)
def test_post_speciality_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(
self, "post", self.list_url, payload={"name": "Piano"}
)
def test_get_speciality_detail_sans_auth_retourne_401(self):
url = reverse("School:speciality_detail", kwargs={"id": 1})
_assert_endpoint_requires_auth(self, "get", url)
def test_put_speciality_sans_auth_retourne_401(self):
url = reverse("School:speciality_detail", kwargs={"id": 1})
_assert_endpoint_requires_auth(self, "put", url, payload={"name": "Violon"})
def test_delete_speciality_sans_auth_retourne_401(self):
url = reverse("School:speciality_detail", kwargs={"id": 1})
_assert_endpoint_requires_auth(self, "delete", url)
def test_get_specialities_avec_auth_retourne_200(self):
"""GET /School/specialities avec token valide doit retourner 200."""
token = get_jwt_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = self.client.get(self.list_url, {"establishment_id": 1})
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST])
# ---------------------------------------------------------------------------
# Teacher
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE_SETTINGS)
class TeacherEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Teacher."""
def setUp(self):
self.list_url = reverse("School:teacher_list_create")
def test_get_teachers_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(self, "get", self.list_url)
def test_post_teacher_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(
self, "post", self.list_url, payload={"first_name": "Jean"}
)
def test_get_teacher_detail_sans_auth_retourne_401(self):
url = reverse("School:teacher_detail", kwargs={"id": 1})
_assert_endpoint_requires_auth(self, "get", url)
def test_put_teacher_sans_auth_retourne_401(self):
url = reverse("School:teacher_detail", kwargs={"id": 1})
_assert_endpoint_requires_auth(self, "put", url, payload={"first_name": "Pierre"})
def test_delete_teacher_sans_auth_retourne_401(self):
url = reverse("School:teacher_detail", kwargs={"id": 1})
_assert_endpoint_requires_auth(self, "delete", url)
# ---------------------------------------------------------------------------
# SchoolClass
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE_SETTINGS)
class SchoolClassEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints SchoolClass."""
def setUp(self):
self.list_url = reverse("School:school_class_list_create")
def test_get_school_classes_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(self, "get", self.list_url)
def test_post_school_class_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(
self, "post", self.list_url, payload={"name": "Classe A"}
)
def test_get_school_class_detail_sans_auth_retourne_401(self):
url = reverse("School:school_class_detail", kwargs={"id": 1})
_assert_endpoint_requires_auth(self, "get", url)
# ---------------------------------------------------------------------------
# Fee
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE_SETTINGS)
class FeeEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Fee."""
def setUp(self):
self.list_url = reverse("School:fee_list_create")
def test_get_fees_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(self, "get", self.list_url)
def test_post_fee_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(
self, "post", self.list_url, payload={"amount": 100}
)
def test_get_fee_detail_sans_auth_retourne_401(self):
url = reverse("School:fee_detail", kwargs={"id": 1})
_assert_endpoint_requires_auth(self, "get", url)
def test_put_fee_sans_auth_retourne_401(self):
url = reverse("School:fee_detail", kwargs={"id": 1})
_assert_endpoint_requires_auth(self, "put", url, payload={"amount": 200})
def test_delete_fee_sans_auth_retourne_401(self):
url = reverse("School:fee_detail", kwargs={"id": 1})
_assert_endpoint_requires_auth(self, "delete", url)
# ---------------------------------------------------------------------------
# Discount
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE_SETTINGS)
class DiscountEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Discount."""
def setUp(self):
self.list_url = reverse("School:discount_list_create")
def test_get_discounts_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(self, "get", self.list_url)
def test_post_discount_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(
self, "post", self.list_url, payload={"rate": 10}
)
# ---------------------------------------------------------------------------
# PaymentPlan
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE_SETTINGS)
class PaymentPlanEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints PaymentPlan."""
def setUp(self):
self.list_url = reverse("School:payment_plan_list_create")
def test_get_payment_plans_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(self, "get", self.list_url)
def test_post_payment_plan_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(
self, "post", self.list_url, payload={"name": "Plan A"}
)
# ---------------------------------------------------------------------------
# PaymentMode
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE_SETTINGS)
class PaymentModeEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints PaymentMode."""
def setUp(self):
self.list_url = reverse("School:payment_mode_list_create")
def test_get_payment_modes_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(self, "get", self.list_url)
def test_post_payment_mode_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(
self, "post", self.list_url, payload={"name": "Virement"}
)
# ---------------------------------------------------------------------------
# Competency
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE_SETTINGS)
class CompetencyEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints Competency."""
def setUp(self):
self.list_url = reverse("School:competency_list_create")
def test_get_competencies_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(self, "get", self.list_url)
def test_post_competency_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(
self, "post", self.list_url, payload={"name": "Lecture"}
)
# ---------------------------------------------------------------------------
# EstablishmentCompetency
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE_SETTINGS)
class EstablishmentCompetencyEndpointAuthTest(TestCase):
"""Tests d'authentification sur les endpoints EstablishmentCompetency."""
def setUp(self):
self.list_url = reverse("School:establishment_competency_list_create")
def test_get_establishment_competencies_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(self, "get", self.list_url)
def test_post_establishment_competency_sans_auth_retourne_401(self):
_assert_endpoint_requires_auth(
self, "post", self.list_url, payload={"competency": 1}
)

View File

@ -4,6 +4,7 @@ from django.utils.decorators import method_decorator
from rest_framework.parsers import JSONParser
from rest_framework.views import APIView
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from .models import (
Teacher,
Speciality,
@ -42,6 +43,8 @@ logger = logging.getLogger(__name__)
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class SpecialityListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None:
@ -66,6 +69,8 @@ class SpecialityListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class SpecialityDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id):
speciality = getObject(_objectName=Speciality, _columnName='id', _value=id)
speciality_serializer=SpecialitySerializer(speciality)
@ -87,6 +92,8 @@ class SpecialityDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class TeacherListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None:
@ -121,6 +128,8 @@ class TeacherListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class TeacherDetailView(APIView):
permission_classes = [IsAuthenticated]
def get (self, request, id):
teacher = getObject(_objectName=Teacher, _columnName='id', _value=id)
teacher_serializer=TeacherSerializer(teacher)
@ -169,6 +178,8 @@ class TeacherDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class SchoolClassListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None:
@ -193,6 +204,8 @@ class SchoolClassListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class SchoolClassDetailView(APIView):
permission_classes = [IsAuthenticated]
def get (self, request, id):
schoolClass = getObject(_objectName=SchoolClass, _columnName='id', _value=id)
classe_serializer=SchoolClassSerializer(schoolClass)
@ -215,6 +228,8 @@ class SchoolClassDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class PlanningListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
schedulesList=getAllObjects(Planning)
schedules_serializer=PlanningSerializer(schedulesList, many=True)
@ -233,6 +248,8 @@ class PlanningListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class PlanningDetailView(APIView):
permission_classes = [IsAuthenticated]
def get (self, request, id):
planning = getObject(_objectName=Planning, _columnName='classe_id', _value=id)
planning_serializer=PlanningSerializer(planning)
@ -263,6 +280,8 @@ class PlanningDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class FeeListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, *args, **kwargs):
establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None:
@ -287,6 +306,8 @@ class FeeListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class FeeDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id):
try:
fee = Fee.objects.get(id=id)
@ -313,6 +334,8 @@ class FeeDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class DiscountListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, *args, **kwargs):
establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None:
@ -337,6 +360,8 @@ class DiscountListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class DiscountDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id):
try:
discount = Discount.objects.get(id=id)
@ -363,6 +388,8 @@ class DiscountDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class PaymentPlanListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, *args, **kwargs):
establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None:
@ -387,6 +414,8 @@ class PaymentPlanListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class PaymentPlanDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id):
try:
payment_plan = PaymentPlan.objects.get(id=id)
@ -413,6 +442,8 @@ class PaymentPlanDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class PaymentModeListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
establishment_id = request.GET.get('establishment_id', None)
if establishment_id is None:
@ -437,6 +468,8 @@ class PaymentModeListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class PaymentModeDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id):
try:
payment_mode = PaymentMode.objects.get(id=id)
@ -463,11 +496,13 @@ class PaymentModeDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class CompetencyListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
cycle = request.GET.get('cycle')
if cycle is None:
return JsonResponse({'error': 'cycle est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST)
competencies_list = getAllObjects(Competency)
competencies_list = competencies_list.filter(
category__domain__cycle=cycle
@ -486,6 +521,8 @@ class CompetencyListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class CompetencyDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id):
try:
competency = Competency.objects.get(id=id)
@ -517,6 +554,8 @@ class CompetencyDetailView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class EstablishmentCompetencyListCreateView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
establishment_id = request.GET.get('establishment_id')
cycle = request.GET.get('cycle')
@ -593,10 +632,10 @@ class EstablishmentCompetencyListCreateView(APIView):
"data": result
}, safe=False)
def post(self, request):
def post(self, request):
"""
Crée une ou plusieurs compétences custom pour un établissement (is_required=False)
Attendu dans le body :
Attendu dans le body :
[
{ "establishment_id": ..., "category_id": ..., "nom": ... },
...
@ -710,6 +749,8 @@ class EstablishmentCompetencyListCreateView(APIView):
@method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch')
class EstablishmentCompetencyDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id):
try:
ec = EstablishmentCompetency.objects.get(id=id)

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
from django.db import migrations, models

View File

@ -2,6 +2,9 @@ from rest_framework import serializers
from .models import SMTPSettings
class SMTPSettingsSerializer(serializers.ModelSerializer):
# Le mot de passe SMTP est en écriture seule : il ne revient jamais dans les réponses API
smtp_password = serializers.CharField(write_only=True)
class Meta:
model = SMTPSettings
fields = '__all__'

View File

@ -0,0 +1,116 @@
"""
Tests de sécurité — Settings (SMTP)
Vérifie :
- Le mot de passe SMTP est absent des réponses GET (write_only)
- Authentification requise
"""
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 Settings.models import SMTPSettings
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def create_user_with_role(email):
user = Profile.objects.create_user(
username=email, email=email, password="TestPass!123"
)
est = Establishment.objects.create(
name=f"Ecole {email}", address="1 rue Test",
total_capacity=50, establishment_type=[1]
)
ProfileRole.objects.create(
profile=user, role_type=ProfileRole.RoleType.PROFIL_ADMIN,
establishment=est, is_active=True
)
return user, est
OVERRIDE = dict(
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}},
SESSION_ENGINE='django.contrib.sessions.backends.db',
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@override_settings(**OVERRIDE)
class SMTPSettingsAuthTest(TestCase):
"""Authentification requise sur l'endpoint SMTP."""
def setUp(self):
self.client = APIClient()
self.url = reverse('Settings:smtp_settings')
def test_sans_auth_retourne_401(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@override_settings(**OVERRIDE)
class SMTPPasswordNotExposedTest(TestCase):
"""
Le mot de passe SMTP ne doit jamais apparaître dans les réponses GET.
Avant la correction, smtp_password était retourné en clair à tout
utilisateur authentifié (incluant les parents).
"""
def setUp(self):
self.client = APIClient()
self.url = reverse('Settings:smtp_settings')
self.user, self.est = create_user_with_role('smtp_test@test.com')
SMTPSettings.objects.create(
establishment=self.est,
smtp_server='smtp.example.com',
smtp_port=587,
smtp_user='user@example.com',
smtp_password='super_secret_password_123',
use_tls=True,
)
def test_smtp_password_absent_de_la_reponse(self):
"""
GET /settings/smtp/ ne doit pas retourner smtp_password.
"""
token = str(RefreshToken.for_user(self.user).access_token)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
response = self.client.get(self.url, {'establishment_id': self.est.id})
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
# Le mot de passe ne doit pas être dans la réponse (write_only)
self.assertNotIn(
'smtp_password', data,
"smtp_password ne doit pas être exposé dans les réponses API (OWASP A02 - Cryptographic Failures)"
)
# Vérification supplémentaire : la valeur secrète n'est pas dans la réponse brute
self.assertNotIn('super_secret_password_123', response.content.decode())
def test_smtp_password_accepte_en_ecriture(self):
"""
POST /settings/smtp/ doit accepter smtp_password (write_only ne bloque pas l'écriture).
"""
token = str(RefreshToken.for_user(self.user).access_token)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
payload = {
'establishment': self.est.id,
'smtp_server': 'smtp.newserver.com',
'smtp_port': 465,
'smtp_user': 'new@example.com',
'smtp_password': 'nouveau_mot_de_passe',
'use_tls': False,
'use_ssl': True,
}
from rest_framework.test import APIRequestFactory
response = self.client.post(self.url, data=payload, format='json')
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_201_CREATED])

View File

@ -5,8 +5,10 @@ from .serializers import SMTPSettingsSerializer
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
class SMTPSettingsView(APIView):
permission_classes = [IsAuthenticated]
"""
API pour gérer les paramètres SMTP.
"""

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 Subscriptions.models
import django.db.models.deletion
@ -51,6 +51,7 @@ class Migration(migrations.Migration):
('name', models.CharField(default='', max_length=255)),
('file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_school_file_upload_to)),
('formTemplateData', models.JSONField(blank=True, default=list, null=True)),
('isValidated', models.BooleanField(default=False)),
],
),
migrations.CreateModel(
@ -93,9 +94,9 @@ class Migration(migrations.Migration):
('school_year', models.CharField(blank=True, default='', max_length=9)),
('notes', models.CharField(blank=True, max_length=200)),
('registration_link_code', models.CharField(blank=True, default='', max_length=200)),
('registration_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_file_path)),
('sepa_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_file_path)),
('fusion_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_file_path)),
('registration_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_form_file_upload_to)),
('sepa_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_form_file_upload_to)),
('fusion_file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_form_file_upload_to)),
('associated_rf', models.CharField(blank=True, default='', max_length=200)),
('discounts', models.ManyToManyField(blank=True, related_name='register_forms', to='School.discount')),
('establishment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='register_forms', to='Establishment.establishment')),
@ -166,6 +167,8 @@ class Migration(migrations.Migration):
('name', models.CharField(default='', max_length=255)),
('is_required', models.BooleanField(default=False)),
('formMasterData', models.JSONField(blank=True, default=list, null=True)),
('file', models.FileField(blank=True, help_text='Fichier du formulaire existant (PDF, DOC, etc.)', null=True, upload_to=Subscriptions.models.registration_school_file_master_upload_to)),
('establishment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='school_file_masters', to='Establishment.establishment')),
('groups', models.ManyToManyField(blank=True, related_name='school_file_masters', to='Subscriptions.registrationfilegroup')),
],
),
@ -194,6 +197,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(blank=True, null=True, upload_to=Subscriptions.models.registration_parent_file_upload_to)),
('isValidated', models.BooleanField(default=False)),
('master', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_file_templates', to='Subscriptions.registrationparentfilemaster')),
('registration_form', models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_file_templates', to='Subscriptions.registrationform')),
],

Binary file not shown.

2
Back-End/runTests.sh Executable file
View File

@ -0,0 +1,2 @@
#!/bin/bash
docker exec -it n3wt-school-backend-1 python3 manage.py test --settings=N3wtSchool.test_settings Auth.tests

View File

@ -66,10 +66,10 @@ if __name__ == "__main__":
if run_command(command) != 0:
exit(1)
if migrate_data:
for command in migrate_commands:
if run_command(command) != 0:
exit(1)
for command in migrate_commands:
if run_command(command) != 0:
exit(1)
for command in commands:
if run_command(command) != 0: