first commit

This commit is contained in:
pacnpal
2024-10-28 17:09:57 -04:00
commit 2e1b4d7af7
9993 changed files with 1182741 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
from allauth.core.internal.adapter import BaseAdapter
from allauth.usersessions import app_settings
from allauth.utils import import_attribute
class DefaultUserSessionsAdapter(BaseAdapter):
"""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 needs.
"""
def end_sessions(self, sessions):
for session in sessions:
session.end()
def get_adapter():
return import_attribute(app_settings.ADAPTER)()

View File

@@ -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")

View File

@@ -0,0 +1,30 @@
class AppSettings:
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)

View File

@@ -0,0 +1,24 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
from allauth import app_settings
class UserSessionsConfig(AppConfig):
name = "allauth.usersessions"
verbose_name = _("User Sessions")
default_auto_field = (
app_settings.DEFAULT_AUTO_FIELD or "django.db.models.BigAutoField"
)
def ready(self):
from allauth.account.signals import (
password_changed,
password_set,
user_logged_in,
)
from allauth.usersessions import signals
user_logged_in.connect(receiver=signals.on_user_logged_in)
for sig in [password_set, password_changed]:
sig.connect(receiver=signals.on_password_changed)

View File

@@ -0,0 +1,12 @@
from django import forms
from allauth.usersessions.internal import flows
class ManageUserSessionsForm(forms.Form):
def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request")
super().__init__(*args, **kwargs)
def save(self, request):
flows.sessions.end_other_sessions(request, request.user)

View File

@@ -0,0 +1,4 @@
from allauth.usersessions.internal.flows import sessions
__all__ = ["sessions"]

View File

@@ -0,0 +1,19 @@
from allauth.account.internal import flows
from allauth.usersessions.adapter import get_adapter
from allauth.usersessions.models import UserSession
def end_other_sessions(request, user):
sessions_to_end = []
for session in UserSession.objects.filter(user=user):
if session.is_current():
continue
sessions_to_end.append(session)
end_sessions(request, sessions_to_end)
def end_sessions(request, sessions):
has_current = any([session.is_current() for session in sessions])
get_adapter().end_sessions(sessions)
if has_current:
flows.logout.logout(request)

View File

@@ -0,0 +1,19 @@
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
and hasattr(request, "session")
and request.session.session_key
and hasattr(request, "user")
and request.user.is_authenticated
):
UserSession.objects.create_from_request(request)
response = self.get_response(request)
return response

View File

@@ -0,0 +1,55 @@
# Generated by Django 4.2.6 on 2023-12-05 11:44
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
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,
),
),
],
),
]

View File

@@ -0,0 +1,129 @@
from importlib import import_module
from django.conf import settings
from django.contrib.auth import get_user
from django.core.exceptions import ImproperlyConfigured
from django.db import models, transaction
from django.http import HttpRequest
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from allauth import app_settings as allauth_settings
from allauth.account.adapter import get_adapter
from allauth.core import context
if not allauth_settings.USERSESSIONS_ENABLED:
raise ImproperlyConfigured(
"allauth.usersessions not installed, yet its models are imported."
)
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:
raise ValueError()
if not request.session.session_key:
request.session.save()
ua = request.META.get("HTTP_USER_AGENT", "")[
0 : UserSession._meta.get_field("user_agent").max_length
]
defaults = dict(
user=request.user,
ip=get_adapter().get_client_ip(request),
user_agent=ua,
)
from_session = None
with transaction.atomic():
from allauth.usersessions.signals import session_client_changed
session, created = UserSession.objects.get_or_create(
session_key=request.session.session_key, defaults=defaults
)
if not created:
from_session = UserSession(
session_key=session.session_key,
user=session.user,
ip=session.ip,
user_agent=session.user_agent,
data=session.data,
created_at=session.created_at,
last_seen_at=session.last_seen_at,
)
# Update session
session.user = defaults["user"]
session.ip = defaults["ip"]
session.user_agent = defaults["user_agent"]
session.last_seen_at = timezone.now()
session.save()
if from_session and (
from_session.ip != session.ip
or from_session.user_agent != session.user_agent
):
session_client_changed.send(
sender=UserSession,
request=request,
from_session=from_session,
to_session=session,
)
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 _session_store(self, *args):
engine = import_module(settings.SESSION_ENGINE)
return engine.SessionStore(*args)
def exists(self):
return self._session_store().exists(self.session_key)
def purge(self):
purge = not self.exists()
if not purge:
# Even if the session still exists, it might be the case that the
# user session hash is out of sync. So, let's see if
# `django.contrib.auth` can find a user...
request = HttpRequest()
request.session = self._session_store(self.session_key)
user = get_user(request)
purge = not user or user.is_anonymous
if purge:
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()

View File

@@ -0,0 +1,20 @@
from django.dispatch import Signal
from allauth.account import app_settings
from .models import UserSession
# Provides the arguments "request", "from_session", "to_session"
session_client_changed = Signal()
def on_user_logged_in(sender, **kwargs):
request = kwargs["request"]
UserSession.objects.create_from_request(request)
def on_password_changed(sender, **kwargs):
if not app_settings.LOGOUT_ON_PASSWORD_CHANGE:
request = kwargs["request"]
UserSession.objects.create_from_request(request)

View File

@@ -0,0 +1,93 @@
from unittest.mock import Mock
from django.contrib.auth.models import AnonymousUser
from django.test.utils import override_settings
import pytest
from allauth.usersessions.middleware import UserSessionsMiddleware
from allauth.usersessions.models import UserSession
from allauth.usersessions.signals import session_client_changed
def test_mw_without_request_user(rf, db, settings):
settings.USERSESSIONS_TRACK_ACTIVITY = True
mw = UserSessionsMiddleware(lambda request: None)
request = rf.get("/")
mw(request)
assert UserSession.objects.count() == 0
@pytest.mark.parametrize("track_activity", [False, True])
def test_mw_with_request_user(rf, db, settings, user, track_activity):
settings.USERSESSIONS_TRACK_ACTIVITY = track_activity
mw = UserSessionsMiddleware(lambda request: None)
request = rf.get("/")
request.user = user
request.session = Mock()
request.session.session_key = "sess-123"
mw(request)
assert (
UserSession.objects.filter(session_key="sess-123", user=user).exists()
is track_activity
)
def test_mw_with_anonymous_request_user(rf, db, settings):
settings.USERSESSIONS_TRACK_ACTIVITY = True
mw = UserSessionsMiddleware(lambda request: None)
request = rf.get("/")
request.user = AnonymousUser()
request.session = Mock()
request.session.session_key = "sess-123"
mw(request)
assert not UserSession.objects.exists()
@override_settings(USERSESSIONS_TRACK_ACTIVITY=True)
def test_mw_change_ip_and_useragent(rf, db, user):
mw = UserSessionsMiddleware(lambda request: None)
# First request
request1 = rf.get("/")
request1.user = user
request1.session = Mock()
request1.session.session_key = "sess-123"
request1.META["HTTP_USER_AGENT"] = "Old User Agent"
request1.META["REMOTE_ADDR"] = "1.1.1.1"
mw(request1)
# Second request with changed IP and User Agent
request2 = rf.get("/")
request2.user = user
request2.session = Mock()
request2.session.session_key = "sess-123"
request2.META["HTTP_USER_AGENT"] = "New User Agent"
request2.META["REMOTE_ADDR"] = "2.2.2.2"
# Set up signal receiver
signal_received = []
def signal_handler(sender, request, from_session, to_session, **kwargs):
signal_received.append((from_session, to_session))
session_client_changed.connect(signal_handler)
# Process second request
mw(request2)
# Check if UserSession was updated
user_session = UserSession.objects.get(session_key="sess-123", user=user)
assert user_session.ip == "2.2.2.2"
assert user_session.user_agent == "New User Agent"
# Check if signal was triggered
assert len(signal_received) == 1
from_session, to_session = signal_received[0]
assert from_session.ip == "1.1.1.1"
assert from_session.user_agent == "Old User Agent"
assert to_session.ip == "2.2.2.2"
assert to_session.user_agent == "New User Agent"
# Clean up signal connection
session_client_changed.disconnect(signal_handler)

View File

@@ -0,0 +1,60 @@
from django.test import Client
from django.urls import reverse
import pytest
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-REMOVED]},
)
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 = client.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"
)
@pytest.mark.parametrize("logout_on_passwd_change", [True, False])
def test_change_password_updates_user_session(
settings, logout_on_passwd_change, client, user, user_password, password_factory
):
settings.ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = logout_on_passwd_change
resp = client.post(
reverse("account_login"),
{"login": user.username, "[PASSWORD-REMOVED]},
)
assert resp.status_code == 302
assert len(UserSession.objects.purge_and_list(user)) == 1
new_[PASSWORD-REMOVED]()
resp = client.post(
reverse("account_change_password"),
{
"old[PASSWORD-REMOVED],
"password1": new_password,
"password2": new_password,
},
)
assert len(UserSession.objects.purge_and_list(user)) == (
0 if logout_on_passwd_change else 1
)

View File

@@ -0,0 +1,8 @@
from django.urls import path
from allauth.usersessions import views
urlpatterns = [
path("", views.list_usersessions, name="usersessions_list"),
]

View File

@@ -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()