.. image:: logo/horizontal.svg
   :width: 400px
   :alt: django-sesame

`django-sesame`_ provides frictionless authentication with "Magic Links" for
your Django project.

.. _django-sesame: https://github.com/aaugustin/django-sesame

It generates URLs containing authentication tokens such as:
https://example.com/?url_auth_token=AAAAARchl18CIQUlImmbV9q7PZk%3A89AEU34b0JLSrkT8Ty2RPISio5

Then it authenticates users based on tokens found in URLs.

Use cases
=========

Known use cases for django-sesame include:

1. Login by email, an increasingly attractive option on mobile where
   typing passwords is uncomfortable. This technique is prominently
   deployed by Slack.

   If you're doing this, you should define a small ``SESAME_MAX_AGE``, perhaps
   10 minutes.

2. Authenticated links, typically if you're generating a report offline, then
   emailing a link to access it when it's ready. An authenticated link works
   even if the user isn't logged in on the device where they're opening it.

   Likewise, you should configure an appropriate ``SESAME_MAX_AGE``, probably
   no more than a few days.

   Since emails may be forwarded, authenticated links shouldn't log the user
   in. They should only allow access to specific views, as described in
   "Per-view authentication" below.

3. Sharing links, which are a variant of authenticated links. When a user
   shares content with a guest, you can create a phantom account for the guest
   and generate an authenticated link tied to that account.

   Email forwarding is even more likely in this context. If you're doing this,
   make sure authenticated links don't log the user in.

4. Non-critical private websites, for example for a family or club site,
   where users don't expect to manage a personal account with a password.
   Authorized users can bookmark personalized authenticated URLs.

   Here you can rely on the default settings because that's the original —
   and, admittedly, niche — use case for which django-sesame was built.

(In)security
============

**Before using django-sesame in your project, please review the following
advice carefully.** (Also, please don't use security-sensitive libraries
published by strangers on the Internet without checking what they do.)

The major security weakness in django-sesame is a direct consequence of the
feature it implements: **whoever obtains an authentication token will be able
to authenticate to your website.**

URLs end up in countless insecure places: emails, referer headers, proxy logs,
browser history, etc. You can't avoid that. At best you can mitigate it by
creating short-lived or single-use tokens, as described below.

Otherwise, a reasonable attempt has been made to provide a secure solution.
django-sesame uses Django's signing framework to create signed tokens. It
offers configurable options for token expiration or invalidation.

Requirements
============

django-sesame is tested with:

- Django 2.2 (LTS) and 3.0;
- all supported Python versions.

It builds upon ``django.contrib.auth``.

It supports custom user models, provided they have ``password`` and
``last_login`` fields. Most custom user models inherit these fields from
``AbstractBaseUser``.

django-sesame is released under the BSD license, like Django itself.

Getting started
===============

1. Install django-sesame and ua-parser::

    $ pip install django-sesame
    $ pip install ua-parser  # optional, see "Safari issues" below

2. Add ``sesame.backends.ModelBackend`` to ``AUTHENTICATION_BACKENDS``::

    AUTHENTICATION_BACKENDS += ["sesame.backends.ModelBackend"]

3. Add ``sesame.middleware.AuthenticationMiddleware`` to ``MIDDLEWARE``::

    MIDDLEWARE += ["sesame.middleware.AuthenticationMiddleware"]

   The best position for ``sesame.middleware.AuthenticationMiddleware`` is
   just after ``django.contrib.auth.middleware.AuthenticationMiddleware``.

4. (Optional) For better security, set ``SESAME_SALT`` to a random string with
   at least 16 bytes of entropy::

    SESAME_SALT = "..."

   You can generate such a random string in a Python shell with::

    >>> from django.utils.crypto import get_random_string
    >>> get_random_string(22)

5. Generate authentication tokens with ``sesame.utils.get_query_string(user)``.

That's all!

Generating URLs
===============

django-sesame provides two functions to generate authenticated URLs.

1. ``sesame.utils.get_query_string(user)`` returns a complete query string
   that you can append to any URL to enable one-click login.

2. ``sesame.utils.get_parameters(user)`` returns a dictionary of GET
   parameters to add to the query string, if you're already building one.

Share resulting URLs with your users while ensuring adequate confidentiality.

By default, the URL parameter is called ``url_auth_token``. You can set the
``SESAME_TOKEN_NAME`` setting to a shorter name that doesn't conflict with
query string parameters used by your application.

Tokens lifecycle
================

By default, tokens don't expire but are tied to the password of the user.
Changing the password invalidates the token. When the authentication backend
uses salted passwords — that's been the default in Django for a long time —
the token is invalidated even if the new password is identical to the old one.

If you want tokens to expire after a given amount of time, set the
``SESAME_MAX_AGE`` setting to a duration in seconds. Then each token will
contain the time it was generated at and django-sesame will check if it's
still valid at each login attempt.

If you want tokens to be usable only once, set the ``SESAME_ONE_TIME`` setting
to ``True``. In that case tokens are only valid if the last login date hasn't
changed since they were generated. Since logging in changes the last login
date, such tokens are usable at most once. If you're intending to send links
by email, be aware that some email providers scan links for security reasons,
which consumes single-use tokens prematurely. Tokens with a short expiry are
more reliable.

If you don't want tokens to be invalidated by password changes, set the
``SESAME_INVALIDATE_ON_PASSWORD_CHANGE`` setting to ``False``. **This is
strongly discouraged because it becomes impossible to invalidate a token**
short of changing the ``SESAME_SALT`` setting and invalidating all tokens at
once. If you're doing it anyway, you should set ``SESAME_MAX_AGE`` to a short
value to minimize risks. This option may be useful for generating tokens
during a signup process, when you don't know if the token will be used before
or after initializing the password.

Finally, if the ``is_active`` attribute of a user is set to ``False``,
django-sesame rejects authentication tokens for this user.

Tokens must be verified with the same settings that were used for generating
them. Changing settings invalidates previously generated tokens. The only
exception to this rule is ``SESAME_MAX_AGE``: as long as it isn't ``None``,
you can change its value and the new value will apply even to previously
generated tokens.

Per-view authentication
=======================

The configuration described in the "Getting started" section enables a
middleware that looks for a token in every request and, if there is a valid
token, logs the user in. It's as if they had submitted their username and
password in a login form. This provides compatibility with APIs like the
``login_required`` decorator and the ``LoginRequired`` mixin.

Sometimes this behavior is too blunt. For example, you may want to build a
Magic Link that gives access to a specific view but doesn't log the user in
permanently.

To achieve this, you can remove ``sesame.middleware.AuthenticationMiddleware``
from the ``MIDDLEWARE`` setting and authenticate the user with django-sesame
in a view as follows::

    from django.core.exceptions import PermissionDenied
    from django.http import HttpResponse

    from sesame.utils import get_user

    def hello(request):
        user = get_user(request)
        if user is None:
            raise PermissionDenied
        return HttpResponse("Hello {}!".format(user))

When ``get_user()`` returns ``None``, it means that the token was missing,
invalid, expired, or already used. Then you can show an appropriate error
message or redirect to a login form.

When ``SESAME_ONE_TIME`` is enabled, ``get_user()`` updates the user's last
login date in order to invalidate the token. When ``SESAME_ONE_TIME`` isn't
enabled, it doesn't, because making a database write for every call to
``get_user()`` could degrade performance. You can override this behavior with
the ``update_last_login`` keyword argument::

    get_user(request, update_last_login=True)   # always update last_login
    get_user(request, update_last_login=False)  # never update last_login

``get_user()`` is a thin wrapper around the low-level ``authenticate()``
function from ``django.contrib.auth``. It's also possible to verify an
authentication token directly with  ``authenticate()``. To do so, the
``sesame.backends.ModelBackend`` authentication backend expects an
``url_auth_token`` argument::

    from django.contrib.auth import authenticate

    user = authenticate(url_auth_token=...)

If you decide to use ``authenticate()`` instead of ``get_user()``, you must
update ``user.last_login`` to invalidate one-time tokens. Indeed, in
``django.contrib.auth``, ``authenticate()`` is a low-level function. The
caller, usually the higher-level ``login()`` function, is responsible for
updating ``user.last_login``.

Safari issues
=============

The django-sesame middleware removes the token from the URL with a HTTP 302
Redirect after authenticating a user successfully. Unfortunately, in some
scenarios, this triggers Safari's "Protection Against First Party Bounce
Trackers". In that case, Safari clears cookies and the user is logged out.

To avoid this problem, django-sesame doesn't perform the redirect when it
detects that the browser is Safari. This relies on the ua-parser package,
which is an optional dependency. If it isn't installed, django-sesame always
redirects.

Custom primary keys
===================

When generating a token for a user, django-sesame stores the primary key of
that user in the token. In order to keep tokens short, django-sesame creates
compact binary representations of primary keys, according to their type.

If you're using integer or UUID primary keys, you're fine. If you're using
another type of primary key, for example a string created by a unique ID
generation algorithm, the default representation may be suboptimal.

For example, let's say primary keys are strings containing 24 hexadecimal
characters. The default packer represents them with 25 bytes. You can reduce
them to 12 bytes with this custom packer::

    from sesame.packers import BasePacker

    class Packer(BasePacker):

        @staticmethod
        def pack_pk(user_pk):
            assert len(user_pk) == 24
            return bytes.fromhex(user_pk)

        @staticmethod
        def unpack_pk(data):
            return data[:12].hex(), data[12:]

Then, set the ``SESAME_PACKER`` setting to the dotted Python path to your
custom packer class.

For details, read ``help(BasePacker)`` and look at built-in packers defined in
the ``sesame.packers`` module.

Tokens security
===============

Authentication tokens generated by django-sesame contain:

- The primary key of the user for which they were generated;
- A revocation key which is used for invalidating tokens.

The revocation key includes:

- The hashed password of the user, unless
  ``SESAME_INVALIDATE_ON_PASSWORD_CHANGE`` is disabled;
- The last login date of the user, if ``SESAME_ONE_TIME`` is enabled.

Primary keys are in clear text. If this is a concern, you can write a custom
packer to encrypt them. See "Custom primary keys" above for details.

Revocation keys are hashed in order to keep tokens short. Also, this avoids
leaking the password hash and corresponding salt if a token is compromised.

The hashing algorithm is PBKDF2 with 10 000 iterations of MD5. It provides a
16 bytes hash with better security than a single round of MD5. The digest
algorithm and number of iterations can be altered with the ``SESAME_DIGEST``
and ``SESAME_ITERATIONS`` settings. The salt is taken from the ``SESAME_SALT``
setting.

Finally, tokens are encoded with URL-safe Base64 and signed with Django's
built-in ``Signer`` or ``TimestampSigner``, depending on whether
``SESAME_MAX_AGE`` is set, to prevent tampering. The signature algorithm
factors in the salt defined in the ``SESAME_SALT`` setting.

Stateless authentication
========================

Technically, django-sesame can provide stateless authenticated navigation
without ``django.contrib.sessions``, provided all internal links include the
authentication token, but that increases the security issues explained above.

If ``django.contrib.sessions.middleware.SessionMiddleware`` and
``django.contrib.auth.middleware.AuthenticationMiddleware`` aren't enabled,
``sesame.middleware.AuthenticationMiddleware`` sets ``request.user`` to the
currently logged-in user or ``AnonymousUser()``.

Changelog
=========

1.8
---

* Added compatibility with custom user models with most types of primary keys,
  including ``BigAutoField``, ``SmallAutoField``, other integer fields,
  ``CharField`` and ``BinaryField``.
* Added the ability to customize how primary keys are stored in tokens.
* Added compatibility with Django ≥ 3.0.

1.7
---

* Fixed invalidation of one-time tokens in ``get_user()``.

1.6
---

* Fixed detection of Safari on iOS.

1.5
---

* Added support for single use tokens with the ``SESAME_ONE_TIME`` setting.
* Added support for not invalidating tokens on password change with the
  ``SESAME_INVALIDATE_ON_PASSWORD_CHANGE`` setting.
* Added compatibility with custom user models where the primary key is a
  ``UUIDField``.
* Added the ``get_user()`` function to obtain a user instance from a request.
* Improved error message for pre-existing tokens when changing the
  ``SESAME_MAX_AGE`` setting.
* Fixed authentication on Safari by disabling the redirect which triggers ITP.

1.4
---

* Added a redirect to the same URL with the query string parameter removed.

1.3
---

* Added compatibility with Django ≥ 2.0.

1.2
---

* Added the ability to rename the query string parameter with the
  ``SESAME_TOKEN_NAME`` setting.
* Added compatibility with Django ≥ 1.8.

1.1
---

* Added support for expiring tokens with the ``SESAME_MAX_AGE`` setting.

1.0
---

* Initial release.
