Skip to content

Authentication

Security is a fundamental part of most applications, and authentication - validating the identity of a user - is where security starts. In this part of the tutorial, we will implement a local authentication scheme in which user credentials are stored in the local database. Other schemes such as OAuth allow you to make use of credentials stored by services such as Facebook and Google to authenticate users.

Configuring authentication

As is the case with many standard application features, there is a handy Flask extension for handling authentication. To get started, you will need to add two packages to your virtual environment. They are

  1. Flask-Login: This package handles all of the main authentication functions transparently.

  2. email-validator: This robust email address syntax checker is required by Flask-Login.

Following the pattern established with previous extensions, you also need to include the new functionality into the application by modifying the factory function. Edit app/__init__.py and add the import statement shown below.

1
from flask_login import LoginManager

Then, add the following line after initialising the global db variable, but before the declaration of the factory function.

1
login_manager = LoginManager()

Finally, add the following code befor the blueprint registrations.

1
2
3
login_manager.init_app(app)
login_manager.login_message = "You must be logged in to access this page."
login_manager.login_view = "auth.login"

Explanation

Line 1: Initialise the login manager

Line 2: Define the default message to show when the user is not authenticated.

Line 3: Define a view to use for the login dialogue.

You will notice that we are maintaining a structure to the factory function which goes:

  1. General initialisation
  2. Flask extensions
  3. Blueprint registrations
  4. Error message definitions

We will add further sections later on.

Creating the user model

In order to use the Flask-Login functionality, we need to add a model and corresponding database table to represent the application users. In this case, that is the staff model. Create the file app/models/staff.py and paste in the following code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from app import db, login_manager


class Staff(UserMixin, db.Model):
    __tablename__ = 'staff'

    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(60), index=True, unique=True)
    first_name = db.Column(db.String(60), index=True)
    last_name = db.Column(db.String(60), index=True)
    password_hash = db.Column(db.String(128))
    subject_group_id = db.Column(db.Integer, db.ForeignKey('subject_group.id'))

    @property
    def password(self):
        raise AttributeError('password is not a readable attribute.')

    @password.setter
    def password(self, password):
        self.password_hash = generate_password_hash(password)

    def verify_password(self, password):
        return check_password_hash(self.password_hash, password)

    def __repr__(self):
        return '<Staff: {} {}>'.format(self.first_name, self.last_name)


@login_manager.user_loader
def load_user(id):
    return Staff.query.get(int(id))

Explanation

Lines 1 - 3: Required imports. Werkzeug is a library of WSGI functions used by Flask.

Line 6: Staff class definition - note that it inherits from UserMixin as well as db.Model.

Line 13: Plain text passwords are not stored, only a hash

Line 14: This line defines an attribute as a foreign key

Lines 16 - 25: These line define a property which is not part of the database table. It is not visible directly and only exists so that the password hash can be stored and a password used at login can be verified against the stored hash.

Lines 31 - 33: The user_loader callback is used by Flask-Login to reload the user object from the user id stored in the session.

Because our models are all in separate files, we also need to add the following line to app/models/__init__.py so that the new model can be found by import statements.

1
from .staff import *

Remember that you need to migrate the database changes. Open a terminal panel in PyCharm and enter the following command.

1
flask db migrate

This will create the migration script. Afterwards, run the upgrade command as shown below.

1
flask db upgrade

Setting up the auth blueprint

We created a directory branch for the auth blueprint earlier in the tutorial, and now we will develop the contents starting with the login form. Many applications allow users to register for an account, but in our case we need to ensure that only genuine members of staff have access. We therefore assume that account creation will be done by an administrator. If you are interested to know how to write a registration form which includes a password verification field, please refer to Mbithe Nzomo's original tutorial.

We will create the login form in the file app/auth/forms/login.py using the following code.

1
2
3
4
5
6
7
8
9
from flask_wtf import FlaskForm
from wtforms import PasswordField, StringField, SubmitField
from wtforms.validators import DataRequired, Email


class LoginForm(FlaskForm):
    email = StringField('Email', validators=[DataRequired(), Email()])
    password = PasswordField('Password', validators=[DataRequired()])
    submit = SubmitField('Login')

Next, we need views for logging in and out. Create the file app/auth/views/login.py and paste in the following code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from flask import flash, redirect, render_template, url_for
from flask_login import login_required, login_user, logout_user

from app.auth import auth
from app.auth.forms.login import *
from app.models import Staff


@auth.route('/login', methods=['GET', 'POST'])
def login():
    form = LoginForm()
    if form.validate_on_submit():

        user = Staff.query.filter_by(email=form.email.data).first()
        if user is not None and user.verify_password(form.password.data):
            login_user(user)
            return redirect(url_for('public.index'))
        else:
            flash('Invalid email or password.')

    return render_template('form_page.html', form=form, title='Login')


@auth.route('/logout')
@login_required
def logout():
    logout_user()
    flash('You have successfully been logged out.')

    return redirect(url_for('auth.login'))

Explanation

Lines 14 - 15: Check whether employee exists in the database and whether the password entered matches the password in the database.

Line 16: Record that the user is successfully authenticated in the session.

Line 17: Redirect to the home page after logging in.

Line 21: Use the generic template defined earlier to render the form

Line 25: Use the login_required decorator from Flask-Login to check that the user is logged in. If not, executing the logout() function will raise a 403 error.

The final step in configuring the auth blueprint is to register it in the factory function. First, we need to indicate that the app/auth directory represents a blueprint. Do this by adding the following code to the fileapp/auth/__init__.py.

1
2
3
4
5
from flask import Blueprint

auth = Blueprint('auth', __name__)

from .views.login import *

Then open app/__init__.py and add the following lines after the other blueprint registrations.

1
2
from .auth import auth as auth_blueprint
app.register_blueprint(auth_blueprint)

User data

To test the authentication functions, we need to add a user to the database. Users in the admin role will need to do this on a regular basis, so the best approach would be to create endpoints to handle user management tasks. As we did with subject groups, we need to implement the CRUD operations.

Staff form

The form needed to create or edit a staff record is shown below. Paste the code into the file app/admin/forms/staff.py.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, PasswordField
from wtforms.ext.sqlalchemy.fields import QuerySelectField
from wtforms.validators import DataRequired, Email, EqualTo, ValidationError
from app.models import SubjectGroup, Staff


class StaffForm(FlaskForm):
    email = StringField('Email', validators=[DataRequired(), Email()])
    first_name = StringField('First name', validators=[DataRequired()])
    last_name = StringField('Last name', validators=[DataRequired()])
    subject_group = QuerySelectField(query_factory=lambda: SubjectGroup.query.all(), get_label="name", allow_blank=True)
    password = PasswordField('Password', validators=[
        DataRequired(),
        EqualTo('confirm_password')
    ])
    confirm_password = PasswordField('Confirm Password')
    submit = SubmitField('Save')

Points of interest

Lines 3 & 12: A QuerySelectField populates an HTML select element from the results of a database query.

Lines 13 - 17: The form contains two password fields which must match to pass validation.

List template

The template for listing members of staff is shown below. Paste the code into the file app/templates/admin/staff.html.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
{% extends "base.html" %}
{% block content %}
    <div class="d-flex justify-content-center">
        <div class="flex-column">
            <h1>{{ title }}</h1>
            {% if rowdata %}
              <div>
                <table class="table table-striped table-bordered fixed-header">
                  <thead>
                    <tr>
                      <th> First name </th>
                      <th> Last name </th>
                      <th> Email </th>
                      <th> Subject group </th>
                      <th> Actions </th>
                    </tr>
                  </thead>
                  <tbody>
                  {% for row in rowdata %}
                    <tr>
                      <td> {{ row.first_name }} </td>
                      <td> {{ row.last_name }} </td>
                      <td> {{ row.email }} </td>
                      <td> {{ row.subject_group.name }} </td>
                      <td>
                          <a title="Edit" href="{{ url_for('admin.edit_staff', id=row.id) }}">
                              <i class="bi-pencil"></i>
                          </a>
                          <a title="Delete" href="{{ url_for('admin.delete_staff', id=row.id) }}">
                              <i class="bi-trash"></i>
                          </a>
                      </td>
                    </tr>
                  {% endfor %}
                  </tbody>
                </table>
              </div>
            {% else %}
              <div>
                <h3> No data found. </h3>
              </div>
            {% endif %}
            <div>
                <a class="btn btn-primary" href="{{ url_for('admin.add_staff') }}">New</a>
            </div>
        </div>
    </div>
{% endblock %}

Staff views

Create the file app/admin/views/staff.py and paste in the following code to define the staff views.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
from flask import render_template, url_for, redirect, flash
from sqlalchemy.exc import SQLAlchemyError

from app.admin import admin
from app import db
from app.admin.forms.staff import *


@admin.route('/staff', methods=['GET'])
def list_staff():
    staff = Staff.query.all()
    return render_template('admin/staff.html',
                           rowdata=staff,
                           title='Staff')


@admin.route('/staff/add', methods=['GET', 'POST'])
def add_staff():
    form = StaffForm()
    if form.validate_on_submit():
        staff = Staff(
            email=form.email.data,
            first_name=form.first_name.data,
            last_name=form.last_name.data,
            password=form.password.data,
            subject_group_id=form.subject_group.data.id,
        )
        try:
            db.session.add(staff)
            db.session.commit()
            flash('New record created', 'success')
        except SQLAlchemyError as e:
            db.session.rollback()
            error = str(e.__dict__['orig'])
            flash('{}'.format(error), 'error')
        except:
            db.session.rollback()
            flash('An error occurred - no record created', 'error')

        return redirect(url_for('admin.list_staff'))

    return render_template('form_page.html',
                           form=form,
                           title="Add staff")


@admin.route('/staff/delete/<int:id>', methods=['GET', 'POST'])
def delete_staff(id):
    staff = Staff.query.get_or_404(id)
    db.session.delete(staff)
    try:
        db.session.commit()
    except SQLAlchemyError as e:
        db.session.rollback()
        error = str(e.__dict__['orig'])
        flash('{}'.format(error), 'error')
    except:
        db.session.rollback()
        flash('An error occurred - delete failed', 'error')

    return redirect(url_for('admin.list_staff'))


@admin.route('/staff/edit/<int:id>', methods=['GET', 'POST'])
def edit_staff(id):
    staff = Staff.query.get_or_404(id)
    form = StaffForm(
        email = staff.email,
        first_name = staff.first_name,
        last_name = staff.last_name,
        subject_group = staff.subject_group
    )

    if form.validate_on_submit():
        staff.email = form.email.data
        staff.first_name = form.first_name.data
        staff.last_name = form.last_name.data
        staff.password = form.password.data.encode('UTF8')
        staff.subject_group_id = form.subject_group.data.id
        try:
            db.session.commit()
            return redirect(url_for('admin.list_staff'))
        except SQLAlchemyError as e:
            db.session.rollback()
            error = str(e.__dict__['orig'])
            flash('{}'.format(error), 'error')
        except Exception as e:
            db.session.rollback()
            flash('An error occurred - update failed', 'error')

    return render_template('form_page.html',
                           form=form,
                           title='Edit staff')

Points of interest

Line 71: The subject_group field on the form is populated by setting it equal to the related subject group object, not the subject_group_id property.

Lines 32, 53 & *3: Notice that exception handling has been added to all three operations which update the database.

To permit the assignment at line 71, we need to define a relationship between the Staff and SubjectGroup models in the file app/models/subject_groups.py. Update the contents to match the following.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
from app import db


class SubjectGroup(db.Model):
    __tablename__ = 'subject_group'

    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(60), unique=True, nullable=False)

    staff = db.relationship('Staff', backref='subject_group', lazy='select')

    def __repr__(self):
        return '{}'.format(self.name)

Explanation

Line 10: The relationship is defined in the parent class. The example here defines a property called staff which will contain the list of staff members related to the specific subject group. It also defines a backref - this identifier can be used in the child object to access the related parent. Here, that means that a Staff object will have a subject_group property.

Importing the views

To make the new views accessible to the application, the following row needs to be added to the file app/admin/__init__.py.

1
from .views.staff import *

The authentication endpoints and the new staff list route will be accessed by items on the application menu which is defined in the base template, app/templates/base.html. Find the div element with the id main-navbar. Replace its contents with the following.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<ul class="nav navbar-nav navbar-left">
    <li class="nav-item dropdown">
        <a class="nav-link dropdown-toggle" href="#" id="DataDropdown"
           role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
           Data maintenance
        </a>
        <div class="dropdown-menu" aria-labelledby="DataDropdown">
            <a class="dropdown-item" href="{{ url_for('admin.list_staff') }}">
                Staff
            </a>
            <a class="dropdown-item" href="{{ url_for('admin.list_subject_groups') }}">
                Subject groups
            </a>
        </div>
    </li>
   {% if current_user.is_authenticated %}
       <li class="nav-link"><a href="{{ url_for('auth.password') }}">Change password</a></li>
       <li class="nav-link"><a href="{{ url_for('auth.logout') }}">Logout</a></li>
   {% else %}
       <li class="nav-link"><a href="{{ url_for('auth.login') }}">Login</a></li>
   {% endif %}
</ul>

Explanation

Line 16: The menu items will be different depending on whether the user is currently logged in or not. Here we use the is_authenticated property of the Flask-Login proxy current_user to determine the current state.

Line 17: Change password link

Line 18: Logout link.

Line 20: Login link.

Adding a new user

Restart the application and the new items should appear on the menu. You should see the Data maintenance drop-down and the new Login option. On the Data maintenance menu, choose Staff.

Click New and fill in the required details. Then, try logging in as the new user.

Changing password

We must allow the users to update their passwords - you might even consider forcing them to change their passwords after a certain period of time. To enable this functionality, we need to add a route to the auth blueprint and we need a corresponding form.

Form

Paste the code below into the file app/auth/forms/password.py.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from flask_wtf import FlaskForm
from wtforms import SubmitField, PasswordField
from wtforms.validators import DataRequired, EqualTo


class PasswordForm(FlaskForm):
    password = PasswordField('Password', validators=[
        DataRequired(),
        EqualTo('confirm_password')
    ])
    confirm_password = PasswordField('Confirm Password')
    submit = SubmitField('Save')

Route

Open the file app/auth/views/login.py and adde the route shown below.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@auth.route('/password', methods=['GET', 'POST'])
@login_required
def password():
    user = current_user
    form = PasswordForm()

    if form.validate_on_submit():
        user.password = form.password.data.encode('UTF8')
        try:
            db.session.commit()
            flash('Password updated', 'success')
            return redirect(url_for('public.index'))
        except SQLAlchemyError as e:
            db.session.rollback()
            error = str(e.__dict__['orig'])
            flash('{}'.format(error), 'error')
        except Exception as e:
            db.session.rollback()
            flash('An error occurred - update failed', 'error')

    return render_template('form_page.html',
                           form=form,
                           title='Change password')

You will also need to add the following import statements at the top of the file.

1
2
3
4
5
from flask_login import  current_user
from sqlalchemy.exc import SQLAlchemyError

from app import db
from app.auth.forms.password import PasswordForm

Protecting endpoints from non-authenticated users

When we created the logout() function, we used a decorator defined by Flask-Login to prevent unauthenticated users from calling it. We need to apply the same decorator to all other endpoints that should not be visible unless the user is logged in.

Open app/subject_group/views/subject_group.py and add the login_required decorator to all four endpoints. The code below shows the list_subject_groups endpoint by way of an example.

1
2
3
4
5
6
7
@admin.route('/subject_groups', methods=['GET'])
@login_required
def list_subject_groups():
    subject_groups = SubjectGroup.query.all()
    return render_template('admin/subject_groups.html',
                           rowdata=subject_groups,
                           title='Subject Groups')

You will also need to add the following import statement.

1
from flask_login import login_required

Do the same for the routes defined in app/admin/views/staff.py.

We can also improve the behaviour of the menu by hiding the items that non-authenticated users should not see. To do this, we will simply move the conditional statement in the base template to a different location to take in the Data maintenance drop-down as well as the Change password and Logout options. Open app/templates/base.html and find the div element with the id main-navbar. Update its contents to match the following.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<ul class="nav navbar-nav navbar-left">
   {% if current_user.is_authenticated %}
       <li class="nav-item dropdown">
           <a class="nav-link dropdown-toggle" href="#" id="DataDropdown"
              role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
              Data maintenance
           </a>
           <div class="dropdown-menu" aria-labelledby="DataDropdown">
               <a class="dropdown-item" href="{{ url_for('admin.list_staff') }}">
                   Staff
               </a>
               <a class="dropdown-item" href="{{ url_for('admin.list_subject_groups') }}">
                   Subject groups
               </a>
           </div>
       </li>
       <li class="nav-link"><a href="{{ url_for('auth.password') }}">Change password</a></li>
       <li class="nav-link"><a href="{{ url_for('auth.logout') }}">Logout</a></li>
   {% else %}
       <li class="nav-link"><a href="{{ url_for('auth.login') }}">Login</a></li>
   {% endif %}
</ul>

Points of interest

Line 2: The conditional statement now includes the Data maintenance drop-down.

Summary

Authentication is a fundamental feature of most applications. Frameworks like Flask make it easier to implement by providing ready-made extensions like Flask-Login to do most of the work. However, tailoring the functionality for your own application still takes quite a bit of effort.

Further reading

 User authentication with Flask-Login