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

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

Updated MyProxy? Cert extension app for use with improved SAML Attribute Query interface class AttributeQuerySslSOAPBinding

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    __PRIVATE_ATTR_PREFIX = '_SOAPBinding__'
72    __slots__ += tuple([__PRIVATE_ATTR_PREFIX + i for i in __slots__])
73    del i
74   
75    def __init__(self, 
76                 requestEnvelopeClass=SOAPEnvelope,
77                 responseEnvelopeClass=SOAPEnvelope,
78                 serialise=AttributeQueryElementTree.toXML,
79                 deserialise=ResponseElementTree.fromXML,
80                 handlers=(HTTPSHandler,)):
81        '''Create SAML SOAP Client - Nb. serialisation functions assume
82        AttributeQuery/Response'''
83        self.__client = None
84        self.serialise = serialise
85        self.deserialise = deserialise
86       
87        self.client = UrlLib2SOAPClient()
88       
89        # ElementTree based envelope class
90        self.requestEnvelopeClass = requestEnvelopeClass
91        self.client.responseEnvelopeClass = responseEnvelopeClass
92
93        if not SOAPBinding.isIterable(handlers):
94            raise TypeError('Expecting iterable for "handlers" keyword; got %r'
95                            % type(handlers))
96           
97        for handler in handlers:
98            self.client.openerDirector.add_handler(handler())
99
100    def _getSerialise(self):
101        return self.__serialise
102
103    def _setSerialise(self, value):
104        if not callable(value):
105            raise TypeError('Expecting callable for "serialise"; got %r' % 
106                            value)
107        self.__serialise = value
108
109    serialise = property(_getSerialise, _setSerialise, 
110                         doc="callable to serialise request into XML type")
111
112    def _getDeserialise(self):
113        return self.__deserialise
114
115    def _setDeserialise(self, value):
116        if not callable(value):
117            raise TypeError('Expecting callable for "deserialise"; got %r' % 
118                            value)
119        self.__deserialise = value
120
121    deserialise = property(_getDeserialise, 
122                           _setDeserialise, 
123                           doc="callable to de-serialise response from XML "
124                               "type")
125
126    def _getRequestEnvelopeClass(self):
127        return self.__requestEnvelopeClass
128
129    def _setRequestEnvelopeClass(self, value):
130        if not issubclass(value, SOAPEnvelopeBase):
131            raise TypeError('Expecting %r for "requestEnvelopeClass"; got %r'% 
132                            (SOAPEnvelopeBase, value))
133       
134        self.__requestEnvelopeClass = value
135
136    requestEnvelopeClass = property(_getRequestEnvelopeClass, 
137                                    _setRequestEnvelopeClass, 
138                                    doc="SOAP Envelope Request Class")
139
140    def _getClient(self):
141        return self.__client
142
143    def _setClient(self, value):     
144        if not isinstance(value, UrlLib2SOAPClient):
145            raise TypeError('Expecting %r for "client"; got %r'% 
146                            (UrlLib2SOAPClient, type(value)))
147        self.__client = value
148
149    client = property(_getClient, _setClient, 
150                      doc="SOAP Client object")   
151
152    def send(self, samlObj, uri=None, request=None):
153        '''Make an request/query to a remote SAML service
154       
155        @type samlObj: saml.common.SAMLObject
156        @param samlObj: SAML query/request object
157        @type uri: basestring
158        @param uri: uri of service.  May be omitted if set from request.url
159        @type request: ndg.security.common.soap.UrlLib2SOAPRequest
160        @param request: SOAP request object to which query will be attached
161        defaults to ndg.security.common.soap.client.UrlLib2SOAPRequest
162        '''
163        if not isinstance(samlObj, SAMLObject):
164            raise TypeError('Expecting %r for input attribute query; got %r'
165                            % (SAMLObject, type(samlObj)))
166           
167        if request is None:
168            request = UrlLib2SOAPRequest()           
169            request.envelope = self.requestEnvelopeClass()
170            request.envelope.create()
171           
172        if uri is not None:
173            request.url = uri
174       
175        samlElem = self.serialise(samlObj)
176
177        # Attach query to SOAP body
178        request.envelope.body.elem.append(samlElem)
179           
180        response = self.client.send(request)
181       
182        if len(response.envelope.body.elem) != 1:
183            raise SOAPBindingInvalidResponse("Expecting single child element "
184                                             "is SOAP body")
185           
186        if QName.getLocalPart(response.envelope.body.elem[0].tag)!='Response':
187            raise SOAPBindingInvalidResponse('Expecting "Response" element in '
188                                             'SOAP body')
189           
190        response = self.deserialise(response.envelope.body.elem[0])
191       
192        return response
193       
194    def __getstate__(self):
195        '''Specific implementation needed with __slots__'''
196        return dict([(attrName, getattr(self, attrName)) 
197                     for attrName in self.__class__.__slots__])
198       
199    def __setstate__(self, attrDict):
200        '''Specific implementation needed with __slots__'''
201        for attr, val in attrDict.items():
202            setattr(self, attr, val)
203           
204
205class AttributeQueryResponseError(SOAPBindingInvalidResponse):
206    """Attribute Authority returned a SAML Response error code"""
207    def __init__(self, *arg, **kw):
208        SOAPBindingInvalidResponse.__init__(self, *arg, **kw)
209        self.__response = None
210   
211    def _getResponse(self):
212        '''Gets the response corresponding to this error
213       
214        @return the response
215        '''
216        return self.__response
217
218    def _setResponse(self, value):
219        '''Sets the response corresponding to this error.
220       
221        @param value: the response
222        '''
223        if not isinstance(value, Response):
224            raise TypeError('"response" must be a %r, got %r' % (Response,
225                                                                 type(value)))
226        self.__response = value
227       
228    response = property(fget=_getResponse, fset=_setResponse, 
229                        doc="SAML Response associated with this exception")
230
231
232class AttributeQuerySOAPBinding(SOAPBinding): 
233    """SAML Attribute Query SOAP Binding
234   
235    Nb. Assumes X.509 subject type for query issuer
236    """
237    SUBJECT_ID_OPTNAME = 'subjectID'
238    ISSUER_DN_OPTNAME = 'issuerDN'
239    CLOCK_SKEW_OPTNAME = 'clockSkew'
240   
241    CONFIG_FILE_OPTNAMES = (
242        SUBJECT_ID_OPTNAME,
243        ISSUER_DN_OPTNAME,                 
244        CLOCK_SKEW_OPTNAME           
245    )
246   
247    QUERY_ATTRIBUTES_ATTRNAME = 'queryAttributes'
248    LEN_QUERY_ATTRIBUTES_ATTRNAME = len(QUERY_ATTRIBUTES_ATTRNAME)
249    QUERY_ATTRIBUTES_PAT = re.compile(',\s*')
250   
251    __slots__ = (
252       QUERY_ATTRIBUTES_ATTRNAME,
253    )
254    __slots__ += CONFIG_FILE_OPTNAMES
255    __PRIVATE_ATTR_PREFIX = '_AttributeQuerySOAPBinding__'
256    __slots__ += tuple([__PRIVATE_ATTR_PREFIX + i for i in __slots__])
257    del i
258   
259    def __init__(self, **kw):
260        '''Create SOAP Client for SAML Attribute Query'''
261        self.__issuerDN = None
262        self.__queryAttributes = TypedList(Attribute)
263        self.__clockSkew = timedelta(seconds=0.)
264               
265        super(AttributeQuerySOAPBinding, self).__init__(**kw)
266
267    @classmethod
268    def fromConfig(cls, cfg, **kw):
269        '''Alternative constructor makes object from config file settings
270        @type cfg: basestring /ConfigParser derived type
271        @param cfg: configuration file path or ConfigParser type object
272        @rtype: ndg.security.common.credentialWallet.AttributeQuery
273        @return: new instance of this class
274        '''
275        obj = cls()
276        obj.parseConfig(cfg, **kw)
277       
278        return obj
279
280    def parseConfig(self, cfg, prefix='', section='DEFAULT'):
281        '''Read config file settings
282        @type cfg: basestring /ConfigParser derived type
283        @param cfg: configuration file path or ConfigParser type object
284        @type prefix: basestring
285        @param prefix: prefix for option names e.g. "attributeQuery."
286        @type section: baestring
287        @param section: configuration file section from which to extract
288        parameters.
289        ''' 
290        if isinstance(cfg, basestring):
291            cfgFilePath = path.expandvars(cfg)
292            _cfg = CaseSensitiveConfigParser()
293            _cfg.read(cfgFilePath)
294           
295        elif isinstance(cfg, ConfigParser):
296            _cfg = cfg   
297        else:
298            raise AttributeError('Expecting basestring or ConfigParser type '
299                                 'for "cfg" attribute; got %r type' % type(cfg))
300       
301        prefixLen = len(prefix)
302        for optName, val in _cfg.items(section):
303            if prefix:
304                # Filter attributes based on prefix
305                if optName.startswith(prefix):
306                    setattr(self, optName[prefixLen:], val)
307            else:
308                # No prefix set - attempt to set all attributes   
309                setattr(self, optName, val)
310           
311    def __setattr__(self, name, value):
312        """Enable setting of SAML query attribute objects via a comma separated
313        string suitable for use reading from an ini file. 
314        """
315        try:
316            super(AttributeQuerySOAPBinding, self).__setattr__(name, value)
317           
318        except AttributeError:
319            if name.startswith(
320                        AttributeQuerySOAPBinding.QUERY_ATTRIBUTES_ATTRNAME):
321                # Special handler for parsing string format settings
322                if not isinstance(value, basestring):
323                    raise TypeError('Expecting string format for special '
324                                    '%r attribute; got %r instead' %
325                                    (name, type(value)))
326                   
327                pat = AttributeQuerySOAPBinding.QUERY_ATTRIBUTES_PAT
328                attribute = Attribute()
329               
330                (attribute.name, 
331                 attribute.friendlyName, 
332                 attribute.format) = pat.split(value)
333                 
334                self.queryAttributes.append(attribute)
335            else:
336                raise
337
338    def _getSubjectID(self):
339        return self.__subjectID
340
341    def _setSubjectID(self, value):
342        if not isinstance(value, basestring):
343            raise TypeError('Expecting string type for "subjectID"; got %r '
344                            'instead' % type(value))
345        self.__subjectID = value
346
347    subjectID = property(_getSubjectID, _setSubjectID, 
348                         doc="ID to be sent as query subject") 
349             
350    def _getQueryAttributes(self):
351        """Returns a *COPY* of the attributes to avoid overwriting the
352        member variable content
353        """
354        return self.__queryAttributes
355
356    def _setQueryAttributes(self, value):
357        if not isinstance(value, TypedList) and value.elementType != Attribute:
358            raise TypeError('Expecting TypedList(Attribute) type for '
359                            '"queryAttributes"; got %r instead' % type(value)) 
360       
361        self.__queryAttributes = value
362   
363    queryAttributes = property(_getQueryAttributes, 
364                               _setQueryAttributes, 
365                               doc="List of attributes to query from the "
366                                   "Attribute Authority")
367
368    def _getIssuerDN(self):
369        return self.__issuerDN
370
371    def _setIssuerDN(self, value):
372        if isinstance(value, basestring):
373            self.__issuerDN = X500DN.fromString(value)
374           
375        elif isinstance(value, X500DN):
376            self.__issuerDN = value
377        else:
378            raise TypeError('Expecting string or X500DN type for "issuerDN"; '
379                            'got %r instead' % type(value))
380
381    issuerDN = property(_getIssuerDN, _setIssuerDN, 
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.issuerDN 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 = str(self.issuerDN)
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', '_AttributeQuerySslSOAPBinding__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.