User session tracking #93587
44
bid_main/templates/bid_main/active_sessions.html
Normal file
44
bid_main/templates/bid_main/active_sessions.html
Normal 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 %}
|
@ -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>
|
||||||
|
@ -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/')
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
|||||||
|
user_session.session.delete()
|
||||||
|
user_session.delete()
|
||||||
|
return redirect('bid_main:active_sessions')
|
||||||
|
return HttpResponseNotFound("session not found")
|
||||||
|
@ -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",
|
||||||
|
Loading…
Reference in New Issue
Block a user
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.