Conditionally applying Flask-HTTPAuth's login_required decorator

Question:

I’m trying to apply a decorator (Flask-HTTPAuth’s login_required) conditionally. If sky_is_blue == True, I want to apply the decorator, if False, to not.

This needs to happen on call, as it could change during the lifetime of the application (actually not so much in practice, but definitely for unit testing purposes, and I’m curious about the cause in any case).

So I wrapped the decorator in a decorator.

Behavior is as expected in the False case (not applying the decorator), but I’m having trouble applying the decorator in the True case. I’m not sure if this is something I’ve done wrong, or a strange interaction with Flask-HTTPAuth.

The following script demonstrates the problem with two unit tests. test_sky_not_blue passes, but test_sky_blue fails with a stack trace.

from flask import Flask
from flask.ext.httpauth import HTTPBasicAuth
from functools import update_wrapper, wraps
from flask.ext.testing import TestCase
import unittest


app = Flask(__name__)
app.config['TESTING'] = True

sky_is_blue = True
auth = HTTPBasicAuth()


class ConditionalAuth(object):
    def __init__(self, decorator):
        print("ini with {}".format(decorator.__name__))
        self.decorator = decorator
        update_wrapper(self, decorator)

    def __call__(self, func):
        print("__call__: ".format(func.__name__))

        @wraps(func)
        def wrapped(*args, **kwargs):
            print("Wrapped call, function {}".format(func.__name__))
            if sky_is_blue:
                rv = self.decorator(func(*args, **kwargs))
                return rv
            else:
                rv = func(*args, **kwargs)
                return rv
        return wrapped


@app.route('/')
@ConditionalAuth(auth.login_required)
def index():
    """
    Get a token
    """
    return "OK"


class TestSky(TestCase):
    def create_app(self):
        return app

    def test_sky_blue(self):
        global sky_is_blue
        sky_is_blue = True
        response = self.client.get('/')
        self.assert200(response)

    def test_sky_not_blue(self):
        global sky_is_blue
        sky_is_blue = False
        response = self.client.get('/')
        self.assert200(response)


def suite():
    return unittest.makeSuite(TestSky)

if __name__ == '__main__':
    unittest.main(defaultTest='suite')

The full stack trace I get is:

Traceback (most recent call last):
  File "test.py", line 64, in test_sky_blue
    response = self.client.get('/')
  File "/usr/local/lib/python2.7/site-packages/werkzeug/test.py", line 778, in get
    return self.open(*args, **kw)
  File "/usr/local/lib/python2.7/site-packages/flask/testing.py", line 108, in open
    follow_redirects=follow_redirects)
  File "/usr/local/lib/python2.7/site-packages/werkzeug/test.py", line 751, in open
    response = self.run_wsgi_app(environ, buffered=buffered)
  File "/usr/local/lib/python2.7/site-packages/werkzeug/test.py", line 668, in run_wsgi_app
    rv = run_wsgi_app(self.application, environ, buffered=buffered)
  File "/usr/local/lib/python2.7/site-packages/werkzeug/test.py", line 871, in run_wsgi_app
    app_rv = app(environ, start_response)
  File "/usr/local/lib/python2.7/site-packages/flask/app.py", line 1836, in __call__
    return self.wsgi_app(environ, start_response)
  File "/usr/local/lib/python2.7/site-packages/flask/app.py", line 1820, in wsgi_app
    response = self.make_response(self.handle_exception(e))
  File "/usr/local/lib/python2.7/site-packages/flask/app.py", line 1403, in handle_exception
    reraise(exc_type, exc_value, tb)
  File "/usr/local/lib/python2.7/site-packages/flask/app.py", line 1817, in wsgi_app
    response = self.full_dispatch_request()
  File "/usr/local/lib/python2.7/site-packages/flask/app.py", line 1477, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/usr/local/lib/python2.7/site-packages/flask/app.py", line 1381, in handle_user_exception
    reraise(exc_type, exc_value, tb)
  File "/usr/local/lib/python2.7/site-packages/flask/app.py", line 1475, in full_dispatch_request
    rv = self.dispatch_request()
  File "/usr/local/lib/python2.7/site-packages/flask/app.py", line 1461, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "test.py", line 40, in wrapped
    rv = self.decorator(func(*args, **kwargs))
  File "/usr/local/lib/python2.7/site-packages/flask_httpauth.py", line 48, in login_required
    @wraps(f)
  File "/usr/local/Cellar/python/2.7.11/Frameworks/Python.framework/Versions/2.7/lib/python2.7/functools.py", line 33, in update_wrapper
    setattr(wrapper, attr, getattr(wrapped, attr))
AttributeError: 'str' object has no attribute '__module__'

Tested with Python 2.7.11, Flask-HTTPAuth==2.7.1, Flask==0.10.1, any insights would be greatly appreciated.

Asked By: Alex Forbes

||

Answers:

It’s funny how effective laying a problem out is at helping one solve it.

The problem was the parenthesis in the decorator call:

rv = self.decorator(func(*args, **kwargs))

Changing it to the following fixes it:

rv = self.decorator(func)(*args, **kwargs)

The decorator needs to return a function, but by passing the arguments to func() directly I wasn’t giving it a chance to do that.

Breaking it into a separate call would have made this clearer, I think:

decorated_function = self.decorator(func)
return decorated_function(*args, **kwargs))
Answered By: Alex Forbes

Interesting question. Note that if all you want is to optionally bypass authentication logic, there is a much easier way to do it, without having to use a new decorator. Just incorporate the bypass logic into your verify_password callback:

@auth.verify_password
def verify(username, password):
    if not sky_is_blue:
        return True  # let the request through, no questions asked!
    # your authentication logic here
    return False  # this will trigger a 401 response

Now you can apply the login_required decorator as usual, and authentication will succeed whenever sky_is_blue == False:

@app.route('/')
@auth.login_required
def index():
    """
    Get a token
    """
    return "OK"

Hope this helps!

Answered By: Miguel Grinberg

Here’s a solution if you need to apply a conditional authentication check on all routes without defining the login_required wrapper on all of them. Simply use the before_request hook:

@app.before_request
def conditional_auth_check():
    if your_condition:
        @auth.login_required
        def _check_login():
            return None

        return _check_login()

login_required doesn’t necessarily needs to wrap a route directly.

Answered By: Epoc

Conditionally turning authorization ‘on/off’ seems like it is also provided out of the box using the optional keyword argument to the auth.login_required decorator.

From the API docs:

An optional optional argument can be set to True to allow the route to execute also when authentication is not included with the request, in which case auth.current_user() will be set to None. Example:

@auth.login_required(optional=True)
def private_page():
    user = auth.current_user()
    return "Hello {}!".format(user.name if user is not None else 'anonymous')
Answered By: jgrussjr

FYI, had a small requirement of only applying the conditional auth on one route and no auth on other health check routes. This is how I did it by extending @epoc’s answer:

@app.before_request
def conditional_auth_check():
    """
    Conditional Authentication
    1. If called predict 
          a. with a certain system header, let it pass
          b. Else, check basic auth
    2. Anything else - let it pass
    """
    if request.path == "/predict":
        # Check Header Auth here
        if request.headers.get("my-header") and os.getenv("system-enabled-var"):
            print("Authorizing using headers")
            return

        # Check Basic Auth here
        print("Authorizing using Basic Auth")
        @basic_auth.login_required
        def _check_login():
            return None

        return _check_login()

    # let all other traffic pass
    return

In my case, system-enabled-var can never be enabled by a user and can only be enabled by a certain system.

Answered By: MAC