source: TI12-security/trunk/python/ndg_security_server/ndg/security/server/myproxy/certificate_extapp/saml_attribute_assertion.py @ 6033

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/TI12-security/trunk/python/ndg_security_server/ndg/security/server/myproxy/certificate_extapp/saml_attribute_assertion.py@6033
Revision 6033, 21.3 KB checked in by pjkersha, 10 years ago (diff)

Refactoring Credential Wallet to enable caching of SAML assertions.

Line 
1"""X.509 certificate extension application for adding SAML assertions into
2certificates issued by MyProxy
3
4NERC DataGrid Project
5"""
6__author__ = "P J Kershaw"
7__date__ = "29/10/09"
8__copyright__ = "(C) 2009 Science and Technology Facilities Council"
9__license__ = "BSD - see LICENSE file in top-level directory"
10__contact__ = "Philip.Kershaw@stfc.ac.uk"
11__revision__ = '$Id: $'
12import logging
13log = logging.getLogger(__name__)
14
15import traceback
16from datetime import datetime, timedelta
17from uuid import uuid4
18from string import Template
19
20from sqlalchemy import create_engine, exc
21
22try: # >= python 2.5
23    from xml.etree import ElementTree
24except ImportError:
25    import ElementTree
26
27from saml.utils import SAMLDateTime
28from saml.common.xml import SAMLConstants
29from saml.saml2.core import (Attribute, 
30                             SAMLVersion, 
31                             Subject, 
32                             NameID, 
33                             Issuer, 
34                             AttributeQuery, 
35                             XSStringAttributeValue, 
36                             Status,
37                             StatusCode,
38                             StatusMessage)
39from saml.xml.etree import AssertionElementTree, ResponseElementTree
40   
41from ndg.security.common.saml.bindings import SOAPBinding as SamlSoapBinding
42from ndg.security.common.saml.esg import EsgSamlNamespaces
43from ndg.security.common.utils.etree import prettyPrint
44from ndg.security.common.X509 import X500DN
45from ndg.security.server.wsgi.openid.provider import IdentityMapping
46from ndg.security.common.utils.configfileparsers import (     
47                                                    CaseSensitiveConfigParser,)
48
49class CertExtAppError(Exception):
50    """Base class for CertExtApp class exceptions"""
51   
52   
53class CertExtAppConfigError(CertExtAppError):
54    """Configuration fault for CertExtApp instance"""
55
56
57class CertExtAppRetrieveError(CertExtAppError):
58    """Error retrieving results from user database or attribute authority"""
59   
60
61class CertExtAppSqlError(CertExtAppError):   
62    """Error with SQL query syntax"""
63   
64   
65class CertExtAppSamlResponseError(CertExtAppError):
66    """Attribute Authority returned a SAML Response error code"""
67    def __init__(self, *arg, **kw):
68        CertExtAppError.__init__(self, *arg, **kw)
69        self.__status = Status()
70        self.__status.statusCode = StatusCode()
71        self.__status.statusMessage = StatusMessage()
72   
73    def _getStatus(self):
74        '''Gets the Status of this response.
75       
76        @return the Status of this response
77        '''
78        return self.__status
79
80    def _setStatus(self, value):
81        '''Sets the Status of this response.
82       
83        @param value: the Status of this response
84        '''
85        if not isinstance(value, Status):
86            raise TypeError('"status" must be a %r, got %r' % (Status,
87                                                               type(value)))
88        self.__status = value
89       
90    status = property(fget=_getStatus, fset=_setStatus, 
91                      doc="Attribute Authority SAML Response error status")
92   
93    def __str__(self):
94        if self.status is not None:
95            return self.status.statusMessage.value or ''
96        else:
97            return ''
98   
99           
100class CertExtApp(object):
101    """Application to create a X.509 certificate extension containing a SAML
102    assertion for inclusion by MyProxy into an issued certificate
103   
104    @type DEFAULT_ATTR_DESCR: tuple
105    @cvar DEFAULT_ATTR_DESCR: a tuple of tuples describing the default
106    SAML attributes to be queried from the Attribute Authority.  The format is,
107   
108    ((<name>, <friendlyName>, <format>), (...), ...)
109   
110    FriendlyName can be defaulted to None in which case it will be omitted from
111    the query
112    """
113    XSSTRING_NS = "%s#%s" % (
114        SAMLConstants.XSD_NS,
115        XSStringAttributeValue.TYPE_LOCAL_NAME
116    )
117    N_ATTR_DESCR_ELEM_ITEMS = 3
118   
119    DEFAULT_ATTR_DESCR = (
120        (EsgSamlNamespaces.FIRSTNAME_ATTRNAME, 
121         EsgSamlNamespaces.FIRSTNAME_FRIENDLYNAME, 
122         XSSTRING_NS),
123        (EsgSamlNamespaces.LASTNAME_ATTRNAME, 
124         EsgSamlNamespaces.LASTNAME_FRIENDLYNAME, 
125         XSSTRING_NS),
126        (EsgSamlNamespaces.EMAILADDRESS_ATTRNAME, 
127         EsgSamlNamespaces.EMAILADDRESS_FRIENDLYNAME, 
128         XSSTRING_NS),
129    )
130    ESG_NAME_ID_FORMAT = EsgSamlNamespaces.NAMEID_FORMAT
131   
132    CONNECTION_STRING_OPTNAME = 'connectionString'
133    OPENID_SQLQUERY_OPTNAME = 'openIdSqlQuery'
134    OPENID_IDENTITY_URI_TMPL_OPTNAME = 'identityUriTemplate'
135    ATTRIBUTE_AUTHORITY_URI_OPTNAME = 'attributeAuthorityURI'
136    ISSUER_DN_OPTNAME = 'issuerDN'
137    CLOCK_SKEW_OPTNAME = 'clockSkew'
138   
139    CONFIG_FILE_OPTNAMES = (
140        ATTRIBUTE_AUTHORITY_URI_OPTNAME,
141        ISSUER_DN_OPTNAME,                 
142        CONNECTION_STRING_OPTNAME,
143        OPENID_SQLQUERY_OPTNAME,
144        OPENID_IDENTITY_URI_TMPL_OPTNAME,
145        CLOCK_SKEW_OPTNAME           
146    )
147    __slots__ = (
148       'userOpenID',
149       'attributeDescr'
150    )
151    __slots__ += CONFIG_FILE_OPTNAMES
152    __PRIVATE_ATTR_PREFIX = '_CertExtApp__'
153    __slots__ += tuple([__PRIVATE_ATTR_PREFIX + i for i in __slots__])
154    del i
155   
156    def __init__(self):
157        self.__attributeAuthorityURI = None
158        self.__userOpenID = None
159        self.__issuerDN = None
160        self.__connectionString = None
161        self.__openIdSqlQuery = None
162        self.__identityUriTemplate = None 
163        self.__clockSkew = timedelta(seconds=0.)
164               
165        # Use property here as a safeguard in case DEFAULT_ATTR_DESCR has been
166        # altered
167        self.attributeDescr = CertExtApp.DEFAULT_ATTR_DESCR
168
169    @classmethod
170    def fromConfigFile(cls, configFilePath, **kw):
171        '''Alternative constructor makes object from config file settings
172        @type configFilePath: basestring
173        @param configFilePath: configuration file path
174        '''
175        certExtApp = cls()
176        certExtApp.readConfig(configFilePath, **kw)
177       
178        return certExtApp
179       
180    def __call__(self, username):
181        """Main method - create SAML assertion by querying the user's OpenID
182        identifier from the user database and using this to query the
183        Attribute Authority for attributes
184        """
185        identifier = self.queryOpenId(username)
186        self.userOpenID = IdentityMapping.userIdentifier2IdentityURI(
187                                                    self.identityUriTemplate, 
188                                                    identifier)
189       
190        response = self.attributeQuery()
191       
192        try:
193            assertionStr = self.serialiseAssertion(response.assertions[0])
194           
195        except (IndexError, TypeError):
196            raise CertExtAppRetrieveError("Error accessing assertion from "
197                                          "Attribute Authority SAML response: "
198                                          "%s" % traceback.format_exc())
199           
200        return assertionStr
201
202    def readConfig(self, configFilePath, prefix='', section='DEFAULT'):
203        '''Read config file settings
204        @type configFilePath: basestring
205        @param configFilePath: configuration file path
206        @type prefix: basestring
207        @param prefix: prefix for option names e.g. "certExtApp."
208        @type section: baestring
209        @param section: configuration file section from which to extract
210        parameters.
211        '''
212        cfg = CaseSensitiveConfigParser()
213        cfg.read(os.path.expandvars(configFilePath))
214       
215        for optName in CertExtApp.CONFIG_FILE_OPTNAMES:
216            val = cfg.get(section, prefix+optName)
217            setattr(self, optName, val)
218           
219    def _getAttributeDescr(self):
220        return self.__attributeDescr
221
222    def _setAttributeDescr(self, value):
223        if not isinstance(value, tuple):
224            raise TypeError('Expecting tuple type for "attributeDescr";'
225                            ' got %r instead' % type(value))
226           
227        for i in value:
228            if not isinstance(value, tuple):
229                raise TypeError('Expecting tuple type for "attributeDescr" '
230                                'tuple sub-elements; got %r instead' % 
231                                type(value))
232            if len(i) != CertExtApp.N_ATTR_DESCR_ELEM_ITEMS:
233                raise TypeError('Expecting %d element tuple for '
234                                '"attributeDescr" sub-elements; got %d '
235                                'elements instead' % 
236                                (CertExtApp.N_ATTR_DESCR_ELEM_ITEMS,
237                                 len(i)))
238               
239        self.__attributeDescr = value
240   
241    attributeDescr = property(_getAttributeDescr, 
242                              _setAttributeDescr, 
243                              doc="List of name, friendly name, format tuples "
244                                  "determining attributes to query from the "
245                                  "Attribute Authority")
246
247    def _getAttributeAuthorityURI(self):
248        return self.__attributeAuthorityURI
249
250    def _setAttributeAuthorityURI(self, value):
251        if not isinstance(value, basestring):
252            raise TypeError('Expecting string type for "attributeAuthorityURI";'
253                            ' got %r instead' % type(value))
254        self.__attributeAuthorityURI = value
255
256    attributeAuthorityURI = property(_getAttributeAuthorityURI,
257                                     _setAttributeAuthorityURI, 
258                                     doc="Attribute Authority SOAP SAML URI")
259
260    def _getUserOpenID(self):
261        return self.__userOpenID
262
263    def _setUserOpenID(self, value):
264        if not isinstance(value, basestring):
265            raise TypeError('Expecting string type for "userOpenID"; got %r '
266                            'instead' % type(value))
267        self.__userOpenID = value
268
269    userOpenID = property(_getUserOpenID, _setUserOpenID, 
270                          doc="OpenID corresponding to user certificate to "
271                              "be issued")
272
273    def _getIssuerDN(self):
274        return self.__issuerDN
275
276    def _setIssuerDN(self, value):
277        if isinstance(value, basestring):
278            self.__issuerDN = X500DN.fromString(value)
279           
280        elif isinstance(value, X500DN):
281            self.__issuerDN = value
282        else:
283            raise TypeError('Expecting string or X500DN type for "issuerDN"; '
284                            'got %r instead' % type(value))
285        self.__issuerDN = value
286
287    issuerDN = property(_getIssuerDN, _setIssuerDN, 
288                        doc="Distinguished Name of issuer of SAML Attribute "
289                            "Query to Attribute Authority")
290
291    def _getConnectionString(self):
292        return self.__connectionString
293
294    def _setConnectionString(self, value):
295        if not isinstance(value, basestring):
296            raise TypeError('Expecting string type for "%s" attribute; got %r'%
297                            (CertExtApp.CONNECTION_STRING_OPTNAME,
298                             type(value)))
299        self.__connectionString = os.path.expandvars(value)
300
301    connectionString = property(fget=_getConnectionString, 
302                                fset=_setConnectionString, 
303                                doc="Database connection string")
304
305    def _getOpenIdSqlQuery(self):
306        return self.__openIdSqlQuery
307
308    def _setOpenIdSqlQuery(self, value):
309        if not isinstance(value, basestring):
310            raise TypeError('Expecting string type for "%s" attribute; got %r'% 
311                        (CertExtApp.OPENID_SQLQUERY_OPTNAME,
312                         type(value)))
313        self.__openIdSqlQuery = value
314
315    openIdSqlQuery = property(fget=_getOpenIdSqlQuery, 
316                        fset=_setOpenIdSqlQuery, 
317                        doc="SQL Query for authentication request")
318
319    def _getIdentityUriTemplate(self):
320        return self.__identityUriTemplate
321
322    def _setIdentityUriTemplate(self, value):
323        if not isinstance(value, basestring):
324            raise TypeError('Expecting string type for "%s" attribute; got %r'% 
325                            (CertExtApp.OPENID_IDENTITY_URI_TMPL_OPTNAME,
326                             type(value)))
327        self.__identityUriTemplate = value
328
329    identityUriTemplate = property(_getIdentityUriTemplate, 
330                                   _setIdentityUriTemplate, 
331                                   doc="Identity URI template string - sets "
332                                       "the common component of user's "
333                                       "identity URI.  It should contain the "
334                                       "${userIdentifier} template "
335                                       "substitution parameter")
336
337    def _getClockSkew(self):
338        return self.__clockSkew
339
340    def _setClockSkew(self, value):
341        if isinstance(value, (float, int, long)):
342            self.__clockSkew = timedelta(seconds=value)
343           
344        elif isinstance(value, basestring):
345            self.__clockSkew = timedelta(seconds=float(value))
346        else:
347            raise TypeError('Expecting float, int, long or string type for '
348                            '"clockSkew"; got %r' % type(value))
349
350    clockSkew = property(fget=_getClockSkew, 
351                         fset=_setClockSkew, 
352                         doc="Allow a clock skew in seconds for SAML Attribute"
353                             " Query issueInstant parameter check")
354       
355    def __getstate__(self):
356        '''Specific implementation needed with __slots__'''
357        return dict([(attrName, getattr(self, attrName)) \
358                     for attrName in CertExtApp.__slots__])
359       
360    def __setstate__(self, attrDict):
361        '''Specific implementation needed with __slots__'''
362        for attr, val in attrDict.items():
363            setattr(self, attr, val)
364   
365    def serialiseAssertion(self, assertion):
366        """Convert SAML assertion object into a string"""
367        samlAssertionElem = AssertionElementTree.toXML(assertion)
368        return ElementTree.tostring(samlAssertionElem)
369       
370    def attributeQuery(self):
371        """Query an Attribute Authority to retrieve an assertion for the
372        given user"""
373               
374        # Create a SAML attribute query
375        attributeQuery = AttributeQuery()
376        attributeQuery.version = SAMLVersion(SAMLVersion.VERSION_20)
377        attributeQuery.id = str(uuid4())
378        attributeQuery.issueInstant = datetime.utcnow()
379       
380        attributeQuery.issuer = Issuer()
381        attributeQuery.issuer.format = Issuer.X509_SUBJECT
382        attributeQuery.issuer.value = self.issuerDN
383                       
384        attributeQuery.subject = Subject() 
385        attributeQuery.subject.nameID = NameID()
386        attributeQuery.subject.nameID.format = \
387                            CertExtApp.ESG_NAME_ID_FORMAT
388        attributeQuery.subject.nameID.value = self.userOpenID
389                 
390        # Add list of attributes to query                     
391        for name, friendlyName, format in self.attributeDescr:
392            attribute = Attribute()
393            attribute.name = name
394            attribute.nameFormat = format
395            attribute.friendlyName = friendlyName
396   
397            attributeQuery.attributes.append(attribute)
398
399        # Make query over SOAP interface to remote service
400        binding = SamlSoapBinding()
401        response = binding.attributeQuery(attributeQuery, 
402                                          self.attributeAuthorityURI)
403       
404        if log.level <= logging.DEBUG:           
405            log.debug("Attribute Authority [%s] SAML Response:", 
406                      self.attributeAuthorityURI)
407            log.debug("_"*80)
408            responseElem = ResponseElementTree.toXML(response)
409            log.debug(prettyPrint(responseElem))
410       
411        if response.status.statusCode.value != StatusCode.SUCCESS_URI:
412            samlResponseError = CertExtAppSamlResponseError()
413            samlResponseError.status = response.status
414            raise samlResponseError
415       
416        # Check Query ID matches the query ID the service received
417        if response.inResponseTo != attributeQuery.id:
418            samlResponseError = CertExtAppSamlResponseError()
419            samlResponseError.status = response.status
420            raise samlResponseError
421       
422        utcNow = datetime.utcnow()+ self.clockSkew
423        if response.issueInstant > utcNow:
424            samlResponseError = CertExtAppSamlResponseError()
425           
426            msg = ('SAML Attribute Response issueInstant [%s] is after '
427                   'the current clock time [%s]' % 
428                   (attributeQuery.issueInstant, SAMLDateTime.toString(utcNow)))
429                     
430            samlResponseError.status.statusCode.value = StatusCode.RESPONDER_URI
431            samlResponseError.status.statusMessage.value = msg
432            raise samlResponseError
433       
434        if utcNow < response.assertions[-1].conditions.notBefore:
435            samlResponseError = CertExtAppSamlResponseError()
436           
437            msg = ('The current clock time [%s] is before the SAML Attribute '
438                   'Response assertion conditions not before time [%s]' % 
439                   (SAMLDateTime.toString(utcNow),
440                    response.assertions[-1].conditions.notBefore))
441                     
442            samlResponseError.status.statusCode.value = StatusCode.RESPONDER_URI
443            samlResponseError.status.statusMessage.value = msg
444            raise samlResponseError
445         
446        if utcNow >= response.assertions[-1].conditions.notOnOrAfter:
447            samlResponseError = CertExtAppSamlResponseError()           
448           
449            msg = ('The current clock time [%s] is on or after the SAML '
450                   'Attribute Response assertion conditions not on or after '
451                   'time [%s]' % 
452                   (SAMLDateTime.toString(utcNow),
453                    response.assertions[-1].conditions.notOnOrAfter))
454                     
455            samlResponseError.status.statusCode.value = StatusCode.RESPONDER_URI
456            samlResponseError.status.statusMessage.value = msg
457            raise samlResponseError
458       
459        return response
460   
461    def queryOpenId(self, username):
462        """Given a username, query for user OpenID identifier from the user
463        database
464
465        @type username: basestring
466        @param username: username
467        @rtype: basestring
468        @return: the OpenID identifier corresponding to the input username
469        """
470
471        try:
472            dbEngine = create_engine(self.connectionString)
473        except ImportError, e:
474            raise CertExtAppConfigError("Missing database engine for "
475                                        "SQLAlchemy: %s" % e)
476        connection = dbEngine.connect()
477       
478        try:
479            queryInputs = dict(username=username)
480            query = Template(self.openIdSqlQuery).substitute(queryInputs)
481            result = connection.execute(query)
482
483        except exc.ProgrammingError:
484            raise CertExtAppSqlError("Error with SQL Syntax: %s" %
485                                     traceback.format_exc())
486        finally:
487            connection.close()
488
489        try:
490            identifier = [r for r in result][0][0]
491       
492        except Exception:
493            raise CertExtAppRetrieveError("Error with result set: %s" %
494                                          traceback.format_exc())
495       
496        log.debug('Query succeeded for user %r' % username)
497        return identifier
498   
499   
500import optparse
501import os
502
503class CertExtConsoleApp(CertExtApp):
504    """Extend CertExtApp with functionality for command line options"""
505
506    DEBUG_ENVVAR_NAME = 'NDGSEC_MYPROXY_CERT_EXT_APP_DEBUG'
507
508    @classmethod
509    def run(cls):
510        """Parse command line arguments and run the query specified"""
511
512        if cls.DEBUG_ENVVAR_NAME in os.environ:
513            import pdb
514            pdb.set_trace()
515
516        parser = optparse.OptionParser()
517
518        parser.add_option("-f",
519                          "--config-file",
520                          dest="configFilePath",
521                          help="ini style configuration file path containing "
522                               "the options: connectionString, "
523                               "openIdSqlQuery, identityUriTemplate, "
524                               "attributeAuthorityURI and issuerDN.  The file "
525                               "can also contain sections to configure logging "
526                               "using the standard logging module log file "
527                               "format")
528
529        parser.add_option("-u",
530                          "--username",
531                          dest="username",
532                          help="username to generate a SAML assertion for")
533
534        opt = parser.parse_args()[0]
535
536        if not opt.configFilePath:
537            msg = "Error: no configuration file set.\n\n" + parser.format_help()
538            raise SystemExit(msg)
539        elif not opt.username:
540            msg = "Error: no username set.\n\n" + parser.format_help()
541            raise SystemExit(msg)
542       
543        # Enable the setting of logging configuration from config file
544        from logging.config import fileConfig
545        from ConfigParser import NoSectionError
546        try:
547            fileConfig(opt.configFilePath)
548        except NoSectionError:
549            pass
550
551        certExtApp = cls.fromConfigFile(opt.configFilePath)
552        assertion = certExtApp(opt.username)
553        print(assertion)
554
Note: See TracBrowser for help on using the repository browser.