TurboGears and OpenID

I thought I might have a go at familiarizing myself with TurboGears, one of the new Python web frameworks, by writing a new and improved clone of the Picky Picky Game. Step one was verifying that I can upload pictures easily. Step two was getting an identity system put together. Creating a registration page and log-in form and so on is the obvious and dull solution, so I thought I’d have a go at exploiting OpenID instead.

But what is OpenID?

Don’t you hate having to learn a new username and password whenever you want to play on a new web site? Well, the people at LiveJournal thought you might, so they concocted a system where you (and only you) use your LiveJournal URL as your log-in name on other sites. If you are alice on LiveJournal, then your OpenID identifier is http://alice.livejournal.com/. It isn’t restricted to LiveJournal, either; anyone can run their own identity server.

Come to Think of it, what is TurboGears?

TurboGears is yet another entrant in Python’s notoriously crowded web-frameworks competition. TurboGears is actaully a sort of Frankestein creation—rather than designing its own quirky template, database and controller conventions, TurboGears uses established, lightweight libraries like Kid, SQLObject, CherryPy, and MochiKit. I had already been thinking it would be nice to swap the Picky Picky Game’s home-brewed templating system with Kid and its controller framework with CherryPy, so finding them nicely packaged up together as TurboGears looks very attractive!

I am not going to attempt to write a TurboGears tutorial here; there is already the 20-minute Wiki movie and a fine TurboGears tutorial. What I will do here is outline the code I wrote. If you, gentle reader, are a web developer who does not already know TurboGears, I hope it will give you a flavour of the brevity of TurboGears code.

Step Zero: Install, Install, Install

Installing TurboGears is a little adventurous on Mac OS X. The first thing you have to do is upgrade Python to version 2.4 (since even the latest Mac OS X revision has Python 2.3), and even the Python web site is mum on how to achieve this. In the end the packages the TurboGears web site points to worked fine. The process of installing TurboGears itself (via something called Easy Install) also worked fine. Most of the time was spent reading the documentation.

Then I decided I wanted to be able to use the latest SQLite as my database driver. This required me to actually install Unix packages from source, something I have gotten out of the habit of, what with everyone supplying fancy installers and Mac OS packages. This required nothing more than the incantations I remember from when I did everything on GNU/Linux boxes:

./configure
make
sudo make install

In the same fashion, installing the Python drivers is very similar, except that Python has its own idiom:

python setup.py build
sudo python setup.py install

I tested this had installed it where Python could find it by starting up python and doing import pysqlite2.

I was fully prepared to roll my own OpenID library based on the specification—I have done the same with RFC 2617 three times over—but a little poking about on the OpenID site pointed me to a Python library Python-OpenID. Cool!

Building an OpenID-enabled TurboGears Application, Step One

I had already run the tg-admin quickstart command to create a web application directory. This creates a directory tree with skeletal Python files and Kid templates ready for you to modify; some people will not like this, but the files it creates are essentially what you would end up with if you wrote it starting from scratch (and were very organized), so I’m happy with it.

To add a ‘Please Log In’ page, I started by adding a method to controllers.py:

class Root(controllers.Root):
    ...
    @turbogears.expose(html='picky2.templates.login')
    def login(self, next='./'):
        return dict(next=next)

This tells the server that URLs like http://localhost:8080/login?next=foo are handled by rendering the template picky2.templates.login (Picky2 is the application name), passing the value of the next variable as a parameter to the template. This will be used, once the user is logged in, to redirect them to the page they started on.

The template in turn is created by copying welcome.kid and editing to look something like this:

<!DOCTYPE html SYSTEM "xhtml+kid.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:py="http://purl.org/kid/ns#"
        py:extends="'master.kid'">    
    <head>
        <meta content="text/html; charset=UTF-8" http-equiv="content-type" py:replace="''"/>
        <title>Picky2 Playpen</title>
    </head>    
    <body>
        <div id="loginForm" class="form">
            <h1>Log in!</h1>
            <form action="loginBegin" method="get">
                <input type="hidden" name="next" value="${next}"/>
                <p>
                    <label for="urlbox">Log in with your blog URL:</label>
                    <input type="text" name="url" size="60" id="urlbox"/>
                    <input type="submit" value="Log me in!"/>
                </p>
            </form>
        </div>
    </body>
</html>

This is mostly the usual HTML for displaying a form. The point to note is is that the next parameter is squirrelled away in a hidden form item.

One nice thing about Kid templates is they they are well-formed XML; if you have an XML-savvy text editor—such as jEdit—it can check the syntax of templates as you type, and save you from generating bad HTML that is only detected when it is in the browser. (In order to placate jEdit’s XML validator, I created my own XHTML+kid.dtd document-type definition file; it is supposed to be XHTML with Kid’s attributes permitted everywhere, but I have only done as much as is necessary to make my templates validate. Or I could have just removed the !DOCTYPE declaration.)

Now I can switch to my Firefox window and visit the login page to see it all working.

The action attribute for the form element is loginBegin, so I need to add another method to controllers.py:

import authentication
...

class Root(...):
    ...
    @turbogears.expose(html='picky2.templates.login')
    def loginBegin(self, url, next):
        """Called when user clicks OK on the login form."""
        openId = authentication.openId()
        status, inf = openId.beginAuth(url)
        if status == 'success':
            returnUri = self.openIdReturnUri(inf, next)
            openIdUri = openId.constructRedirect(inf, returnUri, 'http://localhost:8080/')
            raise cherrypy.HTTPRedirect(openIdUri)
        # If we get here, then the OpenID server could not be contacted.
        return dict(error=status, detail=inf, next=next, who=None, openId=None)

    def openIdReturnUri(self, inf, next):
        """Given an OpenID request information object, return a URI on this server."""
        token = uriParamEncode(inf.token)
        next = uriParamEncode(next)
        return 'http://localhost:8080/loginFinish?token=%s&next=%s' % (token, next)

The argument names for the loginBegin method correspond to the form parameters in the HTML. The url parameter is the URI the user wants to use as their identifier. The openId object is an instance of OpenIDConsumer from a module I will write next that wraps up the Python-OpenID library. The beginAuth method visits the URI and looks for a link to an OpenID server. If all goes well, it returns the information needed to make the complete URI of the remote OpenID server with all the right parameters (via the constructRedirect method). To perform the redirection, it seems the standard idiom is to throw an cherrypy.HTTPRedirect exception.

If the OpenIDConsumer object returns anything other than success, this is passed to the same Kid template (login.kid) as before. Thus it shows the same form as before, except with an error message added below it. This requires adding something to the template to include the error message:

<div class="error" py:if="error">
    <p>
         Could not verify your identity
         because of ${error}<span py:if="detail" py:replace="detail">404</span>.
    </p>
</div>

Not elegant, but adequate for my test version. The py:if attribute ensures this div element completly vanishes if there is no error code.

The big gotcha in the above is in the method openIdReturnUri: the token and next-URI parameters need to be escaped with uriParamEncode so that they do not get scrambled when they get read back in at the end. My first attempt omitted this step, and I got mysterious failures as a result.

Creating the OpenIDController Object

The bit that I have swept under the carpet is the code that actually creates the OpenIDConsumer object that does most of the work in my loginBegin method. I put this in its own module out of a sense of tidiness; as it turns out, the amount of code is small enough that I might as well have simply included it in controllers.py, but never mind. Here is is:

from pysqlite2 import dbapi2 as sqlite
from openid.consumer import consumer
from openid.store import sqlstore

def openId():
    """Return an object that may be used to authenticate user using the OpenID protocol."""
    #TODO get database info from configuration file...?
    con = sqlite.connect('openid.db')
    store = sqlstore.SQLiteStore(con)
    return consumer.OpenIDConsumer(store)

For now the database used is always called openid.db. I could probably have simply used the connection settings from TurboGears so as to use the same database file used to store everything else in my application, but I like the idea of separating the data managed by Python-OpenID from the rest of my data.

The main gotcha is that I expected the database connection function to be pysqlite2.connect but it is instead pysqlite2.dbapi2.connect, which took me a little Googling to find.

Python allows a module to double as a program by adding an if __name__ == '__main__' clause at the end. I used this to add the code that must be run once to initialize the OpenID database:

if __name__ == '__main__':
    import sys

    if sys.argv[1] == 'create':
        print 'Creating tables for OpenID support...'
        con = sqlite.connect('openid.db')
        store = sqlstore.SQLiteStore(con)
        store.createTables()

Step Two: Handling the Return from the OpenID Server

At this point I can enter my LiveJournal URI http://damiancugley.livejournal.com/ in to the form and press the button and I am amagically transported to the LiveJournal page that validates OpenID requests:

(screenshot)

Clicking on the Yes button takes me back to my server, which prompty complains that I have not defined a loginFinish method yet. (It gets that name from the return URI parameter included in the OpenID request.) To implement this, we just add another method to controllers.py as follows:

@turbogears.expose(html='picky2.templates.login')
def loginFinish(self, token, next, **kws):
    """Called when OpenID server has approved user's login.

    token -- as returned by OpenIDConsumer
    next -- as passed to the original login method;
        URI of the page to redirect to once user is authenticated
    """
    # TODO. Check return_to equals expected value
    openId = authentication.openId()
    status, inf = openId.completeAuth(token, kws)
    if status == 'success':
        # TODO. Create cookie
        turbogears.flash('Welcome to Picky2')
        raise cherrypy.HTTPRedirect(next)
    return dict(error=status, detail=None, next=next, who=inf, openId=kws)

There a re a couple of bits that I have not yet put in—thus the TODO comments—but this method does the bare minimum: it checks the results using OpenIDConsumer.completeAuth, and redirects to the URL that was originally passed to the login page and has been passed like a football from page to page since. The turbogears.flash causes an extra message to appear (I’m not sure how this works with more than one visitor, unless there is some form of session management I am unaware ofusing a cookie).

So voilà—a working OpenID consumer in not very many lines of code at all.

What Comes Next?

I have already experimented with uploading pictures; now I need to link up the pictures with the person who uploaded them. When I next have some time for playing with TurboGears, I will report on how I get on with that.