From 1845fd29f844576b2f84cb6c21840de3b680e04b Mon Sep 17 00:00:00 2001 From: Situphen Date: Wed, 12 Oct 2022 00:48:16 +0200 Subject: [PATCH] Ajout d'une page de gestion des sessions --- requirements.txt | 1 + templates/member/settings/base.html | 1 + templates/member/settings/sessions.html | 71 +++++++++++++++++++++ zds/member/models.py | 3 +- zds/member/tests/views/tests_session.py | 26 ++++++++ zds/member/urls.py | 3 + zds/member/utils.py | 12 ++++ zds/member/views/sessions.py | 53 +++++++++++++++ zds/middlewares/managesessionsmiddleware.py | 27 ++++++++ zds/settings/abstract_base/django.py | 3 +- zds/utils/custom_cached_db_backend.py | 34 ++++++++++ zds/utils/migrations/0026_customsession.py | 29 +++++++++ 12 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 templates/member/settings/sessions.html create mode 100644 zds/member/tests/views/tests_session.py create mode 100644 zds/member/views/sessions.py create mode 100644 zds/middlewares/managesessionsmiddleware.py create mode 100644 zds/utils/custom_cached_db_backend.py create mode 100644 zds/utils/migrations/0026_customsession.py diff --git a/requirements.txt b/requirements.txt index d02622adfb..2c28ad1de3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,6 +19,7 @@ lxml==5.1.0 Pillow==10.2.0 pymemcache==4.0.0 requests==2.31.0 +ua-parser==0.18.0 # Api dependencies django-cors-headers==4.3.1 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..dba4bc6889 --- /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 %} +
    + + + + + + + + + + + {% for session in sessions %} + + {% if session.is_active %} + + {% else %} + + {% endif %} + + + + + + + {% endfor %} + +
    {% trans "Session" %}{% trans "Appareil" %}{% trans "Adresse IP" %}{% trans "Géolocalisation" %}{% trans "Dernière utilisation" %}{% trans "Actions" %}
    {% trans "Session actuelle" %}{% trans "Autre session" %}{{ session.user_agent }}{{ session.ip_address }}{{ session.geolocation }}{{ session.last_visit|date_from_timestamp|format_date }} +
    + {% csrf_token %} + + +
    +
    +
    + {% 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 5c0ff057d0..63bb0683da 100644 --- a/zds/member/models.py +++ b/zds/member/models.py @@ -90,7 +90,8 @@ def get_absolute_url(self): def get_city(self): """ Uses geo-localization to get physical localization of a profile through - its last IP address. + 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. 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 d456916424..99cf155512 100644 --- a/zds/member/utils.py +++ b/zds/member/utils.py @@ -8,6 +8,7 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ from social_django.middleware import SocialAuthExceptionMiddleware +from ua_parser import user_agent_parser logger = logging.getLogger(__name__) @@ -76,3 +77,14 @@ def get_geo_location_from_ip(ip: str) -> str: city = geo["city"] country = geo["country_name"] return ", ".join(i for i in [city, country] if i) + + +def get_info_from_user_agent(user_agent): + """Parse the user agent and extract information about the device, OS and browser.""" + + parsed_ua = user_agent_parser.Parse(user_agent) + device = parsed_ua["device"]["family"] + os = user_agent_parser.PrettyOS(*parsed_ua["os"].values()) + browser = user_agent_parser.PrettyUserAgent(*parsed_ua["user_agent"].values()) + + return f"{device} / {os} / {browser}" diff --git a/zds/member/views/sessions.py b/zds/member/views/sessions.py new file mode 100644 index 0000000000..3059ef370e --- /dev/null +++ b/zds/member/views/sessions.py @@ -0,0 +1,53 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +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_geo_location_from_ip, get_info_from_user_agent +from zds.utils.custom_cached_db_backend import CustomSession, SessionStore +from zds.utils.paginator import ZdSPagingListView + + +class ListSessions(LoginRequiredMixin, ZdSPagingListView): + """List the user's sessions with useful information (user agent, IP address, geolocation and last visit).""" + + model = CustomSession + 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 CustomSession.objects.filter(account_id=self.request.user.pk).iterator(): + data = session.get_decoded() + session_context = { + "session_key": session.session_key, + "user_agent": get_info_from_user_agent(data.get("user_agent", "")), + "ip_address": data.get("ip_address", ""), + "geolocation": get_geo_location_from_ip(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.""" + + http_method_names = ["post"] + + 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..be5a44ddff --- /dev/null +++ b/zds/middlewares/managesessionsmiddleware.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from zds.member.views import get_client_ip + + +class ManageSessionsMiddleware: + """This middleware adds the current IP address, user agent and timestamp to user sessions. + This gives them the information they need to manage their sessions, and possibly delete some of them.""" + + 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 f9031e3fc9..ece097f26e 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", ) @@ -285,7 +286,7 @@ }, } -SESSION_ENGINE = "django.contrib.sessions.backends.cached_db" +SESSION_ENGINE = "zds.utils.custom_cached_db_backend" LOGIN_URL = "member-login" LOGIN_REDIRECT_URL = "/" diff --git a/zds/utils/custom_cached_db_backend.py b/zds/utils/custom_cached_db_backend.py new file mode 100644 index 0000000000..cc61cf3e75 --- /dev/null +++ b/zds/utils/custom_cached_db_backend.py @@ -0,0 +1,34 @@ +from django.contrib.sessions.backends.cached_db import SessionStore as DBStore +from django.contrib.sessions.base_session import AbstractBaseSession +from django.db import models + + +class CustomSession(AbstractBaseSession): + """Custom session model to link each session to its user. + This is necessary to list a user's sessions without having to browse all sessions. + Based on https://docs.djangoproject.com/en/4.2/topics/http/sessions/#example""" + + account_id = models.IntegerField(null=True, db_index=True) + + @classmethod + def get_session_store_class(cls): + return SessionStore + + +class SessionStore(DBStore): + """Custom session store for the custom session model.""" + + cache_key_prefix = "zds.utils.custom_cached_db_backend" + + @classmethod + def get_model_class(cls): + return CustomSession + + def create_model_instance(self, data): + obj = super().create_model_instance(data) + try: + account_id = int(data.get("_auth_user_id")) + except (ValueError, TypeError): + account_id = None + obj.account_id = account_id + return obj diff --git a/zds/utils/migrations/0026_customsession.py b/zds/utils/migrations/0026_customsession.py new file mode 100644 index 0000000000..191c2d81a4 --- /dev/null +++ b/zds/utils/migrations/0026_customsession.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.6 on 2024-03-09 23:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("utils", "0025_move_helpwriting"), + ] + + operations = [ + migrations.CreateModel( + name="CustomSession", + fields=[ + ( + "session_key", + models.CharField(max_length=40, primary_key=True, serialize=False, verbose_name="session key"), + ), + ("session_data", models.TextField(verbose_name="session data")), + ("expire_date", models.DateTimeField(db_index=True, verbose_name="expire date")), + ("account_id", models.IntegerField(db_index=True, null=True)), + ], + options={ + "verbose_name": "session", + "verbose_name_plural": "sessions", + "abstract": False, + }, + ), + ]