Defining User Access Levels in Flask

Often in your web applications and REST APIs, you'll have different levels of access: guests, users, administrators, and possibly others. This post discusses a possible implementation of this using Flask.

How are you managing log-ins?

To understand this blog post, we'll need to first look at how your application is currently managing log-ins. Generally in my training and courses I've suggested one of two ways of managing log-ins: sessions if you're creating a web interface with Flask, and JWT tokens if you're creating a service without an interface (such as a REST API).

Managing log-in with sessions

Flask comes with a built-in session manager. Every time a user accesses your site, Flask sends the browser a signed cookie and in it stores the session ID. Flask can store some data in the server under that session ID.

It works like this:

The first time the user accesses a page, a session is created for them; a cookie is created and sent back to the browser.

In every subsequent request, the browser includes that cookie. Flask sees it, and can check if there's a valid session associated with it. If there is, it can load the session data and use it before returning another page to the user.

For example, when the user logs in we can save their e-mail address to their session. Whenever a request comes back with that session identifier in the cookie, we'll see there's an e-mail address in the session. We can use this data to tell us:

  • Who the user is; and
  • That the user is already logged in.

Here's how you would implement something like this with Flask:

@app.route('/login', methods=['GET', 'POST'])
def login_user():
    if request.method == 'POST':
        email = request.form['email']
        password = request.form['password']

        try:
            # Check if the user is valid, this would go through a database.
            if User.is_login_valid(email, password):
                session['email'] = email
                return redirect(url_for("home"))  # When we redirect them, we already have data saved in the session
        except Exception as e:
            return render_template("users/login_error.jinja2")  # Send user to an error page if something happened during login.

    return render_template("users/login.jinja2")  # This template shows a login form, only called if the request.method was not 'POST'.

If you have the templates showing a login form, the endpoint above could connect with that form and allow users to log in.

Remember: a user logging in just means we've saved their e-mail to a session! If they go to a different browser, it wouldn't have the same cookie, and they would appear as logged out.

Checking if a user is logged in in another endpoint

Once we have saved some data in the session to tell us a user is logged in, we can check the session data from other endpoints. For example, imagine we have a profile endpoint:

@user_blueprint.route('/profile')
def profile():
    if not session.get('email'):
        return redirect(url_for('login_user'))
    user = User.find_by_email(session['email'])
    return render_template("users/profile.jinja2", user=user)

Here we're checking that the session has an 'email' property. If it does not, we send the user to the login screen; otherwise we send them to a profile page. We can include the user object in the Jinja2 page, so we can display some information about the user.

That's a topic for another blog post!

Managing log-in with JWTs

The other main way I discuss regarding login is by using JWTs. Instead of storing things in sessions and cookies, we can generate an encrypted token which secretly contains an identifier for the user (e.g. their e-mail or a unique ID).

Whenever our application receives this JWT it can decrypt it, look inside, and get the user identifier. Then it can load user data from the database and use it in processing.

In this case, "logged in" means "the user has sent us a valid JWT".

I'd recommend using something like Flask-JWT-Extended, a Flask plugin, to manage log-ins with JWTs. It's a longer topic, so I won't cover it in this video. However, when a user is logged in with Flask-JWT-Extended, we'll have access to get_jwt_identity() from the library, which gives us the currently logged in user (as long as they've sent a valid JWT in the request).

Defining user access roles

Now, to the meat of the blog post! We've looked at different ways of managing logins, but once the user is logged in we want to allow them only to access certain endpoints and not others.

We would start by defining in our User models a new property, which could be called access_level. That property should also be stored in the database alongside the user's other data.

For this example, I'll define 0 as "no access", 1 as "user", and 2 as "administrator":

ACCESS = {
    'guest': 0,
    'user': 1,
    'admin': 2
}

class User():
    def __init__(self, name, email, password, access=ACCESS['user']):
        self.name = name
        self.email = email
        self.password = password
        self.access = access
    
    def is_admin(self):
        return self.access == ACCESS['admin']
    
    def allowed(self, access_level):
        return self.access >= access_level

Our application should at some point define the level of access of a user. By default, all users will be created with permissions of "user": 1.

If we wanted to create an Admin user, we should modify each user and make their property access equal to 2.

Then, in any endpoint, we can check the access property to do different things.

Showing different things in a template

In any Jinja template, we could pass the user property as data, and then show different things depending on the access level.

In the example below, we only allow admins to invite new users; all users can see the list of users.

<div class="users">
    {% if user and user.is_admin() %}
        <a href="{{ url_for('users.invite_user') }}">Invite new user</a>
    {% endif %}
    <a href="{{ url_for('users.view_users') }}"><i class="fa fa-list"></i> View users</a>
</div>

This is safe because the templates are rendered in the server. The user cannot modify their access level or show and hide elements through the Developer Tools in the browser.

Restricting access to endpoints

Instead of showing and hiding things in the template, we can restrict access to some endpoints that we denominate as "admin-only". For example, we could restrict access to the "control panel" to only admins; and not to users or guests.

@app.route('/control-panel')
def profile():
    if not session.get('email'):
        return redirect(url_for('login'))
    
    user = User.find_by_email(session['email'])
    if not user.is_admin():
        return redirect(url_for('login'))
    
    return render_template('control_panel.jinja2')

We could do that for every endpoint we need to secure.

That will soon be repetitive, though! Instead, we could also abstract it away and put it in a decorator. This is a slightly more advanced topic! If you haven't come across decorators yet, I'd recommend looking into that first.

from functools import wraps
from flask import url_for, request, redirect, session
from user import User

def requires_access_level(access_level):
    def decorator(f):
        @wraps(f)
        def decorated_function(*args, **kwargs):
            if not session.get('email'):
                return redirect(url_for('users.login'))

            user = User.find_by_email(session['email'])
            elif not user.allowed(access_level):
                return redirect(url_for('users.profile', message="You do not have access to that page. Sorry!"))
            return f(*args, **kwargs)
        return decorated_function
    return decorator

The decorator defined above takes in an argument, access_level, which is the minimum required access level that a user must have in order to access the endpoint. For example, 1 would mean that only users and admins can access the endpoint.

If the user doesn't exist, we redirect to the login page. If the user doesn't have access, we redirect to their profile and we attach a message to say they don't have access. Otherwise, we continue.

We'd call it like so:

@app.route('/control-panel')
@requires_access_level(ACCESS['admin'])
def profile():
    return render_template('control_panel.jinja2')

Much simpler, isn't it?

The decorator itself is a bit more work, but now this can be re-used across many endpoints, to easily secure them.


I hope this post will be useful when thinking about defining access levels for your Flask app! Remember: access level can just be a number, then it's up to you what to restrict to what access levels.

If you start feeling like you need many different access levels, it may be time to think of a different approach. But that's for another blog post entirely!