├── tests ├── __init__.py ├── geoip │ ├── GeoIP2-City-Test.mmdb │ └── GeoIP2-Country-Test.mmdb ├── test_system_checks.py ├── conftest.py ├── urls.py ├── test_clearsessions.py ├── test_admin.py ├── settings.py ├── test_middleware.py ├── test_model.py └── test_sessionstore.py ├── qsessions ├── backends │ ├── __init__.py │ ├── cached_db.py │ └── db.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── clearsessions.py │ │ └── download_geoip_db.py ├── migrations │ ├── __init__.py │ ├── 0002_session_created_at.py │ └── 0001_initial.py ├── __init__.py ├── apps.py ├── middleware.py ├── geoip.py ├── admin.py └── models.py ├── .coveragerc ├── .gitignore ├── MANIFEST.in ├── pyproject.toml ├── .editorconfig ├── LICENSE.txt ├── .pre-commit-config.yaml ├── setup.py ├── .github └── workflows │ └── test.yml ├── CHANGELOG.md └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /qsessions/backends/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /qsessions/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /qsessions/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /qsessions/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /qsessions/__init__.py: -------------------------------------------------------------------------------- 1 | IP_SESSION_KEY = "_qsessions_ip" 2 | USER_AGENT_SESSION_KEY = "_qsessions_user_agent" 3 | -------------------------------------------------------------------------------- /tests/geoip/GeoIP2-City-Test.mmdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QueraTeam/django-qsessions/HEAD/tests/geoip/GeoIP2-City-Test.mmdb -------------------------------------------------------------------------------- /tests/geoip/GeoIP2-Country-Test.mmdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QueraTeam/django-qsessions/HEAD/tests/geoip/GeoIP2-Country-Test.mmdb -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | *venv* 4 | *site-packages* 5 | qsessions/management/commands/download_geoip_db.py 6 | setup.py 7 | -------------------------------------------------------------------------------- /tests/test_system_checks.py: -------------------------------------------------------------------------------- 1 | from django.core.management import call_command 2 | 3 | 4 | def test_system_checks(): 5 | call_command("check") 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | .*cache 4 | .coverage 5 | .python-version 6 | .idea/ 7 | .tox/ 8 | build/ 9 | dist/ 10 | htmlcov 11 | venv/ 12 | -------------------------------------------------------------------------------- /qsessions/management/commands/clearsessions.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sessions.management.commands.clearsessions import Command 2 | 3 | __all__ = ("Command",) 4 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE.txt 3 | include CHANGELOG.md 4 | recursive-include qsessions/locale * 5 | include tox.ini 6 | recursive-include tests *.mmdb 7 | recursive-include tests *.py 8 | -------------------------------------------------------------------------------- /qsessions/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class DjangoQsessionsConfig(AppConfig): 6 | name = "qsessions" 7 | verbose_name = _("Sessions") 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pytest.ini_options] 2 | DJANGO_SETTINGS_MODULE = "tests.settings" 3 | norecursedirs = ".git" 4 | django_find_project = false 5 | pythonpath = ["."] 6 | 7 | [tool.black] 8 | line-length = 120 9 | include = '\.pyi?$' 10 | exclude = '/\..+/' 11 | 12 | [tool.isort] 13 | profile = "black" 14 | line_length = 120 15 | skip_gitignore = true 16 | -------------------------------------------------------------------------------- /qsessions/migrations/0002_session_created_at.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11.2 on 2018-02-05 06:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("qsessions", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField(model_name="session", name="created_at", field=models.DateTimeField(null=True)), 13 | ] 14 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.conf import settings 3 | 4 | from qsessions.models import Session 5 | 6 | SESSION_ENGINES = [ 7 | "qsessions.backends.db", 8 | "qsessions.backends.cached_db", 9 | ] 10 | 11 | 12 | @pytest.fixture(autouse=True, name="SessionStore", params=SESSION_ENGINES) 13 | def session_store(request): 14 | settings.SESSION_ENGINE = request.param 15 | return Session.get_session_store_class() 16 | -------------------------------------------------------------------------------- /qsessions/backends/cached_db.py: -------------------------------------------------------------------------------- 1 | from django.contrib.sessions.backends.cached_db import SessionStore as DjangoCachedDBStore 2 | 3 | from .db import SessionStore as QSessionsDBStore 4 | 5 | KEY_PREFIX = "qsessions.q_cached_db" 6 | 7 | 8 | class SessionStore(QSessionsDBStore, DjangoCachedDBStore): 9 | """ 10 | Implements cached, database backed sessions, with a foreign key to User. 11 | It also stores IP and User Agent. 12 | """ 13 | 14 | cache_key_prefix = KEY_PREFIX 15 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.http import HttpResponse, JsonResponse 3 | from django.urls import path 4 | 5 | 6 | def read_session(request): 7 | return JsonResponse(dict(request.session)) 8 | 9 | 10 | def modify_session(request): 11 | request.session["FOO"] = "BAR" 12 | return HttpResponse("") 13 | 14 | 15 | urlpatterns = [ 16 | path("read_session/", read_session), 17 | path("modify_session/", modify_session), 18 | path("admin/", admin.site.urls), 19 | ] 20 | -------------------------------------------------------------------------------- /qsessions/middleware.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.sessions.middleware import SessionMiddleware as DjSessionMiddleware 3 | 4 | 5 | class SessionMiddleware(DjSessionMiddleware): 6 | def process_request(self, request): 7 | session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME) 8 | request.session = self.SessionStore( 9 | ip=request.META.get("REMOTE_ADDR", None), 10 | user_agent=request.headers.get("User-Agent", ""), 11 | session_key=session_key, 12 | ) 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | end_of_line = lf 9 | max_line_length = 120 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | charset = utf-8 13 | 14 | # Do not add "md" here! It breaks Markdown re-formatting in PyCharm. 15 | [*.{json,yml,yaml,html,md}] 16 | indent_size = 2 17 | 18 | [*.md] 19 | # Shorter lines in documentation files improves readability 20 | max_line_length = 80 21 | # 2 spaces at the end of a line forces a line break in MarkDown 22 | trim_trailing_whitespace = false 23 | 24 | [Makefile] 25 | indent_style = tab 26 | -------------------------------------------------------------------------------- /tests/test_clearsessions.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | import pytest 4 | from django.core.management import call_command 5 | 6 | from qsessions.models import Session 7 | 8 | 9 | @pytest.mark.django_db 10 | def test_can_call(): 11 | Session.objects.create( 12 | session_key="s1", 13 | expire_date=datetime.now() + timedelta(hours=1), 14 | ip="127.0.0.1", 15 | ) 16 | Session.objects.create( 17 | session_key="s2", 18 | expire_date=datetime.now() - timedelta(hours=1), 19 | ip="127.0.0.1", 20 | ) 21 | assert Session.objects.count() == 2 22 | call_command("clearsessions") 23 | assert Session.objects.count() == 1 24 | -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.conf import settings 3 | 4 | 5 | @pytest.mark.django_db 6 | @pytest.mark.filterwarnings("ignore:The address 127.0.0.1 is not in the database") 7 | def test_smoke_admin(admin_client): 8 | admin_client.get("/modify_session/", HTTP_USER_AGENT="Chrome/70.0.3538.102", REMOTE_ADDR="89.160.20.112") 9 | resp = admin_client.get("/admin/qsessions/session/?active=1&owner=my") 10 | assert resp.status_code == 200 11 | content = resp.content.decode("UTF-8") 12 | assert "Linköping, Sweden" in content # From REMOTE_ADDR 13 | assert "Chrome 70.0.3538" in content # From HTTP_USER_AGENT 14 | resp = admin_client.get( 15 | f"/admin/qsessions/session/{admin_client.cookies[settings.SESSION_COOKIE_NAME].value}/change/" 16 | ) 17 | assert "FOO" in resp.content.decode("UTF-8") # Set by modify_session 18 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2018 Mohammad Javad Naderi 2 | Copyright (C) 2014 Bouke Haarsma 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | this software and associated documentation files (the "Software"), to deal in 6 | the Software without restriction, including without limitation the rights to 7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 8 | of the Software, and to permit persons to whom the Software is furnished to do 9 | so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /qsessions/geoip.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from django.contrib.gis.geoip2 import HAS_GEOIP2 4 | 5 | 6 | def ip_to_location_info(ip): 7 | """ 8 | Get a dictionary of location info for a given IP address. 9 | 10 | The format of the dictionary is the same provided by the functions 11 | in django.contrib.gis.geoip2.base.GeoIP2. 12 | """ 13 | 14 | if not HAS_GEOIP2: 15 | return None 16 | 17 | from django.contrib.gis.geoip2 import GeoIP2 18 | 19 | try: 20 | g = GeoIP2() 21 | except Exception as e: 22 | warnings.warn(str(e)) 23 | return None 24 | 25 | try: 26 | return g.city(ip) 27 | except Exception: 28 | try: 29 | return g.country(ip) 30 | except Exception as e: 31 | warnings.warn(str(e)) 32 | 33 | 34 | def ip_to_location(ip): 35 | """ 36 | Transform an IP address into an approximate location. 37 | 38 | Example output: 39 | 40 | * Zwolle, The Netherlands 41 | * The Netherlands 42 | * None 43 | """ 44 | loc = ip_to_location_info(ip) 45 | if not loc: 46 | return None 47 | 48 | if loc.get("country_name"): 49 | if loc.get("city"): 50 | return f"{loc['city']}, {loc['country_name']}" 51 | return loc["country_name"] 52 | 53 | return None 54 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: "v4.6.0" 6 | hooks: 7 | - id: trailing-whitespace # trims trailing whitespace 8 | args: [--markdown-linebreak-ext=md] 9 | - id: end-of-file-fixer # ensures that a file is either empty, or ends with one newline 10 | - id: check-yaml # checks syntax of yaml files 11 | - id: check-json # checks syntax of json files 12 | - id: check-added-large-files # prevent giant files from being committed 13 | - id: fix-encoding-pragma # removes "# -*- coding: utf-8 -*-" from python files (since we only support python 3) 14 | args: [--remove] 15 | - id: check-merge-conflict # check for files that contain merge conflict strings 16 | 17 | - repo: https://github.com/adamchainz/django-upgrade 18 | rev: "1.18.0" 19 | hooks: 20 | - id: django-upgrade 21 | args: [--target-version, "4.2"] 22 | 23 | - repo: https://github.com/asottile/pyupgrade 24 | rev: "v3.16.0" 25 | hooks: 26 | - id: pyupgrade 27 | args: [--py38-plus] 28 | 29 | - repo: https://github.com/pycqa/isort 30 | rev: "5.13.2" 31 | hooks: 32 | - id: isort 33 | name: isort (python) 34 | 35 | - repo: https://github.com/psf/black 36 | rev: "24.4.2" 37 | hooks: 38 | - id: black 39 | -------------------------------------------------------------------------------- /qsessions/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.11.2 on 2017-12-19 16:00 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | import qsessions.models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="Session", 20 | fields=[ 21 | ( 22 | "session_key", 23 | models.CharField(max_length=40, primary_key=True, serialize=False, verbose_name="session key"), 24 | ), 25 | ("session_data", models.TextField(verbose_name="session data")), 26 | ("expire_date", models.DateTimeField(db_index=True, verbose_name="expire date")), 27 | ("user_agent", models.CharField(blank=True, max_length=300, null=True)), 28 | ("updated_at", models.DateTimeField(auto_now=True)), 29 | ("ip", models.GenericIPAddressField(blank=True, null=True, verbose_name="IP")), 30 | ( 31 | "user", 32 | models.ForeignKey( 33 | null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL 34 | ), 35 | ), 36 | ], 37 | options={"verbose_name": "session", "abstract": False, "verbose_name_plural": "sessions"}, 38 | managers=[("objects", qsessions.models.SessionManager())], 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 4 | SECRET_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" 5 | 6 | DEBUG = True 7 | USE_TZ = False 8 | 9 | INSTALLED_APPS = [ 10 | "django.contrib.admin", 11 | "django.contrib.auth", 12 | "django.contrib.contenttypes", 13 | "qsessions", 14 | "django.contrib.messages", 15 | ] 16 | 17 | MIDDLEWARE = [ 18 | "qsessions.middleware.SessionMiddleware", 19 | "django.middleware.common.CommonMiddleware", 20 | "django.middleware.csrf.CsrfViewMiddleware", 21 | "django.contrib.auth.middleware.AuthenticationMiddleware", 22 | "django.contrib.messages.middleware.MessageMiddleware", 23 | ] 24 | 25 | ROOT_URLCONF = "tests.urls" 26 | 27 | TEMPLATES = [ 28 | { 29 | "BACKEND": "django.template.backends.django.DjangoTemplates", 30 | "DIRS": [], 31 | "APP_DIRS": True, 32 | "OPTIONS": { 33 | "context_processors": [ 34 | "django.template.context_processors.debug", 35 | "django.template.context_processors.request", 36 | "django.contrib.auth.context_processors.auth", 37 | "django.contrib.messages.context_processors.messages", 38 | ], 39 | }, 40 | }, 41 | ] 42 | 43 | DATABASES = { 44 | "default": { 45 | "ENGINE": "django.db.backends.sqlite3", 46 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 47 | } 48 | } 49 | 50 | STATIC_URL = "/static/" 51 | 52 | GEOIP_PATH = os.path.join(BASE_DIR, "geoip") 53 | GEOIP_CITY = "GeoIP2-City-Test.mmdb" 54 | GEOIP_COUNTRY = "GeoIP2-Country-Test.mmdb" 55 | 56 | CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}} 57 | -------------------------------------------------------------------------------- /qsessions/backends/db.py: -------------------------------------------------------------------------------- 1 | from django.contrib import auth 2 | from django.contrib.sessions.backends.db import SessionStore as DjangoDBStore 3 | 4 | from qsessions import IP_SESSION_KEY, USER_AGENT_SESSION_KEY 5 | 6 | 7 | class SessionStore(DjangoDBStore): 8 | """ 9 | Implements database backed sessions, with a foreign key to User. 10 | It also stores IP and User Agent. 11 | """ 12 | 13 | def __init__(self, session_key=None, user_agent=None, ip=None): 14 | self.user_agent = user_agent[:300] if user_agent else user_agent 15 | self.ip = ip 16 | super().__init__(session_key) 17 | 18 | @classmethod 19 | def get_model_class(cls): 20 | from qsessions.models import Session 21 | 22 | return Session 23 | 24 | def load(self): 25 | data = super().load() 26 | if data.get(USER_AGENT_SESSION_KEY) != self.user_agent or data.get(IP_SESSION_KEY) != self.ip: 27 | # If IP or User Agent has changed, set modified to True in order to save 28 | # the new IP and User Agent 29 | self.modified = True 30 | return data 31 | 32 | def save(self, must_create=False): 33 | # Store IP and User Agent in session_data 34 | if USER_AGENT_SESSION_KEY not in self or self[USER_AGENT_SESSION_KEY] != self.user_agent: 35 | self[USER_AGENT_SESSION_KEY] = self.user_agent 36 | if IP_SESSION_KEY not in self or self[IP_SESSION_KEY] != self.ip: 37 | self[IP_SESSION_KEY] = self.ip 38 | super().save(must_create) 39 | 40 | def create_model_instance(self, data): 41 | # Store User, User Agent, and IP in Session (in DB). 42 | return self.model( 43 | session_key=self._get_or_create_session_key(), 44 | session_data=self.encode(data), 45 | expire_date=self.get_expiry_date(), 46 | user_id=self.get(auth.SESSION_KEY), 47 | user_agent=self.user_agent, 48 | ip=self.ip, 49 | ) 50 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages, setup 4 | 5 | with open(os.path.join(os.path.dirname(__file__), "README.md"), encoding="UTF-8") as readme: 6 | README = readme.read() 7 | 8 | # allow setup.py to be run from any path 9 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 10 | 11 | dev_requirements = [ 12 | "pre-commit", 13 | "pytest>=7", 14 | "pytest-cov", 15 | "pytest-django", 16 | ] 17 | 18 | geoip_requirements = ["geoip2>=4.1.0"] 19 | 20 | setup( 21 | name="django-qsessions", 22 | version="2.1.0", 23 | description="Extended session backends for Django", 24 | long_description=README, 25 | long_description_content_type="text/markdown", 26 | author="Mohammad Javad Naderi", 27 | url="https://github.com/QueraTeam/django-qsessions", 28 | download_url="https://pypi.python.org/pypi/django-qsessions", 29 | license="MIT", 30 | packages=find_packages(".", include=("qsessions", "qsessions.*")), 31 | include_package_data=True, 32 | install_requires=["Django >= 4.2", "ua-parser[regex] >= 1.0.1"], 33 | extras_require={ 34 | "dev": dev_requirements + geoip_requirements, 35 | "geoip2": geoip_requirements, 36 | }, 37 | tests_require=dev_requirements, 38 | classifiers=[ 39 | "Development Status :: 5 - Production/Stable", 40 | "Environment :: Web Environment", 41 | "Framework :: Django", 42 | "Framework :: Django :: 4.2", 43 | "Framework :: Django :: 5.0", 44 | "Framework :: Django :: 5.1", 45 | "Framework :: Django :: 5.2", 46 | "Intended Audience :: Developers", 47 | "License :: OSI Approved :: MIT License", 48 | "Operating System :: OS Independent", 49 | "Programming Language :: Python", 50 | "Programming Language :: Python :: 3.9", 51 | "Programming Language :: Python :: 3.10", 52 | "Programming Language :: Python :: 3.11", 53 | "Programming Language :: Python :: 3.12", 54 | "Programming Language :: Python :: 3.13", 55 | "Topic :: Internet :: WWW/HTTP :: Session", 56 | "Topic :: Security", 57 | ], 58 | ) 59 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | from django.conf import settings 5 | from django.contrib.auth import SESSION_KEY 6 | from django.contrib.auth.models import User 7 | from django.urls import reverse 8 | 9 | from qsessions import USER_AGENT_SESSION_KEY 10 | from qsessions.models import Session 11 | 12 | 13 | @pytest.mark.django_db 14 | def test_unmodified_session(client): 15 | client.get("/", HTTP_USER_AGENT="TestUA/1.1") 16 | assert settings.SESSION_COOKIE_NAME not in client.cookies 17 | 18 | 19 | @pytest.mark.django_db 20 | @pytest.mark.parametrize("logged_in", (False, True)) 21 | def test_modify_session(client, logged_in): 22 | if logged_in: 23 | user = User.objects.create_superuser("user", "", "secret") 24 | client.force_login(user) 25 | else: 26 | user = None 27 | 28 | client.get("/read_session/", HTTP_USER_AGENT="TestUA/1.1") 29 | client.get("/modify_session/", HTTP_USER_AGENT="TestUA/1.1") 30 | data = json.loads(client.get("/read_session/", HTTP_USER_AGENT="TestUA/1.1").content.decode("UTF-8")) 31 | assert data["FOO"] == "BAR" 32 | assert data[USER_AGENT_SESSION_KEY] == "TestUA/1.1" 33 | if user: 34 | assert str(data[SESSION_KEY]) == str(user.id) 35 | 36 | assert settings.SESSION_COOKIE_NAME in client.cookies 37 | session = Session.objects.get(pk=client.cookies[settings.SESSION_COOKIE_NAME].value) 38 | assert session.user_agent == "TestUA/1.1" 39 | assert session.ip == "127.0.0.1" 40 | assert session.user == user 41 | 42 | 43 | @pytest.mark.django_db 44 | def test_login(client): 45 | admin_login_url = reverse("admin:login") 46 | user = User.objects.create_superuser("user", "", "secret") 47 | response = client.post( 48 | admin_login_url, 49 | data={"username": "user", "password": "secret", "this_is_the_login_form": "1", "next": "/admin/"}, 50 | HTTP_USER_AGENT="TestUA/1.1", 51 | ) 52 | assert response.url == "/admin/" 53 | session = Session.objects.get(pk=client.cookies[settings.SESSION_COOKIE_NAME].value) 54 | assert user == session.user 55 | 56 | 57 | @pytest.mark.django_db 58 | def test_long_ua(client): 59 | client.get("/modify_session/", HTTP_USER_AGENT="a" * 500) 60 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | permissions: 6 | contents: write 7 | checks: write 8 | pull-requests: write 9 | 10 | jobs: 11 | pre-commit: 12 | name: Run pre-commits 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-python@v5 17 | - uses: pre-commit/action@v3.0.1 18 | 19 | test: 20 | name: Test 21 | runs-on: ubuntu-latest 22 | strategy: 23 | matrix: 24 | # https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django 25 | python-version: ["3.12", "3.11", "3.10"] 26 | django-version: ["5.2", "5.1", "5.0", "4.2"] 27 | include: 28 | - python-version: "3.13" 29 | django-version: "5.2" 30 | - python-version: "3.13" 31 | django-version: "5.1" 32 | - python-version: "3.9" 33 | django-version: "4.2" 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Set up Python ${{ matrix.python-version }} 37 | uses: actions/setup-python@v5 38 | with: 39 | python-version: ${{ matrix.python-version }} 40 | - name: Install dependencies 41 | run: | 42 | python -m pip install --upgrade pip 43 | pip install django~=${{ matrix.django-version }}.0 44 | pip install -e ".[dev]" 45 | - name: Run tests 46 | run: | 47 | set -o pipefail 48 | py.test --cov --junitxml=pytest.xml --cov-report=term-missing:skip-covered | tee pytest-coverage.txt 49 | - if: ${{ strategy.job-index == 0 }} 50 | name: Pytest coverage comment 51 | id: coverageComment 52 | uses: MishaKav/pytest-coverage-comment@main 53 | with: 54 | pytest-coverage-path: ./pytest-coverage.txt 55 | junitxml-path: ./pytest.xml 56 | - if: ${{ strategy.job-index == 0 && github.ref == 'refs/heads/main' }} 57 | name: Create the coverage badge 58 | uses: schneegans/dynamic-badges-action@v1.7.0 59 | with: 60 | auth: ${{ secrets.CODECOVERAGE_GIST }} 61 | gistID: 24a6d63ff9d29d9be5399169f8199ca0 62 | filename: pytest-coverage__${{ github.ref_name }}.json 63 | label: coverage 64 | message: ${{ steps.coverageComment.outputs.coverage }} 65 | color: ${{ steps.coverageComment.outputs.color }} 66 | -------------------------------------------------------------------------------- /qsessions/management/commands/download_geoip_db.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import tarfile 4 | import urllib.request 5 | 6 | from django.conf import settings 7 | from django.core.management.base import BaseCommand 8 | from django.utils.http import urlencode 9 | 10 | 11 | class Command(BaseCommand): 12 | help = "Update GeoIP2 database" 13 | 14 | def add_arguments(self, parser): 15 | default_license_key = os.environ.get("MAXMIND_LICENSE_KEY") 16 | parser.add_argument( 17 | "-k", "--maxmind-license-key", default=default_license_key, required=(not default_license_key) 18 | ) 19 | 20 | def handle(self, maxmind_license_key, verbosity=0, **options): 21 | db_path = getattr(settings, "GEOIP_PATH", None) 22 | if not db_path: 23 | if verbosity >= 1: 24 | self.stderr.write("No GEOIP_PATH defined, not downloading database.") 25 | return 26 | 27 | if not os.path.exists(db_path): 28 | os.makedirs(db_path) 29 | 30 | for basename, url in [ 31 | ("GeoLite2-City.tar.gz", self.get_download_url("GeoLite2-City", maxmind_license_key)), 32 | ("GeoLite2-Country.tar.gz", self.get_download_url("GeoLite2-Country", maxmind_license_key)), 33 | ]: 34 | filename = os.path.join(db_path, basename) 35 | if verbosity >= 1: 36 | redacted_url = re.sub("license_key=([^&]+)", "license_key=...", url) 37 | self.stdout.write(f"Downloading and extracting {redacted_url}...") 38 | urllib.request.urlretrieve(url, filename) 39 | self.extract_tar(db_path, filename, verbosity) 40 | os.remove(filename) 41 | 42 | @staticmethod 43 | def get_download_url(edition_id, maxmind_license_key): 44 | return "https://download.maxmind.com/app/geoip_download?%s" % urlencode( 45 | {"edition_id": edition_id, "license_key": maxmind_license_key, "suffix": "tar.gz"} 46 | ) 47 | 48 | def extract_tar(self, db_path, tar_path, verbosity): 49 | with tarfile.open(tar_path) as tarball: 50 | for tarinfo in tarball: 51 | if tarinfo.name.endswith(".mmdb"): 52 | tarinfo.name = os.path.basename(tarinfo.name) 53 | tarball.extract(tarinfo, path=db_path) 54 | if verbosity >= 2: 55 | dest_path = os.path.join(db_path, tarinfo.name) 56 | self.stdout.write(f" => {dest_path}") 57 | -------------------------------------------------------------------------------- /tests/test_model.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.contrib import auth 3 | 4 | from qsessions import IP_SESSION_KEY, USER_AGENT_SESSION_KEY 5 | from qsessions.models import Session 6 | 7 | 8 | @pytest.mark.django_db 9 | def test_get_decoded(SessionStore, django_user_model): 10 | django_user_model.objects.create_user(username="test_user") 11 | 12 | store = SessionStore(user_agent="TestUA/1.1", ip="127.0.0.1") 13 | store[auth.SESSION_KEY] = 1 14 | store["foo"] = "bar" 15 | store.save() 16 | 17 | session = Session.objects.get(pk=store.session_key) 18 | assert session.get_decoded() == { 19 | "foo": "bar", 20 | auth.SESSION_KEY: 1, 21 | IP_SESSION_KEY: "127.0.0.1", 22 | USER_AGENT_SESSION_KEY: "TestUA/1.1", 23 | } 24 | 25 | 26 | @pytest.mark.django_db 27 | def test_very_long_ua(SessionStore): 28 | ua = ( 29 | "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0; ELT; " 30 | "BTRS29395; Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1) ; " 31 | "Embedded Web Browser from: http://bsalsa.com/; .NET CLR 2.0.50727; " 32 | ".NET CLR 3.0.04506.30; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022; " 33 | ".NET CLR 1.1.4322; ELT; .NET CLR 3.0.4506.2152; .NET CLR 3.5.30729; FDM; " 34 | ".NET4.0C; .NET4.0E; ELT)" 35 | ) 36 | store = SessionStore(user_agent=ua, ip="127.0.0.1") 37 | store.save() 38 | 39 | session = Session.objects.get(pk=store.session_key) 40 | assert session.user_agent == ua[:300] 41 | 42 | 43 | def test_location(): 44 | session = Session(ip="89.160.20.112") 45 | assert session.location == "Linköping, Sweden" 46 | loc_info = session.location_info 47 | assert loc_info["city"] == "Linköping" 48 | assert loc_info["country_code"] == "SE" 49 | 50 | # This depends on Django version, so be safe 51 | assert loc_info.get("continent_code") == "EU" or loc_info.get("region") == "E" 52 | 53 | 54 | def test_device(): 55 | session = Session( 56 | user_agent=( 57 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) " 58 | "AppleWebKit/537.36 (KHTML, like Gecko) " 59 | "Chrome/70.0.3538.102 Safari/537.36" 60 | ) 61 | ) 62 | device = session.device_info 63 | assert device.os.family == "Mac OS X" 64 | assert device.user_agent.family == "Chrome" 65 | 66 | 67 | @pytest.mark.django_db 68 | def test_delete(SessionStore): 69 | """ 70 | Session.delete should delete session from both DB and cache 71 | """ 72 | store = SessionStore(user_agent="TestUA/1.1", ip="127.0.0.1") 73 | store.create() 74 | session_key = store.session_key 75 | 76 | session = Session.objects.get(pk=session_key) 77 | session.delete() 78 | 79 | assert not store.exists(session_key) 80 | 81 | 82 | @pytest.mark.django_db 83 | def test_bulk_delete_from_both_cache_and_db(SessionStore): 84 | s1 = SessionStore(user_agent="Python/2.7", ip="127.0.0.1") 85 | s1.create() 86 | s2 = SessionStore(user_agent="Python/2.7", ip="127.0.0.1") 87 | s2.create() 88 | s3 = SessionStore(user_agent="TestUA/1.1", ip="127.0.0.1") 89 | s3.create() 90 | assert s1.exists(s1.session_key) 91 | assert s2.exists(s2.session_key) 92 | assert s3.exists(s3.session_key) 93 | 94 | Session.objects.filter(user_agent="Python/2.7").delete() 95 | 96 | assert not s1.exists(s1.session_key) 97 | assert not s2.exists(s2.session_key) 98 | assert s3.exists(s3.session_key) 99 | -------------------------------------------------------------------------------- /qsessions/admin.py: -------------------------------------------------------------------------------- 1 | from pprint import pformat 2 | 3 | from django.contrib import admin 4 | from django.contrib.auth import get_user_model 5 | from django.urls import reverse 6 | from django.utils.html import format_html 7 | from django.utils.timezone import now 8 | from django.utils.translation import gettext_lazy as _ 9 | 10 | from .models import Session 11 | 12 | 13 | def linkify(field_name): 14 | """ 15 | Converts a foreign key value into clickable links. 16 | """ 17 | 18 | def _linkify(obj): 19 | linked_obj = getattr(obj, field_name) 20 | if linked_obj is None: 21 | return "-" 22 | app_label = linked_obj._meta.app_label 23 | model_name = linked_obj._meta.model_name 24 | view_name = f"admin:{app_label}_{model_name}_change" 25 | link_url = reverse(view_name, args=[linked_obj.pk]) 26 | return format_html('{}', link_url, linked_obj) 27 | 28 | _linkify.short_description = field_name # Sets column name 29 | return _linkify 30 | 31 | 32 | class ExpiredFilter(admin.SimpleListFilter): 33 | title = _("Is Valid") 34 | parameter_name = "active" 35 | 36 | def lookups(self, request, model_admin): 37 | return [("1", _("Active")), ("0", _("Expired"))] 38 | 39 | def queryset(self, request, queryset): 40 | if self.value() == "1": 41 | return queryset.filter(expire_date__gt=now()) 42 | elif self.value() == "0": 43 | return queryset.filter(expire_date__lte=now()) 44 | 45 | 46 | class OwnerFilter(admin.SimpleListFilter): 47 | title = _("Owner") 48 | parameter_name = "owner" 49 | 50 | def lookups(self, request, model_admin): 51 | return [("my", _("Self"))] 52 | 53 | def queryset(self, request, queryset): 54 | if self.value() == "my": 55 | return queryset.filter(user=request.user) 56 | 57 | 58 | @admin.register(Session) 59 | class SessionAdmin(admin.ModelAdmin): 60 | list_display = ("ip", linkify("user"), "is_valid", "created_at", "expire_date", "device", "location") 61 | list_select_related = ("user",) 62 | list_filter = ExpiredFilter, OwnerFilter 63 | fields = ( 64 | "user", 65 | "ip", 66 | "location", 67 | "is_valid", 68 | "created_at", 69 | "updated_at", 70 | "expire_date", 71 | "user_agent", 72 | "device", 73 | "session_key", 74 | "session_data_decoded", 75 | ) 76 | ordering = ("-expire_date",) 77 | 78 | def get_search_fields(self, request): 79 | # noinspection PyPep8Naming 80 | User = get_user_model() 81 | return ( 82 | "ip", 83 | f"user__{getattr(User, 'USERNAME_FIELD', 'username')}", 84 | f"user__{getattr(User, 'USERNAME_EMAIL', 'email')}", 85 | ) 86 | 87 | @admin.display(description=_("Is valid"), boolean=True) 88 | def is_valid(self, obj: Session): 89 | return obj.expire_date > now() 90 | 91 | @admin.display(description=_("Session data")) 92 | def session_data_decoded(self, obj: Session): 93 | return format_html( 94 | '
{}
', 95 | pformat(obj.get_decoded()), 96 | ) 97 | 98 | def has_add_permission(self, request): 99 | return False 100 | 101 | def has_change_permission(self, request, obj=None): 102 | return False 103 | -------------------------------------------------------------------------------- /qsessions/models.py: -------------------------------------------------------------------------------- 1 | from functools import cached_property 2 | from importlib import import_module 3 | 4 | from django.conf import settings 5 | from django.contrib.sessions.base_session import AbstractBaseSession, BaseSessionManager 6 | from django.core.cache import caches 7 | from django.db import models 8 | from django.utils import timezone 9 | from django.utils.translation import gettext_lazy as _ 10 | 11 | import qsessions.geoip as geoip 12 | 13 | 14 | class SessionQuerySet(models.QuerySet): 15 | def delete(self): 16 | """ 17 | Delete sessions from both DB and cache (first cache, then DB) 18 | """ 19 | # noinspection PyPep8Naming 20 | SessionStore = Session.get_session_store_class() 21 | prefix = getattr(SessionStore, "cache_key_prefix", None) 22 | if prefix is not None: 23 | caches[settings.SESSION_CACHE_ALIAS].delete_many(prefix + s.session_key for s in self) 24 | return super().delete() 25 | 26 | 27 | class SessionManager(BaseSessionManager.from_queryset(SessionQuerySet)): 28 | use_in_migrations = True 29 | 30 | 31 | class Session(AbstractBaseSession): 32 | """ 33 | Session objects containing user session information. 34 | """ 35 | 36 | user = models.ForeignKey(getattr(settings, "AUTH_USER_MODEL", "auth.User"), null=True, on_delete=models.CASCADE) 37 | user_agent = models.CharField(null=True, blank=True, max_length=300) 38 | created_at = models.DateTimeField(null=True) 39 | updated_at = models.DateTimeField(auto_now=True) 40 | ip = models.GenericIPAddressField(null=True, blank=True, verbose_name=_("IP")) 41 | 42 | objects = SessionManager() 43 | 44 | @classmethod 45 | def get_session_store_class(cls): 46 | return import_module(settings.SESSION_ENGINE).SessionStore 47 | 48 | def save(self, *args, **kwargs): 49 | # FIXME: find a better solution for `created_at` field which does not need an extra query. 50 | # https://code.djangoproject.com/ticket/17654 51 | try: 52 | self.created_at = Session.objects.get(pk=self.pk).created_at 53 | except Session.DoesNotExist: 54 | self.created_at = timezone.now() 55 | super().save(*args, **kwargs) 56 | 57 | def delete(self, *args, **kwargs): 58 | """ 59 | Delete session from both DB and cache (first cache, then DB) 60 | """ 61 | # noinspection PyPep8Naming 62 | SessionStore = Session.get_session_store_class() 63 | prefix = getattr(SessionStore, "cache_key_prefix", None) 64 | if prefix is not None: 65 | caches[settings.SESSION_CACHE_ALIAS].delete(prefix + self.session_key) 66 | return super().delete(*args, **kwargs) 67 | 68 | @cached_property 69 | def location_info(self) -> dict: 70 | return geoip.ip_to_location_info(self.ip) 71 | 72 | @cached_property 73 | def location(self) -> str: 74 | return geoip.ip_to_location(self.ip) 75 | 76 | @cached_property 77 | def device_info(self): 78 | """ 79 | Describe the user agent of this session, if any 80 | :rtype: ua_parser.core.Result | None 81 | """ 82 | if self.user_agent: 83 | from ua_parser import parse # late import to avoid import cost 84 | 85 | return parse(self.user_agent) 86 | return None 87 | 88 | @cached_property 89 | def device(self) -> str: 90 | if device := self.device_info: 91 | 92 | def get_version_string(version_info): 93 | try: 94 | return ".".join(version_info[: version_info.index(None)]) 95 | except ValueError: 96 | return ".".join(version_info) 97 | 98 | return "{device} / {os} / {browser}".format( 99 | device=device.device.family if device.device else "Other", 100 | os=( 101 | f"{device.os.family} {get_version_string([device.os.major, device.os.minor, device.os.patch, device.os.patch_minor])}" 102 | if device.os 103 | else "Other" 104 | ), 105 | browser=( 106 | f"{device.user_agent.family} {get_version_string([device.user_agent.major, device.user_agent.minor, device.user_agent.patch, device.user_agent.patch_minor])}" 107 | if device.user_agent 108 | else "Other" 109 | ), 110 | ) 111 | return "" 112 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2.1.0 (Sep 21, 2025) 2 | 3 | - Add support for Django 5.2. 4 | - Make `geoip2` an optional dependency. It can be installed using the `geoip2` extra. 5 | 6 | # 2.0.0 (Feb 23, 2025) 7 | 8 | - Add support for Python 3.13 and drop support for Python 3.8. 9 | 10 | Backward-incompatible changes: 11 | 12 | - The IP is now read directly from the value of `REMOTE_ADDR` 13 | (instead of relying on **django-ipware**) 14 | for the same reason Django 15 | [removed](https://docs.djangoproject.com/en/5.2/releases/1.1/#removed-setremoteaddrfromforwardedfor-middleware) 16 | `SetRemoteAddrFromForwardedFor` middleware in 1.1. 17 | If you are using a reverse proxy, 18 | you should configure it 19 | to pass the real IP address in the `REMOTE_ADDR` header, 20 | or you can write a custom version of 21 | [`SetRemoteAddrFromForwardedFor` middleware](https://github.com/django/django/blob/91f18400cc0fb37659e2dbaab5484ff2081f1f30/django/middleware/http.py#L33) 22 | which suits your environment. 23 | - `session.location` and `session.location_info` are now properties 24 | instead of methods. 25 | - `session.device` is now a property instead of a method and returns string. 26 | - The device object can be accessed using the new property `session.device_info`. 27 | - User agent parsing is now done using `ua-parser` instead of `user-agents`. 28 | - The device object is now an instance of `ua_parser.core.Result` 29 | instead of `user_agents.parsers.UserAgent`. 30 | 31 | 32 | # 1.1.5 (Jun 22, 2024) 33 | 34 | - Add support for Python 3.12 and drop support for Python 3.7. 35 | - Add support for Django 4.2, 5.0 and drop support for Django 3.2, 4.1. 36 | - Fix a bug in admin ("add" and "edit" session). 37 | - **Nov 3, 2024:** Add support for Django 5.1. 38 | 39 | Thanks [@ataylor32](https://github.com/ataylor32), [@browniebroke](https://github.com/browniebroke) 40 | 41 | # 1.1.4 (Sep 11, 2022) 42 | 43 | - Add Django 4.1 support. 44 | - Drop support for Python 3.6. 45 | - Drop support for Django 2.2, 3.0, 3.1. 46 | 47 | Thanks [@akx](https://github.com/akx) 48 | 49 | # 1.1.3 (Dec 24, 2021) 50 | 51 | - Add Django 4.0 support. 52 | - Drop support for Django 1.11, 2.0, 2.1. 53 | 54 | # 1.1.2 (Oct 17, 2020) 55 | 56 | - Use gettext_lazy instead of ugettext_lazy. 57 | 58 | Thanks [@akx](https://github.com/akx) 59 | 60 | # 1.1.1 (Sep 10, 2020) 61 | 62 | - Set development status to Production/Stable in setup.py. 63 | 64 | # 1.1.0 (Sep 9, 2020) 65 | 66 | - Link to user in admin page. 67 | 68 | Thanks [@YazdanRa](https://github.com/YazdanRa) 69 | 70 | # 1.0.1 (Sep 9, 2020) 71 | 72 | - Fix N+1 problem in admin page by adding `user` to `select_related`. 73 | - Update MANIFEST.in 74 | 75 | Thanks [@jayvdb](https://github.com/jayvdb) 76 | 77 | # 1.0.0 (Aug 19, 2020) 78 | 79 | I think everything is OK for releasing `1.0.0` since django-qsessions is working fine in production for long time. 80 | 81 | - Drop support for Django 1.10. 82 | - Drop support for Python 3.5, since its end of life is near. Plus, maxminddb doesn't support 3.5 anymore. 83 | - Add Django 3.1 to support matrix. 84 | 85 | # 0.5.0 (Jul 2, 2020) 86 | 87 | - Drop support for Python 2. 88 | - Use `ipware.get_client_ip` instead of `ipware.ip.get_real_ip` (which is removed since `django-ipware==3.0.0`) 89 | - Format source code using [black](https://github.com/psf/black) 90 | 91 | Thanks [@sevdog](https://github.com/sevdog) 92 | 93 | # 0.4.1 (Jan 21, 2020) 94 | 95 | - Updated `download_geoip_db` management command to use new Maxmind download URLs, and provide license key. 96 | 97 | Thanks [@akx](https://github.com/akx) 98 | 99 | # 0.4.0 (Jan 21, 2020) 100 | 101 | - Added Django 3.0 to support matrix. 102 | - Removed Python 3.4 from support matrix. 103 | 104 | # 0.3.0 (Nov 2, 2019) 105 | 106 | - Added `qsessions.backends.db` session backend. 107 | 108 | Thanks [@willstott101](https://github.com/willstott101) 109 | 110 | # 0.2.1 (May 8, 2019) 111 | 112 | - Added support for Django 2.2. 113 | 114 | Thanks [@akx](https://github.com/akx) 115 | 116 | # 0.2.0 (Dec 25, 2018) 117 | 118 | - Added support for Python 3.7, Django 2.1. 119 | - Used pytest for testing. 120 | - Improved session delete performance (reduce number of queries) 121 | - Refactored codes 122 | 123 | Thanks [@akx](https://github.com/akx), [@saeed617](https://github.com/saeed617) 124 | 125 | # 0.1.6 (Jun 18, 2018) 126 | 127 | - Improve docs 128 | 129 | # 0.1.5 (May 15, 2018) 130 | 131 | - Fixed a bug when User Agent is an empty string 132 | 133 | # 0.1.4 (Feb 5, 2018) 134 | 135 | - Fixed migrations for `created_at` field. 136 | -------------------------------------------------------------------------------- /tests/test_sessionstore.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | import pytest 4 | from django.conf import settings 5 | from django.contrib import auth 6 | from django.contrib.sessions.backends.base import CreateError 7 | from django.utils.timezone import now 8 | 9 | from qsessions import IP_SESSION_KEY, USER_AGENT_SESSION_KEY 10 | from qsessions.models import Session 11 | 12 | 13 | @pytest.fixture(name="store") 14 | def setup_store(SessionStore): 15 | return SessionStore(user_agent="TestUA/1.1", ip="127.0.0.1") 16 | 17 | 18 | def test_untouched_init(store): 19 | assert store.modified is False 20 | assert store.accessed is False 21 | assert store.get("cat") is None 22 | 23 | 24 | def test_store(store): 25 | store["cat"] = "dog" 26 | assert store.accessed is True 27 | assert store.modified is True 28 | assert "cat" in store 29 | assert store.pop("cat") == "dog" 30 | assert "cat" not in store 31 | assert store.get("cat") is None 32 | 33 | 34 | def test_auth_session_key(store): 35 | assert auth.SESSION_KEY not in store 36 | assert store.modified is False 37 | assert store.accessed is True 38 | 39 | store.get(auth.SESSION_KEY) 40 | assert store.modified is False 41 | 42 | store[auth.SESSION_KEY] = 1 43 | assert store.modified is True 44 | 45 | 46 | @pytest.mark.django_db 47 | def test_save(store, django_user_model): 48 | django_user_model.objects.create_user(username="test_user") 49 | 50 | store[auth.SESSION_KEY] = 1 51 | store.save() 52 | 53 | session = Session.objects.get(pk=store.session_key) 54 | assert session.user_agent == "TestUA/1.1" 55 | assert session.ip == "127.0.0.1" 56 | assert session.user_id == 1 57 | assert now() - timedelta(seconds=5) <= session.updated_at <= now() 58 | 59 | 60 | @pytest.mark.django_db 61 | def test_load_unmodified(SessionStore, store, django_user_model): 62 | django_user_model.objects.create_user(username="test_user") 63 | 64 | store[auth.SESSION_KEY] = 1 65 | store.save() 66 | store2 = SessionStore(session_key=store.session_key, user_agent="TestUA/1.1", ip="127.0.0.1") 67 | store2.load() 68 | assert store2.get(USER_AGENT_SESSION_KEY) == "TestUA/1.1" 69 | assert store2.get(IP_SESSION_KEY) == "127.0.0.1" 70 | assert store2.get(auth.SESSION_KEY) == 1 71 | assert store2.modified is False 72 | 73 | 74 | @pytest.mark.django_db 75 | def test_load_modified(SessionStore, store, django_user_model): 76 | django_user_model.objects.create_user(username="test_user") 77 | 78 | store[auth.SESSION_KEY] = 1 79 | store.save() 80 | store2 = SessionStore(session_key=store.session_key, user_agent="TestUA/1.1-changed", ip="8.8.8.8") 81 | store2.load() 82 | assert store2.get(USER_AGENT_SESSION_KEY) == "TestUA/1.1" 83 | assert store2.get(IP_SESSION_KEY) == "127.0.0.1" 84 | assert store2.get(auth.SESSION_KEY) == 1 85 | assert store2.modified is True 86 | 87 | store2.save() 88 | 89 | assert store2.get(USER_AGENT_SESSION_KEY) == "TestUA/1.1-changed" 90 | assert store2.get(IP_SESSION_KEY) == "8.8.8.8" 91 | 92 | 93 | @pytest.mark.django_db 94 | def test_duplicate_create(SessionStore): 95 | s1 = SessionStore(session_key="DUPLICATE", user_agent="TestUA/1.1", ip="127.0.0.1") 96 | s1.create() 97 | s2 = SessionStore(session_key="DUPLICATE", user_agent="TestUA/1.1", ip="127.0.0.1") 98 | s2.create() 99 | assert s1.session_key != s2.session_key 100 | 101 | s3 = SessionStore(session_key=s1.session_key, user_agent="TestUA/1.1", ip="127.0.0.1") 102 | with pytest.raises(CreateError): 103 | s3.save(must_create=True) 104 | 105 | 106 | @pytest.mark.django_db 107 | def test_delete(store): 108 | # not persisted, should just return 109 | store.delete() 110 | 111 | # create, then delete 112 | store.create() 113 | session_key = store.session_key 114 | store.delete() 115 | 116 | # non-existing sessions, should not raise 117 | store.delete() 118 | store.delete(session_key) 119 | 120 | 121 | @pytest.mark.django_db 122 | def test_clear(store): 123 | """ 124 | Clearing the session should clear all non-browser information 125 | """ 126 | store[auth.SESSION_KEY] = 1 127 | store.clear() 128 | store.save() 129 | 130 | session = Session.objects.get(pk=store.session_key) 131 | assert session.user_id is None 132 | 133 | 134 | def test_import(SessionStore): 135 | if settings.SESSION_ENGINE.endswith(".cached_db"): 136 | from qsessions.backends.cached_db import SessionStore as CachedDBBackend 137 | 138 | assert issubclass(SessionStore, CachedDBBackend) 139 | elif settings.SESSION_ENGINE.endswith(".db"): 140 | from qsessions.backends.db import SessionStore as DBBackend 141 | 142 | assert issubclass(SessionStore, DBBackend) 143 | else: 144 | assert False, "Unrecognised Session Engine" 145 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django QSessions 2 | 3 | [![pypi](https://img.shields.io/pypi/v/django-qsessions.svg)](https://pypi.python.org/pypi/django-qsessions/) 4 | [![tests ci](https://github.com/QueraTeam/django-qsessions/workflows/tests/badge.svg)](https://github.com/QueraTeam/django-qsessions/actions) 5 | [![coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/quera-org/24a6d63ff9d29d9be5399169f8199ca0/raw/pytest-coverage__main.json)](https://github.com/QueraTeam/django-qsessions/actions) 6 | [![MIT](https://img.shields.io/github/license/QueraTeam/django-qsessions.svg)](https://github.com/QueraTeam/django-qsessions/blob/master/LICENSE.txt) 7 | [![black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 8 | 9 | **django-qsessions** offers two extended session backends for Django. 10 | They extend Django's `db` and `cached_db` backends (and `Session` model) 11 | with following extra features: 12 | 13 | - Sessions have a foreign key to User 14 | - Sessions store IP and User Agent 15 | 16 | These features help you implement "Session Management" and show a list 17 | of active sessions to the user. You can display IP, location and user 18 | agent for each session and add an option to revoke sessions. 19 | 20 | ## Comparison 21 | 22 | Here is a brief comparison between Django's session backends (db, cache, 23 | cached_db), and django-qsessions. 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 |
djangoqsessions
cachedbcached_dbdbcached_db
Performance✔✔
Persistence
Foreign Key to User
Store IP and User Agent
75 | 76 | ## Compatibility 77 | 78 | - Python: **3.9**, **3.10**, **3.11**, **3.12**, **3.13** 79 | - Django: **4.2**, **5.0**, **5.1**, **5.2** 80 | 81 | ## Installation 82 | 83 | If your system is in production and there are active sessions using 84 | another session backend, you need to migrate them manually. We have no 85 | migration script. 86 | 87 | 1. If you want to use the `cached_db` backend, make sure you've 88 | [configured your 89 | cache](https://docs.djangoproject.com/en/dev/topics/cache/). If you 90 | have multiple caches defined in `CACHES`, Django will use the 91 | default cache. To use another cache, set `SESSION_CACHE_ALIAS` to 92 | the name of that cache. 93 | 94 | 2. Install the latest version from PyPI: 95 | 96 | ```sh 97 | pip install django-qsessions 98 | ``` 99 | 100 | 3. In settings: 101 | 102 | - In `INSTALLED_APPS` replace `'django.contrib.sessions'` with 103 | `'qsessions'`. 104 | - In `MIDDLEWARE` or `MIDDLEWARE_CLASSES` replace 105 | `'django.contrib.sessions.middleware.SessionMiddleware'` with 106 | `'qsessions.middleware.SessionMiddleware'`. 107 | - Set `SESSION_ENGINE` to: 108 | - `'qsessions.backends.cached_db'` if you want to use 109 | `cached_db` backend. 110 | - `'qsessions.backends.db'` if you want to use `db` backend. 111 | 112 | 4. Run migrations to create `qsessions.models.Session` model. 113 | 114 | ```sh 115 | python manage.py migrate qsessions 116 | ``` 117 | 118 | ### Use GeoIP2 (optional) 119 | 120 | To enable location detection using GeoIP2, you'll need to follow a few extra steps: 121 | 122 | 1. Install `django-qsessions` with the `geoip2` extra: 123 | 124 | ```sh 125 | pip install "django-qsessions[geoip2]" 126 | ``` 127 | 128 | 2. Set `GEOIP_PATH` to a directory in Django settings for storing GeoIP2 129 | database. 130 | 131 | 3. Run the following command to download the latest GeoIP2 database. You 132 | can add this command to a cron job to update the GeoIP2 DB 133 | automatically. Due to [Maxmind license 134 | changes](https://blog.maxmind.com/2019/12/18/significant-changes-to-accessing-and-using-geolite2-databases/), 135 | you will need to acquire and use a license key for downloading the 136 | databases. You can pass the key on the command line or in the 137 | `MAXMIND_LICENSE_KEY` environment variable. 138 | 139 | ```sh 140 | python manage.py download_geoip_db -k mykey 141 | ``` 142 | 143 | ## Usage 144 | 145 | django-qsessions has a custom `Session` model with following extra 146 | fields: `user`, `user_agent`, `created_at`, `updated_at`, `ip`. 147 | 148 | Get a user's sessions: 149 | 150 | ```python 151 | user.session_set.filter(expire_date__gt=timezone.now()) 152 | ``` 153 | 154 | Delete a session: 155 | 156 | ```python 157 | # Deletes the session from both the database and the cache. 158 | session.delete() 159 | ``` 160 | 161 | Logout a user: 162 | 163 | ```python 164 | user.session_set.all().delete() 165 | ``` 166 | 167 | Get session creation time (user login time): 168 | 169 | ```python 170 | >>> session.created_at 171 | datetime.datetime(2018, 6, 12, 17, 9, 17, 443909, tzinfo=) 172 | ``` 173 | 174 | Get IP and user agent: 175 | 176 | ```python 177 | >>> session.ip 178 | '127.0.0.1' 179 | >>> session.user_agent 180 | 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36' 181 | ``` 182 | 183 | Get user device (parsed user-agent string): 184 | 185 | ```python 186 | >>> session.device 187 | 'K / Android 10 / Chrome Mobile 118.0.0.0' 188 | >>> session.device_info.device 189 | Device(family='K', brand='Generic_Android', model='K') 190 | >>> session.device_info.os 191 | OS(family='Android', major='10', minor=None, patch=None, patch_minor=None) 192 | >>> session.device_info.user_agent 193 | UserAgent(family='Chrome Mobile', major='118', minor='0', patch='0', patch_minor='0') 194 | ``` 195 | 196 | 197 | And if you have configured GeoIP2, 198 | you can get location info using `.location` and `.location_info`: 199 | 200 | ```python 201 | >>> session.location 202 | 'Tehran, Iran' 203 | 204 | >>> session.location_info 205 | {'city': 'Tehran', 'continent_code': 'AS', 'continent_name': 'Asia', 'country_code': 'IR', 'country_name': 'Iran', 'time_zone': 'Asia/Tehran', ...} 206 | ``` 207 | 208 | Admin page: 209 | 210 | ![image](https://user-images.githubusercontent.com/2115303/41525284-b0b258b0-72f5-11e8-87f1-8770e0094f4c.png) 211 | 212 | ### Caveats 213 | 214 | - `session.updated_at` is not the session's exact last activity. It's 215 | updated each time the session object is saved in DB. (e.g. when user 216 | logs in, or when ip, user agent, or session data changes) 217 | - The IP address is directly read from `request.META["REMOTE_ADDR"]`. 218 | If you are using a reverse proxy, 219 | you should configure it 220 | to pass the real IP address in the `REMOTE_ADDR` header. 221 | You can also write a custom middleware 222 | to set `REMOTE_ADDR` from the value of other headers 223 | (`X-Forwarded-For`, `X-Real-IP`, ...) 224 | in a safe way suitable for your environment. 225 | More info: [Why Django removed SetRemoteAddrFromForwardedFor](https://docs.djangoproject.com/en/5.2/releases/1.1/#removed-setremoteaddrfromforwardedfor-middleware). 226 | 227 | ## Development 228 | 229 | - Create and activate a python virtualenv. 230 | - Install development dependencies in your virtualenv with `pip install -e '.[dev]'` 231 | - Install pre-commit hooks with `pre-commit install` 232 | - Run tests with coverage: 233 | - `py.test --cov` 234 | 235 | ## TODO 236 | 237 | - Write better documentation. 238 | - Explain how it works (in summary) 239 | - Add more details to existing documentation. 240 | - Write more tests 241 | - Performance benchmark (and compare with Django's `cached_db`) 242 | 243 | Contributions are welcome! 244 | 245 | ## License 246 | 247 | MIT 248 | --------------------------------------------------------------------------------