Flask-Praetorian for dummies

Flask-Praetorian for dummies

A step-by-step walkthrough to help you add authentication to your Flask API

There are a plethora of Python packages providing authentication capabilities for Flask apps, from the bare-bones PyJWT to the more complete and opinionated Flask-Security. Depending on your use case you may want (or need) to manipulate JSON Web Tokens (JWTs) directly, in which case the fine-grained control of a package like PyJWT is useful. There's a nice guide on Real Python to help you do this. In most cases however, your needs will probably be more run-of-the-mill: you'll need the standard registration, login and password reset endpoints. This was certainly the case for me in my current project, so I turned to the batteries-included Flask-Praetorian package.

I found the documentation on Flask-Praetorian homepage (linked above) to be somewhat lacking from the point of view of a new user. There is a quickstart guide but its scope is fairly limited, and it seems the idea of a tutorial has been shelved for now. This is something I'd like to contribute to improving in the future through the proper channels, but for now I thought I'd write this walkthrough to share what I've learned. I'll cover the implementation of the standard authentication routes step-by-step, and briefly mention the Pytest fixtures and tests I used to validate my implementation.

The User model

As a prerequisite to using Flask-Praetorian, we need a compatible User model. A fairly standard SQLAlchemy implementation looks like this (you must have at least the methods and properties required by Flask-Praetorian, namely identity, rolenames, lookup, identify and is_valid):

# models.py
from flask_sqlalchemy import SQLAlchemy
...
db = SQLAlchemy()
...
class User(db.Model):
    __tablename__ = "users"

    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(255), unique=True, nullable=False)
    password = db.Column(db.String(255), nullable=False)
    display_name = db.Column(db.String(30), nullable=True)
    is_active = db.Column(db.Boolean, nullable=False, default=False, server_default="false")
    roles = db.Column(db.String(255), nullable=True)
    posts = db.relationship('Post', backref='users', lazy=True)

    @property
    def identity(self):
        """
        *Required Attribute or Property*

        flask-praetorian requires that the user class has an ``identity`` instance
        attribute or property that provides the unique id of the user instance
        """
        return self.id

    @property
    def rolenames(self):
        """
        *Required Attribute or Property*

        flask-praetorian requires that the user class has a ``rolenames`` instance
        attribute or property that provides a list of strings that describe the roles
        attached to the user instance
        """
        try:
            if self.roles is not None:
                return self.roles.split(",")
            else:
                return []
        except Exception as e:
            return [e]

    @classmethod
    def lookup(cls, email):
        """
        *Required Method*

        flask-praetorian requires that the user class implements a ``lookup()``
        class method that takes a single ``username`` argument and returns a user
        instance if there is one that matches or ``None`` if there is not.
        """
        return cls.query.filter_by(email=email).one_or_none()

    @classmethod
    def identify(cls, uid):
        """
        *Required Method*

        flask-praetorian requires that the user class implements an ``identify()``
        class method that takes a single ``id`` argument and returns user instance if
        there is one that matches or ``None`` if there is not.
        """
        return cls.query.get(uid)

    def is_valid(self):
        return self.is_active

Note that the default for the is_active attribute (and by extension for is_valid) is False; this will have consequences for the implementation of the signup and confirmation endpoints.

Registration and signup confirmation

We can now create our first route, via which new users will submit their e-mail address and chosen password to be added to the database. If the user does not already exist, we would like to add them to the database and send a signup confirmation e-mail with a personalized link allowing them to confirm their registration. If there is already a user in the database with the same e-mail address, we should return a message to that effect.

# routes.py
from flask import jsonify, request, render_template, current_app
from flask_praetorian import Praetorian
from .models import User, db
...
guard = Praetorian()
...
@api.route("/signup", methods=["POST"])
def signup():
    req = request.get_json(force=True)
    email = req.get("email", None)
    password = req.get("password", None)
    if User.lookup(email) is None:
        new_user = User(email=email, password=guard.hash_password(password))
        db.session.add(new_user)
        db.session.commit()
        html = render_template(
            "signup_confirmation_email.html",
            domain=current_app.config.get('DOMAIN'),
            confirmation_uri=current_app.config.get('PRAETORIAN_CONFIRMATION_URI'),
            token=guard.encode_jwt_token(new_user, bypass_user_check=True, is_registration_token=True)
        )
        guard.send_registration_email(
            email,
            template=html,
            user=new_user,
            subject="Confirm your signup"
        )
        return jsonify("Signup successful. Please check your e-mail inbox."), 201

    return jsonify("This e-mail is already in use. Please log in to continue."), 409

Here we are making use of the guard object which was declared near the top of the file. This object must be initialised and associated with the user model in your application factory (in my case this lives in __init__.py in the same directory as the routes.py and models.py files). The application factory contains the lines

from .models import User
from .routes import guard
...
def create_app(test_config=None):
    app = Flask(__name__, instance_relative_config=True)
    ...
    guard.init_app(app, User)

Once we have the guard object, we can use its methods to hash passwords, generate tokens, send e-mails and all the other good things Flask-Praetorian allows us to do. Here in the signup endpoint, we're using the hash_password method to hash the supplied password before adding it to the database (behind the scenes this uses the tried-and-tested PassLib package with PBKDF2-SHA512 as the default hashing algorithm). We're then generating a registration token for the new user with the encode_jwt_token method, where bypass_user_check prevents the attempt to authenticate the user (which would fail in this case, since the user's account has not yet been activated), and putting this token into a template which we will send with the send_registration_email method.

Code without tests is only half finished, so we also want to use Pytest to test this signup route. In the tests folder I have conftest.py with the following fixtures:

import pytest
from application import create_app
from application.models import db, User
from flask_praetorian import Praetorian
from flask_mail import Mail

@pytest.fixture()
def app():
    app = create_app(test_config='test_config.py')
    yield app

@pytest.fixture()
def dbx(app):
    with app.app_context():
        db.create_all()
        yield db

@pytest.fixture()
def outbox(app):
    with app.app_context():
        mail = Mail(app)
        with mail.record_messages() as box:
            yield box

@pytest.fixture()
def client(app):
    return app.test_client()

@pytest.fixture()
def guard(app):
    guard = Praetorian(app, User)
    return guard

@pytest.fixture()
def user_details():
    email = "juan.gomez@realtalk.com"
    password = "HappyGoLucky"
    return {'email': email, 'password': password}

@pytest.fixture()
def user(app, dbx, guard, user_details):
    test_user = User(
        email=user_details['email'],
        password=guard.hash_password(user_details['password']),
        is_active=True
    )
    dbx.session.add(test_user)
    dbx.session.commit()
    user = dbx.session.query(User).get(1)
    return user

This allows us to use the app context, guard, user and so on in our tests. We can test the case of a new user signing up and the case of an existing user attempting to sign up:

def test_signup_success(app, dbx, client, user_details, outbox):
    response = client.post(
        '/signup',
        json={"email": user_details['email'], "password": user_details['password']}
    )
    assert response.status_code == 201
    assert b"Signup successful" in response.data
    assert len(outbox) == 1
    assert outbox[0].subject == "Confirm your signup"
    assert "You have successfully signed up to Blog The World" in outbox[0].html

def test_signup_existing_user(client, user, user_details, outbox):
    response = client.post(
        '/signup',
        json={"email": user_details['email'], "password": user_details['password']}
    )
    assert response.status_code == 409
    assert b"This e-mail is already in use." in response.data
    assert len(outbox) == 0

We also need a route to allow users to confirm their signup using the token we sent them in the e-mail:

from flask_praetorian.exceptions import (
    InvalidTokenHeader,
    InvalidRegistrationToken,
    InvalidResetToken,
    MissingToken,
    MisusedResetToken,
    MisusedRegistrationToken
)
...
@api.route('/confirm-signup/<token>')
def confirm_signup(token):
    try:
        token_user = guard.get_user_from_registration_token(token)
        if token_user and not token_user.is_valid():
            token_user.is_active = True
            db.session.commit()
            return jsonify("Thank you for confirming your signup. You may now log in."), 200
        else:
            return jsonify("Invalid request. If you are already registered, please log in to continue."), 400
    except (InvalidTokenHeader, InvalidRegistrationToken, MissingToken, MisusedResetToken):
        return jsonify("Invalid token in confirmation URL. Please renew your signup request."), 400

If the user exists and is not yet valid (read, active), we activate them and return a success status. Otherwise, we reject the request as the user is not found or is already active. Flask-Praetorian also provides a number of exceptions we can use to handle the case of a missing or misused token. We can test this case by attempting to confirm our signup using not a registration token but a password reset token (note that here we use the signup route rather than the user fixture to get our new_user object, since the fixture returns an already-activated user):

def test_signup_confirmation_invalid_token(app, dbx, client, guard, user_details):
    response = client.post(
        '/signup',
        json={"email": user_details['email'], "password": user_details['password']}
    )
    new_user = User.lookup(user_details['email'])
    token = guard.encode_jwt_token(new_user, bypass_user_check=True, is_reset_token=True)
    response = client.get(
        f'/confirm-signup/{token}'
    )
    assert response.status_code == 400
    assert b"Invalid token in confirmation URL." in response.data

Testing the happy path is almost the same, except we use is_registration_token=True.

Login

The Flask-Praetorian documentation contains a ready-made example of a login endpoint which we can use with minimal modification:

@api.route("/login", methods=["POST"])
def login():
    """
    Logs a user in by parsing a POST request containing user credentials and
    issuing a JWT token.
    .. example::
       $ curl http://localhost:5000/login -X POST \
         -d '{"email":"juan.gomez@realtalk.com","password":"HappyGoLucky"}'
    """
    req = request.get_json(force=True)
    email = req.get("email", None)
    password = req.get("password", None)
    user = guard.authenticate(email, password)
    ret = {"access_token": guard.encode_jwt_token(user)}
    return jsonify(ret), 200

The tests for this endpoint include cases of successful login, incorrect e-mail and incorrect password.

Password reset

A user who forgets their password should be able to provide their e-mail address and receive an e-mail with a link to reset their password. We use Flask-Praetorian to generate a reset token and send the password reset e-mail:

@api.route('/forgotten-password', methods=["POST"])
def forgotten_password():
    req = request.get_json(force=True)
    email = req.get("email", None)
    user = User.lookup(email)
    if user is not None:
        html = render_template(
            "password_reset_email.html",
            domain=current_app.config.get('DOMAIN'),
            reset_uri=current_app.config.get('PRAETORIAN_RESET_URI'),
            token=guard.encode_jwt_token(user, bypass_user_check=True, is_reset_token=True)
        )
        guard.send_reset_email(
            email,
            template=html,
            subject="Reset your password"
        )
        return jsonify("Password reset request successful. Please check your e-mail inbox."), 200

    return jsonify("This e-mail is not associated with any user. Please sign up to continue."), 400

The tests are similar to those of the signup endpoint:

def test_forgotten_password_valid(client, user, user_details, outbox):
    response = client.post(
        '/forgotten-password',
        json={"email": user_details['email']}
    )
    assert response.status_code == 200
    assert b"Password reset request successful." in response.data
    assert len(outbox) == 1
    assert outbox[0].subject == "Reset your password"
    assert "You have requested to reset your password" in outbox[0].html

def test_forgotten_password_invalid_mail(client, user, outbox):
    response = client.post(
        '/forgotten-password',
        json={"email": "not.a.user@realtalk.com"}
    )
    assert response.status_code == 400
    assert b"This e-mail is not associated with any user." in response.data
    assert len(outbox) == 0

When the user clicks the link in the password reset e-mail, they will be taken to a page which prompts them to choose a new password (a GET request). They will then fill-in the new password and submit it (a POST request). Our reset route therefore handles these two request types:

@api.route('/reset-password/<token>', methods=["GET", "POST"])
def reset_password(token):
    try:
        token_user = guard.validate_reset_token(token)
        if token_user is not None:
            if request.method == "POST":
                req = request.get_json(force=True)
                new_pwd = req.get("new_password", None)
                token_user.password = guard.hash_password(new_pwd)
                db.session.commit()
                return jsonify("Password reset successful. You may now log in."), 200
            else:
                return jsonify("Please enter a new password."), 200
        return jsonify("Invalid request. No user found matching supplied token."), 400
    except (InvalidTokenHeader, InvalidResetToken, MissingToken, MisusedRegistrationToken):
        return jsonify("Invalid token in reset URL. Please renew your password reset request."), 400

We test the endpoint with both request types (testing also the case of an invalid token):

def test_password_reset_valid_get(client, user, guard):
    token = guard.encode_jwt_token(user, bypass_user_check=True, is_reset_token=True)
    response = client.get(
        f'/reset-password/{token}'
    )
    assert response.status_code == 200
    assert b"Please enter a new password." in response.data

def test_password_reset_valid_post(client, user, guard):
    token = guard.encode_jwt_token(user, bypass_user_check=True, is_reset_token=True)
    response = client.post(
        f'/reset-password/{token}',
        json={"new_password": "aNewSecurePassword"}
    )
    assert response.status_code == 200
    assert b"Password reset successful." in response.data
    assert guard.authenticate(user.email, "aNewSecurePassword") is user

Note that in testing the POST request, we check not only that the correct response is received, but that the new credentials do indeed allow us to log in (using the same guard.authenticate method as is used by our login endpoint).

Conclusion

This is a very basic example of authentication with Flask-Praetorian, but hopefully you will find it useful as a starting point if you decide this is the right package for your needs. The package makes authentication so easy with such minimal boilerplate that I expect it to continue to gain traction. The first mature version was released in July 2019 and updates have followed at fairly regular intervals since then. There is also continued activity on Github, which bodes well for the project's future. Give it a go, and let me know in the comments what you think!