User session tracking #93587

Merged
Oleg-Komarov merged 18 commits from user-session into main 2024-08-02 16:04:09 +02:00
6 changed files with 110 additions and 3 deletions
Showing only changes of commit a208437b94 - Show all commits

View File

@ -0,0 +1,44 @@
{% extends 'layout.html' %}
{% load humanize pipeline static %}
{% block page_title %}
Active Sessions
{% endblock %}
{% block body %}
<div class="bid box">
<h2>Active Sessions</h2>
<table class="table w-100 mt-4">
<thead>
<tr>
<th>Created</th>
<th>Last Active</th>
<th>IP</th>
<th>Device</th>
<th class="text-center"></th>
</tr>
</thead>
<tbody>
{% for session in sessions %}
<tr>
<td title="{{ session.created_at }}">{{ session.created_at|naturaltime }}</td>
<td title="{{ session.last_active_at }}">
{% if session.is_current %}
Current Session
{% else %}
{{ session.last_active_at|naturaltime }}
{% endif %}
</td>
<td>{{ session.ip }}</td>
<td>{{ session.device }}</td>
<td class="text-center">
<form action="{% url 'bid_main:terminate_session' session.pk %}" method="post">
{% csrf_token %}
<button type="submit" class="btn-danger" title="Terminate Session"><i class="i-trash"></i></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -135,6 +135,11 @@ Profile
<div class="bid-roles container bid box mt-3"> <div class="bid-roles container bid box mt-3">
<h2>Account</h2> <h2>Account</h2>
<div class="btn-row-fluid mt-3">
<a class="btn" href="{% url 'bid_main:active_sessions' %}">
<span>Active Sessions</span>
</a>
</div>
<div class="btn-row-fluid mt-3"> <div class="btn-row-fluid mt-3">
<a class="btn" href="{% url 'bid_main:password_change' %}"> <a class="btn" href="{% url 'bid_main:password_change' %}">
<span>Change Password</span> <span>Change Password</span>

View File

@ -1,5 +1,6 @@
from django.test import TestCase
from django.test.client import Client from django.test.client import Client
from django.test import TestCase
from django.urls import reverse
from bid_main.tests.factories import UserFactory from bid_main.tests.factories import UserFactory
@ -14,3 +15,29 @@ class TestUserSessions(TestCase):
self.assertEqual(user.sessions.count(), 2) self.assertEqual(user.sessions.count(), 2)
client2.logout() client2.logout()
self.assertEqual(user.sessions.count(), 1) self.assertEqual(user.sessions.count(), 1)
class TestActiveSessions(TestCase):
def test_active_sessions(self):
user = UserFactory()
client1 = Client()
client2 = Client()
client1.force_login(user)
first_session_pk = user.sessions.first().pk
client2.force_login(user)
response = client1.get(reverse('bid_main:active_sessions'))
self.assertContains(response, 'Current Session')
self.assertContains(response, 'Terminate Session')
response = client2.post(
reverse('bid_main:terminate_session', kwargs={'pk': first_session_pk})
)
self.assertEqual(response.status_code, 302)
response = client2.get(reverse('bid_main:active_sessions'))
self.assertEqual(response.status_code, 200)
# got logged out, redirect to login
response = client1.get(reverse('bid_main:active_sessions'))
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], '/login?next=/active-sessions/')

View File

@ -139,6 +139,12 @@ urlpatterns = [
registration_email.ConfirmEmailPollView.as_view(), registration_email.ConfirmEmailPollView.as_view(),
name="confirm-email-poll", name="confirm-email-poll",
), ),
path('active-sessions/', normal_pages.ActiveSessionsView.as_view(), name='active_sessions'),
path(
'terminate-session/<int:pk>/',
normal_pages.TerminateSessionView.as_view(),
name='terminate_session',
),
] ]
# Only enable this on a dev server: # Only enable this on a dev server:

View File

@ -14,8 +14,8 @@ from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.db import transaction, IntegrityError from django.db import transaction, IntegrityError
from django.db.models import Count from django.db.models import Count
from django.http import HttpResponseRedirect from django.http import HttpResponseNotFound, HttpResponseRedirect
from django.shortcuts import resolve_url, render from django.shortcuts import redirect, resolve_url, render
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
@ -23,6 +23,7 @@ from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.debug import sensitive_post_parameters from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import TemplateView, FormView from django.views.generic import TemplateView, FormView
from django.views.generic.base import View
from django.views.generic.edit import UpdateView from django.views.generic.edit import UpdateView
import loginas.utils import loginas.utils
import oauth2_provider.models as oauth2_models import oauth2_provider.models as oauth2_models
@ -429,3 +430,26 @@ class DeleteUserView(
if not ok: if not ok:
log.error("Failed to send an email about deletion of account %s", user.pk) log.error("Failed to send an email about deletion of account %s", user.pk)
return render(self.request, "bid_main/delete_user/confirm.html", context=ctx) return render(self.request, "bid_main/delete_user/confirm.html", context=ctx)
class ActiveSessionsView(LoginRequiredMixin, TemplateView):
template_name = "bid_main/active_sessions.html"
def get_context_data(self, **kwargs):
user_sessions = self.request.user.sessions.order_by('-last_active_at')
for session in user_sessions:
if session.session.session_key == self.request.session.session_key:
session.is_current = True
return {
**super().get_context_data(**kwargs),
'sessions': user_sessions,
}
class TerminateSessionView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs):
if user_session := self.request.user.sessions.filter(pk=kwargs.get('pk', 0)).first():
Oleg-Komarov marked this conversation as resolved Outdated

getting the pk (pk = kwargs['pk']: it's fine to expect it to be present at this point) and filtering the session on the separate line would make this more readable.

getting the `pk` (`pk = kwargs['pk']`: it's fine to expect it to be present at this point) and filtering the session on the separate line would make this more readable.
user_session.session.delete()
user_session.delete()
return redirect('bid_main:active_sessions')
return HttpResponseNotFound("session not found")

View File

@ -50,6 +50,7 @@ INSTALLED_APPS = [
"django.contrib.admindocs", "django.contrib.admindocs",
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",
"django.contrib.humanize",
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",