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

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/TI12-security/trunk/python/ndg_security_server/ndg/security/server/wsgi/ssl.py@5757
Revision 5757, 16.1 KB checked in by pjkersha, 10 years ago (diff)

Testing SSL Client Authentication middleware with session and redirect middleware to enable wget support for NDG Security.

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
19import re # Pattern matching to determine which URI paths to apply SSL AuthN to
20
21from ndg.security.server.wsgi import NDGSecurityMiddlewareBase
22from ndg.security.common.X509 import X509Stack, X509Cert, X509CertError, X500DN
23from ndg.security.common.utils.classfactory import instantiateClass
24
25
26class ClientCertVerificationInterface(object):
27    """Interface to enable customised verification of the client certificate
28    Distinguished Name"""
29    def __init__(self, **cfg):
30        """@type cfg: dict
31        @param cfg: configuration parameters, derived class may customise
32        """
33        raise NotImplementedError()
34   
35    def __call__(self, x509Cert):
36        """Derived class implementation should return True if the certificate
37        DN is valid, False otherwise
38        @type x509Cert: ndg.security.common.X509.X509Cert
39        @param x509Cert: client X.509 certificate received from Apache
40        environment"""
41        raise NotImplementedError()
42
43
44class NoClientCertVerification(ClientCertVerificationInterface):
45    """Implementation of ClientCertVerificationInterface ignoring the
46    client certificate DN set"""
47    def __init__(self, **cfg):
48        pass
49   
50    def __call__(self, x509Cert):
51        return True
52   
53class ClientCertVerificationList(ClientCertVerificationInterface):
54    """Implementation of ClientCertVerificationInterface matching the input
55    client certificate DN against a configurable list"""
56   
57    def __init__(self, validDNList=[]):
58        self.validDNList = validDNList
59   
60    def __call__(self, x509Cert):
61        inputDN = x509Cert.dn
62        return inputDN in self._validDNList
63   
64    def _setValidDNList(self, dnList):
65        '''Read CA certificates from file and add them to an X.509 Cert.
66        stack
67       
68        @type dnList: list or tuple
69        @param dnList: list of DNs to match against the input certificate DN
70        '''
71       
72        if isinstance(dnList, basestring):
73            # Try parsing a space separated list of file paths
74            dnList = dnList.split()
75           
76        elif not isinstance(dnList, (list, tuple)):
77            raise TypeError('Expecting a list or tuple for "dnList"')
78
79        self._validDNList = [X500DN(dn) for dn in dnList]
80
81   
82    def _getValidDNList(self):
83        return self._validDNList
84   
85    validDNList = property(fset=_setValidDNList,
86                           fget=_getValidDNList,
87                           doc="list of permissible certificate Distinguished "
88                               "Names permissible")
89   
90
91class ApacheSSLAuthnMiddleware(NDGSecurityMiddlewareBase):
92    """Perform SSL peer certificate authentication making use of Apache
93    SSL environment settings
94   
95    B{This class relies on SSL environment settings being present as available
96    when run embedded within Apache using for example mod_wsgi}
97   
98    - SSL Client certificate is expected to be present in environ as
99    SSL_CLIENT_CERT key as set by Apache SSL with ExportCertData option to
100    SSLOptions directive enabled.
101    """
102    SSL_KEYNAME = 'HTTPS'
103    SSL_KEYVALUE = '1'
104   
105    _isSSLRequest = lambda self: self.environ.get(
106                                    ApacheSSLAuthnMiddleware.SSL_KEYNAME) == \
107                                    ApacheSSLAuthnMiddleware.SSL_KEYVALUE
108    isSSLRequest = property(fget=_isSSLRequest,
109                            doc="Is an SSL request boolean - depends on "
110                                "'HTTPS' Apache environment variable setting")
111   
112    SSL_CLIENT_CERT_KEYNAME = 'SSL_CLIENT_CERT'
113   
114    # Options for ini file
115    RE_PATH_MATCH_LIST_OPTNAME = 'rePathMatchList'
116    CACERT_FILEPATH_LIST_OPTNAME = 'caCertFilePathList'
117    CLIENT_CERT_DN_MATCH_LIST_OPTNAME = 'clientCertDNMatchList'
118   
119    propertyDefaults = {
120        RE_PATH_MATCH_LIST_OPTNAME: [],
121        CACERT_FILEPATH_LIST_OPTNAME: [],
122        CLIENT_CERT_DN_MATCH_LIST_OPTNAME: []
123    }
124    propertyDefaults.update(NDGSecurityMiddlewareBase.propertyDefaults)
125       
126    _isSSLClientCertSet = lambda self: bool(self.environ.get(
127                            ApacheSSLAuthnMiddleware.SSL_CLIENT_CERT_KEYNAME)) 
128    isSSLClientCertSet = property(fget=_isSSLClientCertSet,
129                                  doc="Check for client X.509 certificate "
130                                      "'SSL_CLIENT_CERT' setting in environ")
131   
132    def __init__(self, app, global_conf, prefix='sslAuthn.', **app_conf):
133       
134        super(ApacheSSLAuthnMiddleware, self).__init__(app, 
135                                                       global_conf, 
136                                                       prefix=prefix,
137                                                       **app_conf)
138       
139        self.__caCertFilePathList = None
140        self.__caCertStack = None
141        self.__clientCertDNMatchList = None
142        self.__clientCert = None
143       
144        rePathMatchListVal = app_conf.get(
145                ApacheSSLAuthnMiddleware.RE_PATH_MATCH_LIST_OPTNAME, '')
146        self.rePathMatchList = [re.compile(r) 
147                                for r in rePathMatchListVal.split()]
148
149        self.caCertStack = app_conf.get(
150                ApacheSSLAuthnMiddleware.CACERT_FILEPATH_LIST_OPTNAME, [])
151       
152        self.clientCertDNMatchList = app_conf.get(
153                ApacheSSLAuthnMiddleware.CLIENT_CERT_DN_MATCH_LIST_OPTNAME, [])
154               
155    def _setCACertStack(self, caCertList):
156        '''Read CA certificates from file and add them to an X.509 Cert.
157        stack
158       
159        @type caCertList: basestring, list, tuple or
160        ndg.security.common.X509.X509Stack
161        @param caCertList: list of file paths for CA certificates to
162        be used to verify certificate used to sign message.  If a single
163        string, it will be parsed into a list based on space separator to
164        delimit items'''
165       
166        if isinstance(caCertList, X509Stack):
167            self.__caCertFilePathList = []
168            self.__caCertStack = caCertList
169            return
170       
171        else:
172            if isinstance(caCertList, basestring):
173                # Try parsing a space separated list of file paths
174                self.__caCertFilePathList = caCertList.split()
175               
176            elif isinstance(caCertList, (list, tuple)):
177                self.__caCertFilePathList = caCertList
178            else:
179                raise TypeError('Expecting a list or tuple for '
180                                '"caCertList"')
181   
182            self.__caCertStack = X509Stack()
183   
184            for caCertFilePath in self.__caCertFilePathList:
185                x509Cert = X509Cert.Read(os.path.expandvars(caCertFilePath))
186                self.__caCertStack.push(x509Cert)
187   
188    def _getCACertStack(self):
189        return self.__caCertStack
190
191    caCertStack = property(fset=_setCACertStack,
192                           fget=_getCACertStack,
193                           doc="CA certificate stack object - "
194                               "peer certificate must validate against one")
195   
196    def _getCACertFilePathList(self):
197        return self.__caCertFilePathList
198   
199    caCertFilePathList = property(fset=_setCACertStack,
200                                  fget=_getCACertFilePathList,
201                                  doc="list of CA certificate file paths - "
202                                      "peer certificate must validate against "
203                                      "one.  This property is set from the "
204                                      "caCertStack property assignment")
205
206    caCertStack = property(fset=_setCACertStack,
207                           fget=_getCACertStack,
208                           doc="CA certificate stack object - "
209                               "peer certificate must validate against one")
210       
211    def _setClientCertDNMatchList(self, value):
212        '''       
213        @type value: basestring, list, tuple
214        @param value: list of client certificate Distinguished Names as strings
215        of X500DN instances'''
216       
217        if isinstance(value, basestring):
218            # Try parsing a space separated list of file paths
219            self.__clientCertDNMatchList = [X500DN(dn=dn) 
220                                            for dn in value.split()]
221           
222        elif isinstance(value, (list, tuple)):
223            self.__clientCertDNMatchList = []
224            for dn in value:
225                if isinstance(dn, basestring):
226                    self.__clientCertDNMatchList.append(X500DN(dn=dn))
227                elif isinstance(dn, X500DN):
228                    self.__clientCertDNMatchList.append(dn)
229                else:
230                    raise TypeError('Expecting a string, or %r type for "%s" '
231                                    'list item; got %r' % 
232                (X500DN,
233                 ApacheSSLAuthnMiddleware.CLIENT_CERT_DN_MATCH_LIST_OPTNAME,
234                 type(dn)))
235                   
236        else:
237            raise TypeError('Expecting a string, list or tuple for "%s"; got '
238                            '%r' % 
239                (ApacheSSLAuthnMiddleware.CLIENT_CERT_DN_MATCH_LIST_OPTNAME,
240                 type(value)))
241   
242    def _getClientCertDNMatchList(self):
243        return self.__clientCertDNMatchList
244
245    clientCertDNMatchList = property(fset=_setClientCertDNMatchList,
246                                     fget=_getClientCertDNMatchList,
247                                     doc="List of acceptable Distinguished "
248                                         "Names for client certificates")
249       
250    def _getClientCert(self):
251        return self.__clientCert
252
253    clientCert = property(fget=_getClientCert,
254                          doc="Client certificate for verification set by "
255                              "isValidClientCert()")
256   
257    @NDGSecurityMiddlewareBase.initCall         
258    def __call__(self, environ, start_response):
259        '''Check for peer certificate in environment and if present carry out
260        authentication
261       
262        @type environ: dict
263        @param environ: WSGI environment variables dictionary
264        @type start_response: function
265        @param start_response: standard WSGI start response function
266        '''
267        log.debug("ApacheSSLAuthnMiddleware.__call__ ...")
268       
269        if not self._pathMatch():
270            log.debug("ApacheSSLAuthnMiddleware: ignoring path [%s]", 
271                      self.pathInfo)
272            return self._setResponse()
273       
274        elif not self.isSSLRequest:
275            log.warning("ApacheSSLAuthnMiddleware: 'HTTPS' environment "
276                        "variable not found in environment; ignoring request")
277            return self._setResponse()
278                       
279        elif not self.isSSLClientCertSet:
280            log.error("ApacheSSLAuthnMiddleware: No SSL Client certificate "
281                      "for request to [%s]; setting HTTP 401 Unauthorized", 
282                      self.pathInfo)
283            return self._setErrorResponse(code=401,
284                                          msg='No client SSL Certificate set')
285           
286        if self.isValidClientCert():           
287            return self._setResponse()
288        else:
289            return self._setErrorResponse(code=401)
290
291    def _setResponse(self, 
292                     notFoundMsg='No application set for '
293                                 'ApacheSSLAuthnMiddleware',
294                     **kw):
295        return super(ApacheSSLAuthnMiddleware, 
296                     self)._setResponse(notFoundMsg=notFoundMsg, **kw)
297
298    def _setErrorResponse(self, msg='Invalid SSL client certificate', **kw):
299        return super(ApacheSSLAuthnMiddleware, self)._setErrorResponse(msg=msg,
300                                                                       **kw)
301
302    def _pathMatch(self):
303        """Apply a list of regular expression matching patterns to the contents
304        of environ['PATH_INFO'], if any match, return True.  This method is
305        used to determine whether to apply SSL client authentication
306        """
307        path = self.pathInfo
308        for regEx in self.rePathMatchList:
309            if regEx.match(path):
310                return True
311           
312        return False
313   
314    def isValidClientCert(self):
315        sslClientCert = self.environ[
316                            ApacheSSLAuthnMiddleware.SSL_CLIENT_CERT_KEYNAME]
317        self.__clientCert = X509Cert.Parse(sslClientCert)
318       
319        if len(self.caCertStack) == 0:
320            log.warning("No CA certificates set for Client certificate "
321                        "signature verification")
322        else:
323            try:
324                self.caCertStack.verifyCertChain(
325                                            x509Cert2Verify=self.__clientCert)
326
327            except X509CertError, e:
328                log.info("Client certificate verification failed with %s "
329                         "exception: %s" % (type(e), e))
330                return False
331           
332            except Exception, e:
333                log.error("Client certificate verification failed with "
334                          "unexpected exception type %s: %s" % (type(e), e))
335                return False
336           
337        if len(self.clientCertDNMatchList) > 0:
338            if self.__clientCert.dn not in self.clientCertDNMatchList:
339                return False
340           
341        return True
342#       
343#        # Check certificate Distinguished Name via
344#        # ClientCertVerificationInterface object
345#        return self.verifyClientCert(x509Cert)
346   
347
348class AuthKitSSLAuthnMiddleware(ApacheSSLAuthnMiddleware):
349    """Update REMOTE_USER AuthKit environ key with certificate CommonName to
350    flag logged in status to other middleware
351    """
352    @NDGSecurityMiddlewareBase.initCall         
353    def __call__(self, environ, start_response):
354        '''Check for peer certificate in environment and if present carry out
355        authentication.  Overrides parent class behaviour to set REMOTE_USER
356        AuthKit environ key based on the client certificate's Distinguished
357        Name CommonName field.  If no certificate is present or it is
358        present but invalid no 401 response is set.  Instead, it is left to
359        the following middleware in the chain to deal with this.  When used
360        in conjunction with
361        ndg.security.server.wsgi.openid.relyingparty.OpenIDRelyingPartyMiddleware,
362        this will result in the display of the Relying Party interface but with
363        a 401 status set.
364       
365        @type environ: dict
366        @param environ: WSGI environment variables dictionary
367        @type start_response: function
368        @param start_response: standard WSGI start response function
369        '''
370        if not self._pathMatch():
371            log.debug("AuthKitSSLAuthnMiddleware: ignoring path [%s]", 
372                      self.pathInfo)
373
374        elif not self.isSSLRequest:
375            log.warning("AuthKitSSLAuthnMiddleware: 'HTTPS' environment "
376                        "variable not found in environment; ignoring request")
377                       
378        elif not self.isSSLClientCertSet:
379            log.debug("AuthKitSSLAuthnMiddleware: no client certificate set - "
380                      "passing request to next middleware in the chain ...")
381           
382        elif self.isValidClientCert():
383            # Update environ so that downstream AuthenticationMiddleware can
384            # set the session cookie
385            self.environ['REMOTE_USER'] = self.clientCert.dn['CN']
386           
387            # Set-up redirect back to original request URI
388        else:
389            # IsValidCert will log warnings/errors no need to flag this
390            # condition
391            pass
392           
393        # Pass request to next middleware in the chain without setting an
394        # error response - see method doc string for explanation.
395        return self._setResponse()
Note: See TracBrowser for help on using the repository browser.