Handle User Accounts & Authentication in Flask with Flask-Login
Todd Birchard
Posted on March 2, 2020
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:
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:
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:
importosclassConfig:"""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 :
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:
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:
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:
login.jinja
The login form template contains essentially the same structure as our signup form, but with fewer fields:
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:
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:
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:
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:
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:
Our user is ready to be added to our database, at which point we can login them 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')):
And here's what will happen if we log out and try to sign up with the same information:
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:
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:
IMPORTANT: Login Helpers
Before your app can work like the above, we need to finish auth.py by providing a couple more routes:
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 :
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:"
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.