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.
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.
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).
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).
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).
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).
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).
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).
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).
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.
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.
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.
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 %}
|