├── 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 |
11 | {% csrf_token %} 12 |

13 | {{ form }} 14 | 15 |

16 |
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 |

8x8 / 16x16 @ 2x

17 |

16x16 / 32x32 @ 2x

18 |

32x32 / 32x32 @ 2x

19 |

32x32 / 64x64 @ 2x

20 |

64x64 / 128x128 @ 2x

21 |

128x128 / 256x256 @ 2x

22 |

256x256 / 512x512 @ 2x

23 |

512x512 / 1024x1024 @ 2x

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 |

preview @ 2x

41 |

For regular screens.

42 |

preview

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 | 2 | 3 | 4 | 5 | 6 | 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 ` for 24 | stateless, token-based authentication. 25 | 26 | .. admonition:: Please review :ref:`(in)security considerations <(In)security>` before using django-sesame. 27 | :class: warning 28 | 29 | Also, please don't use libraries published by strangers on the Internet 30 | without understanding what they do :-) 31 | 32 | Requirements 33 | ------------ 34 | 35 | django-sesame is tested with: 36 | 37 | - Django 4.2 (LTS), 5.0, 5.1, and 5.2 (LTS); 38 | - Python ≥ 3.9. 39 | 40 | It requires :mod:`django.contrib.auth`. 41 | 42 | Getting started 43 | --------------- 44 | 45 | Install django-sesame: 46 | 47 | .. code-block:: console 48 | 49 | $ pip install django-sesame 50 | 51 | Open your project settings and add ``"sesame.backends.ModelBackend"`` to the 52 | :setting:`AUTHENTICATION_BACKENDS` setting. Extending the default value, this 53 | looks like: 54 | 55 | .. code-block:: python 56 | 57 | AUTHENTICATION_BACKENDS = [ 58 | "django.contrib.auth.backends.ModelBackend", 59 | "sesame.backends.ModelBackend", 60 | ] 61 | 62 | Now, your project can authenticate users based on django-sesame tokens. 63 | 64 | You can head over to the :ref:`tutorial ` for complete examples or 65 | continue reading for a shorter demo. 66 | 67 | Quick example 68 | ------------- 69 | 70 | Configure :class:`~sesame.views.LoginView` in your URLconf: 71 | 72 | .. code-block:: python 73 | 74 | from django.urls import path 75 | from sesame.views import LoginView 76 | 77 | urlpatterns = [ 78 | ..., 79 | path("sesame/login/", LoginView.as_view(), name="sesame-login"), 80 | ..., 81 | ] 82 | 83 | Load a user from the database: 84 | 85 | .. code-block:: pycon 86 | 87 | >>> from django.contrib.auth import get_user_model 88 | >>> User = get_user_model() 89 | >>> user = User.objects.first() 90 | 91 | Generate a login URL for this user: 92 | 93 | .. code-block:: pycon 94 | 95 | >>> from sesame.utils import get_query_string 96 | >>> LOGIN_URL = "https://127.0.0.1:8000/sesame/login/" 97 | >>> LOGIN_URL + get_query_string(user) 98 | 'https://127.0.0.1:8000/sesame/login/?sesame=zxST9d0XT9xgfYLvoa9e2myN' 99 | 100 | (Your token will be different from this example.) 101 | 102 | Make sure that you're logged out. Open the login URL. You are logged in! 103 | 104 | Use cases 105 | --------- 106 | 107 | Known use cases for django-sesame include: 108 | 109 | 1. :ref:`Login by email`, an attractive option on mobile where typing passwords 110 | is uncomfortable. This technique is prominently deployed by Slack. 111 | 112 | If you're doing this, you should define a small :data:`SESAME_MAX_AGE`, 113 | perhaps 10 minutes. 114 | 115 | 2. :ref:`Authenticated links`. For example, you can generate a report offline 116 | and, when it's ready, email a link to access it. Authenticated links work 117 | even if the user isn't logged in on the device where they're opening it. 118 | 119 | Likewise, you should configure an appropriate :data:`SESAME_MAX_AGE`, 120 | probably a few days. 121 | 122 | Since emails may be forwarded, authenticated links shouldn't log the user 123 | in. They should only allow access to specific views. 124 | 125 | 3. Sharing links, which are a variant of authenticated links. When a user shares 126 | content with a guest, you may create a phantom account for the guest and 127 | generate an authenticated link tied to that account or you may reuse the 128 | user's account. 129 | 130 | Email forwarding is also likely in this context. Make sure that sharing links 131 | don't log the user in. 132 | 133 | 4. Authentication of WebSocket connections. The web application gets a token 134 | generated by the Django server and sends it over the WebSocket connection. 135 | The WebSocket server authenticate the connection with the token. 136 | 137 | Here's an `example with the websockets library`__. 138 | 139 | __ https://websockets.readthedocs.io/en/stable/howto/django.html 140 | 141 | 5. Non-critical private websites, for example for a family or club site, 142 | where users don't expect to manage a personal account with a password. 143 | Authorized users can bookmark personalized authenticated URLs. 144 | 145 | Here you can rely on the default settings because that's the original — 146 | admittedly, niche — use case for which django-sesame was built. 147 | 148 | (In)security 149 | ------------ 150 | 151 | The major security weakness in django-sesame is a direct consequence of the 152 | feature it implements: **whoever obtains an authentication token is able to 153 | authenticate to your website.** 154 | 155 | URLs end up in countless insecure places: emails, referer headers, proxy logs, 156 | browser history, etc. You can't avoid that. At best you can mitigate it by 157 | creating :ref:`short-lived ` or :ref:`single-use ` tokens. 159 | 160 | Otherwise, a reasonable attempt was made to provide a :ref:`secure solution 161 | `. Tokens are secured with modern cryptography. There are 162 | configurable options for :ref:`token invalidation `. 163 | -------------------------------------------------------------------------------- /tests/test_packers.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.test import TestCase, override_settings 5 | 6 | from sesame import packers 7 | from sesame.packers import ( 8 | BasePacker, 9 | BytesPacker, 10 | LongLongPacker, 11 | LongPacker, 12 | ShortPacker, 13 | StrPacker, 14 | UnsignedLongLongPacker, 15 | UnsignedLongPacker, 16 | UnsignedShortPacker, 17 | UUIDPacker, 18 | ) 19 | 20 | 21 | class Packer(BasePacker): 22 | """ 23 | Verbatim copy of the example in the README. 24 | 25 | """ 26 | 27 | @staticmethod 28 | def pack_pk(user_pk): 29 | assert len(user_pk) == 24 30 | return bytes.fromhex(user_pk) 31 | 32 | @staticmethod 33 | def unpack_pk(data): 34 | return data[:12].hex(), data[12:] 35 | 36 | 37 | class RepeatPacker(LongPacker): 38 | """ 39 | Packer that can raise an exception in unpack_pk. 40 | 41 | """ 42 | 43 | @classmethod 44 | def pack_pk(cls, user_pk): 45 | data = super().pack_pk(user_pk) 46 | assert data[:2] == b"\x00\x00" 47 | return data[2:4] + data[2:] 48 | 49 | @classmethod 50 | def unpack_pk(cls, data): 51 | assert data[:2] == data[2:4] 52 | data = b"\x00\x00" + data[2:] 53 | return super().unpack_pk(data) 54 | 55 | 56 | class TestPackers(TestCase): 57 | random_uuid = uuid.uuid4() 58 | cases = [ 59 | (ShortPacker, -32768, b"\x80\x00"), 60 | (ShortPacker, 0, b"\x00\x00"), 61 | (ShortPacker, 1, b"\x00\x01"), 62 | (ShortPacker, 32767, b"\x7f\xff"), 63 | (UnsignedShortPacker, 0, b"\x00\x00"), 64 | (UnsignedShortPacker, 1, b"\x00\x01"), 65 | (UnsignedShortPacker, 65535, b"\xff\xff"), 66 | (LongPacker, -2147483648, b"\x80\x00\x00\x00"), 67 | (LongPacker, 0, b"\x00\x00\x00\x00"), 68 | (LongPacker, 1, b"\x00\x00\x00\x01"), 69 | (LongPacker, 2147483647, b"\x7f\xff\xff\xff"), 70 | (UnsignedLongPacker, 0, b"\x00\x00\x00\x00"), 71 | (UnsignedLongPacker, 1, b"\x00\x00\x00\x01"), 72 | (UnsignedLongPacker, 4294967295, b"\xff\xff\xff\xff"), 73 | (LongLongPacker, -9223372036854775808, b"\x80" + 7 * b"\x00"), 74 | (LongLongPacker, 0, 8 * b"\x00"), 75 | (LongLongPacker, 1, 7 * b"\x00" + b"\x01"), 76 | (LongLongPacker, 9223372036854775807, b"\x7f" + 7 * b"\xff"), 77 | (UnsignedLongLongPacker, 0, 8 * b"\x00"), 78 | (UnsignedLongLongPacker, 1, 7 * b"\x00" + b"\x01"), 79 | (UnsignedLongLongPacker, 18446744073709551615, 8 * b"\xff"), 80 | (UUIDPacker, random_uuid, random_uuid.bytes), 81 | (BytesPacker, b"", b"\x00"), 82 | (BytesPacker, random_uuid.bytes, b"\x10" + random_uuid.bytes), 83 | (BytesPacker, 255 * b"\xff", 256 * b"\xff"), 84 | (StrPacker, "", b"\x00"), 85 | (StrPacker, "marie-noëlle", b"\x0dmarie-no\xc3\xablle"), 86 | (StrPacker, 51 * "👍 ", b"\xff" + 51 * b"\xf0\x9f\x91\x8d "), 87 | ( 88 | Packer, 89 | "abcdef012345abcdef567890", 90 | b"\xab\xcd\xef\x01\x23\x45\xab\xcd\xef\x56\x78\x90", 91 | ), 92 | (RepeatPacker, 2, b"\x00\x02\x00\x02"), 93 | ] 94 | 95 | def test_pack_pk(self): 96 | for Packer, user_pk, data in self.cases: 97 | with self.subTest(Packer=Packer, user_pk=user_pk): 98 | self.assertEqual(Packer().pack_pk(user_pk), data) 99 | 100 | def test_unpack_pk(self): 101 | rest = b"random stuff" 102 | for Packer, user_pk, data in self.cases: 103 | with self.subTest(Packer=Packer, user_pk=user_pk): 104 | self.assertEqual(Packer().unpack_pk(data + rest), (user_pk, rest)) 105 | 106 | error_cases = [ 107 | (BytesPacker, 256 * b"\xff"), 108 | (StrPacker, 64 * "👍"), 109 | ] 110 | 111 | def test_pack_pk_error(self): 112 | for Packer, user_pk in self.error_cases: 113 | with self.subTest(Packer=Packer, user_pk=user_pk): 114 | with self.assertRaises(ValueError): 115 | Packer().pack_pk(user_pk) 116 | 117 | def test_get_packer_with_auto_primary_key(self): 118 | self.assertEqual(type(packers.packer), LongPacker) 119 | 120 | @override_settings(AUTH_USER_MODEL="tests.UUIDUser") 121 | def test_get_packer_with_uuid_primary_key(self): 122 | self.assertEqual(type(packers.packer), UUIDPacker) 123 | 124 | def test_get_packer_with_unsupported_primary_key(self): 125 | with self.assertRaises(NotImplementedError) as exc: 126 | with override_settings(AUTH_USER_MODEL="tests.BooleanUser"): 127 | pass # pragma: no cover 128 | self.assertEqual( 129 | str(exc.exception), 130 | "BooleanField primary keys aren't supported", 131 | ) 132 | 133 | @override_settings(SESAME_PRIMARY_KEY_FIELD="username") 134 | def test_get_packer_with_alternative_primary_key(self): 135 | self.assertEqual(type(packers.packer), StrPacker) 136 | 137 | def test_get_packer_with_non_unique_alternative_primary_key(self): 138 | with self.assertRaises(ImproperlyConfigured) as exc: 139 | with override_settings(SESAME_PRIMARY_KEY_FIELD="email"): 140 | pass # pragma: no cover 141 | self.assertEqual( 142 | str(exc.exception), 143 | "auth.User.email isn't unique", 144 | ) 145 | 146 | @override_settings( 147 | AUTH_USER_MODEL="tests.StrUser", 148 | SESAME_PACKER="tests.test_packers.Packer", 149 | ) 150 | def test_get_packer_with_custom_packer(self): 151 | self.assertEqual(type(packers.packer), Packer) 152 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from django.contrib.auth import get_user 4 | from django.contrib.auth.models import AnonymousUser 5 | from django.test import TestCase 6 | from django.test.utils import override_settings 7 | 8 | from sesame.utils import get_parameters, get_query_string 9 | 10 | from .mixins import CreateUserMixin 11 | 12 | try: 13 | import ua_parser 14 | except ImportError: # pragma: no cover 15 | ua_parser = None 16 | 17 | 18 | SAFARI_USER_AGENT = ( 19 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_4) " 20 | "AppleWebKit/605.1.15 (KHTML, like Gecko) " 21 | "Version/12.1 Safari/605.1.15" 22 | ) 23 | 24 | CHROME_IOS_USER_AGENT = ( 25 | "Mozilla/5.0 (iPhone; CPU iPhone OS 10_3 like Mac OS X) " 26 | "AppleWebKit/602.1.50 (KHTML, like Gecko) " 27 | "CriOS/56.0.2924.75 Mobile/14E5239e Safari/602.1" 28 | ) 29 | 30 | 31 | @override_settings( 32 | MIDDLEWARE=[ 33 | "django.contrib.sessions.middleware.SessionMiddleware", 34 | "django.contrib.auth.middleware.AuthenticationMiddleware", 35 | "sesame.middleware.AuthenticationMiddleware", 36 | ], 37 | ) 38 | class TestMiddleware(CreateUserMixin, TestCase): 39 | should_redirect_after_auth = True 40 | 41 | def assertUserLoggedIn(self, response, redirect_url): 42 | request = response.wsgi_request 43 | # User is logged in with django.contrib.sessions. 44 | self.assertEqual(get_user(request), self.user) 45 | # User is logged in with django.contrib.auth. 46 | self.assertEqual(request.user, self.user) 47 | 48 | if redirect_url is not None and self.should_redirect_after_auth: 49 | self.assertRedirects(response, redirect_url) 50 | else: 51 | self.assertContains(response, self.user.username) 52 | 53 | def assertUserNotLoggedIn(self, response): 54 | request = response.wsgi_request 55 | # User isn't logged in with django.contrib.sessions. 56 | self.assertIsInstance(get_user(request), AnonymousUser) 57 | # User isn't logged in with django.contrib.auth. 58 | self.assertIsInstance(request.user, AnonymousUser) 59 | 60 | self.assertContains(response, "anonymous") 61 | 62 | def test_token(self): 63 | response = self.client.get("/", get_parameters(self.user)) 64 | self.assertUserLoggedIn(response, redirect_url="/") 65 | 66 | def test_no_token(self): 67 | response = self.client.get("/") 68 | self.assertUserNotLoggedIn(response) 69 | 70 | def test_empty_token(self): 71 | response = self.client.get("/", {"sesame": ""}) 72 | self.assertUserNotLoggedIn(response) 73 | 74 | def test_bad_token(self): 75 | params = get_parameters(self.user) 76 | params["sesame"] = params["sesame"].lower() 77 | response = self.client.get("/", params) 78 | self.assertUserNotLoggedIn(response) 79 | 80 | @override_settings(SESAME_ONE_TIME=True) 81 | def test_one_time_token(self): 82 | response = self.client.get("/", get_parameters(self.user)) 83 | self.assertUserLoggedIn(response, redirect_url="/") 84 | 85 | def test_reuse_token(self): 86 | params = get_parameters(self.user) 87 | response = self.client.get("/", params) 88 | self.assertUserLoggedIn(response, redirect_url="/") 89 | self.client.logout() 90 | response = self.client.get("/", params) 91 | self.assertUserLoggedIn(response, redirect_url="/") 92 | 93 | @override_settings(SESAME_ONE_TIME=True) 94 | def test_reuse_one_time_token(self): 95 | params = get_parameters(self.user) 96 | response = self.client.get("/", params) 97 | self.assertUserLoggedIn(response, redirect_url="/") 98 | self.client.logout() 99 | response = self.client.get("/", params) 100 | self.assertUserNotLoggedIn(response) 101 | 102 | # one query to get the user matching the token 103 | # one query to update their last login date 104 | NUM_QUERIES = 2 105 | 106 | def test_token_num_queries(self): 107 | with self.assertNumQueries(self.NUM_QUERIES): 108 | response = self.client.get("/", get_parameters(self.user)) 109 | self.assertUserLoggedIn(response, redirect_url="/") 110 | 111 | ONE_TIME_NUM_QUERIES = 2 112 | 113 | @override_settings(SESAME_ONE_TIME=True) 114 | def test_one_time_token_num_queries(self): 115 | with self.assertNumQueries(self.ONE_TIME_NUM_QUERIES): 116 | response = self.client.get("/", get_parameters(self.user)) 117 | self.assertUserLoggedIn(response, redirect_url="/") 118 | 119 | def test_token_with_path_and_params(self): 120 | params = get_parameters(self.user) 121 | params["bar"] = 42 122 | response = self.client.get("/foo", params) 123 | self.assertUserLoggedIn(response, redirect_url="/foo?bar=42") 124 | 125 | def test_token_in_POST_request(self): 126 | response = self.client.post("/" + get_query_string(self.user)) 127 | self.assertUserLoggedIn(response, redirect_url=None) 128 | 129 | @unittest.skipIf(ua_parser is None, "test requires ua-parser") 130 | def test_token_in_Safari_request(self): 131 | response = self.client.get( 132 | "/", get_parameters(self.user), HTTP_USER_AGENT=SAFARI_USER_AGENT 133 | ) 134 | self.assertUserLoggedIn(response, redirect_url=None) 135 | 136 | @unittest.skipIf(ua_parser is None, "test requires ua-parser") 137 | def test_token_in_iOS_request(self): 138 | response = self.client.get( 139 | "/", get_parameters(self.user), HTTP_USER_AGENT=CHROME_IOS_USER_AGENT 140 | ) 141 | self.assertUserLoggedIn(response, redirect_url=None) 142 | 143 | 144 | @override_settings( 145 | MIDDLEWARE=[ 146 | "django.contrib.sessions.middleware.SessionMiddleware", 147 | "sesame.middleware.AuthenticationMiddleware", 148 | ] 149 | ) 150 | class TestWithoutAuthMiddleware(TestMiddleware): 151 | # When django.contrib.auth isn't enabled, every URL must contain an 152 | # authentication token, so it mustn't be removed with a redirect. 153 | should_redirect_after_auth = False 154 | 155 | 156 | @override_settings( 157 | MIDDLEWARE=[ 158 | "django.contrib.sessions.middleware.SessionMiddleware", 159 | "sesame.middleware.AuthenticationMiddleware", 160 | "django.contrib.auth.middleware.AuthenticationMiddleware", 161 | ] 162 | ) 163 | class TestBeforeAuthMiddleware(TestMiddleware): 164 | # When the sesame middleware is (incorrectly) before the 165 | # django.contrib.auth middleware, sesame doesn't know that 166 | # django.contrib.auth is enabled, so it's the same as when 167 | # django.contrib.auth isn't enabled. 168 | should_redirect_after_auth = False 169 | 170 | # Furthermore, the django.contrib.auth middleware overrides the 171 | # ``request.user`` attribute set by the sesame middleware via 172 | # ``login(request, user)``, which causes a duplicate query. 173 | NUM_QUERIES = TestMiddleware.NUM_QUERIES + 1 174 | ONE_TIME_NUM_QUERIES = TestMiddleware.ONE_TIME_NUM_QUERIES + 1 175 | 176 | 177 | @override_settings(MIDDLEWARE=["sesame.middleware.AuthenticationMiddleware"]) 178 | class TestWithoutSessionMiddleware(TestMiddleware): 179 | def assertUserLoggedIn(self, response, redirect_url=None): 180 | self.assertEqual(response.wsgi_request.user, self.user) 181 | self.assertContains(response, self.user.username) 182 | 183 | def assertUserNotLoggedIn(self, response): 184 | self.assertIsInstance(response.wsgi_request.user, AnonymousUser) 185 | self.assertContains(response, "anonymous") 186 | 187 | # The last login date isn't updated when the session middleware isn't 188 | # enabled, except for one-time tokens. 189 | NUM_QUERIES = TestMiddleware.NUM_QUERIES - 1 190 | -------------------------------------------------------------------------------- /src/sesame/tokens_v2.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import datetime 3 | import hashlib 4 | import hmac 5 | import logging 6 | import re 7 | import struct 8 | import time 9 | 10 | from . import packers, settings 11 | 12 | __all__ = ["create_token", "detect_token", "parse_token"] 13 | 14 | logger = logging.getLogger("sesame") 15 | 16 | TIMESTAMP_OFFSET = 1577836800 # 2020-01-01T00:00:00Z 17 | 18 | 19 | def pack_timestamp(): 20 | """ 21 | When SESAME_MAX_AGE is enabled, encode the time in seconds since the epoch. 22 | 23 | Return bytes. 24 | 25 | """ 26 | if settings.MAX_AGE is None: 27 | return b"" 28 | timestamp = int(time.time()) - TIMESTAMP_OFFSET 29 | return struct.pack("!i", timestamp) 30 | 31 | 32 | def unpack_timestamp(data): 33 | """ 34 | When SESAME_MAX_AGE is enabled, extract the timestamp and calculate the age. 35 | 36 | Return an age in seconds or None and the remaining bytes. 37 | 38 | """ 39 | if settings.MAX_AGE is None: 40 | return None, data 41 | # If data contains less than 4 bytes, this raises struct.error. 42 | (timestamp,), data = struct.unpack("!i", data[:4]), data[4:] 43 | return int(time.time()) - TIMESTAMP_OFFSET - timestamp, data 44 | 45 | 46 | HASH_SIZES = { 47 | "pbkdf2_sha256": 44, 48 | "pbkdf2_sha1": 28, 49 | "argon2": 22, # in Argon2 v1.3; previously 86 50 | "bcrypt_sha256": 31, # salt (22) + hash (31) 51 | "bcrypt": 31, # salt (22) + hash (31) 52 | "sha1": 40, # hex, not base64 53 | "md5": 32, # hex, not base64 54 | "crypt": 11, # salt (2) + hash (11) 55 | } 56 | 57 | 58 | def get_revocation_key(user): 59 | """ 60 | When the value returned by this method changes, this revokes tokens. 61 | 62 | It is derived from the hashed password so that changing the password 63 | revokes tokens. 64 | 65 | It may be derived from the email so that changing the email revokes tokens 66 | too. 67 | 68 | For one-time tokens, it also contains the last login datetime so that 69 | logging in revokes existing tokens. 70 | 71 | """ 72 | data = "" 73 | 74 | # Tokens generated by django-sesame are more likely to leak than hashed 75 | # passwords. To minimize the information tokens might be revealing, we'd 76 | # like to use only hashes, excluding salts, as suggested in issue #40. 77 | 78 | # Since we're hashing the result again with a cryptographic hash function, 79 | # this isn't supposed to make a difference in practice. But it alleviates 80 | # concerns about sending data derived from hashed passwords into the wild. 81 | 82 | # Hashed passwords may be in various formats: 83 | # 1. "[$]?[$]*[$?]?", if set_password() 84 | # was called with a built-in hasher. Unfortunatly, the bcrypt (and 85 | # crypt) hashers don't include a "$" between the salt and the hash, so 86 | # we can't split on this marker. Instead we hardcode hash lengths. 87 | # 2. "!<40 random characters>", if set_unusable_password() was called. 88 | # 3. Anything else, if set_password() was called with a custom hasher or 89 | # if a custom authentication backend is used. 90 | 91 | # An alternative would be to rely on user.get_session_auth_hash(), which 92 | # has the advantage of being a public API. It's a HMAC-SHA256 of the whole 93 | # password hash. However, it's designed for a slightly different purpose, 94 | # so I'm not comfortable reusing it. Also, for clarity, I don't want to 95 | # chain more cryptographic operations than needed. 96 | 97 | if settings.INVALIDATE_ON_PASSWORD_CHANGE and user.password is not None: 98 | algorithm = user.password.partition("$")[0] 99 | try: 100 | hash_size = HASH_SIZES[algorithm] 101 | except KeyError: 102 | data += user.password 103 | else: 104 | data += user.password[-hash_size:] 105 | 106 | if settings.INVALIDATE_ON_EMAIL_CHANGE: 107 | data += getattr(user, user.get_email_field_name()) 108 | 109 | if settings.ONE_TIME and user.last_login is not None: 110 | data += user.last_login.isoformat() 111 | 112 | return data.encode() 113 | 114 | 115 | def sign(data, key, size): 116 | """ 117 | Create a MAC with keyed hashing. 118 | 119 | """ 120 | return hashlib.blake2b( 121 | data, 122 | digest_size=size, 123 | key=key, 124 | person=b"sesame.tokens_v2", 125 | ).digest() 126 | 127 | 128 | def create_token(user, scope=""): 129 | """ 130 | Create a v2 signed token for a user. 131 | 132 | """ 133 | primary_key = packers.packer.pack_pk(getattr(user, settings.PRIMARY_KEY_FIELD)) 134 | timestamp = pack_timestamp() 135 | revocation_key = get_revocation_key(user) 136 | 137 | signature = sign( 138 | primary_key + timestamp + revocation_key + scope.encode(), 139 | settings.SIGNING_KEY, 140 | settings.SIGNATURE_SIZE, 141 | ) 142 | 143 | # If the revocation key changes, the signature becomes invalid, so we 144 | # don't need to include a hash of the revocation key in the token. 145 | data = primary_key + timestamp + signature 146 | token = base64.urlsafe_b64encode(data).rstrip(b"=") 147 | return token.decode() 148 | 149 | 150 | def parse_token(token, get_user, scope="", max_age=None): 151 | """ 152 | Obtain a user from a v2 signed token. 153 | 154 | """ 155 | token = token.encode() 156 | 157 | # Below, error messages should give a hint to developers debugging apps 158 | # but remain sufficiently generic for the common situation where tokens 159 | # get truncated by accident. 160 | 161 | try: 162 | data = base64.urlsafe_b64decode(token + b"=" * (-len(token) % 4)) 163 | except Exception: 164 | logger.debug("Bad token: cannot decode token") 165 | return None 166 | 167 | # Extract user primary key, token age, and signature from token. 168 | 169 | try: 170 | user_pk, timestamp_and_signature = packers.packer.unpack_pk(data) 171 | except Exception: 172 | logger.debug("Bad token: cannot extract primary key") 173 | return None 174 | 175 | try: 176 | age, signature = unpack_timestamp(timestamp_and_signature) 177 | except Exception: 178 | logger.debug("Bad token: cannot extract timestamp") 179 | return None 180 | 181 | if len(signature) != settings.SIGNATURE_SIZE: 182 | logger.debug("Bad token: cannot extract signature") 183 | return None 184 | 185 | # Since we don't include the revocation key in the token, we need to fetch 186 | # the user in the database before we can verify the signature. Usually, 187 | # it's best to verify the signature before doing anything with a message. 188 | 189 | # An attacker could craft tokens to fetch arbitrary users by primary key, 190 | # like they can fetch arbitrary users by username on a login form. 191 | # Determining whether there's a user with a given primary key via a timing 192 | # attack is acceptable within django-sesame's threat model. 193 | 194 | # Check if token is expired. Perform this check first, because it's fast. 195 | 196 | if max_age is None: 197 | max_age = settings.MAX_AGE 198 | elif settings.MAX_AGE is None: 199 | logger.warning( 200 | "Ignoring max_age argument; it isn't supported when SESAME_MAX_AGE = None" 201 | ) 202 | elif isinstance(max_age, datetime.timedelta): 203 | max_age = max_age.total_seconds() 204 | if age is not None and age >= max_age: 205 | logger.debug("Expired token: age = %d seconds", age) 206 | return None 207 | 208 | # Check if user exists and can log in. 209 | 210 | user = get_user(user_pk) 211 | if user is None: 212 | logger.debug( 213 | "Unknown or inactive user: %s = %r", 214 | settings.PRIMARY_KEY_FIELD, 215 | user_pk, 216 | ) 217 | return None 218 | 219 | # Check if signature is valid 220 | 221 | primary_key_and_timestamp = data[: -settings.SIGNATURE_SIZE] 222 | revocation_key = get_revocation_key(user) 223 | for verification_key in settings.VERIFICATION_KEYS: 224 | expected_signature = sign( 225 | primary_key_and_timestamp + revocation_key + scope.encode(), 226 | verification_key, 227 | settings.SIGNATURE_SIZE, 228 | ) 229 | if hmac.compare_digest(signature, expected_signature): 230 | log_scope = "in default scope" if scope == "" else f"in scope {scope}" 231 | logger.debug("Valid token for user %s %s", user, log_scope) 232 | return user 233 | 234 | log_scope = "in default scope" if scope == "" else f"in scope {scope}" 235 | logger.debug("Invalid token for user %s %s", user, log_scope) 236 | return None 237 | 238 | 239 | # Tokens are arbitrary Base64-encoded bytestrings. Their size depends on 240 | # SESAME_PACKER, SESAME_MAX_AGE, and SESAME_SIGNATURE_SIZE. Defaults are: 241 | # - without SESAME_MAX_AGE: 4 + 10 = 14 bytes = 19 Base64 characters. 242 | # - with SESAME_MAX_AGE: 4 + 4 + 10 = 18 bytes = 24 Base64 characters. 243 | # Minimum "sensible" size is 1 + 0 + 2 = 3 bytes = 4 Base64 characters. 244 | token_re = re.compile(r"[A-Za-z0-9-_]{4,}") 245 | 246 | 247 | def detect_token(token): 248 | """ 249 | Tell whether token may be a v2 signed token. 250 | 251 | """ 252 | return token_re.fullmatch(token) is not None 253 | -------------------------------------------------------------------------------- /tests/test_tokens_v1.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.core import signing 4 | from django.test import TestCase, override_settings 5 | from django.utils import timezone 6 | 7 | from sesame import packers 8 | from sesame.tokens_v1 import create_token, detect_token, parse_token 9 | 10 | from .mixins import CaptureLogMixin, CreateUserMixin 11 | 12 | 13 | class TestTokensV1(CaptureLogMixin, CreateUserMixin, TestCase): 14 | def test_valid_token(self): 15 | token = create_token(self.user) 16 | self.assertTrue(detect_token(token)) 17 | user = parse_token(token, self.get_user) 18 | self.assertEqual(user, self.user) 19 | self.assertLogsContain("Valid token for user john") 20 | 21 | # Test invalid tokens 22 | 23 | def test_invalid_signature(self): 24 | token = create_token(self.user) 25 | # Alter signature, which is is in bytes 28 - 55 26 | token = token[:28] + token[28:].lower() 27 | self.assertTrue(detect_token(token)) 28 | user = parse_token(token, self.get_user) 29 | self.assertIsNone(user) 30 | self.assertLogsContain("Bad token") 31 | 32 | def test_random_token(self): 33 | token = "!@#$%" * 11 34 | self.assertEqual(len(token), len(create_token(self.user))) 35 | self.assertFalse(detect_token(token)) 36 | user = parse_token(token, self.get_user) 37 | self.assertIsNone(user) 38 | self.assertLogsContain("Bad token") 39 | 40 | def test_unknown_user(self): 41 | token = create_token(self.user) 42 | self.user.delete() 43 | self.assertTrue(detect_token(token)) 44 | user = parse_token(token, self.get_user) 45 | self.assertIsNone(user) 46 | self.assertLogsContain("Unknown or inactive user") 47 | 48 | # Test token expiry 49 | 50 | @override_settings(SESAME_MAX_AGE=300) 51 | def test_valid_max_age_token(self): 52 | token = create_token(self.user) 53 | self.assertTrue(detect_token(token)) 54 | user = parse_token(token, self.get_user) 55 | self.assertEqual(user, self.user) 56 | self.assertLogsContain("Valid token for user john") 57 | 58 | @override_settings(SESAME_MAX_AGE=-300) 59 | def test_expired_max_age_token(self): 60 | token = create_token(self.user) 61 | self.assertTrue(detect_token(token)) 62 | user = parse_token(token, self.get_user) 63 | self.assertIsNone(user) 64 | self.assertLogsContain("Expired token") 65 | 66 | @override_settings(SESAME_MAX_AGE=-300) 67 | def test_extended_max_age_token(self): 68 | token = create_token(self.user) 69 | with override_settings(SESAME_MAX_AGE=300): 70 | self.assertTrue(detect_token(token)) 71 | user = parse_token(token, self.get_user) 72 | self.assertEqual(user, self.user) 73 | self.assertLogsContain("Valid token for user john") 74 | 75 | @override_settings(SESAME_MAX_AGE=300) 76 | def test_max_age_token_without_timestamp(self): 77 | with override_settings(SESAME_MAX_AGE=None): 78 | token = create_token(self.user) 79 | self.assertFalse(detect_token(token)) 80 | user = parse_token(token, self.get_user) 81 | self.assertIsNone(user) 82 | self.assertLogsContain("Valid signature but unexpected token") 83 | 84 | def test_token_with_timestamp(self): 85 | with override_settings(SESAME_MAX_AGE=300): 86 | token = create_token(self.user) 87 | self.assertFalse(detect_token(token)) 88 | user = parse_token(token, self.get_user) 89 | self.assertIsNone(user) 90 | self.assertLogsContain("Valid signature but unexpected token") 91 | 92 | # Test one-time tokens 93 | 94 | @override_settings(SESAME_ONE_TIME=True) 95 | def test_valid_one_time_token(self): 96 | self.test_valid_token() 97 | 98 | @override_settings(SESAME_ONE_TIME=True) 99 | def test_valid_one_time_token_when_user_never_logged_in(self): 100 | self.user.last_login = None 101 | self.user.save() 102 | self.test_valid_token() 103 | 104 | @override_settings(SESAME_ONE_TIME=True) 105 | def test_one_time_token_invalidation_when_last_login_date_changes(self): 106 | token = create_token(self.user) 107 | self.user.last_login = timezone.now() - datetime.timedelta(1800) 108 | self.user.save() 109 | user = parse_token(token, self.get_user) 110 | self.assertIsNone(user) 111 | self.assertLogsContain("Invalid token") 112 | 113 | # Test token invalidation on password change 114 | 115 | def test_valid_token_when_user_has_no_password(self): 116 | self.user.password = "" 117 | self.user.save() 118 | self.test_valid_token() 119 | 120 | def test_valid_token_when_user_has_unusable_password(self): 121 | self.user.set_unusable_password() 122 | self.user.save() 123 | self.test_valid_token() 124 | 125 | def test_invalid_token_after_password_change(self): 126 | token = create_token(self.user) 127 | self.user.set_password("hunter2") 128 | self.user.save() 129 | user = parse_token(token, self.get_user) 130 | self.assertIsNone(user) 131 | self.assertLogsContain("Invalid token") 132 | 133 | @override_settings(SESAME_INVALIDATE_ON_PASSWORD_CHANGE=False) 134 | def test_valid_token_after_password_change(self): 135 | token = create_token(self.user) 136 | self.user.set_password("hunter2") 137 | self.user.save() 138 | user = parse_token(token, self.get_user) 139 | self.assertEqual(user, self.user) 140 | self.assertLogsContain("Valid token for user john") 141 | 142 | # Test token invalidation on email change 143 | 144 | def test_valid_token_after_email_change(self): 145 | token = create_token(self.user) 146 | self.user.email = "email@example.com" 147 | self.user.save() 148 | user = parse_token(token, self.get_user) 149 | self.assertEqual(user, self.user) 150 | self.assertLogsContain("Valid token for user john") 151 | 152 | @override_settings(SESAME_INVALIDATE_ON_EMAIL_CHANGE=True) 153 | def test_invalid_token_after_email_change(self): 154 | token = create_token(self.user) 155 | self.user.email = "email@example.com" 156 | self.user.save() 157 | user = parse_token(token, self.get_user) 158 | self.assertIsNone(user) 159 | self.assertLogsContain("Invalid token") 160 | 161 | # Test scoped tokens - unsupported 162 | 163 | def test_create_scoped_token(self): 164 | with self.assertRaises(NotImplementedError) as exc: 165 | create_token(self.user, scope="other") 166 | self.assertEqual( 167 | str(exc.exception), 168 | "v1 tokens don't support scope", 169 | ) 170 | 171 | def test_parse_scoped_token(self): 172 | token = create_token(self.user) 173 | with self.assertRaises(NotImplementedError) as exc: 174 | parse_token(token, self.get_user, scope="other") 175 | self.assertEqual( 176 | str(exc.exception), 177 | "v1 tokens don't support scope", 178 | ) 179 | 180 | # Test custom max_age - unsupported 181 | 182 | def test_parse_token_with_custom_max_age(self): 183 | token = create_token(self.user) 184 | with self.assertRaises(NotImplementedError) as exc: 185 | parse_token(token, self.get_user, max_age=300) 186 | self.assertEqual( 187 | str(exc.exception), 188 | "v1 tokens don't support max_age", 189 | ) 190 | 191 | # Test custom primary key packer 192 | 193 | @override_settings( 194 | AUTH_USER_MODEL="tests.StrUser", 195 | SESAME_PACKER="tests.test_packers.Packer", 196 | ) 197 | def test_custom_packer_is_used(self): 198 | user = self.create_user(username="abcdef012345abcdef567890") 199 | token = create_token(user) 200 | # base64.b64encode(bytes.fromhex(username)).decode() == "q83vASNFq83vVniQ" 201 | self.assertEqual(token[:16], "q83vASNFq83vVniQ") 202 | self.assertTrue(detect_token(token)) 203 | 204 | def test_custom_packer_change(self): 205 | token = create_token(self.user) 206 | with override_settings(SESAME_PACKER="tests.test_packers.RepeatPacker"): 207 | user = parse_token(token, self.get_user) 208 | self.assertIsNone(user) 209 | self.assertLogsContain("Valid signature but unexpected token") 210 | 211 | # Miscellaneous tests 212 | 213 | @override_settings(SESAME_INVALIDATE_ON_PASSWORD_CHANGE=False) 214 | def test_naive_token_hijacking_fails(self): 215 | # The revocation key may be identical for two users: 216 | # - if SESAME_INVALIDATE_ON_PASSWORD_CHANGE is False or if they don't 217 | # have a password; 218 | # - if SESAME_ONE_TIME is False or if they have the same last_login. 219 | # In that case, could one user could impersonate the other? 220 | user1 = self.user 221 | user2 = self.create_user("jane") 222 | 223 | token1 = create_token(user1) 224 | token2 = create_token(user2) 225 | 226 | # Check that the test scenario produces identical revocation keys. 227 | # This test depends on the implementation of django.core.signing; 228 | # however, the format of tokens must be stable to keep them valid. 229 | data1, sig1 = token1.split(":", 1) 230 | data2, sig2 = token2.split(":", 1) 231 | data1 = signing.b64_decode(data1.encode()) 232 | data2 = signing.b64_decode(data2.encode()) 233 | pk1 = packers.packer.pack_pk(user1.pk) 234 | pk2 = packers.packer.pack_pk(user2.pk) 235 | self.assertEqual(data1[: len(pk1)], pk1) 236 | self.assertEqual(data2[: len(pk2)], pk2) 237 | key1 = data1[len(pk1) :] 238 | key2 = data2[len(pk2) :] 239 | self.assertEqual(key1, key2) 240 | 241 | # Check that changing just the primary key doesn't allow hijacking the 242 | # other user's account. 243 | data = pk2 + key1 244 | data = signing.b64_encode(data).decode() 245 | token = data + sig1 246 | user = parse_token(token, self.get_user) 247 | self.assertIsNone(user) 248 | self.assertLogsContain("Bad token") 249 | -------------------------------------------------------------------------------- /docs/topics.rst: -------------------------------------------------------------------------------- 1 | Discussions 2 | =========== 3 | 4 | Tokens design 5 | ------------- 6 | 7 | django-sesame builds authentication tokens as follows: 8 | 9 | - Encode the primary key of the user for which they were generated; 10 | - If :data:`SESAME_MAX_AGE` is enabled, encode the token generation timestamp; 11 | - Assemble a revocation key which is used for :ref:`invalidating tokens `; 13 | - Add a message authentication code (MAC) to prevent tampering with the token. 14 | 15 | Primary keys are stored in clear text. If this is a concern, you can 16 | :ref:`customize primary keys `. 17 | 18 | The revocation key is derived from: 19 | 20 | - The password of the user, unless :data:`SESAME_INVALIDATE_ON_PASSWORD_CHANGE` 21 | is disabled; 22 | - The email of the user, if :data:`SESAME_INVALIDATE_ON_EMAIL_CHANGE` is 23 | enabled; 24 | - The last login date of the user, if :data:`SESAME_ONE_TIME` is enabled. 25 | 26 | django-sesame provides two token formats: 27 | 28 | - :ref:`v1 ` is the original format; it is still fully supported; 29 | - :ref:`v2 ` is a better, cleaner, faster design that produces 30 | shorter tokens. 31 | 32 | :data:`SESAME_TOKENS` defaults to ``["sesame.tokens_v2", "sesame.tokens_v1"]``. 33 | 34 | This means "generate tokens v2, accept tokens v2 and v1". 35 | 36 | Tokens v2 37 | ......... 38 | 39 | Tokens v2 contain a primary key, an optional timestamp, and a signature. 40 | 41 | The signature covers the primary key, the optional timestamp, and the 42 | revocation key. If the revocation key changes, the signature becomes invalid. 43 | As a consequence, there's no need to include the revocation key in tokens. 44 | 45 | The signature algorithm is Blake2 in keyed mode. A unique key is derived by 46 | hashing the :setting:`SECRET_KEY` setting and relevant django-sesame settings. 47 | 48 | By default the signature length is 10 bytes. You can adjust it to any value 49 | between 1 and 64 bytes with the :data:`SESAME_SIGNATURE_SIZE` setting. 50 | 51 | Tokens v1 52 | ......... 53 | 54 | Tokens v1 contain a primary key and a revocation key, plus an optional timestamp 55 | and a signature generated by Django's :class:`~django.core.signing.Signer` or 56 | :class:`~django.core.signing.TimestampSigner`. The signature algorithm is 57 | HMAC-SHA1. 58 | 59 | Tokens invalidation 60 | ------------------- 61 | 62 | Once a token is created, you can invalidate it in several ways. 63 | 64 | Invalid tokens are simply rejected. You may :ref:`enable logs to understand the 65 | reason `. 66 | 67 | Expiration 68 | .......... 69 | 70 | By default, tokens are valid forever. You can :ref:`configure expiration ` to give them a finite lifetime. 72 | 73 | When expiration is enabled, tokens store the time when they were created. When 74 | authenticating them, django-sesame verifies how old they are. 75 | 76 | .. admonition:: You can check if an invalid token is expired by 77 | re-authenticating it with a very large ``max_age``. 78 | :class: tip 79 | 80 | If that makes it valid, then it was expired. 81 | 82 | Single-use 83 | .......... 84 | 85 | By default, tokens can be reused. You can :ref:`enable single-use tokens 86 | ` to invalidate them when they're used. 87 | 88 | Single-use tokens are tied to the user's last login date. When authenticating a 89 | single-use token successfully, django-sesame updates the user's last login date, 90 | which invalidates the token. 91 | 92 | As a consequence of this design: 93 | 94 | * As soon as a user logs in, via django-sesame or via another login mechanism, 95 | all their single-use tokens become invalid. 96 | * Authenticating a single-use token updates the user's last login date, even if 97 | the user isn't logged in permanently. 98 | 99 | Finally, single-use tokens can easily get :ref:`invalidated by accident `. 101 | 102 | For all these reasons, tokens with a short lifetime are recommended over 103 | single-use tokens. 104 | 105 | Password change 106 | ............... 107 | 108 | By default, tokens are tied to the users' passwords. Changing the password 109 | invalidates the token. 110 | 111 | Indeed, when there's a suspicion that an account may be compromised, changing 112 | the password is the first step. Invalidating tokens makes sense in that case. 113 | 114 | .. admonition:: Invalidation on password change is less needed when tokens expire 115 | quickly. 116 | :class: tip 117 | 118 | For example, if you rely on short-lived tokens to validate the email address 119 | in a sign up process and you don't know whether validation will occur before 120 | or after initializing the password, you need to disable invalidation. That's 121 | fine from a security perspective. 122 | 123 | Since Django hashes the password with a random salt, the token is invalidated 124 | even if the new password is identical to the old one. 125 | 126 | When users log in with django-sesame only, they don't need a password. In that 127 | case, you should set their passwords to a invalid value with 128 | :meth:`~django.contrib.auth.models.User.set_unusable_password`. You can 129 | invalidate a token at any time by calling 130 | :meth:`~django.contrib.auth.models.User.set_unusable_password` again and saving 131 | the user instance. 132 | 133 | You can disable this behavior by setting 134 | :data:`SESAME_INVALIDATE_ON_PASSWORD_CHANGE` to :obj:`False`. 135 | 136 | .. admonition:: Disabling invalidation on password change makes it impossible to 137 | invalidate a single token. 138 | :class: warning 139 | 140 | If a token is compromised, your only options are to deactivate the user or 141 | to invalidate all tokens for all users. 142 | 143 | Email change 144 | ............ 145 | 146 | You can invalidate tokens when a user changes their email by setting 147 | :data:`SESAME_INVALIDATE_ON_EMAIL_CHANGE` to :obj:`True`. Then, changing the 148 | email invalidates the token. 149 | 150 | Enabling this behavior may improve resilience to compromised email accounts. 151 | 152 | Inactive user 153 | ............. 154 | 155 | When the :attr:`~django.contrib.auth.models.CustomUser.is_active` attribute of a 156 | user is set to :obj:`False`, django-sesame rejects their tokens. 157 | 158 | Different settings 159 | .................. 160 | 161 | You must generate tokens and authenticate them with the same :ref:`settings 162 | `. 163 | 164 | There's a limited exception for :data:`SESAME_MAX_AGE`: as long as it isn't 165 | :obj:`None`, you can change its value and tokens will remain valid. 166 | 167 | If you need to invalidate all tokens, set the :data:`SESAME_KEY` setting to a 168 | new value. This invalidates the signatures of all :ref:`tokens v2 `. 169 | If you still have non-expired :ref:`tokens v1 `, do the same with 170 | :data:`SESAME_SALT`. 171 | 172 | Custom primary keys 173 | ------------------- 174 | 175 | Alternative keys 176 | ................ 177 | 178 | .. versionadded:: 3.1 179 | 180 | When generating a token for a user, django-sesame stores the user's primary key 181 | in the token. 182 | 183 | If you'd like to store an alternative key in the token instead of the primary 184 | key of the user model, set the :data:`SESAME_PRIMARY_KEY_FIELD` setting to the 185 | name of the field storing the alternative key. This field must be declared with 186 | ``unique=True``. 187 | 188 | This may be useful if your user model defines a UUID key in addition to Django's 189 | standard integer primary key and you always want to rely on the UUID externally. 190 | 191 | Custom packers 192 | .............. 193 | 194 | To keep tokens short, django-sesame creates a compact binary representations 195 | depending on the type of the primary key. 196 | 197 | If you're using integer or UUID primary keys, you're fine. 198 | 199 | If you're using another type of primary key, for example a string created by a 200 | unique ID generation algorithm, the default representation may be suboptimal. 201 | 202 | For example, let's say primary keys are strings containing 24 hexadecimal 203 | characters. The default packer represents them with 25 bytes. You can reduce 204 | them to 12 bytes with this custom packer: 205 | 206 | .. code-block:: python 207 | 208 | from sesame.packers import BasePacker 209 | 210 | class Packer(BasePacker): 211 | 212 | @staticmethod 213 | def pack_pk(user_pk): 214 | assert len(user_pk) == 24 215 | return bytes.fromhex(user_pk) 216 | 217 | @staticmethod 218 | def unpack_pk(data): 219 | return data[:12].hex(), data[12:] 220 | 221 | Set the :data:`SESAME_PACKER` setting to the dotted Python path to the custom 222 | packer class. 223 | 224 | For details, see :class:`~sesame.packers.BasePacker` and look at built-in 225 | packers defined in the ``sesame.packers`` module. 226 | 227 | Safari issues 228 | ------------- 229 | 230 | :class:`~sesame.middleware.AuthenticationMiddleware` removes the token from the 231 | URL with an HTTP 302 Redirect after authenticating a user successfully. 232 | 233 | Unfortunately, this triggers a false positive of Safari's `Protection Against 234 | First Party Bounce Trackers`__. As a consequence, Safari clears cookies and the 235 | user is logged out. 236 | 237 | __ https://webkit.org/blog/8311/intelligent-tracking-prevention-2-0/ 238 | 239 | To avoid this problem, django-sesame doesn't redirect when it detects that the 240 | browser is Safari. This relies on the `ua-parser`_ package, which is an optional 241 | dependency. If ua-parser isn't installed, django-sesame always redirects. 242 | 243 | .. _ua-parser: https://github.com/ua-parser/uap-python 244 | 245 | Stateless authentication 246 | ------------------------ 247 | 248 | Theoretically, django-sesame can provide stateless authenticated navigation 249 | without :mod:`django.contrib.sessions`, provided all internal links include the 250 | authentication token. 251 | 252 | When Django's :class:`~django.contrib.sessions.middleware.SessionMiddleware` and 253 | :class:`~django.contrib.auth.middleware.AuthenticationMiddleware` aren't 254 | configured, django-sesame's :class:`~sesame.middleware.AuthenticationMiddleware` 255 | sets ``request.user`` to the logged-in user or 256 | :class:`~django.contrib.auth.models.AnonymousUser`. 257 | 258 | There is no clear use case for this. Better persist authentication in cookies 259 | than in URLs. 260 | -------------------------------------------------------------------------------- /docs/howto.rst: -------------------------------------------------------------------------------- 1 | User guide 2 | ========== 3 | 4 | Generate tokens 5 | --------------- 6 | 7 | A django-sesame token authenticates a user when they access your app. All you 8 | need to generate a token is a user instance. 9 | 10 | For example, let's load a user from the database: 11 | 12 | .. code-block:: pycon 13 | 14 | >>> from django.contrib.auth import get_user_model 15 | >>> User = get_user_model() 16 | >>> user = User.objects.first() 17 | 18 | Let's define a target URL: 19 | 20 | .. code-block:: pycon 21 | 22 | >>> LOGIN_URL = "https://example.com/sesame/login/" 23 | 24 | You can add a django-sesame token to this URL with 25 | :func:`sesame.utils.get_query_string`: 26 | 27 | .. code-block:: pycon 28 | 29 | >>> from sesame.utils import get_query_string 30 | >>> LOGIN_URL + get_query_string(user) 31 | 'https://example.com/sesame/login/?sesame=zxST9d0XT9xgfYLvoa9e2myN' 32 | 33 | Now you can share this URL with the user via any channel providing appropriate 34 | confidentiality. This part is highly dependent on your use case. django-sesame 35 | leaves it up to you. 36 | 37 | .. admonition:: By default, the query string parameter is called ``sesame``. 38 | :class: tip 39 | 40 | You can change it with the :data:`SESAME_TOKEN_NAME` setting. Avoid 41 | conflicts with other parameters in your application. 42 | 43 | At a lower level, you can obtain a :class:`dict` of URL parameters with 44 | :func:`sesame.utils.get_parameters`: 45 | 46 | .. code-block:: pycon 47 | 48 | >>> from sesame.utils import get_parameters 49 | >>> get_parameters(user) 50 | {'sesame': 'zxST9d0XT9xgfYLvoa9e2myN'} 51 | 52 | This makes it more convenient to add more query string parameters to the URL: 53 | 54 | .. code-block:: pycon 55 | 56 | >>> from sesame.utils import get_parameters 57 | >>> from urllib.parse import urlencode 58 | >>> query_params = get_parameters(user) 59 | >>> query_params["next"] = "/welcome/" 60 | >>> LOGIN_URL + "?" + urlencode(query_params) 61 | 'https://example.com/sesame/login/?sesame=zxST9d0XT9xgfYLvoa9e2myN&next=%2Fwelcome%2F' 62 | 63 | Finally, you can get the token itself with :func:`sesame.utils.get_token`: 64 | 65 | .. code-block:: pycon 66 | 67 | >>> from sesame.utils import get_token 68 | >>> get_token(user) 69 | 'zxST9d0XT9xgfYLvoa9e2myN' 70 | 71 | Indeed, you can use django-sesame tokens in other contexts than URLs served by a 72 | Django app, for example to `authenticate WebSocket connections`__. 73 | 74 | __ https://websockets.readthedocs.io/en/stable/howto/django.html#generate-tokens 75 | 76 | Authenticate tokens 77 | ------------------- 78 | 79 | django-sesame provides four mechanisms for authenticating tokens, addressing 80 | different use cases and supporting different levels of customization. 81 | 82 | Site-wide 83 | ......... 84 | 85 | :class:`sesame.middleware.AuthenticationMiddleware` performs authentication 86 | across your application. 87 | 88 | With this middleware, you can add a token to any URL and log the user in as if 89 | they had gone through a login form. This enables one-click access to views 90 | protected by the :func:`~django.contrib.auth.decorators.login_required` 91 | decorator or the :class:`~django.contrib.auth.mixins.LoginRequiredMixin` 92 | class-based view mixin. 93 | 94 | To enable the middleware, add ``"sesame.middleware.AuthenticationMiddleware"`` 95 | to the :setting:`MIDDLEWARE` setting. Place it just after Django's 96 | :class:`~django.contrib.auth.middleware.AuthenticationMiddleware`: 97 | 98 | .. code-block:: python 99 | 100 | MIDDLEWARE = [ 101 | ..., 102 | "django.contrib.auth.middleware.AuthenticationMiddleware", 103 | "sesame.middleware.AuthenticationMiddleware", 104 | ..., 105 | ] 106 | 107 | After a successful login, the token is removed from the URL with an HTTP 302 108 | Redirect. 109 | 110 | .. admonition:: This functionality requires additional setup for Safari. 111 | :class: warning 112 | 113 | :class:`~sesame.middleware.AuthenticationMiddleware` requires the optional 114 | ``ua`` extra to prevent :ref:`issues with Safari `: 115 | 116 | .. code-block:: console 117 | 118 | $ pip install 'django-sesame[ua]' 119 | 120 | This method works well when security concerns are limited and you want the 121 | convenience of adding a django-sesame token to any URL e.g. 122 | ``https://example.com/welcome/?sesame=<...>``. 123 | 124 | Login view 125 | .......... 126 | 127 | .. versionadded:: 3.0 128 | 129 | :class:`sesame.views.LoginView` provides the same functionality as Django's 130 | built-in :class:`~django.contrib.auth.views.LoginView`, except it looks for 131 | a django-sesame token in the URL instead of asking for credentials. 132 | 133 | Configure the view in your URLconf: 134 | 135 | .. code-block:: python 136 | 137 | from django.urls import path 138 | from sesame.views import LoginView 139 | 140 | urlpatterns = [ 141 | ..., 142 | path("sesame/login/", LoginView.as_view(), name="sesame-login"), 143 | ..., 144 | ] 145 | 146 | URLs become longer e.g. 147 | ``https://example.com/sesame/login/?sesame=<...>&next=%2Fwelcome%2F``. On the 148 | positive side, enabling authentication at only one URL yields security benefits: 149 | it's easier to add throttling, to monitor traffic, etc. 150 | 151 | View decorator 152 | .............. 153 | 154 | .. versionadded:: 3.0 155 | 156 | Sometimes the behavior of :class:`~sesame.middleware.AuthenticationMiddleware` 157 | and :class:`~sesame.views.LoginView` is too blunt. Maybe you want to authorize 158 | access to a specific view without logging the user in. Or maybe you want to 159 | :ref:`restrict tokens to specific scopes `. 160 | 161 | Decorate a view with :func:`sesame.decorators.authenticate` to look for a token 162 | and set ``request.user``. 163 | 164 | :func:`~sesame.decorators.authenticate` may be applied to a view directly: 165 | 166 | .. code-block:: python 167 | 168 | from django.http import HttpResponse 169 | from sesame.decorators import authenticate 170 | 171 | @authenticate 172 | def hello(request): 173 | return HttpResponse(f"Hello {request.user}!") 174 | 175 | Or it may be applied with arguments: 176 | 177 | .. code-block:: python 178 | 179 | @authenticate(override=False) 180 | def hello(request): 181 | return HttpResponse(f"Hello {request.user}!") 182 | 183 | :func:`~sesame.decorators.authenticate` can be configured to provide several behaviors: 184 | 185 | * When no valid token is found, it may return a HTTP 403 Forbidden error or, 186 | when ``required=False``, set ``request.user`` to an 187 | :class:`~django.contrib.auth.models.AnonymousUser`. 188 | * When a valid token is found, it may set ``request.user`` to the corresponding 189 | user or, when ``permanent=True``, also log the user in permanently. 190 | * When a user is already logged in and a valid token is found, it may override 191 | ``request.user`` or, when ``override=False``, ignore the token. 192 | 193 | Custom view logic 194 | ................. 195 | 196 | You can call the low-level :func:`sesame.utils.get_user` function to 197 | authenticate a user directly: 198 | 199 | .. code-block:: python 200 | 201 | from django.core.exceptions import PermissionDenied 202 | from django.http import HttpResponse 203 | 204 | from sesame.utils import get_user 205 | 206 | def hello(request): 207 | user = get_user(request) 208 | if user is None: 209 | raise PermissionDenied 210 | return HttpResponse(f"Hello {user}!") 211 | 212 | :func:`~sesame.utils.get_user` returns :obj:`None` when no valid token is found. 213 | Then you can show an appropriate error message or redirect to a login mechanism. 214 | 215 | Outside a view 216 | .............. 217 | 218 | You may want to authenticate users outside of a Django view, where there's no 219 | :class:`~django.http.HttpRequest` object available. To support this use case, 220 | :func:`~sesame.utils.get_user` also accepts a token directly. 221 | 222 | .. code-block:: python 223 | 224 | from sesame.utils import get_user 225 | 226 | user = get_user(token) 227 | 228 | In other words, you may use :func:`~sesame.utils.get_user` as the inverse of 229 | :func:`~sesame.utils.get_token`. 230 | 231 | Low-level 232 | ......... 233 | 234 | The low-level :func:`~django.contrib.auth.authenticate` function provided by 235 | :mod:`django.contrib.auth` can verify a token directly: 236 | 237 | .. code-block:: python 238 | 239 | from django.contrib.auth import authenticate 240 | 241 | user = authenticate(sesame=token) 242 | 243 | Then, you can log the user in with :func:`~django.contrib.auth.login`. 244 | 245 | While this is technically possible, it is best to stick with 246 | :func:`~sesame.utils.get_user` because :func:`~django.contrib.auth.authenticate` 247 | doesn't invalidate single-use tokens. 248 | 249 | Tokens expiration 250 | ----------------- 251 | 252 | When you configure django-sesame, you must decide whether tokens will expire or 253 | will remain valid forever. You cannot mix expiring and non-expiring tokens 254 | within the same project. 255 | 256 | In most cases, expiring tokens are a better choice: 257 | 258 | * You get better security properties, especially in case a token leaks. 259 | * You can customize the lifetime of tokens to support different use cases. 260 | * You can emulate non-expiring tokens by configuring a very long lifetime. 261 | 262 | Set the :data:`SESAME_MAX_AGE` setting to enable expiring tokens and to 263 | configure their lifetime. It may be expressed as a :class:`~datetime.timedelta` 264 | or a duration in seconds. 265 | 266 | If you have several use cases requiring different lifetimes, you can override 267 | :data:`SESAME_MAX_AGE` when you authenticate a token. 268 | 269 | :class:`~sesame.views.LoginView`, :func:`~sesame.decorators.authenticate`, and 270 | :func:`~sesame.utils.get_user` support a ``max_age`` argument: 271 | 272 | .. code-block:: python 273 | 274 | from sesame.utils import get_user 275 | 276 | user = get_user(token, max_age=180) # 180 seconds = 3 minutes 277 | 278 | You cannot override :data:`SESAME_MAX_AGE` when you generate a token because 279 | tokens store only the time when they were created, not their expected lifetime. 280 | 281 | Non-expiring are acceptable for simple cases where tokens should remain valid 282 | forever and where security concerns are low. 283 | 284 | Set :data:`SESAME_MAX_AGE` to :obj:`None`, its default value, to generate 285 | non-expiring tokens. They don't store the time when they were created. As a 286 | consequence, if you need to switch to expiring tokens later, you will have to 287 | change :data:`SESAME_MAX_AGE`, which will invalidate all existing tokens. 288 | 289 | Single-use tokens 290 | ----------------- 291 | 292 | If you set the :data:`SESAME_ONE_TIME` setting to :obj:`True`, tokens will be 293 | usable only once. 294 | 295 | .. admonition:: Authenticating with a single-use token always updates the user's 296 | last login date. 297 | :class: warning 298 | 299 | This is how django-sesame :ref:`invalidates single-use tokens ` 300 | after they're used. 301 | 302 | Like expiration, this is a global setting for the project. Changing it 303 | invalidates all existing tokens. 304 | 305 | Tokens with a short lifetime are often a better choice than single-use tokens 306 | because they don't require the user to obtain a new token in many circumstances 307 | where the token gets invalidated before serving its purpose. 308 | 309 | For example, when doing :ref:`login by email `, the client could 310 | timeout while fetching the response. In that case, the user may click the link 311 | again, but the token was invalidated by their first attempt. They would get a 312 | better experience if the link still worked. 313 | 314 | Scoped tokens 315 | ------------- 316 | 317 | If your application uses tokens for multiple purposes, you should prevent a 318 | token created for one purpose from being reused for another purpose. 319 | 320 | You achieve this by assigning a scope to tokens. You must provide the same scope 321 | when you generate a token and when you authenticate it. Else, it's invalid. 322 | 323 | For example, if you're generating a token for giving access to the report with 324 | ID 66, you can set the token's scope to ``"report:66"``. 325 | 326 | .. admonition:: The default scope (``""``) behaves exactly like any other scope. 327 | :class: tip 328 | 329 | Tokens generated with the default scope are only valid in the default scope. 330 | Tokens generated with another scope aren't valid in the default scope. 331 | 332 | You should reserve the default scope for logging users in. Any other use 333 | case warrants a dedicated scope. 334 | 335 | :func:`~sesame.utils.get_query_string`, :func:`~sesame.utils.get_parameters`, 336 | and :func:`~sesame.utils.get_token` accept an optional ``scope`` argument to 337 | generate scoped tokens: 338 | 339 | .. code-block:: pycon 340 | 341 | >>> from sesame.utils import get_query_string 342 | >>> report_id = 66 343 | >>> get_token(user, scope=f"report:{report_id}") 344 | 'jISWHmrXr4zg8FHVZZuxhpHs' 345 | 346 | :class:`~sesame.views.LoginView`, :func:`~sesame.decorators.authenticate`, and 347 | :func:`~sesame.utils.get_user` accept the same ``scope`` argument to 348 | authenticate scoped tokens: 349 | 350 | .. code-block:: python 351 | 352 | from sesame.utils import get_user 353 | 354 | def share_report(request, report_id): 355 | user = get_user(request, scope=f"report:{report_id}") 356 | if user is None: 357 | raise PermissionDenied 358 | ... 359 | 360 | This view can be implemented more concisely, albeit more magically, as follows: 361 | 362 | .. code-block:: python 363 | 364 | from sesame.decorators import authenticate 365 | 366 | @authenticate(scope="report:{report_id}") 367 | def share_report(request, report_id): 368 | ... 369 | 370 | .. admonition:: :class:`~sesame.middleware.AuthenticationMiddleware` doesn't support scopes. 371 | :class: warning 372 | 373 | It only accepts tokens generated with the default scope. 374 | -------------------------------------------------------------------------------- /logo/vertical.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /logo/horizontal.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /tests/test_tokens_v2.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import datetime 3 | import struct 4 | import unittest.mock 5 | 6 | from django.conf import settings 7 | from django.test import TestCase, override_settings 8 | from django.utils import timezone 9 | 10 | from sesame import packers 11 | from sesame.tokens_v2 import ( 12 | TIMESTAMP_OFFSET, 13 | create_token, 14 | detect_token, 15 | get_revocation_key, 16 | parse_token, 17 | ) 18 | 19 | from .mixins import CaptureLogMixin, CreateUserMixin 20 | 21 | 22 | class TestTokensV2(CaptureLogMixin, CreateUserMixin, TestCase): 23 | def test_valid_token(self): 24 | token = create_token(self.user) 25 | self.assertTrue(detect_token(token)) 26 | user = parse_token(token, self.get_user) 27 | self.assertEqual(user, self.user) 28 | self.assertLogsContain("Valid token for user john in default scope") 29 | 30 | # Test invalid tokens 31 | 32 | @override_settings(SESAME_MAX_AGE=300) 33 | def test_invalid_base64_string(self): 34 | token = "deadbeef-" 35 | self.assertTrue(detect_token(token)) 36 | user = parse_token(token, self.get_user) 37 | self.assertIsNone(user) 38 | self.assertLogsContain("Bad token: cannot decode token") 39 | 40 | @override_settings(SESAME_MAX_AGE=300) 41 | def test_truncated_token_in_primary_key(self): 42 | token = create_token(self.user) 43 | # Primary key is in bytes 0 - 5 1/3 44 | token = token[:4] 45 | self.assertTrue(detect_token(token)) 46 | user = parse_token(token, self.get_user) 47 | self.assertIsNone(user) 48 | self.assertLogsContain("Bad token: cannot extract primary key") 49 | 50 | @override_settings(SESAME_MAX_AGE=300) 51 | def test_truncated_token_in_timestamp(self): 52 | token = create_token(self.user) 53 | # Timestamp is in bytes 5 1/3 - 10 2/3 54 | token = token[:8] 55 | self.assertTrue(detect_token(token)) 56 | user = parse_token(token, self.get_user) 57 | self.assertIsNone(user) 58 | self.assertLogsContain("Bad token: cannot extract timestamp") 59 | 60 | @override_settings(SESAME_MAX_AGE=300) 61 | def test_truncated_token_in_signature(self): 62 | token = create_token(self.user) 63 | # Signature is in bytes 10 2/3 - 24 64 | token = token[:12] 65 | self.assertTrue(detect_token(token)) 66 | user = parse_token(token, self.get_user) 67 | self.assertIsNone(user) 68 | self.assertLogsContain("Bad token: cannot extract signature") 69 | 70 | def test_invalid_signature(self): 71 | token = create_token(self.user) 72 | # Alter signature, which is in bytes 5 1/3 - 18 2/3 73 | token = token[:6] + token[6:].lower() 74 | self.assertTrue(detect_token(token)) 75 | user = parse_token(token, self.get_user) 76 | self.assertIsNone(user) 77 | self.assertLogsContain("Invalid token for user john in default scope") 78 | 79 | @override_settings(SESAME_MAX_AGE=300) 80 | def test_random_token(self): 81 | token = "!@#$" * 6 82 | self.assertEqual(len(token), len(create_token(self.user))) 83 | self.assertFalse(detect_token(token)) 84 | user = parse_token(token, self.get_user) 85 | self.assertIsNone(user) 86 | self.assertLogsContain("Bad token") 87 | 88 | def test_unknown_user(self): 89 | token = create_token(self.user) 90 | self.user.delete() 91 | self.assertTrue(detect_token(token)) 92 | user = parse_token(token, self.get_user) 93 | self.assertIsNone(user) 94 | self.assertLogsContain("Unknown or inactive user: pk = 1") 95 | 96 | # Test token expiry 97 | 98 | @override_settings(SESAME_MAX_AGE=300) 99 | def test_valid_max_age_token(self): 100 | token = create_token(self.user) 101 | self.assertTrue(detect_token(token)) 102 | user = parse_token(token, self.get_user) 103 | self.assertEqual(user, self.user) 104 | self.assertLogsContain("Valid token for user john in default scope") 105 | 106 | @override_settings(SESAME_MAX_AGE=-300) 107 | def test_expired_max_age_token(self): 108 | token = create_token(self.user) 109 | self.assertTrue(detect_token(token)) 110 | user = parse_token(token, self.get_user) 111 | self.assertIsNone(user) 112 | self.assertLogsContain("Expired token") 113 | 114 | @override_settings(SESAME_MAX_AGE=-300) 115 | def test_extended_max_age_token(self): 116 | token = create_token(self.user) 117 | with override_settings(SESAME_MAX_AGE=300): 118 | self.assertTrue(detect_token(token)) 119 | user = parse_token(token, self.get_user) 120 | self.assertEqual(user, self.user) 121 | self.assertLogsContain("Valid token for user john in default scope") 122 | 123 | @override_settings(SESAME_MAX_AGE=300) 124 | def test_max_age_token_without_timestamp(self): 125 | with override_settings(SESAME_MAX_AGE=None): 126 | token = create_token(self.user) 127 | self.assertTrue(detect_token(token)) 128 | user = parse_token(token, self.get_user) 129 | self.assertIsNone(user) 130 | self.assertLogsContain("Bad token: cannot extract signature") 131 | 132 | def test_token_with_timestamp(self): 133 | with override_settings(SESAME_MAX_AGE=300): 134 | token = create_token(self.user) 135 | self.assertTrue(detect_token(token)) 136 | user = parse_token(token, self.get_user) 137 | self.assertIsNone(user) 138 | self.assertLogsContain("Bad token: cannot extract signature") 139 | 140 | # Test one-time tokens 141 | 142 | @override_settings(SESAME_ONE_TIME=True) 143 | def test_valid_one_time_token(self): 144 | self.test_valid_token() 145 | 146 | @override_settings(SESAME_ONE_TIME=True) 147 | def test_valid_one_time_token_when_user_never_logged_in(self): 148 | self.user.last_login = None 149 | self.user.save() 150 | self.test_valid_token() 151 | 152 | @override_settings(SESAME_ONE_TIME=True) 153 | def test_one_time_token_invalidation_when_last_login_date_changes(self): 154 | token = create_token(self.user) 155 | self.user.last_login = timezone.now() - datetime.timedelta(1800) 156 | self.user.save() 157 | user = parse_token(token, self.get_user) 158 | self.assertIsNone(user) 159 | self.assertLogsContain("Invalid token for user john in default scope") 160 | 161 | # Test token invalidation on password change 162 | 163 | def test_valid_token_when_user_has_no_password(self): 164 | self.user.password = "" 165 | self.user.save() 166 | self.test_valid_token() 167 | 168 | def test_valid_token_when_user_has_unusable_password(self): 169 | self.user.set_unusable_password() 170 | self.user.save() 171 | self.test_valid_token() 172 | 173 | def test_invalid_token_after_password_change(self): 174 | token = create_token(self.user) 175 | self.user.set_password("hunter2") 176 | self.user.save() 177 | user = parse_token(token, self.get_user) 178 | self.assertIsNone(user) 179 | self.assertLogsContain("Invalid token for user john in default scope") 180 | 181 | @override_settings(SESAME_INVALIDATE_ON_PASSWORD_CHANGE=False) 182 | def test_valid_token_after_password_change(self): 183 | token = create_token(self.user) 184 | self.user.set_password("hunter2") 185 | self.user.save() 186 | user = parse_token(token, self.get_user) 187 | self.assertEqual(user, self.user) 188 | self.assertLogsContain("Valid token for user john in default scope") 189 | 190 | # Test token invalidation on email change 191 | 192 | def test_valid_token_after_email_change(self): 193 | token = create_token(self.user) 194 | self.user.email = "email@example.com" 195 | self.user.save() 196 | user = parse_token(token, self.get_user) 197 | self.assertEqual(user, self.user) 198 | self.assertLogsContain("Valid token for user john in default scope") 199 | 200 | @override_settings(SESAME_INVALIDATE_ON_EMAIL_CHANGE=True) 201 | def test_invalid_token_after_email_change(self): 202 | token = create_token(self.user) 203 | self.user.email = "email@example.com" 204 | self.user.save() 205 | user = parse_token(token, self.get_user) 206 | self.assertIsNone(user) 207 | self.assertLogsContain("Invalid token for user john in default scope") 208 | 209 | # Test scoped tokens 210 | 211 | def test_valid_scoped_token_in_scope(self): 212 | token = create_token(self.user, scope="test") 213 | self.assertTrue(detect_token(token)) 214 | user = parse_token(token, self.get_user, scope="test") 215 | self.assertEqual(user, self.user) 216 | self.assertLogsContain("Valid token for user john in scope test") 217 | 218 | def test_invalid_scoped_token_in_other_scope(self): 219 | token = create_token(self.user, scope="test") 220 | self.assertTrue(detect_token(token)) 221 | user = parse_token(token, self.get_user, scope="other") 222 | self.assertIsNone(user) 223 | self.assertLogsContain("Invalid token for user john in scope other") 224 | 225 | def test_invalid_scoped_token_in_default_scope(self): 226 | token = create_token(self.user, scope="test") 227 | self.assertTrue(detect_token(token)) 228 | user = parse_token(token, self.get_user) 229 | self.assertIsNone(user) 230 | self.assertLogsContain("Invalid token for user john in default scope") 231 | 232 | def test_invalid_token_in_scope(self): 233 | token = create_token(self.user) 234 | self.assertTrue(detect_token(token)) 235 | user = parse_token(token, self.get_user, scope="test") 236 | self.assertIsNone(user) 237 | self.assertLogsContain("Invalid token for user john in scope test") 238 | 239 | # Test custom max_age 240 | 241 | @override_settings(SESAME_MAX_AGE=-300) 242 | def test_custom_max_age(self): 243 | token = create_token(self.user) 244 | self.assertTrue(detect_token(token)) 245 | user = parse_token(token, self.get_user, max_age=300) 246 | self.assertEqual(user, self.user) 247 | self.assertLogsContain("Valid token for user john") 248 | 249 | @override_settings(SESAME_MAX_AGE=-300) 250 | def test_custom_max_age_timedelta(self): 251 | token = create_token(self.user) 252 | self.assertTrue(detect_token(token)) 253 | max_age = datetime.timedelta(seconds=300) 254 | user = parse_token(token, self.get_user, max_age=max_age) 255 | self.assertEqual(user, self.user) 256 | self.assertLogsContain("Valid token for user john") 257 | 258 | def test_custom_max_age_ignored(self): 259 | token = create_token(self.user) 260 | self.assertTrue(detect_token(token)) 261 | user = parse_token(token, self.get_user, max_age=-300) 262 | self.assertEqual(user, self.user) 263 | self.assertLogsContain("Ignoring max_age argument") 264 | self.assertLogsContain("Valid token for user john") 265 | 266 | # Test custom primary key field 267 | 268 | @override_settings(SESAME_PRIMARY_KEY_FIELD="username") 269 | def test_alternative_primary_key_is_used(self): 270 | token = create_token(self.user) 271 | # base64.b64encode(b"\x04" + "john".encode()).decode() == "BGpvaG4=" 272 | self.assertEqual(token[:6], "BGpvaG") 273 | self.assertTrue(detect_token(token)) 274 | 275 | def test_alternative_primary_key_change(self): 276 | # Create a confusion scenario where changing the primary key field 277 | # causes the token to match another user. 278 | self.user.username = "joe" 279 | self.user.save() 280 | self.create_user( 281 | pk=struct.unpack("!l", b"\x03joe")[0], 282 | username="jane", 283 | ) 284 | with override_settings(SESAME_PRIMARY_KEY_FIELD="username"): 285 | token = create_token(self.user) 286 | user = parse_token(token, self.get_user) 287 | # Signature is invalid. 288 | self.assertIsNone(user) 289 | self.assertLogsContain("Invalid token for user jane in default scope") 290 | 291 | # Test custom primary key packer 292 | 293 | @override_settings( 294 | AUTH_USER_MODEL="tests.StrUser", 295 | SESAME_PACKER="tests.test_packers.Packer", 296 | ) 297 | def test_custom_packer_is_used(self): 298 | # CreateUserMixin.setUp() ran before @override_settings changed the 299 | # user model. 300 | user = self.create_user(username="abcdef012345abcdef567890") 301 | token = create_token(user) 302 | # base64.b64encode(bytes.fromhex(username)).decode() == "q83vASNFq83vVniQ" 303 | self.assertEqual(token[:16], "q83vASNFq83vVniQ") 304 | self.assertTrue(detect_token(token)) 305 | 306 | def test_custom_packer_change(self): 307 | # Create a confusion scenario where changing the primary key field 308 | # causes the token to match another user. 309 | self.create_user( 310 | pk=(self.user.pk << 16) + self.user.pk, 311 | username="jane", 312 | ) 313 | with override_settings(SESAME_PACKER="tests.test_packers.RepeatPacker"): 314 | token = create_token(self.user) 315 | user = parse_token(token, self.get_user) 316 | # Signature is invalid. 317 | self.assertIsNone(user) 318 | self.assertLogsContain("Invalid token for user jane in default scope") 319 | 320 | def test_custom_packer_raises_exception(self): 321 | token = create_token(self.user) 322 | with override_settings(SESAME_PACKER="tests.test_packers.RepeatPacker"): 323 | user = parse_token(token, self.get_user) 324 | self.assertIsNone(user) 325 | self.assertLogsContain("Bad token: cannot extract primary key") 326 | 327 | # Test key rotation 328 | 329 | def test_key_change_invalidates_tokens(self): 330 | """Token signature changes if SESAME_KEY changes.""" 331 | token = create_token(self.user) 332 | with override_settings(SESAME_KEY="new"): 333 | user = parse_token(token, self.get_user) 334 | self.assertIsNone(user) 335 | self.assertLogsContain("Invalid token for user john in default scope") 336 | 337 | def test_secret_key_change_invalidates_tokens(self): 338 | """Token signature changes if SECRET_KEY changes.""" 339 | token = create_token(self.user) 340 | with override_settings(SECRET_KEY="new"): 341 | user = parse_token(token, self.get_user) 342 | self.assertIsNone(user) 343 | self.assertLogsContain("Invalid token for user john in default scope") 344 | 345 | def test_secret_key_fallback_keeps_tokens_valid(self): 346 | token = create_token(self.user) 347 | with override_settings( 348 | SECRET_KEY="new", 349 | SECRET_KEY_FALLBACKS=[settings.SECRET_KEY], 350 | ): 351 | user = parse_token(token, self.get_user) 352 | self.assertEqual(user, self.user) 353 | self.assertLogsContain("Valid token for user john in default scope") 354 | 355 | def test_bad_secret_key_fallback_invalidates_tokens(self): 356 | token = create_token(self.user) 357 | with override_settings( 358 | SECRET_KEY="new", 359 | SECRET_KEY_FALLBACKS=["bad"], 360 | ): 361 | user = parse_token(token, self.get_user) 362 | self.assertIsNone(user) 363 | self.assertLogsContain("Invalid token for user john in default scope") 364 | 365 | # Miscellaneous tests 366 | 367 | @staticmethod 368 | def decode_token(token): 369 | token = token.encode() 370 | return base64.urlsafe_b64decode(token + b"=" * (-len(token) % 4)) 371 | 372 | @staticmethod 373 | def encode_token(data): 374 | token = base64.urlsafe_b64encode(data).rstrip(b"=") 375 | return token.decode() 376 | 377 | def test_primary_key_and_timestamp_confusion(self): 378 | """Token signature changes if SESAME_MAX_AGE is enabled or disabled.""" 379 | TIME = TIMESTAMP_OFFSET + (1 << 24) 380 | user1 = self.user 381 | with override_settings(SESAME_MAX_AGE=300): 382 | with unittest.mock.patch("time.time", return_value=TIME): 383 | token1 = create_token(user1) 384 | 385 | with override_settings(AUTH_USER_MODEL="tests.BigAutoUser"): 386 | user2 = self.create_user("jane", pk=(user1.pk << 32) + (1 << 24)) 387 | token2 = create_token(user2) 388 | # Duplicate user1 in the BigAutoUser table. 389 | user1.__class__ = user2.__class__ 390 | user1.save() 391 | 392 | # The first 8 bytes are the same: 393 | # - token1: primary key of user1 and timestamp 394 | # - token2: primary key of user2 395 | self.assertEqual(self.decode_token(token1)[:8], self.decode_token(token2)[:8]) 396 | 397 | with override_settings(AUTH_USER_MODEL="tests.BigAutoUser"): 398 | user = parse_token(token1, self.get_user) 399 | self.assertIsNone(user) 400 | self.assertLogsContain("Invalid token for user jane") 401 | 402 | with override_settings(SESAME_MAX_AGE=300): 403 | with unittest.mock.patch("time.time", return_value=TIME + 1): 404 | user = parse_token(token2, self.get_user) 405 | self.assertIsNone(user) 406 | self.assertLogsContain("Invalid token for user john in default scope") 407 | 408 | def test_packer_confusion(self): 409 | """Token signature changes if SESAME_PACKER changes.""" 410 | user1 = self.user 411 | with override_settings(SESAME_PACKER="tests.test_packers.RepeatPacker"): 412 | token1 = create_token(user1) 413 | user2 = self.create_user("jane", pk=(user1.pk << 16) + user1.pk) 414 | token2 = create_token(user2) 415 | 416 | # The first 4 bytes are the same: 417 | # - token1: primary key of user1 encoded with RepeatPacker 418 | # - token2: primary key of user2 419 | self.assertEqual(self.decode_token(token1)[:4], self.decode_token(token2)[:4]) 420 | 421 | user = parse_token(token1, self.get_user) 422 | self.assertIsNone(user) 423 | self.assertLogsContain("Invalid token for user jane") 424 | 425 | with override_settings(SESAME_PACKER="tests.test_packers.RepeatPacker"): 426 | user = parse_token(token2, self.get_user) 427 | self.assertIsNone(user) 428 | self.assertLogsContain("Invalid token for user john in default scope") 429 | 430 | @override_settings(SESAME_INVALIDATE_ON_PASSWORD_CHANGE=False) 431 | def test_naive_token_hijacking_fails(self): 432 | # The revocation key may be identical for two users: 433 | # - if SESAME_INVALIDATE_ON_PASSWORD_CHANGE is False or if they don't 434 | # have a password; 435 | # - if SESAME_ONE_TIME is False, which is the default, or if they have 436 | # the same last_login. 437 | # In that case, could one user could impersonate the other? 438 | user1 = self.user 439 | user2 = self.create_user("jane") 440 | 441 | token1 = create_token(user1) 442 | token2 = create_token(user2) 443 | 444 | # Check that the test scenario produces identical revocation keys. 445 | self.assertEqual(get_revocation_key(user1), get_revocation_key(user2)) 446 | 447 | # Check that changing just the PK doesn't allow hijacking the other 448 | # user's account -- because the PK is included in the signature. 449 | data1 = self.decode_token(token1) 450 | data2 = self.decode_token(token2) 451 | self.assertEqual(data1[:4], packers.packer.pack_pk(user1.pk)) 452 | self.assertEqual(data2[:4], packers.packer.pack_pk(user2.pk)) 453 | 454 | # Check that changing just the primary key doesn't allow hijacking the 455 | # other user's account. 456 | token = self.encode_token(data2[:4] + data1[4:]) 457 | 458 | user = parse_token(token, self.get_user) 459 | self.assertIsNone(user) 460 | self.assertLogsContain("Invalid token for user jane") 461 | --------------------------------------------------------------------------------