source: TI12-security/trunk/ndg_saml/ndg/saml/saml2/binding/soap/server/wsgi/queryinterface.py @ 7662

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg-security/TI12-security/trunk/ndg_saml/ndg/saml/saml2/binding/soap/server/wsgi/queryinterface.py@7662
Revision 7662, 21.0 KB checked in by pjkersha, 10 years ago (diff)

Added SOAPFault handling for ndg.soap package. Needs integration into ndg.saml.saml2.binding.soap.server.wsgi.queryinterface to enable SAML query interface to do better error reporting.

  • Property svn:keywords set to Id
Line 
1"""WSGI SAML package for SAML 2.0 Attribute and Authorisation Decision Query/
2Request Profile interfaces
3
4NERC DataGrid Project
5"""
6__author__ = "P J Kershaw"
7__date__ = "15/02/10"
8__copyright__ = "(C) 2010 Science and Technology Facilities Council"
9__contact__ = "Philip.Kershaw@stfc.ac.uk"
10__revision__ = "$Id$"
11__license__ = "http://www.apache.org/licenses/LICENSE-2.0"
12import logging
13log = logging.getLogger(__name__)
14import traceback
15from cStringIO import StringIO
16from uuid import uuid4
17from datetime import datetime, timedelta
18
19from ndg.soap.server.wsgi.middleware import SOAPMiddleware
20from ndg.soap.etree import SOAPEnvelope
21
22from ndg.saml.utils import str2Bool
23from ndg.saml.utils.factory import importModuleObject
24from ndg.saml.xml import UnknownAttrProfile
25from ndg.saml.common import SAMLVersion
26from ndg.saml.utils import SAMLDateTime
27from ndg.saml.saml2.core import (Response, Status, StatusCode, StatusMessage, 
28                                 Issuer) 
29from ndg.saml.saml2.binding.soap import SOAPBindingInvalidResponse
30
31
32class SOAPQueryInterfaceMiddlewareError(Exception):
33    """Base class for WSGI SAML 2.0 SOAP Query Interface Errors"""
34
35
36class SOAPQueryInterfaceMiddlewareConfigError(Exception):
37    """WSGI SAML 2.0 SOAP Query Interface Configuration problem"""
38
39
40class QueryIssueInstantInvalid(SOAPBindingInvalidResponse):
41    """Invalid timestamp for incoming query"""
42   
43   
44class SOAPQueryInterfaceMiddleware(SOAPMiddleware):
45    """Implementation of SAML 2.0 SOAP Binding for Query/Request Binding
46   
47    @type PATH_OPTNAME: basestring
48    @cvar PATH_OPTNAME: name of app_conf option for specifying a path or paths
49    that this middleware will intercept and process
50    @type QUERY_INTERFACE_KEYNAME_OPTNAME: basestring
51    @cvar QUERY_INTERFACE_KEYNAME_OPTNAME: app_conf option name for key name
52    used to reference the SAML query interface in environ
53    @type DEFAULT_QUERY_INTERFACE_KEYNAME: basestring
54    @param DEFAULT_QUERY_INTERFACE_KEYNAME: default key name for referencing
55    SAML query interface in environ
56    """
57    log = logging.getLogger('SOAPQueryInterfaceMiddleware')
58    PATH_OPTNAME = "mountPath"
59    QUERY_INTERFACE_KEYNAME_OPTNAME = "queryInterfaceKeyName"
60    DEFAULT_QUERY_INTERFACE_KEYNAME = ("ndg.security.server.wsgi.saml."
61                            "SOAPQueryInterfaceMiddleware.queryInterface")
62   
63    REQUEST_ENVELOPE_CLASS_OPTNAME = 'requestEnvelopeClass'
64    RESPONSE_ENVELOPE_CLASS_OPTNAME = 'responseEnvelopeClass'
65    SERIALISE_OPTNAME = 'serialise'
66    DESERIALISE_OPTNAME = 'deserialise' 
67    SAML_VERSION_OPTNAME = 'samlVersion'
68    ISSUER_NAME_OPTNAME = 'issuerName'
69    ISSUER_FORMAT_OPTNAME = 'issuerFormat'
70   
71    CONFIG_FILE_OPTNAMES = (
72        PATH_OPTNAME,
73        QUERY_INTERFACE_KEYNAME_OPTNAME,
74        DEFAULT_QUERY_INTERFACE_KEYNAME,
75        REQUEST_ENVELOPE_CLASS_OPTNAME,
76        RESPONSE_ENVELOPE_CLASS_OPTNAME,
77        SERIALISE_OPTNAME,
78        DESERIALISE_OPTNAME,
79        SAML_VERSION_OPTNAME,
80        ISSUER_NAME_OPTNAME,
81        ISSUER_FORMAT_OPTNAME
82    )
83   
84    def __init__(self, app):
85        '''@type app: callable following WSGI interface
86        @param app: next middleware application in the chain
87        '''     
88        super(SOAPQueryInterfaceMiddleware, self).__init__()
89       
90        self._app = app
91       
92        # Set defaults
93        cls = SOAPQueryInterfaceMiddleware
94        self.__queryInterfaceKeyName = cls.DEFAULT_QUERY_INTERFACE_KEYNAME
95        self.__mountPath = '/'
96        self.__requestEnvelopeClass = None
97        self.__responseEnvelopeClass = None
98        self.__serialise = None
99        self.__deserialise = None
100        self.__issuer = None
101        self.__clockSkewTolerance = timedelta(seconds=0.)
102        self.__verifyTimeConditions = True
103        self.__verifySAMLVersion = True
104        self.__samlVersion = SAMLVersion.VERSION_20
105       
106        # Proxy object for SAML Response Issuer attributes.  By generating a
107        # proxy the Response objects inherent attribute validation can be
108        # applied to Issuer related config parameters before they're assigned to
109        # the response issuer object generated in the authorisation decision
110        # query response
111        self.__issuerProxy = Issuer()
112     
113    def initialise(self, global_conf, prefix='', **app_conf):
114        '''
115        @type global_conf: dict       
116        @param global_conf: PasteDeploy global configuration dictionary
117        @type prefix: basestring
118        @param prefix: prefix for configuration items
119        @type app_conf: dict       
120        @param app_conf: PasteDeploy application specific configuration
121        dictionary
122        '''
123        # Override where set in config
124        for name in SOAPQueryInterfaceMiddleware.CONFIG_FILE_OPTNAMES:
125            val = app_conf.get(prefix + name)
126            if val is not None:
127                setattr(self, name, val)
128
129        if self.serialise is None:
130            raise AttributeError('No "serialise" method set to serialise the '
131                                 'SAML response from this middleware.')
132
133        if self.deserialise is None:
134            raise AttributeError('No "deserialise" method set to parse the '
135                                 'SAML request to this middleware.')
136           
137    def _getSerialise(self):
138        return self.__serialise
139
140    def _setSerialise(self, value):
141        if isinstance(value, basestring):
142            self.__serialise = importModuleObject(value)
143           
144        elif callable(value):
145            self.__serialise = value
146        else:
147            raise TypeError('Expecting callable for "serialise"; got %r' % 
148                            value)
149
150    serialise = property(_getSerialise, _setSerialise, 
151                         doc="callable to serialise request into XML type")
152
153    def _getDeserialise(self):
154        return self.__deserialise
155
156    def _setDeserialise(self, value):
157        if isinstance(value, basestring):
158            self.__deserialise = importModuleObject(value)
159           
160        elif callable(value):
161            self.__deserialise = value
162        else:
163            raise TypeError('Expecting callable for "deserialise"; got %r' % 
164                            value)
165       
166    deserialise = property(_getDeserialise, 
167                           _setDeserialise, 
168                           doc="callable to de-serialise response from XML "
169                               "type")       
170
171    def _getIssuer(self):
172        return self.__issuer
173
174    def _setIssuer(self, value):
175        if not isinstance(value, basestring):
176            raise TypeError('Expecting string type for "issuer"; got %r' %
177                            type(value))
178           
179        self.__issuer = value
180       
181    issuer = property(fget=_getIssuer, 
182                      fset=_setIssuer, 
183                      doc="Name of issuing authority")
184
185    def _getIssuerFormat(self):
186        if self.__issuerProxy is None:
187            return None
188        else:
189            return self.__issuerProxy.value
190
191    def _setIssuerFormat(self, value):
192        if self.__issuerProxy is None:
193            self.__issuerProxy = Issuer()
194           
195        self.__issuerProxy.format = value
196
197    issuerFormat = property(_getIssuerFormat, _setIssuerFormat, 
198                            doc="Issuer format")
199
200    def _getIssuerName(self):
201        if self.__issuerProxy is None:
202            return None
203        else:
204            return self.__issuerProxy.value
205
206    def _setIssuerName(self, value):
207        self.__issuerProxy.value = value
208
209    issuerName = property(_getIssuerName, _setIssuerName, 
210                          doc="Name of issuer of SAML Query Response")
211
212    def _getVerifyTimeConditions(self):
213        return self.__verifyTimeConditions
214
215    def _setVerifyTimeConditions(self, value):
216        if isinstance(value, bool):
217            self.__verifyTimeConditions = value
218           
219        if isinstance(value, basestring):
220            self.__verifyTimeConditions = str2Bool(value)
221        else:
222            raise TypeError('Expecting bool or string type for '
223                            '"verifyTimeConditions"; got %r instead' % 
224                            type(value))
225
226    verifyTimeConditions = property(_getVerifyTimeConditions, 
227                                    _setVerifyTimeConditions, 
228                                    doc='Set to True to verify any time '
229                                        'Conditions set in the returned '
230                                        'response assertions')
231
232    def _getVerifySAMLVersion(self):
233        return self.__verifySAMLVersion
234
235    def _setVerifySAMLVersion(self, value):
236        if isinstance(value, bool):
237            self.__verifySAMLVersion = value
238           
239        if isinstance(value, basestring):
240            self.__verifySAMLVersion = str2Bool(value)
241        else:
242            raise TypeError('Expecting bool or string type for '
243                            '"verifySAMLVersion"; got %r instead' % 
244                            type(value))
245
246    verifySAMLVersion = property(_getVerifySAMLVersion, 
247                                 _setVerifySAMLVersion, 
248                                 doc='Set to True to verify the SAML version '
249                                     'set in the query against the SAML '
250                                     'Version set in the "samlVersion" '
251                                     'attribute')
252       
253    def _getClockSkewTolerance(self):
254        return self.__clockSkewTolerance
255
256    def _setClockSkewTolerance(self, value):
257        if isinstance(value, timedelta):
258            self.__clockSkewTolerance = value
259           
260        elif isinstance(value, (float, int, long)):
261            self.__clockSkewTolerance = timedelta(seconds=value)
262           
263        elif isinstance(value, basestring):
264            self.__clockSkewTolerance = timedelta(seconds=float(value))
265        else:
266            raise TypeError('Expecting timedelta, float, int, long or string '
267                            'type for "clockSkewTolerance"; got %r' % 
268                            type(value)) 
269               
270    clockSkewTolerance = property(fget=_getClockSkewTolerance, 
271                                  fset=_setClockSkewTolerance, 
272                                  doc="Set a tolerance of +/- n seconds to "
273                                      "allow for clock skew when checking the "
274                                      "timestamps of client queries")
275
276    def _getSamlVersion(self):
277        return self.__samlVersion
278
279    def _setSamlVersion(self, value):
280        if not isinstance(value, basestring):
281            raise TypeError('Expecting string type for "samlVersion"; got %r' % 
282                            type(value)) 
283        self.__samlVersion = value
284
285    samlVersion = property(_getSamlVersion, _setSamlVersion, None, 
286                           "SAML Version to enforce for incoming queries.  "
287                           "Defaults to version 2.0")
288       
289    def _getMountPath(self):
290        return self.__mountPath
291   
292    def _setMountPath(self, value):
293        '''
294        @type value: basestring
295        @param value: URL paths to apply this middleware to. Paths are relative
296        to the point at which this middleware is mounted as set in
297        environ['PATH_INFO']
298        @raise TypeError: incorrect input type
299        '''
300       
301        if not isinstance(value, basestring):
302            raise TypeError('Expecting string type for "mountPath" attribute; '
303                            'got %r' % value)
304           
305        self.__mountPath = value
306           
307    mountPath = property(fget=_getMountPath,
308                         fset=_setMountPath,
309                         doc='URL path to mount this application equivalent to '
310                             'environ[\'PATH_INFO\'] (Nb. doesn\'t '
311                             'include server domain name or '
312                             'environ[\'SCRIPT_NAME\'] setting')
313   
314    @classmethod
315    def filter_app_factory(cls, app, global_conf, **app_conf):
316        """Set-up using a Paste app factory pattern.  Set this method to avoid
317        possible conflicts from multiple inheritance
318       
319        @type app: callable following WSGI interface
320        @param app: next middleware application in the chain     
321        @type global_conf: dict       
322        @param global_conf: PasteDeploy global configuration dictionary
323        @type prefix: basestring
324        @param prefix: prefix for configuration items
325        @type app_conf: dict       
326        @param app_conf: PasteDeploy application specific configuration
327        dictionary
328        """
329        app = cls(app)
330        app.initialise(global_conf, **app_conf)
331       
332        return app
333   
334    def _getQueryInterfaceKeyName(self):
335        return self.__queryInterfaceKeyName
336
337    def _setQueryInterfaceKeyName(self, value):
338        if not isinstance(value, basestring):
339            raise TypeError('Expecting string type for "queryInterfaceKeyName"'
340                            ' got %r' % value)
341           
342        self.__queryInterfaceKeyName = value
343
344    queryInterfaceKeyName = property(fget=_getQueryInterfaceKeyName, 
345                                     fset=_setQueryInterfaceKeyName, 
346                                     doc="environ key name for Attribute Query "
347                                         "interface")
348   
349    def __call__(self, environ, start_response):
350        """Check for and parse a SOAP SAML Attribute Query and return a
351        SAML Response
352       
353        @type environ: dict
354        @param environ: WSGI environment variables dictionary
355        @type start_response: function
356        @param start_response: standard WSGI start response function
357        """
358   
359        # Ignore non-matching path
360        if environ['PATH_INFO'] not in (self.mountPath, 
361                                        self.mountPath + '/'):
362            return self._app(environ, start_response)
363         
364        # Ignore non-POST requests
365        if environ.get('REQUEST_METHOD') != 'POST':
366            return self._app(environ, start_response)
367       
368        soapRequestStream = environ.get('wsgi.input')
369        if soapRequestStream is None:
370            raise SOAPQueryInterfaceMiddlewareError('No "wsgi.input" in '
371                                                    'environ')
372       
373        # TODO: allow for chunked data
374        contentLength = environ.get('CONTENT_LENGTH')
375        if contentLength is None:
376            raise SOAPQueryInterfaceMiddlewareError('No "CONTENT_LENGTH" in '
377                                                    'environ')
378
379        contentLength = int(contentLength)
380        if contentLength <= 0:
381            raise SOAPQueryInterfaceMiddlewareError('"CONTENT_LENGTH" in '
382                                                    'environ is %d' %
383                                                    contentLength)
384           
385        soapRequestTxt = soapRequestStream.read(contentLength)
386       
387        # Parse into a SOAP envelope object
388        soapRequest = SOAPEnvelope()
389        soapRequest.parse(StringIO(soapRequestTxt))
390       
391        log.debug("SOAPQueryInterfaceMiddleware.__call__: received SAML "
392                  "SOAP Query: %s", soapRequestTxt)
393       
394        queryElem = soapRequest.body.elem[0]
395       
396        # Create a response with basic attributes if provided in the
397        # initialisation config
398        samlResponse = self._initResponse()
399       
400        try:
401            samlQuery = self.deserialise(queryElem)
402           
403        except UnknownAttrProfile, e:
404            log.exception("%r raised parsing incoming query: %s" % 
405                          (type(e), traceback.format_exc()))
406            samlResponse.status.statusCode.value = \
407                                            StatusCode.UNKNOWN_ATTR_PROFILE_URI
408        else:   
409            # Check for Query Interface in environ
410            queryInterface = environ.get(self.queryInterfaceKeyName,
411                                         NotImplemented)
412            if queryInterface == NotImplemented:
413                raise SOAPQueryInterfaceMiddlewareConfigError(
414                                'No query interface %r key found in environ' %
415                                self.queryInterfaceKeyName)
416               
417            elif not callable(queryInterface):
418                raise SOAPQueryInterfaceMiddlewareConfigError(
419                    'Query interface %r set in %r environ key is not callable' %
420                    (queryInterface, self.queryInterfaceKeyName))
421           
422            # Basic validation
423            self._validateQuery(samlQuery, samlResponse)
424           
425            samlResponse.inResponseTo = samlQuery.id
426           
427            # Call query interface       
428            queryInterface(samlQuery, samlResponse)
429       
430        # Convert to ElementTree representation to enable attachment to SOAP
431        # response body
432        samlResponseElem = self.serialise(samlResponse)
433       
434        # Create SOAP response and attach the SAML Response payload
435        soapResponse = SOAPEnvelope()
436        soapResponse.create()
437        soapResponse.body.elem.append(samlResponseElem)
438       
439        response = soapResponse.serialize()
440       
441        log.debug("SOAPQueryInterfaceMiddleware.__call__: sending response "
442                  "...\n\n%s",
443                  response)
444        start_response("200 OK",
445                       [('Content-length', str(len(response))),
446                        ('Content-type', 'text/xml')])
447        return [response]
448   
449    def _validateQuery(self, query, response):
450        """Checking incoming query issue instant and version
451        @type query: saml.saml2.core.SubjectQuery
452        @param query: SAML subject query to be checked
453        @type: saml.saml2.core.Response
454        @param: SAML Response
455        """
456        self._verifyQueryTimeConditions(query, response)
457        self._verifyQuerySAMLVersion(query, response)
458       
459    def _verifyQueryTimeConditions(self, query, response):
460        """Checking incoming query issue instant
461        @type query: saml.saml2.core.SubjectQuery
462        @param query: SAML subject query to be checked
463        @type: saml.saml2.core.Response
464        @param: SAML Response
465        @raise QueryIssueInstantInvalid: for invalid issue instant
466        """
467        if not self.verifyTimeConditions: 
468            log.debug("Skipping verification of SAML query time conditions")
469            return
470             
471        utcNow = datetime.utcnow() 
472        nowPlusSkew = utcNow + self.clockSkewTolerance
473       
474        if query.issueInstant > nowPlusSkew:
475            msg = ('SAML Attribute Query issueInstant [%s] is after '
476                   'the clock time [%s] (skewed +%s)' % 
477                   (query.issueInstant, 
478                    SAMLDateTime.toString(nowPlusSkew),
479                    self.clockSkewTolerance))
480             
481            samlRespError = QueryIssueInstantInvalid(msg)
482            samlRespError.response = response
483            raise samlRespError
484           
485    def _verifyQuerySAMLVersion(self, query, response):
486        """Checking incoming query issue SAML version
487       
488        @type query: saml.saml2.core.SubjectQuery
489        @param query: SAML subject query to be checked
490        @type: saml.saml2.core.Response
491        @param: SAML Response
492        """
493        if not self.verifySAMLVersion:
494            log.debug("Skipping verification of SAML query version")
495            return
496       
497        if query.version < self.samlVersion:
498            log.debug("Query SAML version %r is lower than the supported "
499                      "value %r", query.version, self.samlVersion)
500            response.status.statusCode.value = \
501                                        StatusCode.REQUEST_VERSION_TOO_LOW_URI
502       
503        elif query.version > self.samlVersion:
504            log.debug("Query SAML version %r is higher than the supported "
505                      "value %r", query.version, self.samlVersion)
506            response.status.statusCode.value = \
507                                        StatusCode.REQUEST_VERSION_TOO_HIGH_URI
508           
509       
510    def _initResponse(self):
511        """Create a SAML Response object with basic settings if any have been
512        provided at initialisation of this class - see initialise
513       
514        @return: SAML response object
515        @rtype: ndg.saml.saml2.core.Response
516        """
517        samlResponse = Response()
518        utcNow = datetime.utcnow()
519       
520        samlResponse.issueInstant = utcNow
521        samlResponse.id = str(uuid4())
522        samlResponse.issuer = Issuer()
523       
524        if self.issuerName is not None:
525            samlResponse.issuer.value = self.issuerName
526       
527        if self.issuerFormat is not None:
528            # TODO: Check SAML 2.0 spec says issuer format must be omitted??
529            samlResponse.issuer.format = self.issuerFormat
530       
531        # Initialise to success status but reset on error
532        samlResponse.status = Status()
533        samlResponse.status.statusCode = StatusCode()
534        samlResponse.status.statusMessage = StatusMessage()
535        samlResponse.status.statusCode.value = StatusCode.SUCCESS_URI
536       
537        samlResponse.status.statusMessage = StatusMessage()
538
539        return samlResponse
540
Note: See TracBrowser for help on using the repository browser.