source: TI12-security/branches/ndg-security-1.5.x/ndg_security_server/ndg/security/server/wsgi/openid/relyingparty/validation.py @ 7632

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/TI12-security/branches/ndg-security-1.5.x/ndg_security_server/ndg/security/server/wsgi/openid/relyingparty/validation.py@7632
Revision 7632, 27.1 KB checked in by pjkersha, 10 years ago (diff)

Incomplete - task 15: NDG Security 1.5.8 Branch Release for Questionnaire

  • Committing 1.5.8 release. ZSI based SOAP functionality is disabled for Python 2.6 use but this does not affect currently used interfaces in production deployment.
  • Property svn:keywords set to Id
Line 
1"""NDG Security OpenID Relying Party Provider Validation module
2
3Based on the Earth System Grid IdPValidator interface for restricting
4OpenID Providers that a Relying Party may connect to
5
6An Identity Provider (IdP) is equivalent to an OpenID Provider
7
8NERC DataGrid Project
9"""
10__author__ = "P J Kershaw"
11__date__ = "09/06/2009"
12__copyright__ = "(C) 2009 Science and Technology Facilities Council"
13__license__ = "BSD - see top-level directory for LICENSE file"
14__contact__ = "Philip.Kershaw@stfc.ac.uk"
15__revision__ = "$Id$"
16import logging
17log = logging.getLogger(__name__)
18import os
19import traceback
20import re
21
22from elementtree import ElementTree
23from openid.yadis.manager import Discovery
24
25from ndg.security.common.X509 import X509Cert
26from ndg.security.common.utils.etree import QName
27from ndg.security.common.utils.classfactory import instantiateClass
28
29class _ConfigBase(object):
30    """Base class for IdP Validator and Attribute Provider configuration
31    """
32    def __init__(self):
33        self.__className = None
34        self.__configFile = None
35        self.__parameters = {}
36   
37    def _set_className(self, className):
38        if not isinstance(className, basestring):
39            raise TypeError('Expecting string type for className; got %r' %
40                            type(className))
41        self.__className = className
42   
43    def _get_className(self):
44        return self.__className
45   
46    className = property(fget=_get_className,
47                         fset=_set_className)
48
49    def _get_configFile(self):
50        return self.__configFile
51   
52    def _set_configFile(self, configFile):
53        if not isinstance(configFile, basestring):
54            raise TypeError('Expecting string type for configFile; got %r' %
55                            type(className))
56        self.__configFile = configFile
57
58    configFile = property(fget=_get_configFile,
59                          fset=_set_configFile)
60   
61    def _get_parameters(self):
62        return self.__parameters
63   
64    def _set_parameters(self, parameters):   
65        if not isinstance(parameters, dict):
66            raise TypeError('Expecting string type for parameters; got %r' %
67                            type(parameters))
68        self.__parameters = parameters
69   
70    parameters = property(fget=_get_parameters,
71                          fset=_set_parameters)
72
73class IdPValidatorConfig(_ConfigBase):
74    """Container for IdP validator configuration"""
75   
76class AttributeProviderConfig(_ConfigBase):
77    """Container for Attribute Provider configuration"""
78   
79class XmlConfigReaderError(Exception):
80    """Raise from XmlConfigReader"""
81     
82class XmlConfigReader(object):
83    """Parser for IdP and Attribute Provider config
84    """
85    VALIDATOR_ELEMNAME = "validator"
86    PARAMETER_ELEMNAME = "parameter"
87    ATTRIBUTE_PROVIDER_ELEMNAME = "attributeprovider"
88    NAME_ATTRNAME = "name"
89    VALUE_ATTRNAME = "value"
90   
91    def getValidators(self, source): 
92        """Retrieve IdP Validator objects from XML file
93        @type source: basestring/file
94        @param source: file path to XML file or file object
95        """
96        validators = None
97
98        root = self.__parseConfigFile(source)
99        if root is not None:
100            validators = self.__extractValidatorConfigs(root)
101       
102        return validators
103   
104    def getAttrProviders(self, source):
105        """
106        @type source: basestring/file
107        @param source: file path to XML file or file object
108        """   
109        attrProviders = None
110
111        root = self.__parseConfigFile(source)
112        if root is not None:
113            attrProviders = self.__extractAttrProviderConfigs(root)
114       
115        return attrProviders
116   
117    def __parseConfigFile(self, source):
118        """Read in the XML configuration file
119        @type source: basestring/file
120        @param source: file path to XML file or file object
121        """
122        elem = ElementTree.parse(source)
123        root = elem.getroot()
124       
125        return root
126
127    def __extractValidatorConfigs(self, root):
128        """Parse Validator configuration from the XML config file
129        @type root: ElementTree.Element
130        @param root: root element of parsed XML config file
131        """
132        validators = []
133       
134        for elem in root:
135            localName = QName.getLocalPart(elem.tag).lower()
136            if localName == XmlConfigReader.VALIDATOR_ELEMNAME:   
137                validatorConfig = IdPValidatorConfig()
138               
139                className = elem.attrib.get(XmlConfigReader.NAME_ATTRNAME)
140                if className is None:
141                    raise XmlConfigReaderError('No "%s" attribute found in '
142                                               '"%s" tag' %
143                                              (XmlConfigReader.NAME_ATTRNAME,
144                                               elem.tag))
145                   
146                validatorConfig.className = className
147               
148                parameters = {}
149                for el in elem:
150                    if QName.getLocalPart(
151                       el.tag).lower() == XmlConfigReader.PARAMETER_ELEMNAME:
152                       
153                        nameAttr = el.attrib.get(XmlConfigReader.NAME_ATTRNAME)
154                        if nameAttr is None:
155                            raise XmlConfigReaderError('No "%s" attribute '
156                                                       'found in "%s" tag' %
157                                                (XmlConfigReader.NAME_ATTRNAME,
158                                                 el.tag))
159                        if nameAttr in parameters:
160                            raise XmlConfigReaderError('Duplicate parameter '
161                                                       'name "%s" found' % 
162                                                       el.attrib[
163                                                XmlConfigReader.NAME_ATTRNAME])
164                           
165                        valAttr = el.attrib.get(XmlConfigReader.VALUE_ATTRNAME)
166                        if valAttr is None:
167                            raise XmlConfigReaderError('No "%s" attribute '
168                                                       'found in "%s" tag' %
169                                                (XmlConfigReader.VALUE_ATTRNAME,
170                                                 el.tag))
171                               
172                        parameters[nameAttr] = os.path.expandvars(valAttr)
173           
174                validatorConfig.parameters = parameters
175                validators.append(validatorConfig)
176       
177        return validators
178   
179    def __extractAttrProviderConfigs(self, root):
180        """Parse Attribute Provider configurations from the XML tree
181        @type root: ElementTree.Element
182        @param root: root element of parsed XML config file
183        """
184        attrProviders = []
185        validatorConfig = None
186        parameters = {}
187
188        for elem in root:
189            localName = QName.getLocalPart(elem.tag).lower()
190            if localName == XmlConfigReader.ATTRIBUTE_PROVIDER_ELEMNAME:
191                if validatorConfig is not None:
192                    validatorConfig.parameters = parameters
193                    attrProviders.append(validatorConfig)
194               
195                validatorConfig = AttributeProviderConfig()
196                nameAttr = elem.attrib.get(XmlConfigReader.NAME_ATTRNAME)
197                if nameAttr is None:
198                    raise XmlConfigReaderError('No "%s" attribute '
199                                               'found in "%s" tag' %
200                                               (XmlConfigReader.NAME_ATTRNAME,
201                                                elem.tag))
202                validatorConfig.className(nameAttr)
203           
204            elif localName == XmlConfigReader.PARAMETER_ELEMNAME:
205               
206                nameAttr = elem.attrib.get(XmlConfigReader.NAME_ATTRNAME)
207                if nameAttr is None:
208                    raise XmlConfigReaderError('No "%s" attribute '
209                                               'found in "%s" tag' %
210                                               (XmlConfigReader.NAME_ATTRNAME,
211                                                elem.tag))
212                       
213                if nameAttr in parameters:
214                    raise XmlConfigReaderError('Duplicate parameter name "%s" '
215                                               'found' % nameAttr)
216                   
217                valAttr = elem.attrib.get(XmlConfigReader.VALUE_ATTRNAME)
218                if valAttr is None:
219                    raise XmlConfigReaderError('No "%s" attribute '
220                                               'found in "%s" tag' %
221                                               (XmlConfigReader.VALUE_ATTRNAME,
222                                                elem.tag))
223           
224                parameters[nameAttr] = elem.attrib[valAttr]
225           
226        if validatorConfig != None:
227            validatorConfig.parameters = parameters
228            attrProviders.append(validatorConfig)
229       
230        return attrProviders
231
232
233class IdPValidatorException(Exception):
234    """Base class for IdPValidator exceptions"""
235   
236class IdPInvalidException(IdPValidatorException):
237    """Raise from IdPValidator.validate if the IdP is not acceptable"""
238
239class ConfigException(IdPValidatorException):
240    """Problem with configuration for the IdP Validator class"""
241 
242
243class IdPValidator(object):
244    '''Interface class for implementing OpenID Provider validators for a
245    Relying Party to call'''
246   
247    def __init__(self):
248        raise NotImplementedError()
249
250    def initialize(self, **parameters):
251        '''@raise ConfigException:''' 
252        raise NotImplementedError()
253       
254    def validate(self, idpEndpoint, idpIdentity):
255        '''@raise IdPInvalidException:
256        @raise ConfigException:''' 
257        raise NotImplementedError()
258 
259
260class SSLIdPValidator(object):
261    '''Interface class for implementing OpenID Provider validators for a
262    Relying Party to call'''
263    __slots__ = ()
264   
265    def __init__(self):
266        raise NotImplementedError()
267
268    def initialize(self, **parameters):
269        '''@raise ConfigException:''' 
270        raise NotImplementedError()
271       
272    def validate(self, x509CertCtx):
273        '''@type x509StoreCtx: M2Crypto.X509_Store_Context
274        @param x509StoreCtx: context object containing peer certificate and
275        certificate chain for verification
276 
277        @raise IdPInvalidException:
278        @raise ConfigException:''' 
279        raise NotImplementedError()
280   
281   
282import urllib2
283from M2Crypto import SSL
284from M2Crypto.m2urllib2 import build_opener
285from ndg.security.common.X509 import X500DN
286
287class SSLClientAuthNValidator(SSLIdPValidator):
288    """HTTPS based validation with the addition that this client can provide
289    a certificate to the peer enabling mutual authentication
290    """
291    PARAMETERS = {
292        'configFilePath': basestring,
293        'caCertDirPath': basestring,
294        'certFilePath': basestring,
295        'priKeyFilePath': basestring,
296        'priKeyPwd': basestring
297    }
298    __slots__ = {}
299    __slots__.update(PARAMETERS)
300    __slots__.update({'validIdPNames': []})
301   
302    def __init__(self):
303        """Set-up default SSL context for HTTPS requests"""
304        self.validIdPNames = []
305       
306        for p in SSLClientAuthNValidator.PARAMETERS:
307            setattr(self, p, '')
308
309    def __setattr__(self, name, value):
310        if (name in SSLClientAuthNValidator.PARAMETERS and 
311            not isinstance(value, SSLClientAuthNValidator.PARAMETERS[name])):
312            raise TypeError('Invalid type %r for parameter "%s" expecting '
313                            '%r ' % 
314                            (type(value), 
315                             name, 
316                             SSLClientAuthNValidator.PARAMETERS[name]))
317           
318        super(SSLClientAuthNValidator, self).__setattr__(name, value)
319       
320    def initialize(self, ctx, **parameters):
321        '''@raise ConfigException:''' 
322        for name, val in parameters.items():
323            setattr(self, name, os.path.expandvars(val))
324             
325        ctx.load_verify_locations(capath=self.caCertDirPath)
326        if self.certFilePath and self.priKeyFilePath:
327            ctx.load_cert(self.certFilePath, 
328                          keyfile=self.priKeyFilePath, 
329                          callback=lambda *arg, **kw: self.priKeyPwd)
330           
331        if self.configFilePath is not None:
332            # Simple file format - one IdP server name per line
333            cfgFile = open(self.configFilePath)
334            self.validIdPNames = [l.strip() for l in cfgFile.readlines()
335                                  if not l.startswith('#')]
336                         
337    def validate(self, x509StoreCtx):
338        '''Validate the peer certificate DN common name against a whitelist
339        of acceptable IdP names
340       
341        @type x509StoreCtx: M2Crypto.X509.X509_Store_Context
342        @param x509StoreCtx: locate the certificate to be verified and perform
343        additional verification steps as needed
344       
345        @raise IdPInvalidException: if none of the certificates in the chain
346        have DN common names matching the list of valid IdPs'''
347        x509CertChain = x509StoreCtx.get1_chain()
348        dnList = []
349        for cert in x509CertChain:
350            x509Cert = X509Cert.fromM2Crypto(cert)
351            dn = x509Cert.dn
352            commonName = dn['CN']
353            log.debug("iterating over cert. chain dn = %s", dn)
354   
355            if commonName in self.validIdPNames:
356                # Match found - return
357                log.debug("Found peer certificate with CN matching list of "
358                          "valid OpenID Provider peer certificates %r" %
359                          self.validIdPNames)
360                return
361           
362            dnList.append(dn)
363           
364        log.debug("Certificate chain yield certificates with DNs = %s"
365                  % dnList)
366       
367        # No matching peer certificate was found
368        raise IdPInvalidException("Peer certificate is not in list of valid "
369                                  "OpenID Providers")
370
371
372class FileBasedIdentityUriValidator(IdPValidator):
373    """Validate OpenID identity URI against a list of regular expressions
374    which specify the allowable identities.  The list is read from a simple
375    flat file - one pattern per line
376    """   
377    PARAMETERS = {
378        'configFilePath': basestring,
379    }
380    CONFIGFILE_COMMENT_CHAR = '#'
381   
382    def __init__(self):
383        self.__configFilePath = None
384        self.__identityUriPatterns = None
385
386    def _setIdentityUriPatterns(self, value):
387        if not isinstance(value, dict):
388            raise TypeError('Expecting a dict of pattern objects keyed by '
389                            'pattern string for "identityUriPatterns" object; '
390                            'got %r' % type(value))
391        self.__identityUriPatterns = value
392
393    identityUriPatterns = property(fget=lambda self:self.__identityUriPatterns, 
394                                   fset=_setIdentityUriPatterns, 
395                                   doc="list of regular expression objects "
396                                       "to match input identity URIs against")
397       
398    def _getConfigFilePath(self):
399        return self.__configFilePath
400
401    def _setConfigFilePath(self, value):
402        if not isinstance(value, basestring):
403            raise TypeError('Expecting string type for "configFilePath"; got '
404                            '%r' % type(value))
405        self.__configFilePath = value
406
407    configFilePath = property(fget=_getConfigFilePath, 
408                              fset=_setConfigFilePath, 
409                              doc="Configuration file path for this validator")
410
411    def initialize(self, **parameters):
412        '''@raise ConfigException:''' 
413        for name, val in parameters.items():
414            if name not in FileBasedIdentityUriValidator.PARAMETERS:
415                raise AttributeError('Invalid parameter name "%s".  Valid '
416                            'names are %r' % (name,
417                            FileBasedIdentityUriValidator.PARAMETERS.keys()))
418               
419            if not isinstance(val, 
420                              FileBasedIdentityUriValidator.PARAMETERS[name]):
421                raise TypeError('Invalid type %r for parameter "%s" expecting '
422                            '%r ' % 
423                            (type(val), 
424                             name, 
425                             FileBasedIdentityUriValidator.PARAMETERS[name]))
426       
427            setattr(self, name, os.path.expandvars(val))
428
429        self._parseConfigFile()
430       
431    def _parseConfigFile(self):
432        """Read the configFile containing identity URI regular expressions
433        """
434        try:
435            configFile = open(self.configFilePath)
436        except IOError, e:
437            raise ConfigException('Error parsing %r configuration file "%s": '
438                                  '%s' % (self.__class__.__name__,
439                                   e.filename, e.strerror))
440       
441        lines = re.split('\s', configFile.read().strip())
442        self.identityUriPatterns = dict([
443            (pat, re.compile(pat))
444            for pat in lines if not pat.startswith(
445                        FileBasedIdentityUriValidator.CONFIGFILE_COMMENT_CHAR)
446        ])
447       
448    def validate(self, idpEndpoint, idpIdentity):
449        '''Match user identity URI against list of acceptable patterns parsed
450        from config file.  The idpEndpoint is ignored
451       
452        @type idpEndpoint: basestring
453        @param idpEndpoint: endpoint for OpenID Provider service being
454        discovered
455        @type idpIdentity: basestring
456        @param idpIdentity: endpoint for OpenID Provider service being
457        @raise IdPInvalidException:
458        ''' 
459        for patStr, pat in self.identityUriPatterns.items():
460            if pat.match(idpIdentity) is not None:
461                log.debug("Identity URI %r matches whitelist pattern %r" %
462                          (idpIdentity, patStr))
463                break # identity matches: return silently
464        else:
465            raise IdPInvalidException("OpenID identity URI %r doesn't match "
466                                      "the whitelisted patterns" %
467                                      idpIdentity)
468   
469   
470class IdPValidationDriver(object):
471    """Parse an XML Validation configuration containing XML Validators and
472    execute these against the Provider (IdP) input"""   
473    IDP_VALIDATOR_BASE_CLASS = IdPValidator
474    IDP_CONFIG_FILEPATH_ENV_VARNAME = "IDP_CONFIG_FILE"
475   
476    def __init__(self):
477        self.__idPValidators = []
478       
479    def _get_idPValidators(self):
480        return self.__idPValidators
481   
482    def _set_idPValidators(self, idPValidators):
483        badValidators = [i for i in idPValidators
484                         if not isinstance(i, 
485                                    self.__class__.IDP_VALIDATOR_BASE_CLASS)]
486        if len(badValidators):
487            raise TypeError("Input validators must be of IdPValidator derived "
488                            "type")
489               
490        self.__idPValidators = idPValidators
491       
492    idPValidators = property(fget=_get_idPValidators,
493                             fset=_set_idPValidators,
494                             doc="list of IdP Validators")
495
496    @classmethod
497    def readConfig(cls, idpConfigFilePath=None):
498        """Read IdP Validation configuration file.  This is an XML document
499        containing a list of validator class names and their initialisation
500        parameters
501        """
502        validators = []
503       
504        if idpConfigFilePath is None:
505            idpConfigFilePath = os.environ.get(
506                        IdPValidationDriver.IDP_CONFIG_FILEPATH_ENV_VARNAME)
507           
508        if idpConfigFilePath is None:
509            log.warning("IdPValidationDriver.readConfig: No IdP "
510                        "Configuration file was set")
511            return validators
512       
513        configReader = XmlConfigReader()
514        validatorConfigs = configReader.getValidators(idpConfigFilePath)
515
516        for validatorConfig in validatorConfigs:
517            try:
518                validator = instantiateClass(validatorConfig.className,
519                                             None, 
520                                             objectType=IdPValidator)
521                validator.initialize(**validatorConfig.parameters)
522                validators.append(validator)
523           
524            except Exception, e: 
525                log.error("Failed to initialise validator %s: %s", 
526                          validatorConfig.className, traceback.format_exc())
527               
528        return validators
529   
530    def performIdPValidation(self, 
531                             identifier, 
532                             discoveries=(Discovery(None, None),)):
533        """Perform all IdPValidation for all configured IdPValidators.  if
534        the setIdPValidator method was used to initialise a list of
535        IdPValidators before this method is called, the configurations
536        are still checked each time this method is called and any valid
537        IdPValidators found are appended to the initial list and run in
538        addition.
539       
540        @param identifier: OpenID identity URL
541        @type identifier: basestring
542        @param discoveries: list of discovery instances.  Default to a single
543        one with a provider URI of None
544        @type discoveries: openid.yadis.discover.Discover
545        """
546        validators = self.readConfig()
547
548        if self.idPValidators is not None:
549            validators += self.idPValidators
550
551        log.info("%d IdPValidators initialised", len(validators))
552
553        # validate the discovered end points
554        if len(validators) > 0:
555       
556            newDiscoveries = []
557            for validator in validators:   
558                for discoveryInfo in discoveries:
559                    try:
560                        validator.validate(discoveryInfo.url, identifier)
561
562                        log.info("Whitelist Validator %r accepting endpoint: "
563                                 "%s", validator, discoveryInfo.url)
564
565                        newDiscoveries.append(discoveryInfo)
566                   
567                    except IdPInvalidException, e:
568                        log.warning("Whitelist Validator %r rejecting "
569                                    "identifier: %s: %s", validator, 
570                                    identifier, e)
571                                               
572                    except Exception, e:       
573                        log.warning("Error with Whitelist Validator %r "
574                                    "rejecting identity: %s: %s", validator, 
575                                    identifier, traceback.format_exc())
576                       
577            if len(newDiscoveries) > 0:
578                discoveries = newDiscoveries
579                log.info("Found %d valid endpoint(s)." % len(discoveries))
580            else:     
581                raise IdPInvalidException("No valid endpoints were found "
582                                          "after validation.")
583        else:
584            log.warning("No IdP validation executed because no validators "
585                        "were set")
586           
587        return discoveries
588
589
590class SSLIdPValidationDriver(IdPValidationDriver):
591    '''Validate an IdP using the certificate it returns from an SSL based
592    request'''
593    IDP_VALIDATOR_BASE_CLASS = SSLIdPValidator
594   
595    def __init__(self, idpConfigFilePath=None, installOpener=False):
596        super(SSLIdPValidationDriver, self).__init__()
597       
598        # Context object determines what validation is applied against the
599        # peer's certificate in the SSL connection
600        self.ctx = SSL.Context()
601       
602        # Enforce peer cert checking via this classes' __call__ method
603        self.ctx.set_verify(SSL.verify_peer|SSL.verify_fail_if_no_peer_cert, 
604                            9, 
605                            callback=self)
606
607        if installOpener:
608            urllib2.install_opener(build_opener(ssl_context=self.ctx))
609       
610        if idpConfigFilePath is not None:
611            self.idPValidators += \
612                self.readConfig(idpConfigFilePath=idpConfigFilePath)
613           
614        log.info("%d IdPValidator(s) initialised", len(self.idPValidators))
615       
616    def readConfig(self, idpConfigFilePath):
617        """Read and initialise validators set in a config file""" 
618        validators = []
619       
620        if idpConfigFilePath is None:
621            idpConfigFilePath = os.environ('SSL_IDP_VALIDATION_CONFIG_FILE')
622           
623        if idpConfigFilePath is None:
624            log.warning("SSLIdPValidationDriver.readConfig: No IdP "
625                        "Configuration file was set")
626            return validators
627       
628        configReader = XmlConfigReader()
629        validatorConfigs = configReader.getValidators(idpConfigFilePath)
630
631        for validatorConfig in validatorConfigs:
632            try:
633                validator = instantiateClass(validatorConfig.className,
634                                             None, 
635                                             objectType=SSLIdPValidator)
636               
637                # Validator has access to the SSL context object in addition
638                # to custom settings set in parameters
639                validator.initialize(self.ctx, **validatorConfig.parameters)
640                validators.append(validator)
641           
642            except Exception, e: 
643                raise ConfigException("Validator class %r initialisation "
644                                      "failed with %s exception: %s" %
645                                      (validatorConfig.className, 
646                                       e.__class__.__name__,
647                                       traceback.format_exc()))
648               
649        return validators
650           
651    def __call__(self, preVerifyOK, x509StoreCtx):
652        '''@type preVerifyOK: int
653        @param preVerifyOK: If a verification error is found, this parameter
654        will be set to 0
655        @type x509StoreCtx: M2Crypto.X509_Store_Context
656        @param x509StoreCtx: context object containing peer certificate and
657        certificate chain for verification
658        '''
659        if preVerifyOK == 0:
660            # Something is wrong with the certificate don't bother proceeding
661            # any further
662            log.info("No custom Validation executed: a previous verification "
663                     "error has occurred")
664            return preVerifyOK
665
666        # validate the discovered end points
667        for validator in self.idPValidators:   
668            try:
669                validator.validate(x509StoreCtx)
670                log.info("Whitelist Validator %s succeeded", 
671                         validator.__class__.__name__)
672           
673            except Exception, e:
674                log.error("Whitelist Validator %r caught %s exception with "
675                          "peer certificate context: %s", 
676                          validator.__class__.__name__, 
677                          e.__class__.__name__,
678                          traceback.format_exc())       
679                return 0
680           
681        if len(self.idPValidators) == 0:
682            log.warning("No IdP validation executed because no validators "
683                        "were set")
684           
685        return 1
686       
Note: See TracBrowser for help on using the repository browser.