source: TI12-security/branches/ndg-security-1.5.x/ndg_security_common/ndg/security/common/saml_utils/bindings.py @ 7119

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/TI12-security/branches/ndg-security-1.5.x/ndg_security_common/ndg/security/common/saml_utils/bindings.py@7119
Revision 7119, 23.4 KB checked in by pjkersha, 10 years ago (diff)

Incomplete - task 10: OpenID Provider HTML/Javascript response incompatible with OpenID4Java

  • Removed old Session Manager code from 1.5.x branch
  • started updating certificates for new test CA.
  • Property svn:keywords set to Id
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    VERIFY_TIME_CONDITIONS_OPTNAME = 'verifyTimeConditions'
246   
247    CONFIG_FILE_OPTNAMES = (
248        SUBJECT_ID_OPTNAME,
249        ISSUER_NAME_OPTNAME,                 
250        CLOCK_SKEW_OPTNAME, 
251        VERIFY_TIME_CONDITIONS_OPTNAME     
252    )
253   
254    QUERY_ATTRIBUTES_ATTRNAME = 'queryAttributes'
255    LEN_QUERY_ATTRIBUTES_ATTRNAME = len(QUERY_ATTRIBUTES_ATTRNAME)
256    QUERY_ATTRIBUTES_PAT = re.compile(',\s*')
257   
258    __PRIVATE_ATTR_PREFIX = "__"
259    __slots__ = tuple([__PRIVATE_ATTR_PREFIX + i
260                       for i in \
261                       CONFIG_FILE_OPTNAMES + (QUERY_ATTRIBUTES_ATTRNAME,)])
262    del i
263   
264    def __init__(self, **kw):
265        '''Create SOAP Client for SAML Attribute Query'''
266        self.__issuerName = None
267        self.__queryAttributes = TypedList(Attribute)
268        self.__clockSkew = timedelta(seconds=0.)
269        self.__verifyTimeConditions = True
270   
271        super(AttributeQuerySOAPBinding, self).__init__(**kw)
272
273    @classmethod
274    def fromConfig(cls, cfg, **kw):
275        '''Alternative constructor makes object from config file settings
276        @type cfg: basestring /ConfigParser derived type
277        @param cfg: configuration file path or ConfigParser type object
278        @rtype: ndg.security.common.credentialWallet.AttributeQuery
279        @return: new instance of this class
280        '''
281        obj = cls()
282        obj.parseConfig(cfg, **kw)
283       
284        return obj
285
286    def parseConfig(self, cfg, prefix='', section='DEFAULT'):
287        '''Read config file settings
288        @type cfg: basestring /ConfigParser derived type
289        @param cfg: configuration file path or ConfigParser type object
290        @type prefix: basestring
291        @param prefix: prefix for option names e.g. "attributeQuery."
292        @type section: baestring
293        @param section: configuration file section from which to extract
294        parameters.
295        ''' 
296        if isinstance(cfg, basestring):
297            cfgFilePath = path.expandvars(cfg)
298            _cfg = CaseSensitiveConfigParser()
299            _cfg.read(cfgFilePath)
300           
301        elif isinstance(cfg, ConfigParser):
302            _cfg = cfg   
303        else:
304            raise AttributeError('Expecting basestring or ConfigParser type '
305                                 'for "cfg" attribute; got %r type' % type(cfg))
306       
307        prefixLen = len(prefix)
308        for optName, val in _cfg.items(section):
309            if prefix:
310                # Filter attributes based on prefix
311                if optName.startswith(prefix):
312                    setattr(self, optName[prefixLen:], val)
313            else:
314                # No prefix set - attempt to set all attributes   
315                setattr(self, optName, val)
316           
317    def __setattr__(self, name, value):
318        """Enable setting of SAML query attribute objects via a comma separated
319        string suitable for use reading from an ini file. 
320        """
321        try:
322            super(AttributeQuerySOAPBinding, self).__setattr__(name, value)
323           
324        except AttributeError:
325            if name.startswith(
326                        AttributeQuerySOAPBinding.QUERY_ATTRIBUTES_ATTRNAME):
327                # Special handler for parsing string format settings
328                if not isinstance(value, basestring):
329                    raise TypeError('Expecting string format for special '
330                                    '%r attribute; got %r instead' %
331                                    (name, type(value)))
332                   
333                pat = AttributeQuerySOAPBinding.QUERY_ATTRIBUTES_PAT
334                attribute = Attribute()
335               
336                (attribute.name, 
337                 attribute.friendlyName, 
338                 attribute.nameFormat) = pat.split(value)
339                 
340                self.queryAttributes.append(attribute)
341            else:
342                raise
343
344    def _getVerifyTimeConditions(self):
345        return self.__verifyTimeConditions
346
347    def _setVerifyTimeConditions(self, value):
348        if isinstance(value, bool):
349            self.__verifyTimeConditions = value
350           
351        if isinstance(value, basestring):
352            self.__verifyTimeConditions = str2Bool(value)
353        else:
354            raise TypeError('Expecting bool or string type for '
355                            '"verifyTimeConditions"; got %r instead' % 
356                            type(value))
357
358    verifyTimeConditions = property(_getVerifyTimeConditions, 
359                                    _setVerifyTimeConditions, 
360                                    doc='Set to True to verify any time '
361                                        'Conditions set in the returned '
362                                        'response assertions') 
363   
364    def _getSubjectID(self):
365        return self.__subjectID
366
367    def _setSubjectID(self, value):
368        if not isinstance(value, basestring):
369            raise TypeError('Expecting string type for "subjectID"; got %r '
370                            'instead' % type(value))
371        self.__subjectID = value
372
373    subjectID = property(_getSubjectID, _setSubjectID, 
374                         doc="ID to be sent as query subject") 
375             
376    def _getQueryAttributes(self):
377        """Returns a *COPY* of the attributes to avoid overwriting the
378        member variable content
379        """
380        return self.__queryAttributes
381
382    def _setQueryAttributes(self, value):
383        if not isinstance(value, TypedList) and value.elementType != Attribute:
384            raise TypeError('Expecting TypedList(Attribute) type for '
385                            '"queryAttributes"; got %r instead' % type(value)) 
386       
387        self.__queryAttributes = value
388   
389    queryAttributes = property(_getQueryAttributes, 
390                               _setQueryAttributes, 
391                               doc="List of attributes to query from the "
392                                   "Attribute Authority")
393
394    def _getIssuerName(self):
395        return self.__issuerName
396
397    def _setIssuerName(self, value):
398        if not isinstance(value, basestring):
399            raise TypeError('Expecting string type for "issuerName"; '
400                            'got %r instead' % type(value))
401           
402        self.__issuerName = value
403
404    issuerName = property(_getIssuerName, _setIssuerName, 
405                        doc="Distinguished Name of issuer of SAML Attribute "
406                            "Query to Attribute Authority")
407
408    def _getClockSkew(self):
409        return self.__clockSkew
410
411    def _setClockSkew(self, value):
412        if isinstance(value, (float, int, long)):
413            self.__clockSkew = timedelta(seconds=value)
414           
415        elif isinstance(value, basestring):
416            self.__clockSkew = timedelta(seconds=float(value))
417        else:
418            raise TypeError('Expecting float, int, long or string type for '
419                            '"clockSkew"; got %r' % type(value))
420
421    clockSkew = property(fget=_getClockSkew, 
422                         fset=_setClockSkew, 
423                         doc="Allow a clock skew in seconds for SAML Attribute"
424                             " Query issueInstant parameter check") 
425
426    def _createQuery(self):
427        """ Create a SAML attribute query"""
428        attributeQuery = AttributeQuery()
429        attributeQuery.version = SAMLVersion(SAMLVersion.VERSION_20)
430        attributeQuery.id = str(uuid4())
431        attributeQuery.issueInstant = datetime.utcnow()
432       
433        if self.issuerName is None:
434            raise AttributeError('No issuer DN has been set for SAML Attribute '
435                                 'Query')
436       
437        attributeQuery.issuer = Issuer()
438        attributeQuery.issuer.format = Issuer.X509_SUBJECT
439        attributeQuery.issuer.value = self.issuerName
440                       
441        attributeQuery.subject = Subject() 
442        attributeQuery.subject.nameID = NameID()
443        attributeQuery.subject.nameID.format = EsgSamlNamespaces.NAMEID_FORMAT
444        attributeQuery.subject.nameID.value = self.subjectID
445                 
446        # Add list of attributes to query                     
447        for attribute in self.queryAttributes:
448            attributeQuery.attributes.append(attribute)
449           
450        return attributeQuery
451   
452    def _verifyTimeConditions(self, response):
453        """Verify time conditions set in a response
454        @param response: SAML Response returned from remote service
455        @type response: ndg.saml.saml2.core.Response
456        @raise SubjectQueryResponseError: if a timestamp is invalid
457        """
458       
459        if not self.verifyTimeConditions:
460            log.debug("Skipping verification of SAML Response time conditions")
461           
462        utcNow = datetime.utcnow() 
463        nowMinusSkew = utcNow - self.clockSkew
464        nowPlusSkew = utcNow + self.clockSkew
465       
466        if response.issueInstant > nowPlusSkew:
467            msg = ('SAML Attribute Response issueInstant [%s] is after '
468                   'the clock time [%s] (skewed +%s)' % 
469                   (response.issueInstant, 
470                    SAMLDateTime.toString(nowPlusSkew),
471                    self.clockSkew))
472           
473            samlRespError = AttributeQueryResponseError(msg)                 
474            samlRespError.response = response
475            raise samlRespError
476       
477        for assertion in response.assertions:
478            if assertion.issueInstant is None:
479                samlRespError = AttributeQueryResponseError("No issueInstant "
480                                                            "set in response "
481                                                            "assertion")
482                samlRespError.response = response
483                raise samlRespError
484           
485            elif nowPlusSkew < assertion.issueInstant:
486                msg = ('The clock time [%s] (skewed +%s) is before the '
487                       'SAML Attribute Response assertion issue instant [%s]' % 
488                       (SAMLDateTime.toString(utcNow),
489                        self.clockSkew,
490                        assertion.issueInstant))
491                samlRespError = AttributeQueryResponseError(msg)
492                samlRespError.response = response
493                raise samlRespError
494
495            if assertion.conditions is not None:
496                if nowPlusSkew < assertion.conditions.notBefore:
497                    msg = ('The clock time [%s] (skewed +%s) is before the '
498                           'SAML Attribute Response assertion conditions not '
499                           'before time [%s]' % 
500                           (SAMLDateTime.toString(utcNow),
501                            self.clockSkew,
502                            assertion.conditions.notBefore))
503                             
504                    samlRespError = AttributeQueryResponseError(msg)
505                    samlRespError.response = response
506                    raise samlRespError
507                 
508                if nowMinusSkew >= assertion.conditions.notOnOrAfter:           
509                    msg = ('The clock time [%s] (skewed -%s) is on or after '
510                           'the SAML Attribute Response assertion conditions '
511                           'not on or after time [%s]' % 
512                           (SAMLDateTime.toString(utcNow),
513                            self.clockSkew,
514                            assertion.conditions.notOnOrAfter))
515                   
516                    samlRespError = AttributeQueryResponseError(msg) 
517                    samlRespError.response = response
518                    raise samlRespError
519               
520    def send(self, **kw):
521        '''Make an attribute query to a remote SAML service
522       
523        @type uri: basestring
524        @param uri: uri of service.  May be omitted if set from request.url
525        @type request: ndg.security.common.soap.UrlLib2SOAPRequest
526        @param request: SOAP request object to which query will be attached
527        defaults to ndg.security.common.soap.client.UrlLib2SOAPRequest
528        '''
529        attributeQuery = self._createQuery()
530           
531        response = super(AttributeQuerySOAPBinding, self).send(attributeQuery, 
532                                                               **kw)
533
534        # Perform validation
535        if response.status.statusCode.value != StatusCode.SUCCESS_URI:
536            msg = ('Return status code flagged an error.  The message is: %r' %
537                   response.status.statusMessage.value)
538            samlRespError = AttributeQueryResponseError(msg)
539            samlRespError.response = response
540            raise samlRespError
541       
542        # Check Query ID matches the query ID the service received
543        if response.inResponseTo != attributeQuery.id:
544            msg = ('Response in-response-to ID %r, doesn\'t match the original '
545                   'query ID, %r' % (response.inResponseTo, attributeQuery.id))
546           
547            samlRespError = AttributeQueryResponseError(msg)
548            samlRespError.response = response
549            raise samlRespError
550       
551        self._verifyTimeConditions(response)
552       
553        return response
554
555   
556class AttributeQuerySslSOAPBinding(AttributeQuerySOAPBinding):
557    """Specialisation of AttributeQuerySOAPbinding taking in the setting of
558    SSL parameters for mutual authentication
559    """
560    SSL_CONTEXT_PROXY_SUPPORT = _sslContextProxySupport
561    __slots__ = ('__sslCtxProxy',)
562   
563    def __init__(self, **kw):
564        if not AttributeQuerySslSOAPBinding.SSL_CONTEXT_PROXY_SUPPORT:
565            raise ImportError("ndg.security.common.utils.m2crypto import "
566                              "failed - missing M2Crypto package?")
567       
568        # Miss out default HTTPSHandler and set in send() instead
569        if 'handlers' in kw:
570            raise TypeError("__init__() got an unexpected keyword argument "
571                            "'handlers'")
572           
573        super(AttributeQuerySslSOAPBinding, self).__init__(handlers=(), **kw)
574        self.__sslCtxProxy = SSLContextProxy()
575
576    def send(self, **kw):
577        """Override base class implementation to pass explicit SSL Context
578        """
579        httpsHandler = HTTPSHandler(ssl_context=self.sslCtxProxy.createCtx())
580        self.client.openerDirector.add_handler(httpsHandler)
581        return super(AttributeQuerySslSOAPBinding, self).send(**kw)
582       
583    @property
584    def sslCtxProxy(self):
585        """SSL Context Proxy object used for setting up an SSL Context for
586        queries
587        """
588        return self.__sslCtxProxy
589           
590    def __setattr__(self, name, value):
591        """Enable setting of SSLContextProxy attributes as if they were
592        attributes of this class.  This is intended as a convenience for
593        making settings parameters read from a config file
594        """
595        try:
596            super(AttributeQuerySslSOAPBinding, self).__setattr__(name, value)
597           
598        except AttributeError:
599            # Coerce into setting SSL Context Proxy attributes
600            try:
601                setattr(self.sslCtxProxy, name, value)
602            except:
603                raise
Note: See TracBrowser for help on using the repository browser.