Home | History | Annotate | Download | only in auth
      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 import six
     62 
     63 import paste.request
     64 from paste import httpexceptions
     65 
     66 def 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 
     76 from openid.store import filestore
     77 from openid.consumer import consumer
     78 from openid.oidutil import appendArgs
     79 
     80 class 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&nbsp;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 
    386 middleware = AuthOpenIDHandler
    387 
    388 def 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