1 | # (c) 2005 Ben Bangert |
---|
2 | # This module is part of the Python Paste Project and is released under |
---|
3 | # the MIT License: http://www.opensource.org/licenses/mit-license.php |
---|
4 | """ |
---|
5 | OpenID Authentication (Consumer) |
---|
6 | |
---|
7 | OpenID is a distributed authentication system for single sign-on originally |
---|
8 | developed at/for LiveJournal.com. |
---|
9 | |
---|
10 | http://openid.net/ |
---|
11 | |
---|
12 | URL. You can have multiple identities in the same way you can have multiple |
---|
13 | URLs. All OpenID does is provide a way to prove that you own a URL (identity). |
---|
14 | And it does this without passing around your password, your email address, or |
---|
15 | anything you don't want it to. There's no profile exchange component at all: |
---|
16 | your profiile is your identity URL, but recipients of your identity can then |
---|
17 | learn more about you from any public, semantically interesting documents |
---|
18 | linked thereunder (FOAF, RSS, Atom, vCARD, etc.). |
---|
19 | |
---|
20 | ``Note``: paste.auth.openid requires installation of the Python-OpenID |
---|
21 | libraries:: |
---|
22 | |
---|
23 | http://www.openidenabled.com/ |
---|
24 | |
---|
25 | This module is based highly off the consumer.py that Python OpenID comes with. |
---|
26 | |
---|
27 | Using the OpenID Middleware |
---|
28 | =========================== |
---|
29 | |
---|
30 | Using the OpenID middleware is fairly easy, the most minimal example using the |
---|
31 | basic login form thats included:: |
---|
32 | |
---|
33 | # Add to your wsgi app creation |
---|
34 | from paste.auth import open_id |
---|
35 | |
---|
36 | wsgi_app = open_id.middleware(wsgi_app, '/somewhere/to/store/openid/data') |
---|
37 | |
---|
38 | You will now have the OpenID form available at /oid on your site. Logging in will |
---|
39 | verify that the login worked. |
---|
40 | |
---|
41 | A more complete login should involve having the OpenID middleware load your own |
---|
42 | login page after verifying the OpenID URL so that you can retain the login |
---|
43 | information in your webapp (session, cookies, etc.):: |
---|
44 | |
---|
45 | wsgi_app = open_id.middleware(wsgi_app, '/somewhere/to/store/openid/data', |
---|
46 | login_redirect='/your/login/code') |
---|
47 | |
---|
48 | Your login code should then be configured to retrieve 'paste.auth.open_id' for |
---|
49 | the users OpenID URL. If this key does not exist, the user has not logged in. |
---|
50 | |
---|
51 | Once the login is retrieved, it should be saved in your webapp, and the user |
---|
52 | should be redirected to wherever they would normally go after a successful |
---|
53 | login. |
---|
54 | """ |
---|
55 | |
---|
56 | __all__ = ['AuthOpenIDHandler'] |
---|
57 | |
---|
58 | import cgi |
---|
59 | import urlparse |
---|
60 | import re |
---|
61 | |
---|
62 | import paste.request |
---|
63 | from paste import httpexceptions |
---|
64 | |
---|
65 | def quoteattr(s): |
---|
66 | qs = cgi.escape(s, 1) |
---|
67 | return '"%s"' % (qs,) |
---|
68 | |
---|
69 | # You may need to manually add the openid package into your |
---|
70 | # python path if you don't have it installed with your system python. |
---|
71 | # If so, uncomment the line below, and change the path where you have |
---|
72 | # Python-OpenID. |
---|
73 | # sys.path.append('/path/to/openid/') |
---|
74 | |
---|
75 | from openid.store import filestore |
---|
76 | from openid.consumer import consumer |
---|
77 | from openid.oidutil import appendArgs |
---|
78 | |
---|
79 | class AuthOpenIDHandler(object): |
---|
80 | """ |
---|
81 | This middleware implements OpenID Consumer behavior to authenticate a |
---|
82 | URL against an OpenID Server. |
---|
83 | """ |
---|
84 | |
---|
85 | def __init__(self, app, data_store_path, auth_prefix='/oid', |
---|
86 | login_redirect=None, catch_401=False, |
---|
87 | url_to_username=None): |
---|
88 | """ |
---|
89 | Initialize the OpenID middleware |
---|
90 | |
---|
91 | ``app`` |
---|
92 | Your WSGI app to call |
---|
93 | |
---|
94 | ``data_store_path`` |
---|
95 | Directory to store crypto data in for use with OpenID servers. |
---|
96 | |
---|
97 | ``auth_prefix`` |
---|
98 | Location for authentication process/verification |
---|
99 | |
---|
100 | ``login_redirect`` |
---|
101 | Location to load after successful process of login |
---|
102 | |
---|
103 | ``catch_401`` |
---|
104 | If true, then any 401 responses will turn into open ID login |
---|
105 | requirements. |
---|
106 | |
---|
107 | ``url_to_username`` |
---|
108 | A function called like ``url_to_username(environ, url)``, which should |
---|
109 | return a string username. If not given, the URL will be the username. |
---|
110 | """ |
---|
111 | store = filestore.FileOpenIDStore(data_store_path) |
---|
112 | self.oidconsumer = consumer.OpenIDConsumer(store) |
---|
113 | |
---|
114 | self.app = app |
---|
115 | self.auth_prefix = auth_prefix |
---|
116 | self.data_store_path = data_store_path |
---|
117 | self.login_redirect = login_redirect |
---|
118 | self.catch_401 = catch_401 |
---|
119 | self.url_to_username = url_to_username |
---|
120 | |
---|
121 | def __call__(self, environ, start_response): |
---|
122 | if environ['PATH_INFO'].startswith(self.auth_prefix): |
---|
123 | # Let's load everything into a request dict to pass around easier |
---|
124 | request = dict(environ=environ, start=start_response, body=[]) |
---|
125 | request['base_url'] = paste.request.construct_url(environ, with_path_info=False, |
---|
126 | with_query_string=False) |
---|
127 | |
---|
128 | path = re.sub(self.auth_prefix, '', environ['PATH_INFO']) |
---|
129 | request['parsed_uri'] = urlparse.urlparse(path) |
---|
130 | request['query'] = dict(paste.request.parse_querystring(environ)) |
---|
131 | |
---|
132 | path = request['parsed_uri'][2] |
---|
133 | if path == '/' or not path: |
---|
134 | return self.render(request) |
---|
135 | elif path == '/verify': |
---|
136 | return self.do_verify(request) |
---|
137 | elif path == '/process': |
---|
138 | return self.do_process(request) |
---|
139 | else: |
---|
140 | return self.not_found(request) |
---|
141 | else: |
---|
142 | if self.catch_401: |
---|
143 | return self.catch_401_app_call(environ, start_response) |
---|
144 | return self.app(environ, start_response) |
---|
145 | |
---|
146 | def catch_401_app_call(self, environ, start_response): |
---|
147 | """ |
---|
148 | Call the application, and redirect if the app returns a 401 response |
---|
149 | """ |
---|
150 | was_401 = [] |
---|
151 | def replacement_start_response(status, headers, exc_info=None): |
---|
152 | if int(status.split(None, 1)) == 401: |
---|
153 | # @@: Do I need to append something to go back to where we |
---|
154 | # came from? |
---|
155 | was_401.append(1) |
---|
156 | def dummy_writer(v): |
---|
157 | pass |
---|
158 | return dummy_writer |
---|
159 | else: |
---|
160 | return start_response(status, headers, exc_info) |
---|
161 | app_iter = self.app(environ, replacement_start_response) |
---|
162 | if was_401: |
---|
163 | try: |
---|
164 | list(app_iter) |
---|
165 | finally: |
---|
166 | if hasattr(app_iter, 'close'): |
---|
167 | app_iter.close() |
---|
168 | redir_url = paste.request.construct_url(environ, with_path_info=False, |
---|
169 | with_query_string=False) |
---|
170 | exc = httpexceptions.HTTPTemporaryRedirect(redir_url) |
---|
171 | return exc.wsgi_application(environ, start_response) |
---|
172 | else: |
---|
173 | return app_iter |
---|
174 | |
---|
175 | def do_verify(self, request): |
---|
176 | """Process the form submission, initating OpenID verification. |
---|
177 | """ |
---|
178 | |
---|
179 | # First, make sure that the user entered something |
---|
180 | openid_url = request['query'].get('openid_url') |
---|
181 | if not openid_url: |
---|
182 | return self.render(request, 'Enter an identity URL to verify.', |
---|
183 | css_class='error', form_contents=openid_url) |
---|
184 | |
---|
185 | oidconsumer = self.oidconsumer |
---|
186 | |
---|
187 | # Then, ask the library to begin the authorization. |
---|
188 | # Here we find out the identity server that will verify the |
---|
189 | # user's identity, and get a token that allows us to |
---|
190 | # communicate securely with the identity server. |
---|
191 | status, info = oidconsumer.beginAuth(openid_url) |
---|
192 | |
---|
193 | # If the URL was unusable (either because of network |
---|
194 | # conditions, a server error, or that the response returned |
---|
195 | # was not an OpenID identity page), the library will return |
---|
196 | # an error code. Let the user know that that URL is unusable. |
---|
197 | if status in [consumer.HTTP_FAILURE, consumer.PARSE_ERROR]: |
---|
198 | if status == consumer.HTTP_FAILURE: |
---|
199 | fmt = 'Failed to retrieve <q>%s</q>' |
---|
200 | else: |
---|
201 | fmt = 'Could not find OpenID information in <q>%s</q>' |
---|
202 | |
---|
203 | message = fmt % (cgi.escape(openid_url),) |
---|
204 | return self.render(request, message, css_class='error', form_contents=openid_url) |
---|
205 | elif status == consumer.SUCCESS: |
---|
206 | # The URL was a valid identity URL. Now we construct a URL |
---|
207 | # that will get us to process the server response. We will |
---|
208 | # need the token from the beginAuth call when processing |
---|
209 | # the response. A cookie or a session object could be used |
---|
210 | # to accomplish this, but for simplicity here we just add |
---|
211 | # it as a query parameter of the return-to URL. |
---|
212 | return_to = self.build_url(request, 'process', token=info.token) |
---|
213 | |
---|
214 | # Now ask the library for the URL to redirect the user to |
---|
215 | # his OpenID server. It is required for security that the |
---|
216 | # return_to URL must be under the specified trust_root. We |
---|
217 | # just use the base_url for this server as a trust root. |
---|
218 | redirect_url = oidconsumer.constructRedirect( |
---|
219 | info, return_to, trust_root=request['base_url']) |
---|
220 | |
---|
221 | # Send the redirect response |
---|
222 | return self.redirect(request, redirect_url) |
---|
223 | else: |
---|
224 | assert False, 'Not reached' |
---|
225 | |
---|
226 | def do_process(self, request): |
---|
227 | """Handle the redirect from the OpenID server. |
---|
228 | """ |
---|
229 | oidconsumer = self.oidconsumer |
---|
230 | |
---|
231 | # retrieve the token from the environment (in this case, the URL) |
---|
232 | token = request['query'].get('token', '') |
---|
233 | |
---|
234 | # Ask the library to check the response that the server sent |
---|
235 | # us. Status is a code indicating the response type. info is |
---|
236 | # either None or a string containing more information about |
---|
237 | # the return type. |
---|
238 | status, info = oidconsumer.completeAuth(token, request['query']) |
---|
239 | |
---|
240 | css_class = 'error' |
---|
241 | openid_url = None |
---|
242 | if status == consumer.FAILURE and info: |
---|
243 | # In the case of failure, if info is non-None, it is the |
---|
244 | # URL that we were verifying. We include it in the error |
---|
245 | # message to help the user figure out what happened. |
---|
246 | openid_url = info |
---|
247 | fmt = "Verification of %s failed." |
---|
248 | message = fmt % (cgi.escape(openid_url),) |
---|
249 | elif status == consumer.SUCCESS: |
---|
250 | # Success means that the transaction completed without |
---|
251 | # error. If info is None, it means that the user cancelled |
---|
252 | # the verification. |
---|
253 | css_class = 'alert' |
---|
254 | if info: |
---|
255 | # This is a successful verification attempt. If this |
---|
256 | # was a real application, we would do our login, |
---|
257 | # comment posting, etc. here. |
---|
258 | openid_url = info |
---|
259 | if self.url_to_username: |
---|
260 | username = self.url_to_username(request['environ'], openid_url) |
---|
261 | else: |
---|
262 | username = openid_url |
---|
263 | if 'paste.auth_tkt.set_user' in request['environ']: |
---|
264 | request['environ']['paste.auth_tkt.set_user'](username) |
---|
265 | if not self.login_redirect: |
---|
266 | fmt = ("If you had supplied a login redirect path, you would have " |
---|
267 | "been redirected there. " |
---|
268 | "You have successfully verified %s as your identity.") |
---|
269 | message = fmt % (cgi.escape(openid_url),) |
---|
270 | else: |
---|
271 | # @@: This stuff doesn't make sense to me; why not a remote redirect? |
---|
272 | request['environ']['paste.auth.open_id'] = openid_url |
---|
273 | request['environ']['PATH_INFO'] = self.login_redirect |
---|
274 | return self.app(request['environ'], request['start']) |
---|
275 | #exc = httpexceptions.HTTPTemporaryRedirect(self.login_redirect) |
---|
276 | #return exc.wsgi_application(request['environ'], request['start']) |
---|
277 | else: |
---|
278 | # cancelled |
---|
279 | message = 'Verification cancelled' |
---|
280 | else: |
---|
281 | # Either we don't understand the code or there is no |
---|
282 | # openid_url included with the error. Give a generic |
---|
283 | # failure message. The library should supply debug |
---|
284 | # information in a log. |
---|
285 | message = 'Verification failed.' |
---|
286 | |
---|
287 | return self.render(request, message, css_class, openid_url) |
---|
288 | |
---|
289 | def build_url(self, request, action, **query): |
---|
290 | """Build a URL relative to the server base_url, with the given |
---|
291 | query parameters added.""" |
---|
292 | base = urlparse.urljoin(request['base_url'], self.auth_prefix + '/' + action) |
---|
293 | return appendArgs(base, query) |
---|
294 | |
---|
295 | def redirect(self, request, redirect_url): |
---|
296 | """Send a redirect response to the given URL to the browser.""" |
---|
297 | response_headers = [('Content-type', 'text/plain'), |
---|
298 | ('Location', redirect_url)] |
---|
299 | request['start']('302 REDIRECT', response_headers) |
---|
300 | return ["Redirecting to %s" % redirect_url] |
---|
301 | |
---|
302 | def not_found(self, request): |
---|
303 | """Render a page with a 404 return code and a message.""" |
---|
304 | fmt = 'The path <q>%s</q> was not understood by this server.' |
---|
305 | msg = fmt % (request['parsed_uri'],) |
---|
306 | openid_url = request['query'].get('openid_url') |
---|
307 | return self.render(request, msg, 'error', openid_url, status='404 Not Found') |
---|
308 | |
---|
309 | def render(self, request, message=None, css_class='alert', form_contents=None, |
---|
310 | status='200 OK', title="Python OpenID Consumer"): |
---|
311 | """Render a page.""" |
---|
312 | response_headers = [('Content-type', 'text/html')] |
---|
313 | request['start'](str(status), response_headers) |
---|
314 | |
---|
315 | self.page_header(request, title) |
---|
316 | if message: |
---|
317 | request['body'].append("<div class='%s'>" % (css_class,)) |
---|
318 | request['body'].append(message) |
---|
319 | request['body'].append("</div>") |
---|
320 | self.page_footer(request, form_contents) |
---|
321 | return request['body'] |
---|
322 | |
---|
323 | def page_header(self, request, title): |
---|
324 | """Render the page header""" |
---|
325 | request['body'].append('''\ |
---|
326 | <html> |
---|
327 | <head><title>%s</title></head> |
---|
328 | <style type="text/css"> |
---|
329 | * { |
---|
330 | font-family: verdana,sans-serif; |
---|
331 | } |
---|
332 | body { |
---|
333 | width: 50em; |
---|
334 | margin: 1em; |
---|
335 | } |
---|
336 | div { |
---|
337 | padding: .5em; |
---|
338 | } |
---|
339 | table { |
---|
340 | margin: none; |
---|
341 | padding: none; |
---|
342 | } |
---|
343 | .alert { |
---|
344 | border: 1px solid #e7dc2b; |
---|
345 | background: #fff888; |
---|
346 | } |
---|
347 | .error { |
---|
348 | border: 1px solid #ff0000; |
---|
349 | background: #ffaaaa; |
---|
350 | } |
---|
351 | #verify-form { |
---|
352 | border: 1px solid #777777; |
---|
353 | background: #dddddd; |
---|
354 | margin-top: 1em; |
---|
355 | padding-bottom: 0em; |
---|
356 | } |
---|
357 | </style> |
---|
358 | <body> |
---|
359 | <h1>%s</h1> |
---|
360 | <p> |
---|
361 | This example consumer uses the <a |
---|
362 | href="http://openid.schtuff.com/">Python OpenID</a> library. It |
---|
363 | just verifies that the URL that you enter is your identity URL. |
---|
364 | </p> |
---|
365 | ''' % (title, title)) |
---|
366 | |
---|
367 | def page_footer(self, request, form_contents): |
---|
368 | """Render the page footer""" |
---|
369 | if not form_contents: |
---|
370 | form_contents = '' |
---|
371 | |
---|
372 | request['body'].append('''\ |
---|
373 | <div id="verify-form"> |
---|
374 | <form method="get" action=%s> |
---|
375 | Identity URL: |
---|
376 | <input type="text" name="openid_url" value=%s /> |
---|
377 | <input type="submit" value="Verify" /> |
---|
378 | </form> |
---|
379 | </div> |
---|
380 | </body> |
---|
381 | </html> |
---|
382 | ''' % (quoteattr(self.build_url(request, 'verify')), quoteattr(form_contents))) |
---|
383 | |
---|
384 | |
---|
385 | middleware = AuthOpenIDHandler |
---|
386 | |
---|
387 | def make_open_id_middleware( |
---|
388 | app, |
---|
389 | global_conf, |
---|
390 | # Should this default to something, or inherit something from global_conf?: |
---|
391 | data_store_path, |
---|
392 | auth_prefix='/oid', |
---|
393 | login_redirect=None, |
---|
394 | catch_401=False, |
---|
395 | url_to_username=None, |
---|
396 | apply_auth_tkt=False, |
---|
397 | auth_tkt_logout_path=None): |
---|
398 | from paste.deploy.converters import asbool |
---|
399 | from paste.util import import_string |
---|
400 | catch_401 = asbool(catch_401) |
---|
401 | if url_to_username and isinstance(url_to_username, basestring): |
---|
402 | url_to_username = import_string.eval_import(url_to_username) |
---|
403 | apply_auth_tkt = asbool(apply_auth_tkt) |
---|
404 | new_app = AuthOpenIDHandler( |
---|
405 | app, data_store_path=data_store_path, auth_prefix=auth_prefix, |
---|
406 | login_redirect=login_redirect, catch_401=catch_401, |
---|
407 | url_to_username=url_to_username or None) |
---|
408 | if apply_auth_tkt: |
---|
409 | from paste.auth import auth_tkt |
---|
410 | new_app = auth_tkt.make_auth_tkt_middleware( |
---|
411 | new_app, global_conf, logout_path=auth_tkt_logout_path) |
---|
412 | return new_app |
---|