diff --git a/requirements.txt b/requirements.txt
index 065cf19368..5afce71bf2 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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
@@ -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
diff --git a/templates/member/settings/base.html b/templates/member/settings/base.html
index 44727ab9af..051a9353ac 100644
--- a/templates/member/settings/base.html
+++ b/templates/member/settings/base.html
@@ -40,6 +40,7 @@
{% trans "Paramètres" %}
{% if user.profile.is_dev %}
{% trans "Token GitHub" %}
{% endif %}
+ {% trans "Gestion des sessions" %}
{% trans "Désinscription" %}
diff --git a/templates/member/settings/sessions.html b/templates/member/settings/sessions.html
new file mode 100644
index 0000000000..35a4efa179
--- /dev/null
+++ b/templates/member/settings/sessions.html
@@ -0,0 +1,71 @@
+{% extends "member/settings/base.html" %}
+{% load i18n %}
+{% load date %}
+
+
+{% block title %}
+ {% trans "Gestion des sessions" %}
+{% endblock %}
+
+
+
+{% block breadcrumb %}
+
+ {% trans "Gestion des sessions" %}
+
+{% endblock %}
+
+
+
+{% block headline %}
+ {% trans "Gestion des sessions" %}
+{% endblock %}
+
+
+
+{% block content %}
+ {% include "misc/paginator.html" with position="top" %}
+
+ {% if sessions %}
+
+
+
+ {% trans "Session" %} |
+ {% trans "Appareil" %} |
+ {% trans "Adresse IP" %} |
+ {% trans "Géolocalisation" %} |
+ {% trans "Dernière utilisation" %} |
+ {% trans "Actions" %} |
+
+
+ {% for session in sessions %}
+
+ {% if session.is_active %}
+ {% trans "Session actuelle" %} |
+ {% else %}
+ {% trans "Autre session" %} |
+ {% endif %}
+ {{ session.user_agent }} |
+ {{ session.ip_address }} |
+ {{ session.geolocalization }} |
+ {{ session.last_visit|humane_time }} |
+
+
+ |
+
+ {% endfor %}
+
+
+
+ {% else %}
+ {% trans "Aucune session ne correspond à votre compte." %}
+ {% endif %}
+
+ {% include "misc/paginator.html" with position="bottom" %}
+{% endblock %}
diff --git a/zds/member/models.py b/zds/member/models.py
index 1753ad64a1..abf1058100 100644
--- a/zds/member/models.py
+++ b/zds/member/models.py
@@ -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
@@ -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
@@ -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.
diff --git a/zds/member/tests/views/tests_session.py b/zds/member/tests/views/tests_session.py
new file mode 100644
index 0000000000..324e5f503b
--- /dev/null
+++ b/zds/member/tests/views/tests_session.py
@@ -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"))
diff --git a/zds/member/urls.py b/zds/member/urls.py
index 4e9371eaec..bab24ed6be 100644
--- a/zds/member/urls.py
+++ b/zds/member/urls.py
@@ -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 = [
@@ -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//", CreateProfileReportView.as_view(), name="report-profile"),
path("profil/resoudre//", SolveProfileReportView.as_view(), name="solve-profile-alert"),
diff --git a/zds/member/utils.py b/zds/member/utils.py
index bfc676d02b..a38862ce8c 100644
--- a/zds/member/utils.py
+++ b/zds/member/utils.py
@@ -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__)
@@ -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
diff --git a/zds/member/views/sessions.py b/zds/member/views/sessions.py
new file mode 100644
index 0000000000..a284d109f4
--- /dev/null
+++ b/zds/member/views/sessions.py
@@ -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"))
diff --git a/zds/middlewares/managesessionsmiddleware.py b/zds/middlewares/managesessionsmiddleware.py
new file mode 100644
index 0000000000..af41b7affb
--- /dev/null
+++ b/zds/middlewares/managesessionsmiddleware.py
@@ -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
diff --git a/zds/settings/abstract_base/django.py b/zds/settings/abstract_base/django.py
index 58b81c5ef3..6b670c7df1 100644
--- a/zds/settings/abstract_base/django.py
+++ b/zds/settings/abstract_base/django.py
@@ -107,6 +107,7 @@
"zds.utils.ThreadLocals",
"zds.middlewares.setlastvisitmiddleware.SetLastVisitMiddleware",
"zds.middlewares.matomomiddleware.MatomoMiddleware",
+ "zds.middlewares.managesessionsmiddleware.ManageSessionsMiddleware",
"zds.member.utils.ZDSCustomizeSocialAuthExceptionMiddleware",
)