User Logins (2024 Edition)

This is the fifth part of the Flask mega tutorial series in which I am going to tell you how to create a user login subsystem.

Table of contents
  • Chapter 1: Hello world!

  • Chapter 2: Templates

  • Chapter 3: Web Forms

  • Chapter 4: Database

  • Chapter 5: User Logins (This article)

  • Chapter 6: Profile Page and Avatars

  • Chapter 7: Error Handling

  • Chapter 8: Followers

  • Chapter 9: Pagination

  • Chapter 10: Email Support

  • Chapter 11: Facelift

  • Chapter 12: Dates and Times

  • Chapter 13: I18n and L10n

  • Chapter 14: Ajax

  • Chapter 15: Improved Application Structure

  • Chapter 16: Full Text Search

  • Chapter 17: Linux Deployment

  • Chapter 18: Deployment to Heroku

  • Chapter 19: Deploying to Docker Containers

  • Chapter 20: A Little JavaScript Magic

  • Chapter 21: User Notifications

  • Chapter 22: Background Jobs

  • Chapter 23: Application Programming Interfaces (APIs)

IN chapter 3 you learned how to create a user login form, and in chapter 4 you learned how to work with a database. In this chapter, you will learn how to combine the topics from these two chapters to create a simple user login system.

GitHub links for this chapter: Browse, Zip, Diff.

Password Hashing

IN chapter 4 the user model has been assigned a field password_hash, which is not yet used. The purpose of this field is to store a hash of the user's password, which will be used to verify the password entered by the user during the login process. Password hashing is a complex topic that should be left to security experts, but there are several easy-to-use libraries that implement all this logic in a way that is easy to call from an application.

One of the packages that implements password hashing is Werkzeug , which you may have seen referenced in the pip output when installing Flask, as it is one of its core dependencies. Since this is a dependency, Werkzeug is already installed in your virtual environment. The following Python shell session demonstrates how to hash a password using this package:

>>> from werkzeug.security import generate_password_hash
>>> hash = generate_password_hash('foobar')
>>> hash
'scrypt:32768:8:1$DdbIPADqKg2nniws$4ab051ebb6767a...'

In this example the password is foobar is converted into a long encoded string through a series of cryptographic operations that have no known inverse operation, meaning that a person given the hashed password will not be able to use it to recover the original password. As an additional measure, if you hash the same password multiple times, you will get different results since all hashed passwords have different salt encryption, so it is impossible to determine whether two users have the same password by looking at their hashes.

The verification process is performed using the second function from Werkzeug as follows:

>>> from werkzeug.security import check_password_hash
>>> check_password_hash(hash, 'foobar')
True
>>> check_password_hash(hash, 'barfoo')
False

The verification function uses a hash of the password that was previously generated and the password entered by the user during login. The function returns, True if the user-supplied password matches the hash, or False otherwise.

All of the password hashing logic can be implemented as two new methods in the user model:

app/models.py: Password hashing and verification

from werkzeug.security import generate_password_hash, check_password_hash

# ...

class User(db.Model):
    # ...

    def set_password(self, password):
        self.password_hash = generate_password_hash(password)

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

With these two methods, the user object can now perform secure password verification without having to store the original passwords. Here is an example of using these new methods:

>>> u = User(username="susan", email="susan@example.com")
>>> u.set_password('mypassword')
>>> u.check_password('anotherpassword')
False
>>> u.check_password('mypassword')
True

Introduction to Flask-Login

In this chapter, I'm going to introduce you to a very popular Flask extension called Flask-Login. This extension manages the user's login state so that, for example, users can log into an application and then navigate to different pages while the application “remembers” that the user is logged in. It also provides a “remember me” feature that allows users to remain logged in even after closing the browser window. To prepare for this chapter, you can start by installing Flask-Login in your virtual environment:

(venv) $ pip install flask-login

As with other extensions, Flask-Login must be created and initialized immediately after the application is instantiated in app/__init__.py. This is how this extension is initialized:

app/__init__.py: Initializing Flask login

# ...
from flask_login import LoginManager
app = Flask(name)
# ...
login = LoginManager(app)
# ...

Preparing a user model for Flask-Login

The Flask-Login extension works with the user application model and expects certain properties and methods to be implemented in it. This approach is good because as long as these required elements are added to the model, Flask-Login has no other requirements, so for example it can work with custom models based on any database system.

The four required elements are listed below:

  • is_authenticated: property that returns a value True if the user has valid credentials or False otherwise.

  • is_active: a property that returns a value, True if the user account is active or False otherwise.

  • is_anonymous: property that returns False for ordinary users and True only for a special anonymous user.

  • get_id(): Method that returns the user's unique ID as a string.

I can easily implement these four options, but since the implementations are quite generic, Flask-Login provides hagfish-class with name UserMixin, which includes secure implementations suitable for most classes of user models. Here's how the mixin class is added to the model:

app/models.py: Flask-Login user mixing class

# ...
from flask_login import UserMixin

class User(UserMixin, db.Model):
# ...

Custom bootloader function

Flask-Login keeps track of the logged in user by storing their unique ID in user session Flask, storage assigned to each user who connects to the application. Every time a logged-in user navigates to a new page, Flask-Login retrieves the user ID from the session and then loads that user into memory.

Since Flask-Login doesn't know anything about databases, it needs the application's help when loading the user. For this reason, the extension expects the application to configure a user load function, which can be called to load the user with the given ID. This feature can be added to app/models.py module:

app/models.py: Flask-Login user loading function

from app import login
# ...
@login.user_loader
def load_user(id):
  return db.session.get(User, int(id))

The user's loader is registered in the Flask-Login system using a decorator @login.user_loader. Argument idwhich Flask-Login passes to the function, will be a string, so for databases that use numeric IDs, you need to convert the string to an integer, as you see above.

User authorization

Let's go back to the login view feature, which, as you may recall, implemented a fake login that simply produced a message flash(). Now that the application has access to the user database and knows how to generate and verify password hashes, this browsing functionality can be truly implemented.

app/routes.py: Login view function logic

# ...
from flask_login import current_user, login_user
import sqlalchemy as sa
from app import db
from app.models import User
# ...
@app.route('/login', methods=['GET', 'POST'])
def login():
  if current_user.is_authenticated:
    return redirect(url_for('index'))
  form = LoginForm()
  if form.validate_on_submit():
    user = db.session.scalar(
      sa.select(User).where(User.username == form.username.data))
    if user is None or not user.check_password(form.password.data):
      flash('Invalid username or password')
      return redirect(url_for('login'))
    login_user(user, remember=form.remember_me.data)
    return redirect(url_for('index'))
  return render_template('login.html', title="Sign In", form=form)

Top two lines in the function login() relate to a strange situation. Imagine you have a user who is logged in and the user navigates to a URL /login your application. Obviously this is a mistake, so I want to prevent this from happening. Variable current_user is taken from Flask-Login and can be used at any time during request processing to obtain a user object representing the client of that request. The value of this variable can be a user object from the database (which Flask-Login reads through the user loader callback I provided above), or a special anonymous user object if the user has not yet logged in. Remember what properties were required to login to Flask on the user object? One of these properties was is_authenticated, which is useful for checking whether the user is logged in or not. When the user is already logged in, I simply redirect to the index page.

Instead of calling flash()which I used earlier, now I can boot the user into the system for real. The first step is to load the user from the database. The username is provided when the form is submitted, so I can query the database using it to find the user. For this purpose I use the method where()to find users with a given username. Since I know there will only be one or zero results, I execute the query by calling db.session.scalar()which will return a user object if it exists, or None if it's not there. IN chapter 4 did you see that when calling a method all() the query is executed and you get a list of all the results matching that query. Method first() is another commonly used way to perform a query when you only need one result.

If I get a match to the specified username, I can then check if the password that was also included with the form is valid. This is done by calling the method check_password(), which I defined above. This will take the password hash stored by the user and determine whether the password entered in the form matches the hash or not. So now I have two possible error conditions: the username may be incorrect, or the password may be incorrect for the user. In either of these cases, I send an informational message and redirect back to the login prompt so the user can try again.

If the username and password are correct then I call the function login_user(), which is imported from Flask-Login. This function will register the user as logged in, meaning that all future pages the user goes to will have a variable set to current_user for this user.

To complete the login process, I simply redirect the newly logged in user to the index page.

Logging out users

I know that I will also need to offer users the option to log out of the application. This can be done using the function logout_user() Flask-Login. Here is the logout view function:

app/routes.py: Logout view function

# ...
from flask_login import logout_user
# ...
@app.route('/logout')
def logout():
    logout_user()
    return redirect(url_for('index'))

To give users access to this link, I can make the navigation bar login link automatically switch to a logout link once the user logs in. This can be done using the condition in base.html template:

app/templates/base.html: Links for conditional login and logout

<div>
  Microblog:
  <a href="https://habr.com/ru/articles/808091/{{ url_for("index') }}">Home</a>
  {% if current_user.is_anonymous %}
  <a href="https://habr.com/ru/articles/808091/{{ url_for("login') }}">Login</a>
  {% else %}
  <a href="https://habr.com/ru/articles/808091/{{ url_for("logout') }}">Logout</a>
  {% endif %}
</div>

Property is_anonymous is one of the attributes that Flask-Login adds to user objects via UserMixin Class. Expression current_user.is_anonymous will True only when the user is not logged in.

Requiring user authorization in the system

Flask-Login provides a very useful feature that forces users to log in before they can view certain pages of the application. If a user who is not logged in tries to view a protected page, Flask-Login will automatically redirect the user to the login form and only redirect back to the page the user wanted to view once the login process is complete.

To implement this feature, Flask-Login needs to know what the view function is that handles logins. This can be added to app/__init__.py:

# ...
login = LoginManager(app)
login.login_view = 'login'

Meaning 'login' is the name of the function (or endpoint) to represent the input. In other words, the name you would use in the call url_for() to get the URL.

The way Flask-Login protects the browse function from anonymous users is by using a decorator called @login_required. When you add this decorator to the view function under the decorator @app.route from Flask, the function becomes secure and does not allow access to unauthenticated users. Here's how a decorator can be applied to an application's index view function:

app/routes.py: decorator @login_required

from flask_login import login_required

@app.route('/')
@app.route('/index')
@login_required
def index():
    # ...

All that remains is to implement a redirect from a successful login back to the page the user wanted to access. When a user who is not logged in tries to access a view function protected with a decorator @login_required, the decorator will redirect to the login page, but some additional information will be included in this redirect so that the application can then return to the original page. For example, if the user goes to /indexdecorator @login_required will intercept the request and respond with a redirect to /loginbut it will append a query string argument to that URL, creating the full redirect URL /login?next= /index. In argument next The query string specifies the original URL so the application can use it to redirect back after login.

Here is a code snippet that shows how to read and process the argument next query strings. Changes are made to the four lines below the call login_user().

app/routes.py: Redirect to “next” page

from flask import request
from urllib.parse import urlsplit

@app.route('/login', methods=['GET', 'POST'])
def login():
    # ...
    if form.validate_on_submit():
        user = db.session.scalar(
            sa.select(User).where(User.username == form.username.data))
        if user is None or not user.check_password(form.password.data):
            flash('Invalid username or password')
            return redirect(url_for('login'))
        login_user(user, remember=form.remember_me.data)
        next_page = request.args.get('next')
        if not next_page or urlsplit(next_page).netloc != '':
            next_page = url_for('index')
        return redirect(next_page)
    # ...

Immediately after the user logs in by calling the function login_user() Flask-Login gets the value of the argument next query strings. Flask provides a variable request, containing all the information sent by the client with the request. In particular, the attribute request.args provides the contents of the query string in a convenient dictionary format. There are actually three possible cases that need to be considered to determine where to redirect after a successful login:

  • If the login URL has no argument nextthen the user is redirected to the index page.

  • If the login URL contains an argument nextwhich is given a relative path (or in other words, a URL without specifying a domain), then the user is redirected to that URL.

  • If the login URL contains an argument nextwhich is set to a full URL that includes the domain name, then that URL is ignored and the user is redirected to the index page.

The first and second examples are self-explanatory. The third example is used to improve the security of the application. An attacker could insert the URL of a malicious site into the argument next, so the app only redirects if the URL is relative, which ensures that the redirect happens within the same site as the app. To determine if a URL is absolute or relative, I parse it using urlsplit() Python functions and then checking to see if the component is installed netloc or not.

Displaying the logged-in user in templates

Remember, back in chapter 2 did I create a fake user to help me design the application home page before the user sub was created? Well, now the app has real users, so now I can remove the fake user and start working with real users. Instead of fake user I can use current_user Flask-Login in template index.html:

app/templates/index.html: Passing the current user to the template

{% extends "base.html" %}

{% block content %}
    <h1>Hi, {{ current_user.username }}!</h1>
    {% for post in posts %}
    <div><p>{{ post.author.username }} says: <b>{{ post.body }}</b></p></div>
    {% endfor %}
{% endblock %}

And I can remove the argument user template in the view function:

app/routes.py: The user is no longer passed to the template

@app.route('/')
@app.route('/index')
@login_required
def index():
    # ...
    return render_template("index.html", title="Home Page", posts=posts)

Now is a good time to test how the login and logout functionality works. Since user registration is still missing, the only way to add a user to the database is to do so through the Python shell, so run flask shell and enter the following commands to register the user:

>>> u = User(username="susan", email="susan@example.com")
>>> u.set_password('cat')
>>> db.session.add(u)
>>> db.session.commit()

If you launch the application now and navigate to the application URLs / or /index, you will be immediately redirected to the login page, and after logging in using the credentials of the user you added to your database, you will be returned to the original page where you will see a personalized greeting and blog post layout. If you then click the logout link in the top navigation bar, you will be returned to the index page as an anonymous user and immediately redirected to the Flask-Login login page again.

User registration

The last piece of functionality I'm going to create in this chapter is a registration form that allows users to register via a web form. Let's start by creating a web form class in app/forms.py:

app/forms.py: User registration form

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo
import sqlalchemy as sa
from app import db
from app.models import User

# ...

class RegistrationForm(FlaskForm):
    username = StringField('Username', validators=[DataRequired()])
    email = StringField('Email', validators=[DataRequired(), Email()])
    password = PasswordField('Password', validators=[DataRequired()])
    password2 = PasswordField(
        'Repeat Password', validators=[DataRequired(), EqualTo('password')])
    submit = SubmitField('Register')

    def validate_username(self, username):
        user = db.session.scalar(sa.select(User).where(
            User.username == username.data))
        if user is not None:
            raise ValidationError('Please use a different username.')

    def validate_email(self, email):
        user = db.session.scalar(sa.select(User).where(
            User.email == email.data))
        if user is not None:
            raise ValidationError('Please use a different email address.')

There are a couple of interesting things about verification in this new form. Firstly, for email fields I added a second validator after DataRequiredcalled Email. This is another standard validator that comes with WTForms that ensures that what the user enters into this field matches the structure of the email address.

For Email() validator from WTForms requires installing an external dependency:

(venv) $ pip install email-validator

Since this is a registration form, the user is usually asked to enter the password twice to reduce the risk of a typo. For this reason I have fields password And password2. The second password field uses another standard validator called EqualTowhich ensures that its value is identical to the value for the first password field.

When you add any methods that match the pattern validate_<field_name>WTForms uses them as custom validators and calls them in addition to the standard validators. I have added two of these methods to this class for fields username And email. In this case, I want to make sure that the username and email entered by the user are not already in the database, so these two methods issue queries to the database expecting no results. If the result is present, a validation error occurs when a type exception occurs ValidationError. The message included as an argument in the exception will be the message that appears next to the field for the user to view.

Notice how the two verification requests are executed. These queries will never find more than one result, so instead of running them with db.session.scalars() I use db.session.scalar() in the singular, which returns Noneif there are no results, or the first result.

To display this form on a web page I need an HTML template which I am going to save in a file app/templates/register.html. This template is built similarly to the template for the login form:

app/templates/register.html: Registration template

{% extends "base.html" %}

{% block content %}
    <h1>Register</h1>
    <form action="" method="post">
        {{ form.hidden_tag() }}
        <p>
            {{ form.username.label }}<br>
            {{ form.username(size=32) }}<br>
            {% for error in form.username.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>
            {{ form.email.label }}<br>
            {{ form.email(size=64) }}<br>
            {% for error in form.email.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>
            {{ form.password.label }}<br>
            {{ form.password(size=32) }}<br>
            {% for error in form.password.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>
            {{ form.password2.label }}<br>
            {{ form.password2(size=32) }}<br>
            {% for error in form.password2.errors %}
            <span style="color: red;">[{{ error }}]</span>
            {% endfor %}
        </p>
        <p>{{ form.submit() }}</p>
    </form>
{% endblock %}

The login form template requires a link that sends new users to the registration form located directly below the login form:

app/templates/login.html: Link to registration page

<p>New User? <a href="https://habr.com/ru/articles/808091/{{ url_for("register') }}">Click to Register!</a></p>

And finally, I need to write a view function that will handle user registration in app/routes.py:

app/routes.py: User registration view function

from app import db
from app.forms import RegistrationForm

# ...

@app.route('/register', methods=['GET', 'POST'])
def register():
    if current_user.is_authenticated:
        return redirect(url_for('index'))
    form = RegistrationForm()
    if form.validate_on_submit():
        user = User(username=form.username.data, email=form.email.data)
        user.set_password(form.password.data)
        db.session.add(user)
        db.session.commit()
        flash('Congratulations, you are now a registered user!')
        return redirect(url_for('login'))
    return render_template('register.html', title="Register", form=form)

And this browsing function should also be mostly self-explanatory. First I make sure that the user calling this route is not logged in. The form is processed in the same way as a login form. Logic executed within a condition if validate_on_submit()creates a new user with the specified username, email, and password, writes it to the database, and then redirects to the login prompt so the user can log in.

With these changes, users have the ability to create accounts in this application, log in and log out. Be sure to try out all the validation features I've added to the registration form to better understand how they work. I'm going to return to the user authentication subsystem in a later chapter to add additional functionality, such as allowing the user to reset their password if they forget. But for now, this is enough to continue developing other areas of the application.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *