Skip to content

Commit

Permalink
Ajout d'une page de gestion des sessions
Browse files Browse the repository at this point in the history
  • Loading branch information
Situphen committed Jun 5, 2023
1 parent 939746c commit fe4f79d
Show file tree
Hide file tree
Showing 10 changed files with 229 additions and 31 deletions.
6 changes: 2 additions & 4 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
elasticsearch-dsl==5.4.0
elasticsearch==5.5.3
social-auth-app-django==5.2.0
ua-parser==0.16.1

# Explicit dependencies (references in code)
beautifulsoup4==4.12.2
Expand All @@ -15,10 +16,7 @@ factory-boy==3.2.1
geoip2==4.7.0
GitPython==3.1.31
homoglyphs==2.0.4
lxml==4.9.2
Pillow==9.5.0
pymemcache==4.0.0
requests==2.31.0
user-agents==2.2.0

# Api dependencies
django-cors-headers==4.0.0
Expand Down
1 change: 1 addition & 0 deletions templates/member/settings/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ <h3>{% trans "Paramètres" %}</h3>
{% if user.profile.is_dev %}
<li><a href="{% url "update-github" %}">{% trans "Token GitHub" %}</a></li>
{% endif %}
<li><a href="{% url "list-sessions" %}">{% trans "Gestion des sessions" %}</a></li>
<li><a href="{% url "member-warning-unregister" %}">{% trans "Désinscription" %}</a></li>
</ul>
</div>
Expand Down
71 changes: 71 additions & 0 deletions templates/member/settings/sessions.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{% extends "member/settings/base.html" %}
{% load i18n %}
{% load date %}


{% block title %}
{% trans "Gestion des sessions" %}
{% endblock %}



{% block breadcrumb %}
<li>
{% trans "Gestion des sessions" %}
</li>
{% endblock %}



{% block headline %}
{% trans "Gestion des sessions" %}
{% endblock %}



{% block content %}
{% include "misc/paginator.html" with position="top" %}

{% if sessions %}
<div class="table-wrapper">
<table class="fullwidth">
<thead>
<th>{% trans "Session" %}</th>
<th>{% trans "Appareil" %}</th>
<th>{% trans "Adresse IP" %}</th>
<th>{% trans "Géolocalisation" %}</th>
<th>{% trans "Dernière utilisation" %}</th>
<th>{% trans "Actions" %}</th>
</thead>
<tbody>
{% for session in sessions %}
<tr>
{% if session.is_active %}
<td><strong>{% trans "Session actuelle" %}</strong></td>
{% else %}
<td>{% trans "Autre session" %}</td>
{% endif %}
<td>{{ session.user_agent }}</td>
<td>{{ session.ip_address }}</td>
<td>{{ session.geolocalization }}</td>
<td>{{ session.last_visit|humane_time }}</td>
<td>
<form method="post" action="{% url 'delete-session' %}">
{% csrf_token %}
<input type="hidden" name="session_key" value="{{ session.session_key }}">
<button type="submit" class="btn btn-grey ico-after red cross" {% if session.is_active %}disabled{% endif %}>
{% trans "Déconnecter" %}
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<em>{% trans "Aucune session ne correspond à votre compte." %}</em>
{% endif %}

{% include "misc/paginator.html" with position="bottom" %}
{% endblock %}
32 changes: 8 additions & 24 deletions zds/member/models.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import logging
from datetime import datetime
from geoip2.errors import AddressNotFoundError
from hashlib import md5

from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.gis.geoip2 import GeoIP2, GeoIP2Exception
from django.urls import reverse
from django.db import models
from django.dispatch import receiver
Expand All @@ -15,6 +13,7 @@
from zds.notification.models import TopicAnswerSubscription
from zds.member import NEW_PROVIDER_USES
from zds.member.managers import ProfileManager
from zds.member.utils import get_geolocalization
from zds.tutorialv2.models.database import PublishableContent
from zds.utils import old_slugify
from zds.utils.models import Alert, Licence, Hat
Expand Down Expand Up @@ -90,34 +89,19 @@ def get_absolute_url(self):

def get_city(self):
"""
Uses geo-localization to get physical localization of a profile through its last IP address.
This works relatively well with IPv4 addresses (~city level), but is very imprecise with IPv6 or exotic internet
providers.
Uses geolocalization to get physical localization of a profile through its last IP address.
This works relatively well with IPv4 addresses (~city level), but is very
imprecise with IPv6 or exotic internet providers.
The result is cached on an instance level because this method is called a lot in the profile.
:return: The city and the country name of this profile.
:rtype: str
"""
if self._cached_city is not None and self._cached_city[0] == self.last_ip_address:
return self._cached_city[1]

try:
geo = GeoIP2().city(self.last_ip_address)
except AddressNotFoundError:
geo_location = ""
except GeoIP2Exception as e:
geo_location = ""
logging.getLogger(__name__).warning(
f"GeoIP2 failed with the following message: '{e}'. "
"The Geolite2 database might not be installed or configured correctly. "
"Check the documentation for guidance on how to install it properly."
)
else:
city = geo["city"]
country = geo["country_name"]
geo_location = ", ".join(i for i in [city, country] if i)

self._cached_city = (self.last_ip_address, geo_location)

return geo_location
geolocalization = get_geolocalization(self.last_ip_address)
self._cached_city = (self.last_ip_address, geolocalization)
return geolocalization

def get_avatar_url(self, size=80):
"""Get the avatar URL for this profile.
Expand Down
26 changes: 26 additions & 0 deletions zds/member/tests/views/tests_session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from django.urls import reverse
from django.test import TestCase

from zds.member.tests.factories import ProfileFactory


class SessionManagementTests(TestCase):
def test_anonymous_cannot_access(self):
self.client.logout()

response = self.client.get(reverse("list-sessions"))
self.assertRedirects(response, reverse("member-login") + "?next=" + reverse("list-sessions"))

response = self.client.post(reverse("delete-session"))
self.assertRedirects(response, reverse("member-login") + "?next=" + reverse("delete-session"))

def test_user_can_access(self):
profile = ProfileFactory()
self.client.force_login(profile.user)

response = self.client.get(reverse("list-sessions"))
self.assertEqual(response.status_code, 200)

session_key = self.client.session.session_key
response = self.client.post(reverse("delete-session"), {"session_key": session_key})
self.assertRedirects(response, reverse("list-sessions"))
3 changes: 3 additions & 0 deletions zds/member/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
from zds.member.views.password_recovery import forgot_password, new_password
from zds.member.views.admin import settings_promote
from zds.member.views.reports import CreateProfileReportView, SolveProfileReportView
from zds.member.views.sessions import ListSessions, DeleteSession


urlpatterns = [
Expand All @@ -62,6 +63,8 @@
path("parametres/profil/maj_avatar/", UpdateAvatarMember.as_view(), name="update-avatar-member"),
path("parametres/compte/", UpdatePasswordMember.as_view(), name="update-password-member"),
path("parametres/user/", UpdateUsernameEmailMember.as_view(), name="update-username-email-member"),
path("parametres/sessions/", ListSessions.as_view(), name="list-sessions"),
path("parametres/sessions/supprimer/", DeleteSession.as_view(), name="delete-session"),
# moderation
path("profil/signaler/<int:profile_pk>/", CreateProfileReportView.as_view(), name="report-profile"),
path("profil/resoudre/<int:alert_pk>/", SolveProfileReportView.as_view(), name="solve-profile-alert"),
Expand Down
38 changes: 35 additions & 3 deletions zds/member/utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from django.conf import settings
from django.contrib.auth.models import User
from geoip2.errors import AddressNotFoundError
from social_django.middleware import SocialAuthExceptionMiddleware

from django.conf import settings
from django.contrib import messages
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import User
from django.contrib.gis.geoip2 import GeoIP2, GeoIP2Exception
from django.urls import reverse
from django.utils.translation import gettext_lazy as _

import logging

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -49,3 +53,31 @@ def get_anonymous_account() -> User:
Used for example as a replacement for unregistered users.
"""
return User.objects.get(username=settings.ZDS_APP["member"]["anonymous_account"])


def get_geolocalization(ip_address):
"""
Uses geolocalization to get physical localization of an IP address.
This works relatively well with IPv4 addresses (~city level), but is very
imprecise with IPv6 or exotic internet providers.
:param ip_address: An IP address
:return: The city and the country name corresponding to this IP address
:rtype: str
"""
try:
geo = GeoIP2().city(ip_address)
except AddressNotFoundError:
geolocalization = ""
except GeoIP2Exception as e:
geolocalization = ""
logger.warning(
f"GeoIP2 failed with the following message: '{e}'. "
"The Geolite2 database might not be installed or configured correctly. "
"Check the documentation for guidance on how to install it properly."
)
else:
city = geo["city"]
country = geo["country_name"]
geolocalization = ", ".join(i for i in [city, country] if i)

return geolocalization
58 changes: 58 additions & 0 deletions zds/member/views/sessions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from importlib import import_module
from user_agents import parse

from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.sessions.models import Session
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import View

from zds.member.utils import get_geolocalization
from zds.utils.paginator import ZdSPagingListView

SessionStore = import_module(settings.SESSION_ENGINE).SessionStore


class ListSessions(LoginRequiredMixin, ZdSPagingListView):
"""List the user's sessions."""

model = Session
context_object_name = "sessions"
template_name = "member/settings/sessions.html"
paginate_by = 10

def get_context_data(self, **kwargs):
self.object_list = []
for session in Session.objects.iterator():
data = session.get_decoded()
if data.get("_auth_user_id") == str(self.request.user.pk):
session_context = {
"session_key": session.session_key,
"user_agent": str(parse(data.get("user_agent", ""))),
"ip_address": data.get("ip_address", ""),
"geolocalization": get_geolocalization(data.get("ip_address", "")) or _("Inconnue"),
"last_visit": data.get("last_visit", 0),
"is_active": session.session_key == self.request.session.session_key,
}

if session_context["is_active"]:
self.object_list.insert(0, session_context)
else:
self.object_list.append(session_context)

return super().get_context_data(**kwargs)


class DeleteSession(LoginRequiredMixin, View):
"""Delete a user's session."""

def post(self, request, *args, **kwargs):
session_key = request.POST.get("session_key", None)
if session_key and session_key != self.request.session.session_key:
session = SessionStore(session_key=session_key)
if session.get("_auth_user_id", "") == str(self.request.user.pk):
session.flush()

return redirect(reverse("list-sessions"))
24 changes: 24 additions & 0 deletions zds/middlewares/managesessionsmiddleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from datetime import datetime

from zds.member.views import get_client_ip


class ManageSessionsMiddleware:
def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
return self.process_response(request, self.get_response(request))

def process_response(self, request, response):
try:
user = request.user
except AttributeError:
user = None

if user is not None and user.is_authenticated:
session = request.session
session["ip_address"] = get_client_ip(request)
session["user_agent"] = request.META.get("HTTP_USER_AGENT", "")
session["last_visit"] = datetime.now().timestamp()
return response
1 change: 1 addition & 0 deletions zds/settings/abstract_base/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
"zds.utils.ThreadLocals",
"zds.middlewares.setlastvisitmiddleware.SetLastVisitMiddleware",
"zds.middlewares.matomomiddleware.MatomoMiddleware",
"zds.middlewares.managesessionsmiddleware.ManageSessionsMiddleware",
"zds.member.utils.ZDSCustomizeSocialAuthExceptionMiddleware",
)

Expand Down

0 comments on commit fe4f79d

Please sign in to comment.