TaiwanSettings: openid_auth.py

File openid_auth.py, 26.5 KB (added by marr, 14 years ago)
Line 
1#!/usr/bin/env python
2# coding: utf8
3
4"""
5 OpenID authentication for web2py
6
7 Allowed using OpenID login together with web2py built-in login.
8
9 By default, to support OpenID login, put this in your db.py
10
11 >>> from gluon.contrib.login_methods.openid_auth import OpenIDAuth
12 >>> auth.settings.login_form = OpenIDAuth(auth)
13
14 To show OpenID list in user profile, you can add the following code
15 before the end of function user() of your_app/controllers/default.py
16
17 + if (request.args and request.args(0) == "profile"):
18 + form = DIV(form, openid_login_form.list_user_openids())
19 return dict(form=form, login_form=login_form, register_form=register_form, self_registration=self_registration)
20
21 More detail in the description of the class OpenIDAuth.
22
23 Requirements:
24 python-openid version 2.2.5 or later
25
26 Reference:
27 * w2p openID
28 http://w2popenid.appspot.com/init/default/wiki/w2popenid
29 * RPX and web2py auth module
30 http://www.web2pyslices.com/main/slices/take_slice/28
31 * built-in file: gluon/contrib/login_methods/rpx_account.py
32 * built-in file: gluon/tools.py (Auth class)
33"""
34import time
35from datetime import datetime, timedelta
36
37from gluon.html import *
38from gluon.http import redirect
39from gluon.storage import Storage, Messages
40from gluon.sql import Field, SQLField
41from gluon.validators import IS_NOT_EMPTY, IS_NOT_IN_DB
42
43try:
44 import openid.consumer.consumer
45 from openid.association import Association
46 from openid.store.interface import OpenIDStore
47 from openid.extensions.sreg import SRegRequest, SRegResponse
48 from openid.store import nonce
49 from openid.consumer.discover import DiscoveryFailure
50except ImportError, err:
51 raise ImportError("OpenIDAuth requires python-openid package")
52
53DEFAULT = lambda: None
54
55class OpenIDAuth(object):
56 """
57 OpenIDAuth
58
59 It supports the logout_url, implementing the get_user and login_form
60 for cas usage of gluon.tools.Auth.
61
62 It also uses the ExtendedLoginForm to allow the OpenIDAuth login_methods
63 combined with the standard logon/register procedure.
64
65 It uses OpenID Consumer when render the form and begins the OpenID
66 authentication.
67
68 Example: (put these code after auth.define_tables() in your models.)
69
70 auth = Auth(globals(), db) # authentication/authorization
71 ...
72 auth.define_tables() # creates all needed tables
73 ...
74
75 #include in your model after auth has been defined
76 from gluon.contrib.login_methods.openid_auth import OpenIDAuth
77 openid_login_form = OpenIDAuth(request, auth, db)
78
79 from gluon.contrib.login_methods.extended_login_form import ExtendedLoginForm
80 extended_login_form = ExtendedLoginForm(request, auth, openid_login_form,
81 signals=['oid','janrain_nonce'])
82
83 auth.settings.login_form = extended_login_form
84 """
85
86 def __init__(self, auth):
87 self.auth = auth
88 self.db = auth.db
89 self.environment = auth.environment
90
91 request = self.environment.request
92 self.nextvar = '_next'
93 self.realm = 'http://%s' % request.env.http_host
94 self.login_url = auth.environment.URL(r=request, f='user', args=['login'])
95 self.return_to_url = self.realm + self.login_url
96
97 self.table_alt_logins_name = "alt_logins"
98 if not auth.settings.table_user:
99 raise
100 self.table_user = self.auth.settings.table_user
101 self.openid_expiration = 15 #minutes
102
103 self.messages = self._define_messages()
104
105 if not self.table_alt_logins_name in self.db.tables:
106 self._define_alt_login_table()
107
108 def _define_messages(self):
109 messages = Messages(self.environment.T)
110 messages.label_alt_login_username = 'Sign-in with OpenID: '
111 messages.label_add_alt_login_username = 'Add a new OpenID: '
112 messages.submit_button = 'Sign in'
113 messages.submit_button_add = 'Add'
114 messages.a_delete = 'Delete'
115 messages.comment_openid_signin = 'More about OpenID'
116 messages.comment_openid_help_title = 'Start using your OpenID'
117 messages.comment_openid_help_url = 'http://openid.net/get-an-openid/start-using-your-openid/'
118 messages.openid_fail_discover = 'Failed to discover OpenID service. Check your OpenID or "More about OpenID"?'
119 messages.flash_openid_expired = 'OpenID expired. Please login or authenticate OpenID again. Sorry for the inconvenient.'
120 messages.flash_openid_associated = 'OpenID associated'
121 messages.flash_associate_openid = 'Please login or register an account for this OpenID.'
122 messages.p_openid_not_registered = ("There is no Sahana account associated with that OpenID. "
123 + "Would you like to create one?")
124 messages.flash_openid_authenticated = 'OpenID authenticated successfully.'
125 messages.flash_openid_fail_authentication = 'OpenID authentication failed. (Error message: %s)'
126 messages.flash_openid_canceled = 'OpenID authentication canceled by user.'
127 messages.flash_openid_need_setup = 'OpenID authentication needs to be setup by the user with the provider first.'
128 messages.h_openid_login = 'OpenID Login'
129 messages.h_openid_list = 'OpenID List'
130 return messages
131
132 def _define_alt_login_table(self):
133 """
134 Define the OpenID login table.
135 Note: type is what I used for our project. We're going to support 'fackbook' and
136 'plurk' alternate login methods. Otherwise it's always 'openid' and you
137 may not need it. This should be easy to changed.
138 (Just remove the field of "type" and remove the
139 "and db.alt_logins.type == type_" in _find_matched_openid function)
140 """
141 db = self.db
142 table = db.define_table(
143 self.table_alt_logins_name,
144 Field('username', length=512, default=''),
145 Field('type', length=128, default='openid', readable=False),
146 Field('auth_user', self.table_user, readable=False),
147 )
148 table.username.requires = IS_NOT_IN_DB(db, table.username)
149 self.table_alt_logins = table
150
151 def logout_url(self, next):
152 """
153 Delete the w2popenid record in session as logout
154 """
155 if self.environment.session.w2popenid:
156 del(self.environment.session.w2popenid)
157 return next
158
159 def login_form(self):
160 """
161 Start to process the OpenID response if 'janrain_nonce' in request parameters
162 and not processed yet. Else return the OpenID form for login.
163 """
164 request = self.environment.request
165 if request.vars.has_key('janrain_nonce') and not self._processed():
166 self._process_response()
167 return self.auth()
168 return self._form()
169
170 def get_user(self):
171 """
172 It supports the logout_url, implementing the get_user and login_form
173 for cas usage of gluon.tools.Auth.
174 """
175 environment = self.environment
176 request = environment.request
177 args = request.args
178
179 if args[0] == 'logout':
180 return True # Let logout_url got called
181
182 if environment.session.w2popenid:
183 w2popenid = environment.session.w2popenid
184 db = self.db
185 if (w2popenid.ok is True and w2popenid.oid): # OpenID authenticated
186 if self._w2popenid_expired(w2popenid): # OpenID expired
187 del(self.environment.session.w2popenid)
188 flash = self.messages.flash_openid_expired
189 environment.session.warning = flash
190 redirect(self.auth.settings.login_url)
191
192 oid = self._remove_protocol(w2popenid.oid)
193 alt_login = self._find_matched_openid(db, oid)
194
195 nextvar = self.nextvar
196 # This OpenID not in the database. If user logged in then add it
197 # into database, else ask user to login or register.
198 if not alt_login:
199 if self.auth.is_logged_in():
200 # TODO: ask first maybe
201 self._associate_user_openid(self.auth.user, oid)
202 if self.environment.session.w2popenid:
203 del(self.environment.session.w2popenid)
204 environment.session.flash = self.messages.flash_openid_associated
205 if request.vars.has_key(nextvar):
206 redirect(request.vars[nextvar])
207 redirect(self.auth.settings.login_next)
208
209 if not request.vars.has_key(nextvar):
210 if ('first_time_login' not in w2popenid or
211 w2popenid.first_time_login == True):
212 page = 'register'
213 w2popenid.first_time_login = False
214 else:
215 page = 'login'
216 # no next var, add it and do login again
217 # so if user login or register can go back here to associate the OpenID
218 environment.session.flash = self.messages.p_openid_not_registered
219 redirect(self.environment.URL(r=request,
220 args=[page],
221 vars={nextvar:self.login_url}))
222 #self.login_form = self._form_with_notification()
223 if self.auth.settings.login_form in (self.auth, self):
224 self.auth.settings.login_form = self.auth
225 self.login_form = self.auth()
226 #environment.session.flash = self.messages.flash_associate_openid
227 return None # need to login or register to associate this openid
228
229 # Get existed OpenID user
230 user = db(self.table_user.id==alt_login.auth_user).select().first()
231 if user:
232 if self.environment.session.w2popenid:
233 del(self.environment.session.w2popenid)
234 if 'username' in self.table_user.fields():
235 username = 'username'
236 elif 'email' in self.table_user.fields():
237 username = 'email'
238 return {username: user[username]} if user else None # login success (almost)
239
240 return None # just start to login
241
242 def _find_matched_openid(self, db, oid, type_='openid'):
243 """
244 Get the matched OpenID for given
245 """
246 query = ((db.alt_logins.username == oid) & (db.alt_logins.type == type_))
247 alt_login = db(query).select().first() # Get the OpenID record
248 return alt_login
249
250 def _associate_user_openid(self, user, oid):
251 """
252 Associate the user logged in with given OpenID
253 """
254 print "[DB] %s authenticated" % oid
255 self.db.alt_logins.insert(username=oid, auth_user=user)
256
257 def _form_with_notification(self):
258 """
259 Render the form for normal login with a notice of OpenID authenticated
260 """
261 form = DIV()
262 # TODO: check when will happen
263 if self.auth.settings.login_form in (self.auth, self):
264 self.auth.settings.login_form = self.auth
265 form = DIV(self.auth())
266
267 register_note = DIV(P(B(self.messages.p_openid_not_registered)))
268 form.components.append(register_note)
269 return lambda: form
270
271 def _remove_protocol(self, oid):
272 """
273 Remove https:// or http:// from oid url
274 """
275 protocol = 'https://'
276 if oid.startswith(protocol):
277 oid = oid[len(protocol):]
278 return oid
279 protocol = 'http://'
280 if oid.startswith(protocol):
281 oid = oid[len(protocol):]
282 return oid
283 return oid
284
285 def _init_consumerhelper(self):
286 """
287 Initialize the ConsumerHelper
288 """
289 if not hasattr(self, "consumerhelper"):
290 self.consumerhelper = ConsumerHelper(self.environment.session,
291 self.db)
292 return self.consumerhelper
293
294
295 def _form(self, style=None):
296 w2popenid = self.environment.session.w2popenid
297 if (w2popenid and w2popenid.ok is True and w2popenid.oid): # OpenID authenticated
298 oid = self._remove_protocol(w2popenid.oid)
299 form = P('OpenID: ' + oid, ' ', A("Remove",
300 _href=self.environment.URL(r=self.environment.request,
301 args=['logout'],
302 vars={self.nextvar:self.login_url})
303 ))
304 return form
305
306 form = DIV(H3(self.messages.h_openid_login), self._login_form(style))
307 return form
308
309 def _login_form(self,
310 openid_field_label=None,
311 submit_button=None,
312 _next=None,
313 style=None):
314 """
315 Render the form for OpenID login
316 """
317 def warning_openid_fail(session):
318 session.warning = messages.openid_fail_discover
319
320 style = style or """
321background-attachment: scroll;
322background-repeat: no-repeat;
323background-image: url("http://wiki.openid.net/f/openid-16x16.gif");
324background-position: 0% 50%;
325background-color: transparent;
326padding-left: 18px;
327width: 400px;
328"""
329 style = style.replace("\n","")
330
331 request = self.environment.request
332 session = self.environment.session
333 messages = self.messages
334 hidden_next_input = ""
335 if _next == 'profile':
336 profile_url = self.environment.URL(r=request, f='user', args=['profile'])
337 hidden_next_input = INPUT(_type="hidden", _name="_next", _value=profile_url)
338 form = FORM(openid_field_label or self.messages.label_alt_login_username,
339 INPUT(_type="input", _name="oid",
340 requires=IS_NOT_EMPTY(error_message=messages.openid_fail_discover),
341 _style=style),
342 hidden_next_input,
343 INPUT(_type="submit", _value=submit_button or messages.submit_button),
344 " ",
345 A(messages.comment_openid_signin,
346 _href=messages.comment_openid_help_url,
347 _title=messages.comment_openid_help_title,
348 _class='openid-identifier',
349 _target="_blank"),
350 _action=self.login_url
351 )
352 if form.accepts(request.vars, session):
353 oid = request.vars.oid
354 consumerhelper = self._init_consumerhelper()
355 url = self.login_url
356 return_to_url = self.return_to_url
357 if not oid:
358 warning_openid_fail(session)
359 redirect(url)
360 try:
361 if request.vars.has_key('_next'):
362 return_to_url = self.return_to_url + '?_next=' + request.vars._next
363 url = consumerhelper.begin(oid, self.realm, return_to_url)
364 except DiscoveryFailure:
365 warning_openid_fail(session)
366 redirect(url)
367 return form
368
369 def _processed(self):
370 """
371 Check if w2popenid authentication is processed.
372 Return True if processed else False.
373 """
374 processed = (hasattr(self.environment.session, 'w2popenid') and
375 self.environment.session.w2popenid.ok is True)
376 return processed
377
378 def _set_w2popenid_expiration(self, w2popenid):
379 """
380 Set expiration for OpenID authentication.
381 """
382 w2popenid.expiration = datetime.now() + timedelta(minutes=self.openid_expiration)
383
384 def _w2popenid_expired(self, w2popenid):
385 """
386 Check if w2popenid authentication is expired.
387 Return True if expired else False.
388 """
389 return (not w2popenid.expiration) or (datetime.now() > w2popenid.expiration)
390
391 def _process_response(self):
392 """
393 Process the OpenID by ConsumerHelper.
394 """
395 environment = self.environment
396 request = environment.request
397 request_vars = request.vars
398 consumerhelper = self._init_consumerhelper()
399 process_status = consumerhelper.process_response(request_vars, self.return_to_url)
400 if process_status == "success":
401 w2popenid = environment.session.w2popenid
402 user_data = self.consumerhelper.sreg()
403 environment.session.w2popenid.ok = True
404 self._set_w2popenid_expiration(w2popenid)
405 w2popenid.user_data = user_data
406 environment.session.flash = self.messages.flash_openid_authenticated
407 elif process_status == "failure":
408 flash = self.messages.flash_openid_fail_authentication % consumerhelper.error_message
409 environment.session.warning = flash
410 elif process_status == "cancel":
411 environment.session.warning = self.messages.flash_openid_canceled
412 elif process_status == "setup_needed":
413 environment.session.warning = self.messages.flash_openid_need_setup
414
415 def list_user_openids(self):
416 messages = self.messages
417 environment = self.environment
418 request = environment.request
419 if request.vars.has_key('delete_openid'):
420 self.remove_openid(request.vars.delete_openid)
421
422 query = self.db.alt_logins.auth_user == self.auth.user.id
423 alt_logins = self.db(query).select()
424 l = []
425 for alt_login in alt_logins:
426 username = alt_login.username
427 delete_href = environment.URL(r=request, f='user',
428 args=['profile'],
429 vars={'delete_openid': username})
430 delete_link = A(messages.a_delete, _href=delete_href)
431 l.append(LI(username, " ", delete_link))
432
433 profile_url = environment.URL(r=request, f='user', args=['profile'])
434 #return_to_url = self.return_to_url + '?' + self.nextvar + '=' + profile_url
435 openid_list = DIV(H3(messages.h_openid_list), UL(l),
436 self._login_form(
437 _next='profile',
438 submit_button=messages.submit_button_add,
439 openid_field_label=messages.label_add_alt_login_username)
440 )
441 return openid_list
442
443
444 def remove_openid(self, openid):
445 query = self.db.alt_logins.username == openid
446 self.db(query).delete()
447
448class ConsumerHelper(object):
449 """
450 ConsumerHelper knows the python-openid and
451 """
452
453 def __init__(self, session, db):
454 self.session = session
455 store = self._init_store(db)
456 self.consumer = openid.consumer.consumer.Consumer(session, store)
457
458 def _init_store(self, db):
459 """
460 Initialize Web2pyStore
461 """
462 if not hasattr(self, "store"):
463 store = Web2pyStore(db)
464 session = self.session
465 if not session.has_key('w2popenid'):
466 session.w2popenid = Storage()
467 self.store = store
468 return self.store
469
470 def begin(self, oid, realm, return_to_url):
471 """
472 Begin the OpenID authentication
473 """
474 w2popenid = self.session.w2popenid
475 w2popenid.oid = oid
476 auth_req = self.consumer.begin(oid)
477 auth_req.addExtension(SRegRequest(required=['email','nickname']))
478 url = auth_req.redirectURL(return_to=return_to_url, realm=realm)
479 return url
480
481 def process_response(self, request_vars, return_to_url):
482 """
483 Complete the process and
484 """
485 resp = self.consumer.complete(request_vars, return_to_url)
486 if resp:
487 if resp.status == openid.consumer.consumer.SUCCESS:
488 self.resp = resp
489 if hasattr(resp, "identity_url"):
490 self.session.w2popenid.oid = resp.identity_url
491 return "success"
492 if resp.status == openid.consumer.consumer.FAILURE:
493 self.error_message = resp.message
494 return "failure"
495 if resp.status == openid.consumer.consumer.CANCEL:
496 return "cancel"
497 if resp.status == openid.consumer.consumer.SETUP_NEEDED:
498 return "setup_needed"
499 return "no resp"
500
501 def sreg(self):
502 """
503 Try to get OpenID Simple Registation
504 http://openid.net/specs/openid-simple-registration-extension-1_0.html
505 """
506 if self.resp:
507 resp = self.resp
508 sreg_resp = SRegResponse.fromSuccessResponse(resp)
509 return sreg_resp.data if sreg_resp else None
510 else:
511 return None
512
513
514class Web2pyStore(OpenIDStore):
515 """
516 Web2pyStore
517
518 This class implements the OpenIDStore interface. OpenID stores take care
519 of persisting nonces and associations. The Janrain Python OpenID library
520 comes with implementations for file and memory storage. Web2pyStore uses
521 the web2py db abstration layer. See the source code docs of OpenIDStore
522 for a comprehensive description of this interface.
523 """
524
525 def __init__(self, database):
526 self.database = database
527 self.table_oid_associations_name = 'oid_associations'
528 self.table_oid_nonces_name = 'oid_nonces'
529 self._initDB()
530
531 def _initDB(self):
532
533 if self.table_oid_associations_name not in self.database:
534 self.database.define_table(self.table_oid_associations_name,
535 SQLField('server_url', 'string', length=2047, required=True),
536 SQLField('handle', 'string', length=255, required=True),
537 SQLField('secret', 'blob', required=True),
538 SQLField('issued', 'integer', required=True),
539 SQLField('lifetime', 'integer', required=True),
540 SQLField('assoc_type', 'string', length=64, required=True)
541 )
542 if self.table_oid_nonces_name not in self.database:
543 self.database.define_table(self.table_oid_nonces_name,
544 SQLField('server_url', 'string', length=2047, required=True),
545 SQLField('time_stamp', 'integer', required=True),
546 SQLField('salt', 'string', length=40, required=True)
547 )
548
549 def storeAssociation(self, server_url, association):
550 """
551 Store associations. If there already is one with the same
552 server_url and handle in the table replace it.
553 """
554
555 db = self.database
556 query = (db.oid_associations.server_url == server_url) & (db.oid_associations.handle == association.handle)
557 db(query).delete()
558 db.oid_associations.insert(server_url = server_url,
559 handle = association.handle,
560 secret = association.secret,
561 issued = association.issued,
562 lifetime = association.lifetime,
563 assoc_type = association.assoc_type), 'insert '*10
564
565 def getAssociation(self, server_url, handle=None):
566 """
567 Return the association for server_url and handle. If handle is
568 not None return the latests associations for that server_url.
569 Return None if no association can be found.
570 """
571
572 db = self.database
573 query = (db.oid_associations.server_url == server_url)
574 if handle:
575 query &= (db.oid_associations.handle == handle)
576 rows = db(query).select(orderby=db.oid_associations.issued)
577 keep_assoc, _ = self._removeExpiredAssocations(rows)
578 if len(keep_assoc) == 0:
579 return None
580 else:
581 assoc = keep_assoc.pop() # pop the last one as it should be the latest one
582 return Association(assoc['handle'],
583 assoc['secret'],
584 assoc['issued'],
585 assoc['lifetime'],
586 assoc['assoc_type'])
587
588 def removeAssociation(self, server_url, handle):
589 db = self.database
590 query = (db.oid_associations.server_url == server_url) & (db.oid_associations.handle == handle)
591 return db(query).delete() != None
592
593 def useNonce(self, server_url, time_stamp, salt):
594 """
595 This method returns Falase if a nonce has been used before or its
596 time_stamp is not current.
597 """
598
599 db = self.database
600 if abs(time_stamp - time.time()) > nonce.SKEW:
601 return False
602 query = (db.oid_nonces.server_url == server_url) & (db.oid_nonces.time_stamp == time_stamp) & (db.oid_nonces.salt == salt)
603 if db(query).count() > 0:
604 return False
605 else:
606 db.oid_nonces.insert(server_url = server_url,
607 time_stamp = time_stamp,
608 salt = salt)
609 return True
610
611 def _removeExpiredAssocations(self, rows):
612 """
613 This helper function is not part of the interface. Given a list of
614 association rows it checks which associations have expired and
615 deletes them from the db. It returns a tuple of the form
616 ([valid_assoc], no_of_expired_assoc_deleted).
617 """
618
619 db = self.database
620 keep_assoc = []
621 remove_assoc = []
622 t1970 = time.time()
623 for r in rows:
624 if r['issued'] + r['lifetime'] < t1970:
625 remove_assoc.append(r)
626 else:
627 keep_assoc.append(r)
628 for r in remove_assoc:
629 del db.oid_associations[r['id']]
630 return (keep_assoc, len(remove_assoc)) # return tuple (list of valid associations, number of deleted associations)
631
632 def cleanupNonces(self):
633 """
634 Remove expired nonce entries from DB and return the number
635 of entries deleted.
636 """
637
638 db = self.database
639 query = (db.oid_nonces.time_stamp < time.time() - nonce.SKEW)
640 return db(query).delete()
641
642 def cleanupAssociations(self):
643 """
644 Remove expired associations from db and return the number
645 of entries deleted.
646 """
647
648 db = self.database
649 query = (db.oid_associations.id > 0)
650 return self._removeExpiredAssocations(db(query).select())[1] #return number of assoc removed
651
652 def cleanup(self):
653 """
654 This method should be run periodically to free the db from
655 expired nonce and association entries.
656 """
657
658 return self.cleanupNonces(), self.cleanupAssociations()
659