Commits

Martin von Löwis committed d0dbbd2

Add WSGI middleware.
Release 1.7.

Comments (0)

Files changed (8)

 
 The main library is entire web-framework agnostic, and requires
 the application to store any persistent data needed for the relying
-party. The package also includes a Django authentication module,
+party. 
+
+The package also includes a Django authentication module,
 which stores all necessary data; providing a user interface for
 logging in is still left to the application.
 
+Furthermore, a WSGI middleware is also included which simplifies
+integrating OpenID into WSGI-based web frameworks. Again, all UI
+aspects are left to the application.
+
 .. _documentation: http://packages.python.org/openid2rp
 .. _bugtracker: http://bitbucket.org/loewis/openid2rp/issues
 .. _bitbucket: http://bitbucket.org/loewis/openid2rp
 Changes
 -------
 
+1.7 (2010-09-17):
+
+* Add WSGI middleware
+
 1.6 (2010-07-29):
 
 *  Updated Django backend: session + nonce persistency, documentation, many bug fixes
 .. toctree::
 
    django
+   wsgi
 
 Terminology
 -----------
+WSGI Middleware
+===============
+
+.. module:: openid2rp.wsgi
+
+*This module is considered experimental until peer review has been completed.*
+
+The openid2rp WSGI middleware allows integration of OpenID into arbitrary WSGI applications.
+It supports a zero-configuration mode where the middleware can be taken as is. However, even
+in that mode, the application has to cooperate with the middleware, in sending it the right
+requests and expecting results in the right places.
+
+Using this middleware currently requires the webob package.
+
+Simple Usage
+------------
+
+In its most simple form, :class:openid2rp.wsgi.Openid2Middleware can be used as a wrapper
+for the actual WSGI application. All requests to the application get filtered by the wrapper,
+which will send redirects or augment the environ. The application needs to perform the following
+steps:
+
+ 1. Provide a form with an input field ``openid_identifier``; alternatively, provide links
+    to providers with a query parameter ``openid_identifier``. The middleware will look for this
+    parameter in all GET and POST requests.
+ 2. Look for WSGI environment fields ``'openid2rp.identifier'`` or ``'openid2rp.error'``. See below
+    for what they contain.
+
+In addition to these steps, applications also SHOULD provide a storage object where the middleware
+can persistently store information, at least if subsequent requests may get processed by different
+operating system processes, or if the default in-memory storage may consume too much memory.
+
+API Reference
+-------------
+
+.. class:: Openid2Middleware(app[, store])
+
+   Wrapper middleware to process OpenID2 login attempts. Objects
+   passed as the optional *store* parameter must implement the Store
+   interface below. If no store is passed, a
+   :class:memstore.InMemoryStore is used.
+
+.. class:: Store()
+
+   A store for openid2rp must implement the following methods. All key and value parameters are
+   strings.
+
+.. attribute:: Store.nonce_lifetime
+
+   Minimum lifetime before nonces get discarded from the replay cache. The recommended value is
+   5 minutes.
+
+.. method:: Store.start_login(key, value)
+
+   Store a login session under key. Login sessions that are not ended should be expired
+   after some time. As a recommended value, users should get at least 10 minutes to log in;
+   sessions not ended within a day can certainly be discarded.
+
+.. method:: Store.get_login(key)
+
+   Retrieve a value stored using :meth:`start_login`. If the key is not known, None
+   must be returned.
+
+.. method:: Store.end_login(key)
+
+   Remove a login session. Called when a user returns from the provider.
+
+.. method:: Store.add_nonce(nonce)
+
+   Store a nonce in the replay cache. Each nonce has its creation time
+   recorded (see :func:`openid2rp.parse_nonce`). Nonces stored for more than
+   *nonce_lifetime* can be discarded.
+
+.. method:: Store.has_nonce(nonce)
+
+   Return True if a certain nonce is in the replay cache.
+
+.. method:: Store.add_association(key, expires, value)
+
+   Store a provider association. *expires* specifies the UTC seconds (since 1970)
+   when the association can be deleted from the store.
+
+.. method:: Store.get_asssocation(key)
+
+   Return an association stored for a key. If no association can be found, None is returned.
+
+.. class:: memstore.InMemoryStore()
+
+   Default store, storing all key/value pairs in dictionaries. Using this implementation
+   is correct if all requests use the same Python process which in turn always uses the same
+   store. If the stored values are lost (e.g. after a server restart), the following consequences
+   arise:
+
+   - users returning from their OpenID providers in ongoing login sessions will be refused from
+     logging in. This may in particular happen if multiple simultaneous server processes all operate
+     indepdendent InMemoryStore objects, yet subsquent requests may get dispatched to different servers.
+
+   - attackers attempting a replay attack may succeed if the replay cache is discarded, and the replay
+     occurs within the nonce_lifetime.
+
+   - users returning from their OpenID providers will also be unable to login if the provider assoiation
+     is lost. In addition, losing the provider session will require to establish a new association on the
+     next login attempt for the same user (resp. provider, for provider IDs). Typically, the delay caused
+     by that data loss will not be noticable.
+
+WSGI environment effects
+------------------------
+
+:class:Openid2Middleware parses the request and looks for an ``openid_identifier`` field. If no such field
+is found, and it is not a return URL, the request is passed unmodified.
+
+If the openid_identifier field is present, one of two cases may happen:
+
+ 1. Discovery on the ID fails. This may indicate that the ID entered actually is not an OpenID.
+    The request is forwarded, with ``'openid2rp.notice'`` being set.
+
+ 2. The user is redirected to the provider. The return URL will be the same as the one in the request,
+    except that the query parameters are completely rewritten, and include, in particular, ``openid_return``.
+
+When the user returns from the provider, the response is validated, and either ``'openid2rp.identifier'``
+or openid2rp.error are set (the latter to a string containing an error message).
+
+If openid2rp.identifier is set, openid2rp.ax and openid2rp.sreg will also be set, namely to dictionary
+containing the respective user information.

openid2rp/__init__.py

     return signed
 
 def parse_nonce(nonce):
-    '''Split a nonce into a (timestamp, ID) pair'''
+    '''Extract a datetime.datetime stamp from the nonce'''
     stamp = nonce.split('Z', 1)[0]
     stamp = time.strptime(stamp, "%Y-%m-%dT%H:%M:%S")[:6]
     stamp = datetime.datetime(*stamp)

openid2rp/wsgi/__init__.py

+import cPickle, webob, time, random, calendar
+import openid2rp, memstore
+
+class Openid2Middleware(object):
+    def __init__(self, app, store = None):
+        self.app = app
+        if not store:
+            store = memstore.InMemoryStore()
+        self.store = store
+        # not yet used
+        self.return_to = None
+
+    def __call__(self, environ, start_response):
+        request = webob.Request(environ)
+
+        if 'openid_identifier' in request.params:
+            return self.login(request, start_response)
+
+        if 'openid_return' in request.params:
+            return self.returned(request, start_response)
+
+        return self.app(environ, start_response)
+
+    def login(self, req, start_response):
+        kind, claimed_id = openid2rp.normalize_uri(req.params['openid_identifier'])
+        if kind == 'xri':
+            # XRIs are not supported. Report this to the application as an error
+            return self.error(req.environ, start_response, 'XRIs are not supported')
+        res = openid2rp.discover(claimed_id)
+        if not res:
+            # not an OpenID
+            req.environ['openid2rp.notice'] = 'discovery failed'
+            return self.app(req.environ, start_response)
+        services, op_endpoint, op_local = res
+
+        assoc = self.store.get_association(claimed_id)
+        if not assoc:
+            now = int(time.time())
+            # XXX error handling
+            assoc = openid2rp.associate(services, op_endpoint)
+            assoc_handle = assoc['assoc_handle']
+            expires = now + int(assoc['expires_in'])
+            mac_key = assoc['mac_key']
+            saved_assoc = cPickle.dumps((assoc_handle, mac_key))
+            self.store.add_association(claimed_id, expires, saved_assoc)
+            self.store.add_association(assoc_handle, expires, saved_assoc)
+        else:
+            assoc_handle, mac_key = cPickle.loads(assoc)
+
+        session = str(random.getrandbits(40))
+        self.store.start_login(session, cPickle.dumps((claimed_id, assoc_handle)))
+
+        if self.return_to:
+            return_to = self.return_to
+            if '?' not in return_to:
+                return_to += '?openid_return='+session
+            else:
+                return_to += '&openid_return='+session
+        else:
+            return_to = req.path_url+'?openid_return='+session
+        redirect = openid2rp.request_authentication(
+            services, op_endpoint, assoc_handle, return_to, 
+            claimed=claimed_id, op_local=op_local)
+
+        start_response('303 Go to OpenID provider', [('Location', redirect)])
+        return []
+
+    def returned(self, req, start_response):
+        session = req.params.get('openid_return')
+        dump = self.store.get_login(session)
+        if not dump:
+            req.environ['openid2rp.error'] = 'login session not found'
+            return self.app(req.environ, start_response)
+        claimed_id, assoc_handle = cPickle.loads(dump)
+        self.store.end_login(session)
+        
+        qs = req.environ.get('QUERY_STRING')
+        # XXX error handling
+        assoc = self.store.get_association(assoc_handle)
+        assoc_handle, mac_key = cPickle.loads(assoc)
+        try:
+            signed = openid2rp.authenticate({'assoc_handle':assoc_handle, 'mac_key':mac_key}, qs)
+        except Exception, e:
+            return self.error(req.environ, start_response, str(e))
+
+        # Check for replay attacks
+        nonce = req.params['openid.response_nonce']
+        utc = calendar.timegm(openid2rp.parse_nonce(nonce).utctimetuple())
+        if time.time()+self.store.nonce_lifetime < utc or self.store.has_nonce(nonce):
+            return self.error(req.environ, start_response, 'replay attack detected')
+        self.store.add_nonce(nonce)
+
+        req.environ['openid2rp.identifier'] = claimed_id
+        namespaces = openid2rp.get_namespaces(qs)
+        req.environ['openid2rp.ax'] = openid2rp.get_ax(qs, namespaces, signed)
+        req.environ['openid2rp.sreg'] = openid2rp.get_sreg(qs, signed)
+        return self.app(req.environ, start_response)
+
+    def error(self, environ, start_response, msg):
+        environ['openid2rp.error'] = msg
+        return self.app(environ, start_response)

openid2rp/wsgi/demo.py

+# Demo WSGI application using the openid2rp middleware.
+import cgi
+
+# This is the actual application. It displays a login box,
+# and displays information about the user when login completes.
+def application(environ, start_response):
+    if 'openid2rp.error' in environ:
+        start_response('401 Permission Denied', [('Content-type','text/plain')])
+        return ['Something went wrong: '+environ['openid2rp.error']]
+    if 'openid2rp.identifier' not in environ:
+        # Display login box
+        start_response('200 Ok', [('Content-type','text/html')])
+        notice = environ.get('openid2rp.notice', '')
+        if notice:
+            notice = '<em>%s</em><br/>' % notice
+        google = '/?openid_identifier=%s' % cgi.escape('https://www.google.com/accounts/o8/id')
+        return ['<html><head><title>Login</title></head>'
+                '<body>', notice, 'Login with OpenID:'
+                '<form method="POST">'
+                '<input name="openid_identifier" size="60"/>'
+                '</form></body>',
+                'Alternatively, log in directly with <a href="', google, '">Google</a>.',
+                '</html>']
+    # Display authentication results
+    start_response('200 Ok', [('Content-type', 'text/html; charset=utf-8')])
+    print environ['openid2rp.sreg'].items()
+    return (['<html><head><title>Hello ',
+             environ['openid2rp.identifier'].encode('utf-8'),
+             '</title></head><body>'
+             'We know the following information about you:<ul>']+
+            ['<li>%s %s</li>' % (k, v) for k,v in environ['openid2rp.ax'].items()]+
+            ['<li>%s %s</li>' % (k, v) for k,v in environ['openid2rp.sreg'].items()]+
+            ['</ul></body></html>'])
+
+# Wrap the application with the openid2rp middleware
+# Web frameworks may offer to perform this wrapping by means of configuration,
+# instead of code
+from openid2rp.wsgi import Openid2Middleware
+application = Openid2Middleware(application)
+
+# Put this into a web container
+# We use wsgiref here to keep the dependencies of this package low
+from wsgiref.simple_server import make_server
+server = make_server('', 6543, application)
+print 'Running on http://localhost:6543/'
+server.serve_forever()

openid2rp/wsgi/memstore.py

+# This is a simple store of OpenID data
+import heapq, time, calendar, openid2rp
+
+class InMemoryStore:
+    nonce_lifetime = 300 #s
+
+    def __init__(self):
+        # ongoing logins
+        self.logins = {}
+        # received reply nonces
+        self.reply_nonces = {}
+        # established provider associations
+        self.associations = {}
+        # heap of (time, dictionary, key) triples
+        self.expirations = []
+
+    def expire(self):
+        now = time.time()
+        while True:
+            t, d, k = self.expirations[0]
+            if t > now:
+                break
+            del d[k]
+            heapq.heappop(heap)
+
+    def start_login(self, key, value):
+        now = time.time()
+        self.logins[key] = value
+        heapq.heappush(self.expirations, (now+3600, self.logins, key))
+
+    def get_login(self, key):
+        return self.logins.get(key)
+
+    def end_login(self, key):
+        del self.logins[key]
+        for i, k in enumerate(self.expirations):
+            if k[1] is self.logins and k[2] == key:
+                del self.expirations[i]
+                # deletion in the middle is not supported by heapq,
+                # so re-heapify instead
+                heapq.heapify(self.expirations)
+                break
+
+    def add_nonce(self, nonce):
+        utc = calendar.timegm(openid2rp.parse_nonce(nonce).utctimetuple())
+        self.reply_nonces[nonce] = None
+        heapq.heappush(self.expirations, (utc+self.nonce_lifetime, self.reply_nonces, nonce))
+
+    def has_nonce(self, nonce):
+        return nonce in self.reply_nonces
+
+    def add_association(self, key, expires, value):
+        self.associations[key] = value
+        heapq.heappush(self.expirations, (expires, self.associations, key))
+
+    def get_association(self, key):
+        return self.associations.get(key)
 except ImportError:
     pass
 
-version='1.6'
+version='1.7'
 setup(name='openid2rp',
       version=version,
-      description='OpenID 2.0 Relying Party Support Library',
+      description='OpenID 2.0 Relying Party Support Library with WSGI and Django support',
       license = 'Academic Free License, version 3',
       author='Martin v. Loewis',
       author_email='martin@v.loewis.de',
         "Programming Language :: Python :: 3.1",
         "Programming Language :: Python :: 3.2",
         ],
-      packages=['openid2rp','openid2rp.django'],
+      packages=['openid2rp','openid2rp.django', 'openid2rp.wsgi'],
       cmdclass = cmdclass
       )