This commit is contained in:
Louis Mazin 2026-10-15 13:56:12 +02:00
parent f0fbefa642
commit 71809b6a90
13 changed files with 295 additions and 28 deletions

View File

@ -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',),

View File

@ -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/<str:token>/',views_shares.public_share),
path('api/public/<str:token>/files/<uuid:item_id>/',views_shares.public_file),
path('api/invitations/<str:token>/',views_spaces.invitation_preview),
path('api/invitations/<str:token>/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')),
]

View File

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

View File

@ -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)),
],
),
]

View File

@ -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}'

View File

@ -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())

View File

@ -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)

View File

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

View File

@ -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:

View File

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

View File

@ -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 <PublicSharePage token={publicToken} />
@ -47,6 +54,6 @@ export default function App() {
if(connectionError) return <div className="grid min-h-dvh place-items-center p-6 text-center"><div className="space-y-4"><p>GoodDrive est momentanément inaccessible. Ta session est conservée.</p><button type="button" className="rounded-lg bg-primary px-4 py-2 text-primary-foreground" onClick={() => {setConnectionError(false);setChecking(true);setAttempt(n=>n+1)}}>Réessayer</button></div></div>
if(checking) return <div className="grid h-dvh place-items-center"><LoaderCircle className="size-7 animate-spin text-primary" /></div>
return user
? <DrivePage user={user} onLogout={() => {logoutTokens();setUser(null)}} />
? <DrivePage user={user} onLogout={async () => {try {await logoutSession()} catch { /* Local logout still happens */ } setUser(null)}} />
: <LoginPage onSigned={setUser} />
}

View File

@ -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'))
}

View File

@ -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)
}