How to Implement Two-Factor Authentication with PyOTP and Google Authenticator in Your Flask App

Two-factor authentication (2FA) is a must-have security feature for any modern web application. It provides an additional layer of protection beyond passwords, ensuring that user accounts remain secure even if login credentials are compromised.

While there are various methods of implementing 2FA, one of the most popular and user-friendly approaches is to use time-based one-time passwords (TOTPs). TOTPs are 6-8 digit codes that are generated based on a shared secret key and the current time. The user obtains these codes from an authenticator app on their phone, such as Google Authenticator.

In this tutorial, we‘ll walk through how to add TOTP-based 2FA to a Flask application using the PyOTP library and Google Authenticator. We‘ll cover everything from setting up the basic app and adding 2FA fields to the user model, to generating QR codes for easy setup and validating time-sensitive codes. Let‘s dive in!

Understanding TOTP-based 2FA

Before we get to the code, it‘s helpful to understand how TOTP-based 2FA works at a high level. The basic flow looks like this:

  1. The server generates a unique secret key for each user and shares it with them, often via a QR code.

  2. The user adds this secret key to their authenticator app, which uses it to generate TOTPs.

  3. When logging in, the user enters their username, password, and the current TOTP from their app.

  4. The server validates the username and password as usual, and also checks that the entered TOTP matches the one generated based on the user‘s secret key and the current time.

  5. If everything checks out, the user is granted access. The TOTP is only valid for a short time window, typically 30 seconds, after which a new one is generated.

The security of this system depends on keeping the secret key confidential. Only the server and the user‘s authenticator app should have access to it.

Introducing PyOTP

PyOTP is a Python library that makes it easy to work with one-time passwords, including TOTPs. It implements the open standards for these protocols, abstracting away much of the underlying complexity.

To install PyOTP, simply run:

pip install pyotp

We‘ll see how to use PyOTP for key generation and TOTP validation later in this tutorial. For now, just know that it will handle a lot of the heavy lifting for us.

Setting Up a Basic Flask App

Let‘s start by creating a basic Flask app with user registration and login. We‘ll use Flask-SQLAlchemy to define our user model and store credentials in a database.

First, create a new directory for the project and set up a virtual environment:

mkdir flask_2fa
cd flask_2fa
python3 -m venv venv
source venv/bin/activate

Install the necessary dependencies:

pip install flask flask-sqlalchemy flask-login 

Next, create a file called app.py with the following contents:

from flask import Flask, render_template, redirect, url_for 
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin, LoginManager, login_user, logout_user, login_required

app = Flask(__name__)
app.config[‘SECRET_KEY‘] = ‘super-secret‘
app.config[‘SQLALCHEMY_DATABASE_URI‘] = ‘sqlite:///db.sqlite‘

db = SQLAlchemy(app)
login_manager = LoginManager(app)

class User(UserMixin, db.Model):
    id = db.Column(db.Integer, primary_key=True) 
    username = db.Column(db.String(100), unique=True, nullable=False)
    password = db.Column(db.String(100), nullable=False)

@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))

@app.route(‘/register‘, methods=[‘GET‘, ‘POST‘])
def register():
    if request.method == ‘POST‘:
        username = request.form[‘username‘]
        password = request.form[‘password‘]
        user = User(username=username, password=password)
        db.session.add(user)
        db.session.commit()
        return redirect(url_for(‘login‘))
    return render_template(‘register.html‘)

@app.route(‘/login‘, methods=[‘GET‘, ‘POST‘])  
def login():
    if request.method == ‘POST‘:
        username = request.form[‘username‘]
        password = request.form[‘password‘]
        user = User.query.filter_by(username=username).first()
        if user and user.password == password:
            login_user(user)
            return redirect(url_for(‘dashboard‘))
    return render_template(‘login.html‘)

@app.route(‘/logout‘)
@login_required
def logout():
    logout_user()
    return redirect(url_for(‘login‘))

@app.route(‘/dashboard‘)
@login_required
def dashboard(): 
    return render_template(‘dashboard.html‘)

if __name__ == ‘__main__‘:
    app.run(debug=True)

This sets up a basic Flask app with routes for user registration, login, logout, and a protected dashboard. The User model stores usernames and passwords (in plaintext for simplicity – in a real app, you‘d want to hash the passwords).

Create HTML templates for the login, registration, and dashboard pages. For example:

<!-- templates/register.html -->

<form method="post">
    <input type="text" name="username">
    <input type="password" name="password">
    <input type="submit" value="Register">
</form>
<!-- templates/login.html -->  

<form method="post">
    <input type="text" name="username">
    <input type="password" name="password">  
    <input type="submit" value="Log In">
</form>
<!-- templates/dashboard.html -->

<p>Welcome, {{ current_user.username }}!</p>
<a href="{{ url_for(‘logout‘) }}">Log Out</a>  

With these in place, you should be able to run the app and test the basic registration and login flow:

python app.py

Visit http://localhost:5000/register to create an account, and then http://localhost:5000/login to log in and access the dashboard.

Adding 2FA to the User Model

Now that we have a working user system, let‘s modify the User model to add fields for 2FA. We‘ll need to store two things:

  1. A boolean indicating whether the user has 2FA enabled.
  2. The secret key used to generate TOTPs for the user.

Update the User model in app.py to look like this:

class User(UserMixin, db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(100), unique=True, nullable=False)  
    password = db.Column(db.String(100), nullable=False)
    otp_secret = db.Column(db.String(16))
    otp_enabled = db.Column(db.Boolean, default=False)

The otp_secret field will store the user‘s 16-character base32-encoded secret key. The otp_enabled field defaults to False until the user enables 2FA.

To apply these changes to the database, we need to create a new migration. First, initialize Flask-Migrate:

flask db init 

Then, generate and apply the migration:

flask db migrate -m "add 2FA fields to User model" 
flask db upgrade

Our User model is now ready to handle 2FA. Let‘s look at how to put it to use.

Enabling 2FA for a User

To enable 2FA, the user first needs to generate a secret key and add it to their authenticator app. We‘ll provide a page where they can do this after logging in.

First, add a new route to handle enabling 2FA:

@app.route(‘/enable_2fa‘)
@login_required
def enable_2fa():
    if current_user.otp_enabled:
        return "2FA is already enabled.", 400

    # Generate and store a secret for the user  
    secret = pyotp.random_base32()
    current_user.otp_secret = secret
    db.session.commit()

    # Generate a QR code for the user to scan
    totp = pyotp.TOTP(secret)
    qr_url = totp.provisioning_uri(current_user.username, issuer_name=‘My App‘)
    qr_img = qrcode.make(qr_url)

    # Convert the QR code to base64 for display
    buffered = BytesIO()
    qr_img.save(buffered, format="PNG")
    qr_b64 = base64.b64encode(buffered.getvalue()).decode()

    return render_template(‘enable_2fa.html‘, qr_b64=qr_b64)

This does a few things:

  1. It checks if the user already has 2FA enabled, and returns an error if so.

  2. It generates a new random secret key using PyOTP‘s random_base32() function, and stores it in the user‘s otp_secret field.

  3. It generates a QR code containing the secret key and the user‘s username. This is done using PyOTP‘s provisioning_uri() method to generate a URL, which is then passed to the qrcode library to create an image.

  4. It converts the QR code image to a base64-encoded string so it can be displayed inline in the enable_2fa template.

The enable_2fa.html template should display the QR code and instructions for the user:


<p>Scan the QR code below with your authenticator app, such as Google Authenticator:</p>
<img src="data:image/png;base64,{{ qr_b64 }}" alt="QR Code">
<p>Once you‘ve added the code to your app, enter the 6-digit code below to confirm:</p>
<!-- TODO: Add a form for the user to submit a TOTP -->

With this in place, the user can visit /enable_2fa after logging in to generate a secret key and add it to their authenticator app.

Verifying TOTPs on Login

The last piece of the puzzle is to verify the user‘s entered TOTP when they log in. To do this, we‘ll modify the login route.

Update the login() function to look like this:

@app.route(‘/login‘, methods=[‘GET‘, ‘POST‘])
def login():
    if request.method == ‘POST‘:
        username = request.form[‘username‘] 
        password = request.form[‘password‘]
        user = User.query.filter_by(username=username).first()

        if user and user.password == password:
            if user.otp_enabled:
                # If 2FA is enabled, verify the entered TOTP
                totp = request.form.get(‘totp‘)
                if not totp:
                    return "Please enter your authenticator code.", 400
                if not pyotp.TOTP(user.otp_secret).verify(totp):
                    return "Invalid authenticator code.", 400

            login_user(user)
            return redirect(url_for(‘dashboard‘))

    return render_template(‘login.html‘) 

If the user has 2FA enabled, this code extracts the entered TOTP from the login form data. It then uses PyOTP‘s verify() method to check if the entered code is valid based on the user‘s secret key and the current time. If the code is invalid or not provided, it returns an error message.

You‘ll also need to update the login.html template to include a field for the TOTP:


<form method="post">
    <input type="text" name="username">
    <input type="password" name="password">

    <label for="totp">Authenticator Code</label>
    <input type="text" name="totp">

    <input type="submit" value="Log In">
</form>

With these changes, users who have enabled 2FA will be prompted for a TOTP when logging in. They won‘t be able to access their account without providing a valid code from their authenticator app.

Best Practices and Next Steps

Congratulations! You now have a basic implementation of 2FA in your Flask app. However, there are a few more things to consider for a production-ready system:

  • Secret key storage: In this example, we‘ve stored secret keys directly in the database. For added security, consider encrypting them with a key that‘s kept separate from the database.

  • Recovery options: Provide a way for users to generate backup codes in case they lose access to their authenticator app. Also consider implementing account recovery via email.

  • Rate limiting: To prevent brute-force attacks, limit the number of 2FA attempts a user can make in a short time period.

  • User education: Provide clear instructions and explanations to help users understand the benefits of 2FA and how to set it up correctly.

Implementing 2FA is an important step in securing your application, but it‘s just one piece of the puzzle. Always stay up-to-date with the latest security best practices, and continuously test and improve your systems.

I hope this tutorial has given you a solid foundation for adding two-factor authentication to your Flask applications. Happy coding, and stay secure!

Similar Posts