Skip to main content

Django app for managing tokenised 'magic link' logins.

Project description

Django Magic Link

Opinionated Django app for managing "magic link" logins.

WARNING

If you send a login link to the wrong person, they will gain full access to the user's account. Use with extreme caution, and do not use this package without reading the source code and ensuring that you are comfortable with it. If you have an internal security team, ask them to look at it before using it. If your clients have security sign-off on your application, ask them to look at it before using it.

/WARNING

This app is not intended for general purpose URL tokenisation; it is designed to support a single use case - so-called "magic link" logins.

There are lots of alternative apps that can support this use case, including the project from which this has been extracted - django-request-token. The reason for yet another one is to handle the real-world challenge of URL caching / pre-fetch, where intermediaries use URLs with unintended consequences.

This packages supports a very specific model:

  1. User is sent a link to log them in automatically.
  2. User clicks on the link, and which does a GET request to the URL.
  3. User is presented with a confirmation page, but is not logged in.
  4. User clicks on a button and performs a POST to the same page.
  5. The POST request authenticates the user, and deactivates the token.

The advantage of this is the email clients do not support POST links, and any prefetch that attempts a POST will fail the CSRF checks.

The purpose is to ensure that someone actively, purposefully, clicked on a link to authenticate themselves. This enables instant deactivation of the token, so that it can no longer be used.

In practice, without this check, valid magic links may be requested a number of times via GET request before the intended recipient even sees the link. If you use a "max uses" restriction to lock down the link you may find this limit is hit, and the end user then finds that the link is inactive. The alternative to this is to remove the use limit and rely instead on an expiry window. This risks leaving the token active even after the user has logged in. This package is targeted at this situation.

Use

Prerequisite: Update settings.py and urls.py

Add magic_link to INSTALLED_APPS in settings.py:

INSTALLED_APPS = [
    ...
    'magic_link',
]

Add the magic_link urls to urls.py:

from magic_link import urls as magic_link_urls


urlpatterns = [
    ...
    url(r'^magic_link/', include(magic_link_urls)),
]

Prerequisite: Override the default templates.

This package has two HTML templates that must be overridden in your local application.

templates/magic_link/logmein.html

This is the landing page that a user sees when they click on the magic link. You can add any content you like to this page - the only requirement is that must contains a simple form with a csrf token and a submit button. This form must POST back to the link URL. The template render context includes the link which has a get_absolute_url method to simplify this:

<form method="POST" action="{{ link.get_absolute_url }}">
    {% csrf_token %}
    <button type="submit">Log me in</button>
</form>

templates/magic_link/error.html

If the link has expired, been used, or is being accessed by someone who is already logged in, then the error.html template will be rendered. The template context includes link and error.

<p>Error handling magic link {{ link }}: {{ error }}.</p>

1. Create a new login link

The first step in managing magic links is to create one. Links are bound to a user, and can have a custom post-login redirect URL.

# create a link with the default expiry and redirect
link = MagicLink.objects.create(user=user)

# create a link with a specific redirect
link = MagicLink.objects.create(user=user, redirect_to="/foo")

# construct a full URL from a MagicLink object and a Django HttpResponse
url = request.build_absolute_uri(link.get_absolute_url())

2. Send the link to the user

This package does not handle the sending on your behalf - it is your responsibility to ensure that you send the link to the correct user. If you send the link to the wrong user, they will have full access to the link user's account. YOU HAVE BEEN WARNED.

Auditing

A core requirement of this package is to be able to audit the use of links - for monitoring and analysis. To enable this we have a second model, MagicLinkUse, and we create a new object for every request to a link URL, regardless of outcome. Questions that we want to have answers for include:

  • How long does it take for users to click on a link?
  • How many times is a link used before the POST login?
  • How often is a link used after a successful login?
  • How often does a link expire before a successful login?
  • Can we identify common non-user client requests (email caches, bots, etc)?
  • Should we disable links after X non-POST requests?

In order to facilitate this analysis we denormalise a number of timestamps from the MagicLinkUse object back onto the MagicLink itself:

  • created_at - when the record was created in the database
  • accessed_at - the first GET request to the link URL
  • logged_in_at - the successful POST
  • expires_at - the link expiry, set when the link is created.

Note that the expiry timestamp is not updated when the link is used. This is by design, to retain the original expiry timestamp.

Link validation

In addition to the timestamp fields, there is a separate boolean flag, is_active. This acts as a "kill switch" that overrides any other attribute, and it allows a link to be disabled without having to edit (or destroy) existing timestamp values. You can deactivate all links in one hit by calling MagicLink.objects.deactivate().

A link's is_valid property combines both is_active and timestamp data to return a bool value that defines whether a link can used, based on the following criteria:

  1. The link is active (is_active)
  2. The link has not expired (expires_at)
  3. The link has not already been used (logged_in_at)

In addition to checking the property is_valid, the validate() method will raise an exception based on the specific condition that failed. This is used by the link view to give feedback to the user on the nature of the failure.

Request authorization

If the link's is_valid property returns True, then the link can be used. However, this does not mean that the link can be used by anyone. We do not allow authenticated users to login using someone else's magic link. The authorize() method takes a User argument and determines whether they are authorized to use the link. If the user is authenticated, and does not match the link.user, then a PermissionDenied exception is raised.

Putting it together

Combining the validation, authorization and auditing, we get a simplified flow that looks something like this:

def get(request, token):
    """Render login page."""
    link = get_object_or_404(MagicLink, token=token)
    link.validate()
    link.authorize(request.user)
    link.audit()
    return render("logmein.html")

def post(request, token):
    """Handle the login POST."""
    link = get_object_or_404(MagicLink, token=token)
    link.validate()
    link.authorize(request.user)
    link.login(request)
    link.disable()
    return redirect(link.redirect_to)

Settings

Settings are read from a django.conf.settings settings dictionary called MAGIC_LINK.

Default settings show below:

# settings.py
MAGIC_LINK = {
    # link expiry, in seconds
    "DEFAULT_EXPIRY": 300,
    # default link redirect
    "DEFAULT_REDIRECT": "/",
    # the preferred authorization backend to use, in the case where you have more
    # than one specified in the `settings.AUTHORIZATION_BACKENDS` setting.
    "AUTHENTICATION_BACKEND": "django.contrib.auth.backends.ModelBackend",
    # SESSION_COOKIE_AGE override for magic-link logins - in seconds (default is 1 week)
    "SESSION_EXPIRY": 7 * 24 * 60 * 60
}

Screenshots

Default landing page (logmein.html)

Screenshot of default landing page

Default error page (error.html)

Screenshot of default error page

Admin view of magic link uses

Screenshot of MagicLinkUseInline

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_magic_link-1.0.0.tar.gz (14.1 kB view details)

Uploaded Source

Built Distribution

django_magic_link-1.0.0-py3-none-any.whl (15.3 kB view details)

Uploaded Python 3

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page