Implementing this custom authentication and classification scheme for Pylons was quite simple using repoze.who. Here I describe how I implemented it.
I'll start from a skeleton Pylons app.
$ paster create -t pylons CustomAuth
The defaults of 'mako' for templates and no SQLAlchemy are fine for this example.
I based my repoze.who configuration off this recipe from the Pylons cookbook but I'll quickly repeat the necessary steps here, so that the example is complete.
If you haven't done so already, install the repoze.who package with:
$ easy_install repoze.who
The next step is to add repoze.who to the WSGI middleware of your Pylons app. Edit
config/middleware.pyand add an import:
from repoze.who.config import make_middleware_with_config as make_who_with_config
Then after the comment "CUSTOM MIDDLEWARE HERE" add the following line:
app = make_who_with_config(app, global_conf, app_conf['who.config_file'], app_conf['who.log_file'], app_conf['who.log_level'])
development.iniand add to the
who.config_file = %(here)s/who.ini who.log_level = debug who.log_file = stdout
Now create a
who.inifile in the same location as
[plugin:form] use = repoze.who.plugins.form:make_redirecting_plugin login_form_url = /account/login login_handler_path = /account/dologin logout_handler_path = /account/logout rememberer_name = auth_tkt [plugin:auth_tkt] use = repoze.who.plugins.auth_tkt:make_plugin secret = yoursecret [plugin:basicauth] # identification and challenge use = repoze.who.plugins.basicauth:make_plugin realm = CustomAuth [general] request_classifier = customauth.lib.auth:custom_request_classifier challenge_decider = repoze.who.classifiers:default_challenge_decider [identifiers] plugins = form;browser auth_tkt;browser basicauth [authenticators] plugins = customauth.lib.auth:UserModelPlugin [challengers] plugins = form;browser basicauth [mdproviders] plugins = customauth.lib.auth:UserModelPlugin
You would replace "customauth" with the package name of your Pylons app.
Take note of the request_classifier in
[general]. It specifies a custom classifier function "custom_request_classifier" located in the lib.auth module of your application. This function is called for each request and returns a classification that, for this application, will be either "browser" or "app" (some other classifications are possible, like "dav", but we're not worrying about them in this application; they'll be treated like "app").
You can see that in the
[challengers]sections there are multiple plugins listed. The choice of plugin to use in each case is based on the value returned by the classifier. If the classifier is "browser" then the "form" challenger will be used, otherwise the "basicauth" challenger is chosen. This is the key to the custom authentication, and as you can see it is all handled by repoze.who and extremely simple to configure.
auth.pyfile in the lib directory of the Pylons app containing:
from webob import Request import zope.interface from repoze.who.classifiers import default_request_classifier from repoze.who.interfaces import IRequestClassifier class UserModelPlugin(object): def authenticate(self, environ, identity): """Return username or None. """ try: username = identity['login'] password = identity['password'] except KeyError: return None if (username,password) == ('foo', 'bar'): return username else: return None def add_metadata(self, environ, identity): username = identity.get('repoze.who.userid') if username is not None: identity['user'] = dict( username = username, name = 'Mr Foo', ) def custom_request_classifier(environ): """ Returns one of the classifiers 'app', 'browser' or any standard classifiers returned by repoze.who.classifiers:default_request_classifier """ classifier = default_request_classifier(environ) if classifier == 'browser': # Decide if the client is a (user-driven) browser or an application request = Request(environ) if not request.accept.best_match(['application/xhtml+xml', 'text/html']): # In our view, any client who doesn't support HTML/XHTML is an "app", # not a (user-driven) "browser". classifier = 'app' return classifier zope.interface.directlyProvides(custom_request_classifier, IRequestClassifier)
This is where the
custom_request_classifierfunction is defined. It first calls the
default_request_classifierprovided by repoze.who, which attempts to classify the request as one of a few basic types: 'dav', 'xmlpost', or 'browser'. If the default classification results in 'browser' then we try to classify it further based on content negotiation. If the client prefers a HTML or XHTML response then we leave the classification as 'browser', otherwise we classify it as 'app'.
The other part of the auth module is the
UserModelPluginclass. This class provides "authenticator" and "mdprovider" plugins. The job of the
authenticatemethod is to authenticate the request, typically by verifying the username and password provided, but of course that depends on the type of authentication used. In this example, we simply provide a stub authenticator that compares authentication details against a hard-coded username/password pair. In a real app you would authenticate against data in a database or LDAP service, or whatever you decided to use.
add_metadatamethod of UserModelPlugin is called to supply metadata about the authenticated user. In this example we simply supply a hard-coded name, but in a real app you would fetch details from a database or LDAP or whatever.
The final bit of code needed is the login form. Create an account controller:
$ paster controller account
controllers/account.pyand add a login method to
def login(self): identity = request.environ.get('repoze.who.identity') if identity is not None: came_from = request.params.get('came_from', None) if came_from: redirect_to(str(came_from)) return render('/login.mako')
Also add a test method to the same controller so that we can verify authentication works:
def test(self): identity = request.environ.get('repoze.who.identity') if identity is None: # Force skip the StatusCodeRedirect middleware; it was stripping # the WWW-Authenticate header from the 401 response request.environ['pylons.status_code_redirect'] = True # Return a 401 (Unauthorized) response and signal the repoze.who # basicauth plugin to set the WWW-Authenticate header. abort(401, 'You are not authenticated') return """ <body> Hello %(name)s, you are logged in as %(username)s. <a href="/account/logout">logout</a> </body> </html> """ %identity['user']
The test action checks whether a user has been authenticated for the current request. If not, it forces a 401 response which will have a different effect depending on which classification was chosen. If the request was classified as "browser" then, due to the repoze.who config specifying "form" as the challenger plugin for this classification, the repoze.who middleware will intercept the 401 response and replace it with a 302 redirect to the login form page. For any other classification, the "basicauth" challenger will be chosen which will return the 401 response with an appropriate "WWW-Authenticate" header.
Note that we needed to suppress the StatusCodeRedirect middleware for the 401 response to prevent Pylons from returning a custom error document and messing with our 401 error.
In a real application you may want to move the identity check into the
__before__method of the controller (or BaseController class) or into a custom decorator. Or you could use repoze.what.
In the templates directory create
login.makocontaining a simple form such as:
<html> <body> <p> <form action="/account/dologin" method="POST"> Username: <input type="text" name="login" value="" /> <br /> Password: <input type="password" name="password" value ="" /> <br /> <input type="submit" value="Login" /> </form> </p> </body> </html>
Now you should be ready to run the application and test authentication.
$ paster serve --reload development.ini
Using your favourite web browser, go to http://127.0.0.1:5000/account/test
You should immediately be redirected to /account/login (with a
came_fromparameter) with your login form displayed. Enter bogus details and you shouldn't make it pass the form. Now enter the hard-coded login details ("foo", "bar") and you should be authenticated and see the text from /account/test.
Now we can test whether basic auth works. Using curl, try to fetch /account/test
$ curl -i http://127.0.0.1:5000/account/test HTTP/1.0 302 Found Server: PasteWSGIServer/0.5 Python/2.5.1 Date: Tue, 03 Mar 2009 08:57:59 GMT Location: /account/login?came_from=http%3A%2F%2F127.0.0.1%3A5000%2Faccount%2Ftest content-type: text/html Connection: close <html> <head><title>Found</title></head> <body> <h1>Found</h1> <p>The resource was found at <a href="/account/login?came_from=http%3A%2F%2F127.0.0.1%3A5000%2Faccount%2Ftest">/account/login?came_from=http%3A%2F%2F127.0.0.1%3A5000%2Faccount%2Ftest</a>; you should be redirected automatically. /account/login?came_from=http%3A%2F%2F127.0.0.1%3A5000%2Faccount%2Ftest <!-- --></p> <hr noshade> <div align="right">WSGI Server</div> </body> </html>
You can see that, by default, the request is classified as 'browser' and so a 302 redirect to the login form was returned. Note that if no Accept header field is present, then it is assumed that the client accepts all media types, which is why the request was classified as "browser".
Now let's specify a preference for 'application/json' (using the Accept header) and see what we get.
$ curl -i -H "Accept:application/json" http://127.0.0.1:5000/account/test HTTP/1.0 401 Unauthorized Server: PasteWSGIServer/0.5 Python/2.5.1 Date: Tue, 03 Mar 2009 09:21:09 GMT WWW-Authenticate: Basic realm="CustomAuth" content-type: text/plain; charset=utf8 Connection: close 401 Unauthorized This server could not verify that you are authorized to access the document you requested. Either you supplied the wrong credentials (e.g., bad password), or your browser does not understand how to supply the credentials required.
Perfect. We get a 401 response with a WWW-Authenticate header specifying "Basic" authentication is required. (Note that ideally we should return a JSON response body as that is what the client requested.)
Now we can repeat the request, including our authentication details.
$ curl -i -H "Accept:application/json" -u foo:bar http://127.0.0.1:5000/account/test HTTP/1.0 200 OK Server: PasteWSGIServer/0.5 Python/2.5.1 Date: Tue, 03 Mar 2009 11:39:43 GMT Content-Type: text/html; charset=utf-8 Pragma: no-cache Cache-Control: no-cache Content-Length: 107 <html> <body> Hello Mr Foo, you are logged in as foo. <a href="/account/logout">logout</a> </body> </html>
And there we have it. Dual authentication on the same controller.