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 every 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.

Since the server is stateless (see the chapter on scopes), proof of identity must be included with every request. A token named JWT (*JSON Web Token*) is used: After login, the server generates a token containing the user’s identity and signs it with a secret key. 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. This is the same principle as the [Flask] session (Example 20), but with a standardized format (RFC 7519) that is understood by all programming languages, and an expiration date embedded in the token.

[Flask] does not have a built-in security mechanism, unlike [Spring] ([Spring Security]) or [ASP.NET Core]. Extensions exist ([Flask-Login], [Flask-JWT-Extended]...), but the mechanism is simple enough to be written by hand using the tools from the previous chapters, which has the advantage of making it transparent:

  • authentication is handled by a function [before_request] (previous chapter), which retrieves the token from the cookie, verifies it, and stores the authenticated user in [g], the request object;
  • authorization is handled by decorators applied to the actions ([@login_required], [@role_required("ADMIN")]), built like the [@with_language] decorator in Example 12.

Two libraries are used, installed by the [requirements.txt] file from the examples: [PyJWT], which generates and verifies tokens, and [bcrypt], which hashes passwords.

8.1. Example 25: Login, Token, Protected Page

25-jwt/
├── app.py
├── auth/
│   ├── security.py
│   ├── service.py
│   └── users.py
├── config.py
├── controllers/
│   ├── auth.py
│   └── profile.py
└── templates/
    ├── auth/
    │   └── login.html
    ├── layout.html
    └── profile/
        └── profile.html

8.1.1. Configuration: config.py and app.py

The token is signed with a secret key, which must not be hard-coded. It’s time to separate the configuration from the code:

1
2
3
4
5
6
# config.py: the configuration of the application, read by app.config.from_pyfile.
# Only the UPPERCASE variables are kept.

# the key that signs the JWT tokens (at least 32 characters), and their lifetime
JWT_SECRET = "a-secret-key-of-at-least-32-characters-for-example-25"
JWT_DURATION_MINUTES = 60

A [Flask] configuration file is simply a [Python] module: its variables in uppercase become keys for [app.config]. [JWT_SECRET] is the token signing key; the standard requires, for the algorithm used (HMAC-SHA256), a key of at least 256 bits, or 32 bytes. [JWT_DURATION_MINUTES] is the lifetime of a token.

# app.py: the application of example 25
from pathlib import Path

from flask import Blueprint, Flask

from auth import security
from auth.service import AuthService
from auth.users import UsersService
from controllers.auth import auth
from controllers.profile import profile

BOOTSTRAP = Path(__file__).resolve().parent.parent / "common" / "bootstrap"


def create_app():
    app = Flask(__name__)
    app.jinja_env.trim_blocks = True
    app.jinja_env.lstrip_blocks = True
    # the configuration: the config.py file, then the environment variables
    # that start with FLASK_ (FLASK_JWT_SECRET replaces JWT_SECRET)
    app.config.from_pyfile("config.py")
    app.config.from_prefixed_env()
    # the authentication services
    users = UsersService()
    app.extensions["auth"] = AuthService(users, app.config["JWT_SECRET"],
                                         app.config["JWT_DURATION_MINUTES"])
    app.register_blueprint(Blueprint("bootstrap", __name__, static_folder=BOOTSTRAP,
                                     static_url_path="/bootstrap"))
    app.register_blueprint(auth)
    app.register_blueprint(profile)
    # the user of each request, read from the token of the cookie
    security.register(app)
    return app


if __name__ == "__main__":
    create_app().run(port=5000, debug=True)
  • line 21: reading the configuration file, specified by a path relative to the application folder;
  • line 22: reading environment variables whose names begin with [FLASK_]: [FLASK_JWT_SECRET] replaces the key [JWT_SECRET] in the file. The value of a variable is interpreted as JSON whenever possible ([FLASK_JWT_DURATION_MINUTES=30] yields the integer 30). In production, the secret key is provided by the environment, and the file contains only a development value;
  • lines 24–26: the two authentication services, the second of which receives the key and the token lifetime;
  • line 32: the registration of the [before_request] function, which reads the token.

Under [Linux] or [macOS], the application would be launched with a different key as follows:

FLASK_JWT_SECRET="another-key-of-at-least-thirty-two-characters" python app.py

and under [Windows] (PowerShell):

$env:FLASK_JWT_SECRET="another-key-of-at-least-thirty-two-characters"
python app.py

8.1.2. Users: auth/users.py

# auth/users.py: the users, in memory (example 28 and the case
# study will put them in a database)
from dataclasses import dataclass

import bcrypt


@dataclass(frozen=True)
class User:
    login: str
    name: str
    role: str
    password_hash: bytes


# the hash of a password: bcrypt, with a cost of 10 (2^10 rounds);
# gensalt draws a random "salt", which is stored in the hash itself
def hash_password(password: str) -> bytes:
    return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=10))


class UsersService:

    def __init__(self):
        # we NEVER keep a password, only its hash
        self._users = {u.login: u for u in (
            User("marie", "Marie Pélissier", "USER", hash_password("marie")),
            User("admin", "Administrator", "ADMIN", hash_password("admin")),
        )}

    def find(self, login: str) -> User | None:
        return self._users.get(login)
  • Lines 9–13: a user: their login, name, role, and password hash. A server never stores a password, even in encrypted form: if the database were stolen, the passwords would be too (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 on file;
  • lines 18–19: hashing using the [bcrypt] algorithm, designed for passwords. It has two distinctive features. It is intentionally slow, with an adjustable slowness (the parameter [rounds], or cost: 2^10 computation cycles): a few tens of milliseconds, imperceptible to a user, but which makes it very costly for a hacker to try billions of passwords. It uses a salt: [gensalt] randomly generates a salt, mixes it with the password before hashing, and stores it within the hash itself; thus, two users with the same password will have different hashes. [bcrypt] operates on bytes: [encode()] converts the string into bytes ([UTF-8]). It considers only the first 72 bytes of a password, and version 5 of the library rejects longer passwords (the case study takes this into account);
  • Lines 26–28: two test users, stored in memory, organized in a dictionary built during processing ([{key: value for ...}]). Example 28 and the case study will store them in a database.

8.1.3. The authentication service: auth/service.py

# auth/service.py: the checking of the passwords and the JWT tokens
from datetime import datetime, timedelta, timezone

import bcrypt
import jwt
from flask import current_app

from auth.users import User, UsersService

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


class AuthService:

    def __init__(self, users: UsersService, secret: str, duration_minutes: int):
        self._users = users
        self._secret = secret
        self.duration = timedelta(minutes=duration_minutes)

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

    # the JWT token of the user, signed with the secret key (HMAC-SHA256)
    def create_token(self, user: User) -> str:
        now = datetime.now(timezone.utc)
        # 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 + self.duration,
        }
        return jwt.encode(claims, self._secret, algorithm="HS256")

    # the claims of a token whose signature and expiration date are
    # valid; None otherwise (forged, expired or unreadable token)
    def check(self, token: str) -> dict | None:
        try:
            return jwt.decode(token, self._secret, algorithms=["HS256"])
        except jwt.InvalidTokenError:
            return None


def auth_service() -> AuthService:
    return current_app.extensions["auth"]
  • Lines 24–31: Verification of a username and password. [bcrypt.checkpw] hashes the entered password using the stored hash salt and compares the two hashes. An unknown username and an incorrect password yield the same result, [None];
  • lines 34–44: generating the token. 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. The others are arbitrary: in this case, 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). Dates must be time-zone-aware dates: [datetime.now(timezone.utc)];
  • Lines 48–30: Token verification. [jwt.decode] verifies the signature and expiration date, then returns the assertions. Any anomaly (forged, expired, or malformed token) raises an exception from the [InvalidTokenError] family. The [algorithms] parameter specifies the accepted algorithms: without it, an attacker could create a token that declares a different algorithm (or even none at all, [none]).

8.1.4. Security: auth/security.py

# auth/security.py: the user of each request, and the protection of the actions
from functools import wraps

from flask import g, redirect, request, url_for

from auth.service import COOKIE, auth_service


def register(app):

    # before each action: the token of the cookie -> g.user (its claims), or None
    @app.before_request
    def read_token():
        token = request.cookies.get(COOKIE)
        g.user = auth_service().check(token) if token else None


# a decorator: the action is executed only for an authenticated user;
# otherwise, off to the login page
def login_required(action):
    @wraps(action)
    def protected_action(*args, **kwargs):
        if g.user is None:
            response = redirect(url_for("auth.form"))
            # a cookie present but invalid (expired, forged token): we delete it
            if COOKIE in request.cookies:
                response.delete_cookie(COOKIE)
            return response
        return action(*args, **kwargs)

    return protected_action
  • lines 13–15: Before each action, the cookie token is verified; [g.user] contains the claims of a valid token, or [None]. This function does not reject anything: it establishes who the user is. This is authentication;
  • lines 20–31: the decorator that requires an authenticated user. For an anonymous visitor, it does not return the action but instead redirects to the login page. If the browser sent a cookie, it means the token is invalid (expired or forged): we take this opportunity to delete it ([delete_cookie] sends an empty cookie that has already expired).

8.1.5. The controllers

# controllers/auth.py: login and logout
from flask import Blueprint, redirect, render_template, request, url_for

from auth.service import COOKIE, auth_service

auth = Blueprint("auth", __name__)


@auth.get("/")
def home():
    return redirect(url_for("profile.display"))


@auth.get("/login")
def form():
    return render_template("auth/login.html")


@auth.post("/login")
def login():
    login = request.form.get("login")
    user = auth_service().validate(login, request.form.get("password"))
    if user is None:
        # a deliberately vague message: we do not say whether it is the login or the password
        return render_template("auth/login.html", login=login,
                               error="Incorrect login or password.")
    response = redirect(url_for("profile.display"))
    # the token, in a cookie: the JavaScript of the page cannot read it
    # (httponly); it does not go with POST requests coming from other sites
    # (samesite); it disappears at the same time as the token expires (max_age)
    response.set_cookie(COOKIE, auth_service().create_token(user), httponly=True,
                        samesite="Lax", max_age=auth_service().duration)
    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
@auth.post("/logout")
def logout():
    response = redirect(url_for("auth.form"))
    response.delete_cookie(COOKIE)
    return response
  • lines 20–26: the login. If the login fails, the form is redisplayed with the entered username and a deliberately vague message: saying “unknown username” would reveal to a hacker which usernames exist;
  • lines 27–32: if successful, the token is stored in a cookie, with a redirect 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 (example 18); its lifetime ([max_age], which accepts a [timedelta]) is that of the token. In production, for HTTPS, we would add [secure=True]: the cookie would never be sent over an unencrypted connection;
  • lines 38–41: Logging out deletes the cookie. This is a POST action: 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.
# controllers/profile.py: a page reserved for authenticated users
from flask import Blueprint, g, render_template

from auth.security import login_required

profile = Blueprint("profile", __name__)


@profile.get("/profile")
@login_required
def display():
    # g.user: the claims of the token, already checked (see auth/security.py)
    return render_template("profile/profile.html", name=g.user["name"],
                           admin=g.user["role"] == "ADMIN", claims=g.user)
  • line 10: the page is protected;
  • line 13: the action uses [g.user], ensuring it is not [None].

8.1.6. The views

{% extends "layout.html" %}

{% block content %}
  <h1 class="h3">Login</h1>
  <form method="post" action="/login">
    <div class="mb-2">
      <label class="form-label" for="login">Login</label>
      <input class="form-control" id="login" name="login" value="{{ login or '' }}" 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 8: the entered login is redisplayed after a failure; the expression [login or ''] evaluates to an empty string if [login] is [None] (or absent).
{% extends "layout.html" %}

{% block content %}
  <h1 class="h3">Hello {{ name }}</h1>
  <p>Your role: <b>{{ "administrator" if admin else "user" }}</b>.</p>
  <p>The content of your token:</p>
  <table class="table table-sm w-auto">
    {# a loop over a dictionary: items() gives the pairs (key, value) #}
    {% for key, value in 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="/logout">
    <button class="btn btn-outline-secondary" type="submit">Log out</button>
  </form>
{% endblock %}
  • Lines 9–11: The token’s contents, displayed for illustrative purposes;
  • lines 14–16: Logout, via a form reduced to a single button.

8.1.7. Execution

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

curl -i http://localhost:5000/profile
1
2
3
4
5
HTTP/1.1 302 FOUND
Content-Type: text/html; charset=utf-8
Content-Length: 199
Location: /login
...

An incorrect password:

Image

Login successful. With [curl], we save the received cookie to a file:

curl -i -c token.txt -d "login=marie&password=marie" http://localhost:5000/login
1
2
3
4
5
6
HTTP/1.1 302 FOUND
Content-Type: text/html; charset=utf-8
Content-Length: 203
Location: /profile
Set-Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYXJpZSIsIm5hbWUiOiJNYXJpZSBQXHUwMGU5bGlzc2llciIsInJvbGUiOiJVU0VSIiwiaWF0IjoxNzkwNDMwMDE4LCJleHAiOjE3OTA0MzM2MTh9.5jkKhOHitXj6dsXqgVx85QQIovkt2YB_oygSS-68jiE; Expires=Sat, 26 Sep 2026 14:40:18 GMT; Max-Age=3600; HttpOnly; 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":1790430018,"exp":1790433618}

Dates are expressed in seconds elapsed since January 1, 1970; the expiration time is one hour after creation. In the browser, the profile page displays this same content:

Image

A malicious user modifies their token: they replace the role ***[USER]*** in the content with ***[ADMIN]***, 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>.5jkKhOHitXj6dsXqgVx85QQIovkt2YB_oygSS-68jiE" http://localhost:5000/profile
1
2
3
4
5
6
HTTP/1.1 302 FOUND
Content-Type: text/html; charset=utf-8
Content-Length: 199
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. 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. A decorator with a parameter: auth/security.py

# auth/security.py: the user of each request, and the protection of the actions
from functools import wraps

from flask import abort, g, redirect, request, url_for

from auth.service import COOKIE, auth_service


def register(app):

    # before each action: the token of the cookie -> g.user (its claims), or None
    @app.before_request
    def read_token():
        token = request.cookies.get(COOKIE)
        g.user = auth_service().check(token) if token else None


# a decorator: the action is executed only for an authenticated user;
# otherwise, off to the login page
def login_required(action):
    @wraps(action)
    def protected_action(*args, **kwargs):
        if g.user is None:
            response = redirect(url_for("auth.form"))
            # a cookie present but invalid (expired, forged token): we delete it
            if COOKIE in request.cookies:
                response.delete_cookie(COOKIE)
            return response
        return action(*args, **kwargs)

    return protected_action


# a decorator WITH A PARAMETER: @role_required("ADMIN"). role_required("ADMIN") returns
# the decorator proper, which receives the action. It is used AFTER (under)
# @login_required: the user is then necessarily authenticated
def role_required(role: str):
    def decorator(action):
        @wraps(action)
        def protected_action(*args, **kwargs):
            if g.user["role"] != role:
                abort(403)  # access denied
            return action(*args, **kwargs)

        return protected_action

    return decorator
  • Lines 37–47: [@role_required("ADMIN")] has one parameter. The expression following [@] is evaluated first: [role_required("ADMIN")] returns a function, [decorator], which is the decorator itself, applied to the action. We therefore have three nested functions: [role_required] receives the role, [decorator] receives the action, and [protected_action] receives the request parameters and performs the work. Each has access to the variables of the functions that contain it ([role], [action]);
  • Line 42: An authenticated user who does not have the requested role receives a 403 error (*Forbidden*). This is significantly different from an anonymous user, who is redirected to the login page: logging in would serve no purpose, since the user is already authenticated.

8.2.2. The controller: controllers/admin.py

# controllers/admin.py: a page reserved for the ADMIN role
from flask import Blueprint, g, render_template

from auth.security import login_required, role_required

admin = Blueprint("admin", __name__)


# the decorators apply from bottom to top: role_required wraps the action,
# then login_required wraps the whole. At execution time, it is therefore
# login_required that checks FIRST that the user is authenticated
@admin.get("/admin")
@login_required
@role_required("ADMIN")
def display():
    return render_template("admin/admin.html", name=g.user["name"])
  • Lines 12–14: Decorators are applied from bottom to top: [role_required] wraps the action, then [login_required] wraps the entire structure. At runtime, the order is reversed: the outer wrapper, [login_required], executes first. So when [role_required] runs, the user is necessarily authenticated, and [g.user] is not [None].

8.2.3. The error page: app.py

# app.py: the application of example 26
from pathlib import Path

from flask import Blueprint, Flask, render_template

from auth import security
from auth.service import AuthService
from auth.users import UsersService
from controllers.admin import admin
from controllers.auth import auth
from controllers.profile import profile

BOOTSTRAP = Path(__file__).resolve().parent.parent / "common" / "bootstrap"


def create_app():
    app = Flask(__name__)
    app.jinja_env.trim_blocks = True
    app.jinja_env.lstrip_blocks = True
    # the configuration: the config.py file, then the environment variables
    # that start with FLASK_ (FLASK_JWT_SECRET replaces JWT_SECRET)
    app.config.from_pyfile("config.py")
    app.config.from_prefixed_env()
    # the authentication services
    users = UsersService()
    app.extensions["auth"] = AuthService(users, app.config["JWT_SECRET"],
                                         app.config["JWT_DURATION_MINUTES"])
    app.register_blueprint(Blueprint("bootstrap", __name__, static_folder=BOOTSTRAP,
                                     static_url_path="/bootstrap"))
    app.register_blueprint(auth)
    app.register_blueprint(profile)
    app.register_blueprint(admin)
    # the user of each request, read from the token of the cookie
    security.register(app)

    # insufficient role: abort(403) (see auth/security.py) -> our error page
    @app.errorhandler(403)
    def access_denied(e):
        return render_template("error.html", code=403), 403

    return app


if __name__ == "__main__":
    create_app().run(port=5000, debug=True)
  • Lines 37–39: A registered error handler for a specific code—error 403—displays our page.
1
2
3
4
5
6
7
8
9
{% extends "layout.html" %}

{% block content %}
  <div class="alert alert-danger">
    <h1 class="h4">Error {{ code }}</h1>
    <p class="mb-0">{{ "Access denied: this page is reserved for administrators." if code == 403 else "An error occurred." }}</p>
  </div>
  <p><a href="/profile">Back to the profile</a></p>
{% endblock %}

8.2.4. 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, followed by an anonymous visitor:

curl -i -b token.txt http://localhost:5000/admin
curl -i http://localhost:5000/admin
HTTP/1.1 403 FORBIDDEN
Content-Type: text/html; charset=utf-8
Content-Length: 492
...
  <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
Content-Length: 199
Location: /login

Protecting actions one by one has a flaw: a single oversight opens a page to everyone. The case study will reverse this logic: all actions are protected by default, and a decorator [@public] designates the rare exceptions (the login page, registration).

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 protections 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 (without the administration page) and adds these two security measures.

8.3.1. The CAPTCHA image: auth/captcha_drawing.py

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

WIDTH = 150
HEIGHT = 50

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

# The "font": each character is a list of strokes, each stroke a
# sequence of points (x, y) in a 4 x 6 grid (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]],
}

ALPHABET = "".join(FONT)

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


def random_text(length: int) -> str:
    return "".join(_random.choice(ALPHABET) for _ in range(length))


def random(minimum: float, maximum: float) -> float:
    return _random.uniform(minimum, maximum)


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


def svg(text: str) -> str:
    parts = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{HEIGHT}" '
             f'viewBox="0 0 {WIDTH} {HEIGHT}">',
             '<rect width="100%" height="100%" fill="#f5f7fa"/>']

    # the characters: each one enlarged, tilted, shifted, and each point
    # slightly moved at random
    step = (WIDTH - 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 = HEIGHT / 2 + random(-4, 4)
        d = []
        for stroke in FONT[character]:
            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} {random(0, 50):.1f} '
            f'{random(130, 150):.1f} {random(5, 45):.1f}" fill="none" stroke="{color()}" '
            f'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.

  • Line 11: The [random] module of [Python] produces predictable sequences of numbers (anyone who knows a few values can guess the next ones): it is suitable for simulations, not for security. The [secrets] module provides a cryptographic generator that is unpredictable;
  • Lines 16–48: A minimalist “font”: each character is a list of strokes, and each stroke is a list of coordinates [x, y] in a 4 × 6 grid. Characters that are easily confused ([0] and [O], [1], [I], and [L]) are excluded;
  • line 51: ["".join(FONT)] combines the dictionary keys: the available characters;
  • lines 57–58: random text. [_] is the conventional name for an unused variable;
  • lines 69–103: the drawing. Each character is enlarged, rotated, and moved at random, and each of its points is slightly offset; each line becomes a [<path>] element of the SVG ([M]: displacement, [L]: line). Three curves ([C]: Bézier curve) cross the image to confuse recognition programs. The [:.1f] formats write numbers with one decimal place.

8.3.2. The CAPTCHA service: auth/captcha.py

# auth/captcha.py: the pending captchas, kept in memory by the server
import secrets
from dataclasses import dataclass
from datetime import datetime, timedelta
from threading import Lock

from flask import current_app

from auth import captcha_drawing

LIFETIME = timedelta(minutes=10)


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


class CaptchaService:

    def __init__(self):
        # identifier -> (expected answer, expiration date)
        self._pending: dict[str, tuple[str, datetime]] = {}
        self._lock = Lock()

    def generate(self) -> Captcha:
        text = captcha_drawing.random_text(5)
        id = secrets.token_urlsafe(16)  # a random identifier, impossible to guess
        with self._lock:
            self._pending[id] = (text, datetime.now() + LIFETIME)
        return Captcha(id, captcha_drawing.svg(text))

    # the captcha is CONSUMED in all cases: an image is used only once
    def check(self, id: str | None, answer: str | None) -> bool:
        with self._lock:
            expected = self._pending.pop(id, None) if id else None
        if expected is None or answer is None:
            return False
        text, expires = expected
        return expires > datetime.now() and text.lower() == answer.strip().lower()


def captcha_service() -> CaptchaService:
    return current_app.extensions["captchas"]
  • lines 28–33: a new CAPTCHA: random text, a random and unguessable identifier ([secrets.token_urlsafe]), and the image. For each identifier, the server stores the expected answer and an expiration date. The page receives only the identifier and the image: the answer never leaves the server;
  • lines 36–42: verification. [pop] removes the entry from the dictionary and returns it: 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 and spaces around the input.

This service keeps data in memory on the server: this is a departure from the stateless server principle (see the chapter on scopes), which is acceptable for short-lived data. With multiple servers, the data would need to be stored in a shared storage system (a database, [Redis]...). Note also that captchas that have never been used are not deleted: the case study regularly cleans up expired captchas.

8.3.3. Attempt limit: auth/limiter.py

# auth/limiter.py: counts the login failures of each IP address
import math
from datetime import datetime, timedelta
from threading import Lock

from flask import current_app

MAX_FAILURES = 3
BLOCK_DURATION = timedelta(minutes=1)


class Counter:
    def __init__(self):
        self.failures = 0
        self.blocked_until = datetime.min


class LimiterService:

    def __init__(self):
        self._counters: dict[str, Counter] = {}
        # several simultaneous requests, a single access at a time
        self._lock = Lock()

    # the number of remaining seconds of blocking (0: not blocked)
    def blocked_seconds(self, ip: str) -> int:
        with self._lock:
            counter = self._counters.get(ip)
            if counter is None:
                return 0
            remaining = (counter.blocked_until - datetime.now()).total_seconds()
            return max(0, math.ceil(remaining))

    def failure(self, ip: str):
        with self._lock:
            counter = self._counters.setdefault(ip, Counter())
            counter.failures += 1
            if counter.failures >= MAX_FAILURES:
                counter.blocked_until = datetime.now() + BLOCK_DURATION
                counter.failures = 0  # after the blocking, we start again from zero

    def success(self, ip: str):
        with self._lock:
            self._counters.pop(ip, None)


def limiter_service() -> LimiterService:
    return current_app.extensions["limiter"]
  • lines 12–15: for each address IP, a number of failed attempts and an end-of-block date ([datetime.min], the earliest possible date, means “not blocked”);
  • lines 26–32: the number of seconds remaining in the lock, rounded up to the nearest whole number ([math.ceil]);
  • lines 34–40: one more failure. [setdefault] resets the address counter, creating it if it does not exist. On the third failure, the address is blocked for one minute, and the counter resets to zero;
  • lines 42–44: A successful connection clears the counter.

8.3.4. The controller: controllers/auth.py

# controllers/auth.py: login (with captcha and limitation of attempts) and logout
from flask import Blueprint, redirect, render_template, request, url_for

from auth.captcha import captcha_service
from auth.limiter import limiter_service
from auth.service import COOKIE, auth_service

auth = Blueprint("auth", __name__)


@auth.get("/")
def home():
    return redirect(url_for("profile.display"))


@auth.get("/login")
def form():
    return login_page()


@auth.post("/login")
def login():
    login = request.form.get("login")
    # the IP address of the client
    ip = request.remote_addr

    # 1. a blocked address is not even listened to
    seconds = limiter_service().blocked_seconds(ip)
    if seconds > 0:
        return login_page(login, f"Too many attempts: try again in {seconds} s.")
    # 2. the captcha first (cheap), the password (bcrypt, expensive) afterwards
    if not captcha_service().check(request.form.get("captchaId"), request.form.get("captcha")):
        limiter_service().failure(ip)
        return login_page(login, "The image code is incorrect.")
    user = auth_service().validate(login, request.form.get("password"))
    if user is None:
        limiter_service().failure(ip)
        return login_page(login, "Incorrect login or password.")
    limiter_service().success(ip)
    response = redirect(url_for("profile.display"))
    response.set_cookie(COOKIE, auth_service().create_token(user), httponly=True,
                        samesite="Lax", max_age=auth_service().duration)
    return response


@auth.post("/logout")
def logout():
    response = redirect(url_for("auth.form"))
    response.delete_cookie(COOKIE)
    return response


# each display of the form builds a NEW captcha
def login_page(login: str | None = None, error: str | None = None):
    return render_template("auth/login.html", login=login, error=error,
                           captcha=captcha_service().generate())
  • line 25: the client’s address, IP;
  • lines 28–30: a blocked address is not even listened to;
  • lines 32–34: The CAPTCHA is verified before the password: verifying it costs almost nothing, whereas [bcrypt] is intentionally slow. A program that enters the wrong CAPTCHA would therefore not trigger [bcrypt]. Every failure—whether for the CAPTCHA or the password—is counted;
  • line 39: a successful login clears the failure count;
  • lines 54–56: each time the form is displayed, a new CAPTCHA is generated, even after a failure.

8.3.5. The view: templates/auth/login.html

{% extends "layout.html" %}

{% block content %}
  <h1 class="h3">Login</h1>
  <form method="post" action="/login">
    <div class="mb-2">
      <label class="form-label" for="login">Login</label>
      <input class="form-control" id="login" name="login" value="{{ login or '' }}" 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) #}
      <div class="mb-1">{{ captcha.svg | safe }}</div>
      {# the captcha identifier travels in a hidden field; the answer stays on the server #}
      <input type="hidden" name="captchaId" 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 17: the image SVG is inserted as-is into the page, using the filter [safe] (example 14). This is legitimate: this HTML was generated by the server; it contains no user input;
  • Line 19: The captcha ID, in a hidden field;
  • line 20: the browser does not suggest any previous entries for this field.

8.3.6. Execution

Image

An incorrectly copied code:

Image

On the third failed attempt, the address is blocked:

Image

With [curl], four attempts with a made-up CAPTCHA ID:

curl -d "login=marie&password=marie&captchaId=abc&captcha=XXXXX" http://localhost:5000/login
1
2
3
4
  <div class="alert alert-danger mt-3">The image code is incorrect.</div>
  <div class="alert alert-danger mt-3">The image code is incorrect.</div>
  <div class="alert alert-danger mt-3">The image code is incorrect.</div>
  <div class="alert alert-danger mt-3">Too many attempts: try again in 60 s.</div>

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