├── docs
├── logo
├── _static
│ ├── favicon.ico
│ └── django-sesame.svg
├── requirements.txt
├── tutorial
│ ├── auth_links
│ │ ├── add_booking.png
│ │ ├── share_booking.png
│ │ ├── view_booking.png
│ │ ├── share_booking.html
│ │ ├── views_decorator.py
│ │ ├── admin.py
│ │ ├── views_generic_decorator.py
│ │ ├── views.py
│ │ ├── models.py
│ │ └── views_generic.py
│ └── email_login
│ │ ├── email_login.png
│ │ ├── forms.py
│ │ ├── email_login_success.png
│ │ ├── email_login_success.html
│ │ ├── email_login.html
│ │ └── views.py
├── spelling_wordlist.txt
├── Makefile
├── make.bat
├── contributing.rst
├── faq.rst
├── conf.py
├── reference.rst
├── changelog.rst
├── index.rst
├── topics.rst
└── howto.rst
├── tests
├── __init__.py
├── views.py
├── models.py
├── urls.py
├── settings.py
├── test_settings.py
├── mixins.py
├── test_views.py
├── test_backends.py
├── test_tokens.py
├── test_decorators.py
├── test_utils.py
├── test_packers.py
├── test_middleware.py
├── test_tokens_v1.py
└── test_tokens_v2.py
├── src
└── sesame
│ ├── __init__.py
│ ├── tokens.py
│ ├── backends.py
│ ├── utils.py
│ ├── decorators.py
│ ├── views.py
│ ├── middleware.py
│ ├── settings.py
│ ├── tokens_v1.py
│ ├── packers.py
│ └── tokens_v2.py
├── logo
├── favicon.ico
├── github-social-preview.png
├── icon.html
├── github-social-preview.html
├── icon.svg
├── vertical.svg
└── horizontal.svg
├── .gitignore
├── .readthedocs.yaml
├── Makefile
├── tox.ini
├── LICENSE
├── pyproject.toml
└── README.rst
/docs/logo:
--------------------------------------------------------------------------------
1 | ../logo
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/sesame/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/_static/favicon.ico:
--------------------------------------------------------------------------------
1 | ../../logo/favicon.ico
--------------------------------------------------------------------------------
/docs/_static/django-sesame.svg:
--------------------------------------------------------------------------------
1 | ../../logo/vertical.svg
--------------------------------------------------------------------------------
/logo/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aaugustin/django-sesame/HEAD/logo/favicon.ico
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | .coverage
3 | .tox
4 | dist
5 | docs/_build
6 | htmlcov
7 | poetry.lock
8 |
--------------------------------------------------------------------------------
/docs/requirements.txt:
--------------------------------------------------------------------------------
1 | django
2 | furo
3 | sphinx
4 | sphinx-autobuild
5 | sphinx-copybutton
6 |
--------------------------------------------------------------------------------
/logo/github-social-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aaugustin/django-sesame/HEAD/logo/github-social-preview.png
--------------------------------------------------------------------------------
/docs/tutorial/auth_links/add_booking.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aaugustin/django-sesame/HEAD/docs/tutorial/auth_links/add_booking.png
--------------------------------------------------------------------------------
/docs/tutorial/auth_links/share_booking.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aaugustin/django-sesame/HEAD/docs/tutorial/auth_links/share_booking.png
--------------------------------------------------------------------------------
/docs/tutorial/auth_links/view_booking.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aaugustin/django-sesame/HEAD/docs/tutorial/auth_links/view_booking.png
--------------------------------------------------------------------------------
/docs/tutorial/email_login/email_login.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aaugustin/django-sesame/HEAD/docs/tutorial/email_login/email_login.png
--------------------------------------------------------------------------------
/docs/tutorial/email_login/forms.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 |
3 | class EmailLoginForm(forms.Form):
4 | email = forms.EmailField()
5 |
--------------------------------------------------------------------------------
/docs/tutorial/email_login/email_login_success.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aaugustin/django-sesame/HEAD/docs/tutorial/email_login/email_login_success.png
--------------------------------------------------------------------------------
/docs/spelling_wordlist.txt:
--------------------------------------------------------------------------------
1 | backend
2 | backends
3 | changelog
4 | django
5 | frictionless
6 | hashers
7 | lifecycle
8 | middleware
9 | mixin
10 | referer
11 | suboptimal
12 | ua
13 | websockets
14 |
--------------------------------------------------------------------------------
/.readthedocs.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | build:
4 | os: ubuntu-lts-latest
5 | tools:
6 | python: latest
7 |
8 | sphinx:
9 | configuration: docs/conf.py
10 |
11 | python:
12 | install:
13 | - requirements: docs/requirements.txt
14 |
--------------------------------------------------------------------------------
/docs/tutorial/email_login/email_login_success.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Log in
5 |
6 |
7 | We sent a log in link. Check your email.
8 |
9 |
10 |
--------------------------------------------------------------------------------
/docs/tutorial/auth_links/share_booking.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ booking.name }}
5 |
6 |
7 | {{ booking.name }}
8 | Booked by {{ booking.customer }}
9 |
10 |
11 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | style:
2 | ruff format src tests
3 | ruff check --fix src tests
4 |
5 | test:
6 | python -m django test --settings=tests.settings
7 |
8 | coverage:
9 | coverage erase
10 | coverage run -m django test --settings=tests.settings
11 | coverage html
12 |
13 | clean:
14 | rm -rf .coverage dist docs/_build htmlcov src/django_sesame.egg-info
15 |
--------------------------------------------------------------------------------
/docs/tutorial/auth_links/views_decorator.py:
--------------------------------------------------------------------------------
1 | from django.shortcuts import get_object_or_404, render
2 |
3 | from sesame.decorators import authenticate
4 |
5 | @authenticate(scope="booking:{pk}")
6 | def share_booking(request, pk):
7 | booking = get_object_or_404(request.user.booking_set, pk=pk)
8 | return render(request, "bookings/share_booking.html", {"booking": booking})
9 |
--------------------------------------------------------------------------------
/docs/tutorial/auth_links/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.utils.html import format_html
3 |
4 | from .models import Booking
5 |
6 | @admin.register(Booking)
7 | class BookingAdmin(admin.ModelAdmin):
8 | list_display = ["id", "name", "customer", "private_sharing_link"]
9 |
10 | def private_sharing_link(self, obj):
11 | return format_html('Link', obj.get_private_sharing_link())
12 |
--------------------------------------------------------------------------------
/docs/tutorial/auth_links/views_generic_decorator.py:
--------------------------------------------------------------------------------
1 | from django.utils.decorators import method_decorator
2 | from django.views.generic import DetailView
3 |
4 | from sesame.decorators import authenticate
5 |
6 | @method_decorator(authenticate(scope="booking:{pk}"), name="dispatch")
7 | class ShareBooking(DetailView):
8 |
9 | template_name = "bookings/share_booking.html"
10 |
11 | def get_queryset(self):
12 | return self.request.user.booking_set
13 |
--------------------------------------------------------------------------------
/docs/tutorial/auth_links/views.py:
--------------------------------------------------------------------------------
1 | from django.core.exceptions import PermissionDenied
2 | from django.shortcuts import get_object_or_404, render
3 |
4 | import sesame.utils
5 |
6 | def share_booking(request, pk):
7 | customer = sesame.utils.get_user(request, scope=f"booking:{pk}")
8 | if customer is None:
9 | raise PermissionDenied
10 | booking = get_object_or_404(customer.booking_set, pk=pk)
11 | return render(request, "bookings/share_booking.html", {"booking": booking})
12 |
--------------------------------------------------------------------------------
/tests/views.py:
--------------------------------------------------------------------------------
1 | from django.http import HttpResponse
2 | from django.template import engines
3 |
4 |
5 | def show_user(request, *args, **kwargs):
6 | content = (
7 | engines["django"]
8 | .from_string(
9 | "{% if user.is_authenticated %}{{ user }}"
10 | "{% elif user.is_anonymous %}anonymous"
11 | "{% else %}no user"
12 | "{% endif %}"
13 | )
14 | .render(request=request)
15 | )
16 | return HttpResponse(content, content_type="text/plain")
17 |
--------------------------------------------------------------------------------
/docs/tutorial/auth_links/models.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.db import models
3 | from django.urls import reverse
4 |
5 | from sesame.utils import get_query_string
6 |
7 | class Booking(models.Model):
8 | name = models.CharField(max_length=100)
9 | customer = models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
10 |
11 | def get_private_sharing_link(self):
12 | link = reverse("share-booking", args=(self.pk,))
13 | link += get_query_string(user=self.customer, scope=f"booking:{self.pk}")
14 | return link
15 |
--------------------------------------------------------------------------------
/docs/tutorial/email_login/email_login.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Log in
5 |
6 |
7 | {% if request.user.is_authenticated %}
8 | You are already logged in as {{ request.user.email }}.
9 | {% endif %}
10 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/docs/tutorial/auth_links/views_generic.py:
--------------------------------------------------------------------------------
1 | from django.core.exceptions import PermissionDenied
2 | from django.views.generic import DetailView
3 |
4 | import sesame.utils
5 |
6 | class ShareBooking(DetailView):
7 |
8 | template_name = "bookings/share_booking.html"
9 |
10 | def get(self, request, pk):
11 | self.customer = sesame.utils.get_user(request, scope=f"booking:{pk}")
12 | if self.customer is None:
13 | raise PermissionDenied
14 | return super().get(request, pk=pk)
15 |
16 | def get_queryset(self):
17 | return self.customer.booking_set
18 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | isolated_build = true
3 | envlist =
4 | py39-django42
5 | py310-django50
6 | py312-django51
7 | py313-django52
8 | ruff
9 |
10 | [testenv]
11 | deps =
12 | django42: Django>=4.2,<4.3
13 | django50: Django>=5.0,<5.1
14 | django51: Django>=5.1,<5.2
15 | django52: Django>=5.2,<5.3
16 | extras =
17 | ua
18 | commands =
19 | python -W error::ResourceWarning -W error::DeprecationWarning -W error::PendingDeprecationWarning -m django test --settings=tests.settings
20 |
21 | [testenv:ruff]
22 | commands =
23 | ruff format --check src tests
24 | ruff check src tests
25 | deps =
26 | ruff
27 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line, and also
5 | # from the environment for the first two.
6 | SPHINXOPTS ?=
7 | SPHINXBUILD ?= sphinx-build
8 | SOURCEDIR = .
9 | BUILDDIR = _build
10 |
11 | # Put it first so that "make" without argument is like "make help".
12 | help:
13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 |
15 | .PHONY: help Makefile
16 |
17 | # Catch-all target: route all unknown targets to Sphinx using the new
18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19 | %: Makefile
20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
21 |
22 | livehtml:
23 | sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
24 |
--------------------------------------------------------------------------------
/tests/models.py:
--------------------------------------------------------------------------------
1 | import uuid
2 |
3 | from django.contrib.auth import models as auth_models
4 | from django.db import models
5 |
6 |
7 | class BigAutoUser(auth_models.AbstractBaseUser):
8 | id = models.BigAutoField(primary_key=True)
9 | username = models.CharField(max_length=32, unique=True)
10 |
11 | USERNAME_FIELD = "username"
12 |
13 |
14 | class UUIDUser(auth_models.AbstractBaseUser):
15 | id = models.UUIDField(default=uuid.uuid4, primary_key=True)
16 | username = models.CharField(max_length=32, unique=True)
17 |
18 | USERNAME_FIELD = "username"
19 |
20 |
21 | class BooleanUser(auth_models.AbstractBaseUser):
22 | username = models.BooleanField(primary_key=True) # pathological!
23 |
24 | USERNAME_FIELD = "username"
25 |
26 |
27 | class StrUser(auth_models.AbstractBaseUser):
28 | username = models.CharField(primary_key=True, max_length=24)
29 |
30 | USERNAME_FIELD = "username"
31 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | pushd %~dp0
4 |
5 | REM Command file for Sphinx documentation
6 |
7 | if "%SPHINXBUILD%" == "" (
8 | set SPHINXBUILD=sphinx-build
9 | )
10 | set SOURCEDIR=.
11 | set BUILDDIR=_build
12 |
13 | %SPHINXBUILD% >NUL 2>NUL
14 | if errorlevel 9009 (
15 | echo.
16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
17 | echo.installed, then set the SPHINXBUILD environment variable to point
18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you
19 | echo.may add the Sphinx directory to PATH.
20 | echo.
21 | echo.If you don't have Sphinx installed, grab it from
22 | echo.https://www.sphinx-doc.org/
23 | exit /b 1
24 | )
25 |
26 | if "%1" == "" goto help
27 |
28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
29 | goto end
30 |
31 | :help
32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
33 |
34 | :end
35 | popd
36 |
--------------------------------------------------------------------------------
/src/sesame/tokens.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from . import settings
4 |
5 | logger = logging.getLogger("sesame")
6 |
7 | __all__ = ["create_token", "parse_token"]
8 |
9 |
10 | def create_token(user, scope=""):
11 | """
12 | Create a signed token for a user and an optional scope.
13 |
14 | """
15 | tokens = settings.TOKENS[0]
16 | return tokens.create_token(user, scope)
17 |
18 |
19 | def parse_token(token, get_user, scope="", max_age=None):
20 | """
21 | Obtain a user from a signed token and an optional scope.
22 |
23 | """
24 | for tokens in settings.TOKENS:
25 | # We can detect the version of a token simply by inspecting it:
26 | # v1 tokens contain a colon; v2 tokens don't.
27 | if tokens.detect_token(token):
28 | return tokens.parse_token(token, get_user, scope, max_age)
29 | else:
30 | logger.debug("Bad token: doesn't match a supported format")
31 | return None
32 |
--------------------------------------------------------------------------------
/tests/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path, re_path
2 |
3 | from sesame.decorators import authenticate
4 | from sesame.views import LoginView
5 |
6 | from .views import show_user
7 |
8 | urlpatterns = [
9 | # For test_decorators.TestAuthenticate
10 | path("authenticate/", authenticate(show_user)),
11 | path("authenticate/not_required/", authenticate(required=False)(show_user)),
12 | path("authenticate/permanent/", authenticate(permanent=True)(show_user)),
13 | path("authenticate/no_override/", authenticate(override=False)(show_user)),
14 | path("authenticate/scope/", authenticate(scope="scope")(show_user)),
15 | re_path(
16 | r"authenticate/scope/arg/([a-z]+)/",
17 | authenticate(scope="arg:{}")(show_user),
18 | ),
19 | re_path(
20 | r"authenticate/scope/kwarg/(?P[a-z]+)/",
21 | authenticate(scope="kwarg:{kwarg}")(show_user),
22 | ),
23 | # For test_views.TestLoginView
24 | path("login/", LoginView.as_view()),
25 | path("login/no_redirect/", LoginView.as_view(next_page=None)),
26 | # For test_middleware.TestMiddleware
27 | re_path("", show_user), # catchall pattern
28 | ]
29 |
--------------------------------------------------------------------------------
/logo/icon.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Icon
5 |
13 |
14 |
15 | Take a screenshot of these DOM nodes to2x make a PNG.
16 | 
17 | 
18 | 
19 | 
20 | 
21 | 
22 | 
23 | 
24 |
25 |
26 |
--------------------------------------------------------------------------------
/docs/contributing.rst:
--------------------------------------------------------------------------------
1 | Contributor guide
2 | =================
3 |
4 | Develop
5 | -------
6 |
7 | Prepare a development environment:
8 |
9 | * Install Poetry_.
10 | * Run ``poetry install --extras ua``.
11 | * Run ``poetry shell`` to load the development environment.
12 |
13 | Make changes:
14 |
15 | * Make changes to the code, tests, or docs.
16 | * Run ``make style`` and fix errors.
17 | * Run ``make test`` or ``make coverage`` to run the set suite — it's fast!
18 |
19 | Iterate until you're happy.
20 |
21 | Check quality and submit your changes:
22 |
23 | * Install tox_.
24 | * Run ``tox`` to test across Python and Django versions — it's slower.
25 | * Submit a pull request.
26 |
27 | .. _Poetry: https://python-poetry.org/
28 | .. _tox: https://tox.readthedocs.io/
29 |
30 | Release
31 | -------
32 |
33 | Check that the changelog is complete and add the date of the release.
34 |
35 | Increment version number X.Y in ``docs/conf.py`` and ``pyproject.toml``.
36 |
37 | Commit, tag, and push the change:
38 |
39 | .. code-block:: console
40 |
41 | $ git commit -m "Bump version number".
42 | $ git tag X.Y
43 | $ git push
44 | $ git push --tags
45 |
46 | Build and publish the new version:
47 |
48 | .. code-block:: console
49 |
50 | $ poetry build
51 | $ poetry publish
52 |
--------------------------------------------------------------------------------
/tests/settings.py:
--------------------------------------------------------------------------------
1 | # Include the sesame backend first to avoid bogus database queries caused by
2 | # https://code.djangoproject.com/ticket/30556 and simplify assertNumQueries.
3 |
4 | AUTHENTICATION_BACKENDS = [
5 | "sesame.backends.ModelBackend",
6 | "django.contrib.auth.backends.ModelBackend",
7 | ]
8 |
9 | CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
10 |
11 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3"}}
12 |
13 | INSTALLED_APPS = [
14 | "django.contrib.auth",
15 | "django.contrib.contenttypes",
16 | "tests",
17 | ]
18 |
19 | LOGIN_REDIRECT_URL = "/login/redirect/url/"
20 |
21 | MIDDLEWARE = [
22 | "django.contrib.sessions.middleware.SessionMiddleware",
23 | "django.contrib.auth.middleware.AuthenticationMiddleware",
24 | ]
25 |
26 | PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
27 |
28 | ROOT_URLCONF = "tests.urls"
29 |
30 | SECRET_KEY = "Anyone who finds a URL will be able to log in. Seriously."
31 |
32 | SESSION_ENGINE = "django.contrib.sessions.backends.cache"
33 |
34 | TEMPLATES = [
35 | {
36 | "BACKEND": "django.template.backends.django.DjangoTemplates",
37 | "OPTIONS": {
38 | "context_processors": ["django.contrib.auth.context_processors.auth"]
39 | },
40 | }
41 | ]
42 |
43 | USE_TZ = True
44 |
--------------------------------------------------------------------------------
/tests/test_settings.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from django.core.exceptions import ImproperlyConfigured
4 | from django.test import TestCase, override_settings
5 |
6 | from sesame import settings
7 |
8 |
9 | class TestSettings(TestCase):
10 | @override_settings(SESAME_MAX_AGE=datetime.timedelta(minutes=5))
11 | def test_max_age_timedelta(self):
12 | self.assertEqual(settings.MAX_AGE, 300)
13 |
14 | @override_settings(
15 | SESAME_INVALIDATE_ON_PASSWORD_CHANGE=False,
16 | SESAME_MAX_AGE=None,
17 | )
18 | def test_insecure_configuration(self):
19 | with self.assertRaises(ImproperlyConfigured) as exc:
20 | settings.check()
21 | self.assertEqual(
22 | str(exc.exception),
23 | "insecure configuration: set SESAME_MAX_AGE to a low value "
24 | "or set SESAME_INVALIDATE_ON_PASSWORD_CHANGE to True",
25 | )
26 |
27 | @override_settings(
28 | SESAME_INVALIDATE_ON_EMAIL_CHANGE=True,
29 | AUTH_USER_MODEL="tests.StrUser",
30 | )
31 | def test_invalid_configuration(self):
32 | with self.assertRaises(ImproperlyConfigured) as exc:
33 | settings.check()
34 | self.assertEqual(
35 | str(exc.exception),
36 | "invalid configuration: set User.EMAIL_FIELD correctly "
37 | "or set SESAME_INVALIDATE_ON_EMAIL_CHANGE to False",
38 | )
39 |
--------------------------------------------------------------------------------
/logo/github-social-preview.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | GitHub social preview
5 |
36 |
37 |
38 | Take a screenshot of this DOM node to make a PNG.
39 | For 2x DPI screens.
40 | 
41 | For regular screens.
42 | 
43 |
44 |
45 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) Aymeric Augustin and contributors.
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without
5 | modification, are permitted provided that the following conditions are met:
6 |
7 | * Redistributions of source code must retain the above copyright notice,
8 | this list of conditions and the following disclaimer.
9 | * Redistributions in binary form must reproduce the above copyright notice,
10 | this list of conditions and the following disclaimer in the documentation
11 | and/or other materials provided with the distribution.
12 | * Neither the name of django-sesame nor the names of its contributors may
13 | be used to endorse or promote products derived from this software without
14 | specific prior written permission.
15 |
16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
17 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26 |
--------------------------------------------------------------------------------
/tests/mixins.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import io
3 | import logging
4 | import unittest
5 |
6 | from django.contrib.auth import get_user_model
7 | from django.test import TestCase
8 | from django.utils import timezone
9 |
10 |
11 | class CreateUserMixin(TestCase):
12 | def setUp(self):
13 | super().setUp()
14 | self.user = self.create_user()
15 |
16 | def create_user(self, username="john", password="letmein", **kwargs):
17 | User = get_user_model()
18 | user = User(
19 | username=username,
20 | last_login=timezone.now() - datetime.timedelta(seconds=3600),
21 | **kwargs,
22 | )
23 | user.set_password(password)
24 | user.save()
25 | return user
26 |
27 | @staticmethod
28 | def get_user(user_id):
29 | User = get_user_model()
30 | return User.objects.filter(pk=user_id).first()
31 |
32 |
33 | class CaptureLogMixin(unittest.TestCase):
34 | logger_name = "sesame"
35 |
36 | def setUp(self):
37 | super().setUp()
38 | self.buffer = io.StringIO()
39 | self.handler = logging.StreamHandler(self.buffer)
40 | self.logger = logging.getLogger(self.logger_name)
41 | self.logger.addHandler(self.handler)
42 | self.logger.setLevel(logging.DEBUG)
43 |
44 | @property
45 | def logs(self):
46 | self.handler.flush()
47 | return self.buffer.getvalue()
48 |
49 | def assertNoLogs(self):
50 | self.assertEqual(self.logs, "")
51 |
52 | def assertLogsContain(self, message):
53 | self.assertIn(message, self.logs)
54 |
55 | def tearDown(self):
56 | self.logger.removeHandler(self.handler)
57 | super().tearDown()
58 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [build-system]
2 | requires = ["poetry-core>=1.0.0"]
3 | build-backend = "poetry.core.masonry.api"
4 |
5 | [tool.coverage.run]
6 | branch = true
7 | source = ["sesame", "tests"]
8 |
9 | [tool.poetry]
10 | name = "django-sesame"
11 | version = "3.2.3"
12 | description = """\
13 | Frictionless authentication with "Magic Links" \
14 | for your Django project."""
15 | license = "BSD-3-Clause"
16 | authors = ["Aymeric Augustin "]
17 | readme = "README.rst"
18 | repository = "https://github.com/aaugustin/django-sesame"
19 | documentation = "https://django-sesame.readthedocs.io/"
20 | keywords = ["authentication", "token-based-authentication"]
21 | classifiers = [
22 | "Development Status :: 5 - Production/Stable",
23 | "Environment :: Web Environment",
24 | "Framework :: Django",
25 | "Framework :: Django :: 4.2",
26 | "Framework :: Django :: 5.0",
27 | "Framework :: Django :: 5.1",
28 | "Framework :: Django :: 5.2",
29 | "Intended Audience :: Developers",
30 | "Operating System :: OS Independent",
31 | ]
32 | packages = [
33 | { include = "sesame", from = "src" },
34 | ]
35 |
36 | [tool.poetry.dependencies]
37 | django = ">=4.2"
38 | python = ">=3.9"
39 | ua-parser = { version = ">=0.15", optional = true }
40 |
41 | [tool.poetry.dev-dependencies]
42 | coverage = "*"
43 | furo = "*"
44 | sphinx = "*"
45 | sphinx-autobuild = "*"
46 | sphinx-copybutton = "*"
47 | sphinxcontrib-spelling = { version = "*", optional = true }
48 | ruff = "*"
49 | toml = "*"
50 |
51 | [tool.poetry.extras]
52 | ua = ["ua-parser"]
53 |
54 | [tool.ruff.lint]
55 | select = [
56 | "E", # pycodestyle
57 | "F", # Pyflakes
58 | "W", # pycodestyle
59 | "I", # isort
60 | ]
61 |
--------------------------------------------------------------------------------
/docs/tutorial/email_login/views.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth import get_user_model
2 | from django.shortcuts import render
3 | from django.urls import reverse
4 | from django.views.generic import FormView
5 | from sesame.utils import get_query_string
6 |
7 | import sesame.utils
8 |
9 | from .forms import EmailLoginForm
10 |
11 | class EmailLoginView(FormView):
12 | template_name = "email_login.html"
13 | form_class = EmailLoginForm
14 |
15 | def get_user(self, email):
16 | """Find the user with this email address."""
17 | User = get_user_model()
18 | try:
19 | return User.objects.get(email=email)
20 | except User.DoesNotExist:
21 | return None
22 |
23 | def create_link(self, user):
24 | """Create a login link for this user."""
25 | link = reverse("login")
26 | link = self.request.build_absolute_uri(link)
27 | link += get_query_string(user)
28 | return link
29 |
30 | def send_email(self, user, link):
31 | """Send an email with this login link to this user."""
32 | user.email_user(
33 | subject="[django-sesame] Log in to our app",
34 | message=f"""\
35 | Hello,
36 |
37 | You requested that we send you a link to log in to our app:
38 |
39 | {link}
40 |
41 | Thank you for using django-sesame!
42 | """,
43 | )
44 |
45 | def email_submitted(self, email):
46 | user = self.get_user(email)
47 | if user is None:
48 | # Ignore the case when no user is registered with this address.
49 | # Possible improvement: send an email telling them to register.
50 | print("user not found:", email)
51 | return
52 | link = self.create_link(user)
53 | self.send_email(user, link)
54 |
55 | def form_valid(self, form):
56 | self.email_submitted(form.cleaned_data["email"])
57 | return render(self.request, "email_login_success.html")
58 |
--------------------------------------------------------------------------------
/tests/test_views.py:
--------------------------------------------------------------------------------
1 | import http
2 |
3 | from django.contrib.auth.models import AnonymousUser
4 | from django.core.exceptions import ImproperlyConfigured
5 | from django.test import TestCase
6 | from django.test.utils import override_settings
7 |
8 | from sesame.utils import get_parameters
9 |
10 | from .mixins import CreateUserMixin
11 |
12 |
13 | class TestLoginView(CreateUserMixin, TestCase):
14 | def test_success(self):
15 | params = get_parameters(self.user)
16 | response = self.client.get("/login/", params)
17 | self.assertEqual(response.wsgi_request.user, self.user)
18 | self.assertRedirects(response, "/login/redirect/url/")
19 |
20 | def test_success_no_redirect(self):
21 | params = get_parameters(self.user)
22 | response = self.client.get("/login/no_redirect/", params)
23 | self.assertEqual(response.wsgi_request.user, self.user)
24 | self.assertEqual(response.status_code, http.HTTPStatus.NO_CONTENT)
25 |
26 | def test_failure_missing_token(self):
27 | response = self.client.get("/login/")
28 | self.assertIsInstance(response.wsgi_request.user, AnonymousUser)
29 | self.assertEqual(response.status_code, http.HTTPStatus.FORBIDDEN)
30 |
31 | def test_failure_invalid_token(self):
32 | params = get_parameters(self.user)
33 | params["sesame"] = params["sesame"].lower()
34 | response = self.client.get("/login/", params)
35 | self.assertIsInstance(response.wsgi_request.user, AnonymousUser)
36 | self.assertEqual(response.status_code, http.HTTPStatus.FORBIDDEN)
37 |
38 | @override_settings(
39 | MIDDLEWARE=[
40 | "django.contrib.sessions.middleware.SessionMiddleware",
41 | ],
42 | )
43 | def test_without_auth_middleware(self):
44 | params = get_parameters(self.user)
45 | with self.assertRaises(ImproperlyConfigured) as exc:
46 | self.client.get("/login/", params)
47 | self.assertEqual(
48 | str(exc.exception),
49 | "LoginView requires django.contrib.auth",
50 | )
51 |
--------------------------------------------------------------------------------
/tests/test_backends.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase, override_settings
2 |
3 | from sesame.backends import ModelBackend
4 | from sesame.tokens import create_token
5 |
6 | from .mixins import CaptureLogMixin, CreateUserMixin
7 |
8 |
9 | class TestModelBackend(CaptureLogMixin, CreateUserMixin, TestCase):
10 | def test_token(self):
11 | token = create_token(self.user)
12 | user = ModelBackend().authenticate(request=None, sesame=token)
13 | self.assertEqual(user, self.user)
14 | self.assertLogsContain("Valid token for user john in default scope")
15 |
16 | @override_settings(SESAME_MAX_AGE=300)
17 | def test_token_with_max_age(self):
18 | token = create_token(self.user)
19 | user = ModelBackend().authenticate(request=None, sesame=token)
20 | self.assertEqual(user, self.user)
21 | self.assertLogsContain("Valid token for user john in default scope")
22 |
23 | def test_no_token(self):
24 | token = None
25 | user = ModelBackend().authenticate(request=None, sesame=token)
26 | self.assertIsNone(user)
27 | self.assertNoLogs()
28 |
29 | def test_emtpy_token(self):
30 | token = ""
31 | user = ModelBackend().authenticate(request=None, sesame=token)
32 | self.assertIsNone(user)
33 | self.assertLogsContain("Bad token")
34 |
35 | def test_bad_token(self):
36 | token = "~!@#$%^&*~!@#$%^&*~"
37 | user = ModelBackend().authenticate(request=None, sesame=token)
38 | self.assertIsNone(user)
39 | self.assertLogsContain("Bad token")
40 |
41 | def test_inactive_user(self):
42 | self.user.is_active = False
43 | self.user.save()
44 | token = create_token(self.user)
45 | user = ModelBackend().authenticate(request=None, sesame=token)
46 | self.assertIsNone(user)
47 | self.assertLogsContain("Unknown or inactive user")
48 |
49 | def test_scoped_token(self):
50 | token = create_token(self.user, scope="test")
51 | user = ModelBackend().authenticate(request=None, sesame=token, scope="test")
52 | self.assertEqual(user, self.user)
53 | self.assertLogsContain("Valid token for user john in scope test")
54 |
55 | @override_settings(SESAME_MAX_AGE=300)
56 | def test_token_with_max_age_override(self):
57 | token = create_token(self.user)
58 | user = ModelBackend().authenticate(request=None, sesame=token, max_age=-300)
59 | self.assertIsNone(user)
60 | self.assertLogsContain("Expired token")
61 |
--------------------------------------------------------------------------------
/src/sesame/backends.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth import backends as auth_backends
2 | from django.contrib.auth import get_user_model
3 |
4 | from . import settings
5 | from .tokens import parse_token
6 |
7 | __all__ = ["ModelBackend", "SesameBackendMixin"]
8 |
9 |
10 | class SesameBackendMixin:
11 | """
12 | Mix this class in an authentication backend providing ``get_user(user_id)``
13 | to create an authentication backend usable with django-sesame.
14 |
15 | """
16 |
17 | def authenticate(self, request, sesame, scope="", max_age=None):
18 | """
19 | If ``sesame`` is a valid token, return the user. Else, return :obj:`None`.
20 |
21 | If ``scope`` is set, a :ref:`scoped token ` is expected.
22 |
23 | If ``max_age`` is set, override the :data:`SESAME_MAX_AGE` setting.
24 |
25 | ``request`` is an :class:`~django.http.HttpRequest` or :obj:`None`.
26 |
27 | """
28 | # This check shouldn't be necessary, but it can avoid problems like
29 | # issue #37 and Django's built-in backends include similar checks.
30 | if sesame is None:
31 | return None
32 | return parse_token(sesame, self.get_user, scope, max_age)
33 |
34 |
35 | class ModelBackend(SesameBackendMixin, auth_backends.ModelBackend):
36 | """
37 | Authentication backend that authenticates users with django-sesame tokens.
38 |
39 | It inherits :class:`SesameBackendMixin` and Django's built-in
40 | :class:`~django.contrib.auth.backends.ModelBackend`.
41 |
42 | Extending the default value of the :setting:`AUTHENTICATION_BACKENDS` setting
43 | to add ``"sesame.backends.ModelBackend"`` looks like:
44 |
45 | .. code-block:: python
46 |
47 | AUTHENTICATION_BACKENDS = [
48 | "django.contrib.auth.backends.ModelBackend",
49 | "sesame.backends.ModelBackend",
50 | ]
51 |
52 | """
53 |
54 | def get_user(self, user_id):
55 | """
56 | Fetch user from the database by primary key.
57 |
58 | The field used by django-sesame as a primary key can be configured with
59 | the :data:`SESAME_PRIMARY_KEY_FIELD` setting.
60 |
61 | Return :obj:`None` if no active user is found.
62 |
63 | """
64 | User = get_user_model()
65 | try:
66 | user = User._default_manager.get(**{settings.PRIMARY_KEY_FIELD: user_id})
67 | except User.DoesNotExist:
68 | return None
69 | if self.user_can_authenticate(user):
70 | return user
71 | else:
72 | return None
73 |
--------------------------------------------------------------------------------
/logo/icon.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/tests/test_tokens.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase, override_settings
2 |
3 | from sesame import tokens_v1, tokens_v2
4 | from sesame.tokens import create_token, parse_token
5 |
6 | from .mixins import CaptureLogMixin, CreateUserMixin
7 |
8 |
9 | class TestUtils(CaptureLogMixin, CreateUserMixin, TestCase):
10 | def test_create_token_default_v2(self):
11 | token = create_token(self.user)
12 | self.assertTrue(tokens_v2.detect_token(token))
13 | self.assertFalse(tokens_v1.detect_token(token))
14 |
15 | @override_settings(SESAME_TOKENS=["sesame.tokens_v2"])
16 | def test_create_token_force_v2(self):
17 | token = create_token(self.user)
18 | self.assertTrue(tokens_v2.detect_token(token))
19 | self.assertFalse(tokens_v1.detect_token(token))
20 |
21 | @override_settings(SESAME_TOKENS=["sesame.tokens_v1"])
22 | def test_create_token_force_v1(self):
23 | token = create_token(self.user)
24 | self.assertTrue(tokens_v1.detect_token(token))
25 | self.assertFalse(tokens_v2.detect_token(token))
26 |
27 | @override_settings(SESAME_TOKENS=["sesame.tokens_v1", "sesame.tokens_v2"])
28 | def test_create_token_use_first_choice(self):
29 | token = create_token(self.user)
30 | self.assertTrue(tokens_v1.detect_token(token))
31 | self.assertFalse(tokens_v2.detect_token(token))
32 |
33 | def test_parse_token_accepts_v2(self):
34 | token = create_token(self.user)
35 | user = parse_token(token, self.get_user)
36 | self.assertEqual(user, self.user)
37 | self.assertLogsContain("Valid token for user john")
38 |
39 | def test_parse_token_accepts_v1(self):
40 | with override_settings(SESAME_TOKENS=["sesame.tokens_v1"]):
41 | token = create_token(self.user)
42 | user = parse_token(token, self.get_user)
43 | self.assertEqual(user, self.user)
44 | self.assertLogsContain("Valid token for user john")
45 |
46 | @override_settings(SESAME_TOKENS=["sesame.tokens_v2"])
47 | def test_parse_token_force_v2(self):
48 | with override_settings(SESAME_TOKENS=["sesame.tokens_v1"]):
49 | token = create_token(self.user)
50 | user = parse_token(token, self.get_user)
51 | self.assertIsNone(user)
52 | self.assertLogsContain("Bad token: doesn't match a supported format")
53 |
54 | @override_settings(SESAME_TOKENS=["sesame.tokens_v1"])
55 | def test_parse_token_force_v1(self):
56 | with override_settings(SESAME_TOKENS=["sesame.tokens_v2"]):
57 | token = create_token(self.user)
58 | user = parse_token(token, self.get_user)
59 | self.assertIsNone(user)
60 | self.assertLogsContain("Bad token: doesn't match a supported format")
61 |
--------------------------------------------------------------------------------
/docs/faq.rst:
--------------------------------------------------------------------------------
1 | Frequent questions
2 | ==================
3 |
4 | How do I understand why a token is invalid?
5 | -------------------------------------------
6 |
7 | Enable debug logs by setting the ``sesame`` logger to the ``DEBUG`` level.
8 |
9 | .. code-block:: python
10 |
11 | import logging
12 | logger = logging.getLogger("sesame")
13 | logger.setLevel(logging.DEBUG)
14 | logger.addHandler(logging.StreamHandler())
15 |
16 | Then you should get a hint in logs.
17 |
18 | Depending on how logging is set up in your project, there may by another way
19 | to enable this configuration.
20 |
21 | Why does upgrading Django invalidate tokens?
22 | --------------------------------------------
23 |
24 | As a security measure, django-sesame invalidates tokens when users change their
25 | password.
26 |
27 | Each release of Django increases the work factor of password hashers. After
28 | deploying a new version of Django, when a user logs in with their password,
29 | Django upgrades the password hash.
30 |
31 | From the perspective of django-sesame, this is indistinguishable from changing
32 | their password.
33 |
34 | Indeed, by design, django-sesame relies exclusively on data available in the
35 | user model: ``pk``, ``password`` (hashed), and ``last_login``. When ``password``
36 | changes, django-sesame cannot tell if the password was changed or if the hash
37 | was upgraded.
38 |
39 | That's how tokens become invalid.
40 |
41 | This problem occurs only when a user logs in alternatively with a long-lived
42 | token and with a password. If you're in this situation, you should regenerate
43 | and redistribute tokens after upgrading Django.
44 |
45 | Alternatively, you may set :data:`SESAME_INVALIDATE_ON_PASSWORD_CHANGE` to
46 | :obj:`False` to disable token invalidation on password change. Think through
47 | security ramifications before doing this, especially if tokens are long lived.
48 |
49 | Why do all tokens start with AAAA...?
50 | -------------------------------------
51 |
52 | This is the Base64 encoding of an integer storing a small value.
53 |
54 | By default, Django uses integers as primary keys for users, starting from 1.
55 | These primary keys are included in tokens, which are encoded with Base64.
56 |
57 | When the primary key of the user model is an
58 | :class:`~django.db.models.AutoField`, as long as you have less that one million
59 | users, all tokens start with AA.
60 |
61 | Why do one-time tokens sent by email fail?
62 | ------------------------------------------
63 |
64 | Email providers may fetch links found emails to provide previews or for security
65 | purposes. If the link contains a one-time token, this will invalidate the token.
66 |
67 | To avoid this, you can configure a short :data:`SESAME_MAX_AGE` instead of
68 | enabling :data:`SESAME_ONE_TIME`.
69 |
70 | Is django-sesame usable without passwords?
71 | ------------------------------------------
72 |
73 | Yes, it is.
74 |
75 | You should call :meth:`~django.contrib.auth.models.User.set_unusable_password`
76 | when you create users.
77 |
78 | Is django-sesame compatible with custom user models?
79 | ----------------------------------------------------
80 |
81 | Yes, it is.
82 |
83 | It requires ``password`` and ``last_login`` fields. These are provided by
84 | :class:`~django.contrib.auth.models.AbstractBaseUser`, the recommended base
85 | class for custom user models.
86 |
87 | Is django-sesame compatible with Django REST framework?
88 | -------------------------------------------------------
89 |
90 | Yes, it is.
91 |
92 | However, you should favor Django REST framework's built-in
93 | |TokenAuthentication|__ or recommended alternatives.
94 |
95 | .. |TokenAuthentication| replace:: ``TokenAuthentication``
96 | __ https://www.django-rest-framework.org/api-guide/authentication/#tokenauthentication
97 |
--------------------------------------------------------------------------------
/src/sesame/utils.py:
--------------------------------------------------------------------------------
1 | from urllib.parse import urlencode
2 |
3 | from django.contrib.auth import authenticate
4 | from django.utils import timezone
5 |
6 | from . import settings
7 | from .tokens import create_token
8 |
9 | __all__ = ["get_token", "get_parameters", "get_query_string", "get_user"]
10 |
11 |
12 | def get_token(user, scope=""):
13 | """
14 | Generate a signed token to authenticate ``user``.
15 |
16 | Set ``scope`` to create a :ref:`scoped token `.
17 |
18 | Use this function to create a token that :func:`get_user` will accept.
19 |
20 | """
21 | return create_token(user, scope)
22 |
23 |
24 | def get_parameters(user, scope=""):
25 | """
26 | Generate a :class:`dict` of query string parameters to authenticate ``user``.
27 |
28 | Set ``scope`` to create a :ref:`scoped token `.
29 |
30 | Use this function to add authentication to a URL that already contains a
31 | query string.
32 |
33 | """
34 | return {settings.TOKEN_NAME: create_token(user, scope)}
35 |
36 |
37 | def get_query_string(user, scope=""):
38 | """
39 | Generate a complete query string to authenticate ``user``.
40 |
41 | Set ``scope`` to create a :ref:`scoped token `.
42 |
43 | Use this function to add authentication to a URL that doesn't contain a
44 | query string.
45 |
46 | """
47 | return "?" + urlencode({settings.TOKEN_NAME: create_token(user, scope)})
48 |
49 |
50 | def get_user(request_or_sesame, scope="", max_age=None, *, update_last_login=None):
51 | """
52 | Authenticate a user based on a signed token.
53 |
54 | ``request_or_sesame`` may be a :class:`~django.http.HttpRequest` containing
55 | a token in the URL or the token itself, created with :func:`get_token`. The
56 | latter supports use cases outside the HTTP request lifecycle.
57 |
58 | If a valid token is found, return the user. Else, return :obj:`None`.
59 |
60 | :func:`get_user` doesn't log the user in permanently.
61 |
62 | If ``scope`` is set, a :ref:`scoped token ` is expected.
63 |
64 | If ``max_age`` is set, override the :data:`SESAME_MAX_AGE` setting.
65 |
66 | If single-use tokens are enabled, :func:`get_user` invalidates the token by
67 | updating the user's last login date.
68 |
69 | Set ``update_last_login`` to :obj:`True` or :obj:`False` to always or never
70 | update the user's last login date, regardless of whether single-use tokens
71 | are enabled. Typically, if you're going to log the user in with
72 | :func:`~django.contrib.auth.login`, set ``update_last_login`` to
73 | :obj:`False` to avoid updating the last login date twice.
74 |
75 | """
76 | if isinstance(request_or_sesame, str):
77 | request = None
78 | sesame = request_or_sesame
79 | else:
80 | # request is expected to be a django.http.HttpRequest
81 | request = request_or_sesame
82 | try:
83 | sesame = request_or_sesame.GET.get(settings.TOKEN_NAME)
84 | except Exception:
85 | raise TypeError("get_user() expects an HTTPRequest or a token")
86 | if sesame is None:
87 | return None
88 |
89 | # Call authenticate() to set user.backend to the value expected by login(),
90 | # "sesame.backends.ModelBackend" or the dotted path to a subclass.
91 | user = authenticate(
92 | request,
93 | sesame=sesame,
94 | scope=scope,
95 | max_age=max_age,
96 | )
97 | if user is None:
98 | return None
99 |
100 | if update_last_login is None:
101 | update_last_login = settings.ONE_TIME
102 | if update_last_login:
103 | user.last_login = timezone.now()
104 | user.save(update_fields=["last_login"])
105 |
106 | return user
107 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # Configuration file for the Sphinx documentation builder.
2 | #
3 | # This file only contains a selection of the most common options. For a full
4 | # list see the documentation:
5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html
6 |
7 | import datetime
8 | import os.path
9 | import sys
10 |
11 | import django.conf
12 |
13 | # -- Path setup --------------------------------------------------------------
14 |
15 | # If extensions (or modules to document with autodoc) are in another directory,
16 | # add these directories to sys.path here. If the directory is relative to the
17 | # documentation root, use os.path.abspath to make it absolute, like shown here.
18 | sys.path.insert(0, os.path.join(os.path.abspath(".."), "src"))
19 |
20 |
21 | # -- Django setup ------------------------------------------------------------
22 |
23 | django.conf.settings.configure(
24 | INSTALLED_APPS=["django.contrib.auth", "django.contrib.contenttypes"],
25 | SECRET_KEY="Anyone who finds a URL will be able to log in. Seriously.",
26 | )
27 | django.setup()
28 |
29 |
30 | # -- Project information -----------------------------------------------------
31 |
32 | project = "django-sesame"
33 | copyright = f"2012-{datetime.date.today().year}, Aymeric Augustin and contributors"
34 | author = "Aymeric Augustin"
35 |
36 | # The full version, including alpha/beta/rc tags
37 | release = "3.2.3"
38 |
39 |
40 | # -- General configuration ---------------------------------------------------
41 |
42 | # Add any Sphinx extension module names here, as strings. They can be
43 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
44 | # ones.
45 | extensions = [
46 | "sphinx.ext.autodoc",
47 | "sphinx.ext.autosectionlabel",
48 | "sphinx.ext.intersphinx",
49 | "sphinx_copybutton",
50 | ]
51 | if "spelling" in sys.argv:
52 | extensions.append("sphinxcontrib.spelling")
53 |
54 | intersphinx_mapping = {
55 | "django": (
56 | "https://docs.djangoproject.com/en/stable/",
57 | "https://docs.djangoproject.com/en/stable/_objects/"
58 | ),
59 | "python": (
60 | "https://docs.python.org/3",
61 | None,
62 | ),
63 | }
64 |
65 | # Copied from docs/_ext/djangodocs.py in Django.
66 | def setup(app):
67 | app.add_crossref_type(
68 | directivename="setting",
69 | rolename="setting",
70 | indextemplate="pair: %s; setting",
71 | )
72 |
73 | # Add any paths that contain templates here, relative to this directory.
74 | templates_path = ["_templates"]
75 |
76 | # List of patterns, relative to source directory, that match files and
77 | # directories to ignore when looking for source files.
78 | # This pattern also affects html_static_path and html_extra_path.
79 | exclude_patterns = ["_build"]
80 |
81 |
82 | # -- Options for HTML output -------------------------------------------------
83 |
84 | # The theme to use for HTML and HTML Help pages. See the documentation for
85 | # a list of builtin themes.
86 | html_theme = "furo"
87 |
88 | html_theme_options = {
89 | "light_css_variables": {
90 | "color-brand-primary": "#2b8c67", # green from logo
91 | "color-brand-content": "#0c4b33", # darker green
92 | },
93 | "dark_css_variables": {
94 | "color-brand-primary": "#2b8c67", # green from logo
95 | "color-brand-content": "#c9f0dd", # lighter green
96 | },
97 | "sidebar_hide_name": True,
98 | }
99 |
100 | html_logo = "_static/django-sesame.svg"
101 |
102 | html_favicon = "_static/favicon.ico"
103 |
104 | # Add any paths that contain custom static files (such as style sheets) here,
105 | # relative to this directory. They are copied after the builtin static files,
106 | # so a file named "default.css" will overwrite the builtin "default.css".
107 | html_static_path = ["_static"]
108 |
--------------------------------------------------------------------------------
/src/sesame/decorators.py:
--------------------------------------------------------------------------------
1 | import functools
2 |
3 | from django.contrib.auth import login
4 | from django.contrib.auth.models import AnonymousUser
5 | from django.core.exceptions import ImproperlyConfigured, PermissionDenied
6 |
7 | from .utils import get_user
8 |
9 | __all__ = ["authenticate"]
10 |
11 |
12 | def authenticate(
13 | view=None,
14 | scope="",
15 | max_age=None,
16 | *,
17 | required=True,
18 | permanent=False,
19 | override=True,
20 | ):
21 | """
22 | Decorator that looks for a signed token in the URL and authenticates a user.
23 |
24 | If a valid token is found, the user is available as ``request.user`` in the
25 | view. Else, :exc:`~django.core.exceptions.PermissionDenied` is raised,
26 | resulting in an HTTP 403 Forbidden error.
27 |
28 | :obj:`authenticate` may be applied to a view directly::
29 |
30 | @authenticate
31 | def view(request):
32 | ...
33 |
34 | or with arguments::
35 |
36 | @authenticate(scope="status-page")
37 | def view(request):
38 | ...
39 |
40 | If ``scope`` is set, a :ref:`scoped token ` is expected.
41 |
42 | If ``max_age`` is set, override the :data:`SESAME_MAX_AGE` setting.
43 |
44 | Set ``required`` to :obj:`False` to set ``request.user`` to an
45 | :class:`~django.contrib.auth.models.AnonymousUser` and execute the view when
46 | the token is invalid, instead of raising an exception. Then, you can check
47 | if ``request.user.is_authenticated``.
48 |
49 | :obj:`authenticate` doesn't log the user in permanently. It is intended to
50 | provide direct access to a specific resource without exposing all other
51 | private resources. This makes it more acceptable to use the less secure
52 | authentication mechanism provided by django-sesame.
53 |
54 | Set ``permanent`` to :obj:`True` to call :func:`~django.contrib.auth.login`
55 | after a user is authenticated.
56 |
57 | :obj:`authenticate` doesn't care if a user is already logged in. It looks
58 | for a signed token anyway and overrides ``request.user``.
59 |
60 | Set ``override`` to :obj:`False` to skip authentication if a user is already
61 | logged in.
62 |
63 | """
64 | if view is None:
65 | return functools.partial(
66 | authenticate,
67 | scope=scope,
68 | max_age=max_age,
69 | required=required,
70 | permanent=permanent,
71 | override=override,
72 | )
73 |
74 | @functools.wraps(view)
75 | def wrapper(request, *args, **kwargs):
76 | # Skip when a user is already logged in, unless override is enabled.
77 | if hasattr(request, "user") and request.user.is_authenticated and not override:
78 | return view(request, *args, **kwargs)
79 |
80 | if permanent and not hasattr(request, "session"):
81 | raise ImproperlyConfigured(
82 | "authenticate(permanent=True) requires django.contrib.sessions"
83 | )
84 | # If the user will be logged in, don't update the last login, because
85 | # login(request, user) will do it. Else, keep the default behavior of
86 | # updating last login only for one-time tokens.
87 | user = get_user(
88 | request,
89 | update_last_login=False if permanent else None,
90 | scope=scope.format(*args, **kwargs),
91 | max_age=max_age,
92 | )
93 |
94 | request.user = user if user is not None else AnonymousUser()
95 |
96 | if required and user is None:
97 | raise PermissionDenied
98 |
99 | if permanent and user is not None:
100 | login(request, user) # updates the last login date
101 |
102 | return view(request, *args, **kwargs)
103 |
104 | return wrapper
105 |
--------------------------------------------------------------------------------
/src/sesame/views.py:
--------------------------------------------------------------------------------
1 | from http import HTTPStatus
2 |
3 | from django.conf import settings as django_settings
4 | from django.contrib.auth import REDIRECT_FIELD_NAME, authenticate, login
5 | from django.core.exceptions import ImproperlyConfigured, PermissionDenied
6 | from django.http import HttpResponse, HttpResponseRedirect
7 | from django.shortcuts import resolve_url
8 | from django.utils.http import url_has_allowed_host_and_scheme # private API
9 | from django.views.generic import View
10 |
11 | from . import settings
12 |
13 | try:
14 | from django.contrib.auth.views import RedirectURLMixin # private API
15 | except ImportError: # Django < 4.1
16 | # Added to Django in https://github.com/django/django/pull/15608
17 | class RedirectURLMixin:
18 | next_page = None
19 | redirect_field_name = REDIRECT_FIELD_NAME
20 | success_url_allowed_hosts = set()
21 |
22 | def get_success_url(self):
23 | return self.get_redirect_url() or self.get_default_redirect_url()
24 |
25 | def get_redirect_url(self):
26 | """Return the user-originating redirect URL if it's safe."""
27 | redirect_to = self.request.POST.get(
28 | self.redirect_field_name, self.request.GET.get(self.redirect_field_name)
29 | )
30 | url_is_safe = url_has_allowed_host_and_scheme(
31 | url=redirect_to,
32 | allowed_hosts=self.get_success_url_allowed_hosts(),
33 | require_https=self.request.is_secure(),
34 | )
35 | return redirect_to if url_is_safe else ""
36 |
37 | def get_success_url_allowed_hosts(self):
38 | return {self.request.get_host(), *self.success_url_allowed_hosts}
39 |
40 | def get_default_redirect_url(self):
41 | """Return the default redirect URL."""
42 | if self.next_page:
43 | return resolve_url(self.next_page)
44 | else: # pragma: no cover
45 | raise ImproperlyConfigured("no URL to redirect to; provide a next_page")
46 |
47 |
48 | __all__ = ["LoginView"]
49 |
50 |
51 | class LoginView(RedirectURLMixin, View):
52 | """
53 | Look for a signed token in the URL of a GET request and log a user in.
54 |
55 | If a valid token is found, the user is redirected to the URL specified in
56 | the ``next`` query string parameter or the ``next_page`` attribute of the
57 | view. ``next_page`` defaults to :setting:`LOGIN_REDIRECT_URL`.
58 |
59 | If a ``scope`` attribute is set, a :ref:`scoped token ` is
60 | expected.
61 |
62 | If a ``max_age`` attribute is set, override the :data:`SESAME_MAX_AGE`
63 | setting.
64 |
65 | In addition to ``next_page``, :class:`LoginView` also supports
66 | ``redirect_field_name``, ``success_url_allowed_hosts``, and
67 | ``get_default_redirect_url()``. These APIs behave like their counterparts
68 | in Django's built-in :class:`~django.contrib.auth.views.LoginView`.
69 |
70 | """
71 |
72 | scope = ""
73 | max_age = None
74 | next_page = django_settings.LOGIN_REDIRECT_URL
75 |
76 | def get(self, request):
77 | if not hasattr(request, "user"):
78 | raise ImproperlyConfigured("LoginView requires django.contrib.auth")
79 |
80 | sesame = request.GET.get(settings.TOKEN_NAME)
81 | if sesame is None:
82 | return self.login_failed()
83 |
84 | user = authenticate(
85 | request,
86 | sesame=sesame,
87 | scope=self.scope,
88 | max_age=self.max_age,
89 | )
90 | if user is None:
91 | return self.login_failed()
92 |
93 | login(request, user) # updates the last login date
94 |
95 | return self.login_success()
96 |
97 | def login_failed(self):
98 | raise PermissionDenied
99 |
100 | def login_success(self):
101 | if self.next_page is None:
102 | return HttpResponse(status=HTTPStatus.NO_CONTENT)
103 | else:
104 | return HttpResponseRedirect(self.get_success_url())
105 |
--------------------------------------------------------------------------------
/tests/test_decorators.py:
--------------------------------------------------------------------------------
1 | import http
2 |
3 | from django.contrib.auth import get_user
4 | from django.contrib.auth.models import AnonymousUser
5 | from django.core.exceptions import ImproperlyConfigured
6 | from django.test import TestCase
7 | from django.test.utils import override_settings
8 |
9 | from sesame.utils import get_parameters
10 |
11 | from .mixins import CreateUserMixin
12 |
13 |
14 | class TestAuthenticate(CreateUserMixin, TestCase):
15 | def test_success(self):
16 | params = get_parameters(self.user)
17 | response = self.client.get("/authenticate/", params)
18 | # User is logged in with django.contrib.auth.
19 | self.assertEqual(response.wsgi_request.user, self.user)
20 | self.assertContains(response, self.user.username)
21 |
22 | def test_default_required(self):
23 | response = self.client.get("/authenticate/")
24 | # User isn't logged in with django.contrib.auth.
25 | self.assertIsInstance(response.wsgi_request.user, AnonymousUser)
26 | self.assertEqual(response.status_code, http.HTTPStatus.FORBIDDEN)
27 |
28 | def test_not_required(self):
29 | response = self.client.get("/authenticate/not_required/")
30 | # User isn't logged in with django.contrib.auth.
31 | self.assertIsInstance(response.wsgi_request.user, AnonymousUser)
32 | self.assertContains(response, "anonymous")
33 |
34 | def test_default_not_permanent(self):
35 | params = get_parameters(self.user)
36 | response = self.client.get("/authenticate/", params)
37 | # User isn't logged in with django.contrib.sessions
38 | self.assertIsInstance(get_user(response.wsgi_request), AnonymousUser)
39 | self.assertContains(response, self.user.username)
40 |
41 | def test_permanent(self):
42 | params = get_parameters(self.user)
43 | response = self.client.get("/authenticate/permanent/", params)
44 | # User is logged in with django.contrib.sessions.
45 | self.assertEqual(get_user(response.wsgi_request), self.user)
46 | self.assertContains(response, self.user.username)
47 |
48 | def test_default_override(self):
49 | user1 = self.user
50 | user2 = self.create_user("jane")
51 | self.client.force_login(user1)
52 | params = get_parameters(user2)
53 | response = self.client.get("/authenticate/", params)
54 | self.assertEqual(response.wsgi_request.user, user2)
55 | self.assertContains(response, user2.username)
56 |
57 | def test_no_override(self):
58 | user1 = self.user
59 | user2 = self.create_user("jane")
60 | self.client.force_login(user1)
61 | params = get_parameters(user2)
62 | response = self.client.get("/authenticate/no_override/", params)
63 | self.assertEqual(response.wsgi_request.user, user1)
64 | self.assertContains(response, user1.username)
65 |
66 | def test_scope(self):
67 | params = get_parameters(self.user, scope="scope")
68 | response = self.client.get("/authenticate/scope/", params)
69 | self.assertEqual(response.wsgi_request.user, self.user)
70 | self.assertContains(response, self.user.username)
71 |
72 | def test_scope_interpolate_positional_argument(self):
73 | params = get_parameters(self.user, scope="arg:spam")
74 | response = self.client.get("/authenticate/scope/arg/spam/", params)
75 | self.assertEqual(response.wsgi_request.user, self.user)
76 | self.assertContains(response, self.user.username)
77 |
78 | def test_scope_interpolate_keyword_argument(self):
79 | params = get_parameters(self.user, scope="kwarg:eggs")
80 | response = self.client.get("/authenticate/scope/kwarg/eggs/", params)
81 | self.assertEqual(response.wsgi_request.user, self.user)
82 | self.assertContains(response, self.user.username)
83 |
84 | @override_settings(MIDDLEWARE=[])
85 | def test_without_session_middleware(self):
86 | params = get_parameters(self.user)
87 | response = self.client.get("/authenticate/", params)
88 | self.assertContains(response, self.user.username)
89 |
90 | @override_settings(MIDDLEWARE=[])
91 | def test_permanent_without_session_middleware(self):
92 | params = get_parameters(self.user)
93 | with self.assertRaises(ImproperlyConfigured) as exc:
94 | self.client.get("/authenticate/permanent/", params)
95 | self.assertEqual(
96 | str(exc.exception),
97 | "authenticate(permanent=True) requires django.contrib.sessions",
98 | )
99 |
--------------------------------------------------------------------------------
/src/sesame/middleware.py:
--------------------------------------------------------------------------------
1 | from urllib.parse import urlencode
2 |
3 | from django.contrib.auth import login
4 | from django.contrib.auth.models import AnonymousUser
5 | from django.shortcuts import redirect
6 |
7 | from . import settings
8 | from .utils import get_user
9 |
10 | __all__ = ["AuthenticationMiddleware"]
11 |
12 |
13 | class AuthenticationMiddleware:
14 | """
15 | Look for a signed token in the URL of any request and log a user in.
16 |
17 | After login, remove the token from the URL with an HTTP redirect.
18 |
19 | .. admonition:: This functionality requires additional setup for Safari.
20 | :class: warning
21 |
22 | :class:`AuthenticationMiddleware` requires the optional ``ua`` extra to
23 | prevent :ref:`issues with Safari `:
24 |
25 | .. code-block:: console
26 |
27 | $ pip install 'django-sesame[ua]'
28 |
29 | In the :setting:`MIDDLEWARE` setting,
30 | ``"sesame.middleware.AuthenticationMiddleware"`` should be placed just after
31 | ``"django.contrib.auth.middleware.AuthenticationMiddleware"``:
32 |
33 | .. code-block:: python
34 |
35 | MIDDLEWARE = [
36 | ...,
37 | "django.contrib.auth.middleware.AuthenticationMiddleware",
38 | "sesame.middleware.AuthenticationMiddleware",
39 | ...,
40 | ]
41 |
42 | """
43 |
44 | def __init__(self, get_response):
45 | self.get_response = get_response
46 |
47 | def __call__(self, request):
48 | # When process_request() returns a response, return that response.
49 | # Otherwise continue with the next middleware or the view.
50 | return self.process_request(request) or self.get_response(request)
51 |
52 | def process_request(self, request):
53 | """
54 | Log user the in if ``request`` contains a valid token.
55 |
56 | Return an HTTP redirect response that removes the token from the URL
57 | after a successful login (except on Safari to avoid triggering ITP,
58 | and only when sessions are enabled).
59 |
60 | """
61 | # If django.contrib.sessions is enabled, don't update the last login,
62 | # because login(request, user) will do it.
63 | # If django.contrib.sessions is disabled, keep the default behavior of
64 | # updating last login only for one-time tokens.
65 | user = get_user(
66 | request,
67 | update_last_login=False if hasattr(request, "session") else None,
68 | )
69 |
70 | # If django.contrib.sessions is enabled and the token is valid,
71 | # persist the login in session.
72 | if hasattr(request, "session") and user is not None:
73 | login(request, user)
74 | # Once we persist the login in the session, if the authentication
75 | # middleware is enabled, it will set request.user in future
76 | # requests. We can get rid of the token in the URL by redirecting
77 | # to the same URL with the token removed. We only do this for GET
78 | # requests because redirecting POST requests doesn't work well. We
79 | # don't do this on Safari because it triggers the over-zealous
80 | # "Protection Against First Party Bounce Trackers" of ITP 2.0.
81 | if (
82 | hasattr(request, "user")
83 | and request.method == "GET"
84 | and not self.is_safari(request)
85 | ):
86 | return self.get_redirect(request)
87 |
88 | # If django.contrib.auth isn't enabled, set request.user.
89 | if not hasattr(request, "user"):
90 | request.user = user if user is not None else AnonymousUser()
91 |
92 | @staticmethod
93 | def is_safari(request):
94 | try:
95 | from ua_parser import user_agent_parser
96 | except ImportError: # pragma: no cover
97 | return None
98 | else:
99 | user_agent = request.META.get("HTTP_USER_AGENT", "")
100 | parsed_ua = user_agent_parser.Parse(user_agent)
101 | return (
102 | parsed_ua["user_agent"]["family"] == "Safari"
103 | or parsed_ua["os"]["family"] == "iOS"
104 | )
105 |
106 | @staticmethod
107 | def get_redirect(request):
108 | """
109 | Create an HTTP redirect response that removes the token from the URL.
110 |
111 | """
112 | params = request.GET.copy()
113 | params.pop(settings.TOKEN_NAME)
114 | url = request.path
115 | if params:
116 | url += "?" + urlencode(params)
117 | return redirect(url)
118 |
--------------------------------------------------------------------------------
/src/sesame/settings.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import hashlib
3 | import importlib
4 | import sys
5 |
6 | from django.conf import settings
7 | from django.contrib.auth import get_user_model
8 | from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
9 | from django.core.signals import setting_changed
10 | from django.dispatch import receiver
11 |
12 | DEFAULTS = {
13 | # Generating URLs
14 | "TOKEN_NAME": "sesame",
15 | # Tokens lifecycle
16 | "MAX_AGE": None,
17 | "ONE_TIME": False,
18 | "INVALIDATE_ON_PASSWORD_CHANGE": True,
19 | "INVALIDATE_ON_EMAIL_CHANGE": False,
20 | # Custom primary keys
21 | "PACKER": None,
22 | "PRIMARY_KEY_FIELD": "pk",
23 | # Tokens
24 | "TOKENS": ["sesame.tokens_v2", "sesame.tokens_v1"],
25 | # Tokens v2
26 | "KEY": "",
27 | # We want a short signature in order to keep tokens short. A 10-bytes
28 | # signature has about 1.2e24 possible values, which is sufficient here.
29 | "SIGNATURE_SIZE": 10,
30 | # Tokens v1
31 | "SALT": "sesame",
32 | # These parameters aren't updated anymore. Tokens v2 are recommended.
33 | "DIGEST": hashlib.md5,
34 | "ITERATIONS": 10000,
35 | }
36 |
37 | __all__ = list(DEFAULTS)
38 |
39 |
40 | def derive_key(secret_key, key):
41 | """
42 | Make a 64-bytes key from Django's ``secret_key`` and django-sesame's ``key``.
43 |
44 | Include settings in the key to invalidate tokens when these settings change.
45 | This ensures that tokens generated with one packer cannot be misinterpreted
46 | by another packer, for example.
47 |
48 | """
49 | global MAX_AGE, PACKER, PRIMARY_KEY_FIELD
50 | return hashlib.blake2b(
51 | "|".join(
52 | [
53 | # Usually a str but Django also supports bytes.
54 | str(secret_key),
55 | # Treat key like secret_key for consistency.
56 | str(key),
57 | # Changing MAX_AGE is allowed as long as it is not None.
58 | "max_age" if MAX_AGE is not None else "",
59 | PACKER if PACKER is not None else "",
60 | PRIMARY_KEY_FIELD,
61 | ]
62 | ).encode(),
63 | person=b"sesame.settings",
64 | ).digest()
65 |
66 |
67 | # load() also works for reloading settings, which is useful for testing.
68 |
69 |
70 | def load():
71 | module = sys.modules[__name__]
72 | for name, default in DEFAULTS.items():
73 | setattr(module, name, getattr(settings, "SESAME_" + name, default))
74 |
75 | global KEY, MAX_AGE, SIGNING_KEY, TOKENS, VERIFICATION_KEYS
76 |
77 | # Support defining MAX_AGE as a timedelta rather than a number of seconds.
78 | if isinstance(MAX_AGE, datetime.timedelta):
79 | MAX_AGE = MAX_AGE.total_seconds()
80 |
81 | # Import token creation and parsing modules.
82 | TOKENS = [importlib.import_module(tokens) for tokens in TOKENS]
83 |
84 | # Derive signing and verification keys.
85 | SIGNING_KEY = derive_key(settings.SECRET_KEY, KEY)
86 | VERIFICATION_KEYS = [SIGNING_KEY] + [
87 | derive_key(secret_key, KEY)
88 | for secret_key in getattr(settings, "SECRET_KEY_FALLBACKS", [])
89 | ]
90 |
91 |
92 | load()
93 |
94 |
95 | # Django's checks framework was designed to run such checks. Unfortunately,
96 | # there's no way to guarantee that a check would be discovered, because
97 | # django-sesame never required adding the "sesame" app to INSTALLED_APPS.
98 |
99 | # The benefits of writing this check with the checks framework don't justify
100 | # adding another step to the installation instructions. Raising an exception
101 | # is good enough.
102 |
103 |
104 | def check():
105 | global MAX_AGE, INVALIDATE_ON_PASSWORD_CHANGE
106 | if MAX_AGE is None and not INVALIDATE_ON_PASSWORD_CHANGE:
107 | raise ImproperlyConfigured(
108 | "insecure configuration: set SESAME_MAX_AGE to a low value "
109 | "or set SESAME_INVALIDATE_ON_PASSWORD_CHANGE to True"
110 | )
111 |
112 | global INVALIDATE_ON_EMAIL_CHANGE
113 | if INVALIDATE_ON_EMAIL_CHANGE:
114 | User = get_user_model()
115 | try:
116 | User._meta.get_field(User.get_email_field_name())
117 | except FieldDoesNotExist:
118 | raise ImproperlyConfigured(
119 | "invalid configuration: set User.EMAIL_FIELD correctly "
120 | "or set SESAME_INVALIDATE_ON_EMAIL_CHANGE to False"
121 | )
122 |
123 |
124 | check()
125 |
126 |
127 | @receiver(setting_changed)
128 | def reload(*, setting, **kwargs):
129 | if setting.startswith("SECRET_KEY") or setting.startswith("SESAME_"):
130 | load()
131 |
132 | if setting in ["AUTH_USER_MODEL", "SESAME_PACKER", "SESAME_PRIMARY_KEY_FIELD"]:
133 | from . import packers
134 |
135 | packers.packer = packers.get_packer()
136 |
137 | if setting in ["SESAME_SALT", "SESAME_MAX_AGE"]:
138 | from . import tokens_v1
139 |
140 | tokens_v1.signer = tokens_v1.get_signer()
141 | tokens_v1.token_re = tokens_v1.get_token_re()
142 |
--------------------------------------------------------------------------------
/src/sesame/tokens_v1.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import re
3 |
4 | from django.core import signing
5 | from django.utils import crypto
6 |
7 | from . import packers, settings
8 |
9 | __all__ = ["create_token", "detect_token", "parse_token"]
10 |
11 | logger = logging.getLogger("sesame")
12 |
13 |
14 | def get_revocation_key(user):
15 | """
16 | When the value returned by this method changes, this revokes tokens.
17 |
18 | It is derived from the hashed password so that changing the password
19 | revokes tokens.
20 |
21 | It may be derived from the email so that changing the email revokes tokens
22 | too.
23 |
24 | For one-time tokens, it also contains the last login datetime so that
25 | logging in revokes existing tokens.
26 |
27 | """
28 | data = ""
29 | if settings.INVALIDATE_ON_PASSWORD_CHANGE:
30 | data += user.password
31 | if settings.INVALIDATE_ON_EMAIL_CHANGE:
32 | data += getattr(user, user.get_email_field_name())
33 | if settings.ONE_TIME:
34 | data += str(user.last_login)
35 | # The password is expected to be a secure hash but we hash it again
36 | # for additional safety. We default to MD5 to minimize the length of
37 | # the token. (Remember, if an attacker obtains the URL, he can already
38 | # log in. This isn't high security.)
39 | return crypto.pbkdf2(
40 | data,
41 | settings.SALT,
42 | settings.ITERATIONS,
43 | digest=settings.DIGEST,
44 | )
45 |
46 |
47 | def get_signer():
48 | if settings.MAX_AGE is None:
49 | Signer = signing.Signer
50 | else:
51 | Signer = signing.TimestampSigner
52 | return Signer(salt=settings.SALT, algorithm="sha1")
53 |
54 |
55 | signer = get_signer()
56 |
57 |
58 | def sign(data):
59 | """
60 | Create a URL-safe, signed token from ``data``.
61 |
62 | """
63 | data = signing.b64_encode(data).decode()
64 | return signer.sign(data)
65 |
66 |
67 | def unsign(token):
68 | """
69 | Extract the data from a signed ``token``.
70 |
71 | """
72 | if settings.MAX_AGE is None:
73 | data = signer.unsign(token)
74 | else:
75 | data = signer.unsign(token, max_age=settings.MAX_AGE)
76 | return signing.b64_decode(data.encode())
77 |
78 |
79 | def create_token(user, scope=""):
80 | """
81 | Create a v1 signed token for a user.
82 |
83 | """
84 | if scope != "":
85 | raise NotImplementedError("v1 tokens don't support scope")
86 | primary_key = packers.packer.pack_pk(getattr(user, settings.PRIMARY_KEY_FIELD))
87 | key = get_revocation_key(user)
88 | return sign(primary_key + key)
89 |
90 |
91 | def parse_token(token, get_user, scope="", max_age=None):
92 | """
93 | Obtain a user from a v1 signed token.
94 |
95 | """
96 | if scope != "":
97 | raise NotImplementedError("v1 tokens don't support scope")
98 | if max_age is not None:
99 | raise NotImplementedError("v1 tokens don't support max_age")
100 |
101 | try:
102 | data = unsign(token)
103 | except signing.SignatureExpired:
104 | logger.debug("Expired token: %s", token)
105 | return None
106 | except signing.BadSignature:
107 | logger.debug("Bad token: %s", token)
108 | return None
109 | except Exception:
110 | logger.exception(
111 | "Valid signature but unexpected token; if you enabled "
112 | "or disabled SESAME_MAX_AGE, you must regenerate tokens"
113 | )
114 | return None
115 |
116 | try:
117 | user_pk, key = packers.packer.unpack_pk(data)
118 | except Exception:
119 | logger.exception(
120 | "Valid signature but unexpected token; if you changed "
121 | "SESAME_PACKER, you must regenerate tokens"
122 | )
123 | return None
124 |
125 | user = get_user(user_pk)
126 | if user is None:
127 | logger.debug("Unknown or inactive user: %s", user_pk)
128 | return None
129 | if not crypto.constant_time_compare(key, get_revocation_key(user)):
130 | logger.debug("Invalid token: %s", token)
131 | return None
132 | logger.debug("Valid token for user %s: %s", user, token)
133 |
134 | return user
135 |
136 |
137 | def get_token_re():
138 | if settings.MAX_AGE is None:
139 | # Size of primary key and revocation key depends on SESAME_PACKER and
140 | # SESAME_DIGEST. Default is 4 + 16 = 20 bytes = 27 Base64 characters.
141 | # Minimum "sensible" size is 1 + 2 = 3 bytes = 4 Base64 characters.
142 | return re.compile(r"[A-Za-z0-9-_]{4,}:[A-Za-z0-9-_]{27}")
143 |
144 | else:
145 | # All timestamps use 6 Base62 characters because 100000 in Base62 is
146 | # 1999-01-12T09:20:32Z, before django-sesame existed.
147 | return re.compile(r"[A-Za-z0-9-_]{4,}:[0-9A-Za-z]{6}:[A-Za-z0-9-_]{27}")
148 |
149 |
150 | token_re = get_token_re()
151 |
152 |
153 | def detect_token(token):
154 | """
155 | Tell whether token may be a v1 signed token.
156 |
157 | """
158 | return token_re.fullmatch(token) is not None
159 |
--------------------------------------------------------------------------------
/docs/reference.rst:
--------------------------------------------------------------------------------
1 | API reference
2 | =============
3 |
4 | Settings
5 | --------
6 |
7 | .. admonition:: Changing settings invalidates all existing tokens.
8 | :class: warning
9 |
10 | The format of tokens depends on settings. As a consequence, changing any
11 | setting invalidates all existing tokens. There is a limited exception for
12 | :data:`SESAME_MAX_AGE`.
13 |
14 | For this reason, you should configure settings carefully before you start
15 | generating tokens.
16 |
17 | .. data:: SESAME_TOKEN_NAME
18 | :value: "sesame"
19 |
20 | Name of the query string parameter containing the authentication token.
21 |
22 | .. data:: SESAME_MAX_AGE
23 | :value: None
24 |
25 | Lifetime of authentications tokens, as a :class:`datetime.timedelta` or a
26 | number of seconds.
27 |
28 | When :data:`SESAME_MAX_AGE` is :obj:`None`, tokens always have an unlimited
29 | lifetime.
30 |
31 | When :data:`SESAME_MAX_AGE` is not :obj:`None`, you can adjust the desired
32 | lifetime in any API that accepts a ``max_age`` argument.
33 |
34 | .. admonition:: Changing :data:`SESAME_MAX_AGE` doesn't always invalidate
35 | tokens.
36 | :class: tip
37 |
38 | Changing the value of :data:`SESAME_MAX_AGE` doesn't invalidate tokens
39 | as long as it isn't :obj:`None`.
40 |
41 | The new value applies to all tokens, even those that were generated
42 | before the change.
43 |
44 | Only switching it between :obj:`None` and another value invalidates all
45 | tokens.
46 |
47 | .. data:: SESAME_ONE_TIME
48 | :value: False
49 |
50 | Set :data:`SESAME_ONE_TIME` to :obj:`True` to invalidate tokens when they're
51 | used.
52 |
53 | Specifically, updating the user's last login date invalidates tokens.
54 |
55 | .. data:: SESAME_INVALIDATE_ON_PASSWORD_CHANGE
56 | :value: True
57 |
58 | By default, tokens are invalidated when a user changes their password.
59 |
60 | Set :data:`SESAME_INVALIDATE_ON_PASSWORD_CHANGE` to :obj:`False` to prevent
61 | this.
62 |
63 | .. data:: SESAME_INVALIDATE_ON_EMAIL_CHANGE
64 | :value: False
65 |
66 | Set :data:`SESAME_INVALIDATE_ON_EMAIL_CHANGE` to :obj:`True` to
67 | invalidate tokens when a user changes their email.
68 |
69 | .. data:: SESAME_PRIMARY_KEY_FIELD
70 | :value: "pk"
71 |
72 | Name of the field used as a primary key. See :ref:`Custom primary keys`.
73 |
74 | .. data:: SESAME_PACKER
75 | :value: None
76 |
77 | Dotted path to a built-in or custom packer. See :ref:`Custom primary keys`.
78 |
79 | .. data:: SESAME_TOKENS
80 | :value: ["sesame.tokens_v2", "sesame.tokens_v1"]
81 |
82 | Supported token formats. New tokens are generated with the first format.
83 | Existing tokens are accepted in any format listed here.
84 |
85 | The default value means "generate tokens v2, accept tokens v2 and v1". No
86 | other versions exist at this time.
87 |
88 | .. data:: SESAME_KEY
89 | :value: ""
90 |
91 | Change the value of this setting if you need to invalidate all tokens.
92 |
93 | This setting only applies to tokens v2. See :ref:`Tokens design`.
94 |
95 | .. data:: SESAME_SIGNATURE_SIZE
96 | :value: 10
97 |
98 | Size of the signature in bytes.
99 |
100 | This setting only applies to tokens v2. See :ref:`Tokens design`.
101 |
102 | .. data:: SESAME_SALT
103 | :value: "sesame"
104 |
105 | Change the value of this setting if you need to invalidate all tokens.
106 |
107 | This setting only applies to tokens v1. See :ref:`Tokens design`.
108 |
109 | .. SESAME_DIGEST was never documented and tokens v1 are superseded by tokens v2.
110 |
111 | .. SESAME_ITERATIONS was never documented and tokens v1 are superseded by tokens v2.
112 |
113 | Token generation
114 | ----------------
115 |
116 | django-sesame provides three utility functions for generating tokens,
117 | according to the context.
118 |
119 | .. autofunction:: sesame.utils.get_query_string
120 |
121 | .. autofunction:: sesame.utils.get_parameters
122 |
123 | .. autofunction:: sesame.utils.get_token
124 |
125 | Token validation
126 | ----------------
127 |
128 | django-sesame provides high-level APIs to authenticate a user or to log
129 | them in permanently.
130 |
131 | .. autoclass:: sesame.middleware.AuthenticationMiddleware
132 |
133 | .. autoclass:: sesame.views.LoginView
134 |
135 | .. autodecorator:: sesame.decorators.authenticate
136 |
137 | django-sesame also provides a low-level utility function for validating tokens.
138 |
139 | .. autofunction:: sesame.utils.get_user
140 |
141 | Token customization
142 | -------------------
143 |
144 | .. autoclass:: sesame.packers.BasePacker
145 | :members:
146 |
147 | See :ref:`Custom primary keys`.
148 |
149 | Authentication backend
150 | ----------------------
151 |
152 | django-sesame requires configuring a compatible authentication backend in
153 | :setting:`AUTHENTICATION_BACKENDS`.
154 |
155 | .. autoclass:: sesame.backends.ModelBackend
156 |
157 | .. automethod:: get_user
158 |
159 | .. autoclass:: sesame.backends.SesameBackendMixin
160 |
161 | .. automethod:: authenticate
162 |
--------------------------------------------------------------------------------
/docs/changelog.rst:
--------------------------------------------------------------------------------
1 | Changelog
2 | =========
3 |
4 | 3.2
5 | ---
6 |
7 | *July 30th, 2023*
8 |
9 | * Added support for invalidating tokens on email change with the
10 | :data:`SESAME_INVALIDATE_ON_EMAIL_CHANGE` setting.
11 | * Supported overriding settings for testing.
12 |
13 | 3.1
14 | ---
15 |
16 | *July 28th, 2022*
17 |
18 | * Added the ability to select which field is used as a primary key in tokens
19 | with the :data:`SESAME_PRIMARY_KEY_FIELD` setting.
20 | * Supported the ``SECRET_KEY_FALLBACKS`` setting introduced in Django 4.1.
21 |
22 | 3.0
23 | ---
24 |
25 | *July 11th, 2022*
26 |
27 | .. admonition:: Version 3.0 introduces a new documentation.
28 | :class: important
29 |
30 | Notably, a :ref:`tutorial ` and an :ref:`API reference` were
31 | added.
32 |
33 | .. admonition:: Enforced ``update_last_login`` as a keyword-only argument in
34 | :func:`~sesame.utils.get_user`.
35 | :class: warning
36 |
37 | ``update_last_login`` was documented as a keyword argument. However, it
38 | could also be the first positional argument. If you were doing this, you
39 | will hit an exception.
40 |
41 | Also:
42 |
43 | * Added :func:`~sesame.decorators.authenticate` to authenticate users.
44 | * Added :class:`~sesame.views.LoginView` to log users in.
45 | * Added compatibility with Django ≥ 4.0.
46 |
47 | 2.4
48 | ---
49 |
50 | *May 5th, 2021*
51 |
52 | * Added the ability to pass a token to :func:`~sesame.utils.get_user` instead of
53 | a request.
54 |
55 | 2.3
56 | ---
57 |
58 | *February 15th, 2021*
59 |
60 | * Supported overriding ``max_age``. This feature is only available for v2 tokens.
61 |
62 | 2.2
63 | ---
64 |
65 | *January 16th, 2021*
66 |
67 | * Fixed crash when a v2 token is truncated.
68 |
69 | 2.1
70 | ---
71 |
72 | *November 1st, 2020*
73 |
74 | * Added :ref:`scoped tokens `. This feature is only available for
75 | v2 tokens.
76 |
77 | 2.0
78 | ---
79 |
80 | *June 6th, 2020*
81 |
82 | .. admonition:: Version 2.0 introduces a faster and shorter token format (v2).
83 | :class: important
84 |
85 | The new format (v2) is enabled by default for new tokens.
86 |
87 | The original format (v1) is still supported for backwards-compatibility.
88 |
89 | See :ref:`Tokens design` for details.
90 |
91 | .. admonition:: Changed the default name of the URL parameter to ``sesame``.
92 | :class: warning
93 |
94 | If you need to preserve existing URLs, you can set the
95 | :data:`SESAME_TOKEN_NAME` setting ``"url_auth_token"``.
96 |
97 | .. admonition:: Changed the argument expected by
98 | :func:`~django.contrib.auth.authenticate` to ``sesame``.
99 | :class: warning
100 |
101 | You're affected only if you call ``authenticate(url_auth_token=...)``
102 | explicitly. If so, change this call to ``authenticate(sesame=...)``.
103 |
104 | Also:
105 |
106 | * Added :func:`~sesame.utils.get_token()` to generate a token.
107 | * :data:`SESAME_MAX_AGE` can be a :class:`datetime.timedelta`.
108 | * Improved documentation.
109 |
110 | 1.8
111 | ---
112 |
113 | *May 11th, 2020*
114 |
115 | * Added compatibility with custom user models with most types of primary keys,
116 | including :class:`~django.db.models.BigAutoField`,
117 | :class:`~django.db.models.SmallAutoField`, other integer fields,
118 | :class:`~django.db.models.CharField`, and
119 | :class:`~django.db.models.BinaryField`.
120 | * Added the ability to customize how primary keys are stored in tokens with the
121 | :data:`SESAME_PACKER` setting.
122 | * Added compatibility with Django ≥ 3.0.
123 |
124 | 1.7
125 | ---
126 |
127 | *June 8th, 2019*
128 |
129 | * Fixed invalidation of one-time tokens in :func:`~sesame.utils.get_user`.
130 |
131 | 1.6
132 | ---
133 |
134 | *May 18th, 2019*
135 |
136 | * Fixed detection of Safari on iOS.
137 |
138 | 1.5
139 | ---
140 |
141 | *May 1st, 2019*
142 |
143 | * Added support for single-use tokens with the :data:`SESAME_ONE_TIME` setting.
144 | * Added support for not invalidating tokens on password change with the
145 | :data:`SESAME_INVALIDATE_ON_PASSWORD_CHANGE` setting.
146 | * Added compatibility with custom user models where the primary key is a
147 | :class:`~django.db.models.UUIDField`.
148 | * Added the :func:`~sesame.utils.get_user` function to obtain a user instance
149 | from a request.
150 | * Improved error message for preexisting tokens when changing the
151 | :data:`SESAME_MAX_AGE` setting.
152 | * Fixed authentication on Safari by :ref:`disabling redirect `.
153 |
154 | 1.4
155 | ---
156 |
157 | *April 29th, 2018*
158 |
159 | * Added a redirect to the same URL with the query string parameter removed.
160 |
161 | 1.3
162 | ---
163 |
164 | *December 2nd, 2017*
165 |
166 | * Added compatibility with Django ≥ 2.0.
167 |
168 | 1.2
169 | ---
170 |
171 | *August 19th, 2016*
172 |
173 | * Added the ability to rename the query string parameter with the
174 | :data:`SESAME_TOKEN_NAME` setting.
175 | * Added compatibility with Django ≥ 1.8.
176 |
177 | 1.1
178 | ---
179 |
180 | *September 17th, 2014*
181 |
182 | * Added support for expiring tokens with the :data:`SESAME_MAX_AGE` setting.
183 |
184 | 1.0
185 | ---
186 |
187 | *July 3rd, 2014*
188 |
189 | * Initial release.
190 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | .. image:: logo/horizontal.svg
2 | :width: 400px
3 | :alt: django-sesame
4 |
5 | django-sesame provides frictionless authentication with "Magic Links" for
6 | your Django project.
7 |
8 | It generates URLs containing authentication tokens such as:
9 | https://example.com/?sesame=zxST9d0XT9xgfYLvoa9e2myN
10 |
11 | Then it authenticates users based on tokens found in URLs.
12 |
13 | More broadly, it supports a wide range of `use cases`_ for
14 | stateless, token-based authentication.
15 |
16 | Please review `(in)security`_ considerations before using django-sesame.
17 |
18 | ----
19 |
20 | `Documentation is available on ReadTheDocs.`__
21 |
22 | ----
23 |
24 | __ https://django-sesame.readthedocs.io/en/stable/
25 |
26 | Requirements
27 | ------------
28 |
29 | django-sesame is tested with:
30 |
31 | - Django 4.2 (LTS), 5.0, 5.1, and 5.2 (LTS);
32 | - Python ≥ 3.9.
33 |
34 | It requires ``django.contrib.auth``.
35 |
36 | Getting started
37 | ---------------
38 |
39 | Install django-sesame:
40 |
41 | .. code-block:: console
42 |
43 | $ pip install django-sesame
44 |
45 | Open your project settings and add ``"sesame.backends.ModelBackend"`` to the
46 | ``AUTHENTICATION_BACKENDS`` setting. Extending the default value, this
47 | looks like:
48 |
49 | .. code-block:: python
50 |
51 | AUTHENTICATION_BACKENDS = [
52 | "django.contrib.auth.backends.ModelBackend",
53 | "sesame.backends.ModelBackend",
54 | ]
55 |
56 | Now, your project can authenticate users based on django-sesame tokens.
57 |
58 | Quick example
59 | -------------
60 |
61 | Configure ``LoginView`` in your URLconf:
62 |
63 | .. code-block:: python
64 |
65 | from django.urls import path
66 | from sesame.views import LoginView
67 |
68 | urlpatterns = [
69 | ...,
70 | path("sesame/login/", LoginView.as_view(), name="sesame-login"),
71 | ...,
72 | ]
73 |
74 | Load a user from the database:
75 |
76 | .. code-block:: pycon
77 |
78 | >>> from django.contrib.auth import get_user_model
79 | >>> User = get_user_model()
80 | >>> user = User.objects.first()
81 |
82 | Generate a login URL for this user:
83 |
84 | .. code-block:: pycon
85 |
86 | >>> from sesame.utils import get_query_string
87 | >>> LOGIN_URL = "https://127.0.0.1:8000/sesame/login/"
88 | >>> LOGIN_URL + get_query_string(user)
89 | 'https://127.0.0.1:8000/sesame/login/?sesame=zxST9d0XT9xgfYLvoa9e2myN'
90 |
91 | (Your token will be different from this example.)
92 |
93 | Make sure that you're logged out. Open the login URL. You are logged in!
94 |
95 | Use cases
96 | ---------
97 |
98 | Known use cases for django-sesame include:
99 |
100 | 1. Login by email, an attractive option on mobile where typing passwords
101 | is uncomfortable. This technique is prominently deployed by Slack.
102 |
103 | If you're doing this, you should define a small ``SESAME_MAX_AGE``, perhaps
104 | 10 minutes.
105 |
106 | 2. Authenticated links. For example, you can generate a report offline
107 | and, when it's ready, email a link to access it. Authenticated links work
108 | even if the user isn't logged in on the device where they're opening it.
109 |
110 | Likewise, you should configure an appropriate ``SESAME_MAX_AGE``,
111 | probably a few days.
112 |
113 | Since emails may be forwarded, authenticated links shouldn't log the user
114 | in. They should only allow access to specific views.
115 |
116 | 3. Sharing links, which are a variant of authenticated links. When a user shares
117 | content with a guest, you may create a phantom account for the guest and
118 | generate an authenticated link tied to that account or you may reuse the
119 | user's account.
120 |
121 | Email forwarding is also likely in this context. Make sure that sharing links
122 | don't log the user in.
123 |
124 | 4. Authentication of WebSocket connections. The web application gets a token
125 | generated by the Django server and sends it over the WebSocket connection.
126 | The WebSocket server authenticate the connection with the token.
127 |
128 | Here's an `example with the websockets library`__.
129 |
130 | __ https://websockets.readthedocs.io/en/stable/howto/django.html
131 |
132 | 5. Non-critical private websites, for example for a family or club site,
133 | where users don't expect to manage a personal account with a password.
134 | Authorized users can bookmark personalized authenticated URLs.
135 |
136 | Here you can rely on the default settings because that's the original —
137 | admittedly, niche — use case for which django-sesame was built.
138 |
139 | (In)security
140 | ------------
141 |
142 | The major security weakness in django-sesame is a direct consequence of the
143 | feature it implements: **whoever obtains an authentication token is able to
144 | authenticate to your website.**
145 |
146 | URLs end up in countless insecure places: emails, referer headers, proxy logs,
147 | browser history, etc. You can't avoid that. At best you can mitigate it by
148 | creating short-lived or single-use tokens.
149 |
150 | Otherwise, a reasonable attempt was made to provide a secure solution. Tokens
151 | are secured with modern cryptography. There are configurable options for token
152 | invalidation.
153 |
--------------------------------------------------------------------------------
/src/sesame/packers.py:
--------------------------------------------------------------------------------
1 | import struct
2 | import uuid
3 |
4 | from django.contrib.auth import get_user_model
5 | from django.core.exceptions import ImproperlyConfigured
6 | from django.utils.module_loading import import_string
7 |
8 | from . import settings
9 |
10 | __all__ = [
11 | "BasePacker",
12 | "ShortPacker",
13 | "UnsignedShortPacker",
14 | "LongPacker",
15 | "UnsignedLongPacker",
16 | "LongLongPacker",
17 | "UnsignedLongLongPacker",
18 | "UUIDPacker",
19 | "BytesPacker",
20 | "StrPacker",
21 | "packer",
22 | ]
23 |
24 |
25 | class BasePacker:
26 | """
27 | Abstract base class for packers.
28 |
29 | """
30 |
31 | def pack_pk(self, user_pk):
32 | """
33 | Create a short representation of the primary key of a user.
34 |
35 | Return :class:`bytes`.
36 |
37 | """
38 |
39 | def unpack_pk(self, data):
40 | """
41 | Extract the primary key of a user from a signed token.
42 |
43 | ``data`` contains :class:`bytes`.
44 |
45 | Return the primary key and the remaining data as :class:`bytes`.
46 |
47 | """
48 |
49 |
50 | class StructPackerMeta(type):
51 | def __new__(cls, name, bases, namespace, **kwds):
52 | if "size" not in namespace and "fmt" in namespace:
53 | namespace["size"] = struct.calcsize(namespace["fmt"])
54 | return super().__new__(cls, name, bases, namespace, **kwds)
55 |
56 |
57 | class StructPacker(BasePacker, metaclass=StructPackerMeta):
58 | fmt = ""
59 |
60 | @classmethod
61 | def pack_pk(cls, user_pk):
62 | return struct.pack(cls.fmt, user_pk)
63 |
64 | @classmethod
65 | def unpack_pk(cls, data):
66 | (user_pk,) = struct.unpack(cls.fmt, data[: cls.size])
67 | return user_pk, data[cls.size :]
68 |
69 |
70 | class ShortPacker(StructPacker):
71 | fmt = "!h"
72 |
73 |
74 | class UnsignedShortPacker(StructPacker):
75 | fmt = "!H"
76 |
77 |
78 | class LongPacker(StructPacker):
79 | fmt = "!l"
80 |
81 |
82 | IntPacker = LongPacker # for backwards-compatibility
83 |
84 |
85 | class UnsignedLongPacker(StructPacker):
86 | fmt = "!L"
87 |
88 |
89 | UnsignedIntPacker = UnsignedLongPacker # for consistency
90 |
91 |
92 | class LongLongPacker(StructPacker):
93 | fmt = "!q"
94 |
95 |
96 | class UnsignedLongLongPacker(StructPacker):
97 | fmt = "!Q"
98 |
99 |
100 | class UUIDPacker(BasePacker):
101 | @staticmethod
102 | def pack_pk(user_pk):
103 | return user_pk.bytes
104 |
105 | @staticmethod
106 | def unpack_pk(data):
107 | return uuid.UUID(bytes=data[:16]), data[16:]
108 |
109 |
110 | class BytesPacker(BasePacker):
111 | """
112 | Generic packer for bytestrings, from 0 to 255 bytes.
113 |
114 | In many cases, primary keys stored as bytes are likely to be fixed-length,
115 | which doesn't require a variable length encoding scheme.
116 |
117 | """
118 |
119 | @staticmethod
120 | def pack_pk(user_pk):
121 | length = len(user_pk)
122 | if length > 255:
123 | raise ValueError(f"primary key is too large ({length} bytes)")
124 | return bytes([length]) + user_pk
125 |
126 | @staticmethod
127 | def unpack_pk(data):
128 | length = data[0]
129 | return data[1 : length + 1], data[length + 1 :]
130 |
131 |
132 | class StrPacker(BytesPacker):
133 | """
134 | Generic packer for strings, from 0 to 255 UTF-8 encoded bytes.
135 |
136 | """
137 |
138 | @staticmethod
139 | def pack_pk(user_pk):
140 | user_pk = user_pk.encode()
141 | length = len(user_pk)
142 | if length > 255:
143 | raise ValueError(f"primary key is too large ({length} UTF-8 bytes)")
144 | return bytes([length]) + user_pk
145 |
146 | @staticmethod
147 | def unpack_pk(data):
148 | length = data[0]
149 | return data[1 : length + 1].decode(), data[length + 1 :]
150 |
151 |
152 | PACKERS = {
153 | # 2 bytes
154 | "SmallAutoField": ShortPacker,
155 | "SmallIntegerField": ShortPacker,
156 | "PositiveSmallIntegerField": UnsignedShortPacker,
157 | # 4 bytes
158 | "AutoField": LongPacker,
159 | "IntegerField": LongPacker,
160 | "PositiveIntegerField": UnsignedLongPacker,
161 | # 8 bytes
162 | "BigAutoField": LongLongPacker,
163 | "BigIntegerField": LongLongPacker,
164 | "PositiveBigIntegerField": UnsignedLongLongPacker,
165 | # 16 bytes
166 | "UUIDField": UUIDPacker,
167 | # Variable length
168 | "BinaryField": BytesPacker,
169 | "CharField": StrPacker,
170 | "TextField": StrPacker,
171 | }
172 |
173 |
174 | def get_packer():
175 | if settings.PACKER is None:
176 | User = get_user_model()
177 | if settings.PRIMARY_KEY_FIELD == "pk":
178 | pk_field = User._meta.pk
179 | else:
180 | pk_field = User._meta.get_field(settings.PRIMARY_KEY_FIELD)
181 | if not pk_field.unique:
182 | raise ImproperlyConfigured(
183 | f"{User._meta.label}.{settings.PRIMARY_KEY_FIELD} isn't unique"
184 | )
185 | pk_type = pk_field.get_internal_type()
186 | try:
187 | Packer = PACKERS[pk_type]
188 | except KeyError:
189 | raise NotImplementedError(pk_type + " primary keys aren't supported")
190 | else:
191 | Packer = import_string(settings.PACKER)
192 | return Packer()
193 |
194 |
195 | packer = get_packer()
196 |
--------------------------------------------------------------------------------
/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | from django.test import RequestFactory, TestCase, override_settings
2 |
3 | from sesame.utils import get_parameters, get_query_string, get_token, get_user
4 |
5 | from .mixins import CaptureLogMixin, CreateUserMixin
6 |
7 |
8 | class TestUtils(CaptureLogMixin, CreateUserMixin, TestCase):
9 | def test_get_token(self):
10 | self.assertIsInstance(get_token(self.user), str)
11 |
12 | def test_get_scoped_token(self):
13 | self.assertIsInstance(get_token(self.user, scope="test"), str)
14 |
15 | def test_get_parameters(self):
16 | self.assertEqual(get_parameters(self.user), {"sesame": get_token(self.user)})
17 |
18 | def test_get_parameters_with_scope(self):
19 | self.assertEqual(
20 | get_parameters(self.user, scope="test"),
21 | {"sesame": get_token(self.user, scope="test")},
22 | )
23 |
24 | def test_get_query_string(self):
25 | # Tokens v2 only contain URL-safe characters. There's no escaping.
26 | self.assertEqual(get_query_string(self.user), "?sesame=" + get_token(self.user))
27 |
28 | def test_get_query_string_with_scope(self):
29 | # Tokens v2 only contain URL-safe characters. There's no escaping.
30 | self.assertEqual(
31 | get_query_string(self.user, scope="test"),
32 | "?sesame=" + get_token(self.user, scope="test"),
33 | )
34 |
35 | def test_get_user_no_request_or_token(self):
36 | with self.assertRaises(TypeError) as exc:
37 | self.assertIsNone(get_user(None))
38 | self.assertEqual(
39 | str(exc.exception),
40 | "get_user() expects an HTTPRequest or a token",
41 | )
42 |
43 | def test_get_user_token(self):
44 | token = get_token(self.user)
45 | self.assertEqual(get_user(token), self.user)
46 |
47 | def test_get_user_empty_token(self):
48 | token = ""
49 | self.assertIsNone(get_user(token))
50 |
51 | def test_get_user_bad_token(self):
52 | token = "~!@#$%^&*~!@#$%^&*~"
53 | self.assertIsNone(get_user(token))
54 | self.assertLogsContain("Bad token")
55 |
56 | def test_get_user_request(self):
57 | request = RequestFactory().get("/", get_parameters(self.user))
58 | self.assertEqual(get_user(request), self.user)
59 |
60 | def test_get_user_request_without_token(self):
61 | request = RequestFactory().get("/")
62 | self.assertIsNone(get_user(request))
63 |
64 | def test_get_user_request_with_empty_token(self):
65 | request = RequestFactory().get("/", {"sesame": ""})
66 | self.assertIsNone(get_user(request))
67 |
68 | def test_get_user_request_with_bad_token(self):
69 | request = RequestFactory().get("/", {"sesame": "~!@#$%^&*~!@#$%^&*~"})
70 | self.assertIsNone(get_user(request))
71 | self.assertLogsContain("Bad token")
72 |
73 | @override_settings(SESAME_MAX_AGE=-10)
74 | def test_get_user_expired_token(self):
75 | token = get_token(self.user)
76 | self.assertIsNone(get_user(token))
77 | self.assertLogsContain("Expired token")
78 |
79 | def test_get_user_inactive_user(self):
80 | token = get_token(self.user)
81 | self.user.is_active = False
82 | self.user.save()
83 | self.assertIsNone(get_user(token))
84 | self.assertLogsContain("Unknown or inactive user")
85 |
86 | def test_get_user_unknown_user(self):
87 | token = get_token(self.user)
88 | self.user.delete()
89 | self.assertIsNone(get_user(token))
90 | self.assertLogsContain("Unknown or inactive user")
91 |
92 | def test_get_user_does_not_invalidate_tokens(self):
93 | token = get_token(self.user)
94 | self.assertEqual(get_user(token), self.user)
95 | self.assertEqual(get_user(token), self.user)
96 |
97 | @override_settings(SESAME_ONE_TIME=True)
98 | def test_get_user_invalidates_one_time_tokens(self):
99 | token = get_token(self.user)
100 | self.assertEqual(get_user(token), self.user)
101 | self.assertIsNone(get_user(token))
102 | self.assertLogsContain("Invalid token")
103 |
104 | def test_get_user_does_not_update_last_login(self):
105 | token = get_token(self.user)
106 | last_login = self.user.last_login
107 | self.assertEqual(get_user(token), self.user)
108 | self.user.refresh_from_db()
109 | self.assertEqual(self.user.last_login, last_login)
110 |
111 | @override_settings(SESAME_ONE_TIME=True)
112 | def test_get_user_updates_last_login_for_one_time_tokens(self):
113 | token = get_token(self.user)
114 | last_login = self.user.last_login
115 | self.assertEqual(get_user(token), self.user)
116 | self.user.refresh_from_db()
117 | self.assertGreater(self.user.last_login, last_login)
118 |
119 | def test_get_user_force_update_last_login(self):
120 | token = get_token(self.user)
121 | last_login = self.user.last_login
122 | self.assertEqual(get_user(token, update_last_login=True), self.user)
123 | self.user.refresh_from_db()
124 | self.assertGreater(self.user.last_login, last_login)
125 |
126 | @override_settings(SESAME_ONE_TIME=True)
127 | def test_get_user_force_not_update_last_login(self):
128 | token = get_token(self.user)
129 | last_login = self.user.last_login
130 | self.assertEqual(get_user(token, update_last_login=False), self.user)
131 | self.user.refresh_from_db()
132 | self.assertEqual(self.user.last_login, last_login)
133 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | django-sesame
2 | =============
3 |
4 | .. toctree::
5 | :hidden:
6 |
7 | tutorial
8 | howto
9 | reference
10 | topics
11 | faq
12 | contributing
13 | changelog
14 |
15 | django-sesame provides frictionless authentication with "Magic Links" for
16 | your Django project.
17 |
18 | It generates URLs containing authentication tokens such as:
19 | https://example.com/?sesame=zxST9d0XT9xgfYLvoa9e2myN
20 |
21 | Then it authenticates users based on tokens found in URLs.
22 |
23 | More broadly, it supports a wide range of :ref:`use cases