nickel
This commit is contained in:
parent
f0fbefa642
commit
71809b6a90
@ -129,7 +129,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
'drive.authentication.PersistentJWTAuthentication',
|
||||||
),
|
),
|
||||||
'DEFAULT_PERMISSION_CLASSES': [
|
'DEFAULT_PERMISSION_CLASSES': [
|
||||||
'rest_framework.permissions.IsAuthenticated',
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
@ -145,8 +145,8 @@ REST_FRAMEWORK = {
|
|||||||
SIMPLE_JWT = {
|
SIMPLE_JWT = {
|
||||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=config('JWT_ACCESS_MINUTES', default=30, cast=int)),
|
'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)),
|
'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.
|
# Seulement pour convertir les anciens refresh JWT de 7 jours.
|
||||||
# Activer la rotation demanderait de modifier aussi frontend/src/api/client.js.
|
# Les nouvelles sessions persistantes vivent en base et sont révocables.
|
||||||
'ROTATE_REFRESH_TOKENS': False,
|
'ROTATE_REFRESH_TOKENS': False,
|
||||||
'BLACKLIST_AFTER_ROTATION': False,
|
'BLACKLIST_AFTER_ROTATION': False,
|
||||||
'AUTH_HEADER_TYPES': ('Bearer',),
|
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
|
||||||
from drive.views import me
|
from drive.views import me
|
||||||
from drive import views_spaces, views_shares
|
from drive import views_spaces, views_shares, views_auth
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('api/public/<str:token>/',views_shares.public_share),
|
path('api/public/<str:token>/',views_shares.public_share),
|
||||||
path('api/public/<str:token>/files/<uuid:item_id>/',views_shares.public_file),
|
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>/',views_spaces.invitation_preview),
|
||||||
path('api/invitations/<str:token>/accept/',views_spaces.accept_invitation),
|
path('api/invitations/<str:token>/accept/',views_spaces.accept_invitation),
|
||||||
path('django-admin/', admin.site.urls),
|
path('django-admin/', admin.site.urls),
|
||||||
path('api/auth/login/', TokenObtainPairView.as_view()),
|
path('api/auth/login/', views_auth.login),
|
||||||
path('api/auth/refresh/', TokenRefreshView.as_view()),
|
path('api/auth/refresh/', views_auth.refresh),
|
||||||
|
path('api/auth/logout/', views_auth.logout),
|
||||||
path('api/auth/me/', me),
|
path('api/auth/me/', me),
|
||||||
path('api/drive/',include('drive.urls')),
|
path('api/drive/',include('drive.urls')),
|
||||||
]
|
]
|
||||||
|
|||||||
25
backend/drive/authentication.py
Normal file
25
backend/drive/authentication.py
Normal 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
|
||||||
|
|
||||||
|
|
||||||
22
backend/drive/migrations/0018_persistent_login_sessions.py
Normal file
22
backend/drive/migrations/0018_persistent_login_sessions.py
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -132,3 +132,19 @@ class ArchivedDriveAccount(models.Model):
|
|||||||
"""Inactive, anonymized historical owner of shared files after account removal."""
|
"""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')
|
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='drive_archive')
|
||||||
deleted_at = models.DateTimeField(auto_now_add=True)
|
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}'
|
||||||
|
|||||||
64
backend/drive/test_persistent_login.py
Normal file
64
backend/drive/test_persistent_login.py
Normal 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())
|
||||||
98
backend/drive/views_auth.py
Normal file
98
backend/drive/views_auth.py
Normal 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)
|
||||||
@ -19,4 +19,4 @@ python manage.py ensure_admin
|
|||||||
sleep 3600
|
sleep 3600
|
||||||
done
|
done
|
||||||
) &
|
) &
|
||||||
exec python manage.py runserver 0.0.0.0:1029
|
exec python manage.py runserver 0.0.0.0:1035
|
||||||
|
|||||||
@ -14,7 +14,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
ports:
|
ports:
|
||||||
- "1029:1029"
|
- "1035:1035"
|
||||||
env_file:
|
env_file:
|
||||||
- ".env"
|
- ".env"
|
||||||
environment:
|
environment:
|
||||||
@ -25,7 +25,7 @@ services:
|
|||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
ports:
|
ports:
|
||||||
- "1028:1028"
|
- "1034:1034"
|
||||||
env_file:
|
env_file:
|
||||||
- ".env"
|
- ".env"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@ -8,7 +8,7 @@ RUN npm install
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
EXPOSE 1028
|
EXPOSE 1034
|
||||||
|
|
||||||
CMD ["npm", "run", "dev", "--", "--host", "--port", "1028"]
|
CMD ["npm", "run", "dev", "--", "--host", "--port", "1034"]
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { LoaderCircle } from 'lucide-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 DrivePage from '@/pages/DrivePage'
|
||||||
import LoginPage from '@/pages/LoginPage'
|
import LoginPage from '@/pages/LoginPage'
|
||||||
import InvitationPage from '@/pages/InvitationPage'
|
import InvitationPage from '@/pages/InvitationPage'
|
||||||
@ -22,8 +22,11 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
let active = true
|
let active = true
|
||||||
const probe = async () => {
|
const probe = async () => {
|
||||||
if(!savedToken()) {setChecking(false); return}
|
if(!savedToken() && !savedRefresh()) {setChecking(false); return}
|
||||||
try {
|
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
|
const data = (await api.get('/auth/me/')).data
|
||||||
if(active) { setUser(data); setConnectionError(false) }
|
if(active) { setUser(data); setConnectionError(false) }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -38,8 +41,12 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
probe()
|
probe()
|
||||||
const expired = () => {setUser(null);setChecking(false)}
|
const expired = () => {setUser(null);setChecking(false)}
|
||||||
|
const otherTabLogout = event => {
|
||||||
|
if (event.key === 'drive_refresh' && !event.newValue) expired()
|
||||||
|
}
|
||||||
window.addEventListener('drive:logout', 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])
|
}, [attempt, publicToken, invitationToken])
|
||||||
|
|
||||||
if(publicToken) return <PublicSharePage token={publicToken} />
|
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(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>
|
if(checking) return <div className="grid h-dvh place-items-center"><LoaderCircle className="size-7 animate-spin text-primary" /></div>
|
||||||
return user
|
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} />
|
: <LoginPage onSigned={setUser} />
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,35 +5,66 @@ export const api = axios.create({
|
|||||||
baseURL: import.meta.env?.PROD ? '/api' : `http://${window.location.hostname}:1029/api`,
|
baseURL: import.meta.env?.PROD ? '/api' : `http://${window.location.hostname}:1029/api`,
|
||||||
headers: { Accept: 'application/json' },
|
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
|
// Public links must remain accessible without a session, even if a browser
|
||||||
// has a previously expired access token stored locally.
|
// has a previously expired access token stored locally.
|
||||||
const publicApi = axios.create({
|
const publicApi = axios.create({
|
||||||
baseURL: api.defaults.baseURL,
|
baseURL: api.defaults.baseURL,
|
||||||
headers: { Accept: 'application/json' },
|
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 => {
|
export const storeTokens = data => {
|
||||||
if (data.access) sessionStorage.setItem(ACCESS,data.access)
|
if (data.access) save(ACCESS, data.access)
|
||||||
if (data.refresh) sessionStorage.setItem(REFRESH,data.refresh)
|
if (data.refresh) save(REFRESH, data.refresh)
|
||||||
}
|
}
|
||||||
export const logoutTokens = () => {
|
export const logoutTokens = () => {
|
||||||
sessionStorage.removeItem(ACCESS)
|
for (const key of [ACCESS, REFRESH]) {
|
||||||
sessionStorage.removeItem(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 => {
|
api.interceptors.request.use(config => {
|
||||||
const token = savedToken()
|
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}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
let refreshPromise = null
|
let refreshPromise = null
|
||||||
export async function refreshAccess() {
|
export async function refreshAccess() {
|
||||||
const token = sessionStorage.getItem(REFRESH)
|
const token = savedRefresh()
|
||||||
if (!token) throw new Error('Aucun jeton de renouvellement')
|
if (!token) throw new Error('Aucun jeton de renouvellement')
|
||||||
if (!refreshPromise) {
|
if (!refreshPromise) {
|
||||||
refreshPromise = raw.post('/auth/refresh/',{refresh:token}).then(response => {
|
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)
|
storeTokens(response.data)
|
||||||
return response.data.access
|
return response.data.access
|
||||||
}).finally(() => { refreshPromise = null })
|
}).finally(() => { refreshPromise = null })
|
||||||
@ -48,6 +79,7 @@ api.interceptors.response.use(response => response, async error => {
|
|||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
original._retry = true
|
original._retry = true
|
||||||
|
const beforeRefresh = savedRefresh()
|
||||||
try {
|
try {
|
||||||
const access = await refreshAccess()
|
const access = await refreshAccess()
|
||||||
original.headers.Authorization = `Bearer ${access}`
|
original.headers.Authorization = `Bearer ${access}`
|
||||||
@ -55,7 +87,7 @@ api.interceptors.response.use(response => response, async error => {
|
|||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
// Temporary network outages and server 502/503 must NEVER log out a user.
|
// Temporary network outages and server 502/503 must NEVER log out a user.
|
||||||
// Only a definitively rejected/expired refresh token revokes the session.
|
// Only a definitively rejected/expired refresh token revokes the session.
|
||||||
if (authError(refreshError)) {
|
if (authError(refreshError) && savedRefresh() === beforeRefresh) {
|
||||||
logoutTokens()
|
logoutTokens()
|
||||||
window.dispatchEvent(new Event('drive:logout'))
|
window.dispatchEvent(new Event('drive:logout'))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { ArrowUpRight, LoaderCircle, ShieldCheck } from 'lucide-react'
|
import { ArrowUpRight, LoaderCircle, ShieldCheck } from 'lucide-react'
|
||||||
@ -14,14 +14,17 @@ export default function LoginPage({ onSigned }) {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
let signedIn = false
|
||||||
try {
|
try {
|
||||||
const tokens = (await api.post('/auth/login/', { username, password })).data
|
const tokens = (await api.post('/auth/login/', { username, password })).data
|
||||||
storeTokens(tokens)
|
storeTokens(tokens)
|
||||||
|
signedIn = true
|
||||||
const user = (await api.get('/auth/me/')).data
|
const user = (await api.get('/auth/me/')).data
|
||||||
onSigned(user)
|
onSigned(user)
|
||||||
} catch (exception) {
|
} catch (exception) {
|
||||||
logoutTokens()
|
// Never forget a successful login because loading /auth/me/ failed.
|
||||||
setError(exception.response?.status === 401 ? 'Identifiants incorrects.' : message(exception))
|
// 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 {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user