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