loading...
Hackers And Slackers

Handle User Accounts & Authentication in Flask with Flask-Login

toddbirchard profile image Todd Birchard Originally published at hackersandslackers.com on ・14 min read

Handle User Accounts & Authentication in Flask with Flask-Login

We’ve covered a lot of Flask goodness in this series thus far. We fully understand how to structure a sensible application; we can serve up complex page templates with Jinja, and we've tackled interacting with databases using Flask-SQLAlchemy. For our next challenge, we’re going to need all of the knowledge we've acquired thus far and much, much more. Welcome to the Super Bowl of Flask development. This. Is. Flask-Login.

Flask-Login is a dope library that handles all aspects of user management, including user sign-ups, encrypting passwords, handling sessions, and securing parts of our app behind login walls. Flask-Login also happens to play nicely with other Flask libraries we’re already familiar with! There's built-in support for storing user data in a database via Flask-SQLAlchemy , while Flask-WTForms covers the subtleties of effective sign-up & log-in forms. This tutorial assumes you have some working knowledge of these libraries.

Flask-Login is shockingly quite easy to use after the initial learning curve... but therein lies the catch. Perhaps I’m not the only one to have noticed, but most Flask-related documentation tends to be, well, God-awful. The community is riddled with helplessly outdated information; if you ever come across import flask.ext.plugin_name in a tutorial, it is inherently worthless to anybody developing in 2019. To make matters worse, official Flask-Login documentation contains some artifacts which are just plain wrong. The documentation contradicts itself (I’ll show you what I mean), and offers little to no code examples to speak of. My only hope is that I might save somebody the endless headaches I’ve experienced myself.

Getting Started

Let’s start with installing dependencies. Depending on which flavor of database you prefer, install either psycopg2-binary or pymysql along with the following:

$ pip3 install flask flask-login flask-sqlalchemy flask-wtf

It should be clear as to why we need each of these packages. Handling user accounts requires a database to store user data, hence flask-sqlalchemy. We'll be capturing that data via forms using flask-wtf.

I'm going to store all routes related to user authentication under auth.py, and the rest of our "logged-in" routes in routes.py. The rest should be clear as per standard procedure:

flasklogin-tutorial
├── /flask_login_tutorial
│   ├── /static
│   ├── /templates
│   ├── __init__.py
│   ├── auth.py
│   ├── forms.py
│   ├── models.py
│   └── routes.py
├── config.py
└── wsgi.py

Configuring For Flask-Login

I wouldn't be a gentleman unless I revealed my config.py file. Again, this should all be straightforward if you've been following the rest of our series:

import os


class Config:
    """Set Flask configuration vars from .env file."""

    # General Config
    SECRET_KEY = os.environ.get('SECRET_KEY')
    FLASK_APP = os.environ.get('FLASK_APP')
    FLASK_ENV = os.environ.get('FLASK_ENV')
    FLASK_DEBUG = os.environ.get('FLASK_DEBUG')

    # Database
    SQLALCHEMY_DATABASE_URI = os.environ.get('SQLALCHEMY_DATABASE_URI')
    SQLALCHEMY_TRACK_MODIFICATIONS = os.environ.get('SQLALCHEMY_TRACK_MODIFICATIONS')

Of these variables, SECRET_KEY and SQLALCHEMY_DATABASE_URI deserve our attention. SQLALCHEMY_DATABASE_URI is a given, as this is how we'll be connecting to our database.

Flask's SECRET_KEY variable is a string from which all of our user's passwords (or other sensitive information) will be encrypted. We should strive to set this string to be as long, nonsensical, and impossible-to-remember as humanly possible. Protect this string with your life: anybody who gets their hands on your app's secret key has the power to potentially unencrypted all user passwords in your application. In an ideal world, even you shouldn't know your own key. Seriously: having your secret key compromised is the equivalent of feeding gremlins after midnight.

Initializing Flask-Login

Setting up Flask-Login via the application factory pattern is no different from using any other Flask plugin (or whatever they're called now). This makes setting up easy: all we need to do is make sure Flask-Login is initialized in __init__.py along with the rest of our plugins, as we do with Flask-SQLAlchemy :

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager


db = SQLAlchemy()
login_manager = LoginManager()


def create_app():
    """Construct the core application."""
    app = Flask(__name__, instance_relative_config=False)

    # Application Configuration
    app.config.from_object('config.Config')

    # Initialize Plugins
    db.init_app(app)
    login_manager.init_app(app)

    with app.app_context():
        # Import parts of our application
        from . import routes
        from . import login
        app.register_blueprint(routes.main_bp)
        app.register_blueprint(login.login_bp)

        # Initialize Global db
        db.create_all()

        return app
__init__.py

This is the minimum we need to set up Flask-Login properly.

As mentioned earlier, we're separating routes pertaining to user authentication from our main application routes. We handle this by registering two blueprints: auth_bp is imported from auth.py , and our “main” application routes are associated with main_bp from routes.py. We'll be digging into both of these blueprints, but let's first turn our focus to preparing our database to store users.

Creating a User Model

Flask-Login is tightly coupled with Flask-SQLAlchemy's ORM, which makes creating and validating users trivially easy. All we need to do upfront is create a User model, which we'll keep in models.py. It helps to be familiar with the basics creating database models in Flask here; if you've skipped ahead, well... that's your problem.

The most notable tidbit for creating a User model is a nifty shortcut from Flask-Login called the UserMixin. UserMixin is a helper provided by the Flask-Login library to provide boilerplate methods necessary for managing users. Models which inherit UserMixin immediately gain access to 4 useful methods:

  • is_authenticated: Checks to see if the current user is already authenticated, thus allowing them to bypass login screens.
  • is_active: In the event that your app supports disabling or temporarily banning accounts, we can check if user.is_active() to handle a case where their account exists, but have been banished from the land.
  • is_anonymous: Many apps have a case where user accounts aren't entirely black-and-white, and anonymous users have access to interact without authenticating. This method might come in handy for allowing anonymous blog comments (which is madness, by the way).
  • get_id: Fetches a unique ID identifying the user.

Creating a User model via UserMixin is by far the easiest way of getting started- the bulk of what remains is specifying the fields we want to capture for users. At a minimum, I'd suggest a username/email and password:

from . import db
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash


class User(UserMixin, db.Model):
    """Model for user accounts."""

    __tablename__ = 'flasklogin-users'

    id = db.Column(db.Integer,
                   primary_key=True)
    name = db.Column(db.String,
                     nullable=False,
                     unique=False)
    email = db.Column(db.String(40),
                      unique=True,
                      nullable=False)
    password = db.Column(db.String(200),
                         primary_key=False,
                         unique=False,
                         nullable=False)
    website = db.Column(db.String(60),
                        index=False,
                        unique=False,
                        nullable=True)
    created_on = db.Column(db.DateTime,
                           index=False,
                           unique=False,
                           nullable=True)
    last_login = db.Column(db.DateTime,
                           index=False,
                           unique=False,
                           nullable=True)

    def set_password(self, password):
        """Create hashed password."""
        self.password = generate_password_hash(password, method='sha256')

    def check_password(self, password):
        """Check hashed password."""
        return check_password_hash(self.password, password)

    def __repr__(self):
        return '<User {}>'.format(self.username)
models.py

You may notice that our password field explicitly allows 200 characters: this is because our database will be storing hashed passwords. Thus, even if a user's password is 8 characters long, the string in our database will look much different.

It's nice to keep logic related to users bundled in our model and out of our routes, hence the set_password() and check_password() methods. Both of these methods use the werkzeug library to handle the hashing and checking of passwords (this is what depends on our SECRET_KEY from earlier to store passwords securely).

Creating Log-in and Sign-up Forms

Hopefully you've become somewhat acquainted withFlask-WTF or WTForms in the past. We create two form classes in forms.py which cover our most essential needs: a sign-up form, and a log-in form:

"""Signup & login forms."""
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, EqualTo, Length, Optional


class SignupForm(FlaskForm):
    """User Signup Form."""
    name = StringField('Name',
                       validators=[DataRequired()])
    email = StringField('Email',
                        validators=[Length(min=6),
                                    Email(message='Enter a valid email.'),
                                    DataRequired()])
    password = PasswordField('Password',
                             validators=[DataRequired(),
                                         Length(min=6, message='Select a stronger password.')])
    confirm = PasswordField('Confirm Your Password',
                            validators=[DataRequired(),
                                        EqualTo('password', message='Passwords must match.')])
    website = StringField('Website',
                          validators=[Optional()])
    submit = SubmitField('Register')


class LoginForm(FlaskForm):
    """User Login Form."""
    email = StringField('Email', validators=[DataRequired(),
                                             Email(message='Enter a valid email.')])
    password = PasswordField('Password', validators=[DataRequired()])
    submit = SubmitField('Log In')
forms.py

Be sure to add fields in your form for whichever fields you specified in your User model, unless you're okay with having users change this information later. In my case, created_on and last_login are empty as these are easily handled on the SQL side of things.

This is normally where we'd expect to jump into creating routes for our signup and login pages. Instead of blowing your mind with the information overload that section is about to inflict on your unsuspecting mind, let's get comfortable by quickly looking at the Jinja templates for the signup and login forms we just created.

signup.jinja2

The signup form is the heavier of the two forms as there's far more validation associated with creating a brand new user account as opposed to validating an email and password. The form template below wraps each field of the signup form in a <fieldset> tag, where we keep our field, our field's label, and all error message handling associated with submitting an invalid form:

{% extends "layout.jinja2" %}

{% block content %}
  <div class="formwrapper">
    <form method="POST">

      <div class="logo">
        <img src="{{ url_for('static', filename='dist/img/logo.png') }}">
      </div>

      {% for message in get_flashed_messages() %}
        <div class="alert alert-warning">
            <button type="button" class="close" data-dismiss="alert">&times;</button>
            {{ message }}
        </div>
      {% endfor %}

      <h1>Sign Up</h1>

      <fieldset class="name">
        {{ form.name.label }}
        {{ form.name(placeholder='John Smith') }}
        {% if form.name.errors %}
          <ul class="errors">
            {% for error in form.email.errors %}<li>{{ error }}</li>{% endfor %}
          </ul>
        {% endif %}
      </fieldset>

      <fieldset class="email">
        {{ form.email.label }}
        {{ form.email(placeholder='youremail@example.com') }}
        {% if form.email.errors %}
          <ul class="errors">
            {% for error in form.email.errors %}<li>{{ error }}</li>{% endfor %}
          </ul>
        {% endif %}
      </fieldset>

      <fieldset class="password">
        {{ form.password.label }}
        {{ form.password }}
        {% if form.password.errors %}
          <ul class="errors">
            {% for error in form.password.errors %}<li>{{ error }}</li>{% endfor %}
          </ul>
        {% endif %}
      </fieldset>

      <fieldset class="confirm">
        {{ form.confirm.label }}
        {{ form.confirm }}
        {% if form.confirm.errors %}
          <ul class="errors">
            {% for error in form.confirm.errors %}<li>{{ error }}</li>{% endfor %}
          </ul>
        {% endif %}
      </fieldset>

      <fieldset class="website">
        {{ form.website.label }}
        {{ form.website(placeholder='http://example.com') }}
      </fieldset>

      <div class="submitbutton">
        <input id="submit" type="submit" value="Submit">
      </div>

    </form>

    <div class="loginsignup">
      <span>Already have an account? <a href="{{ url_for('auth_bp.login') }}">Log in.</a></span>
    </div>
  </div>
{% endblock %}
signup.jinja

login.jinja

The login form template contains essentially the same structure as our signup form, but with fewer fields:

{% extends "layout.jinja2" %}

{% block content %}
  <div class="formwrapper">
    <form method="POST">

      <div class="logo">
        <img src="{{ url_for('static', filename='dist/img/logo.png') }}">
      </div>

      {% for message in get_flashed_messages() %}
        <div class="alert alert-warning">
            <button type="button" class="close" data-dismiss="alert">&times;</button>
            {{ message }}
        </div>
      {% endfor %}

      <h1>Log In</h1>

      <fieldset class="email">
         {{ form.email.label }}
         {{ form.email(placeholder='youremail@example.com') }}
         {% if form.email.errors %}
           <ul class="errors">
             {% for error in form.email.errors %}<li>{{ error }}</li>{% endfor %}
           </ul>
         {% endif %}
      </fieldset>

      <fieldset class="password">
        {{ form.password.label }}
        {{ form.password }}
        {% if form.email.errors %}
          <ul class="errors">
            {% for error in form.password.errors %}<li>{{ error }}</li>{% endfor %}
          </ul>
        {% endif %}
      </fieldset>

      <div class="submitbutton">
        <input id="submit" type="submit" value="Submit">
      </div>

      <div class="loginsignup">
        <span>Don't have an account? <a href="{{ url_for('auth_bp.signup') }}">Sign up.</a></span>
        </div>

    </form>
  </div>
{% endblock %}
login.jinja2

The stage is set to start kicking some ass. Let's dig in.

Creating Our Login Routes

Let's get things started by setting up a Blueprint for our authentication-related routes:

"""Routes for user authentication."""
from flask import Blueprint
from flask import current_app as app


# Blueprint Configuration
auth_bp = Blueprint('auth_bp', __name__,
                    template_folder='templates',
                    static_folder='static')
compile_auth_assets(app)


@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
    # Login route logic goes here


@auth_bp.route('/signup', methods=['GET', 'POST'])
def signup():
    # Signup logic goes here
auth.py

Now we can start fleshing our signup and login routes. Before we can log anybody in, we need to make sure they can sign up.

Signing Up

The skeleton of our signup route needs to handle GET requests when user land on the page for the first time, and POST requests when users attempt to submit the signup form:

...

from flask import render_template, request
from .forms import SignupForm

...

@auth_bp.route('/signup', methods=['GET', 'POST'])
def signup():
    """
    User sign-up page.

    GET: Serve sign-up page.
    POST: If submitted credentials are valid, redirect user to the logged-in homepage.
    """
    signup_form = SignupForm()
    if request.method == 'POST':
        # User sign-up logic will go here.

    return render_template('signup.jinja2',
                           title='Create an Account.',
                           form=SignupForm(),
                           template='signup-page',
                           body="Sign up for a user account.")
auth.py

Our route will check all cases of a user attempting to sign in first by checking the HTTP method via Flask's request object. If the user is arriving for the first time, our route falls back to serve the signup.jinja2 template via render_template().

Time to introduce our first taste of flask_login logic:

...

from flask import redirect, render_template, flash, request, session, url_for
from flask_login import login_required, logout_user, current_user, login_user
from .forms import LoginForm, SignupForm
from .models import db, User
from . import login_manager

...

@auth_bp.route('/signup', methods=['GET', 'POST'])
def signup():
    """
    User sign-up page.

    GET: Serve sign-up page.
    POST: If submitted credentials are valid, redirect user to the logged-in homepage.
    """
    signup_form = SignupForm()
    if request.method == 'POST':
        if signup_form.validate_on_submit():
            name = signup_form.get('name')
            email = signup_form.get('email')
            password = signup_form.get('password')
            website = signup_form.get('website')
            existing_user = User.query.filter_by(email=email).first()  # Check if user exists
            if existing_user is None:
                user = User(name=name,
                            email=email,
                            website=website)
                user.set_password(password)
                db.session.add(user)
                db.session.commit()  # Create new user
                login_user(user)  # Log in as newly created user
                return redirect(url_for('main_bp.dashboard'), code=400)
            flash('A user already exists with that email address.')
            return redirect(url_for('auth_bp.signup'))

    return render_template('signup.jinja2',
                           title='Create an Account.',
                           form=signup_form,
                           template='signup-page',
                           body="Sign up for a user account.")
auth.py

In the event of an attempted signup (where POST equals True), we first validate if the user filled out the form correctly via a useful built-in method: signup_form.validate(). If any of our form's validators are not met, the user is redirected back to the signup form with error messages present.

Assuming that our user isn't inept, we move on to capture the information they've passed us by running get() on get form field. We need to make sure the user isn't trying to sign up with an email which is taken by another user. Thanks to our User model, this is as simple as a single line:

existing_user = User.query.filter_by(email=email).first() 
Attempt to fetch a user with the provided email

If existing_user is None, we're clear to create a new user. Creating a user is as easy as passing some keyword arguments to our User model and generating a password via our model's set_password() method:

user = User(name=name,
            email=email,
            website=website)
user.set_password(password)
Create a user via our model.

Our user is ready to be added to our database, at which point we can login them in:

db.session.add(user)
db.session.commit()  # Create new user
login_user(user)  # Log in as newly created user
Commit our new user record and log the user in.

login_user() is a method that comes from the flask_login package that does exactly what it says: magically logs our user in. Flask-Login handles the nuances of of everything this implies behind the scenes... all we need to know is that WE'RE IN!

If everything goes well, the user will finally be redirected to the main application. We handle this via return redirect(url_for('main_bp.dashboard')):

Handle User Accounts & Authentication in Flask with Flask-Login
A successful user log in.

And here's what will happen if we log out and try to sign up with the same information:

Handle User Accounts & Authentication in Flask with Flask-Login
Attempting to sign up with an existing email

Logging In

You'll be happy to hear that signup up was the hard part. Our login route actually shares much of the same logic we've already covered:

...

@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
    """
    User login page.

    GET: Serve Log-in page.
    POST: If form is valid and new user creation succeeds, redirect user to the logged-in homepage.
    """
    if current_user.is_authenticated:
        return redirect(url_for('main_bp.dashboard'))  # Bypass if user is logged in

    login_form = LoginForm()
    if request.method == 'POST':
        if login_form.validate_on_submit():
            email = login_form.get('email')
            password = login_form.get('password')
            user = User.query.filter_by(email=email).first()  # Validate Login Attempt
            if user and user.check_password(password=password):
                login_user(user)
                next_page = request.args.get('next')
                return redirect(next_page or url_for('main_bp.dashboard'))
        flash('Invalid username/password combination')
        return redirect(url_for('auth_bp.login'))

    return render_template('login.jinja2',
                           form=login_form,
                           title='Log in.',
                           template='login-page',
                           body="Log in with your User account.")
auth.py

The login route is virtually identical to signing up until we check to see if the user exists. This time around a match results in success as opposed to a failure.

We then use our model's user.check_password() method to check the hashed password we created earlier.

As with last time, a successful login ends in login_user(user). Our redirect logic is little more sophisticated this time around: instead of always sending the user back to the dashboard, we check for next, which is a parameter stored in the query string of the current user. If the user attempted to access our app before logging in, next would equal the page they had attempted to reach: this allows us wall-off our app from unauthorized users, and then drop users off at the page they attempted to reach before they logged in:

Handle User Accounts & Authentication in Flask with Flask-Login
A successful log in.

IMPORTANT: Login Helpers

Before your app can work like the above, we need to finish auth.py by providing a couple more routes:

...

@login_manager.user_loader
def load_user(user_id):
    """Check if user is logged-in on every page load."""
    if user_id is not None:
        return User.query.get(user_id)
    return None


@login_manager.unauthorized_handler
def unauthorized():
    """Redirect unauthorized users to Login page."""
    flash('You must be logged in to view that page.')
    return redirect(url_for('auth_bp.login'))
auth.py

load_user is critical for making our app work: before every page load, our app must verify whether or not the user is logged in (or still logged in after time has elapsed). user_loader loads users by their unique ID. If a user is returned, this signifies a logged-out user. Otherwise, when None is returned, the user is logged out.

Lastly, we have the unauthorized route, which uses the unauthorized_handler decorator for dealing with unauthorized users. Any time a user attempts to hit our app and is unauthorized, this route will fire.

Logged-in Routes

The last thing we'll cover is how to protect parts of our app from unauthorized users. Here's what we have in routes.py :

"""Logged-in pages."""
from flask import Blueprint, render_template
from flask_login import current_user
from flask import current_app as app
from .assets import compile_auth_assets
from flask_login import login_required


# Blueprint Configuration
main_bp = Blueprint('main_bp', __name__,
                    template_folder='templates',
                    static_folder='static')
compile_auth_assets(app)


@main_bp.route('/', methods=['GET'])
@login_required
def dashboard():
    """Serve logged-in Dashboard."""
    return render_template('dashboard.jinja2',
                           title='Flask-Login Tutorial.',
                           template='dashboard-template',
                           current_user=current_user,
                           body="You are now logged in!")
routes.py

The magic here is all contained within the @login_required decorator. When this decorator is present on a route, the following things happen:

  • The @login_manager.user_loader route we created determines whether or not the user is authorized to view the page (logged in). If the user is logged in, they'll be permitted to view the page.
  • If the user is not logged in, the user will be redirected as per the logic in the route decorated with @login_manager.unauthorized_handler.
  • The name of the route the user attempted to access will be stored in the URL as ?url=[name-of-route]. This what allows next to work.

Logging Out

There's one last route to handle before we say goodbye. Coincidently, it's the route that figuratively allows our users to "say goodbye:"

...

from flask_login import logout_user

...

@main_bp.route("/logout")
@login_required
def logout():
    """User log-out logic."""
    logout_user()
    return redirect(url_for('auth_bp.login'))
routes.py

There You Have It

If you've made it this far, I commend you for your courage. To reward your accomplishments, I've published the source code for this tutorial on Github for your reference. Godspeed, brave adventurer.

https://github.com/toddbirchard/flasklogin-tutorial

Discussion

pic
Editor guide