Skip to content

8. Authentication and Authorization

Two distinct questions arise for each request:

  • Who is the user? This is authentication: the user has identified themselves with a username and password, and each subsequent request must prove that it comes from them;
  • Do they have permission to access this page? This is authorization: in the case study, only a user with the role ADMIN manages doctors and clients.

Proof of identity must accompany every request. [Django] has a complete authentication application, [django.contrib.auth]: a [User] model (a database table), middleware that stores the logged-in user in [request.user], login and logout views, permissions, groups, and the administration interface that manages them. It stores the logged-in user in their session (see the chapter on scopes), which is the simplest solution for a web application. The case study, in all its versions ([ASP.NET Core MVC], [Spring MVC], [NestJS], [Flask], [Laravel], [Symfony]), instead uses a JWT token (JSON Web Token), which we will use here: After login, the server generates a token containing the user’s identity and signs it with a secret key (this is the principle behind the signed cookie in Example 23). The token is stored in a cookie; the browser sends it back with every request; the server verifies the signature. The user can read their token but cannot modify it: any modification invalidates the signature. The format is standardized (RFC 7519), understood by all programming languages, and the token includes its expiration date. Most importantly, the server doesn’t have to store anything: any server that knows the key can verify the token.

The users in the case study are those in their own table, [users], and not those in [django.contrib.auth]; the tokens themselves have no place in [django.contrib.auth]. So we’ll implement the authentication ourselves, using the tools we’re already familiar with, which has the advantage of making everything transparent:

  • a middleware component (Example 03) determines, for each request, who the user is based on the token in their cookie, and includes it in the request ([request.user], Example 12). The rest of the application sees only this result;
  • decorators (example 08) protect the views: [@login_required], [@role_required("ADMIN")];
  • passwords are verified using the hashing functions of [Django] ([django.contrib.auth.hashers], which can be used without the rest of the application [auth]);
  • access denials are [PermissionDenied] exceptions, converted into an error page by a [handler403] view (Example 24);
  • Login attempts are limited using the cache in [Django] (Example 27).

The creation and verification of tokens are handled by the [PyJWT] library, the most commonly used one in [Python], installed by the example file [requirements.txt] (it is imported under the name [jwt]). (There are JWT token libraries for [Django], such as [djangorestframework-simplejwt], designed for API; for a web application, just a few lines are enough, and they better illustrate the mechanism.)

8.1. Example 25: Login, Token, Protected Page

25-jwt/
├── accounts/
│   ├── __init__.py
│   └── views.py
├── config/
│   ├── settings.py
│   ├── urls.py
│   └── ...
├── security/
│   ├── __init__.py
│   ├── auth.py
│   ├── decorators.py
│   ├── middleware.py
│   └── users.py
├── templates/
│   ├── auth/
│   │   └── login.html
│   ├── profile/
│   │   └── profile.html
│   └── layout.html
└── manage.py

8.1.1. Configuration: config/settings.py

The token is signed with a secret key, which must not be hardcoded in production application code. It’s time to truly separate the configuration from the code:

# config/settings.py: the configuration of example 25

import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-jr8u&rl2ff+bpg(h18&^u2dtu^qtulf9yoy!e&la+nt_z-vb*8"
ALLOWED_HOSTS = []
LANGUAGE_CODE = "en"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

INSTALLED_APPS = [
    "django.contrib.staticfiles",
    "security",
    "accounts",
]

MIDDLEWARE = [
    "django.middleware.csrf.CsrfViewMiddleware",
    # AUTHENTICATION: at each request, the user is found from the
    # JWT token of their cookie (see security/middleware.py)
    "security.middleware.Authentication",
]

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
    },
]

STATIC_URL = "static/"
STATICFILES_DIRS = [
    BASE_DIR.parent / "common" / "static",
]

# our own settings: the key that signs the JWT tokens (at least 32 characters), and
# their lifetime. os.environ.get: the environment variable of the same name if it
# exists (in production), the value written here otherwise (in development)
JWT_SECRET = os.environ.get("JWT_SECRET", "a-secret-key-of-at-least-32-characters-for-example-25")
JWT_DURATION_MINUTES = int(os.environ.get("JWT_DURATION_MINUTES", "60"))
  • lines 21–26: two middleware components: anti-CSRF protection (Example 18), followed by our authentication middleware;
  • lines 43–44: two application-specific settings (Example 06): the key that signs the tokens, and their lifetime, in minutes. [os.environ.get("NAME", default)] sets the value of the environment variable to [NAME] if it exists; otherwise, it uses the default value. During development, the value written to the file is sufficient; in production, the actual key is provided via an environment variable, which takes precedence. An environment variable is always a string: [int] converts the duration to an integer.

The standard requires, for the algorithm used (HMAC-SHA256), a key of at least 256 bits, or 32 bytes: [PyJWT] issues a warning for a shorter key. With [Linux] or [macOS], the application would be launched with a different key as follows:

JWT_SECRET="another-key-of-at-least-thirty-two-characters" python manage.py runserver

and for [Windows] ([PowerShell]):

$env:JWT_SECRET="another-key-of-at-least-thirty-two-characters"
python manage.py runserver

The secret key for [Django] itself, [SECRET_KEY], would require the same treatment; the case study reads all its secrets from a file named [.env], or from the environment.

8.1.2. Users: security/users.py

# security/users.py: the users, in memory (example 28 and the case
# study will put them in a database)

from dataclasses import dataclass, field


@dataclass(frozen=True)
class User:
    login: str
    name: str
    # the role: USER or ADMIN
    role: str
    # the HASH of the password (empty for a user read from a token)
    password_hash: str = ""
    # the content of the token that authenticated the user; field(default_factory=dict):
    # a NEW empty dictionary for each object
    claims: dict = field(default_factory=dict)


# we NEVER keep a password, only its hash, computed once and for
# all by Django's make_password function (PBKDF2-SHA256 algorithm, 1,500,000
# iterations, a random salt):
# python -c "from django.conf import settings; settings.configure();
#            from django.contrib.auth.hashers import make_password; print(make_password('marie'))"
USERS = {
    "marie": ("Marie Pélissier", "USER",
              "pbkdf2_sha256$1500000$pjnFn2tL1G4bu5rVFTv6sZ$Y6v+fj3dC+Y0LZYJGxeE1OOi9beaZVuMVm6R6tljEwI="),
    "admin": ("Administrator", "ADMIN",
              "pbkdf2_sha256$1500000$UTT2hXVaLIEAuGHPFxgnv9$D9+Owt9lDG14VrvArR3n0GGQXWjY9Sg8cgFwip4+yx4="),
}


def find(login: str) -> User | None:
    if login not in USERS:
        return None
    # the "unpacking" of a tuple: its three elements in three variables
    name, role, password_hash = USERS[login]
    return User(login, name, role, password_hash)
  • lines 7–17: a user: the login, the name, the role, the password hash, and the content of the token that authenticated the user (empty for a user read from the user list). The last two attributes have a default value. For a dictionary, this value cannot be written to [claims: dict = {}]: this dictionary would be created only once and shared by all objects. [field(default_factory=dict)] instructs [@dataclass] to call [dict()] for each new object;
  • Lines 20–24: A server never stores a password, even in encrypted form: if the database were stolen, the passwords would be stolen as well (and many people use the same password everywhere). It stores the result of a hash function, a one-way function: you can calculate the hash of a password, but you cannot recover a password from its hash. To verify an entered password, you calculate its hash and compare it to the one stored in the database;
  • lines 25–30: two test users, hard-coded into the program (Example 28 and the case study will populate a database with them): for each login, the name, role, and password hash. The hash was calculated once and for all by the function [make_password] from [Django] (the command is provided in a comment), using its default algorithm, PBKDF2-SHA256. The stored hash has four parts, separated by [$]: the algorithm ([pbkdf2_sha256]), the number of iterations, the salt, and then the result. A good password hashing algorithm has two characteristics. It is intentionally slow, with an adjustable level of slowness: PBKDF2 applies the function SHA-256 1,500,000 times, which takes a few hundred milliseconds—imperceptible to a user, but makes it very costly for a hacker to try billions of passwords; [Django] increases this number with each version to keep pace with computing power (its [auth] application then updates the hashes stored during logins); older hashes remain verifiable, since they include their iteration count. It is salted: a randomly generated salt is mixed with the password before hashing and stored within the hash itself; thus, two users with the same password will have different hashes. [Django] can also use [Argon2] and [bcrypt] (setting [PASSWORD_HASHERS]); the case study, whose database contains hashes [bcrypt], uses the library [bcrypt] directly;
  • lines 33–38: the user of a login, or [None]. [not in] checks for the absence of a key; the assignment [name, role, password_hash = ...] unpacks a tuple: each variable receives the corresponding element.

8.1.3. The authentication service: security/auth.py

# security/auth.py: the checking of the passwords and the JWT tokens

import time

import jwt
from django.conf import settings
from django.contrib.auth.hashers import check_password

from . import users
from .users import User

# the name of the cookie that carries the token
COOKIE = "token"


# the user, or None if the login is unknown or the password wrong.
# check_password hashes the entered password with the salt and algorithm of the stored
# hash, then compares the two results
def validate(login: str, password: str) -> User | None:
    if not login or not password:
        return None
    user = users.find(login)
    if user is None or not check_password(password, user.password_hash):
        return None
    return user


# the JWT token of the user, signed with the secret key (HMAC-SHA256)
def create_token(user: User) -> str:
    now = int(time.time())
    # the content of the token: the "claims" (statements about the user)
    claims = {
        "sub": user.login,
        "name": user.name,
        "role": user.role,
        "iat": now,
        "exp": now + settings.JWT_DURATION_MINUTES * 60,
    }
    return jwt.encode(claims, settings.JWT_SECRET, algorithm="HS256")


# the user described by a token whose signature and expiration date are
# valid; None otherwise (forged, expired or unreadable token)
def verify(token: str) -> User | None:
    try:
        # decode checks the signature AND the expiration date (exp claim)
        claims = jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
    except jwt.InvalidTokenError:
        return None
    return User(claims["sub"], claims["name"], claims["role"], claims=claims)
  • Line 5: the [PyJWT] library;
  • line 7: the function [check_password] from [Django];
  • line 13: the name of the cookie that carries the token;
  • lines 19–25: verification of a username and password. [check_password] reads the algorithm, the number of iterations, and the salt from the stored hash, hashes the entered password using them, and compares the two results (in constant time, see example 23). An unknown username and an incorrect password yield the same result, [None];
  • lines 29–39: token generation. Its content is a dictionary of claims about the user. Some have standardized names: [sub] (subject), the user’s identifier; [iat] (issued at), the creation date; [exp] (expiration), the expiration date. Dates are expressed in seconds elapsed since January 1, 1970 ([time.time()] returns this number, including decimals, which [int] omits). The other fields are optional: here, the name and role. [jwt.encode] generates the token and signs it using the algorithm [HS256] (HMAC-SHA256, a signature using a shared secret key);
  • lines 44–50: token verification. [jwt.decode] verifies the signature and expiration date, then returns the assertions. Any anomaly (forged, expired, or malformed token) raises an exception of class [jwt.InvalidTokenError] or one of its subclasses, which the [except] block intercepts. The parameter [algorithms] specifies the list of accepted algorithms: a token that declares a different algorithm (or none at all, [none]) would be rejected. The valid token becomes a [User], without a hash, along with its claims (parameter named [claims]).

8.1.4. The authentication middleware: security/middleware.py

# security/middleware.py: AUTHENTICATION: at each request, who is the user?

from django.shortcuts import redirect

from . import auth


class Authentication:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        # by default, the visitor is anonymous
        request.user = None
        token = request.COOKIES.get(auth.COOKIE)
        if token is not None:
            user = auth.verify(token)
            if user is None:
                # an invalid token (expired, forged): off to /login, and we
                # delete the cookie
                response = redirect("login")
                response.delete_cookie(auth.COOKIE)
                return response
            request.user = user
        return self.get_response(request)

For each request, before the view:

  • line 14: by default, the visitor is anonymous: [request.user] equals [None];
  • lines 15–17: if the request contains the token cookie, the token is verified;
  • lines 18–23: an invalid token (expired or forged): the middleware responds on its own, without invoking the rest of the process, by redirecting to the login page, and takes the opportunity to delete the cookie: [delete_cookie] adds an empty, already expired cookie to the response;
  • line 24: a valid token: the user it identifies is included in the request. The user is not looked up in the user list: the token is sufficient, since it is signed.

The middleware does not deny access: it determines who the user is, or determines that the user is anonymous. This is authentication. Denials are handled by access control.

8.1.5. Access control: security/decorators.py

# security/decorators.py: the access control of the views

from functools import wraps

from django.shortcuts import redirect


# @login_required: the view is executed only for an authenticated user; an
# anonymous visitor is redirected to the login page
def login_required(view):
    @wraps(view)
    def wrapper(request, *args, **kwargs):
        if request.user is None:
            return redirect("login")
        return view(request, *args, **kwargs)
    return wrapper

The decorator [@login_required] wraps a view (Example 08): if the user is anonymous, it responds by redirecting to the login page without calling the view; otherwise, it calls the view. [django.contrib.auth] provides a decorator with the same name and role, [@login_required], which is based on its own [request.user].

8.1.6. Views: accounts/views.py

# accounts/views.py: login, logout, and a page reserved for authenticated
# users

from django.conf import settings
from django.shortcuts import redirect, render
from django.views.decorators.http import require_GET, require_http_methods, require_POST

from security import auth
from security.decorators import login_required


@require_GET
def home(request):
    return redirect("profile")


# GET: the form; POST: the login (the anti-CSRF token has been checked by
# CsrfViewMiddleware)
@require_http_methods(["GET", "POST"])
def login(request):
    if request.method == "GET":
        return render(request, "auth/login.html")
    login = request.POST.get("login", "")
    user = auth.validate(login, request.POST.get("password", ""))
    if user is None:
        # a deliberately vague message: we do not say whether it is the login or the password
        return render(request, "auth/login.html",
                      {"login": login, "error": "Incorrect login or password."})
    # the token, in a cookie: the page's JavaScript cannot read it
    # (httponly); it does not accompany the POST requests coming from other sites
    # (samesite); it disappears at the same time as the token expires (max_age)
    response = redirect("profile")
    response.set_cookie(auth.COOKIE, auth.create_token(user),
                        max_age=settings.JWT_DURATION_MINUTES * 60, httponly=True, samesite="Lax")
    return response


# logging out: POST only (a simple link, placed on another site, cannot
# log the user out without their knowledge); we delete the cookie
@require_POST
def logout(request):
    response = redirect("login")
    response.delete_cookie(auth.COOKIE)
    return response


# request.user: the authenticated user, found by the middleware
@require_GET
@login_required
def profile(request):
    return render(request, "profile/profile.html", {"user": request.user})
  • lines 19–22: The login form, hand-coded in the template, is protected against forged requests by the [CsrfViewMiddleware] middleware, just like all POST forms (Example 18);
  • lines 23–28: the login process. If the login fails, the form is redisplayed with the entered username and a deliberately vague message: stating “unknown username” would reveal to a hacker which usernames exist;
  • lines 32–35: if successful, the token is stored in a cookie, and the user is redirected to the protected page (schema PRG). The cookie is [httponly]: the page’s [JavaScript] cannot read it, which protects the token against script injection (XSS); it is [SameSite=Lax]: the browser does not send it with a POST request originating from another site, which protects the application against CSRF attacks; its lifetime ([max_age], in seconds) is the same as that of the token. In production, for HTTPS, we would add [secure=True]: the cookie would never be sent over an unencrypted connection;
  • Lines 40–44: Logging out deletes the cookie ([delete_cookie]). This is a view of POST, protected by the anti-CSRF token: a simple link to [/logout], placed on another site or within an image, cannot log the user out without their knowledge. Note a limitation of tokens: the token itself remains valid until it expires. Someone who has made a copy of it could still use it. That is why their lifetime is limited;
  • Lines 48–51: The protected page. The decorator [@login_required] ensures that [request.user] is not [None].

8.1.7. Templates

{% extends "layout.html" %}

{% block content %}
  <h1 class="h3">Login</h1>
  <form method="post" action="{% url 'login' %}">
    {% csrf_token %}
    <div class="mb-2">
      <label class="form-label" for="login">Login</label>
      <input class="form-control" id="login" name="login" value="{{ login }}" required>
    </div>
    <div class="mb-3">
      <label class="form-label" for="password">Password</label>
      <input class="form-control" type="password" id="password" name="password" required>
    </div>
    <button class="btn btn-primary" type="submit">Log in</button>
  </form>
  {% if error %}
    <div class="alert alert-danger mt-3">{{ error }}</div>
  {% endif %}
  <p class="text-muted small mt-3">Test accounts (password = login): marie (USER), admin (ADMIN).</p>
{% endblock %}
  • Line 6: the form’s anti-CSRF token;
  • line 9: the entered login is displayed again after a failure (a missing variable is displayed as an empty string).
{% extends "layout.html" %}

{% block content %}
  <h1 class="h3">Hello {{ user.name }}</h1>
  <p>Your role: <b>{% if user.role == "ADMIN" %}administrator{% else %}user{% endif %}</b>.</p>
  <p>The content of your token:</p>
  <table class="table table-sm w-auto">
    {% for key, value in user.claims.items %}
      <tr><td><code>{{ key }}</code></td><td>{{ value }}</td></tr>
    {% endfor %}
  </table>
  {# logging out: a POST form reduced to its button #}
  <form method="post" action="{% url 'logout' %}">
    {% csrf_token %}
    <button class="btn btn-outline-secondary" type="submit">Log out</button>
  </form>
{% endblock %}
  • lines 8–10: the token’s contents, displayed for illustrative purposes. [items] iterates through the (key, value) pairs in the dictionary (Example 18);
  • lines 13–16: Logout, via a form reduced to a single button.

8.1.8. Execution

Let’s request the protected page [/profile] without being logged in:

curl -i http://localhost:8000/profile
1
2
3
HTTP/1.1 302 Found
Content-Type: text/html; charset=utf-8
Location: /login

An incorrect password:

Image

Login successful. With [curl], you must first request the form to obtain the anti-CSRF token and its cookie (example 18), then submit the form while retaining the received cookie:

curl -c token.txt -b token.txt http://localhost:8000/login
curl -i -c token.txt -b token.txt -d "csrfmiddlewaretoken=...&login=marie&password=marie" http://localhost:8000/login
1
2
3
4
HTTP/1.1 302 Found
Content-Type: text/html; charset=utf-8
Location: /profile
Set-Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYXJpZSIsIm5hbWUiOiJNYXJpZSBQXHUwMGU5bGlzc2llciIsInJvbGUiOiJVU0VSIiwiaWF0IjoxNzkwNTMwOTM5LCJleHAiOjE3OTA1MzQ1Mzl9.eJQdDTFEYVlAzHENW7esgY82dUbjsj5dN6jW-Dy6Lfo; expires=Sun, 27 Sep 2026 18:42:19 GMT; HttpOnly; Max-Age=3600; Path=/; SameSite=Lax

The token consists of three parts, separated by periods, each encoded in Base64: the header, the content, and the signature. The first two parts can be decoded without any key:

{"alg":"HS256","typ":"JWT"}
{"sub":"marie","name":"Marie Pélissier","role":"USER","iat":1790530939,"exp":1790534539}

(The [é] is written in its escaped form, JSON, [\u00e9].) The expiration time is one hour (3,600 seconds) after creation. In the browser, the profile page displays this same content:

Image

A malicious user modifies their token: they replace the role ***[USER]*** with ***[ADMIN]*** in the content, re-encode this part in Base64, and keep the original signature. The server recalculates the signature of the modified content, which no longer matches:

curl -i -b "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.<modified content>.eJQdDTFEYVlAzHENW7esgY82dUbjsj5dN6jW-Dy6Lfo" http://localhost:8000/profile
1
2
3
4
HTTP/1.1 302 Found
Content-Type: text/html; charset=utf-8
Location: /login
Set-Cookie: token=""; expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/

The forged token is rejected and deleted (the empty value and expired date instruct the browser to delete the cookie). To generate a valid signature, one would need to know the secret key—hence the importance of keeping it secret and choosing a long, random key.

8.2. Example 26: Roles

Example 26 adds an administration page, restricted to the role ADMIN. Only new or modified files are displayed.

8.2.1. Roles: security/users.py

# security/users.py: the users, in memory (example 28 and the case
# study will put them in a database)

from dataclasses import dataclass, field


@dataclass(frozen=True)
class User:
    login: str
    name: str
    # the role: USER or ADMIN
    role: str
    # the HASH of the password (empty for a user read from a token)
    password_hash: str = ""
    # the content of the token that authenticated the user; field(default_factory=dict):
    # a NEW empty dictionary for each object
    claims: dict = field(default_factory=dict)

    # the HIERARCHY of the roles: an administrator also has the rights of a user
    def has_role(self, role: str) -> bool:
        return role in HIERARCHY.get(self.role, set())

    # for the templates, which cannot call a method with a parameter
    @property
    def is_admin(self) -> bool:
        return self.has_role("ADMIN")


# each role, and the set of roles it has ({...}: a set)
HIERARCHY = {
    "ADMIN": {"ADMIN", "USER"},
    "USER": {"USER"},
}


# we NEVER keep a password, only its hash, computed once and for
# all by Django's make_password function (PBKDF2-SHA256 algorithm, 1,500,000
# iterations, a random salt):
# python -c "from django.conf import settings; settings.configure();
#            from django.contrib.auth.hashers import make_password; print(make_password('marie'))"
USERS = {
    "marie": ("Marie Pélissier", "USER",
              "pbkdf2_sha256$1500000$pjnFn2tL1G4bu5rVFTv6sZ$Y6v+fj3dC+Y0LZYJGxeE1OOi9beaZVuMVm6R6tljEwI="),
    "admin": ("Administrator", "ADMIN",
              "pbkdf2_sha256$1500000$UTT2hXVaLIEAuGHPFxgnv9$D9+Owt9lDG14VrvArR3n0GGQXWjY9Sg8cgFwip4+yx4="),
}


def find(login: str) -> User | None:
    if login not in USERS:
        return None
    # the "unpacking" of a tuple: its three elements in three variables
    name, role, password_hash = USERS[login]
    return User(login, name, role, password_hash)
  • lines 20–21: The [has_role] method determines whether the user has a role, based on the role hierarchy;
  • lines 24–26: a property (example 14), for templates: DTL cannot call a method with a parameter ([has_role("ADMIN")]), but it can read a property ([user.is_admin]);
  • lines 30–33: the role hierarchy: for each role, the set ([set], enclosed in curly braces) of roles it possesses. The role [ADMIN] includes the role [USER]: an administrator thus has all the rights of a user without needing to be assigned both roles. The method [has_role] can use [HIERARCHY], even though it is defined further down in the file: the name is looked up only at the time of the call, once the entire module has been loaded.

8.2.2. Access control: security/decorators.py

# security/decorators.py: the access control of the views

from functools import wraps

from django.core.exceptions import PermissionDenied
from django.shortcuts import redirect


# @login_required: the view is executed only for an authenticated user; an
# anonymous visitor is redirected to the login page
def login_required(view):
    @wraps(view)
    def wrapper(request, *args, **kwargs):
        if request.user is None:
            return redirect("login")
        return view(request, *args, **kwargs)
    return wrapper


# @role_required("ADMIN"): a PARAMETERIZED decorator (see example 08). The view is
# executed only if the authenticated user has the requested role; otherwise, the
# PermissionDenied exception produces a 403 response (see security/errors.py)
def role_required(role):
    def decorator(view):
        @wraps(view)
        @login_required
        def wrapper(request, *args, **kwargs):
            if not request.user.has_role(role):
                raise PermissionDenied(f"role {role} required")
            return view(request, *args, **kwargs)
        return wrapper
    return decorator
  • lines 23–32: a parameterized decorator (example 08), [@role_required("ADMIN")]. The wrapper is itself wrapped by [@login_required]: an anonymous visitor is first sent to the login page; an authenticated user who does not have the requested role triggers the [PermissionDenied] exception (example 24), which will result in a 403 (Forbidden) error.

The difference between the two denial scenarios is significant: an anonymous visitor requesting a protected page is redirected to the login page; an authenticated user who does not have the requested role receives a 403 error—logging in would serve no purpose, since they are already authenticated.

8.2.3. Access denial: security/errors.py and config/urls.py

1
2
3
4
5
6
7
8
# security/errors.py: the response given to an AUTHENTICATED user who requests a
# page that is forbidden to them (see handler403 in config/urls.py)

from django.shortcuts import render


def access_denied(request, exception):
    return render(request, "errors/403.html", status=403)
# config/urls.py: the main routing table

from django.urls import path

from accounts import views
from security.errors import access_denied

urlpatterns = [
    path("", views.home),
    path("login", views.login, name="login"),
    path("logout", views.logout, name="logout"),
    path("profile", views.profile, name="profile"),
    path("admin", views.admin, name="admin"),
]

# the view of the 403 errors (PermissionDenied exception)
handler403 = access_denied

The view [access_denied] is the application’s 403 error view (variable [handler403], example 24) : [Django] calls it for any [PermissionDenied] exception, in both debug and production modes. The [templates/errors/403.html] page is a standard error page with a link back to the profile. Without this view, the user would see the plain 403 page from [Django] (“403 Forbidden”).

8.2.4. Views: accounts/views.py

# accounts/views.py: login, logout, and pages reserved for authenticated
# users

from django.conf import settings
from django.shortcuts import redirect, render
from django.views.decorators.http import require_GET, require_http_methods, require_POST

from security import auth
from security.decorators import login_required, role_required


@require_GET
def home(request):
    return redirect("profile")


# GET: the form; POST: the login (the anti-CSRF token has been checked by
# CsrfViewMiddleware)
@require_http_methods(["GET", "POST"])
def login(request):
    if request.method == "GET":
        return render(request, "auth/login.html")
    login = request.POST.get("login", "")
    user = auth.validate(login, request.POST.get("password", ""))
    if user is None:
        # a deliberately vague message: we do not say whether it is the login or the password
        return render(request, "auth/login.html",
                      {"login": login, "error": "Incorrect login or password."})
    # the token, in a cookie: the page's JavaScript cannot read it
    # (httponly); it does not accompany the POST requests coming from other sites
    # (samesite); it disappears at the same time as the token expires (max_age)
    response = redirect("profile")
    response.set_cookie(auth.COOKIE, auth.create_token(user),
                        max_age=settings.JWT_DURATION_MINUTES * 60, httponly=True, samesite="Lax")
    return response


# logging out: POST only (a simple link, placed on another site, cannot
# log the user out without their knowledge); we delete the cookie
@require_POST
def logout(request):
    response = redirect("login")
    response.delete_cookie(auth.COOKIE)
    return response


# request.user: the authenticated user, found by the middleware
@require_GET
@login_required
def profile(request):
    return render(request, "profile/profile.html", {"user": request.user})


# a page reserved for the ADMIN role
@require_GET
@role_required("ADMIN")
def admin(request):
    return render(request, "admin/admin.html", {"name": request.user.name})
  • Lines 55–58: the administration page, protected by the decorator [@role_required("ADMIN")]. The view itself performs no checks.

For rules that are more granular than a role (“a user can only edit their own appointments”), the view performs the validation itself and raises [PermissionDenied] as needed. The case study provides examples of this.

8.2.5. The profile view

{% extends "layout.html" %}

{% block content %}
  <h1 class="h3">Hello {{ user.name }}</h1>
  <p>Your role: <b>{% if user.role == "ADMIN" %}administrator{% else %}user{% endif %}</b>.</p>
  {# is_admin: does the user have the ADMIN role? #}
  {% if user.is_admin %}
    <p><a href="{% url 'admin' %}">The administration area</a></p>
  {% else %}
    <p><a href="{% url 'admin' %}">The administration area</a> (which is forbidden to you)</p>
  {% endif %}
  <p>The content of your token:</p>
  <table class="table table-sm w-auto">
    {% for key, value in user.claims.items %}
      <tr><td><code>{{ key }}</code></td><td>{{ value }}</td></tr>
    {% endfor %}
  </table>
  {# logging out: a POST form reduced to its button #}
  <form method="post" action="{% url 'logout' %}">
    {% csrf_token %}
    <button class="btn btn-outline-secondary" type="submit">Log out</button>
  </form>
{% endblock %}
  • lines 7–11: The [is_admin] property allows the page to be adapted to the role. This prevents a link from being displayed that would lead to an error in a real application (here, the link is left for Marie, for the sake of the experiment); however, this is merely a convenience: the actual protection is provided by the view.

8.2.6. Execution

Marie, with role USER, follows the link to the administration page:

Image

The administrator, on the other hand, has access to it:

Image

With [curl]: a user with role USER (logged in as in Example 25, with cookies stored in the file [token.txt]), followed by an anonymous visitor:

curl -i -b token.txt http://localhost:8000/admin
curl -i http://localhost:8000/admin
HTTP/1.1 403 Forbidden
Content-Type: text/html; charset=utf-8
...
  <div class="alert alert-danger">
    <h1 class="h4">Error 403</h1>
    <p class="mb-0">Access denied: this page is reserved for administrators.</p>
  </div>
...
HTTP/1.1 302 Found
Content-Type: text/html; charset=utf-8
Location: /login

Protecting views one by one has a flaw: if one is overlooked, the page becomes accessible to everyone. The case study will reverse this logic: all URL pages are protected by default through access control middleware, and only a few URL pages (the login page, the registration page) are exempt.

8.3. Example 27: CAPTCHA and Attempt Limits

A login form is a target: a program can try thousands of passwords per minute (brute-force attack). Two protective measures complement each other:

  • a CAPTCHA (Completely Automated Public Turing test to tell Computers and Humans Apart): an image containing a distorted code that is easy for a human to read but difficult for a program;
  • an attempt limit: after three failed attempts, the client’s address IP is blocked for one minute.

Example 27 is based on Example 25 and adds these two security measures:

27-captcha/
├── accounts/
│   └── views.py
├── config/
│   └── settings.py
├── security/
│   ├── auth.py
│   ├── captcha_drawing.py
│   ├── captchas.py
│   ├── decorators.py
│   ├── limiter.py
│   ├── middleware.py
│   └── users.py
├── templates/
│   └── auth/
│       └── login.html
└── ...

8.3.1. The CAPTCHA image: security/captcha_drawing.py

# security/captcha_drawing.py: the drawing of a captcha, an SVG image (XML text)
# built by hand

import math
import secrets

WIDTH = 150
HEIGHT = 50

# The "font": each character is a list of strokes, each stroke a sequence of
# points x, y in a grid of 4 x 6 (y downwards).
# Characters too easy to confuse with each other: excluded (0 O, 1 I L).
FONT = {
    "A": [[0, 6, 2, 0, 4, 6], [0.8, 3.8, 3.2, 3.8]],
    "B": [[0, 0, 0, 6, 3, 6, 4, 5, 4, 4, 3, 3, 0, 3], [0, 0, 3, 0, 4, 1, 4, 2, 3, 3]],
    "C": [[4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5]],
    "D": [[0, 0, 0, 6, 2.5, 6, 4, 4.5, 4, 1.5, 2.5, 0, 0, 0]],
    "E": [[4, 0, 0, 0, 0, 6, 4, 6], [0, 3, 3, 3]],
    "F": [[4, 0, 0, 0, 0, 6], [0, 3, 3, 3]],
    "G": [[4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5, 4, 3.5, 2.5, 3.5]],
    "H": [[0, 0, 0, 6], [4, 0, 4, 6], [0, 3, 4, 3]],
    "J": [[4, 0, 4, 5, 3, 6, 1, 6, 0, 5]],
    "K": [[0, 0, 0, 6], [4, 0, 0, 3.5], [1.3, 2.6, 4, 6]],
    "M": [[0, 6, 0, 0, 2, 3.5, 4, 0, 4, 6]],
    "N": [[0, 6, 0, 0, 4, 6, 4, 0]],
    "P": [[0, 6, 0, 0, 3, 0, 4, 1, 4, 2, 3, 3, 0, 3]],
    "Q": [[1, 0, 3, 0, 4, 1, 4, 5, 3, 6, 1, 6, 0, 5, 0, 1, 1, 0], [2.5, 4.5, 4, 6.3]],
    "R": [[0, 6, 0, 0, 3, 0, 4, 1, 4, 2, 3, 3, 0, 3], [2, 3, 4, 6]],
    "S": [[4, 1, 3, 0, 1, 0, 0, 1, 0, 2, 1, 3, 3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5]],
    "T": [[0, 0, 4, 0], [2, 0, 2, 6]],
    "U": [[0, 0, 0, 5, 1, 6, 3, 6, 4, 5, 4, 0]],
    "V": [[0, 0, 2, 6, 4, 0]],
    "W": [[0, 0, 1, 6, 2, 2.5, 3, 6, 4, 0]],
    "X": [[0, 0, 4, 6], [4, 0, 0, 6]],
    "Y": [[0, 0, 2, 3, 4, 0], [2, 3, 2, 6]],
    "Z": [[0, 0, 4, 0, 0, 6, 4, 6]],
    "2": [[0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 0, 6, 4, 6]],
    "3": [[0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 3, 3, 1.5, 3], [3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5]],
    "4": [[3, 6, 3, 0, 0, 4, 4, 4]],
    "5": [[4, 0, 0, 0, 0, 3, 3, 3, 4, 4, 4, 5, 3, 6, 1, 6, 0, 5]],
    "6": [[4, 1, 3, 0, 1, 0, 0, 1, 0, 5, 1, 6, 3, 6, 4, 5, 4, 4, 3, 3, 0, 3]],
    "7": [[0, 0, 4, 0, 1.5, 6]],
    "8": [[1, 3, 0, 2, 0, 1, 1, 0, 3, 0, 4, 1, 4, 2, 3, 3, 1, 3, 0, 4, 0, 5, 1, 6, 3, 6, 4, 5,
           4, 4, 3, 3]],
    "9": [[4, 3, 1, 3, 0, 2, 0, 1, 1, 0, 3, 0, 4, 1, 4, 5, 3, 6, 1, 6, 0, 5]],
}

# a few dark colors, easy to read on the light background
COLORS = ["#1f4e79", "#7b2d26", "#2e6b30", "#5b2c83", "#8a5a00", "#23395d", "#9c2a5b"]

# a "cryptographic" generator, unpredictable (unlike the random module,
# whose sequence could be guessed)
_random = secrets.SystemRandom()


def random_text(length: int) -> str:
    alphabet = list(FONT)
    return "".join(secrets.choice(alphabet) for _ in range(length))


# a floating-point number, at random, between minimum and maximum
def random(minimum: float, maximum: float) -> float:
    return _random.uniform(minimum, maximum)


def color() -> str:
    return secrets.choice(COLORS)


def svg(text: str) -> str:
    w, h = WIDTH, HEIGHT
    parts = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}">',
             '<rect width="100%" height="100%" fill="#f5f7fa"/>']
    # the characters: each one enlarged, tilted, shifted, and each point slightly
    # moved at random
    step = (w - 20) / len(text)
    for i, character in enumerate(text):
        scale = random(4.6, 5.6)    # size of a unit of the grid, in pixels
        angle = random(-0.35, 0.35)   # rotation, in radians (about ±20°)
        cx = 10 + step * (i + 0.5) + random(-3, 3)    # center of the character
        cy = h / 2 + random(-4, 4)
        for stroke in FONT[character]:
            d = []
            # range(0, n, 2): 0, 2, 4... a point = two numbers
            for k in range(0, len(stroke), 2):
                # point of the grid, centered on (2, 3), distorted, rotated, placed
                x = (stroke[k] - 2 + random(-0.2, 0.2)) * scale
                y = (stroke[k + 1] - 3 + random(-0.2, 0.2)) * scale
                px = cx + x * math.cos(angle) - y * math.sin(angle)
                py = cy + x * math.sin(angle) + y * math.cos(angle)
                d.append(f"{'M' if k == 0 else 'L'}{px:.1f} {py:.1f}")
            parts.append(f'<path d="{" ".join(d)}" fill="none" stroke="{color()}" '
                         f'stroke-width="{random(2.2, 3):.1f}" stroke-linecap="round" '
                         f'stroke-linejoin="round"/>')
    # 3 scrambling strokes: curves that cross the image
    for _ in range(3):
        parts.append(f'<path d="M{random(0, 20):.1f} {random(5, 45):.1f} '
                     f'C{random(30, 60):.1f} {random(0, 50):.1f} {random(90, 120):.1f} '
                     f'{random(0, 50):.1f} {random(130, 150):.1f} {random(5, 45):.1f}" '
                     f'fill="none" stroke="{color()}" stroke-width="1.3"/>')
    parts.append("</svg>")
    return "".join(parts)

The image is in SVG format (Scalable Vector Graphics): a vector graphic described in XML—that is, text—which the server generates without a graphics library and which the browser displays directly on the page. This module is pure [Python], with nothing specific to [Django]: it is the [Python] implementation of the classes from the [Laravel] and [Symfony] versions of this course.

  • Lines 13–46: a minimalist “font”: each character is a list of strokes, and each stroke is a list of x and y coordinates in a 4 × 6 grid. Characters that are easily confused ([0] and [O], [1], [I], and [L]) are excluded;
  • lines 53–58: random text, drawn from the characters in the font ([list(FONT)]: the list of dictionary keys). The [random] module of [Python] produces predictable number sequences (anyone who knows a few values can guess the next ones): it is suitable for simulations, not for security. The [secrets] module is a cryptographic generator that is unpredictable: [secrets.choice] draws an element from a list, and [secrets.SystemRandom] provides the functions of [random] ([uniform]: a floating-point number between two bounds) using this generator. [for _ in range(length)] repeats [length] times; the name [_] indicates a variable that is not used;
  • Lines 70–102: the drawing. Each character is enlarged, rotated, and moved at random, and each of its points is slightly offset; each stroke becomes an element [<path>] of SVG ([M]: displacement, [L]: line). Three curves ([C]: Bézier curve) cross the image to interfere with recognition programs. In an f-string, [{px:.1f}] writes a number with one decimal place; a conditional expression may be included ([{'M' if k == 0 else 'L'}]). The [math] module provides [cos] and [sin]. The parts of SVG are assembled by [join].

8.3.2. The CAPTCHA service: security/captchas.py

# security/captchas.py: the pending captchas, kept by the server, in the cache

import secrets
from dataclasses import dataclass

from django.core.cache import cache

from . import captcha_drawing

# the lifetime of a captcha, in seconds
LIFETIME = 600


# a captcha: its identifier, and its image (SVG)
@dataclass(frozen=True)
class Captcha:
    id: str
    svg: str


def generate() -> Captcha:
    text = captcha_drawing.random_text(5)
    # a random identifier, impossible to guess: 12 bytes drawn by a
    # cryptographic generator, written in hexadecimal
    id = secrets.token_hex(12)
    # the expected answer, in the cache, for 10 minutes: the cache will forget it by
    # itself afterwards
    cache.set(f"captcha.{id}", text, LIFETIME)
    return Captcha(id, captcha_drawing.svg(text))


# the captcha is CONSUMED in all cases: an image is used only once
def verify(id: str, entered: str) -> bool:
    if not id or not entered:
        return False
    # get returns the value, or None; delete removes the answer from the cache, whether it is
    # right or not
    expected = cache.get(f"captcha.{id}")
    cache.delete(f"captcha.{id}")
    if expected is None:
        return False    # unknown identifier, or expired captcha
    # casefold: the string in lowercase, for a comparison that ignores case
    return expected.casefold() == entered.strip().casefold()
  • line 6: the cache for [Django]: a store of key→value pairs shared by all modules. Where it stores these values depends on the setting [CACHES] (see below);
  • Lines 21–29: a new CAPTCHA: a random text string, a random identifier that is impossible to guess ([secrets.token_hex(12)]: 12 random bytes, written in hexadecimal, or 24 characters), and the image. For each identifier, the server stores the expected response in the cache for ten minutes: the third parameter of [cache.set] is the lifetime, in seconds. After this time, the cache automatically discards it. The page receives only the identifier and the image: the response never leaves the server;
  • lines 33–43: the verification. The expected response is read ([cache.get] yields [None] for a missing key), then retrieved from the cache ([cache.delete]): The CAPTCHA is consumed in all cases, whether successful or not. Without this, a program could try every possible answer on the same image. The comparison ignores case ([casefold] converts a string to lowercase, correctly handling all alphabets) and spaces around the input ([strip]).

8.3.3. Attempt Limit: security/limiter.py

# security/limiter.py: the limitation of the login attempts, per IP address

import time

from django.conf import settings
from django.core.cache import cache


# the failures of a client are counted in a fixed time WINDOW: at most
# LOGIN_MAX_FAILURES failures per window of LOGIN_WINDOW_SECONDS seconds
class Limiter:
    def __init__(self, ip: str):
        self.key = f"login.{ip}"

    # the seconds to wait if the client is blocked, 0 otherwise
    def wait(self) -> int:
        state = cache.get(self.key)
        if state is None or state["failures"] < settings.LOGIN_MAX_FAILURES:
            return 0
        return max(1, int(state["end"] - time.time()))

    # one more failure
    def failure(self) -> None:
        state = cache.get(self.key)
        if state is None:
            # a new window starts
            state = {"failures": 0, "end": time.time() + settings.LOGIN_WINDOW_SECONDS}
        state["failures"] += 1
        # the key disappears from the cache at the end of the window (timeout, in seconds)
        cache.set(self.key, state, timeout=max(1, int(state["end"] - time.time())))

    # a successful login clears the failures
    def clear(self) -> None:
        cache.delete(self.key)

The limiter counts a client’s failed attempts within a fixed time window: no more than three failed attempts per 60-second window, counted from the first failed attempt. Each client is identified by its address IP.

  • line 13: the cache key, specific to the client: [login.127.0.0.1];
  • lines 16–20: the number of seconds to wait if the client is blocked (the number of failures has reached the maximum), 0 otherwise. The state stored in the cache is a dictionary: the number of failures and the end time of the window;
  • lines 23–30: one more failure. If there is no state in the cache, a new window begins. The state is rewritten with a lifetime that expires at the end of the window: the cache will then automatically forget it, and the client will start over from scratch;
  • lines 33–34: a successful connection clears the failures.

This limiter, written for illustrative purposes, has a weakness: two simultaneous failures could read the same state and count it as only one. The cache in [Django] offers atomic operations ([cache.add], which writes only if the key does not exist, and [cache.incr], which increments a number), which are used in the case study version. Ready-made libraries are also available ([django-ratelimit], [django-axes]).

8.3.4. Configuration: config/settings.py

# config/settings.py: the configuration of example 27

import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = True
SECRET_KEY = "django-insecure-y=#3-i_9nrm%&a&_%_v^ajq460=!u*ry3uit-nj9_thr&v3ev)"
ALLOWED_HOSTS = []
LANGUAGE_CODE = "en"
TIME_ZONE = "Europe/Paris"
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"

INSTALLED_APPS = [
    "django.contrib.staticfiles",
    "security",
    "accounts",
]

MIDDLEWARE = [
    "django.middleware.csrf.CsrfViewMiddleware",
    # AUTHENTICATION: at each request, the user is found from the
    # JWT token of their cookie (see security/middleware.py)
    "security.middleware.Authentication",
]

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
    },
]

STATIC_URL = "static/"
STATICFILES_DIRS = [
    BASE_DIR.parent / "common" / "static",
]

# our own settings: the key that signs the JWT tokens (at least 32 characters), and
# their lifetime. os.environ.get: the environment variable of the same name if it
# exists (in production), the value written here otherwise (in development)
JWT_SECRET = os.environ.get("JWT_SECRET", "a-secret-key-of-at-least-32-characters-for-example-27")
JWT_DURATION_MINUTES = int(os.environ.get("JWT_DURATION_MINUTES", "60"))

# the application's CACHE: here, the memory of the server process (it is
# Django's default cache). The pending captchas and the failure counters are
# stored in it
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
    },
}

# the limitation of the login attempts: 3 failures per window of 60 seconds
LOGIN_MAX_FAILURES = 3
LOGIN_WINDOW_SECONDS = 60
  • Lines 49–53: the application cache. [LocMemCache] stores values in the process’s memory: this is the default cache for [Django], which we are writing to make it visible. It is limited to the application scope (see the chapter on scopes): it is process-specific and is lost when the server shuts down. The case study uses a cache stored in files ([FileBasedCache]), which survives restarts; in production, we would prefer [Redis] ([django.core.cache.backends.redis.RedisCache]), shared by all processes;
  • lines 56–57: the limiter’s parameters.

8.3.5. The view: accounts/views.py

# accounts/views.py: login (with captcha and limitation of the attempts),
# logout, and a page reserved for authenticated users

from django.conf import settings
from django.shortcuts import redirect, render
from django.views.decorators.http import require_GET, require_http_methods, require_POST

from security import auth, captchas
from security.decorators import login_required
from security.limiter import Limiter


@require_GET
def home(request):
    return redirect("profile")


# GET: the form; POST: the login, with captcha and limitation of the attempts
@require_http_methods(["GET", "POST"])
def login(request):
    if request.method == "GET":
        return login_page(request)
    login = request.POST.get("login", "")
    # the limiter of THIS client: its failures are counted under its IP address
    limiter = Limiter(request.META["REMOTE_ADDR"])
    # 1. a blocked address is not even listened to
    seconds = limiter.wait()
    if seconds > 0:
        return login_page(request, login, f"Too many attempts: try again in {seconds} s.")
    # 2. the captcha first (cheap), the password (PBKDF2, expensive) afterwards
    if not captchas.verify(request.POST.get("captcha_id", ""), request.POST.get("captcha", "")):
        limiter.failure()
        return login_page(request, login, "The code of the image is incorrect.")
    user = auth.validate(login, request.POST.get("password", ""))
    if user is None:
        limiter.failure()
        return login_page(request, login, "Incorrect login or password.")
    limiter.clear()
    response = redirect("profile")
    response.set_cookie(auth.COOKIE, auth.create_token(user),
                        max_age=settings.JWT_DURATION_MINUTES * 60, httponly=True, samesite="Lax")
    return response


# each display of the form builds a NEW captcha
def login_page(request, login="", error=None):
    return render(request, "auth/login.html",
                  {"login": login, "error": error, "captcha": captchas.generate()})


# logging out: POST only (a simple link, placed on another site, cannot
# log the user out without their knowledge); we delete the cookie
@require_POST
def logout(request):
    response = redirect("login")
    response.delete_cookie(auth.COOKIE)
    return response


# request.user: the authenticated user, found by the middleware
@require_GET
@login_required
def profile(request):
    return render(request, "profile/profile.html", {"user": request.user})
  • line 25: this client’s limiter, based on its address IP ([REMOTE_ADDR], example 05);
  • Lines 27–29: A blocked address is not even listened to;
  • lines 31–37: The CAPTCHA is verified before the password; verifying it costs almost nothing, whereas the hash PBKDF2 is intentionally slow. A program that gets the CAPTCHA wrong would therefore not put any load on the server. Every failure—whether for the CAPTCHA or the password—is counted. On the third failure, the address is blocked until the end of the 60-second window, counted from the first failure;
  • line 38: a successful login clears the failure count;
  • lines 46–48: each time the form is displayed, a new CAPTCHA is generated, even after a failure.

8.3.6. The template: templates/auth/login.html

{% extends "layout.html" %}

{% block content %}
  <h1 class="h3">Login</h1>
  <form method="post" action="{% url 'login' %}">
    {% csrf_token %}
    <div class="mb-2">
      <label class="form-label" for="login">Login</label>
      <input class="form-control" id="login" name="login" value="{{ login }}" required>
    </div>
    <div class="mb-2">
      <label class="form-label" for="password">Password</label>
      <input class="form-control" type="password" id="password" name="password" required>
    </div>
    <div class="mb-3">
      <label class="form-label" for="captcha">Copy the code of the image</label>
      {# the SVG image, built by the server: inserted without escaping (safe filter) #}
      <div class="mb-1">{{ captcha.svg|safe }}</div>
      {# the captcha identifier travels in a hidden field; the answer itself stays on the server #}
      <input type="hidden" name="captcha_id" value="{{ captcha.id }}">
      <input class="form-control" id="captcha" name="captcha" autocomplete="off" required>
    </div>
    <button class="btn btn-primary" type="submit">Log in</button>
  </form>
  {% if error %}
    <div class="alert alert-danger mt-3">{{ error }}</div>
  {% endif %}
  <p class="text-muted small mt-3">Test accounts (password = login): marie (USER), admin (ADMIN).</p>
{% endblock %}
  • Line 18: The image SVG is inserted into the page as-is, without escaping (filter [safe], Example 14). This is legitimate: this HTML was generated by the server; it does not contain any user input;
  • line 20: the captcha ID, in a hidden field;
  • line 21: the browser does not suggest any previous entries for this field ([autocomplete="off"]).

8.3.7. Execution

Image

An incorrectly copied code:

Image

After three failed attempts, the address is blocked:

Image

With [curl], four attempts using a made-up CAPTCHA ID (after obtaining, as in Example 25, the cookie and the anti-CSRF token):

curl -b cookies.txt -d "csrfmiddlewaretoken=...&login=marie&password=marie&captcha_id=abc&captcha=ABCDE" http://localhost:8000/login
1
2
3
4
    <div class="alert alert-danger mt-3">The code of the image is incorrect.</div>
    <div class="alert alert-danger mt-3">The code of the image is incorrect.</div>
    <div class="alert alert-danger mt-3">The code of the image is incorrect.</div>
    <div class="alert alert-danger mt-3">Too many attempts: try again in 59 s.</div>

Even with the correct password, the fourth attempt is rejected without being processed.