Mercurial > public > pelican-blog
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/ |