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

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

Adding OpenIDProviderMiddleware filter to Combined Services unit test code:

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