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&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
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