diff --git a/src/backend/InvenTree/InvenTree/admin.py b/src/backend/InvenTree/InvenTree/admin.py index 11da00afa6..787d7b76f9 100644 --- a/src/backend/InvenTree/InvenTree/admin.py +++ b/src/backend/InvenTree/InvenTree/admin.py @@ -44,6 +44,15 @@ admin.site.register(Schedule, ReadOnlyScheduleAdmin) class InvenTreeUserSessionAdmin(NoEditAdminMixin, UserSessionAdmin): """Admin interface for UserSession - view and delete only, no add or edit.""" + def delete_model(self, request, obj): + """End the underlying session, not just the tracking record.""" + obj.end() + + def delete_queryset(self, request, queryset): + """End the underlying sessions, not just the tracking records.""" + for session in queryset: + session.end() + admin.site.unregister(UserSession) admin.site.register(UserSession, InvenTreeUserSessionAdmin) diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index 75b28fbbb1..55e11a76a7 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -198,6 +198,8 @@ class UserDetail(RetrieveUpdateDestroyAPI): """Override destroy method to ensure sessions are deleted first.""" # Remove all sessions for this user if sessions := instance.usersession_set.all(): + for session in sessions: + session.end() sessions.delete() # Normally delete the user return super().perform_destroy(instance) diff --git a/src/backend/InvenTree/users/tests.py b/src/backend/InvenTree/users/tests.py index f21efa2be8..b25ef8bacb 100644 --- a/src/backend/InvenTree/users/tests.py +++ b/src/backend/InvenTree/users/tests.py @@ -534,6 +534,56 @@ class AdminTest(AdminTestCase): ) self.assertEqual(qs.count(), 0) + def test_usersession_admin_delete(self): + """Deleting a UserSession via the admin must log the user out.""" + from django.contrib.auth import get_user_model + from django.contrib.sessions.models import Session + + from allauth.usersessions.models import UserSession + from rest_framework.test import APIClient + + victim = get_user_model().objects.create_user( + username='victim', password='password' + ) + me_url = reverse('api-user-me') + + def login_victim(): + """Log in as the victim user and return the client and its UserSession.""" + client = APIClient() + client.login(username='victim', password='password') + self.assertEqual(client.get(me_url).status_code, 200) + # client.login() bypasses the allauth signal which tracks the session + session = UserSession.objects.create( + user=victim, session_key=client.session.session_key, ip='127.0.0.1' + ) + return client, session + + def check_logged_out(client, session): + """Check the victim session has been ended.""" + self.assertFalse(UserSession.objects.filter(pk=session.pk).exists()) + self.assertFalse(Session.objects.filter(pk=session.session_key).exists()) + self.assertIn(client.get(me_url).status_code, [401, 403]) + + # Single object delete view + client, session = login_victim() + self.client.post( + reverse('admin:usersessions_usersession_delete', args=[session.pk]), + {'post': 'yes'}, + ) + check_logged_out(client, session) + + # Bulk "delete selected" action + client, session = login_victim() + self.client.post( + reverse('admin:usersessions_usersession_changelist'), + { + 'action': 'delete_selected', + '_selected_action': [session.pk], + 'post': 'yes', + }, + ) + check_logged_out(client, session) + def test_owner_admin_autocomplete(self): """Test admin autocomplete endpoint for SalesOrder responsible field.""" url = reverse('admin:autocomplete')