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