DEV Community

loading...
Cover image for Implementing Two-factor Authentication with Flask, PyOTP, and Fauna

Implementing Two-factor Authentication with Flask, PyOTP, and Fauna

lordghostx profile image LordGhostX ・15 min read

Written in connection with the Write with Fauna program.

This article focuses on implementing two-factor authentication in a web application built with Python and Flask using Fauna’s built-in authentication features and PyOTP for generating and verifying one-time passwords.

Fauna is a flexible, developer-friendly, transactional cloud database delivered as a secure Data API that provides two interfaces: GraphQL and also the Fauna Query Language (FQL), providing functionality to store collections, indexes, and other databases (multi-tenancy). It also offers built-in user authentication and session management features. To learn more about Fauna, visit the official documentation.

This article includes a step-by-step guide on integrating Fauna’s authentication features into a Flask web application. You will also add another layer of security to user authentication and authenticate users with multiple factors using PyOTP. To find out more about PyOTP, visit the official documentation.

Setting Up the Fauna Database

Create the Fauna Database

You need to create the database for the web application in Fauna’s dashboard. If you have not created an account on Fauna before now, create one on Fauna’s website.

In the dashboard, click on the NEW DATABASE button, provide a name for your database then press the SAVE button.

Create the Database Collections

You need to create a Fauna collection to store data collected in the database you just created. A collection is analogous to SQL tables containing similar characteristics, e.g., a user collection with information about users in the database.

To create a collection, navigate to the Collections tab on the Fauna sidebar (left side of the screen), click on the NEW COLLECTION button, provide a name for the collection you want to create, and press the SAVE button.

Create the Collection Indexes

You need to create an index for the collection of the database. A Fauna index allows you to browse through data stored in a database collection based on specific attributes.

To create an index, head to the Indexes tab on the Fauna sidebar (left side of the screen), click the NEW INDEX button, provide the required information, and press the SAVE button.

Generate Database Security Key

Now, you need to create a security key to connect your database to any application using any of Fauna’s drivers. Head to the Security tab on the Fauna sidebar (left side of the screen), click the NEW KEY button, provide the required information, and then press the SAVE button.

Once you have done this, Fauna will display your Secret Key. You should copy the key as soon as Fauna generates it and store it somewhere secure and easily retrievable because Fauna will only show this once.

Integrating Fauna into Python

Next, you need to get the Python driver for Fauna. You can install it from PIP with one line in your terminal.

pip install faunadb
Enter fullscreen mode Exit fullscreen mode

After this, you should run the sample code provided in the Fauna Python driver documentation.

from faunadb import query as q  
from faunadb.objects import Ref  
from faunadb.client import FaunaClient   

client = FaunaClient(secret="your-secret-here")  

indexes = client.query(q.paginate(q.indexes()))  

print(indexes)
Enter fullscreen mode Exit fullscreen mode

The code above shows how the Fauna Python driver connects to a database with its Secret Key and prints the indexes associated with it. The result from running this code should look like the image below.

One-factor Authentication with Fauna

Now that you have successfully set up your Fauna database and integrated a Python script with it, let's get started with the web app and implementing Fauna’s built-in authentication. The app backend will be built with Flask, while the user interface will be built with Flask-Bootstrap.

You will be designing four (4) web pages for the app. They are:

  • Index/Landing Page
  • Registration Page
  • Login Page
  • Authentication Success Page

Step 1: Installing the Project’s Requirements

We need to install the Flask library and Flask-Bootstrap. We will do that the same way we installed Fauna earlier using PIP. In your terminal, type:

pip install Flask Flask-Bootstrap4
Enter fullscreen mode Exit fullscreen mode

Step 2: Setting Up the Flask Server

Create a file with the name app.py and save the subsequent code in it:

from flask import *
from flask_bootstrap import Bootstrap

app = Flask(__name__)
Bootstrap(app)


@app.route("/")
def index():
    return "Hello World!"


if __name__ == "__main__":
    app.run(debug=True)
Enter fullscreen mode Exit fullscreen mode

When you run the app.py file and open your browser, you will get a response similar to the image below:

Step 3: Designing the Landing Page

The landing page is where your users see your application’s information with navigation links to various routes within the app.

First, create a folder named templates within the same folder as your app.py. Flask uses a templates folder to store its HTML files that the server renders within the application. Your project folder should resemble the image below:

Create another file named index.html which will be stored in the templates folder and save the following code in it:

{% extends "bootstrap/base.html" %}

{% block content %}
<div class="container">
  <div class="row justify-content-center">
    <div class="col-lg-12">
      <div class="jumbotron text-center p-4">
        <h2>Fauna Two-Factor Authentication (2FA) Demo</h2>
      </div>
    </div>
    <div class="col-lg-9 text-center">
      <h5>This is a Python + Flask demo application implementing two-factor authentication using Fauna's built-in authentication and PyOTP.</h5>
    </div>
    <div class="col-lg-5 text-center">
      <a href="{{ url_for('register') }}" class="btn btn-success m-3">GET STARTED</a>
    </div>
  </div>
</div>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

You will also need to update the app.py file to implement the register route referenced in the landing page. Update the app.py file with the code below:

@app.route("/")
def index():
    return render_template("index.html")


@app.route("/register/")
def register():
    return "Register Page!"


@app.route("/login/")
def login():
    return "Login Page!"
Enter fullscreen mode Exit fullscreen mode

When you run the app.py file, you will get a response just like the image below in your browser:

Step 4: Building the Registration Page

The registration page is where your users create new accounts on the application.

To create new user profiles that Fauna will help manage authentication, you need to create a new user document in your collection containing the user’s email address and password within the credentials parameter. Here is a sample Python code that demonstrates this functionality:

client.query(
    q.create(
        q.collection("users"), {
            "credentials": {"password": "secret password"},
            "data": {"email": "test@test.com"}
        }
    )
)
Enter fullscreen mode Exit fullscreen mode

You need to update the register route within the app.py file to render the registration page template; update the register route with the code below:

@app.route("/register/")
def register():
    return render_template("register.html")
Enter fullscreen mode Exit fullscreen mode

Create another file named register.html that will be stored in the templates folder and save the following code in it:

{% extends "bootstrap/base.html" %}

{% block content %}
<div class="container">
  <div class="row justify-content-center">
    <div class="col-lg-12">
      <div class="jumbotron text-center p-4">
        <h2>Fauna Two-Factor Authentication (2FA) Demo</h2>
        <h5>Create an account to get started!</h5>
      </div>
    </div>

    <div class="col-lg-6">
      {% with messages = get_flashed_messages(with_categories=true) %}
      {% if messages %}
      {% for category, message in messages %}
      <div class="alert alert-{{ category }}" role="alert">
        {{ message }}
      </div>
      {% endfor %}
      {% endif %}
      {% endwith %}
      <form method="POST">
        <div class="form-group">
          <label for="email">Email Address</label>
          <input type="email" class="form-control" id="email" name="email" placeholder="Enter Email Address" required>
        </div>
        <div class="form-group">
          <label for="password">Password</label>
          <input type="password" class="form-control" id="password" name="password" placeholder="Choose Password" required>
        </div>
        <div class="text-center">
          <button type="submit" class="btn btn-success">Create Account</button>
          <a href="{{ url_for('login') }}"><small class="form-text text-success">Already have an account? Login</small></a>
        </div>
      </form>
    </div>
  </div>
</div>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

When you run the app.py file, your register page should resemble the image below:

You need to import the required libraries required by the application. Update the app.py file imports with the code below:

from faunadb import query as q  
from faunadb.objects import Ref  
from faunadb.client import FaunaClient  
from faunadb.errors import BadRequest, Unauthorized
Enter fullscreen mode Exit fullscreen mode

You also need to configure the application configurations (Fauna secret key, app secret key). Update the app.py file with the code below:

app.config["SECRET_KEY"] = "APP_SECRET_KEY"  
client = FaunaClient(secret="FAUNA_SECRET_KEY")
Enter fullscreen mode Exit fullscreen mode

Next, you’ll update the register route to record new users creation details. Update the app.py file with the code below:

@app.route("/register/", methods=["GET", "POST"])
def register():
    if request.method == "POST":
        email = request.form.get("email").strip().lower()
        password = request.form.get("password")

        try:
            result = client.query(
                q.create(
                    q.collection("users"), {
                        "credentials": {"password": password},
                        "data": {"email": email}
                    }
                )
            )
        except BadRequest as e:
            flash("The account you are trying to create already exists!", "danger")
            return redirect(url_for("register"))

        flash(
            "You have successfully created your account, you can proceed to login!", "success")
        return redirect(url_for("register"))

    return render_template("register.html")
Enter fullscreen mode Exit fullscreen mode

Step 5: Building the Authentication Page

The authentication page is where users provide their credentials to the application and grant authorization.

To authenticate stored user profiles with Fauna, you need to provide the user’s email address and password, then use the Login function. Here is a sample Python code that demonstrates this functionality:

client.query(
    q.login(
        q.match(q.index("users_by_email"), "test@test.com"), {
            "password": "secret password"}
    )
)
Enter fullscreen mode Exit fullscreen mode

Create a file named login.html that will be stored within the templates folder and save the following code in it:

{% extends "bootstrap/base.html" %}

{% block content %}
<div class="container">
  <div class="row justify-content-center">
    <div class="col-lg-12">
      <div class="jumbotron text-center p-4">
        <h2>Fauna Two-Factor Authentication (2FA) Demo</h2>
        <h5>Login to access your account!</h5>
      </div>
    </div>

    <div class="col-lg-6">
      {% with messages = get_flashed_messages(with_categories=true) %}
      {% if messages %}
      {% for category, message in messages %}
      <div class="alert alert-{{ category }}" role="alert">
        {{ message }}
      </div>
      {% endfor %}
      {% endif %}
      {% endwith %}
      <form method="POST">
        <div class="form-group">
          <label for="email">Email Address</label>
          <input type="email" class="form-control" id="email" name="email" placeholder="Enter Email Address" required>
        </div>
        <div class="form-group">
          <label for="password">Password</label>
          <input type="password" class="form-control" id="password" name="password" placeholder="Enter Account Password" required>
        </div>
        <div class="text-center">
          <button type="submit" class="btn btn-success">Access Account</button>
          <a href="{{ url_for('register') }}"><small class="form-text text-success">Don't have an account? Register</small></a>
        </div>
      </form>
    </div>
  </div>
</div>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

Next, you will update the login route to integrate Fauna’s built-in authentication into our application. Update the app.py file with the code below:

@app.route("/login/", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        email = request.form.get("email").strip().lower()
        password = request.form.get("password")

        try:
            result = client.query(
                q.login(
                    q.match(q.index("users_by_email"), email), {
                        "password": password}
                )
            )
        except BadRequest as e:
            flash(
                "You have supplied invalid login credentials, please try again!", "danger")
            return redirect(url_for("login"))

        session["user_secret"] = result["secret"]
        return redirect(url_for("auth_success"))

    return render_template("login.html")


@app.route("/auth-success/")
def auth_success():
    return "<h1>Successfully authenticated account using Fauna!</h1>"
Enter fullscreen mode Exit fullscreen mode

Step 6: Implementing Authorization Rules in Flask

You would want to make the auth_success route only accessible by users who have successfully authenticated themselves. Anyone can access the page and obtain the required response, but it shouldn’t be that way.

You need to import the functools library into the application to create custom wrappers for its routes. Update the app.py file imports with the code below:

from functools import wraps
Enter fullscreen mode Exit fullscreen mode

Next, you will update the app.py file with the code below to define a custom wrapper for ensuring a user is authenticated:

def login_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        if "user_secret" in session:
            try:
                user_client = FaunaClient(secret=session["user_secret"])
                result = user_client.query(
                    q.current_identity()
                )
            except Unauthorized as e:
                flash("Your login session has expired, please login again!", "danger")
                return redirect(url_for("login"))
        else:
            flash("You need to be logged in before you can access here!", "danger")
            return redirect(url_for("login"))
        return f(*args, **kwargs)

    return decorated
Enter fullscreen mode Exit fullscreen mode

Now you’ve defined the custom wrapper. You need to implement it in routes that need it. Update your auth_success route with the code below:

@app.route("/auth-success/")
@login_required
def auth_success():
    return "<h1>Successfully authenticated account using Fauna!</h1>"
Enter fullscreen mode Exit fullscreen mode

Two-factor Authentication with PyOTP

After setting up authentication with Fauna, you want to integrate two-factor authentication. In this tutorial, you will be using PyOTP, a Python library that generates and verifies one-time passwords.

You will be designing two (2) additional web pages for the app. They are:

  • 2FA Enrollment Page
  • 2FA Verification Page

Step 1: Installing the PyOTP Library

In your terminal, type:

pip install pyotp
Enter fullscreen mode Exit fullscreen mode

Step 2: Updating Old Authentication Flow

You need to update the previous authentication flow to accommodate the new two-factor authentication update. You will generate a 2FA seed token when registering a new user account and redirecting them to verify their OTP before their dashboard.

First, import the PyOTP library into the application’s code. Update the app.py file imports with the code below:

import pyotp
Enter fullscreen mode Exit fullscreen mode

Next, update the Fauna query in the register route within the app.py file with the code below to generate a 2FA seed token alongside the user profile:

try:
    result = client.query(
        q.create(
            q.collection("users"), {
                "credentials": {"password": password},
                "data": {
                    "email": email,
                    "auth_enrolled": False,
                    "auth_secret": pyotp.random_base32()
                }
            }
        )
    )
except BadRequest as e:
    flash("The account you are trying to create already exists!", "danger")
    return redirect(url_for("register"))
Enter fullscreen mode Exit fullscreen mode

The auth_enrolled parameter stores if the current user is enrolled or not, while auth_secret generates and verifies 2FA tokens.

You will also be updating the login route to redirect the user to the 2FA verification page instead of the previous auth_success route.

session["user_secret"] = result["secret"]  
session["verified_2fa"] = False  
return redirect(url_for("verify_2fa"))
Enter fullscreen mode Exit fullscreen mode

You will also need to update the app.py file to implement the verify_2fa route the app will redirect the user to after successful one-factor authentication. Update the app.py file with the code below:

@app.route("/2fa/verify/")
@login_required
def verify_2fa():
    return "Verify 2FA Page!"
Enter fullscreen mode Exit fullscreen mode

Next, you will implement a few authorization rules in the app, like preventing users who have not enrolled 2FA from accessing the verify_2fa route. To do this, you need a custom wrapper that handles this process. Update the app.py file with the code below:

def get_user_details(user_secret):
    user_client = FaunaClient(secret=user_secret)
    user = user_client.query(
        q.current_identity()
    )
    user_details = client.query(
        q.get(
            q.ref(q.collection("users"), user.id())
        )
    )
    return user_details


def auth_enrolled(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        user_details = get_user_details(session["user_secret"])
        if not user_details["data"]["auth_enrolled"]:
            return redirect(url_for("enroll_2fa"))
        return f(*args, **kwargs)

    return decorated


def auth_not_enrolled(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        user_details = get_user_details(session["user_secret"])
        if user_details["data"]["auth_enrolled"]:
            return redirect(url_for("verify_2fa"))
        return f(*args, **kwargs)

    return decorated
Enter fullscreen mode Exit fullscreen mode

Now you’ve defined the custom wrapper. You need to implement it in routes that need it. Update your verify_2fa route with the code below:

@app.route("/2fa/verify/")
@login_required
@auth_enrolled
def verify_2fa():
    return "Verify 2FA Page!"


@app.route("/2fa/enroll/")
@login_required
@auth_not_enrolled
def enroll_2fa():
    return "Enroll 2FA Page!"
Enter fullscreen mode Exit fullscreen mode

Step 3: Building the 2FA Enrollment Page

The 2FA enrollment page is where users are provided with their 2FA secret tokens to be registered on their authenticator app and tested on the web application.

You need to update the enroll_2fa route within the app.py file to render the enrollment page template; update the enroll_2fa route with the code below:

@app.route("/2fa/enroll/")
@login_required
@auth_not_enrolled
def enroll_2fa():
    user_details = get_user_details(session["user_secret"])
    secret_key = user_details["data"]["auth_secret"]

    return render_template("enroll_2fa.html", secret=secret_key)
Enter fullscreen mode Exit fullscreen mode

Create another file named enroll_2fa.html that will be stored in the templates folder and save the following code in it:

{% extends "bootstrap/base.html" %}

{% block content %}
<div class="container">
  <div class="row justify-content-center">
    <div class="col-lg-12">
      <div class="jumbotron text-center p-4">
        <h2>Fauna Two-Factor Authentication (2FA) Demo</h2>
        <h5>Set Up TOTP 2FA!</h5>
      </div>
    </div>

    <div class="col-lg-5">
      <form>
        <div>
          <h5>Instructions!</h5>
          <ul>
            <li>Download <a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en&gl=US" target="_blank">Google Authenticator</a> on your mobile.</li>
            <li>Create a new profile with the <strong>SECRET KEY</strong> below.</li>
            <li>Submit the generated OTP in the form for authentication.</li>
          </ul>
        </div>
        <div class="form-group">
          <label for="secret">SECRET KEY</label>
          <input type="text" class="form-control" id="secret" value="{{ secret }}" readonly>
        </div>
        <div class="text-center">
          <button type="button" class="btn btn-success mb-3" onclick="copySecret()">Copy Secret Key</button>
        </div>
      </form>
    </div>
    <div class="col-lg-7 m-auto">
      {% with messages = get_flashed_messages(with_categories=true) %}
      {% if messages %}
      {% for category, message in messages %}
      <div class="alert alert-{{ category }}" role="alert">
        {{ message }}
      </div>
      {% endfor %}
      {% endif %}
      {% endwith %}
      <form method="POST">
        <div class="form-group">
          <label for="otp">Enter Generated OTP</label>
          <input type="number" class="form-control" id="otp" name="otp" required>
        </div>
        <div class="text-center">
          <button type="submit" class="btn btn-success">Test 2FA Authentication</button>
        </div>
      </form>
    </div>
  </div>
</div>

<script>
  function copySecret() {
    /* Get the text field */
    var copyText = document.getElementById("secret");

    /* Select the text field */
    copyText.select();
    copyText.setSelectionRange(0, 99999); /*For mobile devices*/

    /* Copy the text inside the text field */
    document.execCommand("copy");

    alert("Successfully copied Secret Key!");
  }
</script>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

Next, you will update the enroll_2fa route to verify the submitted 2FA token and mark the user account as enrolled if the verification was successful. Update the app.py file with the code below:

@app.route("/2fa/enroll/", methods=["GET", "POST"])
@login_required
@auth_not_enrolled
def enroll_2fa():
    user_details = get_user_details(session["user_secret"])
    secret_key = user_details["data"]["auth_secret"]

    if request.method == "POST":
        otp = int(request.form.get("otp"))

        if pyotp.TOTP(secret_key).verify(otp):
            user_details["data"]["auth_enrolled"] = True
            client.query(
                q.update(
                    q.ref(q.collection("users"), user_details["ref"].id()), {
                        "data": user_details["data"]
                    }
                )
            )
            flash("You have successfully enrolled 2FA for your profile, please authenticate yourself once more!", "success")
            return redirect(url_for("verify_2fa"))
        else:
            flash("The OTP provided is invalid, it has either expired or was generated using a wrong SECRET!", "danger")
            return redirect(url_for("enroll_2fa"))

    return render_template("enroll_2fa.html", secret=secret_key)
Enter fullscreen mode Exit fullscreen mode

Step 4: Building the 2FA Verification Page

The 2FA verification page is where users verify their current login session right after authenticating themselves with their password.

You need to update the verify_2fa route within the app.py file to render the verification page template; update the verify_2fa route with the code below:

@app.route("/2fa/verify/")
@login_required
def verify_2fa():
    return render_template("verify_2fa.html")
Enter fullscreen mode Exit fullscreen mode

Create another file named verify_2fa.html that will be stored in the templates folder and save the following code in it:

{% extends "bootstrap/base.html" %}

{% block content %}
<div class="container">
  <div class="row justify-content-center">
    <div class="col-lg-12">
      <div class="jumbotron text-center p-4">
        <h2>Fauna Two-Factor Authentication (2FA) Demo</h2>
        <h5>Set Up TOTP 2FA!</h5>
      </div>
    </div>

    <div class="col-lg-5">
      <h5>Instructions!</h5>
      <ul>
        <li>Download <a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en&gl=US" target="_blank">Google Authenticator</a> on your mobile.</li>
        <li>Enroll your account.</li>
        <li>Submit the generated OTP in the form for authentication.</li>
      </ul>
    </div>
    <div class="col-lg-7">
      {% with messages = get_flashed_messages(with_categories=true) %}
      {% if messages %}
      {% for category, message in messages %}
      <div class="alert alert-{{ category }}" role="alert">
        {{ message }}
      </div>
      {% endfor %}
      {% endif %}
      {% endwith %}
      <form method="POST">
        <div class="form-group">
          <label for="otp">Enter Generated OTP</label>
          <input type="number" class="form-control" id="otp" name="otp" required>
        </div>
        <div class="text-center">
          <button type="submit" class="btn btn-success">Authenticate Account</button>
          <a href="{{ url_for('logout') }}" class="btn btn-danger">Logout</a>
        </div>
      </form>
    </div>
  </div>
</div>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

You will also need to update the app.py file to implement the logout route referenced in the 2FA verification page. Update the app.py file with the code below:

@app.route("/logout/")
def logout():
    user_client = FaunaClient(secret=session["user_secret"])
    result = user_client.query(
        q.logout(True)
    )
    session.clear()
    return redirect(url_for("index"))
Enter fullscreen mode Exit fullscreen mode

Next, you will update the verify_2fa route to verify the submitted 2FA token and complete the two-factor authentication of the user account. Update the app.py file with the code below:

@app.route("/2fa/verify/", methods=["GET", "POST"])
@login_required
@auth_enrolled
def verify_2fa():
    if request.method == "POST":
        otp = int(request.form.get("otp"))

        user_details = get_user_details(session["user_secret"])
        secret_key = user_details["data"]["auth_secret"]

        if pyotp.TOTP(secret_key).verify(otp):
            session["verify_2fa"] = True
            return redirect(url_for("auth_success"))
        else:
            flash("The OTP provided is invalid, it has either expired or was generated using a wrong SECRET!", "danger")
            return redirect(url_for("verify_2fa"))
    return render_template("verify_2fa.html")
Enter fullscreen mode Exit fullscreen mode

Step 5: Implementing Authorization Rules in Flask

You would want to make the auth_success route only accessible by users who have successfully authenticated themselves with their password and 2FA.

To do this, you need a custom wrapper that handles this process. Update the app.py file with the code below:

def auth_verified(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        if not session.get("verify_2fa"):
            return redirect(url_for("verify_2fa"))
        return f(*args, **kwargs)

    return decorated
Enter fullscreen mode Exit fullscreen mode

You have now defined the custom wrapper. You need to implement it in routes that need it. Update your auth_success route with the code below:

@app.route("/auth-success/")
@login_required
@auth_verified
def auth_success():
    return "<h1>Successfully authenticated account using Fauna and PyOTP!</h1>"
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this article, you built a web application with Python and Flask, then implemented two-factor authentication using Fauna’s built-in user identity and session management capabilities and PyOTP for generating and verifying one-time passwords.

Looking to build the demo application further, look at sample code, or improve its functionality? Visit the GitHub Repo. I also created a Github Gist that shows Fauna's user identity and session management capabilities using Python. If you have any questions, don't hesitate to contact me on Twitter: @LordGhostX.

Discussion (1)

Collapse
bamimoretomi profile image
Forem Open with the Forem app