remove use of 1.6.4-compatible 'wrapper'
[gae-samples.git] / openid-consumer / consumer.py
blob1423374b0df6fea486876a66205d14c7cb591957
1 #!/usr/bin/python
3 # Copyright 2008 Google Inc.
5 # Licensed under the Apache License, Version 2.0 (the "License");
6 # you may not use this file except in compliance with the License.
7 # You may obtain a copy of the License at
9 # http://www.apache.org/licenses/LICENSE-2.0
11 # Unless required by applicable law or agreed to in writing, software
12 # distributed under the License is distributed on an "AS IS" BASIS,
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 # See the License for the specific language governing permissions and
15 # limitations under the License.
17 """
18 A sample OpenID consumer app for Google App Engine. Allows users to log into
19 other OpenID providers, then displays their OpenID login. Also stores and
20 displays the most recent logins.
22 Part of http://code.google.com/p/google-app-engine-samples/.
24 For more about OpenID, see:
25 http://openid.net/
26 http://openid.net/about.bml
28 Uses JanRain's Python OpenID library, version 2.1.1, licensed under the
29 Apache Software License 2.0:
30 http://openidenabled.com/python-openid/
32 The JanRain library includes a reference OpenID provider that can be used to
33 test this consumer. After starting the dev_appserver with this app, unpack the
34 JanRain library and run these commands from its root directory:
36 setenv PYTHONPATH .
37 python ./examples/server.py -s localhost
39 Then go to http://localhost:8080/ in your browser, type in
40 http://localhost:8000/test as your OpenID identifier, and click Verify.
41 """
43 import datetime
44 import logging
45 import os
46 import re
47 import sys
48 import urlparse
49 import wsgiref.handlers
51 from google.appengine.ext import db
52 from google.appengine.ext import webapp
53 from google.appengine.ext.webapp import template
55 from openid import fetchers
56 from openid.consumer.consumer import Consumer
57 from openid.consumer import discover
58 from openid.extensions import pape, sreg
59 import fetcher
60 import store
62 # Set to True if stack traces should be shown in the browser, etc.
63 _DEBUG = False
66 class Session(db.Expando):
67 """An in-progress OpenID login."""
68 claimed_id = db.StringProperty()
69 server_url = db.LinkProperty()
70 store_and_display = db.BooleanProperty()
73 class Login(db.Model):
74 """A completed OpenID login."""
75 status = db.StringProperty(choices=('success', 'cancel', 'failure'))
76 claimed_id = db.LinkProperty()
77 server_url = db.LinkProperty()
78 timestamp = db.DateTimeProperty(auto_now_add=True)
79 session = db.ReferenceProperty(Session)
82 class Handler(webapp.RequestHandler):
83 """A base handler class with a couple OpenID-specific utilities."""
84 consumer = None
85 session = None
86 session_args = None
88 def __init__(self):
89 self.session_args = {}
91 def get_consumer(self):
92 """Returns a Consumer instance.
93 """
94 if not self.consumer:
95 fetchers.setDefaultFetcher(fetcher.UrlfetchFetcher())
96 if not self.load_session():
97 return
98 self.consumer = Consumer(self.session_args, store.DatastoreStore())
100 return self.consumer
102 def args_to_dict(self):
103 """Converts the URL and POST parameters to a singly-valued dictionary.
105 Returns:
106 dict with the URL and POST body parameters
108 req = self.request
109 return dict([(arg, req.get(arg)) for arg in req.arguments()])
111 def load_session(self):
112 """Loads the current session.
114 if not self.session:
115 id = self.request.get('session_id')
116 if id:
117 try:
118 self.session = db.get(db.Key.from_path('Session', int(id)))
119 assert self.session
120 except (AssertionError, db.Error), e:
121 self.report_error('Invalid session id: %d' % id)
122 return None
124 fields = self.session.dynamic_properties()
125 self.session_args = dict((f, getattr(self.session, f)) for f in fields)
127 else:
128 self.session_args = {}
129 self.session = Session()
130 self.session.claimed_id = self.request.get('openid')
132 return self.session
134 def store_session(self):
135 """Stores the current session.
137 assert self.session
138 for field, value in self.session_args.items():
139 setattr(self.session, field, value)
140 self.session.put()
142 def render(self, extra_values={}):
143 """Renders the page, including the extra (optional) values.
145 Args:
146 template_name: string
147 The template to render.
149 extra_values: dict
150 Template values to provide to the template.
152 logins = Login.gql('ORDER BY timestamp DESC').fetch(20)
153 for login in logins:
154 login.display_name = self.display_name(login.claimed_id)
155 login.friendly_time = self.relative_time(login.timestamp)
157 values = {
158 'response': {},
159 'openid': '',
160 'logins': logins,
162 values.update(extra_values)
163 cwd = os.path.dirname(__file__)
164 path = os.path.join(cwd, 'templates', 'base.html')
165 self.response.out.write(template.render(path, values, debug=_DEBUG))
167 def report_error(self, message, exception=None):
168 """Shows an error HTML page.
170 Args:
171 message: string
172 A detailed error message.
174 if exception:
175 logging.exception('Error: %s' % message)
176 self.render({'error': message})
178 def show_front_page(self):
179 """Do an internal (non-302) redirect to the front page.
181 Preserves the user agent's requested URL.
183 front_page = FrontPage()
184 front_page.request = self.request
185 front_page.response = self.response
186 front_page.get()
188 def relative_time(self, timestamp):
189 """Returns a friendly string describing how long ago the timestamp was.
191 Args:
192 timestamp: a datetime
194 Returns:
195 string
197 def format_number(num):
198 if num <= 9:
199 return {1: 'one', 2: 'two', 3: 'three', 4: 'four', 5: 'five',
200 6: 'six', 7: 'seven', 8: 'eight', 9: 'nine'}[num]
201 else:
202 return str(num)
204 delta = datetime.datetime.now() - timestamp
205 minutes = delta.seconds / 60
206 hours = minutes / 60
208 if delta.days > 1:
209 return '%s days ago' % format_number(delta.days)
210 elif delta.days == 1:
211 return 'yesterday'
212 elif hours > 1:
213 return '%s hours ago' % format_number(hours)
214 elif hours == 1:
215 return 'an hour ago'
216 elif minutes > 25:
217 return 'half an hour ago'
218 elif minutes > 5:
219 return '%s minutes ago' % format_number(minutes)
220 else:
221 return 'moments ago'
223 def display_name(self, openid_url):
224 """Extracts a short, representative part of an OpenID URL for display.
226 For example, it returns "ryan" for:
227 ryan.com
228 www.ryan.com
229 ryan.provider.com
230 provider.com/ryan
231 provider.com/id/path/ryan
233 Adapted from Net::OpenID::Consumer, by Brad Fitzpatrick. See:
235 http://code.sixapart.com/svn/openid/trunk/perl/Net-OpenID-Consumer/lib/Net/OpenID/VerifiedIdentity.pm
237 Args:
238 openid_url: string
240 Returns:
241 string
243 if not openid_url:
244 return 'None'
246 username_re = '[\w.+-]+'
248 scheme, host, path, params, query, frag = urlparse.urlparse(openid_url)
250 def sanitize(display_name):
251 if '@' in display_name:
252 # don't display full email addresses; use just the user name part
253 display_name = display_name[:display_name.index('@')]
254 return display_name
256 # is the username in the params?
257 match = re.search('(u|id|user|userid|user_id|profile)=(%s)' % username_re,
258 path)
259 if match:
260 return sanitize(match.group(2))
262 # is the username in the path?
263 path = path.split('/')
264 if re.match(username_re, path[-1]):
265 return sanitize(path[-1])
267 # use the hostname
268 host = host.split('.')
269 if len(host) == 1:
270 return host[0]
272 # strip common tlds and country code tlds
273 common_tlds = ('com', 'org', 'net', 'edu', 'info', 'biz', 'gov', 'mil')
274 if host[-1] in common_tlds or len(host[-1]) == 2:
275 host = host[:-1]
276 if host[-1] == 'co':
277 host = host[:-1]
279 # strip www prefix
280 if host[0] == 'www':
281 host = host[1:]
283 return sanitize('.'.join(host))
286 class FrontPage(Handler):
287 """Show the default front page."""
288 def get(self):
289 self.render()
292 class LoginHandler(Handler):
293 """Handles a POST response to the OpenID login form."""
295 def post(self):
296 """Handles login requests."""
297 logging.info(self.args_to_dict())
298 openid_url = self.request.get('openid')
299 if not openid_url:
300 self.report_error('Please enter an OpenID URL.')
301 return
303 logging.debug('Beginning discovery for OpenID %s' % openid_url)
304 try:
305 consumer = self.get_consumer()
306 if not consumer:
307 return
308 auth_request = consumer.begin(openid_url)
309 except discover.DiscoveryFailure, e:
310 self.report_error('Error during OpenID provider discovery.', e)
311 return
312 except discover.XRDSError, e:
313 self.report_error('Error parsing XRDS from provider.', e)
314 return
316 self.session.claimed_id = auth_request.endpoint.claimed_id
317 self.session.server_url = auth_request.endpoint.server_url
318 self.session.store_and_display = self.request.get('display', 'no') != 'no'
319 self.store_session()
321 sreg_request = sreg.SRegRequest(optional=['nickname', 'fullname', 'email'])
322 auth_request.addExtension(sreg_request)
324 pape_request = pape.Request([pape.AUTH_MULTI_FACTOR,
325 pape.AUTH_MULTI_FACTOR_PHYSICAL,
326 pape.AUTH_PHISHING_RESISTANT,
328 auth_request.addExtension(pape_request)
330 parts = list(urlparse.urlparse(self.request.uri))
331 parts[2] = 'finish'
332 parts[4] = 'session_id=%d' % self.session.key().id()
333 parts[5] = ''
334 return_to = urlparse.urlunparse(parts)
335 realm = urlparse.urlunparse(parts[0:2] + [''] * 4)
337 redirect_url = auth_request.redirectURL(realm, return_to)
338 logging.debug('Redirecting to %s' % redirect_url)
339 self.response.set_status(302)
340 self.response.headers['Location'] = redirect_url
343 class FinishHandler(Handler):
344 """Handle a redirect from the provider."""
345 def get(self):
346 args = self.args_to_dict()
347 consumer = self.get_consumer()
348 if not consumer:
349 return
351 if self.session.login_set.get():
352 self.render()
353 return
355 response = consumer.complete(args, self.request.uri)
356 assert response.status in Login.status.choices
358 if response.status == 'success':
359 sreg_data = sreg.SRegResponse.fromSuccessResponse(response).items()
360 pape_data = pape.Response.fromSuccessResponse(response)
361 self.session.claimed_id = response.endpoint.claimed_id
362 self.session.server_url = response.endpoint.server_url
363 elif response.status == 'failure':
364 logging.error(str(response))
366 logging.debug('Login status %s for claimed_id %s' %
367 (response.status, self.session.claimed_id))
369 if self.session.store_and_display:
370 login = Login(status=response.status,
371 claimed_id=self.session.claimed_id,
372 server_url=self.session.server_url,
373 session=self.session.key())
374 login.put()
376 self.render(locals())
379 # Map URLs to our RequestHandler subclasses above
380 _URLS = [
381 ('/', FrontPage),
382 ('/login', LoginHandler),
383 ('/finish', FinishHandler),
386 def main(argv):
387 application = webapp.WSGIApplication(_URLS, debug=_DEBUG)
388 wsgiref.handlers.CGIHandler().run(application)
390 if __name__ == '__main__':
391 main(sys.argv)