source: TI12-security/trunk/NDGSecurity/python/ndg_security_server/ndg/security/server/wsgi/ssl.py @ 7077

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/TI12-security/trunk/NDGSecurity/python/ndg_security_server/ndg/security/server/wsgi/ssl.py@7077
Revision 7077, 20.0 KB checked in by pjkersha, 9 years ago (diff)
  • Property svn:keywords set to Id
Line 
1"""SSL Peer Authentication Middleware Module
2
3Apply to SSL client authentication to configured URL paths.
4
5SSL Client certificate is expected to be present in environ as SSL_CLIENT_CERT
6key as set by standard Apache SSL.
7
8NERC DataGrid Project
9"""
10__author__ = "P J Kershaw"
11__date__ = "11/12/08"
12__copyright__ = "(C) 2009 Science and Technology Facilities Council"
13__contact__ = "Philip.Kershaw@stfc.ac.uk"
14__revision__ = "$Id$"
15__license__ = "BSD - see top-level directory for LICENSE file"
16import logging
17log = logging.getLogger(__name__)
18import os
19# Pattern matching to determine which URI paths to apply SSL AuthN to and to
20# parse SSL certificate environment variable
21import re 
22
23# Decode SSL certificate environment variable
24import base64       
25
26from ndg.security.server.wsgi import NDGSecurityMiddlewareBase
27from ndg.security.common.X509 import X509Stack, X509Cert, X509CertError, X500DN
28from ndg.security.common.utils.classfactory import instantiateClass
29   
30
31class ApacheSSLAuthnMiddleware(NDGSecurityMiddlewareBase):
32    """Perform SSL peer certificate authentication making use of Apache
33    SSL environment settings
34   
35    B{This class relies on SSL environment settings being present as available
36    when run embedded within Apache using for example mod_wsgi}
37   
38    - SSL Client certificate is expected to be present in environ as
39    SSL_CLIENT_CERT key as set by Apache SSL with ExportCertData option to
40    SSLOptions directive enabled.
41    """
42    SSL_KEYNAME = 'HTTPS'
43    SSL_KEYVALUES = ('1', 'on')
44   
45    _isSSLRequest = lambda self: self.environ.get(self.sslKeyName) in \
46                                    ApacheSSLAuthnMiddleware.SSL_KEYVALUES
47    isSSLRequest = property(fget=_isSSLRequest,
48                            doc="Is an SSL request boolean - depends on "
49                                "'HTTPS' Apache environment variable setting")
50   
51    SSL_CLIENT_CERT_KEYNAME = 'SSL_CLIENT_CERT'
52    PEM_CERT_PREFIX = '-----BEGIN CERTIFICATE-----'
53   
54    # Options for ini file
55    RE_PATH_MATCH_LIST_OPTNAME = 'rePathMatchList'
56    CACERT_FILEPATH_LIST_OPTNAME = 'caCertFilePathList'
57    CLIENT_CERT_DN_MATCH_LIST_OPTNAME = 'clientCertDNMatchList'
58    CLIENT_CERT_DN_MATCH_LIST_SEP_PAT = re.compile(',\s*')
59    SSL_KEYNAME_OPTNAME = 'sslKeyName'
60    SSL_CLIENT_CERT_KEYNAME_OPTNAME = 'sslClientCertKeyName'
61   
62    propertyDefaults = {
63        RE_PATH_MATCH_LIST_OPTNAME: [],
64        CACERT_FILEPATH_LIST_OPTNAME: [],
65        CLIENT_CERT_DN_MATCH_LIST_OPTNAME: [],
66        SSL_KEYNAME_OPTNAME: SSL_KEYNAME,
67        SSL_CLIENT_CERT_KEYNAME_OPTNAME: SSL_CLIENT_CERT_KEYNAME
68    }
69    propertyDefaults.update(NDGSecurityMiddlewareBase.propertyDefaults)
70   
71    PARAM_PREFIX = 'sslAuthn.'
72   
73    # isValidCert requires special parsing of certificate when passed via a
74    # proxy
75    X509_CERT_PAT = re.compile('(\s?-----[A-Z]+\sCERTIFICATE-----\s?)|\s+')
76   
77    # Flag to other middleware that authentication succeeded by setting this key
78    # in the environ to True.  This is done in the isValidCert method
79    AUTHN_SUCCEEDED_ENVIRON_KEYNAME = ('ndg.security.server.wsgi.ssl.'
80                                       'ApacheSSLAuthnMiddleware.authenticated')
81
82    def __init__(self, app, global_conf, prefix=PARAM_PREFIX, **app_conf):
83        """Read configuration settings from the global and application specific
84        ini file settings
85        """
86        super(ApacheSSLAuthnMiddleware, self).__init__(app, 
87                                                       global_conf, 
88                                                       prefix=prefix,
89                                                       **app_conf)
90
91        self.__caCertFilePathList = None
92        self.__caCertStack = None
93        self.__clientCertDNMatchList = None
94        self.__clientCert = None
95        self.__sslClientCertKeyName = None
96        self.__sslKeyName = None
97       
98        rePathMatchListParamName = prefix + \
99                    ApacheSSLAuthnMiddleware.RE_PATH_MATCH_LIST_OPTNAME
100        rePathMatchListVal = app_conf.get(rePathMatchListParamName, '')
101       
102        self.rePathMatchList = [re.compile(r) 
103                                for r in rePathMatchListVal.split()]
104       
105        caCertFilePathListParamName = prefix + \
106                    ApacheSSLAuthnMiddleware.CACERT_FILEPATH_LIST_OPTNAME
107                       
108        # Verify against trust root if set.  Alternatively, the verification
109        # step can be configured in the Apache config file.  The latter will
110        # correctly verify proxy certificates if the environment variable
111        # OPENSSL_ALLOW_PROXY_CERTS is set in the start up.  The verification
112        # code in isValidClientCert can't correctly verify proxy certificates
113        # because only a single certificate is passed in the SSL_CLIENT_CERT
114        # environ variable and not the complete certificate chain
115        self.caCertStack = app_conf.get(caCertFilePathListParamName, [])
116       
117        clientCertDNMatchListParamName = prefix + \
118                    ApacheSSLAuthnMiddleware.CLIENT_CERT_DN_MATCH_LIST_OPTNAME
119                   
120        # Specify a restricted list of DNs of which the input client certificate
121        # DN must match at least one
122        self.clientCertDNMatchList = app_conf.get(
123                                        clientCertDNMatchListParamName, [])
124       
125        sslClientCertParamName = prefix + \
126                    ApacheSSLAuthnMiddleware.SSL_CLIENT_CERT_KEYNAME_OPTNAME   
127        self.sslClientCertKeyName = app_conf.get(sslClientCertParamName, 
128                            ApacheSSLAuthnMiddleware.SSL_CLIENT_CERT_KEYNAME)
129       
130        sslKeyNameParamName = prefix + \
131                    ApacheSSLAuthnMiddleware.SSL_KEYNAME_OPTNAME   
132        self.sslKeyName = app_conf.get(sslKeyNameParamName, 
133                                       ApacheSSLAuthnMiddleware.SSL_KEYNAME)
134
135    def _getSslClientCertKeyName(self):
136        return self.__sslClientCertKeyName
137
138    def _setSslClientCertKeyName(self, value):
139        if not isinstance(value, basestring):
140            raise TypeError('Expecting %r type for "sslClientCertKeyName"; '
141                            'got %r' % (basestring, type(value)))
142        self.__sslClientCertKeyName = value
143
144    sslClientCertKeyName = property(_getSslClientCertKeyName, 
145                                    _setSslClientCertKeyName, 
146                                    doc="SslClientCertKeyName's Docstring")
147
148    def _getSslKeyName(self):
149        return self.__sslKeyName
150
151    def _setSslKeyName(self, value):       
152        if not isinstance(value, basestring):
153            raise TypeError('Expecting %r type for "sslKeyName"; got %r' %
154                            (basestring, type(value)))
155        self.__sslKeyName = value
156
157    sslKeyName = property(_getSslKeyName, 
158                          _setSslKeyName, 
159                          doc="SslKeyName's Docstring")
160
161               
162    def _setCACertStack(self, caCertList):
163        '''Read CA certificates from file and add them to an X.509 Cert.
164        stack
165       
166        @type caCertList: basestring, list, tuple or
167        ndg.security.common.X509.X509Stack
168        @param caCertList: list of file paths for CA certificates to
169        be used to verify certificate used to sign message.  If a single
170        string, it will be parsed into a list based on space separator to
171        delimit items'''
172       
173        if isinstance(caCertList, X509Stack):
174            self.__caCertFilePathList = []
175            self.__caCertStack = caCertList
176            return
177       
178        else:
179            if isinstance(caCertList, basestring):
180                # Try parsing a space separated list of file paths
181                self.__caCertFilePathList = caCertList.split()
182               
183            elif isinstance(caCertList, (list, tuple)):
184                self.__caCertFilePathList = caCertList
185            else:
186                raise TypeError('Expecting a list or tuple for '
187                                '"caCertList"')
188   
189            self.__caCertStack = X509Stack()
190   
191            for caCertFilePath in self.__caCertFilePathList:
192                x509Cert = X509Cert.Read(os.path.expandvars(caCertFilePath))
193                self.__caCertStack.push(x509Cert)
194   
195    def _getCACertStack(self):
196        return self.__caCertStack
197
198    caCertStack = property(fset=_setCACertStack,
199                           fget=_getCACertStack,
200                           doc="CA certificate stack object - "
201                               "peer certificate must validate against one")
202   
203    def _getCACertFilePathList(self):
204        return self.__caCertFilePathList
205   
206    caCertFilePathList = property(fset=_setCACertStack,
207                                  fget=_getCACertFilePathList,
208                                  doc="list of CA certificate file paths - "
209                                      "peer certificate must validate against "
210                                      "one.  This property is set from the "
211                                      "caCertStack property assignment")
212
213    caCertStack = property(fset=_setCACertStack,
214                           fget=_getCACertStack,
215                           doc="CA certificate stack object - "
216                               "peer certificate must validate against one")
217       
218    def _setClientCertDNMatchList(self, value):
219        '''       
220        @type value: basestring, list, tuple
221        @param value: list of client certificate Distinguished Names as strings
222        of X500DN instances'''
223       
224        if isinstance(value, basestring):
225            # Try parsing a space separated list of file paths
226            pat = ApacheSSLAuthnMiddleware.CLIENT_CERT_DN_MATCH_LIST_SEP_PAT
227            dnList = pat.split(value)
228            self.__clientCertDNMatchList = [X500DN(dn=dn) for dn in dnList]
229           
230        elif isinstance(value, (list, tuple)):
231            self.__clientCertDNMatchList = []
232            for dn in value:
233                if isinstance(dn, basestring):
234                    self.__clientCertDNMatchList.append(X500DN(dn=dn))
235                elif isinstance(dn, X500DN):
236                    self.__clientCertDNMatchList.append(dn)
237                else:
238                    raise TypeError('Expecting a string, or %r type for "%s" '
239                                    'list item; got %r' % 
240                    (X500DN,
241                     ApacheSSLAuthnMiddleware.CLIENT_CERT_DN_MATCH_LIST_OPTNAME,
242                     type(dn)))
243                   
244        else:
245            raise TypeError('Expecting a string, list or tuple for "%s"; got '
246                            '%r' % 
247                (ApacheSSLAuthnMiddleware.CLIENT_CERT_DN_MATCH_LIST_OPTNAME,
248                 type(value)))
249   
250    def _getClientCertDNMatchList(self):
251        return self.__clientCertDNMatchList
252
253    clientCertDNMatchList = property(fset=_setClientCertDNMatchList,
254                                     fget=_getClientCertDNMatchList,
255                                     doc="List of acceptable Distinguished "
256                                         "Names for client certificates")
257       
258    def _getClientCert(self):
259        return self.__clientCert
260
261    clientCert = property(fget=_getClientCert,
262                          doc="Client certificate for verification set by "
263                              "isValidClientCert()")
264
265   
266    @NDGSecurityMiddlewareBase.initCall         
267    def __call__(self, environ, start_response):
268        '''Check for peer certificate in environment and if present carry out
269        authentication
270       
271        @type environ: dict
272        @param environ: WSGI environment variables dictionary
273        @type start_response: function
274        @param start_response: standard WSGI start response function
275        '''
276        log.debug("ApacheSSLAuthnMiddleware.__call__ ...")
277       
278        if not self._pathMatch():
279            log.debug("ApacheSSLAuthnMiddleware: ignoring path [%s]", 
280                      self.pathInfo)
281            return self._setResponse()
282       
283        elif not self.isSSLRequest:
284            log.warning("ApacheSSLAuthnMiddleware: %r environment variable "
285                        "not found in environment; ignoring request" % 
286                        self.sslKeyName)
287            return self._setResponse()
288                       
289        elif not self.isSSLClientCertSet:
290            log.error("ApacheSSLAuthnMiddleware: No SSL Client certificate "
291                      "for request to [%s]; setting HTTP 401 Unauthorized", 
292                      self.pathInfo)
293            return self._setErrorResponse(code=401,
294                                          msg='No client SSL Certificate set')
295           
296        if self.isValidClientCert():
297            self._setUser()         
298            return self._setResponse()
299        else:
300            return self._setErrorResponse(code=401)
301
302    def _setResponse(self, 
303                     notFoundMsg='No application set for '
304                                 'ApacheSSLAuthnMiddleware',
305                     **kw):
306        return super(ApacheSSLAuthnMiddleware, 
307                     self)._setResponse(notFoundMsg=notFoundMsg, **kw)
308
309    def _setErrorResponse(self, msg='Invalid SSL client certificate', **kw):
310        return super(ApacheSSLAuthnMiddleware, self)._setErrorResponse(msg=msg,
311                                                                       **kw)
312
313    def _pathMatch(self):
314        """Apply a list of regular expression matching patterns to the contents
315        of environ['PATH_INFO'], if any match, return True.  This method is
316        used to determine whether to apply SSL client authentication
317        """
318        path = self.pathInfo
319        for regEx in self.rePathMatchList:
320            if regEx.match(path):
321                return True
322           
323        return False
324       
325    def _isSSLClientCertSet(self):
326        """Check for SSL Certificate set in environ"""
327        sslClientCert = self.environ.get(
328                        self.sslClientCertKeyName, '')
329        return sslClientCert.startswith(
330                                    ApacheSSLAuthnMiddleware.PEM_CERT_PREFIX) 
331       
332    isSSLClientCertSet = property(fget=_isSSLClientCertSet,
333                                  doc="Check for client X.509 certificate "
334                                      "%r setting in environ" %
335                                      SSL_CLIENT_CERT_KEYNAME)
336   
337    def isValidClientCert(self):
338        sslClientCert = self.environ[self.sslClientCertKeyName]
339
340        # Certificate string passed through a proxy has spaces in place of
341        # newline delimiters.  Fix by re-organising the string into a single
342        # line and remove the BEGIN CERTIFICATE / END CERTIFICATE delimiters.
343        # Then, treat as a base64 encoded string decoding and passing as DER
344        # format to the X.509 parser
345       
346        cert = self.__class__.X509_CERT_PAT.sub('', sslClientCert)
347        derCert = base64.decodestring(cert)
348        self.__clientCert = X509Cert.Parse(derCert, format=X509Cert.formatDER)
349       
350        # Check validity time
351        if not self.__clientCert.isValidTime():
352            return False
353       
354        # Verify against trust root if set.  Alternatively, the verification
355        # step can be configured in the Apache config file.  The latter will
356        # correctly verify proxy certificates if the environment variable
357        # OPENSSL_ALLOW_PROXY_CERTS is set in the start up.  The verification
358        # code HERE can't correctly verify proxy certificates because only a
359        # single certificate is passed in the SSL_CLIENT_CERT environ variable
360        # and not the complete certificate chain
361        if len(self.caCertStack) == 0:
362            log.warning("No CA certificates set for Client certificate "
363                        "signature verification")
364        else:
365            try:
366                self.caCertStack.verifyCertChain(
367                                            x509Cert2Verify=self.__clientCert)
368
369            except X509CertError, e:
370                log.info("Client certificate verification failed with %s "
371                         "exception: %s" % (type(e), e))
372                return False
373           
374            except Exception, e:
375                log.error("Client certificate verification failed with "
376                          "unexpected exception type %s: %s" % (type(e), e))
377                return False
378           
379        # Verify against list of acceptable DNs if set
380        if len(self.clientCertDNMatchList) > 0:
381            dn = self.__clientCert.dn
382            for expectedDN in self.clientCertDNMatchList: 
383                if dn == expectedDN:
384                    self.environ[
385                        ApacheSSLAuthnMiddleware.AUTHN_SUCCEEDED_ENVIRON_KEYNAME
386                    ] = True
387                    log.debug("Client Certificate DN %s matches DN in "
388                              "permitted DNs list %r", dn, 
389                              self.clientCertDNMatchList)
390                    return True
391               
392            log.debug("No match found for Client Certificate DN %s in "
393                      "permitted DNs list %r", dn, self.clientCertDNMatchList)
394               
395            return False
396
397        self.environ[
398            ApacheSSLAuthnMiddleware.AUTHN_SUCCEEDED_ENVIRON_KEYNAME] = True
399        return True
400
401    def _setUser(self):
402        """Interface hook for a derived class to set user ID from certificate
403        set or other context info.
404        """
405
406
407class AuthKitSSLAuthnMiddleware(ApacheSSLAuthnMiddleware):
408    """Update REMOTE_USER AuthKit environ key with certificate CommonName to
409    flag logged in status to other middleware and set cookie using Paste
410    Auth Ticket
411    """
412    SET_USER_ENVIRON_KEYNAME = 'paste.auth_tkt.set_user'
413   
414    @NDGSecurityMiddlewareBase.initCall         
415    def __call__(self, environ, start_response):
416        '''Check for peer certificate in environment and if present carry out
417        authentication.  Overrides parent class behaviour to set REMOTE_USER
418        AuthKit environ key based on the client certificate's Distinguished
419        Name CommonName field.  If no certificate is present or it is
420        present but invalid no 401 response is set.  Instead, it is left to
421        the following middleware in the chain to deal with this.  When used
422        in conjunction with
423        ndg.security.server.wsgi.openid.relyingparty.OpenIDRelyingPartyMiddleware,
424        this will result in the display of the Relying Party interface but with
425        a 401 status set.
426       
427        @type environ: dict
428        @param environ: WSGI environment variables dictionary
429        @type start_response: function
430        @param start_response: standard WSGI start response function
431        '''
432        if not self._pathMatch():
433            log.debug("AuthKitSSLAuthnMiddleware: ignoring path [%s]", 
434                      self.pathInfo)
435
436        elif not self.isSSLRequest:
437            log.debug("AuthKitSSLAuthnMiddleware: 'HTTPS' environment "
438                      "variable not found in environment; ignoring request")
439                       
440        elif not self.isSSLClientCertSet:
441            log.debug("AuthKitSSLAuthnMiddleware: no client certificate set - "
442                      "passing request to next middleware in the chain ...")
443           
444        elif self.isValidClientCert():
445            # Update session cookie with user ID
446            self._setUser()
447           
448        # ... isValidCert will log warnings/errors no need to flag the False
449        # condition
450           
451        # Pass request to next middleware in the chain without setting an
452        # error response - see method doc string for explanation.
453        return self._setResponse()
454
455    def _setUser(self):
456        """Set user ID in AuthKit cookie from client certificate submitted
457        """
458        commonName = self.clientCert.dn['CN']
459        if len(commonName) > 0:
460            # Proxy certificate will have multiple CNs
461            userId = commonName[0]
462        else:
463            userId = commonName
464       
465        self.environ[
466            AuthKitSSLAuthnMiddleware.USERNAME_ENVIRON_KEYNAME] = userId
467        self.environ[AuthKitSSLAuthnMiddleware.SET_USER_ENVIRON_KEYNAME](userId)
Note: See TracBrowser for help on using the repository browser.