source: TI12-security/trunk/python/ndg.security.server/ndg/security/server/wsgi/openid_provider.py @ 4404

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/TI12-security/trunk/python/ndg.security.server/ndg/security/server/wsgi/openid_provider.py@4501
Revision 4404, 52.8 KB checked in by pjkersha, 11 years ago (diff)

Updated contact e-mail address

Line 
1"""NDG Security OpenID Provider Middleware
2
3Compliments AuthKit OpenID Middleware used for OpenID *Relying Party*
4
5NERC Data Grid Project
6
7This software may be distributed under the terms of the Q Public License,
8version 1.0 or later.
9"""
10__author__ = "P J Kershaw"
11__date__ = "01/08/08"
12__copyright__ = "(C) 2008 STFC & NERC"
13__contact__ = "Philip.Kershaw@stfc.ac.uk"
14__revision__ = "$Id$"
15
16import logging
17log = logging.getLogger(__name__)
18_debugLevel = log.getEffectiveLevel() <= logging.DEBUG
19
20import paste.request
21from paste.util.import_string import eval_import
22
23from authkit.authenticate import AuthKitConfigError
24
25from openid.extensions import sreg, ax
26from openid.server import server
27from openid.store.filestore import FileOpenIDStore
28from openid.consumer import discover
29
30import httplib
31import sys
32import cgi
33quoteattr = lambda s: '"%s"' % cgi.escape(s, 1)
34getInvalidKw = lambda kw: [k for k in kw if k not in \
35                           OpenIDProviderMiddleware.defKw]
36
37class OpenIDProviderMiddlewareError(Exception):
38    """OpenID Provider WSGI Middleware Error"""
39
40class OpenIDProviderConfigError(Exception):
41    """OpenID Provider Configuration Error"""
42   
43class OpenIDProviderMiddleware(object):
44    """WSGI Middleware to implement an OpenID Provider
45   
46    @cvar defKw: keywords to __init__ and their default values.  Input
47    keywords must match these
48    @type defKw: dict
49   
50    @cvar defPaths: subset of defKw.  These are keyword items corresponding
51    to the URL paths to be set for the individual OpenID Provider functions
52    @type: defPaths: dict
53   
54    @cvar formRespWrapperTmpl: If the response to the Relying Party is too long
55    it's rendered as form with the POST method instead of query arguments in a
56    GET 302 redirect.  Wrap the form in this document to make the form submit
57    automatically without user intervention.  See _displayResponse method
58    below...
59    @type formRespWrapperTmpl: basestring"""
60   
61    formRespWrapperTmpl = """<html>
62    <head>
63        <script type="text/javascript">
64            function doRedirect()
65            {
66                document.forms[0].submit();
67            }
68        </script>
69    </head>
70    <body onLoad="doRedirect()">
71        %s
72    </body>
73</html>"""
74
75    defKw = dict(path_openidserver='/openidserver',
76               path_login='/login',
77               path_loginsubmit='/loginsubmit',
78               path_id='/id',
79               path_yadis='/yadis',
80               path_serveryadis='/serveryadis',
81               path_allow='/allow',
82               path_decide='/decide',
83               path_mainpage='/',
84               session_middleware='beaker.session', 
85               base_url='',
86               consumer_store_dirpath='./',
87               charset=None,
88               trace=False,
89               renderingClass=None,
90               sregResponseHandler=None,
91               axResponseHandler=None,
92               usercreds=None)
93   
94    defPaths=dict([(k,v) for k,v in defKw.items() if k.startswith('path_')])
95     
96    def __init__(self, app, app_conf=None, prefix='openid_provider.', **kw):
97        '''
98        @type app: callable following WSGI interface
99        @param app: next middleware application in the chain     
100        @type app_conf: dict       
101        @param app_conf: PasteDeploy application configuration dictionary
102        @type prefix: basestring
103        @param prefix: prefix for OpenID Provider configuration items
104        @type kw: dict
105        @param kw: keyword dictionary - must follow format of defKw
106        class variable   
107        '''
108
109        opt = OpenIDProviderMiddleware.defKw.copy()
110        kw2AppConfOpt = {}
111        if app_conf is not None:
112            # Update from application config dictionary - filter from using
113            # prefix
114            for k,v in app_conf.items():
115                if k.startswith(prefix):
116                    subK = k.replace(prefix, '')                   
117                    filtK = '_'.join(subK.split('.'))                   
118                    opt[filtK] = v
119                    kw2AppConfOpt[filtK] = k
120                   
121            invalidOpt = getInvalidKw(opt)
122            if len(invalidOpt) > 0:
123                raise TypeError("Unexpected app_conf option(s): %s" % \
124                        (", ".join([kw2AppConfOpt[i] for i in invalidOpt])))
125           
126            # Convert from string type where required   
127            opt['charset'] = eval(opt.get('charset', 'None'))     
128            opt['trace'] = bool(eval(opt.get('trace', 'False'))) 
129             
130            renderingClassVal = opt.get('renderingClass', None)     
131            if renderingClassVal:
132                opt['renderingClass'] = eval_import(renderingClassVal)
133           
134            sregResponseHandlerVal = opt.get('sregResponseHandler', None) 
135            if sregResponseHandlerVal:
136                opt['sregResponseHandler']=eval_import(sregResponseHandlerVal) 
137            else:
138                 opt['sregResponseHandler'] = None
139
140            axResponseHandlerVal = opt.get('axResponseHandler', None) 
141            if axResponseHandlerVal:
142                opt['axResponseHandler'] = eval_import(axResponseHandlerVal)
143            else:
144                opt['axResponseHandler'] = None
145                         
146        invalidKw = getInvalidKw(kw)
147        if len(invalidKw) > 0:
148            raise TypeError("Unexpected keyword(s): %s" % ", ".join(invalidKw))
149       
150        # Update options from keywords - matching app_conf ones will be
151        # overwritten
152        opt.update(kw)
153
154        # Paths relative to base URL - Nb. remove trailing '/'
155        self.paths = dict([(k, opt[k].rstrip('/')) \
156                           for k in OpenIDProviderMiddleware.defPaths])
157       
158        if not opt['base_url']:
159            raise TypeError("base_url is not set")
160       
161        self.base_url = opt['base_url']
162
163        # Full Paths
164        self.urls = dict([(k.replace('path_', 'url_'), self.base_url+v) \
165                          for k,v in self.paths.items()])
166
167        self.method = dict([(v, k.replace('path_', 'do_')) \
168                            for k,v in self.paths.items()])
169
170        self.session_middleware = opt['session_middleware']
171
172        if opt['charset'] is None:
173            self.charset = ''
174        else:
175            self.charset = '; charset='+charset
176       
177        # If True and debug log level is set display content of response
178        self._trace = opt['trace']
179
180        # Test/Admin username/password set from ini/kw args
181        userCreds = opt.get('usercreds')
182        if userCreds:
183            self._userCreds = dict([i.strip().split(':') \
184                                    for i in userCreds.split(',')])
185        else:
186            self._userCreds = {}
187
188        # TODO: revise this once a link an authN mechanism has been included.
189        if not self._userCreds:
190            raise OpenIDProviderConfigError("No username/password config "
191                                            "set-up")
192
193        log.debug("opt=%r", opt)       
194       
195        # Pages can be customised by setting external rendering interface
196        # class
197        renderingClass = opt.get('renderingClass', None) or RenderingInterface         
198        if not issubclass(renderingClass, RenderingInterface):
199            raise OpenIDProviderMiddlewareError("Rendering interface "
200                                                "class %r is not a %r "
201                                                "derived type" % \
202                                                (renderingClass, 
203                                                 RenderingInterface))
204
205        try:
206            self._renderer = renderingClass(self.base_url, self.urls)
207        except Exception, e:
208            log.error("Error instantiating rendering interface...")
209            raise
210           
211        # Callable for setting of Simple Registration attributes in HTTP header
212        # of response to Relying Party
213        self.sregResponseHandler = opt.get('sregResponseHandler', None)
214        if self.sregResponseHandler and not callable(self.sregResponseHandler):
215            raise OpenIDProviderMiddlewareError("Expecting callable for "
216                                                "sregResponseHandler keyword, "
217                                                "got %r" % \
218                                                self.sregResponseHandler)
219           
220        # Callable to handle OpenID Attribute Exchange (AX) requests from
221        # the Relying Party
222        self.axResponseHandler = opt.get('axResponseHandler', None)
223        if self.axResponseHandler and not callable(self.axResponseHandler):
224            raise OpenIDProviderMiddlewareError("Expecting callable for "
225                                                "axResponseHandler keyword, "
226                                                "got %r" % \
227                                                self.axResponseHandler)
228       
229        self.app = app
230       
231        # Instantiate OpenID consumer store and OpenID consumer.  If you
232        # were connecting to a database, you would create the database
233        # connection and instantiate an appropriate store here.
234        store = FileOpenIDStore(opt['consumer_store_dirpath'])
235        self.oidserver = server.Server(store, self.urls['url_openidserver'])
236
237   
238    def __call__(self, environ, start_response):
239        """Standard WSGI interface.  Intercepts the path if it matches any of
240        the paths set in the path_* keyword settings to the config
241       
242        @type environ: dict
243        @param environ: dictionary of environment variables
244        @type start_response: callable
245        @param start_response: standard WSGI callable to set HTTP headers
246        @rtype: basestring
247        @return: WSGI response
248        """
249        if not environ.has_key(self.session_middleware):
250            raise OpenIDProviderConfigError('The session middleware %r is not '
251                                            'present. Have you set up the '
252                                            'session middleware?' % \
253                                            self.session_middleware)
254
255        self.path = environ.get('PATH_INFO').rstrip('/')
256        self.environ = environ
257        self.start_response = start_response
258        self.session = environ[self.session_middleware]
259        self._renderer.session = self.session
260       
261        if self.path in (self.paths['path_id'], self.paths['path_yadis']):
262            log.debug("No user id given in URL %s" % self.path)
263           
264            # Disallow identifier and yadis URIs where no ID was specified
265            return self.app(environ, start_response)
266           
267        elif self.path.startswith(self.paths['path_id']) or \
268           self.path.startswith(self.paths['path_yadis']):
269           
270            # Match against path minus ID as this is not known in advance           
271            pathMatch = self.path[:self.path.rfind('/')]
272        else:
273            pathMatch = self.path
274           
275        if pathMatch in self.method:
276            self.query = dict(paste.request.parse_formvars(environ)) 
277            log.debug("Calling method %s ..." % self.method[pathMatch]) 
278           
279            action = getattr(self, self.method[pathMatch])
280            response = action(environ, start_response) 
281            if self._trace and _debugLevel:
282                if isinstance(response, list):
283                    log.debug('Output for %s:\n%s', self.method[pathMatch],
284                                                    ''.join(response))
285                else:
286                    log.debug('Output for %s:\n%s', self.method[pathMatch],
287                                                    response)
288                   
289            return response
290        else:
291            log.debug("No match for path %s" % self.path)
292            return self.app(environ, start_response)
293
294
295    def do_id(self, environ, start_response):
296        '''URL based discovery with an ID provided
297       
298        @type environ: dict
299        @param environ: dictionary of environment variables
300        @type start_response: callable
301        @param start_response: standard WSGI callable to set HTTP headers
302        @rtype: basestring
303        @return: WSGI response
304       
305        '''
306        response = self._renderer.renderIdentityPage(environ)
307
308        start_response("200 OK", 
309                       [('Content-type', 'text/html'+self.charset),
310                        ('Content-length', str(len(response)))])
311        return response
312
313
314    def do_yadis(self, environ, start_response):
315        """Handle Yadis based discovery with an ID provided
316       
317        @type environ: dict
318        @param environ: dictionary of environment variables
319        @type start_response: callable
320        @param start_response: standard WSGI callable to set HTTP headers
321        @rtype: basestring
322        @return: WSGI response
323
324        """
325        response = self._renderer.renderYadis(environ)
326     
327        start_response('200 OK',
328                       [('Content-type', 'application/xrds+xml'+self.charset),
329                        ('Content-length', str(len(response)))])
330        return response
331
332
333    def do_openidserver(self, environ, start_response):
334        """Handle OpenID Server Request for ID Select mode i.e. no user id
335        given OpenID URL at Relying Party
336       
337        @type environ: dict
338        @param environ: dictionary of environment variables
339        @type start_response: callable
340        @param start_response: standard WSGI callable to set HTTP headers
341        @rtype: basestring
342        @return: WSGI response
343        """
344
345        try:
346            oidRequest = self.oidserver.decodeRequest(self.query)
347           
348        except server.ProtocolError, why:
349            response = self._displayResponse(why)
350           
351        else:
352            if oidRequest is None:
353                # Display text indicating that this is an endpoint.
354                response = self.do_mainpage(environ, start_response)
355           
356            # Check mode is one of "checkid_immediate", "checkid_setup"
357            elif oidRequest.mode in server.BROWSER_REQUEST_MODES:
358                response = self._handleCheckIDRequest(oidRequest)
359            else:
360                oidResponse = self.oidserver.handleRequest(oidRequest)
361                response = self._displayResponse(oidResponse)
362           
363        return response
364           
365
366    def do_allow(self, environ, start_response):
367        """Handle allow request - user allow credentials to be passed back to
368        the Relying Party?
369       
370        @type environ: dict
371        @param environ: dictionary of environment variables
372        @type start_response: callable
373        @param start_response: standard WSGI callable to set HTTP headers
374        @rtype: basestring
375        @return: WSGI response
376
377        """
378       
379        oidRequest = self.session.get('lastCheckIDRequest')
380        if oidRequest is None:
381            log.error("Suspected do_allow called from stale request")
382            #return self.app(environ, start_response)
383            response = self._renderer.renderErrorPage(environ, 
384                                                      "Invalid request")
385            start_response("400 Bad Request", 
386                           [('Content-type', 'text/html'),
387                            ('Content-length', str(len(response)))])
388       
389        if 'Yes' in self.query:
390            if oidRequest.idSelect():
391                identity = self.urls['url_id']+'/'+self.session['username']
392            else:
393                identity = oidRequest.identity
394
395            trust_root = oidRequest.trust_root
396            if self.query.get('remember', 'no') == 'yes':
397                self.session['approved'] = {trust_root: 'always'}
398                self.session.save()
399             
400            try:
401                oidResponse = self._identityApproved(oidRequest, identity)
402            except Exception, e:
403                log.error("Setting response following ID Approval: %s" % e)
404                response = self._renderer.renderErrorPage(environ, 
405                    'Error setting response.  Please report the error to your '
406                    'site administrator.')
407                start_response("500 Internal Server Error", 
408                               [('Content-type', 'text/html'),
409                                ('Content-length', str(len(response)))])
410                return response
411                         
412            response = self._displayResponse(oidResponse)
413       
414        elif 'No' in self.query:
415            # TODO: Check 'no' response is OK - no causes AuthKit's Relying
416            # Party implementation to crash with 'openid.return_to' KeyError
417            # in Authkit.authenticate.open_id.process
418            oidResponse = oidRequest.answer(False)
419            #response = self._displayResponse(oidResponse)
420            response = self._renderer.renderMainPage(environ)           
421            start_response("200 OK", 
422                           [('Content-type', 'text/html'),
423                            ('Content-length', str(len(response)))])
424        else:
425            response = self._renderer.renderErrorPage(environ, 
426                                                      'Expecting yes/no in '
427                                                      'allow post.  %r' % \
428                                                      self.query)
429            start_response("400 Bad Request", 
430                           [('Content-type', 'text/html'),
431                            ('Content-length', str(len(response)))])
432       
433        return response
434
435    def do_serveryadis(self, environ, start_response):
436        """Yadis based discovery for ID Select mode i.e. no user ID given for
437        OpenID identifier at Relying Party
438       
439        @type environ: dict
440        @param environ: dictionary of environment variables
441        @type start_response: callable
442        @param start_response: standard WSGI callable to set HTTP headers
443        @rtype: basestring
444        @return: WSGI response
445
446        """
447        response = self._renderer.renderServerYadis(environ)
448        start_response("200 OK", 
449                       [('Content-type', 'application/xrds+xml'),
450                        ('Content-length', str(len(response)))])
451        return response
452
453
454    def do_login(self, environ, start_response, **kw):
455        """Display Login form
456       
457        @type environ: dict
458        @param environ: dictionary of environment variables
459        @type start_response: callable
460        @param start_response: standard WSGI callable to set HTTP headers
461        @type kw: dict
462        @param kw: keywords to login renderer - see RenderingInterface class
463        @rtype: basestring
464        @return: WSGI response
465        """
466       
467        if 'fail_to' not in kw:
468            kw['fail_to'] = self.urls['url_login']
469           
470        response = self._renderer.renderLogin(environ, **kw)
471        start_response('200 OK', 
472                       [('Content-type', 'text/html'+self.charset),
473                        ('Content-length', str(len(response)))])
474        return response
475
476
477    def do_loginsubmit(self, environ, start_response):
478        """Handle user submission from login and logout
479       
480        @type environ: dict
481        @param environ: dictionary of environment variables
482        @type start_response: callable
483        @param start_response: standard WSGI callable to set HTTP headers
484        @rtype: basestring
485        @return: WSGI response
486        """
487       
488        if 'submit' in self.query:
489            if 'username' in self.query:
490                # login
491                if 'username' in self.session:
492                    log.error("Attempting login for user %s: user %s is "
493                              "already logged in", self.session['username'],
494                              self.session['username'])
495                    return self._redirect(start_response,self.query['fail_to'])
496               
497                # TODO: revise this once a link an authN mechanism has been
498                # included.
499                if self._userCreds:
500                    username = self.query['username']
501                    password = self.query.get('password')
502                    if username not in self._userCreds or \
503                       password != self._userCreds[username]:
504                        log.error("Invalid username/password entered")
505                        msg = "<p>Invalid username/password entered.  " + \
506                            "Please try again or if the problems persists " + \
507                            "contact your system administrator.</p>"
508                        response = self._renderer.renderLogin(environ, 
509                                          msg=msg,
510                                          success_to=self.urls['url_decide'])
511                        start_response('200 OK', 
512                               [('Content-type', 'text/html'+self.charset),
513                                ('Content-length', str(len(response)))])
514                        return response
515                       
516                self.session['username'] = self.query['username']
517                self.session['approved'] = {}
518                self.session.save()
519            else:
520                # logout
521                if 'username' not in self.session:
522                    log.error("No user is logged in")
523                    return self._redirect(start_response,self.query['fail_to'])
524               
525                del self.session['username']
526                self.session.pop('approved', None)
527                self.session.save()
528               
529            return self._redirect(start_response, self.query['success_to'])
530       
531        elif 'cancel' in self.query:
532            return self._redirect(start_response, self.query['fail_to'])
533        else:
534            log.error('Login input not recognised %r' % self.query)
535            return self._redirect(start_response, self.query['fail_to'])
536           
537
538    def do_mainpage(self, environ, start_response):
539        '''Show an information page about the OpenID Provider
540       
541        @type environ: dict
542        @param environ: dictionary of environment variables
543        @type start_response: callable
544        @param start_response: standard WSGI callable to set HTTP headers
545        @rtype: basestring
546        @return: WSGI response
547        '''
548       
549        response = self._renderer.renderMainPage(environ)
550        start_response('200 OK', 
551                       [('Content-type', 'text/html'+self.charset),
552                        ('Content-length', str(len(response)))])
553        return response
554
555
556    def do_decide(self, environ, start_response):
557        """Display page prompting the user to decide whether to trust the site
558        requesting their credentials
559       
560        @type environ: dict
561        @param environ: dictionary of environment variables
562        @type start_response: callable
563        @param start_response: standard WSGI callable to set HTTP headers
564        @rtype: basestring
565        @return: WSGI response
566        """
567
568        oidRequest = self.session.get('lastCheckIDRequest')
569        if oidRequest is None:
570            log.error("No OpenID request set in session")
571            return self.do_mainpage(environ, start_response)
572       
573        approvedRoots = self.session.get('approved', {})
574       
575        if oidRequest.trust_root in approvedRoots and \
576           not oidRequest.idSelect():
577            try:
578                response = self._identityApproved(oidRequest, 
579                                                  oidRequest.identity)
580            except Exception, e:
581                log.error("Setting response following ID Approval: %s" % e)
582                response = self._renderer.renderErrorPage(environ, 
583                    'Error setting response.  Please report the error to your '
584                    'site administrator.')
585               
586                start_response("500 Internal Server Error", 
587                               [('Content-type', 'text/html'),
588                                ('Content-length', str(len(response)))])
589                return response
590
591            return self._displayResponse(response)
592        else:
593            response = self._renderer.renderDecidePage(environ, oidRequest)
594           
595            start_response('200 OK', 
596                           [('Content-type', 'text/html'+self.charset),
597                            ('Content-length', str(len(response)))])
598            return response
599       
600       
601    def _identityIsAuthorized(self, oidRequest):
602        '''Check that a user is authorized i.e. does a session exist for their
603        username and if so does it correspond to the identity URL provided.
604        This last check doesn't apply for ID Select mode where no ID was input
605        at the Relying Party.
606       
607        @type oidRequest: openid.server.server.CheckIDRequest
608        @param oidRequest: OpenID Request object
609        @rtype: bool
610        @return: True/False is user authorized
611        '''
612        username = self.session.get('username')
613        if username is None:
614            return False
615
616        if oidRequest.idSelect():
617            log.debug("OpenIDProviderMiddleware._identityIsAuthorized - "
618                      "ID Select mode set but user is already logged in")
619            return True
620       
621        identityURL = self.urls['url_id']+'/'+username
622        if oidRequest.identity != identityURL:
623            log.debug("OpenIDProviderMiddleware._identityIsAuthorized - "
624                      "user is already logged in with a different ID=%s" % \
625                      username)
626            return False
627       
628        log.debug("OpenIDProviderMiddleware._identityIsAuthorized - "
629                  "user is logged in with ID matching ID URL")
630        return True
631   
632   
633    def _trustRootIsAuthorized(self, trust_root):
634        '''Return True/False for the given trust root (Relying Party)
635        previously been approved by the user
636       
637        @type trust_root: dict
638        @param trust_root: keyed by trusted root (Relying Party) URL and
639        containing string item 'always' if approved
640        @rtype: bool
641        @return: True - trust has already been approved, False - trust root is
642        not approved'''
643        approvedRoots = self.session.get('approved', {})
644        return approvedRoots.get(trust_root) is not None
645
646
647    def _addSRegResponse(self, oidRequest, oidResponse):
648        '''Add Simple Registration attributes to response to Relying Party
649       
650        @type oidRequest: openid.server.server.CheckIDRequest
651        @param oidRequest: OpenID Check ID Request object
652        @type oidResponse: openid.server.server.OpenIDResponse
653        @param oidResponse: OpenID response object'''
654       
655        if self.sregResponseHandler is None:
656            # No Simple Registration response object was set
657            return
658       
659        sreg_req = sreg.SRegRequest.fromOpenIDRequest(oidRequest)
660
661        # Callout to external callable sets additional user attributes to be
662        # returned in response to Relying Party       
663        sreg_data = self.sregResponseHandler(self.session.get('username'))
664        sreg_resp = sreg.SRegResponse.extractResponse(sreg_req, sreg_data)
665        oidResponse.addExtension(sreg_resp)
666
667
668    def _addAXResponse(self, oidRequest, oidResponse):
669        '''Add attributes to response based on the OpenID Attribute Exchange
670        interface
671       
672        @type oidRequest: openid.server.server.CheckIDRequest
673        @param oidRequest: OpenID Check ID Request object
674        @type oidResponse: openid.server.server.OpenIDResponse
675        @param oidResponse: OpenID response object'''
676
677
678        ax_req = ax.FetchRequest.fromOpenIDRequest(oidRequest)
679        if ax_req is None:
680            log.debug("No Attribute Exchange extension set in request")
681            return
682       
683        ax_resp = ax.FetchResponse(request=ax_req)
684       
685        if self.axResponseHandler is None:
686            requiredAttr = ax_req.getRequiredAttrs()
687            if len(requiredAttr) > 0:
688                msg = ("Relying party requires these attributes: %s; but no"
689                        "Attribute exchange handler 'axResponseHandler' has "
690                        "been set" % requiredAttr)
691                log.error(msg)
692                raise OpenIDProviderConfigError(msg)
693           
694            return
695       
696        # Set requested values - need user intervention here to confirm
697        # release of attributes + assignment based on required attributes -
698        # possibly via FetchRequest.getRequiredAttrs()
699        try:
700            self.axResponseHandler(ax_req, ax_resp, 
701                                   self.session.get('username'))
702        except Exception, e:
703            log.error("%s exception raised setting requested Attribute "
704                      "Exchange values: %s" % (e.__class__, e))
705            raise
706       
707        oidResponse.addExtension(ax_resp)
708       
709       
710    def _identityApproved(self, oidRequest, identifier=None):
711        '''Action following approval of a Relying Party by the user.  Add
712        Simple Registration and/or Attribute Exchange parameters if handlers
713        were specified - See _addSRegResponse and _addAXResponse methods
714       
715        @type oidRequest: openid.server.server.CheckIDRequest
716        @param oidRequest: OpenID Check ID Request object
717        @type identifier: basestring
718        @param identifier: OpenID selected by user - for ID Select mode only
719        @rtype: openid.server.server.OpenIDResponse
720        @return: OpenID response object'''
721
722        oidResponse = oidRequest.answer(True, identity=identifier)
723        self._addSRegResponse(oidRequest, oidResponse)
724        self._addAXResponse(oidRequest, oidResponse)
725       
726        return oidResponse
727
728
729    def _handleCheckIDRequest(self, oidRequest):
730        """Handle "checkid_immediate" and "checkid_setup" type requests from
731        Relying Party
732       
733        @type oidRequest: openid.server.server.CheckIDRequest
734        @param oidRequest: OpenID Check ID request
735        @rtype: basestring
736        @return: WSGI response
737        """
738        log.debug("OpenIDProviderMiddleware._handleCheckIDRequest ...")
739       
740        # Save request
741        self.session['lastCheckIDRequest'] = oidRequest
742        self.session.save()
743       
744        if self._identityIsAuthorized(oidRequest):
745           
746            # User is logged in - check for ID Select type request i.e. the
747            # user entered their IdP address at the Relying Party and not their
748            # OpenID Identifier.  In this case, the identity they wish to use
749            # must be confirmed.
750            if oidRequest.idSelect():
751                # OpenID identifier must be confirmed
752                return self.do_decide(self.environ, self.start_response)
753           
754            elif self._trustRootIsAuthorized(oidRequest.trust_root):
755                # User has approved this Relying Party
756                try:
757                    oidResponse = self._identityApproved(oidRequest)
758                except Exception, e:
759                    log.error("Setting response following ID Approval: %s" % e)
760                    response = self._renderer.renderErrorPage(environ, 
761                        'Error setting response.  Please report the error to '
762                        'your site administrator.')
763                    start_response("500 Internal Server Error", 
764                                   [('Content-type', 'text/html'),
765                                    ('Content-length', str(len(response)))])
766                    return response
767               
768                return self._displayResponse(oidResponse)
769            else:
770                return self.do_decide(self.environ, self.start_response)
771               
772        elif oidRequest.immediate:
773            oidResponse = oidRequest.answer(False)
774            return self._displayResponse(oidResponse)
775       
776        else:
777            # User is not logged in
778           
779            # Call login and if successful then call decide page to confirm
780            # user wishes to trust the Relying Party.
781            response = self.do_login(self.environ,
782                                     self.start_response,
783                                     success_to=self.urls['url_decide'])
784            return response
785
786
787    def _displayResponse(self, oidResponse):
788        """Serialize an OpenID Response object, set headers and return WSGI
789        response.
790       
791        If the URL length for a GET request exceeds a maximum, then convert the
792        response into a HTML form and use POST method.
793       
794        @type oidResponse: openid.server.server.OpenIDResponse
795        @param oidResponse: OpenID response object
796       
797        @rtype: basestring
798        @return: WSGI response'''
799        """
800       
801        try:
802            webresponse = self.oidserver.encodeResponse(oidResponse)
803        except server.EncodingError, why:
804            text = why.response.encodeToKVForm()
805            return self.showErrorPage(text)
806       
807        hdr = webresponse.headers.items()
808       
809        # If the content length exceeds the maximum to represent on a URL, it's
810        # rendered as a form instead
811        # FIXME: Commented out oidResponse.renderAsForm() test as it doesn't
812        # give consistent answers.  Testing based on body content should work
813        # OK
814        if webresponse.body:
815        #if oidResponse.renderAsForm():
816            # Wrap in HTML with Javascript OnLoad to submit the form
817            # automatically without user intervention
818            response = OpenIDProviderMiddleware.formRespWrapperTmpl % \
819                                                        webresponse.body
820        else:
821            response = webresponse.body
822           
823        hdr += [('Content-type', 'text/html'+self.charset),
824                ('Content-length', str(len(response)))]
825           
826        self.start_response('%d %s' % (webresponse.code, 
827                                       httplib.responses[webresponse.code]), 
828                            hdr)
829        return response
830
831
832    def _redirect(self, start_response, url):
833        """Do a HTTP 302 redirect
834       
835        @type start_response: callable following WSGI start_response convention
836        @param start_response: WSGI start response callable
837        @type url: basestring
838        @param url: URL to redirect to
839        @rtype: list
840        @return: empty HTML body
841        """
842        start_response('302 %s' % httplib.responses[302], 
843                       [('Content-type', 'text/html'+self.charset),
844                        ('Location', url)])
845        return []
846
847
848    def _showErrorPage(self, msg, code=500):
849        """Display error information to the user
850       
851        @type msg: basestring
852        @param msg: error message
853        @type code: int
854        @param code: HTTP error code
855        """
856       
857        response = self._renderer.renderErrorPage(self.environ,cgi.escape(msg))
858        self.start_response('%d %s' % (code, httplib.responses[code]), 
859                            [('Content-type', 'text/html'+self.charset),
860                             ('Content-length', str(len(msg)))])
861        return response
862   
863   
864class RenderingInterface(object):
865    """Interface class for rendering of OpenID Provider pages.  Create a
866    derivative from this class to override the default look and feel and
867    behaviour of these pages.  Pass the new class name via the renderClass
868    keyword to OpenIDProviderMiddleware.__init__
869   
870    @cvar tmplServerYadis: template for returning Yadis document to Relying
871    Party.  Derived classes can reset this or completely override the
872    renderServerYadis method.
873   
874    @type tmplServerYadis: basestring
875   
876    @cvar tmplYadis: template for returning Yadis document containing user
877    URL to Relying Party.  Derived classes can reset this or completely
878    override the renderYadis method.
879   
880    @type tmplYadis: basestring"""
881   
882    tmplServerYadis = """\
883<?xml version="1.0" encoding="UTF-8"?>
884<xrds:XRDS
885    xmlns:xrds="xri://$xrds"
886    xmlns="xri://$xrd*($v*2.0)">
887  <XRD>
888
889    <Service priority="0">
890      <Type>%(openid20type)s</Type>
891      <URI>%(endpoint_url)s</URI>
892    </Service>
893
894  </XRD>
895</xrds:XRDS>
896"""
897
898    tmplYadis = """\
899<?xml version="1.0" encoding="UTF-8"?>
900<xrds:XRDS
901    xmlns:xrds="xri://$xrds"
902    xmlns="xri://$xrd*($v*2.0)">
903  <XRD>
904
905    <Service priority="0">
906      <Type>%(openid20type)s</Type>
907      <Type>%(openid10type)s</Type>
908      <URI>%(endpoint_url)s</URI>
909      <LocalID>%(user_url)s</LocalID>
910    </Service>
911
912  </XRD>
913</xrds:XRDS>"""   
914   
915    def __init__(self, base_url, urls):
916        """
917        @type base_url: basestring
918        @param base_url: base URL for OpenID Provider to which individual paths
919        are appended
920        @type urls: dict
921        @param urls: full urls for all the paths used by all the exposed
922        methods - keyed by method name - see OpenIDProviderMiddleware.paths
923        """
924        self.base_url = base_url
925        self.urls = urls
926
927
928    def renderIdentityPage(self, environ):
929        """Render the identity page.
930       
931        @type environ: dict
932        @param environ: dictionary of environment variables
933        @rtype: basestring
934        @return: WSGI response
935        """
936        path = environ.get('PATH_INFO').rstrip('/')
937        username = path[len(self.paths['path_id'])+1:]
938       
939        link_tag = '<link rel="openid.server" href="%s">' % \
940              self.urls['url_openidserver']
941             
942        yadis_loc_tag = '<meta http-equiv="x-xrds-location" content="%s">' % \
943            (self.urls['url_yadis']+'/'+path[4:])
944           
945        disco_tags = link_tag + yadis_loc_tag
946        ident = self.base_url + path
947
948        msg = ''
949        return self._showPage(environ, 
950                              'Identity Page', 
951                              head_extras=disco_tags, 
952                              msg='''<p>This is the identity page for %s.</p>
953                                %s
954                                ''' % (ident, msg))
955   
956   
957    def renderServerYadis(self, environ):
958        '''Render Yadis info
959       
960        @type environ: dict
961        @param environ: dictionary of environment variables
962        @rtype: basestring
963        @return: WSGI response
964        '''
965        endpoint_url = self.urls['url_openidserver']
966        return RenderingInterface.tmplServerYadis % \
967            {'openid20type': discover.OPENID_IDP_2_0_TYPE, 
968             'endpoint_url': endpoint_url}   
969
970
971    def renderYadis(self, environ):
972        """Render Yadis document containing user URL
973       
974        @type environ: dict
975        @param environ: dictionary of environment variables
976        @rtype: basestring
977        @return: WSGI response
978        """
979       
980        username = environ['PATH_INFO'].rstrip('/').split('/')[-1]
981       
982        endpoint_url = self.urls['url_openidserver']
983        user_url = self.urls['url_id'] + '/' + username
984       
985        yadisDict = dict(openid20type=discover.OPENID_2_0_TYPE, 
986                         openid10type=discover.OPENID_1_0_TYPE,
987                         endpoint_url=endpoint_url, 
988                         user_url=user_url)
989       
990        return RenderingInterface.tmplYadis % yadisDict
991
992       
993    def renderLogin(self, environ, success_to=None, fail_to=None, msg=''):
994        """Render the login form.
995       
996        @type environ: dict
997        @param environ: dictionary of environment variables
998        @type success_to: basestring
999        @param success_to: URL put into hidden field telling 
1000        OpenIDProviderMiddleware.do_loginsubmit() where to forward to on
1001        successful login
1002        @type fail_to: basestring
1003        @param fail_to: URL put into hidden field telling 
1004        OpenIDProviderMiddleware.do_loginsubmit() where to forward to on
1005        login error
1006        @type msg: basestring
1007        @param msg: display (error) message below login form e.g. following
1008        previous failed login attempt.
1009        @rtype: basestring
1010        @return: WSGI response
1011        """
1012       
1013        if success_to is None:
1014            success_to = self.urls['url_mainpage']
1015           
1016        if fail_to is None:
1017            fail_to = self.urls['url_mainpage']
1018       
1019        return self._showPage(environ,
1020                              'Login Page', form='''\
1021            <h2>Login</h2>
1022            <form method="GET" action="%s">
1023              <input type="hidden" name="success_to" value="%s" />
1024              <input type="hidden" name="fail_to" value="%s" />
1025              <input type="text" name="username" value="" />
1026              <input type="submit" name="submit" value="Log In" />
1027              <input type="submit" name="cancel" value="Cancel" />
1028            </form>
1029            %s
1030            ''' % (self.urls['url_loginsubmit'], success_to, fail_to, msg))
1031
1032
1033    def renderMainPage(self, environ):
1034        """Rendering the main page.
1035       
1036        @type environ: dict
1037        @param environ: dictionary of environment variables
1038        @rtype: basestring
1039        @return: WSGI response
1040        """
1041       
1042        yadis_tag = '<meta http-equiv="x-xrds-location" content="%s">' % \
1043                    self.urls['url_serveryadis']
1044        username = environ['beaker.session']['username']   
1045        if username:
1046            openid_url = self.urls['url_id'] + '/' + username
1047            user_message = """\
1048            <p>You are logged in as %s. Your OpenID identity URL is
1049            <tt><a href=%s>%s</a></tt>. Enter that URL at an OpenID
1050            consumer to test this server.</p>
1051            """ % (username, quoteattr(openid_url), openid_url)
1052        else:
1053            user_message = "<p>You are not <a href='%s'>logged in</a>.</p>" % \
1054                            self.urls['url_login']
1055
1056        return self._showPage(environ,
1057                              'Main Page', head_extras=yadis_tag, msg='''\
1058            <p>OpenID server</p>
1059   
1060            %s
1061   
1062            <p>The URL for this server is <a href=%s><tt>%s</tt></a>.</p>
1063        ''' % (user_message, quoteattr(self.base_url), self.base_url))
1064   
1065    def renderDecidePage(self, environ, oidRequest):
1066        """Show page giving the user the option to approve the return of their
1067        credentials to the Relying Party.  This page is also displayed for
1068        ID select mode if the user is already logged in at the OpenID Provider.
1069        This enables them to confirm the OpenID to be sent back to the
1070        Relying Party
1071       
1072        @type environ: dict
1073        @param environ: dictionary of environment variables
1074        @type oidRequest: openid.server.server.CheckIDRequest
1075        @param oidRequest: OpenID Check ID Request object
1076        @rtype: basestring
1077        @return: WSGI response
1078        """
1079        id_url_base = self.urls['url_id'] + '/'
1080       
1081        # XXX: This may break if there are any synonyms for id_url_base,
1082        # such as referring to it by IP address or a CNAME.
1083       
1084        # TODO: OpenID 2.0 Allows oidRequest.identity to be set to
1085        # http://specs.openid.net/auth/2.0/identifier_select.  See,
1086        # http://openid.net/specs/openid-authentication-2_0.html.  This code
1087        # implements this overriding the behaviour of the example code on
1088        # which this is based.  - Check is the example code based on OpenID 1.0
1089        # and therefore wrong for this behaviour?
1090#        assert oidRequest.identity.startswith(id_url_base), \
1091#               repr((oidRequest.identity, id_url_base))
1092        expected_user = oidRequest.identity[len(id_url_base):]
1093        username = environ['beaker.session']['username']
1094       
1095        if oidRequest.idSelect(): # We are being asked to select an ID
1096            msg = '''\
1097            <p>A site has asked for your identity.  You may select an
1098            identifier by which you would like this site to know you.
1099            On a production site this would likely be a drop down list
1100            of pre-created accounts or have the facility to generate
1101            a random anonymous identifier.
1102            </p>
1103            '''
1104            fdata = {
1105                'path_allow': self.urls['url_allow'],
1106                'id_url_base': id_url_base,
1107                'trust_root': oidRequest.trust_root,
1108                }
1109            form = '''\
1110            <form method="POST" action="%(path_allow)s">
1111            <table>
1112              <tr><td>Identity:</td>
1113                 <td>%(id_url_base)s<input type='text' name='identifier'></td></tr>
1114              <tr><td>Trust Root:</td><td>%(trust_root)s</td></tr>
1115            </table>
1116            <p>Allow this authentication to proceed?</p>
1117            <input type="checkbox" id="remember" name="remember" value="yes"
1118                /><label for="remember">Remember this
1119                decision</label><br />
1120            <input type="submit" name="yes" value="yes" />
1121            <input type="submit" name="no" value="no" />
1122            </form>
1123            '''%fdata
1124           
1125        elif expected_user == username:
1126            msg = '''\
1127            <p>A new site has asked to confirm your identity.  If you
1128            approve, the site represented by the trust root below will
1129            be told that you control identity URL listed below. (If
1130            you are using a delegated identity, the site will take
1131            care of reversing the delegation on its own.)</p>'''
1132
1133            fdata = {
1134                'path_allow': self.urls['url_allow'],
1135                'identity': oidRequest.identity,
1136                'trust_root': oidRequest.trust_root,
1137                }
1138            form = '''\
1139            <table>
1140              <tr><td>Identity:</td><td>%(identity)s</td></tr>
1141              <tr><td>Trust Root:</td><td>%(trust_root)s</td></tr>
1142            </table>
1143            <p>Allow this authentication to proceed?</p>
1144            <form method="POST" action="%(path_allow)s">
1145              <input type="checkbox" id="remember" name="remember" value="yes"
1146                  /><label for="remember">Remember this
1147                  decision</label><br />
1148              <input type="submit" name="yes" value="yes" />
1149              <input type="submit" name="no" value="no" />
1150            </form>''' % fdata
1151        else:
1152            mdata = {
1153                'expected_user': expected_user,
1154                'username': username,
1155                }
1156            msg = '''\
1157            <p>A site has asked for an identity belonging to
1158            %(expected_user)s, but you are logged in as %(username)s.  To
1159            log in as %(expected_user)s and approve the login oidRequest,
1160            hit OK below.  The "Remember this decision" checkbox
1161            applies only to the trust root decision.</p>''' % mdata
1162
1163            fdata = {
1164                'path_allow': self.urls['url_allow'],
1165                'identity': oidRequest.identity,
1166                'trust_root': oidRequest.trust_root,
1167                'expected_user': expected_user,
1168                }
1169            form = '''\
1170            <table>
1171              <tr><td>Identity:</td><td>%(identity)s</td></tr>
1172              <tr><td>Trust Root:</td><td>%(trust_root)s</td></tr>
1173            </table>
1174            <p>Allow this authentication to proceed?</p>
1175            <form method="POST" action="%(path_allow)s">
1176              <input type="checkbox" id="remember" name="remember" value="yes"
1177                  /><label for="remember">Remember this
1178                  decision</label><br />
1179              <input type="hidden" name="login_as" value="%(expected_user)s"/>
1180              <input type="submit" name="yes" value="yes" />
1181              <input type="submit" name="no" value="no" />
1182            </form>''' % fdata
1183
1184        return self._showPage(environ,
1185                              'Approve OpenID request?', 
1186                              msg=msg, form=form)
1187       
1188
1189    def _showPage(self, environ, 
1190                  title, head_extras='', msg=None, err=None, form=None):
1191        """Generic page rendering method.  Derived classes may ignore this.
1192       
1193        @type environ: dict
1194        @param environ: dictionary of environment variables
1195        @type title: basestring
1196        @param title: page title
1197        @type head_extras: basestring
1198        @param head_extras: add extra HTML header elements
1199        @type msg: basestring
1200        @param msg: optional message for page body
1201        @type err: basestring
1202        @param err: optional error message for page body
1203        @type form: basestring
1204        @param form: optional form for page body       
1205        @rtype: basestring
1206        @return: WSGI response
1207        """
1208       
1209        username = environ['beaker.session'].get('username')
1210        if username is None:
1211            user_link = '<a href="/login">not logged in</a>.'
1212        else:
1213            user_link = 'logged in as <a href="%s/%s">%s</a>.<br />'\
1214                        '<a href="%s?submit=true&'\
1215                        'success_to=%s">Log out</a>' % \
1216                        (self.urls['url_id'], username, username, 
1217                         self.urls['url_loginsubmit'],
1218                         self.urls['url_login'])
1219
1220        body = ''
1221
1222        if err is not None:
1223            body +=  '''\
1224            <div class="error">
1225              %s
1226            </div>
1227            ''' % err
1228
1229        if msg is not None:
1230            body += '''\
1231            <div class="message">
1232              %s
1233            </div>
1234            ''' % msg
1235
1236        if form is not None:
1237            body += '''\
1238            <div class="form">
1239              %s
1240            </div>
1241            ''' % form
1242
1243        contents = {
1244            'title': 'Python OpenID Server - ' + title,
1245            'head_extras': head_extras,
1246            'body': body,
1247            'user_link': user_link,
1248            }
1249
1250        response = '''<html>
1251  <head>
1252    <title>%(title)s</title>
1253    %(head_extras)s
1254  </head>
1255  <style type="text/css">
1256      h1 a:link {
1257          color: black;
1258          text-decoration: none;
1259      }
1260      h1 a:visited {
1261          color: black;
1262          text-decoration: none;
1263      }
1264      h1 a:hover {
1265          text-decoration: underline;
1266      }
1267      body {
1268        font-family: verdana,sans-serif;
1269        width: 50em;
1270        margin: 1em;
1271      }
1272      div {
1273        padding: .5em;
1274      }
1275      table {
1276        margin: none;
1277        padding: none;
1278      }
1279      .banner {
1280        padding: none 1em 1em 1em;
1281        width: 100%%;
1282      }
1283      .leftbanner {
1284        text-align: left;
1285      }
1286      .rightbanner {
1287        text-align: right;
1288        font-size: smaller;
1289      }
1290      .error {
1291        border: 1px solid #ff0000;
1292        background: #ffaaaa;
1293        margin: .5em;
1294      }
1295      .message {
1296        border: 1px solid #2233ff;
1297        background: #eeeeff;
1298        margin: .5em;
1299      }
1300      .form {
1301        border: 1px solid #777777;
1302        background: #ddddcc;
1303        margin: .5em;
1304        margin-top: 1em;
1305        padding-bottom: 0em;
1306      }
1307      dd {
1308        margin-bottom: 0.5em;
1309      }
1310  </style>
1311  <body>
1312    <table class="banner">
1313      <tr>
1314        <td class="leftbanner">
1315          <h1><a href="/">Python OpenID Server</a></h1>
1316        </td>
1317        <td class="rightbanner">
1318          You are %(user_link)s
1319        </td>
1320      </tr>
1321    </table>
1322%(body)s
1323  </body>
1324</html>
1325''' % contents
1326
1327        return response
1328
1329    def renderErrorPage(self, environ, msg):
1330        """Display error page
1331       
1332        @type environ: dict
1333        @param environ: dictionary of environment variables
1334        @type msg: basestring
1335        @param msg: optional message for page body
1336        @rtype: basestring
1337        @return: WSGI response
1338        """
1339       
1340        response = self._showPage(environ, 'Error Processing Request', err='''\
1341        <p>%s</p>
1342        <!--
1343
1344        This is a large comment.  It exists to make this page larger.
1345        That is unfortunately necessary because of the "smart"
1346        handling of pages returned with an error code in IE.
1347
1348        *************************************************************
1349        *************************************************************
1350        *************************************************************
1351        *************************************************************
1352        *************************************************************
1353        *************************************************************
1354        *************************************************************
1355        *************************************************************
1356        *************************************************************
1357        *************************************************************
1358        *************************************************************
1359        *************************************************************
1360        *************************************************************
1361        *************************************************************
1362        *************************************************************
1363        *************************************************************
1364        *************************************************************
1365        *************************************************************
1366        *************************************************************
1367        *************************************************************
1368        *************************************************************
1369        *************************************************************
1370        *************************************************************
1371
1372        -->
1373        ''' % msg)
1374       
1375        return response
Note: See TracBrowser for help on using the repository browser.