5. Logins

As our next step, we want to make sure that only authenticated users will be able to modify the collection. So we’ll need a way of logging users in to and out of our site. We’ll create two users: the normaluser (password normaluserpw) will be able to edit existing movies, but only the admin (password adminpw) user will be able to add or delete movies.

5.1. Storing Passwords

Applications should never store passwords in plain text, or even encrypted. The correct way to store a password is after hashing it. The passlib package provides the necessary tools for storing passwords securely. After installing it, you can hash a password as shown in the example Python session below:

>>> from passlib.hash import pbkdf2_sha256 as hasher
>>> password = "adminpw"
>>> hashed = hasher.hash(password)
>>> hashed
'$pbkdf2-sha256$29000$PIdwDqH03hvjXAuhlLL2Pg$B1K8TX6Efq3GzvKlxDKIk4T7yJzIIzsuSegjZ6hAKLk'

Later, when a user supplies a password, you can check whether it’s the correct password or not by verifying it against the hashed value:

>>> hasher.verify("admin_pw", hashed)
False
>>> hasher.verify("adminpw", hashed)
True

Although the proper place to store hashed passwords would be a file or a database, for this example we’re going to store them in the settings file as a map from usernames to passwords (Listing 5.1). The ADMIN_USERS setting lists the usernames who have administrative privileges.

Listing 5.1 Setting for passwords (file: settings.py, version: v0501).
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
DEBUG = True
PORT = 8080
SECRET_KEY = "secret"
WTF_CSRF_ENABLED = True

PASSWORDS = {
    "admin": "$pbkdf2-sha256$29000$PIdwDqH03hvjXAuhlLL2Pg$B1K8TX6Efq3GzvKlxDKIk4T7yJzIIzsuSegjZ6hAKLk",
    "normaluser": "$pbkdf2-sha256$29000$Umotxdhbq9UaI2TsnTMmZA$uVtN2jo0I/de/Kz9/seebkM0n0MG./KGBc1EPw5X.f0",
}

ADMIN_USERS = ["admin"]

5.2. Login Management

For handling login management, we’ll use the Flask-Login plugin. This plugin requires us to implement a class for representing users on our site (Listing 5.2). Our user data basically consists of a username and a password. Flask-Login assumes that there will be a unique string value for identifying each user. In most cases, this will be the string value of the database id number for a user. In our example, we will use the username for this purpose. Flask-Login also keeps an attribute for checking whether a user is active or not (line 9). And we add an attribute for marking users with administrative privileges (line 10). For details about how Flask-Login represents users, check out its documentation.

Listing 5.2 Model class for users (file: user.py, version: v0501).
 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
from flask import current_app
from flask_login import UserMixin


class User(UserMixin):
    def __init__(self, username, password):
        self.username = username
        self.password = password
        self.active = True
        self.is_admin = False

    def get_id(self):
        return self.username

    @property
    def is_active(self):
        return self.active


def get_user(user_id):
    password = current_app.config["PASSWORDS"].get(user_id)
    user = User(user_id, password) if password else None
    if user is not None:
        user.is_admin = user.username in current_app.config["ADMIN_USERS"]
    return user

We also implement a function that given a user’s id, returns the user object associated with that id (lines 20-25). It first checks whether there is an entry in the passwords map, and if so, creates the user object (lines 21-22). It also sets the is_admin property according to the user being in the ADMIN_USERS settings or not (line 24).

To incorporate Flask-Login, we have to instantiate a LoginManager (lines 2 and 10) and add it to our application (line 42). We add two URL rules for the login and logout pages (lines 24-27). If a visitor makes a request to a protected page without logging in, we can redirect the request to the login page by setting the login_view property of the login manager (line 43). And finally, a user loader function will be responsible for creating a user object from the user id in the session (lines 7 and 13-15).

Listing 5.3 Application with login manager (file: server.py, version: v0501).
 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
from flask import Flask
from flask_login import LoginManager

import views
from database import Database
from movie import Movie
from user import get_user


lm = LoginManager()


@lm.user_loader
def load_user(user_id):
    return get_user(user_id)


def create_app():
    app = Flask(__name__)
    app.config.from_object("settings")

    app.add_url_rule("/", view_func=views.home_page)

    app.add_url_rule(
        "/login", view_func=views.login_page, methods=["GET", "POST"]
    )
    app.add_url_rule("/logout", view_func=views.logout_page)

    app.add_url_rule(
        "/movies", view_func=views.movies_page, methods=["GET", "POST"]
    )
    app.add_url_rule("/movies/<int:movie_key>", view_func=views.movie_page)
    app.add_url_rule(
        "/movies/<int:movie_key>/edit",
        view_func=views.movie_edit_page,
        methods=["GET", "POST"],
    )
    app.add_url_rule(
        "/new-movie", view_func=views.movie_add_page, methods=["GET", "POST"]
    )

    lm.init_app(app)
    lm.login_view = "login_page"

    db = Database()
    app.config["db"] = db

    return app


if __name__ == "__main__":
    app = create_app()
    port = app.config.get("PORT", 5000)
    app.run(host="0.0.0.0", port=port)

Let’s create a form for our login page (Listing 5.4).

Listing 5.4 Login page form (file: forms.py, version: v0501).
1
2
3
4
class LoginForm(FlaskForm):
    username = StringField("Username", validators=[DataRequired()])

    password = PasswordField("Password", validators=[DataRequired()])

Next, we add the template for this form (Listing 5.5).

Listing 5.5 Login page template (file: templates/login.html, version: v0501).
 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
{% extends "layout.html" %}
{% block title %}Login{% endblock %}
{% block content %}
    <section class="hero">
      <div class="hero-body">
        <div class="container has-text-centered">
          <div class="column is-4 is-offset-4">
            <h3 class="title has-text-grey">Login</h3>
            <div class="box">
              <form action="" method="post" name="login">
                {{ form.csrf_token }}

                <div class="field">
                  <div class="control">
                    {{ form.username(required=True, autofocus=True,
                                     class='input is-large',
                                     placeholder='your username') }}
                  </div>
                  {% for error in form.username.errors %}
                    <p class="help has-background-warning">
                        {{ error }}
                    </p>
                  {% endfor %}
                </div>

                <div class="field">
                  <div class="control">
                    {{ form.password(required=True, class='input is-large',
                                     placeholder='your password') }}
                  </div>
                  {% for error in form.password.errors %}
                    <p class="help has-background-warning">
                        {{ error }}
                    </p>
                  {% endfor %}
                </div>

                <button class="button is-block is-info is-large is-fullwidth">
                  Login
                </button>
              </form>
            </div>
          </div>
        </div>
      </div>
    </section>
{% endblock %}

The navigation part of the layout template is given in Listing 5.6. Note the following changes:

  • If the visitor is not logged in, a login link is displayed (line 16).
  • If the visitor is logged in, the username is displayed along with a logout link (lines 18-19).
  • The menu item for adding a movie is only shown to administrative users (lines 9-13).
Listing 5.6 Base layout with login link (file: templates/layout.html, version: v0501).
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
      <nav class="navbar" aria-label="main navigation">
        <div class="navbar-brand">
          <span class="navbar-item">
            <a class="button is-link" href="{{ url_for('home_page') }}">Home</a>
          </span>
          <span class="navbar-item">
            <a class="button is-link" href="{{ url_for('movies_page') }}">List movies</a>
          </span>
          {% if current_user.is_admin %}
          <span class="navbar-item">
            <a class="button is-link" href="{{ url_for('movie_add_page') }}">Add movie</a>
          </span>
          {% endif %}
          <span class="navbar-item">
          {% if not current_user.is_authenticated %}
            <a class="button is-link" href="{{ url_for('login_page') }}">Log in</a>
          {% else %}
            {{ current_user.username }}
            <a class="button is-link" href="{{ url_for('logout_page') }}">Log out</a>
          {% endif %}
          </span>
        </div>
      </nav>

The view functions for the login and logout pages are given in Listing 5.7. The supplied username will be used to get a user object from the registered users (lines 4-5). If such a user is found, it will have a hashed password attribute which can be checked against the password sent by the user (lines 7-8). Flask-Login provides functions for handling the login and logout operations easily (lines 9 and 18).

Listing 5.7 Handlers for login and logout pages (file: views.py, version: v0501).
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def login_page():
    form = LoginForm()
    if form.validate_on_submit():
        username = form.data["username"]
        user = get_user(username)
        if user is not None:
            password = form.data["password"]
            if hasher.verify(password, user.password):
                login_user(user)
                flash("You have logged in.")
                next_page = request.args.get("next", url_for("home_page"))
                return redirect(next_page)
        flash("Invalid credentials.")
    return render_template("login.html", form=form)


def logout_page():
    logout_user()
    flash("You have logged out.")
    return redirect(url_for("home_page"))

The redirection to the next page (lines 11-12) is needed to handle the cases where the user will be automatically redirected when accessing protected pages. For example, if an anonymous user visits the /movies/add page, they will be redirected to the login page (because of the login_view setting, Listing 5.3, line 43), and after successfully logging in, this part will redirect the user back to the movie addition page.

Another feature provided by Flask is to easily display messages to logged-in users. The flash function registers a message that the user will see on the next page (lines 10 and 19). To display these messages, we have to add the necessary markup to the base layout template (Listing 5.8, lines 2-4).

Listing 5.8 Base layout with notification messages (file: templates/layout.html, version: v0501).
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    <main>
        {% for message in get_flashed_messages() %}
        <div class="notification is-info">{{ message }}</div>
        {% endfor %}

      <section class="section">
        <div class="content">
          {% block content %}{% endblock %}
        </div>
      </section>
    </main>

We can simply protect a page by adding the login_required decorator to its view function. For example, to make sure that only authenticated users can add or edit movie data we can decorate the relevant view functions (Listing 5.9).

Listing 5.9 Protecting the movie add and edit pages (file: views.py, version: v0501).
 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
@login_required
def movie_add_page():
    if not current_user.is_admin:
        abort(401)
    form = MovieEditForm()
    if form.validate_on_submit():
        title = form.data["title"]
        year = form.data["year"]
        movie = Movie(title, year=year)
        db = current_app.config["db"]
        movie_key = db.add_movie(movie)
        flash("Movie added.")
        return redirect(url_for("movie_page", movie_key=movie_key))
    return render_template("movie_edit.html", form=form)


@login_required
def movie_edit_page(movie_key):
    db = current_app.config["db"]
    movie = db.get_movie(movie_key)
    form = MovieEditForm()
    if form.validate_on_submit():
        title = form.data["title"]
        year = form.data["year"]
        movie = Movie(title, year=year)
        db.update_movie(movie_key, movie)
        flash("Movie data updated.")
        return redirect(url_for("movie_page", movie_key=movie_key))
    form.title.data = movie.title
    form.year.data = movie.year if movie.year else ""
    return render_template("movie_edit.html", form=form)

In addition to the login requirement, we also want to prevent normal users from adding movies, so we add role based protections to the view function (Listing 5.9, lines 3-4). In this example, the abort function will cause an “Unauthorized” error due to the 401 HTTP status code. Similarly, to prevent normal users from deleting movies we modify the movie page view function (Listing 5.10, lines 7-8). Note that this page is not login protected.

Listing 5.10 Protecting the movie delete operation (file: views.py, version: v0501).
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def movies_page():
    db = current_app.config["db"]
    if request.method == "GET":
        movies = db.get_movies()
        return render_template("movies.html", movies=sorted(movies))
    else:
        if not current_user.is_admin:
            abort(401)
        form_movie_keys = request.form.getlist("movie_keys")
        for form_movie_key in form_movie_keys:
            db.delete_movie(int(form_movie_key))
        flash("%(num)d movies deleted." % {"num": len(form_movie_keys)})
        return redirect(url_for("movies_page"))

It would be a good idea to reflect these protections to the templates and to not display any operations for which the user doesn’t have the necessary privileges. For example, we can organize the movie display template as in Listing 5.11 (lines 19 and 26) so they won’t see the movie edit button at all.

Listing 5.11 Showing the edit button only to authenticated users (file: templates/movie.html, version: v0501).
 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
{% extends "layout.html" %}
{% block title %}Movie{% endblock %}
{% block content %}
    <h1 class="title">Movie</h1>

    <table class="table">
      <tr>
        <th>Title:</th>
        <td>{{ movie.title }}</td>
      </tr>
      {% if movie.year %}
      <tr>
        <th>Year:</th>
        <td>{{ movie.year }}</td>
      </tr>
      {% endif %}
    </table>

    {% if current_user.is_authenticated %}
    <div class="field is-grouped">
      <div class="control">
        <a class="button is-primary is-outlined is-small"
           href="{{ request.path }}/edit">Edit</a>
      </div>
    </div>
    {% endif %}
{% endblock %}

We also have to modify the template (Listing 5.12) for movie deletions in order to restrict the operation to administrative users. Here, we’re not only hiding the delete button from non-administrative users (lines 26-32), we’re also hiding the check boxes (lines 12-16) because they wouldn’t make sense without the button.

Listing 5.12 Protecting the delete operation (file: templates/movies.html, version: v0501).
 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
{% extends "layout.html" %}
{% block title %}Movie list{% endblock %}
{% block content %}
    <h1 class="title">Movies</h1>

    {% if movies %}
    <form action="" method="post" name="movie_list">
      <table class="table is-striped is-fullwidth">
        {% for movie_key, movie in movies %}
        <tr>
          <td>
            {% if current_user.is_admin %}
            <label class="checkbox">
              <input type="checkbox" name="movie_keys" value="{{ movie_key }}"/>
            </label>
            {% endif %}
            <a href="{{ url_for('movie_page', movie_key=movie_key) }}">
              {{ movie.title }}
              {% if movie.year %} ({{ movie.year }}) {% endif %}
            </a>
          </td>
        </tr>
        {% endfor %}
      </table>

      {% if current_user.is_admin %}
      <div class="field is-grouped">
        <div class="control">
          <button class="button is-danger is-small">Delete</button>
        </div>
      </div>
      {% endif %}
    </form>
    {% endif %}
{% endblock %}