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.
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:
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:
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.
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
62 # Set to True if stack traces should be shown in the browser, etc.
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."""
89 self
.session_args
= {}
91 def get_consumer(self
):
92 """Returns a Consumer instance.
95 fetchers
.setDefaultFetcher(fetcher
.UrlfetchFetcher())
96 if not self
.load_session():
98 self
.consumer
= Consumer(self
.session_args
, store
.DatastoreStore())
102 def args_to_dict(self
):
103 """Converts the URL and POST parameters to a singly-valued dictionary.
106 dict with the URL and POST body parameters
109 return dict([(arg
, req
.get(arg
)) for arg
in req
.arguments()])
111 def load_session(self
):
112 """Loads the current session.
115 id = self
.request
.get('session_id')
118 self
.session
= db
.get(db
.Key
.from_path('Session', int(id)))
120 except (AssertionError, db
.Error
), e
:
121 self
.report_error('Invalid session id: %d' % id)
124 fields
= self
.session
.dynamic_properties()
125 self
.session_args
= dict((f
, getattr(self
.session
, f
)) for f
in fields
)
128 self
.session_args
= {}
129 self
.session
= Session()
130 self
.session
.claimed_id
= self
.request
.get('openid')
134 def store_session(self
):
135 """Stores the current session.
138 for field
, value
in self
.session_args
.items():
139 setattr(self
.session
, field
, value
)
142 def render(self
, extra_values
={}):
143 """Renders the page, including the extra (optional) values.
146 template_name: string
147 The template to render.
150 Template values to provide to the template.
152 logins
= Login
.gql('ORDER BY timestamp DESC').fetch(20)
154 login
.display_name
= self
.display_name(login
.claimed_id
)
155 login
.friendly_time
= self
.relative_time(login
.timestamp
)
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.
172 A detailed error message.
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
188 def relative_time(self
, timestamp
):
189 """Returns a friendly string describing how long ago the timestamp was.
192 timestamp: a datetime
197 def format_number(num
):
199 return {1: 'one', 2: 'two', 3: 'three', 4: 'four', 5: 'five',
200 6: 'six', 7: 'seven', 8: 'eight', 9: 'nine'}[num
]
204 delta
= datetime
.datetime
.now() - timestamp
205 minutes
= delta
.seconds
/ 60
209 return '%s days ago' % format_number(delta
.days
)
210 elif delta
.days
== 1:
213 return '%s hours ago' % format_number(hours
)
217 return 'half an hour ago'
219 return '%s minutes ago' % format_number(minutes
)
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:
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
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('@')]
256 # is the username in the params?
257 match
= re
.search('(u|id|user|userid|user_id|profile)=(%s)' % username_re
,
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])
268 host
= host
.split('.')
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:
283 return sanitize('.'.join(host
))
286 class FrontPage(Handler
):
287 """Show the default front page."""
292 class LoginHandler(Handler
):
293 """Handles a POST response to the OpenID login form."""
296 """Handles login requests."""
297 logging
.info(self
.args_to_dict())
298 openid_url
= self
.request
.get('openid')
300 self
.report_error('Please enter an OpenID URL.')
303 logging
.debug('Beginning discovery for OpenID %s' % openid_url
)
305 consumer
= self
.get_consumer()
308 auth_request
= consumer
.begin(openid_url
)
309 except discover
.DiscoveryFailure
, e
:
310 self
.report_error('Error during OpenID provider discovery.', e
)
312 except discover
.XRDSError
, e
:
313 self
.report_error('Error parsing XRDS from provider.', e
)
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'
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
))
332 parts
[4] = 'session_id=%d' % self
.session
.key().id()
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."""
346 args
= self
.args_to_dict()
347 consumer
= self
.get_consumer()
351 if self
.session
.login_set
.get():
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())
376 self
.render(locals())
379 # Map URLs to our RequestHandler subclasses above
382 ('/login', LoginHandler
),
383 ('/finish', FinishHandler
),
387 application
= webapp
.WSGIApplication(_URLS
, debug
=_DEBUG
)
388 wsgiref
.handlers
.CGIHandler().run(application
)
390 if __name__
== '__main__':