nickel
This commit is contained in:
parent
f0fbefa642
commit
71809b6a90
@ -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',),
|
||||
|
||||
@ -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')),
|
||||
]
|
||||
|
||||
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."""
|
||||
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}'
|
||||
|
||||
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
|
||||
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:
|
||||
- ./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:
|
||||
|
||||
@ -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"]
|
||||
|
||||
|
||||
@ -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} />
|
||||
}
|
||||
|
||||
@ -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'))
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user