Search code examples
pythonflaskbasic-authenticationpython-decoratorsflask-httpauth

Create privileged users with Flask-HTTPAuth by chaining decorators --- losing context?


I'm trying to create a two-tiered authentication system with basic auth, using Flask-HTTPAuth. My application has two routes, a base route at / accessible to any logged in user, and an admin route at /admin accessible only to users who are (as you might expect) logged in as admins.

So I decided to implement this by chaining decorators, with the relevant part of the code as follows (where dbops is just a namespace that handles talking to the database):

@auth.verify_password
def verify_pw(lastname, password):
    ln = lastname.lower()
    if ln in dbops.list_users():
        hashed_pw = dbops.find_hashed_password(ln)
        return bcrypt.checkpw(password.encode('utf8'), hashed_pw.encode('utf8'))
    return False

def must_be_admin(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        if dbops.is_admin(auth.username()):
            return f(*args, **kwargs)
        return "Not authorized."
    return wrapper

@core.route("/")
@auth.login_required
def dataentry():
    return render_template("dataentry.html")

@core.route("/admin")
@must_be_admin
@auth.login_required
def admin():
    return render_template("admin.html")

This works fine so long as anyone trying to log in as an admin user first visits the / route: it prompts for a username and password, and then the admin user can go to /admin and carry out logged-in admin tasks.

However, if an admin user first visits /admin it doesn't give a login prompt. It just throws, and after poking around in the debugger I've determined that auth.username() is returning an empty string. So, my guess is that for some reason, the inner decorator isn't being applied, hence the lack of a login prompt.

Does anyone know what might be going on here?

My first hypothesis was that this was an easy bug, because the inner function on the admin decorator wasn't being called until after the is_admin check. So I tried to fix that my calling the function---and thus presumably making auth.username() available--- before the check, as follows:

def must_be_admin(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        dummy_to_get_username = f(*args, **kwargs)
        if dbops.is_admin(auth.username()):
            return dummy_to_get_username
        return "Not authorized."
    return wrapper

But that just caused the same behavior.

I see from this prior SO that the recommended way to do this from the library author is to just create two separate Flask-HTTPAuth objects. Which I can do, no problem. But clearly my mental model about how decorators are working are failing, so I'd like to solve this problem independent of getting the functionality I want working...


Solution

  • The correct order in which you apply decorators is sometimes hard to figure out without knowing what the decorator does, but unfortunately the wrong order will make the application behave incorrectly.

    For decorators that do something "before" the view function runs, as in this case, you typically have to put the decorators in the order in which you want them to execute. So I think your code will do what you expect when you use Flask-HTTPAuth's login_required before your must_be_admin:

    @core.route("/admin")
    @auth.login_required
    @must_be_admin
    def admin():
        return render_template("admin.html")
    

    In this way, the credentials will be checked first, and if missing or invalid login_required will return a 401 error to the browser, which will make the login prompt appear. Only after credentials are determined to be valid you want to evaluate the admin decorator.