I remember this taking me a long time to figure out, but being really really happy when I finally got something to work. I had written a StackOverflow question hoping for insight, but I eventually figured it out and answered my own question. (I’ve cleaned up the answer somewhat for this blog post.)
So, the short answer is you can do this, but you have to write your own CherryPy tool (a
before_handler
), and you must not enable Basic Authentication in the CherryPy config (that is, you shouldn’t do anything liketools.auth.on
ortools.auth.basic...
etc) - you have to handle HTTP Basic Authentication yourself. The reason for this is that if you enable the built-in Basic Authentication stuff, it will do that auth check before it checks the session, and your cookies will do nothing.My solution, in prose
Fortunately, even though CherryPy doesn’t have a way to do both built-in, you can still use its built-in session code. You still have to write your own code for handling the Basic Authentication part, but in total this is not so bad, and using the session code is a big win because writing a custom session manager is a good way to introduce security bugs into your webapp.
I ended up being able to take a lot of things from a page on the CherryPy wiki called Simple authentication and access restrictions helpers. That code uses CP sessions, but rather than Basic Auth it uses a special page with a login form that submits
?username=USERNAME&password=PASSWORD
. What I did is basically nothing more than changing the providedcheck_auth
function from using the special login page to using the HTTP auth headers.In general, you need a function you can add as a CherryPy tool - specifically a
before_handler
. (In the original code, this function was calledcheck_auth()
, but I renamed it toprotect()
.) This function first tries to see if the cookies contain a (valid) session ID, and if that fails, it tries to see if there is HTTP auth information in the headers.You then need a way to require authentication for a given page; I do this with
require()
, plus some conditions, which are just callables that returnTrue
. In my case, those conditions arezkn_admin()
, anduser_is()
functions; if you have more complex needs, you might want to also look atmember_of()
,any_of()
, andall_of()
from the original code.If you do it like that, you already have a way to log in - you just submit a valid session cookie or HTTPBA credentials to any URL you protect with the
@require()
decorator. All you need now is a way to log out.(The original code instead has an
AuthController
class which containslogin()
andlogout()
, and you can use the wholeAuthController
object in your HTTP document tree by just puttingauth = AuthController()
inside your CherryPy root class, and get to it with a URL of e.g. http://example.com/auth/login and http://example.com/auth/logout. My code doesn’t use an authcontroller object, just a few functions.)Some notes about my code
- Caveat: Because I wrote my own parser for HTTP auth headers, it only parses what I told it about, which means just HTTP Basic Auth - not, for example, Digest Auth or anything else. For my application that’s fine; for yours, it may not be.
- It assumes a few functions defined elsewhere in my code:
user_verify()
anduser_is_admin()
- The code is not 100% tested against edge cases - sorry about that! It will still give you enough of an idea to go on, though, I hope.
My solution, in code
import base64 import log import re import cherrypy SESSION_KEY = '_zkn_username' def protect(*args, **kwargs): log.debug("Inside protect()...") authenticated = False conditions = cherrypy.request.config.get('auth.require', None) log.debug("conditions: {}".format(conditions)) if conditions is not None: # A condition is just a callable that returns true or false try: this_session = cherrypy.session[SESSION_KEY] cherrypy.session.regenerate() email = cherrypy.request.login = cherrypy.session[SESSION_KEY] authenticated = True log.debug("Authenticated with session: {}, for user: {}".format( this_session, email)) except KeyError: # If the session isn't set, it either wasn't present or wasn't # valid. Now check if the request includes an HTTPBA header like # "AUTHORIZATION: Basic <base64shit>" authheader = cherrypy.request.headers.get('AUTHORIZATION') if authheader: b64data = re.sub("Basic ", "", authheader) decodeddata = base64.b64decode(b64data.encode("ASCII")) email,passphrase = decodeddata.decode().split(":", 1) if user_verify(email, passphrase): cherrypy.session.regenerate() cherrypy.session[SESSION_KEY] = cherrypy.request.login = email authenticated = True else: log.debug( "Attempted login as '{}', but failed".format(email)) else: log.debug("Auth header was not present.") except: log.debug( "Client has no valid session and did not provide HTTPBA creds") if authenticated: for condition in conditions: if not condition(): log.debug( "Authentication succeeded but authorization failed.") raise cherrypy.HTTPError("403 Forbidden") else: raise cherrypy.HTTPError("401 Unauthorized") # This could be called cherrypy.tools.ANYTHING; I chose 'zkauth' based on the # name of my app. Take care NOT to call it 'auth' or the name of any other # built-in tool. cherrypy.tools.zkauth = cherrypy.Tool('before_handler', protect) def require(*conditions): """A decorator that appends conditions to the auth.require config var""" def decorate(f): if not hasattr(f, '_cp_config'): f._cp_config = dict() if 'auth.require' not in f._cp_config: f._cp_config['auth.require'] = [] f._cp_config['auth.require'].extend(conditions) return f return decorate #### CONDITIONS # # Conditions are callables that return True # if the user fulfills the conditions they define, False otherwise # # They can access the current user as cherrypy.request.login # TODO: test this function with cookies, I want to make sure that # cherrypy.request.login is set properly so that this function can use # it. def zkn_admin(): return lambda: user_is_admin(cherrypy.request.login) def user_is(reqd_email): return lambda: reqd_email == cherrypy.request.login def logout(): email = cherrypy.session.get(SESSION_KEY, None) cherrypy.session[SESSION_KEY] = cherrypy.request.login = None return "Logout successful"
Now all you have to do is enable both builtin sessions and your own
cherrypy.tools.WHATEVER
in your CherryPy configuration. Again, take care not to enablecherrypy.tools.auth
. My configuration ended up looking like this:config_root = { '/' : { 'tools.zkauth.on': True, 'tools.sessions.on': True, 'tools.sessions.name': 'zknsrv', } }