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 %} +
    + + + + + + + + + + + {% 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.geolocalization }}{{ session.last_visit|humane_time }} +
    + {% 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 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", )