Tutorial#

Let’s get practical.

First, we’ll implement login by email, also known as “Magic Links”, in a Django project. Login by email can provide a better user experience as users generally don’t forget their email address. Keep in mind that it requires fast and reliable email delivery.

Then, we’ll create authenticated links to share private content without logging in a user. As an example, a vacation rentals service could let a customer share a booking with other travelers, while not giving them full access to the customer’s account.

The two sections are independent. Once you implement the prerequisites, you may proceed with the first or the second section.

Prerequisites#

Create a Django project#

You may work in an existing project or you may initialize a new project as follows:

$ django-admin startproject tutorial
$ cd tutorial
$ ./manage.py migrate

If you reuse an existing project, it should enable django.contrib.auth.

If you have a custom user model, ensure that the email address of users is stored in a field named email. Else, you will have to adapt references to this field.

You need a user for testing. The tutorial uses jane.doe@example.com in examples. In a new project, you can create a superuser this purpose:

$ ./manage.py createsuperuser

Install django-sesame#

Follow the instructions in the getting started guide to install and configure django-sesame.

Login by email#

We are going to build the following login flow:

  1. The user provides their email address.

  2. We create an authentication token and build a magic link.

  3. We email the magic link to them to confirm that they own the email address.

  4. The user clicks the link, we check the token, and we log them in.

Configure short-lived tokens#

Before we start coding, we should think about the security of the system. Tokens are only intended to allow the user to log in now. Let’s limit their validity to five minutes.

Open your project settings and add this line:

SESAME_MAX_AGE = 300  # 300 seconds = 5 minutes

Configure redirect after login#

Set LOGIN_REDIRECT_URL to a URL that tells whether the user is authenticated so you can easily tell whether login succeeds.

If you initialized a new project and you test with a superuser, you can simply use the admin. Conveniently, it provides a link to log out:

LOGIN_REDIRECT_URL = "/admin/"

Now we can go back to building the login flow.

Create a login form#

Decide where you’re going to add the code. In an existing project, perhaps you already have an app for project-wide concerns; that’s a good place. Otherwise, you can create a new app.

In this app, create a login form with only one field, the email address of the user.

forms.py#
from django import forms

class EmailLoginForm(forms.Form):
    email = forms.EmailField()

Create templates to display the form and to show a message after submitting it successfully.

templates/email_login.html#
<!DOCTYPE html>
<html>
    <head>
        <title>Log in</title>
    </head>
    <body>
        {% if request.user.is_authenticated %}
        <p>You are already logged in as {{ request.user.email }}.</p>
        {% endif %}
        <form action="{{ request.path }}" method="POST">
            {% csrf_token %}
            <p>
                {{ form }}
                <input type="submit" value="Send log in link">
            </p>
        </form>
    </body>
</html>
templates/email_login_success.html#
<!DOCTYPE html>
<html>
    <head>
        <title>Log in</title>
    </head>
    <body>
        <p>We sent a log in link. Check your email.</p>
    </body>
</html>

In an existing project, you may inherit a base template and add styling.

Make sure that Django can find the templates. If needed, add the directory where they’re stored to the DIRS option of the TEMPLATES setting.

Create a view to handle the form display and submission logic.

views.py#
from django.shortcuts import render
from django.views.generic import FormView

from .forms import EmailLoginForm

class EmailLoginView(FormView):
    template_name = "email_login.html"
    form_class = EmailLoginForm

    def form_valid(self, form):
        # TODO: email magic link to user.
        return render(self.request, "email_login_success.html")

Why does form_valid() ignore the Post/Redirect/Get pattern?

After handling a form submission, it is a good practice to redirect the user to a new URL to avoid a duplicate if the user reloads the page.

In our case, resubmitting the form will send another Magic Link. This is a sensible result after refreshing a page that says “We sent a log in link.”

If you prefer to stick to Post/Redirect/Get, you can replace render(...) with redirect(...) and add a view for rendering the success template.

Add a route to this view in your URLconf:

urls.py#
from django.urls import path

from .views import EmailLoginView

urlpatterns = [
    ...,
    path("login/", EmailLoginView.as_view(), name="email_login"),
    ...,
]

Check that your development server is running.

Open http://127.0.0.1:8000/login/ in a browser. You should see this form:

_images/email_login.png

If you see a message saying that you are already logged in, log out.

Put an email address in the form and submit it. You should see this message:

_images/email_login_success.png

Good. With the scaffolding in place, we can move on to the actual logic.

Improve the view#

For reference, here’s a version of EmailLoginView with error handling and more structure.

Feel free to make your own improvements!

views.py#
from django.contrib.auth import get_user_model
from django.shortcuts import render
from django.urls import reverse
from django.views.generic import FormView
from sesame.utils import get_query_string

import sesame.utils

from .forms import EmailLoginForm

class EmailLoginView(FormView):
    template_name = "email_login.html"
    form_class = EmailLoginForm

    def get_user(self, email):
        """Find the user with this email address."""
        User = get_user_model()
        try:
            return User.objects.get(email=email)
        except User.DoesNotExist:
            return None

    def create_link(self, user):
        """Create a login link for this user."""
        link = reverse("login")
        link = self.request.build_absolute_uri(link)
        link += get_query_string(user)
        return link

    def send_email(self, user, link):
        """Send an email with this login link to this user."""
        user.email_user(
            subject="[django-sesame] Log in to our app",
            message=f"""\
Hello,

You requested that we send you a link to log in to our app:

    {link}

Thank you for using django-sesame!
""",
        )

    def email_submitted(self, email):
        user = self.get_user(email)
        if user is None:
            # Ignore the case when no user is registered with this address.
            # Possible improvement: send an email telling them to register.
            print("user not found:", email)
            return
        link = self.create_link(user)
        self.send_email(user, link)

    def form_valid(self, form):
        self.email_submitted(form.cleaned_data["email"])
        return render(self.request, "email_login_success.html")