diff --git a/ChangeLog.rst b/ChangeLog.rst index 8fb32b8b16..9ffc77dc6b 100644 --- a/ChangeLog.rst +++ b/ChangeLog.rst @@ -16,6 +16,9 @@ Note worthy changes available that can be used to prevent users from deactivating e.g. their TOTP authenticator. +- Added a new app, user sessions, allowing users to view a list of all their + active sessions, as well as offering a means to end these sessions. + 0.58.2 (2023-11-06) ******************* diff --git a/allauth/app_settings.py b/allauth/app_settings.py index a4eb70c42d..fc8903fcff 100644 --- a/allauth/app_settings.py +++ b/allauth/app_settings.py @@ -22,6 +22,10 @@ def SOCIALACCOUNT_ENABLED(self): def MFA_ENABLED(self): return apps.is_installed("allauth.mfa") + @property + def USERSESSIONS_ENABLED(self): + return apps.is_installed("allauth.usersessions") + _app_settings = AppSettings("ALLAUTH_") diff --git a/allauth/templates/allauth/elements/table.html b/allauth/templates/allauth/elements/table.html new file mode 100644 index 0000000000..6876e9f02c --- /dev/null +++ b/allauth/templates/allauth/elements/table.html @@ -0,0 +1,5 @@ +{% load allauth %} + + {% slot %} + {% endslot %} +
diff --git a/allauth/templates/usersessions/base_manage.html b/allauth/templates/usersessions/base_manage.html new file mode 100644 index 0000000000..ba6316b9a6 --- /dev/null +++ b/allauth/templates/usersessions/base_manage.html @@ -0,0 +1 @@ +{% extends "allauth/layouts/manage.html" %} diff --git a/allauth/templates/usersessions/messages/sessions_logged_out.txt b/allauth/templates/usersessions/messages/sessions_logged_out.txt new file mode 100644 index 0000000000..ad144056cf --- /dev/null +++ b/allauth/templates/usersessions/messages/sessions_logged_out.txt @@ -0,0 +1,2 @@ +{% load i18n %} +{% blocktranslate %}Signed out of all other sessions.{% endblocktranslate %} diff --git a/allauth/templates/usersessions/usersession_list.html b/allauth/templates/usersessions/usersession_list.html new file mode 100644 index 0000000000..2968b60fb1 --- /dev/null +++ b/allauth/templates/usersessions/usersession_list.html @@ -0,0 +1,69 @@ +{% extends "usersessions/base_manage.html" %} +{% load allauth %} +{% load i18n %} +{% load humanize %} +{% block head_title %} + {% trans "Sessions" %} +{% endblock head_title %} +{% block content %} + {% element h1 tags="usersessions,list" %} + {% trans "Sessions" %} + {% endelement %} + {% if session_count > 1 %} + {% url 'usersessions_list' as action_url %} + {% else %} + {% url 'account_logout' as action_url %} + {% endif %} + {% element form action=action_url method="post" tags="sessions" no_visible_fields=True %} + {% slot body %} + {% csrf_token %} + {% element table tags="sessions" %} + + + {% translate "Started at" %} + {% translate "IP Address" %} + {% translate "Browser" %} + {% if show_last_seen_at %} + {% translate "Last seen at" %} + {% endif %} + + + + {% for session in sessions %} + + + {{ session.created_at|naturaltime }} + + {{ session.ip }} + {{ session.user_agent }} + {% if show_last_seen_at %} + + {{ session.last_seen_at|naturaltime }} + + {% endif %} + + {% if session.is_current %} + {% element badge tags="session,current" %} + {% translate "Current" %} + {% endelement %} + {% else %} + {% endif %} + + + {% endfor %} + + {% endelement %} + {% endslot %} + {% slot actions %} + {% if session_count > 1 %} + {% element button type="submit" %} + {% translate "Sign Out Other Sessions" %} + {% endelement %} + {% else %} + {% element button type="submit" %} + {% translate "Sign Out" %} + {% endelement %} + {% endif %} + {% endslot %} + {% endelement %} +{% endblock content %} diff --git a/allauth/urls.py b/allauth/urls.py index 5543ef0b82..c4b4e6aa4b 100644 --- a/allauth/urls.py +++ b/allauth/urls.py @@ -14,6 +14,9 @@ if app_settings.SOCIALACCOUNT_ENABLED: urlpatterns += [path("social/", include("allauth.socialaccount.urls"))] +if app_settings.USERSESSIONS_ENABLED: + urlpatterns += [path("sessions/", include("allauth.usersessions.urls"))] + # Provider urlpatterns, as separate attribute (for reusability). provider_urlpatterns = [] provider_classes = providers.registry.get_class_list() diff --git a/allauth/usersessions/__init__.py b/allauth/usersessions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/allauth/usersessions/adapter.py b/allauth/usersessions/adapter.py new file mode 100644 index 0000000000..86e7e29ffd --- /dev/null +++ b/allauth/usersessions/adapter.py @@ -0,0 +1,18 @@ +from allauth.usersessions import app_settings +from allauth.utils import import_attribute + + +class DefaultUserSessionsAdapter: + """The adapter class allows you to override various functionality of the + ``allauth.usersessions`` app. To do so, point ``settings.USERSESSIONS_ADAPTER`` to your own + class that derives from ``DefaultUserSessionsAdapter`` and override the behavior by + altering the implementation of the methods according to your own need. + """ + + def end_sessions(self, sessions): + for session in sessions: + session.end() + + +def get_adapter(): + return import_attribute(app_settings.ADAPTER)() diff --git a/allauth/usersessions/admin.py b/allauth/usersessions/admin.py new file mode 100644 index 0000000000..89518d6a60 --- /dev/null +++ b/allauth/usersessions/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +from allauth.usersessions.models import UserSession + + +@admin.register(UserSession) +class UserSessionAdmin(admin.ModelAdmin): + raw_id_fields = ("user",) + list_display = ("user", "created_at", "last_seen_at", "ip", "user_agent") diff --git a/allauth/usersessions/app_settings.py b/allauth/usersessions/app_settings.py new file mode 100644 index 0000000000..686fcf592a --- /dev/null +++ b/allauth/usersessions/app_settings.py @@ -0,0 +1,30 @@ +class AppSettings(object): + def __init__(self, prefix): + self.prefix = prefix + + def _setting(self, name, dflt): + from allauth.utils import get_setting + + return get_setting(self.prefix + name, dflt) + + @property + def ADAPTER(self): + return self._setting( + "ADAPTER", "allauth.usersessions.adapter.DefaultUserSessionsAdapter" + ) + + @property + def TRACK_ACTIVITY(self): + """Whether or not sessions are to be actively tracked. When tracking is + enabled, the last seen IP address and last seen timestamp will be kept + track of. + """ + return self._setting("TRACK_ACTIVITY", False) + + +_app_settings = AppSettings("USERSESSIONS_") + + +def __getattr__(name): + # See https://peps.python.org/pep-0562/ + return getattr(_app_settings, name) diff --git a/allauth/usersessions/apps.py b/allauth/usersessions/apps.py new file mode 100644 index 0000000000..7865873b07 --- /dev/null +++ b/allauth/usersessions/apps.py @@ -0,0 +1,14 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class UserSessionsConfig(AppConfig): + name = "allauth.usersessions" + verbose_name = _("User Sessions") + default_auto_field = "django.db.models.BigAutoField" + + def ready(self): + from allauth.account.signals import user_logged_in + from allauth.usersessions import signals + + user_logged_in.connect(receiver=signals.on_user_logged_in) diff --git a/allauth/usersessions/forms.py b/allauth/usersessions/forms.py new file mode 100644 index 0000000000..1dfe624478 --- /dev/null +++ b/allauth/usersessions/forms.py @@ -0,0 +1,18 @@ +from django import forms + +from allauth.usersessions.adapter import get_adapter +from allauth.usersessions.models import UserSession + + +class ManageUserSessionsForm(forms.Form): + def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request") + super().__init__(*args, **kwargs) + + def save(self, request): + sessions_to_end = [] + for session in UserSession.objects.filter(user=request.user): + if session.is_current(): + continue + sessions_to_end.append(session) + get_adapter().end_sessions(sessions_to_end) diff --git a/allauth/usersessions/middleware.py b/allauth/usersessions/middleware.py new file mode 100644 index 0000000000..7dd3d5bade --- /dev/null +++ b/allauth/usersessions/middleware.py @@ -0,0 +1,13 @@ +from allauth.usersessions import app_settings +from allauth.usersessions.models import UserSession + + +class UserSessionsMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if app_settings.TRACK_ACTIVITY: + UserSession.objects.create_from_request(request) + response = self.get_response(request) + return response diff --git a/allauth/usersessions/migrations/0001_initial.py b/allauth/usersessions/migrations/0001_initial.py new file mode 100644 index 0000000000..efb4c65a6a --- /dev/null +++ b/allauth/usersessions/migrations/0001_initial.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.6 on 2023-12-05 11:44 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="UserSession", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ("ip", models.GenericIPAddressField()), + ( + "last_seen_at", + models.DateTimeField(default=django.utils.timezone.now), + ), + ( + "session_key", + models.CharField( + editable=False, + max_length=40, + unique=True, + verbose_name="session key", + ), + ), + ("user_agent", models.CharField(max_length=200)), + ("data", models.JSONField(default=dict)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/allauth/usersessions/migrations/__init__.py b/allauth/usersessions/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/allauth/usersessions/models.py b/allauth/usersessions/models.py new file mode 100644 index 0000000000..ea374b0565 --- /dev/null +++ b/allauth/usersessions/models.py @@ -0,0 +1,72 @@ +from importlib import import_module + +from django.conf import settings +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from allauth.account.adapter import get_adapter +from allauth.core import context + + +class UserSessionManager(models.Manager): + def purge_and_list(self, user): + ret = [] + sessions = UserSession.objects.filter(user=user) + for session in sessions.iterator(): + if not session.purge(): + ret.append(session) + return ret + + def create_from_request(self, request): + if not request.user.is_authenticated or not request.session.session_key: + raise ValueError() + ua = request.META.get("HTTP_USER_AGENT", "")[ + 0 : UserSession._meta.get_field("user_agent").max_length + ] + UserSession.objects.update_or_create( + user=request.user, + session_key=request.session.session_key, + defaults=dict( + ip=get_adapter().get_client_ip(request), + user_agent=ua, + last_seen_at=timezone.now(), + ), + ) + + +class UserSession(models.Model): + objects = UserSessionManager() + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + created_at = models.DateTimeField(default=timezone.now) + ip = models.GenericIPAddressField() + last_seen_at = models.DateTimeField(default=timezone.now) + session_key = models.CharField( + _("session key"), max_length=40, unique=True, editable=False + ) + user_agent = models.CharField(max_length=200) + data = models.JSONField(default=dict) + + def __str__(self): + return f"{self.ip} ({self.user_agent})" + + def exists(self): + engine = import_module(settings.SESSION_ENGINE) + store = engine.SessionStore() + return store.exists(self.session_key) + + def purge(self): + if not self.exists(): + self.delete() + return True + return False + + def is_current(self): + return self.session_key == context.request.session.session_key + + def end(self): + engine = import_module(settings.SESSION_ENGINE) + store = engine.SessionStore() + store.delete(self.session_key) + self.delete() diff --git a/allauth/usersessions/signals.py b/allauth/usersessions/signals.py new file mode 100644 index 0000000000..76e675f684 --- /dev/null +++ b/allauth/usersessions/signals.py @@ -0,0 +1,6 @@ +from .models import UserSession + + +def on_user_logged_in(sender, **kwargs): + request = kwargs["request"] + UserSession.objects.create_from_request(request) diff --git a/allauth/usersessions/tests/__init__.py b/allauth/usersessions/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/allauth/usersessions/tests/test_views.py b/allauth/usersessions/tests/test_views.py new file mode 100644 index 0000000000..67c8479f55 --- /dev/null +++ b/allauth/usersessions/tests/test_views.py @@ -0,0 +1,32 @@ +from django.test import Client +from django.urls import reverse + +from allauth.usersessions.models import UserSession + + +def test_overall_flow(user, user_password): + firefox = Client(HTTP_USER_AGENT="Mozilla Firefox") + nyxt = Client(HTTP_USER_AGENT="Nyxt") + for client in [firefox, nyxt]: + resp = client.post( + reverse("account_login"), + {"login": user.username, "password": user_password}, + ) + assert resp.status_code == 302 + assert UserSession.objects.filter(user=user).count() == 2 + sessions = list(UserSession.objects.filter(user=user).order_by("pk")) + assert sessions[0].user_agent == "Mozilla Firefox" + assert sessions[1].user_agent == "Nyxt" + for client in [firefox, nyxt]: + resp = firefox.get(reverse("usersessions_list")) + assert resp.status_code == 200 + resp = firefox.post(reverse("usersessions_list")) + assert resp.status_code == 302 + assert UserSession.objects.filter(user=user).count() == 1 + assert UserSession.objects.filter(user=user, pk=sessions[0].pk).exists() + assert not UserSession.objects.filter(user=user, pk=sessions[1].pk).exists() + resp = nyxt.get(reverse("usersessions_list")) + assert resp.status_code == 302 + assert resp["location"] == reverse("account_login") + "?next=" + reverse( + "usersessions_list" + ) diff --git a/allauth/usersessions/urls.py b/allauth/usersessions/urls.py new file mode 100644 index 0000000000..3d481ccd38 --- /dev/null +++ b/allauth/usersessions/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from allauth.usersessions import views + + +urlpatterns = [ + path("", views.list_usersessions, name="usersessions_list"), +] diff --git a/allauth/usersessions/views.py b/allauth/usersessions/views.py new file mode 100644 index 0000000000..173ee1c597 --- /dev/null +++ b/allauth/usersessions/views.py @@ -0,0 +1,48 @@ +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.urls import reverse_lazy +from django.utils.decorators import method_decorator +from django.views.generic.edit import FormView + +from allauth.account import app_settings as account_settings +from allauth.account.adapter import get_adapter as get_account_adapter +from allauth.usersessions import app_settings +from allauth.usersessions.forms import ManageUserSessionsForm +from allauth.usersessions.models import UserSession + + +@method_decorator(login_required, name="dispatch") +class ListUserSessionsView(FormView): + template_name = ( + "usersessions/usersession_list." + account_settings.TEMPLATE_EXTENSION + ) + form_class = ManageUserSessionsForm + success_url = reverse_lazy("usersessions_list") + + def get_context_data(self, **kwargs): + ret = super().get_context_data(**kwargs) + sessions = sorted( + UserSession.objects.purge_and_list(self.request.user), + key=lambda s: s.created_at, + ) + ret["sessions"] = sessions + ret["session_count"] = len(sessions) + ret["show_last_seen_at"] = app_settings.TRACK_ACTIVITY + return ret + + def get_form_kwargs(self): + ret = super().get_form_kwargs() + ret["request"] = self.request + return ret + + def form_valid(self, form): + form.save(self.request) + get_account_adapter().add_message( + self.request, + messages.INFO, + "usersessions/messages/sessions_logged_out.txt", + ) + return super().form_valid(form) + + +list_usersessions = ListUserSessionsView.as_view() diff --git a/docs/index.rst b/docs/index.rst index ac03e47699..25fb246845 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,6 +15,7 @@ Contents account/index socialaccount/index mfa/index + usersessions/index common/index project/index faq diff --git a/docs/mfa/configuration.rst b/docs/mfa/configuration.rst index 11044497fd..f36bee6bfe 100644 --- a/docs/mfa/configuration.rst +++ b/docs/mfa/configuration.rst @@ -3,7 +3,7 @@ Configuration Available settings: -``MFA_ADAPTER`` (default: ``"allauth.mfa.adapter.DefaultAccountAdapter"``) +``MFA_ADAPTER`` (default: ``"allauth.mfa.adapter.DefaultMFAAdapter"``) Specifies the adapter class to use, allowing you to alter certain default behaviour. diff --git a/docs/usersessions/adapter.rst b/docs/usersessions/adapter.rst new file mode 100644 index 0000000000..0d4b1ae9ca --- /dev/null +++ b/docs/usersessions/adapter.rst @@ -0,0 +1,5 @@ +Adapter +======= + +.. autoclass:: allauth.usersessions.adapter.DefaultUserSessionsAdapter + :members: diff --git a/docs/usersessions/configuration.rst b/docs/usersessions/configuration.rst new file mode 100644 index 0000000000..e8599c4379 --- /dev/null +++ b/docs/usersessions/configuration.rst @@ -0,0 +1,16 @@ +Configuration +============= + +Available settings: + +``USERSESSIONS_ADAPTER`` (default: ``"allauth.usersessions.adapter.DefaultUserSessionsAdapter"``) + Specifies the adapter class to use, allowing you to alter certain + default behaviour. + +``USERSESSIONS_TRACK_ACTIVITY`` (default: ``False``) + Whether or not user sessions are kept updated. User sessions are created at + login time, but as the user continues to access the site the IP address might + change. Enabling this setting makes sure that the session is kept track of, + meaning, the IP address, user agent and last seen timestamp are all kept up to + date. Requires ``allauth.usersessions.middleware.UserSessionsMiddleware`` to + be installed. diff --git a/docs/usersessions/index.rst b/docs/usersessions/index.rst new file mode 100644 index 0000000000..7b3ea19ec3 --- /dev/null +++ b/docs/usersessions/index.rst @@ -0,0 +1,8 @@ +User Sessions +============= + +.. toctree:: + + introduction + configuration + adapter diff --git a/docs/usersessions/introduction.rst b/docs/usersessions/introduction.rst new file mode 100644 index 0000000000..411fe20cba --- /dev/null +++ b/docs/usersessions/introduction.rst @@ -0,0 +1,6 @@ +Introduction +============ + +The purpose of the optional ``allauth.usersessions`` app is to keep track of +(authenticated) user sessions, allowing users to view a list of all their active +sessions, as well as offering a means to end these sessions. diff --git a/example/example/settings.py b/example/example/settings.py index 55771b8b15..d2d81171b3 100644 --- a/example/example/settings.py +++ b/example/example/settings.py @@ -125,6 +125,7 @@ "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", + "django.contrib.humanize", "django.contrib.sites", "django.contrib.messages", "django.contrib.staticfiles", @@ -161,6 +162,7 @@ "allauth.socialaccount.providers.vimeo_oauth2", "allauth.socialaccount.providers.weibo", "allauth.socialaccount.providers.xing", + "allauth.usersessions", "example.demo", ) diff --git a/example/example/templates/allauth/elements/table.html b/example/example/templates/allauth/elements/table.html new file mode 100644 index 0000000000..178ae7df8b --- /dev/null +++ b/example/example/templates/allauth/elements/table.html @@ -0,0 +1,5 @@ +{% load allauth %} + + {% slot %} + {% endslot %} +
diff --git a/example/example/templates/allauth/layouts/manage.html b/example/example/templates/allauth/layouts/manage.html index 89f0ba4206..8c0ff1e72f 100644 --- a/example/example/templates/allauth/layouts/manage.html +++ b/example/example/templates/allauth/layouts/manage.html @@ -19,6 +19,10 @@ Two-Factor Authentication +
diff --git a/example/example/templates/usersessions/base_manage.html b/example/example/templates/usersessions/base_manage.html new file mode 100644 index 0000000000..9a84c72ac9 --- /dev/null +++ b/example/example/templates/usersessions/base_manage.html @@ -0,0 +1,2 @@ +{% extends "allauth/layouts/manage.html" %} +{% block nav_class_usersessions %}{{ block.super }} active{% endblock %} diff --git a/shell.nix b/shell.nix index 18887ddd14..64f343cf3c 100644 --- a/shell.nix +++ b/shell.nix @@ -10,6 +10,7 @@ stdenv.mkDerivation { python310 python310Packages.django python310Packages.flake8 + python310Packages.debugpy python310Packages.pycodestyle python310Packages.pyls-flake8 python310Packages.pylsp-rope diff --git a/test_settings.py b/test_settings.py index 1073f33dc2..def13ee66f 100644 --- a/test_settings.py +++ b/test_settings.py @@ -65,6 +65,7 @@ "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.admin", + "django.contrib.humanize", "allauth", "allauth.account", "allauth.mfa", @@ -187,6 +188,7 @@ "allauth.socialaccount.providers.zoom", "allauth.socialaccount.providers.okta", "allauth.socialaccount.providers.feishu", + "allauth.usersessions", ) AUTHENTICATION_BACKENDS = (