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
+
+ Sessions
+
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 = (