source: TI12-security/trunk/NDGSecurity/python/ndg_security_server/ndg/security/server/wsgi/saml/__init__.py @ 7077

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/TI12-security/trunk/NDGSecurity/python/ndg_security_server/ndg/security/server/wsgi/saml/__init__.py@7077
Revision 7077, 19.6 KB checked in by pjkersha, 9 years ago (diff)
  • 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__ = "BSD - see LICENSE file in top-level directory"
12import logging
13log = logging.getLogger(__name__)
14import traceback
15from cStringIO import StringIO
16from uuid import uuid4
17from datetime import datetime, timedelta
18from xml.etree import ElementTree
19
20from ndg.saml.common import SAMLVersion
21from ndg.saml.utils import SAMLDateTime
22from ndg.saml.saml2.core import (Response, SubjectQuery, Status, StatusCode,
23                                 StatusMessage, Issuer) 
24from ndg.saml.xml import UnknownAttrProfile
25
26from ndg.security.common.utils import str2Bool
27from ndg.security.common.utils.factory import importModuleObject
28from ndg.security.common.soap.etree import SOAPEnvelope
29from ndg.security.common.saml_utils.esg import XSGroupRoleAttributeValue
30from ndg.security.common.saml_utils.esg.xml.etree import (
31                                        XSGroupRoleAttributeValueElementTree)
32from ndg.security.server.wsgi import NDGSecurityPathFilter
33from ndg.security.server.wsgi.soap import SOAPMiddleware
34
35
36class SOAPQueryInterfaceMiddlewareError(Exception):
37    """Base class for WSGI SAML 2.0 SOAP Query Interface Errors"""
38
39
40class SOAPQueryInterfaceMiddlewareConfigError(Exception):
41    """WSGI SAML 2.0 SOAP Query Interface Configuration problem"""
42
43
44class SOAPQueryInterfaceMiddleware(SOAPMiddleware, NDGSecurityPathFilter):
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 = "pathMatchList"
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        NDGSecurityPathFilter.__init__(self, app, None)
89       
90        self._app = app
91       
92        # Set defaults
93        cls = SOAPQueryInterfaceMiddleware
94        self.__queryInterfaceKeyName = cls.DEFAULT_QUERY_INTERFACE_KEYNAME
95        self.pathMatchList = ['/']
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        cls = SOAPQueryInterfaceMiddleware
124       
125        # Override where set in config
126        for name in SOAPQueryInterfaceMiddleware.CONFIG_FILE_OPTNAMES:
127            val = app_conf.get(prefix + name)
128            if val is not None:
129                setattr(self, name, val)
130
131        if self.serialise is None:
132            raise AttributeError('No "serialise" method set to serialise the '
133                                 'SAML response from this middleware.')
134
135        if self.deserialise is None:
136            raise AttributeError('No "deserialise" method set to parse the '
137                                 'SAML request to this middleware.')
138           
139    def _getSerialise(self):
140        return self.__serialise
141
142    def _setSerialise(self, value):
143        if isinstance(value, basestring):
144            self.__serialise = importModuleObject(value)
145           
146        elif callable(value):
147            self.__serialise = value
148        else:
149            raise TypeError('Expecting callable for "serialise"; got %r' % 
150                            value)
151
152    serialise = property(_getSerialise, _setSerialise, 
153                         doc="callable to serialise request into XML type")
154
155    def _getDeserialise(self):
156        return self.__deserialise
157
158    def _setDeserialise(self, value):
159        if isinstance(value, basestring):
160            self.__deserialise = importModuleObject(value)
161           
162        elif callable(value):
163            self.__deserialise = value
164        else:
165            raise TypeError('Expecting callable for "deserialise"; got %r' % 
166                            value)
167       
168    deserialise = property(_getDeserialise, 
169                           _setDeserialise, 
170                           doc="callable to de-serialise response from XML "
171                               "type")       
172
173    def _getIssuer(self):
174        return self.__issuer
175
176    def _setIssuer(self, value):
177        if not isinstance(value, basestring):
178            raise TypeError('Expecting string type for "issuer"; got %r' %
179                            type(value))
180           
181        self.__issuer = value
182       
183    issuer = property(fget=_getIssuer, 
184                      fset=_setIssuer, 
185                      doc="Name of issuing authority")
186
187    def _getIssuerFormat(self):
188        if self.__issuerProxy is None:
189            return None
190        else:
191            return self.__issuerProxy.value
192
193    def _setIssuerFormat(self, value):
194        if self.__issuerProxy is None:
195            self.__issuerProxy = Issuer()
196           
197        self.__issuerProxy.format = value
198
199    issuerFormat = property(_getIssuerFormat, _setIssuerFormat, 
200                            doc="Issuer format")
201
202    def _getIssuerName(self):
203        if self.__issuerProxy is None:
204            return None
205        else:
206            return self.__issuerProxy.value
207
208    def _setIssuerName(self, value):
209        self.__issuerProxy.value = value
210
211    issuerName = property(_getIssuerName, _setIssuerName, 
212                          doc="Name of issuer of SAML Query Response")
213
214    def _getVerifyTimeConditions(self):
215        return self.__verifyTimeConditions
216
217    def _setVerifyTimeConditions(self, value):
218        if isinstance(value, bool):
219            self.__verifyTimeConditions = value
220           
221        if isinstance(value, basestring):
222            self.__verifyTimeConditions = str2Bool(value)
223        else:
224            raise TypeError('Expecting bool or string type for '
225                            '"verifyTimeConditions"; got %r instead' % 
226                            type(value))
227
228    verifyTimeConditions = property(_getVerifyTimeConditions, 
229                                    _setVerifyTimeConditions, 
230                                    doc='Set to True to verify any time '
231                                        'Conditions set in the returned '
232                                        'response assertions')
233
234    def _getVerifySAMLVersion(self):
235        return self.__verifySAMLVersion
236
237    def _setVerifySAMLVersion(self, value):
238        if isinstance(value, bool):
239            self.__verifySAMLVersion = value
240           
241        if isinstance(value, basestring):
242            self.__verifySAMLVersion = str2Bool(value)
243        else:
244            raise TypeError('Expecting bool or string type for '
245                            '"verifySAMLVersion"; got %r instead' % 
246                            type(value))
247
248    verifySAMLVersion = property(_getVerifySAMLVersion, 
249                                 _setVerifySAMLVersion, 
250                                 doc='Set to True to verify the SAML version '
251                                     'set in the query against the SAML '
252                                     'Version set in the "samlVersion" '
253                                     'attribute')
254       
255    def _getClockSkewTolerance(self):
256        return self.__clockSkewTolerance
257
258    def _setClockSkewTolerance(self, value):
259        if isinstance(value, timedelta):
260            self.__clockSkewTolerance = value
261           
262        elif isinstance(value, (float, int, long)):
263            self.__clockSkewTolerance = timedelta(seconds=value)
264           
265        elif isinstance(value, basestring):
266            self.__clockSkewTolerance = timedelta(seconds=float(value))
267        else:
268            raise TypeError('Expecting timedelta, float, int, long or string '
269                            'type for "clockSkewTolerance"; got %r' % 
270                            type(value)) 
271               
272    clockSkewTolerance = property(fget=_getClockSkewTolerance, 
273                                  fset=_setClockSkewTolerance, 
274                                  doc="Set a tolerance of +/- n seconds to "
275                                      "allow for clock skew when checking the "
276                                      "timestamps of client queries")
277
278    def _getSamlVersion(self):
279        return self.__samlVersion
280
281    def _setSamlVersion(self, value):
282        if not isinstance(value, basestring):
283            raise TypeError('Expecting string type for "samlVersion"; got %r' % 
284                            type(value)) 
285        self.__samlVersion = value
286
287    samlVersion = property(_getSamlVersion, _setSamlVersion, None, 
288                           "SAML Version to enforce for incoming queries.  "
289                           "Defaults to version 2.0")
290
291    @classmethod
292    def filter_app_factory(cls, app, global_conf, **app_conf):
293        """Set-up using a Paste app factory pattern.  Set this method to avoid
294        possible conflicts from multiple inheritance
295       
296        @type app: callable following WSGI interface
297        @param app: next middleware application in the chain     
298        @type global_conf: dict       
299        @param global_conf: PasteDeploy global configuration dictionary
300        @type prefix: basestring
301        @param prefix: prefix for configuration items
302        @type app_conf: dict       
303        @param app_conf: PasteDeploy application specific configuration
304        dictionary
305        """
306        app = cls(app)
307        app.initialise(global_conf, **app_conf)
308       
309        return app
310   
311    def _getQueryInterfaceKeyName(self):
312        return self.__queryInterfaceKeyName
313
314    def _setQueryInterfaceKeyName(self, value):
315        if not isinstance(value, basestring):
316            raise TypeError('Expecting string type for "queryInterfaceKeyName"'
317                            ' got %r' % value)
318           
319        self.__queryInterfaceKeyName = value
320
321    queryInterfaceKeyName = property(fget=_getQueryInterfaceKeyName, 
322                                     fset=_setQueryInterfaceKeyName, 
323                                     doc="environ key name for Attribute Query "
324                                         "interface")
325   
326    @NDGSecurityPathFilter.initCall
327    def __call__(self, environ, start_response):
328        """Check for and parse a SOAP SAML Attribute Query and return a
329        SAML Response
330       
331        @type environ: dict
332        @param environ: WSGI environment variables dictionary
333        @type start_response: function
334        @param start_response: standard WSGI start response function
335        """
336   
337        # Ignore non-matching path
338        if not self.pathMatch:
339            return self._app(environ, start_response)
340         
341        # Ignore non-POST requests
342        if environ.get('REQUEST_METHOD') != 'POST':
343            return self._app(environ, start_response)
344       
345        soapRequestStream = environ.get('wsgi.input')
346        if soapRequestStream is None:
347            raise SOAPQueryInterfaceMiddlewareError('No "wsgi.input" in '
348                                                    'environ')
349       
350        # TODO: allow for chunked data
351        contentLength = environ.get('CONTENT_LENGTH')
352        if contentLength is None:
353            raise SOAPQueryInterfaceMiddlewareError('No "CONTENT_LENGTH" in '
354                                                    'environ')
355
356        contentLength = int(contentLength)
357        soapRequestTxt = soapRequestStream.read(contentLength)
358       
359        # Parse into a SOAP envelope object
360        soapRequest = SOAPEnvelope()
361        soapRequest.parse(StringIO(soapRequestTxt))
362       
363        log.debug("SOAPQueryInterfaceMiddleware.__call__: received SAML "
364                  "SOAP SQuery ...")
365       
366        queryElem = soapRequest.body.elem[0]
367       
368        # Create a response with basic attributes if provided in the
369        # initialisation config
370        samlResponse = self._initResponse()
371       
372        try:
373            samlQuery = self.deserialise(queryElem)
374           
375        except UnknownAttrProfile, e:
376            log.exception("%r raised parsing incoming query: %s" % 
377                          (type(e), traceback.format_exc()))
378            samlResponse.statusCode.value = StatusCode.UNKNOWN_ATTR_PROFILE_URI
379        else:   
380            # Check for Query Interface in environ
381            queryInterface = environ.get(self.queryInterfaceKeyName)
382            if queryInterface is None:
383                raise SOAPQueryInterfaceMiddlewareConfigError(
384                                'No query interface "%s" key found in environ' %
385                                self.queryInterfaceKeyName)
386           
387            # Basic validation
388            self._validateQuery(samlQuery, samlResponse)
389           
390            samlResponse.inResponseTo = samlQuery.id
391           
392            # Call query interface       
393            queryInterface(samlQuery, samlResponse)
394       
395        # Convert to ElementTree representation to enable attachment to SOAP
396        # response body
397        samlResponseElem = self.serialise(samlResponse)
398       
399        # Create SOAP response and attach the SAML Response payload
400        soapResponse = SOAPEnvelope()
401        soapResponse.create()
402        soapResponse.body.elem.append(samlResponseElem)
403       
404        response = soapResponse.serialize()
405       
406        log.debug("SOAPQueryInterfaceMiddleware.__call__: sending response "
407                  "...\n\n%s",
408                  response)
409        start_response("200 OK",
410                       [('Content-length', str(len(response))),
411                        ('Content-type', 'text/xml')])
412        return [response]
413   
414    def _validateQuery(self, query, response):
415        """Checking incoming query issue instant and version
416        @type query: saml.saml2.core.SubjectQuery
417        @param query: SAML subject query to be checked
418        @type: saml.saml2.core.Response
419        @param: SAML Response
420        """
421        self._verifyQueryTimeConditions(query, response)
422        self._verifyQuerySAMLVersion(query, response)
423       
424    def _verifyQueryTimeConditions(self, query, response):
425        """Checking incoming query issue instant
426        @type query: saml.saml2.core.SubjectQuery
427        @param query: SAML subject query to be checked
428        @type: saml.saml2.core.Response
429        @param: SAML Response
430        @raise QueryIssueInstantInvalid: for invalid issue instant
431        """
432        if not self.verifyTimeConditions: 
433            log.debug("Skipping verification of SAML query time conditions")
434            return
435             
436        utcNow = datetime.utcnow() 
437        nowPlusSkew = utcNow + self.clockSkewTolerance
438       
439        if query.issueInstant > nowPlusSkew:
440            msg = ('SAML Attribute Query issueInstant [%s] is after '
441                   'the clock time [%s] (skewed +%s)' % 
442                   (query.issueInstant, 
443                    SAMLDateTime.toString(nowPlusSkew),
444                    self.clockSkewTolerance))
445             
446            samlRespError = QueryIssueInstantInvalid(msg)
447            samlRespError.response = response
448            raise samlRespError
449           
450    def _verifyQuerySAMLVersion(self, query, response):
451        """Checking incoming query issue SAML version
452       
453        @type query: saml.saml2.core.SubjectQuery
454        @param query: SAML subject query to be checked
455        @type: saml.saml2.core.Response
456        @param: SAML Response
457        """
458        if not self.verifySAMLVersion:
459            log.debug("Skipping verification of SAML query version")
460            return
461       
462        if query.version < self.samlVersion:
463            msg = ("Query SAML version %r is lower than the supported "
464                   "value %r"
465                   % (query.version, self.samlVersion))
466            response.status.statusCode.value = \
467                                        StatusCode.REQUEST_VERSION_TOO_LOW_URI
468            return
469       
470        elif query.version > self.samlVersion:
471            msg = ("Query SAML version %r is higher than the supported "
472                   "value %r"
473                   % (query.version, self.samlVersion))
474            response.status.statusCode.value = \
475                                        StatusCode.REQUEST_VERSION_TOO_HIGH_URI
476            return           
477       
478    def _initResponse(self):
479        """Create a SAML Response object with basic settings if any have been
480        provided at initialisation of this class - see initialise
481       
482        @return: SAML response object
483        @rtype: ndg.saml.saml2.core.Response
484        """
485        samlResponse = Response()
486        utcNow = datetime.utcnow()
487       
488        samlResponse.issueInstant = utcNow
489        samlResponse.id = str(uuid4())
490        samlResponse.issuer = Issuer()
491       
492        if self.issuerName is not None:
493            samlResponse.issuer.value = self.issuerName
494       
495        if self.issuerFormat is not None:
496            # TODO: Check SAML 2.0 spec says issuer format must be omitted??
497            samlResponse.issuer.format = self.issuerFormat
498       
499        # Initialise to success status but reset on error
500        samlResponse.status = Status()
501        samlResponse.status.statusCode = StatusCode()
502        samlResponse.status.statusMessage = StatusMessage()
503        samlResponse.status.statusCode.value = StatusCode.SUCCESS_URI
504       
505        samlResponse.status.statusMessage = StatusMessage()
506
507        return samlResponse
508
Note: See TracBrowser for help on using the repository browser.