DEV Community

Todd Birchard for Hackers And Slackers

Posted on • Originally published at hackersandslackers.com on

Using Flask-Login to Handle User Accounts

Using Flask-Login to Handle User Accounts

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, and have dived into 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 which handles all aspects of user management, including vital nuances you might not expect. Some noteworthy features include securing parts of our app behind login walls, encrypting passwords, and handling sessions. Moreover, It plays nicely with other Flask libraries we’re already familiar with: Flask-SQLAlchemy to create and fetch accounts, and Flask-WTForms for handling intelligent sign-up & log-in forms. This tutorial assumes you have some working knowledge of these things.

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 flask.ext 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.

Structuring Our Application

Let’s start with installing dependencies. This should give you an idea of what you’re in for:

$ pip3 install flask flask-login flask-sqlalchemy psycopg2-binary python-dotenv

Sweet. Let’s take this one step at a time, starting with our project file structure:

flasklogin-tutorial
├── /login_tutorial
│ ├── __init__.py
│ ├── auth.py
│ ├── forms.py
│ ├── models.py
│ ├── routes.py
│ ├── /static
│ │ ├── /dist
│ │ │ ├── /css
│ │ │ │ ├── account.css
│ │ │ │ └── dashboard.css
│ │ │ └── /js
│ │ │ └── main.min.js
│ │ └── /src
│ │ ├── /js
│ │ │ └── main.js
│ │ └── /less
│ │ ├── account.less
│ │ ├── dashboard.less
│ │ └── vars.less
│ └── /templates
│ ├── dashboard.html
│ ├── layout.html
│ ├── login.html
│ ├── meta.html
│ ├── scripts.html
│ └── signup.html
├── config.py
├── requirements.txt
├── setup.py
├── start.sh
└── wsgi.py

Of course, I wouldn't be a gentleman unless I revealed my config.py as well:

from os import environ

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

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

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

The configuration values live in a .env file, a practice I highly encourage. Of these configuration variables, SECRET_KEY is where we should turn our attention. SECRET_KEY is the equivalent of a password used to secure our app; it should be as long, nonsensical, and impossible-to-remember as humanly possible. Seriously: having your secret key compromised is the equivalent of feeding gremlins after midnight.

Initializing Flask-Login

With a standard "application factory" app, setting up Flask-Login is no different from other Flask plugins (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:

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

In the above example, we're using the minimal number of plug-ins to get logins working: Flask-SQLAlchemy and Flask-Login.

To keep our sanity, we're going to separate our login routes from our main application routes and logic. This is why we register a Blueprint called auth_bp , imported from a file called auth.py. Our “main” application (AKA anything that isn’t logging in) will instead live in routes.py, in a Blueprint called main_bp. We'll come back to these in a moment

Creating a User Model

We'll save our User model in models.py. There are a few things to keep in mind when creating models compatible with Flask-Login- the most important being the utilization of UserMixin from the flask_login library. When we inherit our class from UserMixin, our model is immediately extended to include all the methods necessary for Flask-Login to work. This is by far the easiest way of creating a User model. I won't bother getting into details of what these classes do, because if you simply begin your class with class User(UserMixin, db.Model):, you genuinely don't need to understand any of it:

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)

The set_password and check_password methods don't necessarily need to live inside our User model, but it's nice to keep related logic bundled together and out of our routes.

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.

Creating Log-in and Sign-up Forms

If you're well versed in WTForms , our form logic in forms.py probably looks as you'd expect. Of course, the constraints we set here are to handle front-end validation only:

from wtforms import Form, StringField, PasswordField, validators, SubmitField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional

class SignupForm(Form):
    """User Signup Form."""

    name = StringField('Name',
                        validators=[DataRequired(message=('Enter a fake name or something.'))])
    email = StringField('Email',
                        validators=[Length(min=6, message=('Please enter a valid email address.')),
                                    Email(message=('Please enter a valid email address.')),
                                    DataRequired(message=('Please enter a valid email address.'))])
    password = PasswordField('Password',
                             validators=[DataRequired(message='Please enter a password.'),
                                         Length(min=6, message=('Please select a stronger password.')),
                                         EqualTo('confirm', message='Passwords must match')])
    confirm = PasswordField('Confirm Your Password',)
    website = StringField('Website',
                          validators=[Optional()])
    submit = SubmitField('Register')

class LoginForm(Form):
    """User Login Form."""

    email = StringField('Email', validators=[DataRequired('Please enter a valid email address.'),
                                             Email('Please enter a valid email address.')])
    password = PasswordField('Password', validators=[DataRequired('Uhh, your password tho?')])
    submit = SubmitField('Log In')

With those out of the way, let's look at how we implement these on the Jinja side.

signup.html

{% extends "layout.html" %}

{% block pagestyles %}
    <link href="{{ url_for('static', filename='dist/css/account.css') }}" rel="stylesheet" type="text/css">
{% endblock %}

{% 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>
      <div 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 %}
      </div>
      <div 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 %}
      </div>
      <div 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 %}
      </div>
      <div 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 %}
      </div>
      <div class="website">
        {{ form.website.label }}
        {{ form.website(placeholder='http://example.com') }}
      </div>
      <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_page') }}">Log in.</a><span>
    </div>
  </div>
{% endblock %}

login.py

{% extends "layout.html" %}

{% block pagestyles %}
  <link href="{{ url_for('static', filename='dist/css/account.css') }}" rel="stylesheet" type="text/css">
{% endblock %}

{% 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>
      <div 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 %}
      </div>
      <div 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 %}
      </div>
      <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_page') }}">Sign up.</a><span>
        </div>
    </form>
  </div>
{% endblock %}

Excellent: the stage is set to start kicking some ass.

Creating Our Login Routes

Let us turn our attention to the heart of the logic we'll be writing in auth.py :

import os
from flask import redirect, render_template, flash, Blueprint, request, session, url_for
from flask_login import login_required, logout_user, current_user, login_user
from flask import current_app as app
from werkzeug.security import generate_password_hash, check_password_hash
from .forms import LoginForm, SignupForm
from .models import db, User
from . import login_manager

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

@auth_bp.route('/login', methods=['GET', 'POST'])
def login_page():
    """User login page."""
    # Bypass Login screen if user is logged in
    if current_user.is_authenticated:
        return redirect(url_for('main_bp.dashboard'))
    login_form = LoginForm(request.form)
    # POST: Create user and redirect them to the app
    if request.method == 'POST':
        ...
    # GET: Serve Log-in page
    return render_template('login.html',
                           form=LoginForm(),
                           title='Log in | Flask-Login Tutorial.',
                           template='login-page',
                           body="Log in with your User account.")

@auth_bp.route('/signup', methods=['GET', 'POST'])
def signup_page():
    """User sign-up page."""
    signup_form = SignupForm(request.form)
    # POST: Sign user in
    if request.method == 'POST':
        ...
    # GET: Serve Sign-up page
    return render_template('/signup.html',
                           title='Create an Account | Flask-Login Tutorial.',
                           form=SignupForm(),
                           template='signup-page',
                           body="Sign up for a user account.")

Here we find two separate skeleton routes for Sign up and Log in. Without the authentication logic added quite yet, these routes look almost identical thus far.

Each time a user visits a page in your app, the corresponding route is sent a request object. This object contains contextual information about the request made by the user, such as the type of request (GET or POST), any form data which was submitted, etc. We leverage this to see whether the user is just arriving at the page for the first time (a GET request), or if they're attempting to sign in (a POST request). The fairly clever takeaway here is that our login pages verify users by making POST requests to themselves: this allows us to keep all logic related to logging in or signing up in a single route.

Signing Up

We're able to validate the submitted form by importing the SignupForm class and passing request.form as the form in question. if signup_form.validate() checks the information submitted by the user against all the form's validators. If any of the 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 can move on with our logic. First, we need to make sure a user with the provided email doesn't already exist:

...

@auth_bp.route('/signup', methods=['GET', 'POST'])
def signup_page():
    """User sign-up page."""
    signup_form = SignupForm(request.form)
    # POST: Sign user in
    if request.method == 'POST':
        if signup_form.validate():
            # Get Form Fields
            name = request.form.get('name')
            email = request.form.get('email')
            password = request.form.get('password')
            website = request.form.get('website')
            existing_user = User.query.filter_by(email=email).first()
            if existing_user is None:
                user = User(name=name,
                            email=email,
                            password=generate_password_hash(password, method='sha256'),
                            website=website)
                db.session.add(user)
                db.session.commit()
                login_user(user)
                return redirect(url_for('main_bp.dashboard'))
            flash('A user already exists with that email address.')
            return redirect(url_for('auth_bp.signup_page'))
    # GET: Serve Sign-up page
    return render_template('/signup.html',
                           title='Create an Account | Flask-Login Tutorial.',
                           form=SignupForm(),
                           template='signup-page',
                           body="Sign up for a user account.")

If existing_user is None, we're all clear to actually clear to create a new user record. We create an instance of our User model via user = User(...). We then add the user via standard SQLAlchemy syntax and finally use the imported method login_user() to log the user in.

If everything goes well, the user will finally be redirected to the main application, which is handled by return redirect(url_for('main_bp.dashboard')):

Using Flask-Login to Handle User Accounts

A successful user log in.

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

Using Flask-Login to Handle User Accounts

Attempting to sign up with an existing email

Logging In

Moving on to our login route:

@auth_bp.route('/login', methods=['GET', 'POST'])
def login_page():
    """User login page."""
    # Bypass Login screen if user is logged in
    if current_user.is_authenticated:
        return redirect(url_for('main_bp.dashboard'))
    login_form = LoginForm(request.form)
    # POST: Create user and redirect them to the app
    if request.method == 'POST':
        if login_form.validate():
            # Get Form Fields
            email = request.form.get('email')
            password = request.form.get('password')
            # Validate Login Attempt
            user = User.query.filter_by(email=email).first()
            if user:
                if user.check_password(password=password):
                    login_user(user)
                    next = request.args.get('next')
                    return redirect(next or url_for('main_bp.dashboard'))
        flash('Invalid username/password combination')
        return redirect(url_for('auth_bp.login_page'))
    # GET: Serve Log-in page
    return render_template('login.html',
                           form=LoginForm(),
                           title='Log in | Flask-Login Tutorial.',
                           template='login-page',
                           body="Log in with your User account.")

This should mostly look the same! our logic is identical up until the point where we check to see if the user exists. This time, a match results in success as opposed to a failure. Continuing, we then use user.check_password() to check the hashed password we created earlier with user.generate_password_hash(). Both of these methods handle the encrypting and decrypting of passwords on their own (based on that SECRET_KEY we created earlier) to ensure that nobody (not even us) has any business looking at user passwords.

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:

Using Flask-Login to Handle User Accounts

A successful log in

IMPORTANT: Login Helpers

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

@auth_bp.route("/logout")
@login_required
def logout_page():
    """User log-out logic."""
    logout_user()
    return redirect(url_for('auth_bp.login_page'))

@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_page'))

Our first route, logout_page, handles the logic of users logging out. This will simply end the user's session and redirect them to the login screen.

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.

The Last Piece: routes.py

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 :

import os
from flask import Blueprint, render_template
from flask_assets import Environment, Bundle
from flask_login import current_user
from flask import current_app as app
from .models import User
from flask_login import login_required

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

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

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.

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.

Top comments (1)

Collapse
 
vdelitz profile image
vdelitz

Hey man, just read through your flask authentication guide and have to say it's great piece of content - could learn a lot about authentication in flask. In the article, authentication is still password-based - have you ever worked with WebAuthn / passkeys and flask? (Reason I ask is because I dug deeper into it in the past months)