From 71809b6a902a1a0018a58b6ca549cc66f6772aae Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Thu, 15 Oct 2026 13:56:12 +0200 Subject: [PATCH] nickel --- backend/core/settings.py | 6 +- backend/core/urls.py | 8 +- backend/drive/authentication.py | 25 +++++ .../0018_persistent_login_sessions.py | 22 +++++ backend/drive/models.py | 16 +++ backend/drive/test_persistent_login.py | 64 ++++++++++++ backend/drive/views_auth.py | 98 +++++++++++++++++++ backend/entrypoint.sh | 2 +- docker-compose.yml | 4 +- frontend/dockerfile | 4 +- frontend/src/App.jsx | 15 ++- frontend/src/api/client.js | 50 ++++++++-- frontend/src/pages/LoginPage.jsx | 9 +- 13 files changed, 295 insertions(+), 28 deletions(-) create mode 100644 backend/drive/authentication.py create mode 100644 backend/drive/migrations/0018_persistent_login_sessions.py create mode 100644 backend/drive/test_persistent_login.py create mode 100644 backend/drive/views_auth.py diff --git a/backend/core/settings.py b/backend/core/settings.py index 6960e35..4465c55 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -129,7 +129,7 @@ AUTH_PASSWORD_VALIDATORS = [ REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'drive.authentication.PersistentJWTAuthentication', ), 'DEFAULT_PERMISSION_CLASSES': [ 'rest_framework.permissions.IsAuthenticated', @@ -145,8 +145,8 @@ REST_FRAMEWORK = { SIMPLE_JWT = { 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=config('JWT_ACCESS_MINUTES', default=30, cast=int)), 'REFRESH_TOKEN_LIFETIME': timedelta(days=config('JWT_REFRESH_DAYS', default=7, cast=int)), - # Le frontend actuel ne stocke pas de nouveau refresh token après renouvellement. - # Activer la rotation demanderait de modifier aussi frontend/src/api/client.js. + # Seulement pour convertir les anciens refresh JWT de 7 jours. + # Les nouvelles sessions persistantes vivent en base et sont révocables. 'ROTATE_REFRESH_TOKENS': False, 'BLACKLIST_AFTER_ROTATION': False, 'AUTH_HEADER_TYPES': ('Bearer',), diff --git a/backend/core/urls.py b/backend/core/urls.py index 96213c1..1395548 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -1,16 +1,16 @@ from django.contrib import admin from django.urls import path, include -from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView from drive.views import me -from drive import views_spaces, views_shares +from drive import views_spaces, views_shares, views_auth urlpatterns = [ path('api/public//',views_shares.public_share), path('api/public//files//',views_shares.public_file), path('api/invitations//',views_spaces.invitation_preview), path('api/invitations//accept/',views_spaces.accept_invitation), path('django-admin/', admin.site.urls), - path('api/auth/login/', TokenObtainPairView.as_view()), - path('api/auth/refresh/', TokenRefreshView.as_view()), + path('api/auth/login/', views_auth.login), + path('api/auth/refresh/', views_auth.refresh), + path('api/auth/logout/', views_auth.logout), path('api/auth/me/', me), path('api/drive/',include('drive.urls')), ] diff --git a/backend/drive/authentication.py b/backend/drive/authentication.py new file mode 100644 index 0000000..b5910b0 --- /dev/null +++ b/backend/drive/authentication.py @@ -0,0 +1,25 @@ +"""JWT authentication bound to a server-revocable GoodDrive session.""" +from rest_framework.exceptions import AuthenticationFailed +from rest_framework_simplejwt.authentication import JWTAuthentication + +from .models import PersistentLoginSession + + +class PersistentJWTAuthentication(JWTAuthentication): + """Invalidate access immediately when the associated login is revoked.""" + + def get_user(self, validated_token): + user = super().get_user(validated_token) + session_id = validated_token.get('sid') + if session_id is not None: + valid = PersistentLoginSession.objects.filter( + pk=session_id, user_id=user.pk, revoked_at__isnull=True, + auth_hash=user.get_session_auth_hash(), + ).exists() + if not valid: + raise AuthenticationFailed('Session fermée ou invalidée.') + # JWTs issued before the update remain valid for their original (short) + # access-token lifetime and can upgrade using their old refresh token. + return user + + diff --git a/backend/drive/migrations/0018_persistent_login_sessions.py b/backend/drive/migrations/0018_persistent_login_sessions.py new file mode 100644 index 0000000..62271cd --- /dev/null +++ b/backend/drive/migrations/0018_persistent_login_sessions.py @@ -0,0 +1,22 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [('drive', '0017_granular_permissions'), migrations.swappable_dependency(settings.AUTH_USER_MODEL)] + + operations = [ + migrations.CreateModel( + name='PersistentLoginSession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token_hash', models.CharField(max_length=64, unique=True)), + ('auth_hash', models.CharField(max_length=128)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('last_used_at', models.DateTimeField(auto_now_add=True)), + ('revoked_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gooddrive_sessions', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/drive/models.py b/backend/drive/models.py index 8589f15..bf1911e 100644 --- a/backend/drive/models.py +++ b/backend/drive/models.py @@ -132,3 +132,19 @@ class ArchivedDriveAccount(models.Model): """Inactive, anonymized historical owner of shared files after account removal.""" user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='drive_archive') deleted_at = models.DateTimeField(auto_now_add=True) + + +class PersistentLoginSession(models.Model): + """Session explicitly revocable, without an automatic expiration date. + + The browser only knows the random secret. Never store that secret in the DB. + """ + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='gooddrive_sessions') + token_hash = models.CharField(max_length=64, unique=True) + auth_hash = models.CharField(max_length=128) + created_at = models.DateTimeField(auto_now_add=True) + last_used_at = models.DateTimeField(auto_now_add=True) + revoked_at = models.DateTimeField(null=True, blank=True) + + def __str__(self): + return f'Session {self.pk} – utilisateur {self.user_id}' diff --git a/backend/drive/test_persistent_login.py b/backend/drive/test_persistent_login.py new file mode 100644 index 0000000..c1b1a5a --- /dev/null +++ b/backend/drive/test_persistent_login.py @@ -0,0 +1,64 @@ +"""Regression coverage for opt-in persistent sessions and explicit logout.""" +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import RefreshToken + +from .models import PersistentLoginSession + + +class PersistentLoginTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user(username='persistent-user', password='correct-secret-123') + self.client = APIClient() + + def login(self): + response = self.client.post('/api/auth/login/', { + 'username': 'persistent-user', 'password': 'correct-secret-123', + }, format='json') + self.assertEqual(response.status_code, 200, response.data) + return response.data + + def test_refresh_is_persistent_without_an_expiration_date(self): + tokens = self.login() + session = PersistentLoginSession.objects.get(token_hash__isnull=False) + self.assertIsNone(session.revoked_at) + self.assertFalse(hasattr(session, 'expires_at')) + self.assertNotIn('.', tokens['refresh']) # opaque, not a time-limited JWT + for _ in range(3): + response = self.client.post('/api/auth/refresh/', {'refresh': tokens['refresh']}, format='json') + self.assertEqual(response.status_code, 200) + self.assertIn('access', response.data) + + def test_logout_revokes_refresh_and_access(self): + tokens = self.login() + authenticated = APIClient() + authenticated.credentials(HTTP_AUTHORIZATION=f"Bearer {tokens['access']}") + self.assertEqual(authenticated.get('/api/auth/me/').status_code, 200) + self.assertEqual(self.client.post('/api/auth/logout/', {'refresh': tokens['refresh']}, format='json').status_code, 204) + self.assertEqual(self.client.post('/api/auth/refresh/', {'refresh': tokens['refresh']}, format='json').status_code, 401) + self.assertEqual(authenticated.get('/api/auth/me/').status_code, 401) + + def test_password_change_invalidates_existing_session(self): + tokens = self.login() + self.user.set_password('different-secret-456') + self.user.save(update_fields=['password']) + self.assertEqual(self.client.post('/api/auth/refresh/', {'refresh': tokens['refresh']}, format='json').status_code, 401) + authenticated = APIClient() + authenticated.credentials(HTTP_AUTHORIZATION=f"Bearer {tokens['access']}") + self.assertEqual(authenticated.get('/api/auth/me/').status_code, 401) + + def test_legacy_refresh_can_upgrade(self): + old_refresh = str(RefreshToken.for_user(self.user)) + response = self.client.post('/api/auth/refresh/', {'refresh': old_refresh}, format='json') + self.assertEqual(response.status_code, 200, response.data) + self.assertIn('access', response.data) + self.assertIn('refresh', response.data) + self.assertNotIn('.', response.data['refresh']) + + def test_invalid_credentials_never_create_a_session(self): + response = self.client.post('/api/auth/login/', { + 'username': 'persistent-user', 'password': 'wrong', + }, format='json') + self.assertIn(response.status_code, (400, 401)) + self.assertFalse(PersistentLoginSession.objects.exists()) diff --git a/backend/drive/views_auth.py b/backend/drive/views_auth.py new file mode 100644 index 0000000..f923e97 --- /dev/null +++ b/backend/drive/views_auth.py @@ -0,0 +1,98 @@ +"""Sessions GoodDrive: permanent until logout, account removal or password change. + +Access JWTs remain short-lived. Refresh secrets are opaque random values hashed +in MySQL, not stateless JWTs that silently expire after seven days. +""" +import hashlib +import secrets + +from django.contrib.auth import get_user_model +from django.utils import timezone +from rest_framework import status +from rest_framework.decorators import api_view, authentication_classes, permission_classes, throttle_classes +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.throttling import AnonRateThrottle +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer +from rest_framework_simplejwt.tokens import AccessToken, RefreshToken +from rest_framework_simplejwt.exceptions import TokenError + +from .models import PersistentLoginSession + + +def _digest(value): + return hashlib.sha256(value.encode('utf-8')).hexdigest() + + +def _new_session(user): + secret = secrets.token_urlsafe(48) + session = PersistentLoginSession.objects.create( + user=user, token_hash=_digest(secret), auth_hash=user.get_session_auth_hash() + ) + return session, secret + + +def _issue_access(session): + access = AccessToken.for_user(session.user) + access['sid'] = session.pk + return str(access) + + +def _valid_session(secret): + if not isinstance(secret, str) or len(secret) > 2048 or not secret: + return None + try: + session = PersistentLoginSession.objects.select_related('user').get(token_hash=_digest(secret), revoked_at__isnull=True) + except PersistentLoginSession.DoesNotExist: + return None + user = session.user + if not user.is_active or not secrets.compare_digest(session.auth_hash, user.get_session_auth_hash()): + return None + return session + + +@api_view(['POST']) +@authentication_classes([]) +@permission_classes([AllowAny]) +@throttle_classes([AnonRateThrottle]) +def login(request): + serializer = TokenObtainPairSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + session, secret = _new_session(serializer.user) + return Response({'access': _issue_access(session), 'refresh': secret}) + + +@api_view(['POST']) +@authentication_classes([]) +@permission_classes([AllowAny]) +@throttle_classes([AnonRateThrottle]) +def refresh(request): + secret = request.data.get('refresh') + session = _valid_session(secret) + if session: + PersistentLoginSession.objects.filter(pk=session.pk).update(last_used_at=timezone.now()) + return Response({'access': _issue_access(session)}) + + # One-time compatibility for browsers that still hold an old SimpleJWT + # refresh token in sessionStorage after deploying this update. + if isinstance(secret, str) and secret.count('.') == 2: + try: + legacy = RefreshToken(secret) + user_id = legacy['user_id'] + user = get_user_model().objects.filter(pk=user_id, is_active=True).first() + if user: + session, new_secret = _new_session(user) + return Response({'access': _issue_access(session), 'refresh': new_secret}) + except (TokenError, KeyError, ValueError, TypeError): + pass + return Response({'detail': 'Session expirée ou révoquée.'}, status=status.HTTP_401_UNAUTHORIZED) + + +@api_view(['POST']) +@authentication_classes([]) +@permission_classes([AllowAny]) +def logout(request): + secret = request.data.get('refresh') + if isinstance(secret, str) and 0 < len(secret) <= 2048: + PersistentLoginSession.objects.filter(token_hash=_digest(secret)).update(revoked_at=timezone.now()) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 4686539..a1c3a83 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -19,4 +19,4 @@ python manage.py ensure_admin sleep 3600 done ) & -exec python manage.py runserver 0.0.0.0:1029 +exec python manage.py runserver 0.0.0.0:1035 diff --git a/docker-compose.yml b/docker-compose.yml index 8018c8c..4880124 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ services: volumes: - ./backend:/app ports: - - "1029:1029" + - "1035:1035" env_file: - ".env" environment: @@ -25,7 +25,7 @@ services: - ./frontend:/app - /app/node_modules ports: - - "1028:1028" + - "1034:1034" env_file: - ".env" depends_on: diff --git a/frontend/dockerfile b/frontend/dockerfile index 128e0a1..ec91830 100644 --- a/frontend/dockerfile +++ b/frontend/dockerfile @@ -8,7 +8,7 @@ RUN npm install COPY . . -EXPOSE 1028 +EXPOSE 1034 -CMD ["npm", "run", "dev", "--", "--host", "--port", "1028"] +CMD ["npm", "run", "dev", "--", "--host", "--port", "1034"] diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 487d606..391e375 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { LoaderCircle } from 'lucide-react' -import { api, logoutTokens, savedToken } from '@/api/client' +import { api, logoutTokens, logoutSession, refreshAccess, savedToken, savedRefresh } from '@/api/client' import DrivePage from '@/pages/DrivePage' import LoginPage from '@/pages/LoginPage' import InvitationPage from '@/pages/InvitationPage' @@ -22,8 +22,11 @@ export default function App() { } let active = true const probe = async () => { - if(!savedToken()) {setChecking(false); return} + if(!savedToken() && !savedRefresh()) {setChecking(false); return} try { + // Upgrade old 7-day JWT refresh tokens immediately, not at the first 401. + const oldRefresh = savedRefresh() + if (oldRefresh?.split('.').length === 3) await refreshAccess() const data = (await api.get('/auth/me/')).data if(active) { setUser(data); setConnectionError(false) } } catch (error) { @@ -38,8 +41,12 @@ export default function App() { } probe() const expired = () => {setUser(null);setChecking(false)} + const otherTabLogout = event => { + if (event.key === 'drive_refresh' && !event.newValue) expired() + } window.addEventListener('drive:logout', expired) - return () => {active = false;window.removeEventListener('drive:logout', expired)} + window.addEventListener('storage', otherTabLogout) + return () => {active = false;window.removeEventListener('drive:logout', expired);window.removeEventListener('storage', otherTabLogout)} }, [attempt, publicToken, invitationToken]) if(publicToken) return @@ -47,6 +54,6 @@ export default function App() { if(connectionError) return

GoodDrive est momentanément inaccessible. Ta session est conservée.

if(checking) return
return user - ? {logoutTokens();setUser(null)}} /> + ? {try {await logoutSession()} catch { /* Local logout still happens */ } setUser(null)}} /> : } diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index b573aa6..3346272 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -5,35 +5,66 @@ export const api = axios.create({ baseURL: import.meta.env?.PROD ? '/api' : `http://${window.location.hostname}:1029/api`, headers: { Accept: 'application/json' }, }); -const raw = axios.create({baseURL:'/api', headers:{Accept:'application/json'}}) +const raw = axios.create({baseURL:api.defaults.baseURL, headers:{Accept:'application/json'}}) // Public links must remain accessible without a session, even if a browser // has a previously expired access token stored locally. const publicApi = axios.create({ baseURL: api.defaults.baseURL, headers: { Accept: 'application/json' }, }) -export const savedToken = () => sessionStorage.getItem(ACCESS) +// localStorage survives browser restarts; import any existing tab-only session. +// Always clear both stores on explicit logout (or definitive server revocation). +const stored = key => { + try { + const persistent = localStorage.getItem(key) + if (persistent) return persistent + const previous = sessionStorage.getItem(key) + if (previous) save(key, previous) + return previous + } + catch { try { return sessionStorage.getItem(key) } catch { return null } } +} +const save = (key, value) => { + try { localStorage.setItem(key, value); sessionStorage.removeItem(key) } + catch { sessionStorage.setItem(key, value) } +} +export const savedToken = () => stored(ACCESS) +export const savedRefresh = () => stored(REFRESH) export const storeTokens = data => { - if (data.access) sessionStorage.setItem(ACCESS,data.access) - if (data.refresh) sessionStorage.setItem(REFRESH,data.refresh) + if (data.access) save(ACCESS, data.access) + if (data.refresh) save(REFRESH, data.refresh) } export const logoutTokens = () => { - sessionStorage.removeItem(ACCESS) - sessionStorage.removeItem(REFRESH) + for (const key of [ACCESS, REFRESH]) { + try { localStorage.removeItem(key) } catch {} + try { sessionStorage.removeItem(key) } catch {} + } +} +// Server revocation and device logout. Always clear the local tokens, even if +// the device is temporarily offline when the user clicks "Déconnexion". +export const logoutSession = async () => { + const refresh = savedRefresh() + try { + if (refresh) await raw.post('/auth/logout/', {refresh}, {timeout:5000}) + } finally { + logoutTokens() + } } api.interceptors.request.use(config => { const token = savedToken() - if (token && !config.url?.startsWith('/invitations/')) { + if (token && !config.url?.startsWith('/invitations/') && !config.url?.startsWith('/auth/login/')) { config.headers.Authorization = `Bearer ${token}` } return config }) let refreshPromise = null export async function refreshAccess() { - const token = sessionStorage.getItem(REFRESH) + const token = savedRefresh() if (!token) throw new Error('Aucun jeton de renouvellement') if (!refreshPromise) { refreshPromise = raw.post('/auth/refresh/',{refresh:token}).then(response => { + // A logout in another tab may have removed this session meanwhile. + if (savedRefresh() !== token) throw new Error('Session fermée pendant le renouvellement') storeTokens(response.data) return response.data.access }).finally(() => { refreshPromise = null }) @@ -48,6 +79,7 @@ api.interceptors.response.use(response => response, async error => { return Promise.reject(error) } original._retry = true + const beforeRefresh = savedRefresh() try { const access = await refreshAccess() original.headers.Authorization = `Bearer ${access}` @@ -55,7 +87,7 @@ api.interceptors.response.use(response => response, async error => { } catch (refreshError) { // Temporary network outages and server 502/503 must NEVER log out a user. // Only a definitively rejected/expired refresh token revokes the session. - if (authError(refreshError)) { + if (authError(refreshError) && savedRefresh() === beforeRefresh) { logoutTokens() window.dispatchEvent(new Event('drive:logout')) } diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index ece313f..f04a571 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { api, logoutTokens, message, storeTokens } from '@/api/client' +import { api, message, storeTokens } from '@/api/client' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { ArrowUpRight, LoaderCircle, ShieldCheck } from 'lucide-react' @@ -14,14 +14,17 @@ export default function LoginPage({ onSigned }) { event.preventDefault() setLoading(true) setError('') + let signedIn = false try { const tokens = (await api.post('/auth/login/', { username, password })).data storeTokens(tokens) + signedIn = true const user = (await api.get('/auth/me/')).data onSigned(user) } catch (exception) { - logoutTokens() - setError(exception.response?.status === 401 ? 'Identifiants incorrects.' : message(exception)) + // Never forget a successful login because loading /auth/me/ failed. + // Failed credentials never created a session, so only show the error. + setError(signedIn ? 'Connexion enregistrée, mais le chargement du compte a échoué. Actualise la page pour réessayer.' : exception.response?.status === 401 ? 'Identifiants incorrects.' : message(exception)) } finally { setLoading(false) }