annotate content/Coding/020-django-moinmoin.rst @ 5:4b5cdcc351c5

Use a cloned copy of pelican-bootstrap3 repo as my theme.
author Brian Neal <bgneal@gmail.com>
date Fri, 31 Jan 2014 19:12:50 -0600
parents 7ce6393e6d30
children 49bebfa6f9d3
rev   line source
bgneal@4 1 Integrating Django and MoinMoin with Redis
bgneal@4 2 ##########################################
bgneal@4 3
bgneal@4 4 :date: 2012-12-02 14:50
bgneal@4 5 :tags: Django, MoinMoin, Redis
bgneal@4 6 :slug: integrating-django-and-moinmoin-with-redis
bgneal@4 7 :author: Brian Neal
bgneal@4 8
bgneal@4 9 We want a Wiki!
bgneal@4 10 ===============
bgneal@4 11
bgneal@4 12 Over at SurfGuitar101.com_, we decided we'd like to have a wiki to capture
bgneal@4 13 community knowledge. I briefly looked at candidate wiki engines with an eye
bgneal@4 14 towards integrating them with Django_, the framework that powers
bgneal@4 15 SurfGuitar101.com_. And of course I was biased towards a wiki solution that was
bgneal@4 16 written in Python_. I had tried a few wikis in the past, including the behemoth
bgneal@4 17 MediaWiki_. MediaWiki is a very powerful piece of software, but it is also
bgneal@4 18 quite complex, and I didn't want to have to maintain a PHP infrastructure to run
bgneal@4 19 it.
bgneal@4 20
bgneal@4 21 Enter MoinMoin_. This is a mature wiki platform that is actively maintained and
bgneal@4 22 written in Python. It is full featured but did not seem overly complex to me.
bgneal@4 23 It stores its pages in flat files, which seemed appealing for our likely small
bgneal@4 24 wiki needs. It turns out I had been a user of MoinMoin for many years without
bgneal@4 25 really knowing it. The `Python.org wiki`_, `Mercurial wiki`_, and `omniORB
bgneal@4 26 wiki`_ are all powered by MoinMoin_. We'd certainly be in good company.
bgneal@4 27
bgneal@4 28 Single Sign-On
bgneal@4 29 ==============
bgneal@4 30
bgneal@4 31 The feature that clinched it was MoinMoin's flexible `authentication system`_.
bgneal@4 32 It would be very desirable if my users did not have to sign into Django and
bgneal@4 33 then sign in again to the wiki with possibly a different username. Managing two
bgneal@4 34 different password databases would be a real headache. The ideal solution would
bgneal@4 35 mean signing into Django would log the user into MoinMoin with the same
bgneal@4 36 username automatically.
bgneal@4 37
bgneal@4 38 MoinMoin supports this with their `external cookie authentication`_ mechanism.
bgneal@4 39 The details are provided in the previous link; basically a Django site needs to
bgneal@4 40 perform the following:
bgneal@4 41
bgneal@4 42 #. Set an external cookie for MoinMoin to read whenever a user logs into Django.
bgneal@4 43 #. To prevent spoofing, the Django application should create a record that the
bgneal@4 44 cookie was created in some shared storage area accessible to MoinMoin. This
bgneal@4 45 allows MoinMoin to validate that the cookie is legitimate and not a fake.
bgneal@4 46 #. When the user logs out of the Django application, it should delete this
bgneal@4 47 external cookie and the entry in the shared storage area.
bgneal@4 48 #. Periodically the Django application should expire entires in the shared
bgneal@4 49 storage area for clean-up purposes. Otherwise this storage would grow and
bgneal@4 50 grow if users never logged out. Deleting entries older than the external
bgneal@4 51 cookie's age should suffice.
bgneal@4 52
bgneal@4 53 My Django Implementation
bgneal@4 54 ========================
bgneal@4 55
bgneal@4 56 There are of course many ways to approach this problem. Here is what I came up
bgneal@4 57 with. I created a Django application called *wiki* to hold this integration
bgneal@4 58 code. There is quite a lot of code here, too much to conveniently show in this
bgneal@4 59 blog post. I will post snippets below, but you can refer to the complete code
bgneal@4 60 in my `Bitbucket repository`_. You can also view online the `wiki application in
bgneal@4 61 bitbucket`_ for convenience.
bgneal@4 62
bgneal@4 63 Getting notified of when users log into or out of Django is made easy thanks to
bgneal@4 64 Django's `login and logout signals`_. By creating a signal handler I can be
bgneal@4 65 notified when a user logs in or out. The signal handler code looks like this:
bgneal@4 66
bgneal@4 67 .. sourcecode:: python
bgneal@4 68
bgneal@4 69 import logging
bgneal@4 70 from django.contrib.auth.signals import user_logged_in, user_logged_out
bgneal@4 71 from wiki.constants import SESSION_SET_MEMBER
bgneal@4 72
bgneal@4 73 logger = logging.getLogger(__name__)
bgneal@4 74
bgneal@4 75 def login_callback(sender, request, user, **kwargs):
bgneal@4 76 """Signal callback function for a user logging in.
bgneal@4 77
bgneal@4 78 Sets a flag for the middleware to create an external cookie.
bgneal@4 79
bgneal@4 80 """
bgneal@4 81 logger.info('User login: %s', user.username)
bgneal@4 82
bgneal@4 83 request.wiki_set_cookie = True
bgneal@4 84
bgneal@4 85 def logout_callback(sender, request, user, **kwargs):
bgneal@4 86 """Signal callback function for a user logging in.
bgneal@4 87
bgneal@4 88 Sets a flag for the middleware to delete the external cookie.
bgneal@4 89
bgneal@4 90 Since the user is about to logout, her session will be wiped out after
bgneal@4 91 this function returns. This forces us to set an attribute on the request
bgneal@4 92 object so that the response middleware can delete the wiki's cookie.
bgneal@4 93
bgneal@4 94 """
bgneal@4 95 if user:
bgneal@4 96 logger.info('User logout: %s', user.username)
bgneal@4 97
bgneal@4 98 # Remember what Redis set member to delete by adding an attribute to the
bgneal@4 99 # request object:
bgneal@4 100 request.wiki_delete_cookie = request.session.get(SESSION_SET_MEMBER)
bgneal@4 101
bgneal@4 102
bgneal@4 103 user_logged_in.connect(login_callback, dispatch_uid='wiki.signals.login')
bgneal@4 104 user_logged_out.connect(logout_callback, dispatch_uid='wiki.signals.logout')
bgneal@4 105
bgneal@4 106 When a user logs in I want to create an external cookie for MoinMoin. But
bgneal@4 107 cookies can only be created on HttpResponse_ objects, and all we have access to
bgneal@4 108 here in the signal handler is the request object. The solution here is to set
bgneal@4 109 an attribute on the request object that a later piece of middleware_ will
bgneal@4 110 process. I at first resisted this approach, thinking it was kind of hacky.
bgneal@4 111 I initially decided to set a flag in the session, but then found out that in
bgneal@4 112 some cases the session is not always available. I then reviewed some of the
bgneal@4 113 Django supplied middleware classes and saw that they also set attributes on the
bgneal@4 114 request object, so this must be an acceptable practice.
bgneal@4 115
bgneal@4 116 My middleware looks like this.
bgneal@4 117
bgneal@4 118 .. sourcecode:: python
bgneal@4 119
bgneal@4 120 class WikiMiddleware(object):
bgneal@4 121 """
bgneal@4 122 Check for flags on the request object to determine when to set or delete an
bgneal@4 123 external cookie for the wiki application. When creating a cookie, also
bgneal@4 124 set an entry in Redis that the wiki application can validate to prevent
bgneal@4 125 spoofing.
bgneal@4 126
bgneal@4 127 """
bgneal@4 128
bgneal@4 129 def process_response(self, request, response):
bgneal@4 130
bgneal@4 131 if hasattr(request, 'wiki_set_cookie'):
bgneal@4 132 create_wiki_session(request, response)
bgneal@4 133 elif hasattr(request, 'wiki_delete_cookie'):
bgneal@4 134 destroy_wiki_session(request.wiki_delete_cookie, response)
bgneal@4 135
bgneal@4 136 return response
bgneal@4 137
bgneal@4 138 The ``create_wiki_session()`` function creates the cookie for MoinMoin and
bgneal@4 139 stores a hash of the cookie in a shared storage area for MoinMoin to validate.
bgneal@4 140 In our case, Redis_ makes an excellent shared storage area. We create a sorted
bgneal@4 141 set in Redis to store our cookie hashes. The score for each hash is the
bgneal@4 142 timestamp of when the cookie was created. This allows us to easily delete
bgneal@4 143 expired cookies by score periodically.
bgneal@4 144
bgneal@4 145 .. sourcecode:: python
bgneal@4 146
bgneal@4 147 def create_wiki_session(request, response):
bgneal@4 148 """Sets up the session for the external wiki application.
bgneal@4 149
bgneal@4 150 Creates the external cookie for the Wiki.
bgneal@4 151 Updates the Redis set so the Wiki can verify the cookie.
bgneal@4 152
bgneal@4 153 """
bgneal@4 154 now = datetime.datetime.utcnow()
bgneal@4 155 value = cookie_value(request.user, now)
bgneal@4 156 response.set_cookie(settings.WIKI_COOKIE_NAME,
bgneal@4 157 value=value,
bgneal@4 158 max_age=settings.WIKI_COOKIE_AGE,
bgneal@4 159 domain=settings.WIKI_COOKIE_DOMAIN)
bgneal@4 160
bgneal@4 161 # Update a sorted set in Redis with a hash of our cookie and a score
bgneal@4 162 # of the current time as a timestamp. This allows us to delete old
bgneal@4 163 # entries by score periodically. To verify the cookie, the external wiki
bgneal@4 164 # application computes a hash of the cookie value and checks to see if
bgneal@4 165 # it is in our Redis set.
bgneal@4 166
bgneal@4 167 h = hashlib.sha256()
bgneal@4 168 h.update(value)
bgneal@4 169 name = h.hexdigest()
bgneal@4 170 score = time.mktime(now.utctimetuple())
bgneal@4 171 conn = get_redis_connection()
bgneal@4 172
bgneal@4 173 try:
bgneal@4 174 conn.zadd(settings.WIKI_REDIS_SET, score, name)
bgneal@4 175 except redis.RedisError:
bgneal@4 176 logger.error("Error adding wiki cookie key")
bgneal@4 177
bgneal@4 178 # Store the set member name in the session so we can delete it when the
bgneal@4 179 # user logs out:
bgneal@4 180 request.session[SESSION_SET_MEMBER] = name
bgneal@4 181
bgneal@4 182 We store the name of the Redis set member in the user's session so we can
bgneal@4 183 delete it from Redis when the user logs out. During logout, this set member is
bgneal@4 184 retrieved from the session in the logout signal handler and stored on the
bgneal@4 185 request object. This is because the session will be destroyed after the logout
bgneal@4 186 signal handler runs and before the middleware can access it. The middleware
bgneal@4 187 can check for the existence of this attribute as its cue to delete the wiki
bgneal@4 188 session.
bgneal@4 189
bgneal@4 190 .. sourcecode:: python
bgneal@4 191
bgneal@4 192 def destroy_wiki_session(set_member, response):
bgneal@4 193 """Destroys the session for the external wiki application.
bgneal@4 194
bgneal@4 195 Delete the external cookie.
bgneal@4 196 Deletes the member from the Redis set as this entry is no longer valid.
bgneal@4 197
bgneal@4 198 """
bgneal@4 199 response.delete_cookie(settings.WIKI_COOKIE_NAME,
bgneal@4 200 domain=settings.WIKI_COOKIE_DOMAIN)
bgneal@4 201
bgneal@4 202 if set_member:
bgneal@4 203 conn = get_redis_connection()
bgneal@4 204 try:
bgneal@4 205 conn.zrem(settings.WIKI_REDIS_SET, set_member)
bgneal@4 206 except redis.RedisError:
bgneal@4 207 logger.error("Error deleting wiki cookie set member")
bgneal@4 208
bgneal@4 209 As suggested in the MoinMoin external cookie documentation, I create a cookie
bgneal@4 210 whose value consists of the username, email address, and a key separated by
bgneal@4 211 the ``#`` character. The key is just a string of stuff that makes it difficult
bgneal@4 212 for a spoofer to recreate.
bgneal@4 213
bgneal@4 214 .. sourcecode:: python
bgneal@4 215
bgneal@4 216 def cookie_value(user, now):
bgneal@4 217 """Creates the value for the external wiki cookie."""
bgneal@4 218
bgneal@4 219 # The key part of the cookie is just a string that would make things
bgneal@4 220 # difficult for a spoofer; something that can't be easily made up:
bgneal@4 221
bgneal@4 222 h = hashlib.sha256()
bgneal@4 223 h.update(user.username + user.email)
bgneal@4 224 h.update(now.isoformat())
bgneal@4 225 h.update(''.join(random.sample(string.printable, 64)))
bgneal@4 226 h.update(settings.SECRET_KEY)
bgneal@4 227 key = h.hexdigest()
bgneal@4 228
bgneal@4 229 parts = (user.username, user.email, key)
bgneal@4 230 return '#'.join(parts)
bgneal@4 231
bgneal@4 232 Finally on the Django side we should periodically delete expired Redis set
bgneal@4 233 members in case users do not log out. Since I am using Celery_ with my Django
bgneal@4 234 application, I created a Celery task that runs periodically to delete old set
bgneal@4 235 members. This function is a bit longer than it probably needs to be, but
bgneal@4 236 I wanted to log how big this set is before and after we cull the expired
bgneal@4 237 entries.
bgneal@4 238
bgneal@4 239 .. sourcecode:: python
bgneal@4 240
bgneal@4 241 @task
bgneal@4 242 def expire_cookies():
bgneal@4 243 """
bgneal@4 244 Periodically run this task to remove expired cookies from the Redis set
bgneal@4 245 that is shared between this Django application & the MoinMoin wiki for
bgneal@4 246 authentication.
bgneal@4 247
bgneal@4 248 """
bgneal@4 249 now = datetime.datetime.utcnow()
bgneal@4 250 cutoff = now - datetime.timedelta(seconds=settings.WIKI_COOKIE_AGE)
bgneal@4 251 min_score = time.mktime(cutoff.utctimetuple())
bgneal@4 252
bgneal@4 253 conn = get_redis_connection()
bgneal@4 254
bgneal@4 255 set_name = settings.WIKI_REDIS_SET
bgneal@4 256 try:
bgneal@4 257 count = conn.zcard(set_name)
bgneal@4 258 except redis.RedisError:
bgneal@4 259 logger.error("Error getting zcard")
bgneal@4 260 return
bgneal@4 261
bgneal@4 262 try:
bgneal@4 263 removed = conn.zremrangebyscore(set_name, 0.0, min_score)
bgneal@4 264 except redis.RedisError:
bgneal@4 265 logger.error("Error removing by score")
bgneal@4 266 return
bgneal@4 267
bgneal@4 268 total = count - removed
bgneal@4 269 logger.info("Expire wiki cookies: removed %d, total is now %d",
bgneal@4 270 removed, total)
bgneal@4 271
bgneal@4 272 MoinMoin Implementation
bgneal@4 273 =======================
bgneal@4 274
bgneal@4 275 As described in the MoinMoin external cookie documentation, you have to
bgneal@4 276 configure MoinMoin to use your external cookie authentication mechanism.
bgneal@4 277 It is also nice to disable the ability for the MoinMoin user to change their
bgneal@4 278 username and email address since that is being managed by the Django
bgneal@4 279 application. These changes to the MoinMoin ``Config`` class are shown below.
bgneal@4 280
bgneal@4 281 .. sourcecode:: python
bgneal@4 282
bgneal@4 283 class Config(multiconfig.DefaultConfig):
bgneal@4 284
bgneal@4 285 # ...
bgneal@4 286
bgneal@4 287 # Use ExternalCookie method for integration authentication with Django:
bgneal@4 288 auth = [ExternalCookie(autocreate=True)]
bgneal@4 289
bgneal@4 290 # remove ability to change username & email, etc.
bgneal@4 291 user_form_disable = ['name', 'aliasname', 'email',]
bgneal@4 292 user_form_remove = ['password', 'password2', 'css_url', 'logout', 'create',
bgneal@4 293 'account_sendmail', 'jid']
bgneal@4 294
bgneal@4 295 Next we create an ``ExternalCookie`` class and associated helper functions to
bgneal@4 296 process the cookie and verify it in Redis. This code is shown in its entirety
bgneal@4 297 below. It is based off the example in the MoinMoin external cookie
bgneal@4 298 documentation, but uses Redis as the shared storage area.
bgneal@4 299
bgneal@4 300 .. sourcecode:: python
bgneal@4 301
bgneal@4 302 import hashlib
bgneal@4 303 import Cookie
bgneal@4 304 import logging
bgneal@4 305
bgneal@4 306 from MoinMoin.auth import BaseAuth
bgneal@4 307 from MoinMoin.user import User
bgneal@4 308 import redis
bgneal@4 309
bgneal@4 310 COOKIE_NAME = 'YOUR_COOKIE_NAME_HERE'
bgneal@4 311
bgneal@4 312 # Redis connection and database settings
bgneal@4 313 REDIS_HOST = 'localhost'
bgneal@4 314 REDIS_PORT = 6379
bgneal@4 315 REDIS_DB = 0
bgneal@4 316
bgneal@4 317 # The name of the set in Redis that holds cookie hashes
bgneal@4 318 REDIS_SET = 'wiki_cookie_keys'
bgneal@4 319
bgneal@4 320 logger = logging.getLogger(__name__)
bgneal@4 321
bgneal@4 322
bgneal@4 323 def get_cookie_value(cookie):
bgneal@4 324 """Returns the value of the Django cookie from the cookie.
bgneal@4 325 None is returned if the cookie is invalid or the value cannot be
bgneal@4 326 determined.
bgneal@4 327
bgneal@4 328 This function works around an issue with different Python versions.
bgneal@4 329 In Python 2.5, if you construct a SimpleCookie with a dict, then
bgneal@4 330 type(cookie[key]) == unicode
bgneal@4 331 whereas in later versions of Python:
bgneal@4 332 type(cookie[key]) == Cookie.Morsel
bgneal@4 333 """
bgneal@4 334 if cookie:
bgneal@4 335 try:
bgneal@4 336 morsel = cookie[COOKIE_NAME]
bgneal@4 337 except KeyError:
bgneal@4 338 return None
bgneal@4 339
bgneal@4 340 if isinstance(morsel, unicode): # Python 2.5
bgneal@4 341 return morsel
bgneal@4 342 elif isinstance(morsel, Cookie.Morsel): # Python 2.6+
bgneal@4 343 return morsel.value
bgneal@4 344
bgneal@4 345 return None
bgneal@4 346
bgneal@4 347
bgneal@4 348 def get_redis_connection(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB):
bgneal@4 349 """
bgneal@4 350 Create and return a Redis connection using the supplied parameters.
bgneal@4 351
bgneal@4 352 """
bgneal@4 353 return redis.StrictRedis(host=host, port=port, db=db)
bgneal@4 354
bgneal@4 355
bgneal@4 356 def validate_cookie(value):
bgneal@4 357 """Determines if cookie was created by Django. Returns True on success,
bgneal@4 358 False on failure.
bgneal@4 359
bgneal@4 360 Looks up the hash of the cookie value in Redis. If present, cookie
bgneal@4 361 is deemed legit.
bgneal@4 362
bgneal@4 363 """
bgneal@4 364 h = hashlib.sha256()
bgneal@4 365 h.update(value)
bgneal@4 366 set_member = h.hexdigest()
bgneal@4 367
bgneal@4 368 conn = get_redis_connection()
bgneal@4 369 success = False
bgneal@4 370 try:
bgneal@4 371 score = conn.zscore(REDIS_SET, set_member)
bgneal@4 372 success = score is not None
bgneal@4 373 except redis.RedisError:
bgneal@4 374 logger.error('Could not check Redis for ExternalCookie auth')
bgneal@4 375
bgneal@4 376 return success
bgneal@4 377
bgneal@4 378
bgneal@4 379 class ExternalCookie(BaseAuth):
bgneal@4 380 name = 'external_cookie'
bgneal@4 381
bgneal@4 382 def __init__(self, autocreate=False):
bgneal@4 383 self.autocreate = autocreate
bgneal@4 384 BaseAuth.__init__(self)
bgneal@4 385
bgneal@4 386 def request(self, request, user_obj, **kwargs):
bgneal@4 387 user = None
bgneal@4 388 try_next = True
bgneal@4 389
bgneal@4 390 try:
bgneal@4 391 cookie = Cookie.SimpleCookie(request.cookies)
bgneal@4 392 except Cookie.CookieError:
bgneal@4 393 cookie = None
bgneal@4 394
bgneal@4 395 val = get_cookie_value(cookie)
bgneal@4 396 if val:
bgneal@4 397 try:
bgneal@4 398 username, email, _ = val.split('#')
bgneal@4 399 except ValueError:
bgneal@4 400 return user, try_next
bgneal@4 401
bgneal@4 402 if validate_cookie(val):
bgneal@4 403 user = User(request, name=username, auth_username=username,
bgneal@4 404 auth_method=self.name)
bgneal@4 405
bgneal@4 406 changed = False
bgneal@4 407 if email != user.email:
bgneal@4 408 user.email = email
bgneal@4 409 changed = True
bgneal@4 410
bgneal@4 411 if user:
bgneal@4 412 user.create_or_update(changed)
bgneal@4 413 if user and user.valid:
bgneal@4 414 try_next = False
bgneal@4 415
bgneal@4 416 return user, try_next
bgneal@4 417
bgneal@4 418 Conclusion
bgneal@4 419 ==========
bgneal@4 420
bgneal@4 421 I've been running this setup for a month now and it is working great. My users
bgneal@4 422 and I are enjoying our shiny new MoinMoin wiki integrated with our Django
bgneal@4 423 powered community website. The single sign-on experience is quite seamless and
bgneal@4 424 eliminates the need for separate accounts.
bgneal@4 425
bgneal@4 426
bgneal@4 427 .. _SurfGuitar101.com: http://surfguitar101.com
bgneal@4 428 .. _Django: https://www.djangoproject.com
bgneal@4 429 .. _MediaWiki: http://www.mediawiki.org
bgneal@4 430 .. _MoinMoin: http://moinmo.in/
bgneal@4 431 .. _Python: http://www.python.org
bgneal@4 432 .. _Python.org wiki: http://wiki.python.org/moin/
bgneal@4 433 .. _Mercurial wiki: http://mercurial.selenic.com/wiki/
bgneal@4 434 .. _omniORB wiki: http://www.omniorb-support.com/omniwiki
bgneal@4 435 .. _authentication system: http://moinmo.in/HelpOnAuthentication
bgneal@4 436 .. _external cookie authentication: http://moinmo.in/HelpOnAuthentication/ExternalCookie
bgneal@4 437 .. _login and logout signals: https://docs.djangoproject.com/en/1.4/topics/auth/#login-and-logout-signals
bgneal@4 438 .. _HttpResponse: https://docs.djangoproject.com/en/1.4/ref/request-response/#httpresponse-objects
bgneal@4 439 .. _middleware: https://docs.djangoproject.com/en/1.4/topics/http/middleware/
bgneal@4 440 .. _Redis: http://redis.io/
bgneal@4 441 .. _Bitbucket repository: https://bitbucket.org/bgneal/sg101
bgneal@4 442 .. _wiki application in bitbucket: https://bitbucket.org/bgneal/sg101/src/a5b8f25e1752faf71ed429ec7f22ff6f3b3dc851/wiki?at=default
bgneal@4 443 .. _Celery: http://celeryproject.org/