From c861239d4817dcc65297d33af4334b4645cfddf4 Mon Sep 17 00:00:00 2001 From: Luc SORIGNET Date: Sat, 22 Feb 2025 10:52:50 +0100 Subject: [PATCH] chore: ajustement JWT --- Back-End/Auth/urls.py | 3 +- Back-End/Auth/views.py | 144 ++++++++++++++++-- Back-End/N3wtSchool/settings.py | 2 +- Back-End/src/app.js | 0 Back-End/src/middleware/cors.js | 0 Front-End/next.config.mjs | 3 + Front-End/src/app/[locale]/admin/layout.js | 3 +- .../app/[locale]/parents/messagerie/page.js | 5 +- Front-End/src/components/Logo.js | 4 +- Front-End/src/components/ProtectedRoute.js | 22 ++- Front-End/src/hooks/useLocalStorage.js | 57 ++++--- Front-End/src/pages/api/auth/[...nextauth].js | 76 +++++---- 12 files changed, 244 insertions(+), 75 deletions(-) delete mode 100644 Back-End/src/app.js delete mode 100644 Back-End/src/middleware/cors.js diff --git a/Back-End/Auth/urls.py b/Back-End/Auth/urls.py index d16a5f9..d795b0b 100644 --- a/Back-End/Auth/urls.py +++ b/Back-End/Auth/urls.py @@ -2,12 +2,13 @@ from django.urls import path, re_path from . import views import Auth.views -from Auth.views import ProfileSimpleView, ProfileView, SessionView, LoginView, SubscribeView, NewPasswordView, ResetPasswordView +from Auth.views import ProfileSimpleView, ProfileView, SessionView, LoginView, RefreshJWTView, SubscribeView, NewPasswordView, ResetPasswordView urlpatterns = [ re_path(r'^csrf$', Auth.views.csrf, name='csrf'), re_path(r'^login$', LoginView.as_view(), name="login"), + re_path(r'^refreshJWT$', RefreshJWTView.as_view(), name="refresh_jwt"), re_path(r'^subscribe$', SubscribeView.as_view(), name='subscribe'), re_path(r'^newPassword$', NewPasswordView.as_view(), name='newPassword'), re_path(r'^resetPassword/(?P[a-zA-Z]+)$', ResetPasswordView.as_view(), name='resetPassword'), diff --git a/Back-End/Auth/views.py b/Back-End/Auth/views.py index 2eb71a5..45c3598 100644 --- a/Back-End/Auth/views.py +++ b/Back-End/Auth/views.py @@ -13,8 +13,9 @@ from rest_framework import status from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi -from datetime import datetime +from datetime import datetime, timedelta import jwt +from jwt.exceptions import ExpiredSignatureError, InvalidTokenError import json from . import validator @@ -26,11 +27,13 @@ from Subscriptions.models import RegistrationForm from Subscriptions.signals import clear_cache import Subscriptions.mailManager as mailer import Subscriptions.util as util - +import logging from N3wtSchool import bdd, error from rest_framework_simplejwt.authentication import JWTAuthentication +logger = logging.getLogger("AuthViews") + @swagger_auto_schema( method='get', @@ -162,13 +165,17 @@ class LoginView(APIView): ), responses={ 200: openapi.Response('Connexion réussie', schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'token': openapi.Schema(type=openapi.TYPE_STRING), + 'refresh': openapi.Schema(type=openapi.TYPE_STRING) + } + )), + 400: openapi.Response('Connexion échouée', schema=openapi.Schema( type=openapi.TYPE_OBJECT, properties={ 'errorFields': openapi.Schema(type=openapi.TYPE_OBJECT), - 'errorMessage': openapi.Schema(type=openapi.TYPE_STRING), - 'profil': openapi.Schema(type=openapi.TYPE_INTEGER), - 'droit': openapi.Schema(type=openapi.TYPE_INTEGER), - 'id': openapi.Schema(type=openapi.TYPE_INTEGER), + 'errorMessage': openapi.Schema(type=openapi.TYPE_STRING) } )) } @@ -179,6 +186,7 @@ class LoginView(APIView): retour = error.returnMessage[error.WRONG_ID] validationOk, errorFields = validatorAuthentication.validate() user = None + if validationOk: user = authenticate( email=data.get('email'), @@ -191,6 +199,31 @@ class LoginView(APIView): user.save() clear_cache() retour = '' + # Générer le JWT avec la bonne syntaxe datetime + access_payload = { + 'user_id': user.id, + 'email': user.email, + 'droit': user.droit, + 'type': 'access', + 'exp': datetime.utcnow() + settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'], + 'iat': datetime.utcnow(), + } + + access_token = jwt.encode(access_payload, settings.SIMPLE_JWT['SIGNING_KEY'], algorithm=settings.SIMPLE_JWT['ALGORITHM']) + # Générer le Refresh Token (exp: 7 jours) + refresh_payload = { + 'user_id': user.id, + 'type': 'refresh', + '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 JsonResponse({ + 'token': access_token, + 'refresh': refresh_token + }, safe=False) + else: retour = error.returnMessage[error.PROFIL_INACTIVE] else: @@ -199,10 +232,101 @@ class LoginView(APIView): return JsonResponse({ 'errorFields': errorFields, 'errorMessage': retour, - 'profil': user.id if user else -1, - 'droit': user.droit if user else -1, - 'id': user.id if user else -1, - }, safe=False) + }, safe=False, status=status.HTTP_400_BAD_REQUEST) + + +class RefreshJWTView(APIView): + @swagger_auto_schema( + operation_description="Rafraîchir le token d'accès", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=['refresh'], + properties={ + 'refresh': openapi.Schema(type=openapi.TYPE_STRING) + } + ), + responses={ + 200: openapi.Response('Token rafraîchi avec succès', schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'token': openapi.Schema(type=openapi.TYPE_STRING), + 'refresh': openapi.Schema(type=openapi.TYPE_STRING), + } + )), + 400: openapi.Response('Échec du rafraîchissement', schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'errorMessage': openapi.Schema(type=openapi.TYPE_STRING) + } + )) + } + ) + @method_decorator(csrf_exempt, name='dispatch') + def post(self, request): + data = JSONParser().parse(request) + refresh_token = data.get("refresh") + logger.info(f"Token reçu: {refresh_token[:20]}...") # Ne pas logger le token complet pour la sécurité + + if not refresh_token: + return JsonResponse({'errorMessage': 'Refresh token manquant'}, status=400) + + try: + # Décoder le Refresh Token + logger.info("Tentative de décodage du token") + logger.info(f"Algorithme utilisé: {settings.SIMPLE_JWT['ALGORITHM']}") + + # Vérifier le format du token avant décodage + token_parts = refresh_token.split('.') + if len(token_parts) != 3: + logger.error("Format de token invalide - pas 3 parties") + return JsonResponse({'errorMessage': 'Format de token invalide'}, status=400) + + payload = jwt.decode( + refresh_token, + settings.SIMPLE_JWT['SIGNING_KEY'], + algorithms=[settings.SIMPLE_JWT['ALGORITHM']] # Noter le passage en liste + ) + + logger.info(f"Token décodé avec succès. Type: {payload.get('type')}") + # Vérifier s'il s'agit bien d'un Refresh Token + if payload.get('type') != 'refresh': + return JsonResponse({'errorMessage': 'Token invalide'}, status=400) + + # Récupérer les informations utilisateur + user = Profile.objects.get(id=payload['user_id']) + + # Générer un nouveau Access Token avec les informations complètes + new_access_payload = { + 'user_id': user.id, + 'email': user.email, + 'droit': user.droit, + 'type': 'access', + 'exp': datetime.utcnow() + settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'], + 'iat': datetime.utcnow(), + } + + new_access_token = jwt.encode(new_access_payload, settings.SIMPLE_JWT['SIGNING_KEY'], algorithm=settings.SIMPLE_JWT['ALGORITHM']) + + new_refresh_payload = { + 'user_id': user.id, + 'type': 'refresh', + 'exp': datetime.utcnow() + settings.SIMPLE_JWT['REFRESH_TOKEN_LIFETIME'], + 'iat': datetime.utcnow(), + } + new_refresh_token = jwt.encode(new_refresh_payload, settings.SIMPLE_JWT['SIGNING_KEY'], algorithm=settings.SIMPLE_JWT['ALGORITHM']) + + return JsonResponse({'token': new_access_token, 'refresh': new_refresh_token}, status=200) + + except ExpiredSignatureError as e: + logger.error(f"Token expiré: {str(e)}") + 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) + except Exception as e: + logger.error(f"Erreur inattendue: {str(e)}") + return JsonResponse({'errorMessage': f'Erreur inattendue: {str(e)}'}, status=400) + @method_decorator(csrf_protect, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch') diff --git a/Back-End/N3wtSchool/settings.py b/Back-End/N3wtSchool/settings.py index 8f08fac..3090976 100644 --- a/Back-End/N3wtSchool/settings.py +++ b/Back-End/N3wtSchool/settings.py @@ -314,7 +314,7 @@ REDIS_PASSWORD = None SECRET_KEY = 'QWQ8bYlCz1NpQ9G0vR5kxMnvWszfH2y3' SIMPLE_JWT = { - 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5), + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15), 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), 'ROTATE_REFRESH_TOKENS': False, 'BLACKLIST_AFTER_ROTATION': True, diff --git a/Back-End/src/app.js b/Back-End/src/app.js deleted file mode 100644 index e69de29..0000000 diff --git a/Back-End/src/middleware/cors.js b/Back-End/src/middleware/cors.js deleted file mode 100644 index e69de29..0000000 diff --git a/Front-End/next.config.mjs b/Front-End/next.config.mjs index 1e90f16..b795174 100644 --- a/Front-End/next.config.mjs +++ b/Front-End/next.config.mjs @@ -8,6 +8,9 @@ const nextConfig = { output: "standalone", experimental: { instrumentationHook: true, + }, + images: { + domains:['i.pravatar.cc'], }, env: { NEXT_PUBLIC_APP_VERSION: pkg.version, diff --git a/Front-End/src/app/[locale]/admin/layout.js b/Front-End/src/app/[locale]/admin/layout.js index 6f875ed..caf3ad9 100644 --- a/Front-End/src/app/[locale]/admin/layout.js +++ b/Front-End/src/app/[locale]/admin/layout.js @@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react'; import Sidebar from '@/components/Sidebar'; import { usePathname } from 'next/navigation'; import {useTranslations} from 'next-intl'; +import Image from 'next/image'; import { Users, Building, @@ -94,7 +95,7 @@ export default function Layout({
{headerTitle}
} + buttonContent={Profile} items={dropdownItems} buttonClassName="" menuClassName="absolute right-0 mt-2 w-48 bg-white border border-gray-200 rounded shadow-lg" diff --git a/Front-End/src/app/[locale]/parents/messagerie/page.js b/Front-End/src/app/[locale]/parents/messagerie/page.js index 34bfc00..9306564 100644 --- a/Front-End/src/app/[locale]/parents/messagerie/page.js +++ b/Front-End/src/app/[locale]/parents/messagerie/page.js @@ -1,6 +1,7 @@ 'use client' import React, { useState, useRef, useEffect } from 'react'; import { SendHorizontal } from 'lucide-react'; +import Image from 'next/image'; const contacts = [ { id: 1, name: 'Facturation', profilePic: 'https://i.pravatar.cc/32' }, @@ -61,7 +62,7 @@ export default function MessageriePage() { className={`p-2 cursor-pointer ${selectedContact?.id === contact.id ? 'bg-gray-200' : ''}`} onClick={() => setSelectedContact(contact)} > - {`${contact.name}'s + {`${contact.name}'s {contact.name} ))} @@ -75,7 +76,7 @@ export default function MessageriePage() { style={{ borderRadius: message.isResponse ? '20px 20px 0 20px' : '20px 20px 20px 0', minWidth: '25%' }} >
- {`${selectedContact.name}'s + {`${selectedContact.name}'s {selectedContact.name} {new Date(message.date).toLocaleTimeString()}
diff --git a/Front-End/src/components/Logo.js b/Front-End/src/components/Logo.js index c6b902d..38acac0 100644 --- a/Front-End/src/components/Logo.js +++ b/Front-End/src/components/Logo.js @@ -4,8 +4,8 @@ import logoImage from '@/img/logo_min.svg'; // Assurez-vous que le chemin vers l const Logo = ({ className }) => { return ( -
- Logo +
+ Logo
); }; diff --git a/Front-End/src/components/ProtectedRoute.js b/Front-End/src/components/ProtectedRoute.js index 34cdd4c..61064c9 100644 --- a/Front-End/src/components/ProtectedRoute.js +++ b/Front-End/src/components/ProtectedRoute.js @@ -5,23 +5,21 @@ import Loader from '@/components/Loader'; // Importez le composant Loader import { FE_USERS_LOGIN_URL } from '@/utils/Url'; const ProtectedRoute = ({ children }) => { - const { data: session, status } = useSession(); - const router = useRouter(); - - useEffect(() => { - if (status === 'loading') return; // Ne rien faire tant que le statut est "loading" - if (!session) { - // Rediriger vers la page de login si l'utilisateur n'est pas connecté + const { data: session, status } = useSession({ + required: true, + onUnauthenticated() { router.push(`${FE_USERS_LOGIN_URL}`); } - }, [session, status, router]); + }); + const router = useRouter(); - if (status === 'loading' || !session) { - return ; // Affichez un loader pendant le chargement ou si l'utilisateur n'est pas connecté + // Ne vérifier que si le statut est définitif + if (status === 'loading') { + return ; } - // Afficher les enfants seulement si l'utilisateur est connecté - return children; + // Autoriser l'affichage si authentifié + return session ? children : null; }; export default ProtectedRoute; \ No newline at end of file diff --git a/Front-End/src/hooks/useLocalStorage.js b/Front-End/src/hooks/useLocalStorage.js index e9a05ea..3444cc2 100644 --- a/Front-End/src/hooks/useLocalStorage.js +++ b/Front-End/src/hooks/useLocalStorage.js @@ -1,31 +1,48 @@ -import { useState } from "react" +'use client'; + +import { useState, useEffect } from 'react'; const useLocalStorage = (key, initialValue) => { - const [state, setState] = useState(() => { - // Initialize the state + const [storedValue, setStoredValue] = useState(() => { try { - const value = window.localStorage.getItem(key) - // Check if the local storage already has any values, - // otherwise initialize it with the passed initialValue - return value ? JSON.parse(value) : initialValue + if (typeof window !== 'undefined') { + const item = window.localStorage.getItem(key); + // Vérifier si l'item existe et n'est pas undefined + return item !== null && item !== 'undefined' + ? JSON.parse(item) + : initialValue; + } + return initialValue; } catch (error) { - console.log(error) + console.error('Error reading from localStorage:', error); + return initialValue; } - }) + }); - const setValue = value => { + useEffect(() => { try { - // If the passed value is a callback function, - // then call it with the existing state. - const valueToStore = value instanceof Function ? value(state) : value - window.localStorage.setItem(key, JSON.stringify(valueToStore)) - setState(value) + // Vérifier si la valeur n'est pas undefined avant de la stocker + if (typeof storedValue !== 'undefined') { + window.localStorage.setItem(key, JSON.stringify(storedValue)); + } else { + window.localStorage.removeItem(key); + } } catch (error) { - console.log(error) + console.error('Error writing to localStorage:', error); } - } + }, [key, storedValue]); - return [state, setValue] -} + const setValue = (value) => { + try { + // Permettre à la valeur d'être une fonction + const valueToStore = value instanceof Function ? value(storedValue) : value; + setStoredValue(valueToStore); + } catch (error) { + console.error('Error updating localStorage value:', error); + } + }; -export default useLocalStorage \ No newline at end of file + return [storedValue, setValue]; +}; + +export default useLocalStorage; \ No newline at end of file diff --git a/Front-End/src/pages/api/auth/[...nextauth].js b/Front-End/src/pages/api/auth/[...nextauth].js index a22ef80..df190df 100644 --- a/Front-End/src/pages/api/auth/[...nextauth].js +++ b/Front-End/src/pages/api/auth/[...nextauth].js @@ -34,45 +34,69 @@ const options = { }) ], session: { - strategy:"jwt", + strategy: "jwt", + maxAge: 30 * 24 * 60 * 60, // 30 jours + updateAge: 24 * 60 * 60, // 24 heures + }, + cookies: { + sessionToken: { + name: 'n3wtschool_session_token', + options: { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: process.env.NODE_ENV === 'production' + } + } }, callbacks: { - async jwt({ token, user }) { - if (user) { - token.token = user.token; - token.refresh = user.refresh; - token.tokenExpires = jwt_decode.decode(user.token).exp * 1000; + async jwt({ token, user, trigger }) { + // Si c'est la première connexion + if (user) { + return { + ...token, + token: user.token, + refresh: user.refresh, + tokenExpires: jwt_decode.decode(user.token).exp * 1000 + }; } - // Vérifie si l'access token a expiré + + // Vérifier si le token n'est pas expiré if (Date.now() < token.tokenExpires) { return token; } - // Renouvelle le token expiré + + // Token expiré, essayer de le rafraîchir try { - const data = {refresh: token.refresh} - const res = await refreshJWT(data); - console.log(res); - token.token = res.token; - token.refresh = res.refresh; - token.tokenExpires = jwt_decode.decode(res.token).exp * 1000; - console.log("Token refreshed", token); - return token; + const response = await refreshJWT({ refresh: token.refresh }); + if (!response) { + throw new Error('Failed to refresh token'); + } + + return { + ...token, + token: response.token, + refresh: response.refresh, + tokenExpires: jwt_decode.decode(response.token).exp * 1000 + }; } catch (error) { - console.error("Erreur lors du rafraîchissement du token", error); + console.error("Refresh token failed:", error); return token; } }, async session({ session, token }) { - console.log("Session callback called", token); - if (!token) { - throw new Error('Token not found'); - } - else{ - const decodedToken = jwt_decode.decode(token.token); - const {user_id,email,droit} = decodedToken; - session.user = {id:user_id,email,droit}; - return session; + if (token) { + const {user_id, droit, email} = jwt_decode.decode(token.token); + session.user = { + ...session.user, + token: token.token, + refresh: token.refresh + }; + session.user.user_id = user_id; + session.user.droit = droit; + session.user.email = email; } + return session; } }, pages: {