source: TI12-security/trunk/python/ndg.security.server/ndg/security/server/wsgi/openid/provider/__init__.py @ 4863

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/TI12-security/trunk/python/ndg.security.server/ndg/security/server/wsgi/openid/provider/__init__.py@4863
Revision 4863, 75.7 KB checked in by pjkersha, 11 years ago (diff)
  • added initialisation decorator for use with derived classes if ndg.security.server.wsgi.NDGSecurityMiddlewareBase.call
  • fixed SSO Client Middleware - client interface for using Single Sign On Service from Pylons or other app
  • Added capability to pass in args to class to be instantiated from instantiateClass factory function in classfactory module
  • Modified SSO wayf template to enable login from this page if the user is at their home site - saves an extra login step
  • Added Signin interface plugin for OpenID Relying Party middleware. The plugin is itself middleware so that it can include other middleware filters such StaticURLParser to include static content used in template.
Line 
1"""NDG Security OpenID Provider Middleware
2
3Compliments AuthKit OpenID Middleware used for OpenID *Relying Party*
4
5NERC Data Grid Project
6
7"""
8__author__ = "P J Kershaw"
9__date__ = "01/08/08"
10__copyright__ = "(C) 2009 Science and Technology Facilities Council"
11__license__ = "BSD - see top-level directory for LICENSE file"
12__contact__ = "Philip.Kershaw@stfc.ac.uk"
13__revision__ = "$Id$"
14import httplib
15import sys
16import cgi
17import os
18import logging
19log = logging.getLogger(__name__)
20_debugLevel = log.getEffectiveLevel() <= logging.DEBUG
21
22import paste.request
23from paste.util.import_string import eval_import
24from openid.extensions import sreg, ax
25from openid.server import server
26from openid.store.filestore import FileOpenIDStore
27from openid.consumer import discover
28
29quoteattr = lambda s: '"%s"' % cgi.escape(s, 1)
30
31
32class AuthNInterfaceError(Exception):
33    """Base class for AbstractAuthNInterface exceptions
34   
35    A standard message is raised set by the msg class variable but the actual
36    exception details are logged to the error log.  The use of a standard
37    message enables callers to use its content for user error messages.
38   
39    @type msg: basestring
40    @cvar msg: standard message to be raised for this exception"""
41    userMsg = ("An internal error occurred during login,  Please contact your "
42               "system administrator")
43    errorMsg = "AuthNInterface error"
44   
45    def __init__(self, *arg, **kw):
46        if len(arg) > 0:
47            msg = arg[0]
48        else:
49            msg = self.__class__.errorMsg
50           
51        log.error(msg)
52        Exception.__init__(self, msg, **kw)
53       
54class AuthNInterfaceInvalidCredentials(AuthNInterfaceError):
55    """User has provided incorrect username/password.  Raise from logon"""
56    userMsg = ("Invalid username / password provided.  Please try again.  If "
57               "the problem persists please contact your system administrator")
58    errorMsg = "Invalid username/password provided"
59
60class AuthNInterfaceUsername2IdentifierMismatch(AuthNInterfaceError): 
61    """User has provided a username which doesn't match the identifier from
62    the OpenID URL that they provided.  DOESN'T apply to ID Select mode where
63    the user has given a generic URL for their OpenID Provider."""
64    userMsg = ("Invalid username for the OpenID entered.  Please ensure you "
65               "have the correct OpenID and username and try again.  If the "
66               "problem persists contact your system administrator")
67    errorMsg = "invalid username / OpenID identifier combination"
68   
69class AuthNInterfaceRetrieveError(AuthNInterfaceError):
70    """Error with retrieval of information to authenticate user e.g. error with
71    database look-up.  Raise from logon"""
72    errorMsg = ("An error occurred retrieving information to check the login "
73                "credentials")
74
75class AuthNInterfaceInitError(AuthNInterfaceError):
76    """Error with initialisation of AuthNInterface.  Raise from __init__"""
77    errorMsg = "AuthNInterface initialisation error"
78   
79class AuthNInterfaceConfigError(AuthNInterfaceError):
80    """Error with Authentication configuration.  Raise from __init__"""
81    errorMsg = "AuthNInterface configuration error"
82   
83class AbstractAuthNInterface(object):
84    '''OpenID Provider abstract base class for authentication configuration.
85    Derive from this class to define the authentication interface for users
86    logging into the OpenID Provider'''
87   
88    def __init__(self, **prop):
89        """Make any initial settings
90       
91        Settings are held in a dictionary which can be set from **prop,
92        a call to setProperties() or by passing settings in an XML file
93        given by propFilePath
94       
95        @type **prop: dict
96        @param **prop: set properties via keywords
97        @raise AuthNInterfaceInitError: error with initialisation
98        @raise AuthNInterfaceConfigError: error with configuration
99        @raise AuthNInterfaceError: generic exception not described by the
100        other specific exception types.
101        """
102   
103    def logon(self, environ, userIdentifier, username, password):
104        """Interface login method
105       
106        @type environ: dict
107        @param environ: standard WSGI environ parameter
108       
109        @type userIdentifier: basestring or None
110        @param userIdentifier: OpenID user identifier - this implementation of
111        an OpenID Provider uses the suffix of the user's OpenID URL to specify
112        a unique user identifier.  It ID Select mode was chosen, the identifier
113        will be None and can be ignored.  In this case, the implementation of
114        the decide method in the rendering interface must match up the username
115        to a corresponding identifier in order to construct a complete OpenID
116        user URL.
117       
118        @type username: basestring
119        @param username: user identifier for authentication
120       
121        @type password: basestring
122        @param password: corresponding password for username givens
123       
124        @raise AuthNInterfaceInvalidCredentials: invalid username/password
125        @raise AuthNInterfaceUsername2IdentifierMismatch: username doesn't
126        match the OpenID URL provided by the user.  (Doesn't apply to ID Select
127        type requests).
128        @raise AuthNInterfaceRetrieveError: error with retrieval of information
129        to authenticate user e.g. error with database look-up.
130        @raise AuthNInterfaceError: generic exception not described by the
131        other specific exception types.
132        """
133        raise NotImplementedError()
134   
135    def username2UserIdentifiers(self, environ, username):
136        """Map the login username to an identifier which will become the
137        unique path suffix to the user's OpenID identifier.  The
138        OpenIDProviderMiddleware takes self.urls['id_url'] and adds it to this
139        identifier:
140       
141            identifier = self._authN.username2UserIdentifiers(environ,username)
142            identityURL = self.urls['url_id'] + '/' + identifier
143       
144        @type environ: dict
145        @param environ: standard WSGI environ parameter
146
147        @type username: basestring
148        @param username: user identifier
149       
150        @rtype: tuple
151        @return: one or more identifiers to be used to make OpenID user
152        identity URL(s).
153       
154        @raise AuthNInterfaceConfigError: problem with the configuration
155        @raise AuthNInterfaceRetrieveError: error with retrieval of information
156        to identifier e.g. error with database look-up.
157        @raise AuthNInterfaceError: generic exception not described by the
158        other specific exception types.
159        """
160        raise NotImplementedError()
161       
162       
163class OpenIDProviderMiddlewareError(Exception):
164    """OpenID Provider WSGI Middleware Error"""
165
166class OpenIDProviderConfigError(OpenIDProviderMiddlewareError):
167    """OpenID Provider Configuration Error"""
168
169class OpenIDProviderMissingRequiredAXAttrs(OpenIDProviderMiddlewareError): 
170    """Raise if a Relying Party *requires* one or more attributes via
171    the AX interface but this OpenID Provider cannot return them.  This doesn't
172    apply to attributes that are optional"""
173
174class OpenIDProviderMissingAXResponseHandler(OpenIDProviderMiddlewareError): 
175    """Raise if a Relying Party *requires* one or more attributes via
176    the AX interface but no AX Response handler has been set"""
177 
178class OpenIDProviderMiddleware(object):
179    """WSGI Middleware to implement an OpenID Provider
180   
181    @cvar defOpt: app_conf options / keywords to __init__ and their default
182    values.  Input keywords must match these
183    @type defOpt: dict
184   
185    @cvar defPaths: subset of defOpt.  These are keyword items corresponding
186    to the URL paths to be set for the individual OpenID Provider functions
187    @type: defPaths: dict
188   
189    @cvar formRespWrapperTmpl: If the response to the Relying Party is too long
190    it's rendered as form with the POST method instead of query arguments in a
191    GET 302 redirect.  Wrap the form in this document to make the form submit
192    automatically without user intervention.  See _displayResponse method
193    below...
194    @type formRespWrapperTmpl: basestring"""
195   
196    formRespWrapperTmpl = """<html>
197    <head>
198        <script type="text/javascript">
199            function doRedirect()
200            {
201                document.forms[0].submit();
202            }
203        </script>
204    </head>
205    <body onLoad="doRedirect()">
206        %s
207    </body>
208</html>"""
209
210    defOpt = dict(
211        path_openidserver='/openidserver',
212        path_login='/login',
213        path_loginsubmit='/loginsubmit',
214        path_id='/id',
215        path_yadis='/yadis',
216        path_serveryadis='/serveryadis',
217        path_allow='/allow',
218        path_decide='/decide',
219        path_mainpage='/',
220        session_middleware='beaker.session', 
221        base_url='',
222        consumer_store_dirpath='./',
223        charset=None,
224        trace=False,
225        renderingClass=None,
226        sregResponseHandler=None,
227        axResponseHandler=None,
228        authNInterface=AbstractAuthNInterface)
229   
230    defPaths=dict([(k,v) for k,v in defOpt.items() if k.startswith('path_')])
231     
232    def __init__(self, app, app_conf=None, prefix='openid.provider.', **kw):
233        '''
234        @type app: callable following WSGI interface
235        @param app: next middleware application in the chain     
236        @type app_conf: dict       
237        @param app_conf: PasteDeploy application configuration dictionary
238        @type prefix: basestring
239        @param prefix: prefix for OpenID Provider configuration items
240        @type kw: dict
241        @param kw: keyword dictionary - must follow format of defOpt
242        class variable   
243        '''
244
245        opt = OpenIDProviderMiddleware.defOpt.copy()
246        if app_conf is not None:
247            # Update from application config dictionary - filter from using
248            # prefix
249            OpenIDProviderMiddleware._filterOpts(opt, app_conf, prefix=prefix)
250                       
251        # Similarly, filter keyword input                 
252        OpenIDProviderMiddleware._filterOpts(opt, kw, prefix=prefix)
253       
254        # Update options from keywords - matching app_conf ones will be
255        # overwritten
256        opt.update(kw)
257       
258        # Convert from string type where required   
259        opt['charset'] = opt.get('charset', '')
260        opt['trace'] = opt.get('trace', 'false').lower() == 'true'
261         
262        renderingClassVal = opt.get('renderingClass', None)     
263        if renderingClassVal:
264            opt['renderingClass'] = eval_import(renderingClassVal)
265       
266        sregResponseHandlerVal = opt.get('sregResponseHandler', None) 
267        if sregResponseHandlerVal:
268            opt['sregResponseHandler'] = eval_import(sregResponseHandlerVal) 
269        else:
270            opt['sregResponseHandler'] = None
271
272        axResponseHandlerVal = opt.get('axResponseHandler', None) 
273        if axResponseHandlerVal:
274            opt['axResponseHandler'] = eval_import(axResponseHandlerVal)
275        else:
276            opt['axResponseHandler'] = None
277
278        # Authentication interface to OpenID Provider - interface to for
279        # example a user database or other means of authentication
280        authNInterfaceName = opt.get('authNInterface')
281        if authNInterfaceName:
282            authNInterfaceClass = eval_import(authNInterfaceName)
283            if not issubclass(authNInterfaceClass, AbstractAuthNInterface):
284                raise OpenIDProviderMiddlewareError("Authentication interface "
285                                                    "class %r is not a %r "
286                                                    "derived type" % 
287                                                    (authNInterfaceClass, 
288                                                     AbstractAuthNInterface))
289        else:
290            authNInterfaceClass = AbstractAuthNInterface
291       
292        # Extract Authentication interface specific properties
293        authNInterfaceProperties = dict([(k.replace('authN_', ''), v) 
294                                         for k,v in opt.items() 
295                                         if k.startswith('authN_')]) 
296         
297        try:
298            self._authN = authNInterfaceClass(**authNInterfaceProperties)
299        except Exception, e:
300            log.error("Error instantiating authentication interface: %s" % e)
301            raise
302
303        # Paths relative to base URL - Nb. remove trailing '/'
304        self.paths = dict([(k, opt[k].rstrip('/'))
305                           for k in OpenIDProviderMiddleware.defPaths])
306       
307        if not opt['base_url']:
308            raise TypeError("base_url is not set")
309       
310        self.base_url = opt['base_url']
311
312        # Full Paths
313        self.urls = dict([(k.replace('path_', 'url_'), self.base_url+v)
314                          for k,v in self.paths.items()])
315
316        self.method = dict([(v, k.replace('path_', 'do_'))
317                            for k,v in self.paths.items()])
318
319        self.session_middleware = opt['session_middleware']
320
321        if not opt['charset']:
322            self.charset = ''
323        else:
324            self.charset = '; charset='+charset
325       
326        # If True and debug log level is set display content of response
327        self._trace = opt['trace']
328
329        log.debug("opt=%r", opt)       
330       
331        # Pages can be customised by setting external rendering interface
332        # class
333        renderingClass = opt.get('renderingClass', None) or RenderingInterface         
334        if not issubclass(renderingClass, RenderingInterface):
335            raise OpenIDProviderMiddlewareError("Rendering interface "
336                                                "class %r is not a %r "
337                                                "derived type" % \
338                                                (renderingClass, 
339                                                 RenderingInterface))
340       
341        # Extract rendering interface specific properties
342        renderingProperties = dict([(k.replace('rendering_', ''), v) 
343                                         for k,v in opt.items() 
344                                         if k.startswith('rendering_')])   
345
346        try:
347            self._render = renderingClass(self._authN,
348                                          self.base_url,
349                                          self.urls,
350                                          **renderingProperties)
351        except Exception, e:
352            log.error("Error instantiating rendering interface: %s" % e)
353            raise
354                   
355        # Callable for setting of Simple Registration attributes in HTTP header
356        # of response to Relying Party
357        self.sregResponseHandler = opt.get('sregResponseHandler', None)
358        if self.sregResponseHandler and not callable(self.sregResponseHandler):
359            raise OpenIDProviderMiddlewareError("Expecting callable for "
360                                                "sregResponseHandler keyword, "
361                                                "got %r" %
362                                                self.sregResponseHandler)
363           
364        # Callable to handle OpenID Attribute Exchange (AX) requests from
365        # the Relying Party
366        self.axResponseHandler = opt.get('axResponseHandler', None)
367        if self.axResponseHandler and not callable(self.axResponseHandler):
368            raise OpenIDProviderMiddlewareError("Expecting callable for "
369                                                "axResponseHandler keyword, "
370                                                "got %r" %
371                                                self.axResponseHandler)
372       
373        self.app = app
374       
375        # Instantiate OpenID consumer store and OpenID consumer.  If you
376        # were connecting to a database, you would create the database
377        # connection and instantiate an appropriate store here.
378        store = FileOpenIDStore(
379                            os.path.expandvars(opt['consumer_store_dirpath']))
380        self.oidserver = server.Server(store, self.urls['url_openidserver'])
381
382    @classmethod
383    def main_app(cls, global_conf, **app_conf):
384        '''Provide Paste main_app function signature for inclusion in Paste ini
385        files
386        @type global_conf: dict       
387        @param global_conf: PasteDeploy configuration dictionary
388        @type app_conf: dict
389        @param app_conf: keyword dictionary - must follow format of defOpt
390        class variable'''   
391       
392        openIDProviderApp = cls(None, global_conf, **app_conf)
393       
394        # Make an application to handle invalid URLs making use of the
395        # rendering object created in the OpenID Provider initialisation
396        def app(environ, start_response):
397            msg = "Page not found"
398            response = openIDProviderApp.render.errorPage(environ, 
399                                                          start_response, 
400                                                          msg, 
401                                                          code=404)
402            return response
403       
404        # Update the OpenID Provider object with the new app
405        openIDProviderApp.app = app
406       
407        return openIDProviderApp
408       
409    @classmethod
410    def _filterOpts(cls, opt, newOpt, prefix=''):
411        '''Convenience utility to filter input options set in __init__ via
412        app_conf or keywords
413       
414        Nb. exclusions for authN and rendering interface properties.
415       
416        @type opt: dict
417        @param opt: existing options set.  These will be updated by this
418        method based on the content of newOpt
419        @type newOpt: dict
420        @param newOpt: new options to update opt with
421        @type prefix: basestring
422        @param prefix: if set, remove the given prefix from the input options
423        @raise KeyError: if an option is set that is not in the classes
424        defOpt class variable
425        '''
426        def _isBadOptName(optName):
427            # Allow for authN.* and rendering.* properties used by the
428            # Authentication and Rendering interfaces respectively
429            return optName not in cls.defOpt and \
430               not optName.startswith('authN_') and \
431               not optName.startswith('rendering_')
432               
433        badOptNames = [] 
434        for optName, optVal in newOpt.items():
435            if prefix:
436                if optName.startswith(prefix):
437                    optName = optName.replace(prefix, '')               
438                    filtOptName = '_'.join(optName.split('.'))
439                                           
440                    # Skip assignment for bad option names and record them in
441                    # an error list instead
442                    if _isBadOptName(filtOptName):
443                        badOptNames += [optName]                   
444                    else:
445                        opt[filtOptName] = optVal
446#                else:
447                    # Options not starting with prefix are ignored - omit debug
448                    # it's too verbose even for debug setting :)
449#                    log.debug("Skipping option \"%s\": it doesn't start with "
450#                              "the prefix \"%s\"", optName, prefix)
451            else:
452                filtOptName = '_'.join(optName.split('.'))
453
454                # Record any bad option names
455                if _isBadOptName(filtOptName):
456                    badOptNames += [optName]                   
457                else:
458                    opt[filtOptName] = optVal
459               
460        if len(badOptNames) > 0:
461            raise TypeError("Invalid input option(s) set: %s" % 
462                            (", ".join(badOptNames)))
463           
464
465    def __call__(self, environ, start_response):
466        """Standard WSGI interface.  Intercepts the path if it matches any of
467        the paths set in the path_* keyword settings to the config
468       
469        @type environ: dict
470        @param environ: dictionary of environment variables
471        @type start_response: callable
472        @param start_response: standard WSGI callable to set HTTP headers
473        @rtype: basestring
474        @return: WSGI response
475        """
476        if not environ.has_key(self.session_middleware):
477            raise OpenIDProviderConfigError('The session middleware %r is not '
478                                            'present. Have you set up the '
479                                            'session middleware?' % \
480                                            self.session_middleware)
481
482        self.path = environ.get('PATH_INFO').rstrip('/')
483        self.environ = environ
484        self.start_response = start_response
485        self.session = environ[self.session_middleware]
486        self._render.session = self.session
487       
488        if self.path in (self.paths['path_id'], self.paths['path_yadis']):
489            log.debug("No user id given in URL %s" % self.path)
490           
491            # Disallow identifier and yadis URIs where no ID was specified
492            return self.app(environ, start_response)
493           
494        elif self.path.startswith(self.paths['path_id']) or \
495             self.path.startswith(self.paths['path_yadis']):
496           
497            # Match against path minus ID as this is not known in advance           
498            pathMatch = self.path[:self.path.rfind('/')]
499        else:
500            pathMatch = self.path
501           
502        if pathMatch in self.method:
503            self.query = dict(paste.request.parse_formvars(environ)) 
504            log.debug("Calling method %s ..." % self.method[pathMatch]) 
505           
506            action = getattr(self, self.method[pathMatch])
507            response = action(environ, start_response) 
508            if self._trace and _debugLevel:
509                if isinstance(response, list):
510                    log.debug('Output for %s:\n%s', self.method[pathMatch],
511                                                    ''.join(response))
512                else:
513                    log.debug('Output for %s:\n%s', self.method[pathMatch],
514                                                    response)
515                   
516            return response
517        else:
518            log.debug("No match for path %s" % self.path)
519            return self.app(environ, start_response)
520
521
522    def do_id(self, environ, start_response):
523        '''URL based discovery with an ID provided
524       
525        @type environ: dict
526        @param environ: dictionary of environment variables
527        @type start_response: callable
528        @param start_response: standard WSGI callable to set HTTP headers
529        @rtype: basestring
530        @return: WSGI response
531       
532        '''
533        response = self._render.identityPage(environ, start_response)
534        return response
535
536
537    def do_yadis(self, environ, start_response):
538        """Handle Yadis based discovery with an ID provided
539       
540        @type environ: dict
541        @param environ: dictionary of environment variables
542        @type start_response: callable
543        @param start_response: standard WSGI callable to set HTTP headers
544        @rtype: basestring
545        @return: WSGI response
546
547        """
548        response = self._render.yadis(environ, start_response)
549        return response
550
551
552    def do_serveryadis(self, environ, start_response):
553        """Yadis based discovery for ID Select mode i.e. no user ID given for
554        OpenID identifier at Relying Party
555       
556        @type environ: dict
557        @param environ: dictionary of environment variables
558        @type start_response: callable
559        @param start_response: standard WSGI callable to set HTTP headers
560        @rtype: basestring
561        @return: WSGI response
562
563        """
564        response = self._render.serverYadis(environ, start_response)
565        return response
566
567
568    def do_openidserver(self, environ, start_response):
569        """OpenID Server endpoint - handles OpenID Request following discovery
570       
571        @type environ: dict
572        @param environ: dictionary of environment variables
573        @type start_response: callable
574        @param start_response: standard WSGI callable to set HTTP headers
575        @rtype: basestring
576        @return: WSGI response
577        """
578
579        try:
580            oidRequest = self.oidserver.decodeRequest(self.query)
581           
582        except server.ProtocolError, why:
583            response = self._displayResponse(why)
584           
585        else:
586            if oidRequest is None:
587                # Display text indicating that this is an endpoint.
588                response = self.do_mainpage(environ, start_response)
589           
590            # Check mode is one of "checkid_immediate", "checkid_setup"
591            elif oidRequest.mode in server.BROWSER_REQUEST_MODES:
592                response = self._handleCheckIDRequest(oidRequest)
593            else:
594                oidResponse = self.oidserver.handleRequest(oidRequest)
595                response = self._displayResponse(oidResponse)
596           
597        return response
598           
599
600    def do_allow(self, environ, start_response):
601        """Handle allow request processing the result of do_decide: does user
602        allow credentials to be passed back to the Relying Party?
603       
604        This method expects the follow fields to have been set in the posted
605        form created by the RedneringInterface.decidePage method called by
606        do_decide:
607       
608        'Yes'/'No': for return authentication details back to the RP or
609        abort return to RP respectively
610        'remember': remember the decision corresponding to the above 'Yes'
611        /'No'.
612        This may be set to 'Yes' or 'No'
613        'identity': set to the user's identity URL.  This usually is not
614        required since it can be obtained from oidRequest.identity attribute
615        but in ID Select mode, the identity URL will have been selected or set
616        in the decide page interface.
617       
618       
619        @type environ: dict
620        @param environ: dictionary of environment variables
621        @type start_response: callable
622        @param start_response: standard WSGI callable to set HTTP headers
623        @rtype: basestring
624        @return: WSGI response
625
626        """
627       
628        oidRequest = self.session.get('lastCheckIDRequest')
629        if oidRequest is None:
630            log.error("Suspected do_allow called from stale request")
631            return self._render.errorPage(environ, start_response,
632                                          "Invalid request",
633                                          code=400)
634       
635        if 'Yes' in self.query:
636            if oidRequest.idSelect():
637                identity = self.query.get('identity')
638                if identity is None:
639                    log.error("No identity field set from decide page for "
640                              "processing in ID Select mode")
641                    return self._render.errorPage(environ, start_response,
642                                                  "An internal error has "
643                                                  "occurred setting the "
644                                                  "OpenID user identity")
645            else:
646                identity = oidRequest.identity
647
648            trust_root = oidRequest.trust_root
649            if self.query.get('remember', 'No') == 'Yes':
650                self.session['approved'] = {trust_root: 'always'}
651                self.session.save()
652             
653            try:
654                oidResponse = self._identityApprovedPostProcessing(oidRequest, 
655                                                                   identity)
656
657            except (OpenIDProviderMissingRequiredAXAttrs, 
658                    OpenIDProviderMissingAXResponseHandler):
659                response = self._render.errorPage(environ, start_response,
660                    'The site where you wish to signin requires '
661                    'additional information which this site isn\'t '
662                    'configured to provide.  Please report this fault to '
663                    'your site administrator.')
664                return response
665                   
666            except Exception, e:
667                log.error("Setting response following ID Approval: %s" % e)
668                return self._render.errorPage(environ, start_response,
669                        'An error occurred setting additional parameters '
670                        'required by the site requesting your ID.  Please '
671                        'report this fault to your site administrator.')
672            else:
673                return self._displayResponse(oidResponse)
674       
675        elif 'No' in self.query:
676            # TODO: Check 'No' response is OK - No causes AuthKit's Relying
677            # Party implementation to crash with 'openid.return_to' KeyError
678            # in Authkit.authenticate.open_id.process
679            oidResponse = oidRequest.answer(False)
680            #return self._displayResponse(oidResponse)
681            return self._render.mainPage(environ, start_response)           
682        else:
683            return self._render.errorPage(environ, start_response,
684                                          'Expecting Yes/No in allow '
685                                          'post. %r' % self.query,
686                                          code=400)
687
688
689    def do_login(self, environ, start_response, **kw):
690        """Display Login form
691       
692        @type environ: dict
693        @param environ: dictionary of environment variables
694        @type start_response: callable
695        @param start_response: standard WSGI callable to set HTTP headers
696        @type kw: dict
697        @param kw: keywords to login renderer - see RenderingInterface class
698        @rtype: basestring
699        @return: WSGI response
700        """
701       
702        if 'fail_to' not in kw:
703            kw['fail_to'] = self.urls['url_login']
704           
705        response = self._render.login(environ, start_response, **kw)
706        return response
707
708
709    def do_loginsubmit(self, environ, start_response):
710        """Handle user submission from login and logout
711       
712        @type environ: dict
713        @param environ: dictionary of environment variables
714        @type start_response: callable
715        @param start_response: standard WSGI callable to set HTTP headers
716        @rtype: basestring
717        @return: WSGI response
718        """
719       
720        if 'submit' in self.query:
721            if 'username' in self.query:
722                # login
723                if 'username' in self.session:
724                    log.error("Attempting login for user %s: user %s is "
725                              "already logged in", self.session['username'],
726                              self.session['username'])
727                    return self._redirect(start_response,self.query['fail_to'])
728               
729                oidRequest = self.session.get('lastCheckIDRequest')
730                if oidRequest is None:
731                    log.error("Getting OpenID request for login - No request "
732                              "found in session")
733                    return self._render.errorPage(environ, start_response,
734                        "An internal error occured possibly due to a request "
735                        "that's expired.  Please retry from the site where "
736                        "you entered your OpenID.  If the problem persists "
737                        "report it to your site administrator.")
738                   
739                # Get user identifier to check against credentials provided
740                if oidRequest.idSelect():
741                    # ID select mode enables the user to request specifying
742                    # their OpenID Provider without giving a personal user URL
743                    userIdentifier = None
744                else:
745                    # Get the unique user identifier from the user's OpenID URL
746                    userIdentifier = oidRequest.identity.split('/')[-1]
747                   
748                # Invoke custom authentication interface plugin
749                try:
750                    self._authN.logon(environ,
751                                      userIdentifier,
752                                      self.query['username'],
753                                      self.query.get('password', ''))
754                   
755                except AuthNInterfaceError, e:
756                    return self._render.login(environ, start_response,
757                                          msg=e.userMsg,
758                                          success_to=self.urls['url_decide'])                   
759                except Exception, e:
760                    log.error("Unexpected exception raised during "
761                              "authentication: %s" % e)
762                    msg = ("An internal error occured.  "
763                           "Please try again or if the problems persists "
764                           "contact your system administrator.")
765
766                    response = self._render.login(environ, start_response,
767                                      msg=msg,
768                                      success_to=self.urls['url_decide'])
769                    return response
770                       
771                self.session['username'] = self.query['username']
772                self.session['approved'] = {}
773                self.session.save()
774            else:
775                # logout
776                if 'username' not in self.session:
777                    log.error("No user is logged in")
778                    return self._redirect(start_response,self.query['fail_to'])
779               
780                del self.session['username']
781                self.session.pop('approved', None)
782                self.session.save()
783               
784            return self._redirect(start_response, self.query['success_to'])
785       
786        elif 'cancel' in self.query:
787            return self._redirect(start_response, self.query['fail_to'])
788        else:
789            log.error('Login input not recognised %r' % self.query)
790            return self._redirect(start_response, self.query['fail_to'])
791           
792
793    def do_mainpage(self, environ, start_response):
794        '''Show an information page about the OpenID Provider
795       
796        @type environ: dict
797        @param environ: dictionary of environment variables
798        @type start_response: callable
799        @param start_response: standard WSGI callable to set HTTP headers
800        @rtype: basestring
801        @return: WSGI response
802        '''   
803        response = self._render.mainPage(environ, start_response)
804        return response
805
806    def _getRender(self):
807        """Get method for rendering interface object
808        @rtype: RenderingInterface
809        @return: rendering interface object
810        """
811        return self._render
812   
813    render = property(fget=_getRender, doc="Rendering interface instance")
814   
815   
816    def do_decide(self, environ, start_response):
817        """Display page prompting the user to decide whether to trust the site
818        requesting their credentials
819       
820        @type environ: dict
821        @param environ: dictionary of environment variables
822        @type start_response: callable
823        @param start_response: standard WSGI callable to set HTTP headers
824        @rtype: basestring
825        @return: WSGI response
826        """
827
828        oidRequest = self.session.get('lastCheckIDRequest')
829        if oidRequest is None:
830            log.error("No OpenID request set in session")
831            return self._render.errorPage(environ, start_response,
832                                          "Invalid request.  Please report "
833                                          "the error to your site "
834                                          "administrator.",
835                                          code=400)
836       
837        approvedRoots = self.session.get('approved', {})
838       
839        if oidRequest.trust_root in approvedRoots and \
840           not oidRequest.idSelect():
841            try:
842                response = self._identityApprovedPostProcessing(oidRequest, 
843                                                        oidRequest.identity)
844            except (OpenIDProviderMissingRequiredAXAttrs, 
845                    OpenIDProviderMissingAXResponseHandler):
846                response = self._render.errorPage(environ, start_response,
847                    'The site where you wish to signin requires '
848                    'additional information which this site isn\'t '
849                    'configured to provide.  Please report this fault to '
850                    'your site administrator.')
851                return response
852                   
853            except Exception, e:
854                log.error("Setting response following ID Approval: %s" % e)
855                response = self._render.errorPage(environ, start_response,
856                        'An error occurred setting additional parameters '
857                        'required by the site requesting your ID.  Please '
858                        'report this fault to your site administrator.')
859                return response
860
861            return self.oidResponse(response)
862        else:
863            return self._render.decidePage(environ, start_response, oidRequest)
864       
865       
866    def _identityIsAuthorized(self, oidRequest):
867        '''Check that a user is authorized i.e. does a session exist for their
868        username and if so does it correspond to the identity URL provided.
869        This last check doesn't apply for ID Select mode where No ID was input
870        at the Relying Party.
871       
872        @type oidRequest: openid.server.server.CheckIDRequest
873        @param oidRequest: OpenID Request object
874        @rtype: bool
875        @return: True/False is user authorized
876        '''
877        username = self.session.get('username')
878        if username is None:
879            return False
880
881        if oidRequest.idSelect():
882            log.debug("OpenIDProviderMiddleware._identityIsAuthorized - "
883                      "ID Select mode set but user is already logged in")
884            return True
885       
886        identifiers = self._authN.username2UserIdentifiers(self.environ,
887                                                           username)
888        idURLBase = self.urls['url_id']+'/'
889        identityURLs = [idURLBase+i for i in identifiers]
890        if oidRequest.identity not in identityURLs:
891            log.debug("OpenIDProviderMiddleware._identityIsAuthorized - "
892                      "user is already logged in with a different ID=%s" % \
893                      username)
894            return False
895       
896        log.debug("OpenIDProviderMiddleware._identityIsAuthorized - "
897                  "user is logged in with ID matching ID URL")
898        return True
899   
900   
901    def _trustRootIsAuthorized(self, trust_root):
902        '''Return True/False for the given trust root (Relying Party)
903        previously been approved by the user
904       
905        @type trust_root: dict
906        @param trust_root: keyed by trusted root (Relying Party) URL and
907        containing string item 'always' if approved
908        @rtype: bool
909        @return: True - trust has already been approved, False - trust root is
910        not approved'''
911        approvedRoots = self.session.get('approved', {})
912        return approvedRoots.get(trust_root) is not None
913
914
915    def _addSRegResponse(self, oidRequest, oidResponse):
916        '''Add Simple Registration attributes to response to Relying Party
917       
918        @type oidRequest: openid.server.server.CheckIDRequest
919        @param oidRequest: OpenID Check ID Request object
920        @type oidResponse: openid.server.server.OpenIDResponse
921        @param oidResponse: OpenID response object'''
922       
923        if self.sregResponseHandler is None:
924            # No Simple Registration response object was set
925            return
926       
927        sreg_req = sreg.SRegRequest.fromOpenIDRequest(oidRequest)
928
929        # Callout to external callable sets additional user attributes to be
930        # returned in response to Relying Party       
931        sreg_data = self.sregResponseHandler(self.session.get('username'))
932        sreg_resp = sreg.SRegResponse.extractResponse(sreg_req, sreg_data)
933        oidResponse.addExtension(sreg_resp)
934
935
936    def _addAXResponse(self, oidRequest, oidResponse):
937        '''Add attributes to response based on the OpenID Attribute Exchange
938        interface
939       
940        @type oidRequest: openid.server.server.CheckIDRequest
941        @param oidRequest: OpenID Check ID Request object
942        @type oidResponse: openid.server.server.OpenIDResponse
943        @param oidResponse: OpenID response object'''
944
945
946        ax_req = ax.FetchRequest.fromOpenIDRequest(oidRequest)
947        if ax_req is None:
948            log.debug("No Attribute Exchange extension set in request")
949            return
950       
951        ax_resp = ax.FetchResponse(request=ax_req)
952       
953        if self.axResponseHandler is None:
954            requiredAttr = ax_req.getRequiredAttrs()
955            if len(requiredAttr) > 0:
956                msg = ("Relying party requires these attributes: %s; but No"
957                       "Attribute exchange handler 'axResponseHandler' has "
958                       "been set" % requiredAttr)
959                log.error(msg)
960                raise OpenIDProviderMissingAXResponseHandler(msg)
961           
962            return
963       
964        # Set requested values - need user intervention here to confirm
965        # release of attributes + assignment based on required attributes -
966        # possibly via FetchRequest.getRequiredAttrs()
967        try:
968            self.axResponseHandler(ax_req,ax_resp,self.session.get('username'))
969           
970        except OpenIDProviderMissingRequiredAXAttrs, e:
971            log.error("OpenID Provider is unable to set the AX attributes "
972                      "required by the Relying Party's request: %s" % e)
973            raise
974       
975        except Exception, e:
976            log.error("%s exception raised setting requested Attribute "
977                      "Exchange values: %s" % (e.__class__, e))
978            raise
979       
980        oidResponse.addExtension(ax_resp)
981       
982       
983    def _identityApprovedPostProcessing(self, oidRequest, identifier=None):
984        '''Action following approval of a Relying Party by the user.  Add
985        Simple Registration and/or Attribute Exchange parameters if handlers
986        were specified - See _addSRegResponse and _addAXResponse methods - and
987        only if the Relying Party has requested them
988       
989        @type oidRequest: openid.server.server.CheckIDRequest
990        @param oidRequest: OpenID Check ID Request object
991        @type identifier: basestring
992        @param identifier: OpenID selected by user - for ID Select mode only
993        @rtype: openid.server.server.OpenIDResponse
994        @return: OpenID response object'''
995
996        oidResponse = oidRequest.answer(True, identity=identifier)
997        self._addSRegResponse(oidRequest, oidResponse)
998        self._addAXResponse(oidRequest, oidResponse)
999       
1000        return oidResponse
1001
1002
1003    def _handleCheckIDRequest(self, oidRequest):
1004        """Handle "checkid_immediate" and "checkid_setup" type requests from
1005        Relying Party
1006       
1007        @type oidRequest: openid.server.server.CheckIDRequest
1008        @param oidRequest: OpenID Check ID request
1009        @rtype: basestring
1010        @return: WSGI response
1011        """
1012        log.debug("OpenIDProviderMiddleware._handleCheckIDRequest ...")
1013       
1014        # Save request
1015        self.session['lastCheckIDRequest'] = oidRequest
1016        self.session.save()
1017       
1018        if self._identityIsAuthorized(oidRequest):
1019           
1020            # User is logged in - check for ID Select type request i.e. the
1021            # user entered their IdP address at the Relying Party and not their
1022            # OpenID Identifier.  In this case, the identity they wish to use
1023            # must be confirmed.
1024            if oidRequest.idSelect():
1025                # OpenID identifier must be confirmed
1026                return self.do_decide(self.environ, self.start_response)
1027           
1028            elif self._trustRootIsAuthorized(oidRequest.trust_root):
1029                # User has approved this Relying Party
1030                try:
1031                    oidResponse = self._identityApprovedPostProcessing(
1032                                                                    oidRequest)
1033                except (OpenIDProviderMissingRequiredAXAttrs, 
1034                        OpenIDProviderMissingAXResponseHandler):
1035                    response = self._render.errorPage(environ, start_response,
1036                        'The site where you wish to signin requires '
1037                        'additional information which this site isn\'t '
1038                        'configured to provide.  Please report this fault to '
1039                        'your site administrator.')
1040                    return response
1041                   
1042                except Exception, e:
1043                    log.error("Setting response following ID Approval: %s" % e)
1044                    response = self._render.errorPage(environ, start_response,
1045                        'An error occurred setting additional parameters '
1046                        'required by the site requesting your ID.  Please '
1047                        'report this fault to your site administrator.')
1048                    return response
1049               
1050                return self._displayResponse(oidResponse)
1051            else:
1052                return self.do_decide(self.environ, self.start_response)
1053               
1054        elif oidRequest.immediate:
1055            oidResponse = oidRequest.answer(False)
1056            return self._displayResponse(oidResponse)
1057       
1058        else:
1059            # User is not logged in
1060           
1061            # Call login and if successful then call decide page to confirm
1062            # user wishes to trust the Relying Party.
1063            response = self.do_login(self.environ,
1064                                     self.start_response,
1065                                     success_to=self.urls['url_decide'])
1066            return response
1067
1068
1069    def _displayResponse(self, oidResponse):
1070        """Serialize an OpenID Response object, set headers and return WSGI
1071        response.
1072       
1073        If the URL length for a GET request exceeds a maximum, then convert the
1074        response into a HTML form and use POST method.
1075       
1076        @type oidResponse: openid.server.server.OpenIDResponse
1077        @param oidResponse: OpenID response object
1078       
1079        @rtype: basestring
1080        @return: WSGI response'''
1081        """
1082       
1083        try:
1084            webresponse = self.oidserver.encodeResponse(oidResponse)
1085        except server.EncodingError, why:
1086            text = why.response.encodeToKVForm()
1087            return self.showErrorPage(text)
1088       
1089        hdr = webresponse.headers.items()
1090       
1091        # If the content length exceeds the maximum to represent on a URL, it's
1092        # rendered as a form instead
1093        # FIXME: Commented out oidResponse.renderAsForm() test as it doesn't
1094        # give consistent answers.  Testing based on body content should work
1095        # OK
1096        if webresponse.body:
1097        #if oidResponse.renderAsForm():
1098            # Wrap in HTML with Javascript OnLoad to submit the form
1099            # automatically without user intervention
1100            response = OpenIDProviderMiddleware.formRespWrapperTmpl % \
1101                                                        webresponse.body
1102        else:
1103            response = webresponse.body
1104           
1105        hdr += [('Content-type', 'text/html'+self.charset),
1106                ('Content-length', str(len(response)))]
1107           
1108        self.start_response('%d %s' % (webresponse.code, 
1109                                       httplib.responses[webresponse.code]), 
1110                            hdr)
1111        return response
1112
1113
1114    def _redirect(self, start_response, url):
1115        """Do a HTTP 302 redirect
1116       
1117        @type start_response: callable following WSGI start_response convention
1118        @param start_response: WSGI start response callable
1119        @type url: basestring
1120        @param url: URL to redirect to
1121        @rtype: list
1122        @return: empty HTML body
1123        """
1124        start_response('302 %s' % httplib.responses[302], 
1125                       [('Content-type', 'text/html'+self.charset),
1126                        ('Location', url)])
1127        return []
1128   
1129   
1130class RenderingInterfaceError(Exception):
1131    """Base class for RenderingInterface exceptions
1132   
1133    A standard message is raised set by the msg class variable but the actual
1134    exception details are logged to the error log.  The use of a standard
1135    message enables callers to use its content for user error messages.
1136   
1137    @type msg: basestring
1138    @cvar msg: standard message to be raised for this exception"""
1139    userMsg = ("An internal error occurred with the page layout,  Please "
1140               "contact your system administrator")
1141    errorMsg = "RenderingInterface error"
1142   
1143    def __init__(self, *arg, **kw):
1144        if len(arg) > 0:
1145            msg = arg[0]
1146        else:
1147            msg = self.__class__.errorMsg
1148           
1149        log.error(msg)
1150        Exception.__init__(self, msg, **kw)
1151       
1152class RenderingInterfaceInitError(RenderingInterfaceError):
1153    """Error with initialisation of RenderingInterface.  Raise from __init__"""
1154    errorMsg = "RenderingInterface initialisation error"
1155   
1156class RenderingInterfaceConfigError(RenderingInterfaceError):
1157    """Error with configuration settings.  Raise from __init__"""
1158    errorMsg = "RenderingInterface configuration error"   
1159   
1160class RenderingInterface(object):
1161    """Interface class for rendering of OpenID Provider pages.  It implements
1162    methods for handling Yadis requests only.  All other interface methods
1163    return a 404 error response.  Create a derivative from this class to
1164    implement the other rendering methods as required.  DemoRenderingInterface
1165    provides an example of how to do this.  To apply a custom
1166    RenderingInterface class pass it's name in the OpenIDProviderMiddleware
1167    app_conf dict or as a keyword argument using the option name
1168    renderingClass.
1169   
1170    @cvar tmplServerYadis: template for returning Yadis document to Relying
1171    Party.  Derived classes can reset this or completely override the
1172    serverYadis method.
1173   
1174    @type tmplServerYadis: basestring
1175   
1176    @cvar tmplYadis: template for returning Yadis document containing user
1177    URL to Relying Party.  Derived classes can reset this or completely
1178    override the yadis method.
1179   
1180    @type tmplYadis: basestring"""
1181   
1182    tmplServerYadis = """\
1183<?xml version="1.0" encoding="UTF-8"?>
1184<xrds:XRDS
1185    xmlns:xrds="xri://$xrds"
1186    xmlns="xri://$xrd*($OptNameSfx*2.0)">
1187  <XRD>
1188
1189    <Service priority="0">
1190      <Type>%(openid20type)s</Type>
1191      <URI>%(endpoint_url)s</URI>
1192    </Service>
1193
1194  </XRD>
1195</xrds:XRDS>
1196"""
1197
1198    tmplYadis = """\
1199<?xml version="1.0" encoding="UTF-8"?>
1200<xrds:XRDS
1201    xmlns:xrds="xri://$xrds"
1202    xmlns="xri://$xrd*($v*2.0)">
1203  <XRD>
1204
1205    <Service priority="0">
1206      <Type>%(openid20type)s</Type>
1207      <Type>%(openid10type)s</Type>
1208      <URI>%(endpoint_url)s</URI>
1209      <LocalID>%(user_url)s</LocalID>
1210    </Service>
1211
1212  </XRD>
1213</xrds:XRDS>"""   
1214   
1215    def __init__(self, authN, base_url, urls, **opt):
1216        """
1217        @type authN: AuthNInterface
1218        @param param: reference to authentication interface to enable OpenID
1219        user URL construction from username
1220        @type base_url: basestring
1221        @param base_url: base URL for OpenID Provider to which individual paths
1222        are appended
1223        @type urls: dict
1224        @param urls: full urls for all the paths used by all the exposed
1225        methods - keyed by method name - see OpenIDProviderMiddleware.paths
1226        @type opt: dict
1227        @param opt: additional custom options passed from the
1228        OpenIDProviderMiddleware config
1229        """
1230        self._authN = authN
1231        self.base_url = base_url
1232        self.urls = urls
1233        self.charset = ''
1234   
1235   
1236    def serverYadis(self, environ, start_response):
1237        '''Render Yadis info for ID Select mode request
1238       
1239        @type environ: dict
1240        @param environ: dictionary of environment variables
1241        @type start_response: callable
1242        @param start_response: WSGI start response function.  Should be called
1243        from this method to set the response code and HTTP header content
1244        @rtype: basestring
1245        @return: WSGI response
1246        '''
1247        endpoint_url = self.urls['url_openidserver']
1248        response = RenderingInterface.tmplServerYadis % \
1249                                {'openid20type': discover.OPENID_IDP_2_0_TYPE, 
1250                                 'endpoint_url': endpoint_url}
1251             
1252        start_response("200 OK", 
1253                       [('Content-type', 'application/xrds+xml'),
1254                        ('Content-length', str(len(response)))])
1255        return response
1256
1257
1258    def yadis(self, environ, start_response):
1259        """Render Yadis document containing user URL
1260       
1261        @type environ: dict
1262        @param environ: dictionary of environment variables
1263        @type start_response: callable
1264        @param start_response: WSGI start response function.  Should be called
1265        from this method to set the response code and HTTP header content
1266        @rtype: basestring
1267        @return: WSGI response
1268        """
1269        # Override this method to implement an alternate means to derive the
1270        # username identifier
1271        userIdentifier = environ['PATH_INFO'].rstrip('/').split('/')[-1]
1272       
1273        endpoint_url = self.urls['url_openidserver']
1274        user_url = self.urls['url_id'] + '/' + userIdentifier
1275       
1276        yadisDict = dict(openid20type=discover.OPENID_2_0_TYPE, 
1277                         openid10type=discover.OPENID_1_0_TYPE,
1278                         endpoint_url=endpoint_url, 
1279                         user_url=user_url)
1280       
1281        response = RenderingInterface.tmplYadis % yadisDict
1282     
1283        start_response('200 OK',
1284                       [('Content-type', 'application/xrds+xml'+self.charset),
1285                        ('Content-length', str(len(response)))])
1286        return response
1287   
1288
1289    def identityPage(self, environ, start_response):
1290        """Render the identity page.
1291       
1292        @type environ: dict
1293        @param environ: dictionary of environment variables
1294        @type start_response: callable
1295        @param start_response: WSGI start response function.  Should be called
1296        from this method to set the response code and HTTP header content
1297        @rtype: basestring
1298        @return: WSGI response
1299        """
1300        response = "Page is not implemented"
1301        start_response('%d %s' % (404, httplib.responses[code]), 
1302                       [('Content-type', 'text/html'+self.charset),
1303                        ('Content-length', str(len(response)))])
1304        return response
1305   
1306       
1307    def login(self, environ, start_response, 
1308              success_to=None, fail_to=None, msg=''):
1309        """Render the login form.
1310       
1311        @type environ: dict
1312        @param environ: dictionary of environment variables
1313        @type start_response: callable
1314        @param start_response: WSGI start response function.  Should be called
1315        from this method to set the response code and HTTP header content
1316        @type success_to: basestring
1317        @param success_to: URL put into hidden field telling 
1318        OpenIDProviderMiddleware.do_loginsubmit() where to forward to on
1319        successful login
1320        @type fail_to: basestring
1321        @param fail_to: URL put into hidden field telling 
1322        OpenIDProviderMiddleware.do_loginsubmit() where to forward to on
1323        login error
1324        @type msg: basestring
1325        @param msg: display (error) message below login form e.g. following
1326        previous failed login attempt.
1327        @rtype: basestring
1328        @return: WSGI response
1329        """
1330       
1331        response = "Page is not implemented"
1332        start_response('%d %s' % (404, httplib.responses[code]), 
1333                       [('Content-type', 'text/html'+self.charset),
1334                        ('Content-length', str(len(response)))])
1335        return response
1336
1337
1338    def mainPage(self, environ, start_response):
1339        """Rendering the main page.
1340       
1341        @type environ: dict
1342        @param environ: dictionary of environment variables
1343        @type start_response: callable
1344        @param start_response: WSGI start response function.  Should be called
1345        from this method to set the response code and HTTP header content
1346        @rtype: basestring
1347        @return: WSGI response
1348        """   
1349        response = "Page is not implemented"
1350        start_response('%d %s' % (404, httplib.responses[code]), 
1351                       [('Content-type', 'text/html'+self.charset),
1352                        ('Content-length', str(len(response)))])
1353        return response
1354   
1355
1356    def decidePage(self, environ, start_response, oidRequest):
1357        """Show page giving the user the option to approve the return of their
1358        credentials to the Relying Party.  This page is also displayed for
1359        ID select mode if the user is already logged in at the OpenID Provider.
1360        This enables them to confirm the OpenID to be sent back to the
1361        Relying Party
1362
1363        These fields should be posted by this page ready for
1364        OpenIdProviderMiddleware.do_allow to process:
1365       
1366        'Yes'/'No': for return authentication details back to the RP or
1367        abort return to RP respectively
1368        'remember': remember the decision corresponding to the above 'Yes'
1369        /'No'.
1370        This may be set to 'Yes' or 'No'
1371        'identity': set to the user's identity URL.  This usually is not
1372        required since it can be obtained from oidRequest.identity attribute
1373        but in ID Select mode, the identity URL will have been selected or set
1374        here.
1375       
1376       
1377        @type environ: dict
1378        @param environ: dictionary of environment variables
1379        @type start_response: callable
1380        @param start_response: WSGI start response function.  Should be called
1381        from this method to set the response code and HTTP header content
1382        @type oidRequest: openid.server.server.CheckIDRequest
1383        @param oidRequest: OpenID Check ID Request object
1384        @rtype: basestring
1385        @return: WSGI response
1386        """
1387        response = "Page is not implemented"
1388        start_response('%d %s' % (404, httplib.responses[code]), 
1389                       [('Content-type', 'text/html'+self.charset),
1390                        ('Content-length', str(len(response)))])
1391        return response
1392
1393
1394    def errorPage(self, environ, start_response, msg, code=500):
1395        """Display error page
1396       
1397        @type environ: dict
1398        @param environ: dictionary of environment variables
1399        @type start_response: callable
1400        @param start_response: WSGI start response function.  Should be called
1401        from this method to set the response code and HTTP header content
1402        @type msg: basestring
1403        @param msg: optional message for page body
1404        @type code: int
1405        @param code: HTTP Error code to return
1406        @rtype: basestring
1407        @return: WSGI response
1408        """     
1409        response = "Page is not implemented"
1410        start_response('%d %s' % (404, httplib.responses[code]), 
1411                       [('Content-type', 'text/html'+self.charset),
1412                        ('Content-length', str(len(response)))])
1413        return response
1414       
1415   
1416class DemoRenderingInterface(RenderingInterface):
1417    """Example rendering interface class for demonstration purposes"""
1418   
1419    def identityPage(self, environ, start_response):
1420        """Render the identity page.
1421       
1422        @type environ: dict
1423        @param environ: dictionary of environment variables
1424        @type start_response: callable
1425        @param start_response: WSGI start response function.  Should be called
1426        from this method to set the response code and HTTP header content
1427        @rtype: basestring
1428        @return: WSGI response
1429        """
1430        path = environ.get('PATH_INFO').rstrip('/')
1431        userIdentifier = path.split('/')[-1]
1432       
1433        link_tag = '<link rel="openid.server" href="%s">' % \
1434              self.urls['url_openidserver']
1435             
1436        yadis_loc_tag = '<meta http-equiv="x-xrds-location" content="%s">' % \
1437            (self.urls['url_yadis']+'/'+userIdentifier)
1438           
1439        disco_tags = link_tag + yadis_loc_tag
1440        ident = self.base_url + path
1441
1442        response = self._showPage(environ, 
1443                                  'Identity Page', 
1444                                  head_extras=disco_tags, 
1445                                  msg='<p>This is the identity page for %s.'
1446                                      '</p>' % ident)
1447       
1448        start_response("200 OK", 
1449                       [('Content-type', 'text/html'+self.charset),
1450                        ('Content-length', str(len(response)))])
1451        return response
1452   
1453       
1454    def login(self, environ, start_response, 
1455              success_to=None, fail_to=None, msg=''):
1456        """Render the login form.
1457       
1458        @type environ: dict
1459        @param environ: dictionary of environment variables
1460        @type success_to: basestring
1461        @param success_to: URL put into hidden field telling 
1462        OpenIDProviderMiddleware.do_loginsubmit() where to forward to on
1463        successful login
1464        @type fail_to: basestring
1465        @param fail_to: URL put into hidden field telling 
1466        OpenIDProviderMiddleware.do_loginsubmit() where to forward to on
1467        login error
1468        @type msg: basestring
1469        @param msg: display (error) message below login form e.g. following
1470        previous failed login attempt.
1471        @rtype: basestring
1472        @return: WSGI response
1473        """
1474       
1475        if success_to is None:
1476            success_to = self.urls['url_mainpage']
1477           
1478        if fail_to is None:
1479            fail_to = self.urls['url_mainpage']
1480       
1481        form = '''\
1482<h2>Login</h2>
1483<form method="GET" action="%s">
1484  <input type="hidden" name="success_to" value="%s" />
1485  <input type="hidden" name="fail_to" value="%s" />
1486  <table cellspacing="0" border="0" cellpadding="5">
1487    <tr>
1488        <td>Username:</td>
1489        <td><input type="text" name="username" value=""/></td>
1490    </tr><tr>
1491        <td>Password:</td>
1492        <td><input type="password" name="password"/></td>
1493    </tr><tr>
1494        <td colspan="2" align="right">
1495            <input type="submit" name="submit" value="Login"/>
1496            <input type="submit" name="cancel" value="Cancel"/>
1497        </td>
1498    </tr>
1499  </table>
1500</form>
1501%s
1502''' % (self.urls['url_loginsubmit'], success_to, fail_to, msg)
1503
1504        response = self._showPage(environ, 'Login Page', form=form)
1505        start_response('200 OK', 
1506                       [('Content-type', 'text/html'+self.charset),
1507                        ('Content-length', str(len(response)))])
1508        return response
1509
1510
1511    def mainPage(self, environ, start_response):
1512        """Rendering the main page.
1513       
1514        @type environ: dict
1515        @param environ: dictionary of environment variables
1516        @type start_response: callable
1517        @param start_response: WSGI start response function.  Should be called
1518        from this method to set the response code and HTTP header content
1519        @rtype: basestring
1520        @return: WSGI response
1521        """
1522       
1523        yadis_tag = '<meta http-equiv="x-xrds-location" content="%s">' % \
1524                    self.urls['url_serveryadis']
1525        username = environ['beaker.session'].get('username')   
1526        if username:
1527            openid_url = self.urls['url_id'] + '/' + username
1528            user_message = """\
1529            <p>You are logged in as %s. Your OpenID identity URL is
1530            <tt><a href=%s>%s</a></tt>. Enter that URL at an OpenID
1531            consumer to test this server.</p>
1532            """ % (username, quoteattr(openid_url), openid_url)
1533        else:
1534            user_message = "<p>You are not <a href='%s'>logged in</a>.</p>" % \
1535                            self.urls['url_login']
1536
1537        msg = '''\
1538<p>OpenID server</p>
1539
1540%s
1541
1542<p>The URL for this server is <a href=%s><tt>%s</tt></a>.</p>
1543''' % (user_message, quoteattr(self.base_url), self.base_url)
1544        response = self._showPage(environ,
1545                                  'Main Page', 
1546                                  head_extras=yadis_tag, 
1547                                  msg=msg)
1548   
1549        start_response('200 OK', 
1550                       [('Content-type', 'text/html'+self.charset),
1551                        ('Content-length', str(len(response)))])
1552        return response
1553   
1554
1555    def decidePage(self, environ, start_response, oidRequest):
1556        """Show page giving the user the option to approve the return of their
1557        credentials to the Relying Party.  This page is also displayed for
1558        ID select mode if the user is already logged in at the OpenID Provider.
1559        This enables them to confirm the OpenID to be sent back to the
1560        Relying Party
1561       
1562        @type environ: dict
1563        @param environ: dictionary of environment variables
1564        @type start_response: callable
1565        @param start_response: WSGI start response function.  Should be called
1566        from this method to set the response code and HTTP header content
1567        @type oidRequest: openid.server.server.CheckIDRequest
1568        @param oidRequest: OpenID Check ID Request object
1569        @rtype: basestring
1570        @return: WSGI response
1571        """
1572        idURLBase = self.urls['url_id'] + '/'
1573       
1574        # XXX: This may break if there are any synonyms for idURLBase,
1575        # such as referring to it by IP address or a CNAME.
1576       
1577        # TODO: OpenID 2.0 Allows oidRequest.identity to be set to
1578        # http://specs.openid.net/auth/2.0/identifier_select.  See,
1579        # http://openid.net/specs/openid-authentication-2_0.html.  This code
1580        # implements this overriding the behaviour of the example code on
1581        # which this is based.  - Check is the example code based on OpenID 1.0
1582        # and therefore wrong for this behaviour?
1583#        assert oidRequest.identity.startswith(idURLBase), \
1584#               repr((oidRequest.identity, idURLBase))
1585        userIdentifier = oidRequest.identity[len(idURLBase):]
1586        username = environ['beaker.session']['username']
1587       
1588        if oidRequest.idSelect(): # We are being asked to select an ID
1589            userIdentifier = self._authN.username2UserIdentifiers(environ,
1590                                                                  username)[0]
1591            identity = idURLBase + userIdentifier
1592           
1593            msg = '''\
1594            <p>A site has asked for your identity.  You may select an
1595            identifier by which you would like this site to know you.
1596            On a production site this would likely be a drop down list
1597            of pre-created accounts or have the facility to generate
1598            a random anonymous identifier.
1599            </p>
1600            '''
1601            fdata = {
1602                'pathAllow': self.urls['url_allow'],
1603                'identity': identity,
1604                'trust_root': oidRequest.trust_root,
1605                }
1606            form = '''\
1607<form method="POST" action="%(pathAllow)s">
1608<table>
1609  <tr><td>Identity:</td>
1610     <td>%(identity)s</td></tr>
1611  <tr><td>Trust Root:</td><td>%(trust_root)s</td></tr>
1612</table>
1613<p>Allow this authentication to proceed?</p>
1614<input type="checkbox" id="remember" name="remember" value="Yes"
1615    /><label for="remember">Remember this
1616    decision</label><br />
1617<input type="hidden" name="identity" value="%(identity)s" />
1618<input type="submit" name="Yes" value="Yes" />
1619<input type="submit" name="No" value="No" />
1620</form>
1621''' % fdata
1622           
1623        elif userIdentifier in self._authN.username2UserIdentifiers(environ,
1624                                                                    username):
1625            msg = '''\
1626            <p>A new site has asked to confirm your identity.  If you
1627            approve, the site represented by the trust root below will
1628            be told that you control identity URL listed below. (If
1629            you are using a delegated identity, the site will take
1630            care of reversing the delegation on its own.)</p>'''
1631
1632            fdata = {
1633                'pathAllow': self.urls['url_allow'],
1634                'identity': oidRequest.identity,
1635                'trust_root': oidRequest.trust_root,
1636                }
1637            form = '''\
1638<table>
1639  <tr><td>Identity:</td><td>%(identity)s</td></tr>
1640  <tr><td>Trust Root:</td><td>%(trust_root)s</td></tr>
1641</table>
1642<p>Allow this authentication to proceed?</p>
1643<form method="POST" action="%(pathAllow)s">
1644  <input type="checkbox" id="remember" name="remember" value="Yes"
1645      /><label for="remember">Remember this
1646      decision</label><br />
1647  <input type="submit" name="Yes" value="Yes" />
1648  <input type="submit" name="No" value="No" />
1649</form>''' % fdata
1650        else:
1651            mdata = {
1652                'userIdentifier': userIdentifier,
1653                'username': username,
1654                }
1655            msg = '''\
1656            <p>A site has asked for an identity belonging to
1657            %(userIdentifier)s, but you are logged in as %(username)s.  To
1658            log in as %(userIdentifier)s and approve the login oidRequest,
1659            hit OK below.  The "Remember this decision" checkbox
1660            applies only to the trust root decision.</p>''' % mdata
1661
1662            fdata = {
1663                'pathAllow': self.urls['url_allow'],
1664                'identity': oidRequest.identity,
1665                'trust_root': oidRequest.trust_root,
1666                'username': username,
1667                }
1668            form = '''\
1669<table>
1670  <tr><td>Identity:</td><td>%(identity)s</td></tr>
1671  <tr><td>Trust Root:</td><td>%(trust_root)s</td></tr>
1672</table>
1673<p>Allow this authentication to proceed?</p>
1674<form method="POST" action="%(pathAllow)s">
1675  <input type="checkbox" id="remember" name="remember" value="Yes"
1676      /><label for="remember">Remember this
1677      decision</label><br />
1678  <input type="hidden" name="login_as" value="%(username)s"/>
1679  <input type="submit" name="Yes" value="Yes" />
1680  <input type="submit" name="No" value="No" />
1681</form>''' % fdata
1682
1683        response = self._showPage(environ, 'Approve OpenID request?', 
1684                                  msg=msg, form=form)           
1685        start_response('200 OK', 
1686                       [('Content-type', 'text/html'+self.charset),
1687                        ('Content-length', str(len(response)))])
1688        return response
1689   
1690
1691    def _showPage(self, 
1692                  environ, 
1693                  title, 
1694                  head_extras='', 
1695                  msg=None, 
1696                  err=None, 
1697                  form=None):
1698        """Generic page rendering method.  Derived classes may ignore this.
1699       
1700        @type environ: dict
1701        @param environ: dictionary of environment variables
1702        @type title: basestring
1703        @param title: page title
1704        @type head_extras: basestring
1705        @param head_extras: add extra HTML header elements
1706        @type msg: basestring
1707        @param msg: optional message for page body
1708        @type err: basestring
1709        @param err: optional error message for page body
1710        @type form: basestring
1711        @param form: optional form for page body       
1712        @rtype: basestring
1713        @return: WSGI response
1714        """
1715       
1716        username = environ['beaker.session'].get('username')
1717        if username is None:
1718            user_link = '<a href="/login">not logged in</a>.'
1719        else:
1720            user_link = 'logged in as <a href="%s/%s">%s</a>.<br />'\
1721                        '<a href="%s?submit=true&'\
1722                        'success_to=%s">Log out</a>' % \
1723                        (self.urls['url_id'], username, username, 
1724                         self.urls['url_loginsubmit'],
1725                         self.urls['url_login'])
1726
1727        body = ''
1728
1729        if err is not None:
1730            body +=  '''\
1731            <div class="error">
1732              %s
1733            </div>
1734            ''' % err
1735
1736        if msg is not None:
1737            body += '''\
1738            <div class="message">
1739              %s
1740            </div>
1741            ''' % msg
1742
1743        if form is not None:
1744            body += '''\
1745            <div class="form">
1746              %s
1747            </div>
1748            ''' % form
1749
1750        contents = {
1751            'title': 'Python OpenID Provider - ' + title,
1752            'head_extras': head_extras,
1753            'body': body,
1754            'user_link': user_link,
1755            }
1756
1757        response = '''<html>
1758  <head>
1759    <title>%(title)s</title>
1760    %(head_extras)s
1761  </head>
1762  <style type="text/css">
1763      h1 a:link {
1764          color: black;
1765          text-decoration: none;
1766      }
1767      h1 a:visited {
1768          color: black;
1769          text-decoration: none;
1770      }
1771      h1 a:hover {
1772          text-decoration: underline;
1773      }
1774      body {
1775        font-family: verdana,sans-serif;
1776        width: 50em;
1777        margin: 1em;
1778      }
1779      div {
1780        padding: .5em;
1781      }
1782      table {
1783        margin: none;
1784        padding: none;
1785      }
1786      .banner {
1787        padding: none 1em 1em 1em;
1788        width: 100%%;
1789      }
1790      .leftbanner {
1791        text-align: left;
1792      }
1793      .rightbanner {
1794        text-align: right;
1795        font-size: smaller;
1796      }
1797      .error {
1798        border: 1px solid #ff0000;
1799        background: #ffaaaa;
1800        margin: .5em;
1801      }
1802      .message {
1803        border: 1px solid #2233ff;
1804        background: #eeeeff;
1805        margin: .5em;
1806      }
1807      .form {
1808        border: 1px solid #777777;
1809        background: #ddddcc;
1810        margin: .5em;
1811        margin-top: 1em;
1812        padding-bottom: 0em;
1813      }
1814      dd {
1815        margin-bottom: 0.5em;
1816      }
1817  </style>
1818  <body>
1819    <table class="banner">
1820      <tr>
1821        <td class="leftbanner">
1822          <h1><a href="/">Python OpenID Provider</a></h1>
1823        </td>
1824        <td class="rightbanner">
1825          You are %(user_link)s
1826        </td>
1827      </tr>
1828    </table>
1829%(body)s
1830  </body>
1831</html>
1832''' % contents
1833
1834        return response
1835
1836    def errorPage(self, environ, start_response, msg, code=500):
1837        """Display error page
1838       
1839        @type environ: dict
1840        @param environ: dictionary of environment variables
1841        @type start_response: callable
1842        @param start_response: WSGI start response function.  Should be called
1843        from this method to set the response code and HTTP header content
1844        @type msg: basestring
1845        @param msg: optional message for page body
1846        @rtype: basestring
1847        @return: WSGI response
1848        """
1849       
1850        response = self._showPage(environ, 'Error Processing Request', err='''\
1851        <p>%s</p>
1852        <!--
1853
1854        This is a large comment.  It exists to make this page larger.
1855        That is unfortunately necessary because of the "smart"
1856        handling of pages returned with an error code in IE.
1857
1858        *************************************************************
1859        *************************************************************
1860        *************************************************************
1861        *************************************************************
1862        *************************************************************
1863        *************************************************************
1864        *************************************************************
1865        *************************************************************
1866        *************************************************************
1867        *************************************************************
1868        *************************************************************
1869        *************************************************************
1870        *************************************************************
1871        *************************************************************
1872        *************************************************************
1873        *************************************************************
1874        *************************************************************
1875        *************************************************************
1876        *************************************************************
1877        *************************************************************
1878        *************************************************************
1879        *************************************************************
1880        *************************************************************
1881
1882        -->
1883        ''' % msg)
1884       
1885        start_response('%d %s' % (code, httplib.responses[code]), 
1886                       [('Content-type', 'text/html'+self.charset),
1887                        ('Content-length', str(len(response)))])
1888        return response
Note: See TracBrowser for help on using the repository browser.