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

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

Incomplete - task 2: XACML-Security Integration

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