Mercurial > public > pelican-blog
diff content/Coding/020-django-moinmoin.rst @ 4:7ce6393e6d30
Adding converted blog posts from old blog.
author | Brian Neal <bgneal@gmail.com> |
---|---|
date | Thu, 30 Jan 2014 21:45:03 -0600 |
parents | |
children | 49bebfa6f9d3 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/content/Coding/020-django-moinmoin.rst Thu Jan 30 21:45:03 2014 -0600 @@ -0,0 +1,443 @@ +Integrating Django and MoinMoin with Redis +########################################## + +:date: 2012-12-02 14:50 +:tags: Django, MoinMoin, Redis +:slug: integrating-django-and-moinmoin-with-redis +:author: Brian Neal + +We want a Wiki! +=============== + +Over at SurfGuitar101.com_, we decided we'd like to have a wiki to capture +community knowledge. I briefly looked at candidate wiki engines with an eye +towards integrating them with Django_, the framework that powers +SurfGuitar101.com_. And of course I was biased towards a wiki solution that was +written in Python_. I had tried a few wikis in the past, including the behemoth +MediaWiki_. MediaWiki is a very powerful piece of software, but it is also +quite complex, and I didn't want to have to maintain a PHP infrastructure to run +it. + +Enter MoinMoin_. This is a mature wiki platform that is actively maintained and +written in Python. It is full featured but did not seem overly complex to me. +It stores its pages in flat files, which seemed appealing for our likely small +wiki needs. It turns out I had been a user of MoinMoin for many years without +really knowing it. The `Python.org wiki`_, `Mercurial wiki`_, and `omniORB +wiki`_ are all powered by MoinMoin_. We'd certainly be in good company. + +Single Sign-On +============== + +The feature that clinched it was MoinMoin's flexible `authentication system`_. +It would be very desirable if my users did not have to sign into Django and +then sign in again to the wiki with possibly a different username. Managing two +different password databases would be a real headache. The ideal solution would +mean signing into Django would log the user into MoinMoin with the same +username automatically. + +MoinMoin supports this with their `external cookie authentication`_ mechanism. +The details are provided in the previous link; basically a Django site needs to +perform the following: + +#. Set an external cookie for MoinMoin to read whenever a user logs into Django. +#. To prevent spoofing, the Django application should create a record that the + cookie was created in some shared storage area accessible to MoinMoin. This + allows MoinMoin to validate that the cookie is legitimate and not a fake. +#. When the user logs out of the Django application, it should delete this + external cookie and the entry in the shared storage area. +#. Periodically the Django application should expire entires in the shared + storage area for clean-up purposes. Otherwise this storage would grow and + grow if users never logged out. Deleting entries older than the external + cookie's age should suffice. + +My Django Implementation +======================== + +There are of course many ways to approach this problem. Here is what I came up +with. I created a Django application called *wiki* to hold this integration +code. There is quite a lot of code here, too much to conveniently show in this +blog post. I will post snippets below, but you can refer to the complete code +in my `Bitbucket repository`_. You can also view online the `wiki application in +bitbucket`_ for convenience. + +Getting notified of when users log into or out of Django is made easy thanks to +Django's `login and logout signals`_. By creating a signal handler I can be +notified when a user logs in or out. The signal handler code looks like this: + +.. sourcecode:: python + + import logging + from django.contrib.auth.signals import user_logged_in, user_logged_out + from wiki.constants import SESSION_SET_MEMBER + + logger = logging.getLogger(__name__) + + def login_callback(sender, request, user, **kwargs): + """Signal callback function for a user logging in. + + Sets a flag for the middleware to create an external cookie. + + """ + logger.info('User login: %s', user.username) + + request.wiki_set_cookie = True + + def logout_callback(sender, request, user, **kwargs): + """Signal callback function for a user logging in. + + Sets a flag for the middleware to delete the external cookie. + + Since the user is about to logout, her session will be wiped out after + this function returns. This forces us to set an attribute on the request + object so that the response middleware can delete the wiki's cookie. + + """ + if user: + logger.info('User logout: %s', user.username) + + # Remember what Redis set member to delete by adding an attribute to the + # request object: + request.wiki_delete_cookie = request.session.get(SESSION_SET_MEMBER) + + + user_logged_in.connect(login_callback, dispatch_uid='wiki.signals.login') + user_logged_out.connect(logout_callback, dispatch_uid='wiki.signals.logout') + +When a user logs in I want to create an external cookie for MoinMoin. But +cookies can only be created on HttpResponse_ objects, and all we have access to +here in the signal handler is the request object. The solution here is to set +an attribute on the request object that a later piece of middleware_ will +process. I at first resisted this approach, thinking it was kind of hacky. +I initially decided to set a flag in the session, but then found out that in +some cases the session is not always available. I then reviewed some of the +Django supplied middleware classes and saw that they also set attributes on the +request object, so this must be an acceptable practice. + +My middleware looks like this. + +.. sourcecode:: python + + class WikiMiddleware(object): + """ + Check for flags on the request object to determine when to set or delete an + external cookie for the wiki application. When creating a cookie, also + set an entry in Redis that the wiki application can validate to prevent + spoofing. + + """ + + def process_response(self, request, response): + + if hasattr(request, 'wiki_set_cookie'): + create_wiki_session(request, response) + elif hasattr(request, 'wiki_delete_cookie'): + destroy_wiki_session(request.wiki_delete_cookie, response) + + return response + +The ``create_wiki_session()`` function creates the cookie for MoinMoin and +stores a hash of the cookie in a shared storage area for MoinMoin to validate. +In our case, Redis_ makes an excellent shared storage area. We create a sorted +set in Redis to store our cookie hashes. The score for each hash is the +timestamp of when the cookie was created. This allows us to easily delete +expired cookies by score periodically. + +.. sourcecode:: python + + def create_wiki_session(request, response): + """Sets up the session for the external wiki application. + + Creates the external cookie for the Wiki. + Updates the Redis set so the Wiki can verify the cookie. + + """ + now = datetime.datetime.utcnow() + value = cookie_value(request.user, now) + response.set_cookie(settings.WIKI_COOKIE_NAME, + value=value, + max_age=settings.WIKI_COOKIE_AGE, + domain=settings.WIKI_COOKIE_DOMAIN) + + # Update a sorted set in Redis with a hash of our cookie and a score + # of the current time as a timestamp. This allows us to delete old + # entries by score periodically. To verify the cookie, the external wiki + # application computes a hash of the cookie value and checks to see if + # it is in our Redis set. + + h = hashlib.sha256() + h.update(value) + name = h.hexdigest() + score = time.mktime(now.utctimetuple()) + conn = get_redis_connection() + + try: + conn.zadd(settings.WIKI_REDIS_SET, score, name) + except redis.RedisError: + logger.error("Error adding wiki cookie key") + + # Store the set member name in the session so we can delete it when the + # user logs out: + request.session[SESSION_SET_MEMBER] = name + +We store the name of the Redis set member in the user's session so we can +delete it from Redis when the user logs out. During logout, this set member is +retrieved from the session in the logout signal handler and stored on the +request object. This is because the session will be destroyed after the logout +signal handler runs and before the middleware can access it. The middleware +can check for the existence of this attribute as its cue to delete the wiki +session. + +.. sourcecode:: python + + def destroy_wiki_session(set_member, response): + """Destroys the session for the external wiki application. + + Delete the external cookie. + Deletes the member from the Redis set as this entry is no longer valid. + + """ + response.delete_cookie(settings.WIKI_COOKIE_NAME, + domain=settings.WIKI_COOKIE_DOMAIN) + + if set_member: + conn = get_redis_connection() + try: + conn.zrem(settings.WIKI_REDIS_SET, set_member) + except redis.RedisError: + logger.error("Error deleting wiki cookie set member") + +As suggested in the MoinMoin external cookie documentation, I create a cookie +whose value consists of the username, email address, and a key separated by +the ``#`` character. The key is just a string of stuff that makes it difficult +for a spoofer to recreate. + +.. sourcecode:: python + + def cookie_value(user, now): + """Creates the value for the external wiki cookie.""" + + # The key part of the cookie is just a string that would make things + # difficult for a spoofer; something that can't be easily made up: + + h = hashlib.sha256() + h.update(user.username + user.email) + h.update(now.isoformat()) + h.update(''.join(random.sample(string.printable, 64))) + h.update(settings.SECRET_KEY) + key = h.hexdigest() + + parts = (user.username, user.email, key) + return '#'.join(parts) + +Finally on the Django side we should periodically delete expired Redis set +members in case users do not log out. Since I am using Celery_ with my Django +application, I created a Celery task that runs periodically to delete old set +members. This function is a bit longer than it probably needs to be, but +I wanted to log how big this set is before and after we cull the expired +entries. + +.. sourcecode:: python + + @task + def expire_cookies(): + """ + Periodically run this task to remove expired cookies from the Redis set + that is shared between this Django application & the MoinMoin wiki for + authentication. + + """ + now = datetime.datetime.utcnow() + cutoff = now - datetime.timedelta(seconds=settings.WIKI_COOKIE_AGE) + min_score = time.mktime(cutoff.utctimetuple()) + + conn = get_redis_connection() + + set_name = settings.WIKI_REDIS_SET + try: + count = conn.zcard(set_name) + except redis.RedisError: + logger.error("Error getting zcard") + return + + try: + removed = conn.zremrangebyscore(set_name, 0.0, min_score) + except redis.RedisError: + logger.error("Error removing by score") + return + + total = count - removed + logger.info("Expire wiki cookies: removed %d, total is now %d", + removed, total) + +MoinMoin Implementation +======================= + +As described in the MoinMoin external cookie documentation, you have to +configure MoinMoin to use your external cookie authentication mechanism. +It is also nice to disable the ability for the MoinMoin user to change their +username and email address since that is being managed by the Django +application. These changes to the MoinMoin ``Config`` class are shown below. + +.. sourcecode:: python + + class Config(multiconfig.DefaultConfig): + + # ... + + # Use ExternalCookie method for integration authentication with Django: + auth = [ExternalCookie(autocreate=True)] + + # remove ability to change username & email, etc. + user_form_disable = ['name', 'aliasname', 'email',] + user_form_remove = ['password', 'password2', 'css_url', 'logout', 'create', + 'account_sendmail', 'jid'] + +Next we create an ``ExternalCookie`` class and associated helper functions to +process the cookie and verify it in Redis. This code is shown in its entirety +below. It is based off the example in the MoinMoin external cookie +documentation, but uses Redis as the shared storage area. + +.. sourcecode:: python + + import hashlib + import Cookie + import logging + + from MoinMoin.auth import BaseAuth + from MoinMoin.user import User + import redis + + COOKIE_NAME = 'YOUR_COOKIE_NAME_HERE' + + # Redis connection and database settings + REDIS_HOST = 'localhost' + REDIS_PORT = 6379 + REDIS_DB = 0 + + # The name of the set in Redis that holds cookie hashes + REDIS_SET = 'wiki_cookie_keys' + + logger = logging.getLogger(__name__) + + + def get_cookie_value(cookie): + """Returns the value of the Django cookie from the cookie. + None is returned if the cookie is invalid or the value cannot be + determined. + + This function works around an issue with different Python versions. + In Python 2.5, if you construct a SimpleCookie with a dict, then + type(cookie[key]) == unicode + whereas in later versions of Python: + type(cookie[key]) == Cookie.Morsel + """ + if cookie: + try: + morsel = cookie[COOKIE_NAME] + except KeyError: + return None + + if isinstance(morsel, unicode): # Python 2.5 + return morsel + elif isinstance(morsel, Cookie.Morsel): # Python 2.6+ + return morsel.value + + return None + + + def get_redis_connection(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB): + """ + Create and return a Redis connection using the supplied parameters. + + """ + return redis.StrictRedis(host=host, port=port, db=db) + + + def validate_cookie(value): + """Determines if cookie was created by Django. Returns True on success, + False on failure. + + Looks up the hash of the cookie value in Redis. If present, cookie + is deemed legit. + + """ + h = hashlib.sha256() + h.update(value) + set_member = h.hexdigest() + + conn = get_redis_connection() + success = False + try: + score = conn.zscore(REDIS_SET, set_member) + success = score is not None + except redis.RedisError: + logger.error('Could not check Redis for ExternalCookie auth') + + return success + + + class ExternalCookie(BaseAuth): + name = 'external_cookie' + + def __init__(self, autocreate=False): + self.autocreate = autocreate + BaseAuth.__init__(self) + + def request(self, request, user_obj, **kwargs): + user = None + try_next = True + + try: + cookie = Cookie.SimpleCookie(request.cookies) + except Cookie.CookieError: + cookie = None + + val = get_cookie_value(cookie) + if val: + try: + username, email, _ = val.split('#') + except ValueError: + return user, try_next + + if validate_cookie(val): + user = User(request, name=username, auth_username=username, + auth_method=self.name) + + changed = False + if email != user.email: + user.email = email + changed = True + + if user: + user.create_or_update(changed) + if user and user.valid: + try_next = False + + return user, try_next + +Conclusion +========== + +I've been running this setup for a month now and it is working great. My users +and I are enjoying our shiny new MoinMoin wiki integrated with our Django +powered community website. The single sign-on experience is quite seamless and +eliminates the need for separate accounts. + + +.. _SurfGuitar101.com: http://surfguitar101.com +.. _Django: https://www.djangoproject.com +.. _MediaWiki: http://www.mediawiki.org +.. _MoinMoin: http://moinmo.in/ +.. _Python: http://www.python.org +.. _Python.org wiki: http://wiki.python.org/moin/ +.. _Mercurial wiki: http://mercurial.selenic.com/wiki/ +.. _omniORB wiki: http://www.omniorb-support.com/omniwiki +.. _authentication system: http://moinmo.in/HelpOnAuthentication +.. _external cookie authentication: http://moinmo.in/HelpOnAuthentication/ExternalCookie +.. _login and logout signals: https://docs.djangoproject.com/en/1.4/topics/auth/#login-and-logout-signals +.. _HttpResponse: https://docs.djangoproject.com/en/1.4/ref/request-response/#httpresponse-objects +.. _middleware: https://docs.djangoproject.com/en/1.4/topics/http/middleware/ +.. _Redis: http://redis.io/ +.. _Bitbucket repository: https://bitbucket.org/bgneal/sg101 +.. _wiki application in bitbucket: https://bitbucket.org/bgneal/sg101/src/a5b8f25e1752faf71ed429ec7f22ff6f3b3dc851/wiki?at=default +.. _Celery: http://celeryproject.org/