source: TI12-security/trunk/python/ndg_security_common/ndg/security/common/saml_utils/bindings.py @ 6069

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/TI12-security/trunk/python/ndg_security_common/ndg/security/common/saml_utils/bindings.py@6069
Revision 6069, 20.7 KB checked in by pjkersha, 10 years ago (diff)

Re-release as rc1

Line 
1"""SAML 2.0 bindings module implements SOAP binding for attribute query
2
3NERC DataGrid Project
4"""
5__author__ = "P J Kershaw"
6__date__ = "02/09/09"
7__copyright__ = "(C) 2009 Science and Technology Facilities Council"
8__license__ = "BSD - see LICENSE file in top-level directory"
9__contact__ = "Philip.Kershaw@stfc.ac.uk"
10__revision__ = '$Id: $'
11import logging
12log = logging.getLogger(__name__)
13
14import re
15from os import path
16from datetime import datetime, timedelta
17from uuid import uuid4
18from ConfigParser import ConfigParser
19
20from M2Crypto.m2urllib2 import HTTPSHandler
21
22from saml.common import SAMLObject
23from saml.utils import SAMLDateTime
24from saml.saml2.core import (Attribute, AttributeQuery, StatusCode, Response,
25                             Issuer, Subject, SAMLVersion, NameID)
26from saml.xml.etree import AttributeQueryElementTree, ResponseElementTree
27
28from ndg.security.common.saml_utils.esg import EsgSamlNamespaces
29from ndg.security.common.utils import TypedList
30from ndg.security.common.utils.configfileparsers import (
31                                                    CaseSensitiveConfigParser)
32from ndg.security.common.utils.etree import QName   
33from ndg.security.common.X509 import X500DN
34from ndg.security.common.soap import SOAPEnvelopeBase
35from ndg.security.common.soap.etree import SOAPEnvelope
36from ndg.security.common.soap.client import (UrlLib2SOAPClient, 
37                                             UrlLib2SOAPRequest)
38
39# Prevent whole module breaking if this is not available - it's only needed for
40# AttributeQuerySslSOAPBinding
41try:
42    from ndg.security.common.utils.m2crypto import SSLContextProxy
43    _sslContextProxySupport = True
44   
45except ImportError:
46    _sslContextProxySupport = False
47
48
49
50class SOAPBindingError(Exception):
51    '''Base exception type for client SAML SOAP Binding for Attribute Query'''
52
53
54class SOAPBindingInvalidResponse(SOAPBindingError):
55    '''Raise if the response is invalid'''
56   
57   
58_isIterable = lambda obj: getattr(obj, '__iter__', False) 
59   
60
61class SOAPBinding(object):
62    '''Client SAML SOAP Binding'''
63   
64    isIterable = staticmethod(_isIterable)
65    __slots__ = (
66        "__client",
67        "__requestEnvelopeClass",
68        "__serialise",
69        "__deserialise"
70    )
71   
72    def __init__(self, 
73                 requestEnvelopeClass=SOAPEnvelope,
74                 responseEnvelopeClass=SOAPEnvelope,
75                 serialise=AttributeQueryElementTree.toXML,
76                 deserialise=ResponseElementTree.fromXML,
77                 handlers=(HTTPSHandler,)):
78        '''Create SAML SOAP Client - Nb. serialisation functions assume
79        AttributeQuery/Response'''
80        self.__client = None
81        self.serialise = serialise
82        self.deserialise = deserialise
83       
84        self.client = UrlLib2SOAPClient()
85       
86        # ElementTree based envelope class
87        self.requestEnvelopeClass = requestEnvelopeClass
88        self.client.responseEnvelopeClass = responseEnvelopeClass
89
90        if not SOAPBinding.isIterable(handlers):
91            raise TypeError('Expecting iterable for "handlers" keyword; got %r'
92                            % type(handlers))
93           
94        for handler in handlers:
95            self.client.openerDirector.add_handler(handler())
96
97    def _getSerialise(self):
98        return self.__serialise
99
100    def _setSerialise(self, value):
101        if not callable(value):
102            raise TypeError('Expecting callable for "serialise"; got %r' % 
103                            value)
104        self.__serialise = value
105
106    serialise = property(_getSerialise, _setSerialise, 
107                         doc="callable to serialise request into XML type")
108
109    def _getDeserialise(self):
110        return self.__deserialise
111
112    def _setDeserialise(self, value):
113        if not callable(value):
114            raise TypeError('Expecting callable for "deserialise"; got %r' % 
115                            value)
116        self.__deserialise = value
117
118    deserialise = property(_getDeserialise, 
119                           _setDeserialise, 
120                           doc="callable to de-serialise response from XML "
121                               "type")
122
123    def _getRequestEnvelopeClass(self):
124        return self.__requestEnvelopeClass
125
126    def _setRequestEnvelopeClass(self, value):
127        if not issubclass(value, SOAPEnvelopeBase):
128            raise TypeError('Expecting %r for "requestEnvelopeClass"; got %r'% 
129                            (SOAPEnvelopeBase, value))
130       
131        self.__requestEnvelopeClass = value
132
133    requestEnvelopeClass = property(_getRequestEnvelopeClass, 
134                                    _setRequestEnvelopeClass, 
135                                    doc="SOAP Envelope Request Class")
136
137    def _getClient(self):
138        return self.__client
139
140    def _setClient(self, value):     
141        if not isinstance(value, UrlLib2SOAPClient):
142            raise TypeError('Expecting %r for "client"; got %r'% 
143                            (UrlLib2SOAPClient, type(value)))
144        self.__client = value
145
146    client = property(_getClient, _setClient, 
147                      doc="SOAP Client object")   
148
149    def send(self, samlObj, uri=None, request=None):
150        '''Make an request/query to a remote SAML service
151       
152        @type samlObj: saml.common.SAMLObject
153        @param samlObj: SAML query/request object
154        @type uri: basestring
155        @param uri: uri of service.  May be omitted if set from request.url
156        @type request: ndg.security.common.soap.UrlLib2SOAPRequest
157        @param request: SOAP request object to which query will be attached
158        defaults to ndg.security.common.soap.client.UrlLib2SOAPRequest
159        '''
160        if not isinstance(samlObj, SAMLObject):
161            raise TypeError('Expecting %r for input attribute query; got %r'
162                            % (SAMLObject, type(samlObj)))
163           
164        if request is None:
165            request = UrlLib2SOAPRequest()           
166            request.envelope = self.requestEnvelopeClass()
167            request.envelope.create()
168           
169        if uri is not None:
170            request.url = uri
171       
172        samlElem = self.serialise(samlObj)
173
174        # Attach query to SOAP body
175        request.envelope.body.elem.append(samlElem)
176           
177        response = self.client.send(request)
178       
179        if len(response.envelope.body.elem) != 1:
180            raise SOAPBindingInvalidResponse("Expecting single child element "
181                                             "is SOAP body")
182           
183        if QName.getLocalPart(response.envelope.body.elem[0].tag)!='Response':
184            raise SOAPBindingInvalidResponse('Expecting "Response" element in '
185                                             'SOAP body')
186           
187        response = self.deserialise(response.envelope.body.elem[0])
188       
189        return response
190       
191    def __getstate__(self):
192        '''Enable pickling for use with beaker.session'''
193        _dict = {}
194        for attrName in SOAPBinding.__slots__:
195            # Ugly hack to allow for derived classes setting private member
196            # variables
197            if attrName.startswith('__'):
198                attrName = "_SOAPBinding" + attrName
199               
200            _dict[attrName] = getattr(self, attrName)
201           
202        return _dict
203       
204    def __setstate__(self, attrDict):
205        '''Specific implementation needed with __slots__'''
206        for attr, val in attrDict.items():
207            setattr(self, attr, val)
208           
209
210class AttributeQueryResponseError(SOAPBindingInvalidResponse):
211    """Attribute Authority returned a SAML Response error code"""
212    def __init__(self, *arg, **kw):
213        SOAPBindingInvalidResponse.__init__(self, *arg, **kw)
214        self.__response = None
215   
216    def _getResponse(self):
217        '''Gets the response corresponding to this error
218       
219        @return the response
220        '''
221        return self.__response
222
223    def _setResponse(self, value):
224        '''Sets the response corresponding to this error.
225       
226        @param value: the response
227        '''
228        if not isinstance(value, Response):
229            raise TypeError('"response" must be a %r, got %r' % (Response,
230                                                                 type(value)))
231        self.__response = value
232       
233    response = property(fget=_getResponse, fset=_setResponse, 
234                        doc="SAML Response associated with this exception")
235
236
237class AttributeQuerySOAPBinding(SOAPBinding): 
238    """SAML Attribute Query SOAP Binding
239   
240    Nb. Assumes X.509 subject type for query issuer
241    """
242    SUBJECT_ID_OPTNAME = 'subjectID'
243    ISSUER_NAME_OPTNAME = 'issuerName'
244    CLOCK_SKEW_OPTNAME = 'clockSkew'
245   
246    CONFIG_FILE_OPTNAMES = (
247        SUBJECT_ID_OPTNAME,
248        ISSUER_NAME_OPTNAME,                 
249        CLOCK_SKEW_OPTNAME           
250    )
251   
252    QUERY_ATTRIBUTES_ATTRNAME = 'queryAttributes'
253    LEN_QUERY_ATTRIBUTES_ATTRNAME = len(QUERY_ATTRIBUTES_ATTRNAME)
254    QUERY_ATTRIBUTES_PAT = re.compile(',\s*')
255   
256    __PRIVATE_ATTR_PREFIX = "__"
257    __slots__ = tuple([__PRIVATE_ATTR_PREFIX + i
258                       for i in \
259                       CONFIG_FILE_OPTNAMES + (QUERY_ATTRIBUTES_ATTRNAME,)])
260    del i
261   
262    def __init__(self, **kw):
263        '''Create SOAP Client for SAML Attribute Query'''
264        self.__issuerName = None
265        self.__queryAttributes = TypedList(Attribute)
266        self.__clockSkew = timedelta(seconds=0.)
267               
268        super(AttributeQuerySOAPBinding, self).__init__(**kw)
269
270    @classmethod
271    def fromConfig(cls, cfg, **kw):
272        '''Alternative constructor makes object from config file settings
273        @type cfg: basestring /ConfigParser derived type
274        @param cfg: configuration file path or ConfigParser type object
275        @rtype: ndg.security.common.credentialWallet.AttributeQuery
276        @return: new instance of this class
277        '''
278        obj = cls()
279        obj.parseConfig(cfg, **kw)
280       
281        return obj
282
283    def parseConfig(self, cfg, prefix='', section='DEFAULT'):
284        '''Read config file settings
285        @type cfg: basestring /ConfigParser derived type
286        @param cfg: configuration file path or ConfigParser type object
287        @type prefix: basestring
288        @param prefix: prefix for option names e.g. "attributeQuery."
289        @type section: baestring
290        @param section: configuration file section from which to extract
291        parameters.
292        ''' 
293        if isinstance(cfg, basestring):
294            cfgFilePath = path.expandvars(cfg)
295            _cfg = CaseSensitiveConfigParser()
296            _cfg.read(cfgFilePath)
297           
298        elif isinstance(cfg, ConfigParser):
299            _cfg = cfg   
300        else:
301            raise AttributeError('Expecting basestring or ConfigParser type '
302                                 'for "cfg" attribute; got %r type' % type(cfg))
303       
304        prefixLen = len(prefix)
305        for optName, val in _cfg.items(section):
306            if prefix:
307                # Filter attributes based on prefix
308                if optName.startswith(prefix):
309                    setattr(self, optName[prefixLen:], val)
310            else:
311                # No prefix set - attempt to set all attributes   
312                setattr(self, optName, val)
313           
314    def __setattr__(self, name, value):
315        """Enable setting of SAML query attribute objects via a comma separated
316        string suitable for use reading from an ini file. 
317        """
318        try:
319            super(AttributeQuerySOAPBinding, self).__setattr__(name, value)
320           
321        except AttributeError:
322            if name.startswith(
323                        AttributeQuerySOAPBinding.QUERY_ATTRIBUTES_ATTRNAME):
324                # Special handler for parsing string format settings
325                if not isinstance(value, basestring):
326                    raise TypeError('Expecting string format for special '
327                                    '%r attribute; got %r instead' %
328                                    (name, type(value)))
329                   
330                pat = AttributeQuerySOAPBinding.QUERY_ATTRIBUTES_PAT
331                attribute = Attribute()
332               
333                (attribute.name, 
334                 attribute.friendlyName, 
335                 attribute.nameFormat) = pat.split(value)
336                 
337                self.queryAttributes.append(attribute)
338            else:
339                raise
340
341    def _getSubjectID(self):
342        return self.__subjectID
343
344    def _setSubjectID(self, value):
345        if not isinstance(value, basestring):
346            raise TypeError('Expecting string type for "subjectID"; got %r '
347                            'instead' % type(value))
348        self.__subjectID = value
349
350    subjectID = property(_getSubjectID, _setSubjectID, 
351                         doc="ID to be sent as query subject") 
352             
353    def _getQueryAttributes(self):
354        """Returns a *COPY* of the attributes to avoid overwriting the
355        member variable content
356        """
357        return self.__queryAttributes
358
359    def _setQueryAttributes(self, value):
360        if not isinstance(value, TypedList) and value.elementType != Attribute:
361            raise TypeError('Expecting TypedList(Attribute) type for '
362                            '"queryAttributes"; got %r instead' % type(value)) 
363       
364        self.__queryAttributes = value
365   
366    queryAttributes = property(_getQueryAttributes, 
367                               _setQueryAttributes, 
368                               doc="List of attributes to query from the "
369                                   "Attribute Authority")
370
371    def _getIssuerName(self):
372        return self.__issuerName
373
374    def _setIssuerName(self, value):
375        if not isinstance(value, basestring):
376            raise TypeError('Expecting string type for "issuerName"; '
377                            'got %r instead' % type(value))
378           
379        self.__issuerName = value
380
381    issuerName = property(_getIssuerName, _setIssuerName, 
382                        doc="Distinguished Name of issuer of SAML Attribute "
383                            "Query to Attribute Authority")
384
385    def _getClockSkew(self):
386        return self.__clockSkew
387
388    def _setClockSkew(self, value):
389        if isinstance(value, (float, int, long)):
390            self.__clockSkew = timedelta(seconds=value)
391           
392        elif isinstance(value, basestring):
393            self.__clockSkew = timedelta(seconds=float(value))
394        else:
395            raise TypeError('Expecting float, int, long or string type for '
396                            '"clockSkew"; got %r' % type(value))
397
398    clockSkew = property(fget=_getClockSkew, 
399                         fset=_setClockSkew, 
400                         doc="Allow a clock skew in seconds for SAML Attribute"
401                             " Query issueInstant parameter check") 
402
403    def _createQuery(self):
404        """ Create a SAML attribute query"""
405        attributeQuery = AttributeQuery()
406        attributeQuery.version = SAMLVersion(SAMLVersion.VERSION_20)
407        attributeQuery.id = str(uuid4())
408        attributeQuery.issueInstant = datetime.utcnow()
409       
410        if self.issuerName is None:
411            raise AttributeError('No issuer DN has been set for SAML Attribute '
412                                 'Query')
413       
414        attributeQuery.issuer = Issuer()
415        attributeQuery.issuer.format = Issuer.X509_SUBJECT
416        attributeQuery.issuer.value = self.issuerName
417                       
418        attributeQuery.subject = Subject() 
419        attributeQuery.subject.nameID = NameID()
420        attributeQuery.subject.nameID.format = EsgSamlNamespaces.NAMEID_FORMAT
421        attributeQuery.subject.nameID.value = self.subjectID
422                 
423        # Add list of attributes to query                     
424        for attribute in self.queryAttributes:
425            attributeQuery.attributes.append(attribute)
426           
427        return attributeQuery
428
429    def send(self, **kw):
430        '''Make an attribute query to a remote SAML service
431       
432        @type uri: basestring
433        @param uri: uri of service.  May be omitted if set from request.url
434        @type request: ndg.security.common.soap.UrlLib2SOAPRequest
435        @param request: SOAP request object to which query will be attached
436        defaults to ndg.security.common.soap.client.UrlLib2SOAPRequest
437        '''
438        attributeQuery = self._createQuery()
439           
440        response = super(AttributeQuerySOAPBinding, self).send(attributeQuery, 
441                                                               **kw)
442
443        # Perform validation
444        if response.status.statusCode.value != StatusCode.SUCCESS_URI:
445            msg = ('Return status code flagged an error.  The message is: %r' %
446                   response.status.statusMessage.value)
447            samlRespError = AttributeQueryResponseError(msg)
448            samlRespError.response = response
449            raise samlRespError
450       
451        # Check Query ID matches the query ID the service received
452        if response.inResponseTo != attributeQuery.id:
453            msg = ('Response in-response-to ID %r, doesn\'t match the original '
454                   'query ID, %r' % (response.inResponseTo, attributeQuery.id))
455           
456            samlRespError = AttributeQueryResponseError(msg)
457            samlRespError.response = response
458            raise samlRespError
459       
460        utcNow = datetime.utcnow() + self.clockSkew
461        if response.issueInstant > utcNow:
462            msg = ('SAML Attribute Response issueInstant [%s] is after '
463                   'the current clock time [%s]' % 
464                   (attributeQuery.issueInstant, SAMLDateTime.toString(utcNow)))
465           
466            samlRespError = AttributeQueryResponseError(msg)                 
467            samlRespError.response = response
468            raise samlRespError
469       
470        for assertion in response.assertions:
471            if utcNow < assertion.conditions.notBefore:           
472                msg = ('The current clock time [%s] is before the SAML '
473                       'Attribute Response assertion conditions not before '
474                       'time [%s]' % 
475                       (SAMLDateTime.toString(utcNow),
476                        assertion.conditions.notBefore))
477                         
478                samlRespError = AttributeQueryResponseError(msg)
479                samlRespError.response = response
480                raise samlRespError
481             
482            if utcNow >= assertion.conditions.notOnOrAfter:           
483                msg = ('The current clock time [%s] is on or after the SAML '
484                       'Attribute Response assertion conditions not on or '
485                       'after time [%s]' % 
486                       (SAMLDateTime.toString(utcNow),
487                        response.assertion.conditions.notOnOrAfter))
488               
489                samlRespError = AttributeQueryResponseError(msg) 
490                samlRespError.response = response
491                raise samlRespError   
492           
493        return response
494
495   
496class AttributeQuerySslSOAPBinding(AttributeQuerySOAPBinding):
497    """Specialisation of AttributeQuerySOAPbinding taking in the setting of
498    SSL parameters for mutual authentication
499    """
500    SSL_CONTEXT_PROXY_SUPPORT = _sslContextProxySupport
501    __slots__ = ('__sslCtxProxy',)
502   
503    def __init__(self, **kw):
504        if not AttributeQuerySslSOAPBinding.SSL_CONTEXT_PROXY_SUPPORT:
505            raise ImportError("ndg.security.common.utils.m2crypto import "
506                              "failed - missing M2Crypto package?")
507       
508        # Miss out default HTTPSHandler and set in send() instead
509        if 'handlers' in kw:
510            raise TypeError("__init__() got an unexpected keyword argument "
511                            "'handlers'")
512           
513        super(AttributeQuerySslSOAPBinding, self).__init__(handlers=(), **kw)
514        self.__sslCtxProxy = SSLContextProxy()
515
516    def send(self, **kw):
517        """Override base class implementation to pass explicit SSL Context
518        """
519        httpsHandler = HTTPSHandler(ssl_context=self.sslCtxProxy.createCtx())
520        self.client.openerDirector.add_handler(httpsHandler)
521        return super(AttributeQuerySslSOAPBinding, self).send(**kw)
522       
523    @property
524    def sslCtxProxy(self):
525        """SSL Context Proxy object used for setting up an SSL Context for
526        queries
527        """
528        return self.__sslCtxProxy
529           
530    def __setattr__(self, name, value):
531        """Enable setting of SSLContextProxy attributes as if they were
532        attributes of this class.  This is intended as a convenience for
533        making settings parameters read from a config file
534        """
535        try:
536            super(AttributeQuerySslSOAPBinding, self).__setattr__(name, value)
537           
538        except AttributeError:
539            # Coerce into setting SSL Context Proxy attributes
540            try:
541                setattr(self.sslCtxProxy, name, value)
542            except:
543                raise
Note: See TracBrowser for help on using the repository browser.