Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ajout d'une page de gestion des sessions #6021

Merged
merged 1 commit into from
Mar 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
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" %}
Situphen marked this conversation as resolved.
Show resolved Hide resolved

{% 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.geolocation }}</td>
<td>{{ session.last_visit|date_from_timestamp|format_date }}</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 %}
3 changes: 2 additions & 1 deletion zds/member/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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
12 changes: 12 additions & 0 deletions zds/member/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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}"
53 changes: 53 additions & 0 deletions zds/member/views/sessions.py
Original file line number Diff line number Diff line change
@@ -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."""

Situphen marked this conversation as resolved.
Show resolved Hide resolved
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"))
27 changes: 27 additions & 0 deletions zds/middlewares/managesessionsmiddleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from datetime import datetime

from zds.member.views import get_client_ip


class ManageSessionsMiddleware:
Situphen marked this conversation as resolved.
Show resolved Hide resolved
"""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
3 changes: 2 additions & 1 deletion 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 Expand Up @@ -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 = "/"
Expand Down
34 changes: 34 additions & 0 deletions zds/utils/custom_cached_db_backend.py
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions zds/utils/migrations/0026_customsession.py
Original file line number Diff line number Diff line change
@@ -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,
},
),
]