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

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