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

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

Added a wrapper class to Paste Deploy httpserver to enable automated setup and teardown of services for unit tests.

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    SSL_KEYNAME_OPTNAME = 'sslKeyName'
59    SSL_CLIENT_CERT_KEYNAME_OPTNAME = 'sslClientCertKeyName'
60   
61    propertyDefaults = {
62        RE_PATH_MATCH_LIST_OPTNAME: [],
63        CACERT_FILEPATH_LIST_OPTNAME: [],
64        CLIENT_CERT_DN_MATCH_LIST_OPTNAME: [],
65        SSL_KEYNAME_OPTNAME: SSL_KEYNAME,
66        SSL_CLIENT_CERT_KEYNAME_OPTNAME: SSL_CLIENT_CERT_KEYNAME
67    }
68    propertyDefaults.update(NDGSecurityMiddlewareBase.propertyDefaults)
69   
70    PARAM_PREFIX = 'sslAuthn.'
71   
72    def __init__(self, app, global_conf, prefix=PARAM_PREFIX, **app_conf):
73       
74        super(ApacheSSLAuthnMiddleware, self).__init__(app, 
75                                                       global_conf, 
76                                                       prefix=prefix,
77                                                       **app_conf)
78
79        self.__caCertFilePathList = None
80        self.__caCertStack = None
81        self.__clientCertDNMatchList = None
82        self.__clientCert = None
83        self.__sslClientCertKeyName = None
84        self.__sslKeyName = None
85       
86        rePathMatchListParamName = prefix + \
87                    ApacheSSLAuthnMiddleware.RE_PATH_MATCH_LIST_OPTNAME
88        rePathMatchListVal = app_conf.get(rePathMatchListParamName, '')
89       
90        self.rePathMatchList = [re.compile(r) 
91                                for r in rePathMatchListVal.split()]
92       
93        caCertFilePathListParamName = prefix + \
94                    ApacheSSLAuthnMiddleware.CACERT_FILEPATH_LIST_OPTNAME
95                       
96        self.caCertStack = app_conf.get(caCertFilePathListParamName, [])
97       
98        clientCertDNMatchListParamName = prefix + \
99                    ApacheSSLAuthnMiddleware.CLIENT_CERT_DN_MATCH_LIST_OPTNAME
100                   
101        self.clientCertDNMatchList = app_conf.get(
102                                        clientCertDNMatchListParamName, [])
103       
104        sslClientCertParamName = prefix + \
105                    ApacheSSLAuthnMiddleware.SSL_CLIENT_CERT_KEYNAME_OPTNAME   
106        self.sslClientCertKeyName = app_conf.get(sslClientCertParamName, 
107                            ApacheSSLAuthnMiddleware.SSL_CLIENT_CERT_KEYNAME)
108       
109        sslKeyNameParamName = prefix + \
110                    ApacheSSLAuthnMiddleware.SSL_KEYNAME_OPTNAME   
111        self.sslKeyName = app_conf.get(sslKeyNameParamName, 
112                                       ApacheSSLAuthnMiddleware.SSL_KEYNAME)
113
114    def _getSslClientCertKeyName(self):
115        return self.__sslClientCertKeyName
116
117    def _setSslClientCertKeyName(self, value):
118        if not isinstance(value, basestring):
119            raise TypeError('Expecting %r type for "sslClientCertKeyName"; '
120                            'got %r' % (basestring, type(value)))
121        self.__sslClientCertKeyName = value
122
123    sslClientCertKeyName = property(_getSslClientCertKeyName, 
124                                    _setSslClientCertKeyName, 
125                                    doc="SslClientCertKeyName's Docstring")
126
127    def _getSslKeyName(self):
128        return self.__sslKeyName
129
130    def _setSslKeyName(self, value):       
131        if not isinstance(value, basestring):
132            raise TypeError('Expecting %r type for "sslKeyName"; got %r' %
133                            (basestring, type(value)))
134        self.__sslKeyName = value
135
136    sslKeyName = property(_getSslKeyName, 
137                          _setSslKeyName, 
138                          doc="SslKeyName's Docstring")
139
140               
141    def _setCACertStack(self, caCertList):
142        '''Read CA certificates from file and add them to an X.509 Cert.
143        stack
144       
145        @type caCertList: basestring, list, tuple or
146        ndg.security.common.X509.X509Stack
147        @param caCertList: list of file paths for CA certificates to
148        be used to verify certificate used to sign message.  If a single
149        string, it will be parsed into a list based on space separator to
150        delimit items'''
151       
152        if isinstance(caCertList, X509Stack):
153            self.__caCertFilePathList = []
154            self.__caCertStack = caCertList
155            return
156       
157        else:
158            if isinstance(caCertList, basestring):
159                # Try parsing a space separated list of file paths
160                self.__caCertFilePathList = caCertList.split()
161               
162            elif isinstance(caCertList, (list, tuple)):
163                self.__caCertFilePathList = caCertList
164            else:
165                raise TypeError('Expecting a list or tuple for '
166                                '"caCertList"')
167   
168            self.__caCertStack = X509Stack()
169   
170            for caCertFilePath in self.__caCertFilePathList:
171                x509Cert = X509Cert.Read(os.path.expandvars(caCertFilePath))
172                self.__caCertStack.push(x509Cert)
173   
174    def _getCACertStack(self):
175        return self.__caCertStack
176
177    caCertStack = property(fset=_setCACertStack,
178                           fget=_getCACertStack,
179                           doc="CA certificate stack object - "
180                               "peer certificate must validate against one")
181   
182    def _getCACertFilePathList(self):
183        return self.__caCertFilePathList
184   
185    caCertFilePathList = property(fset=_setCACertStack,
186                                  fget=_getCACertFilePathList,
187                                  doc="list of CA certificate file paths - "
188                                      "peer certificate must validate against "
189                                      "one.  This property is set from the "
190                                      "caCertStack property assignment")
191
192    caCertStack = property(fset=_setCACertStack,
193                           fget=_getCACertStack,
194                           doc="CA certificate stack object - "
195                               "peer certificate must validate against one")
196       
197    def _setClientCertDNMatchList(self, value):
198        '''       
199        @type value: basestring, list, tuple
200        @param value: list of client certificate Distinguished Names as strings
201        of X500DN instances'''
202       
203        if isinstance(value, basestring):
204            # Try parsing a space separated list of file paths
205            self.__clientCertDNMatchList = [X500DN(dn=dn) 
206                                            for dn in value.split()]
207           
208        elif isinstance(value, (list, tuple)):
209            self.__clientCertDNMatchList = []
210            for dn in value:
211                if isinstance(dn, basestring):
212                    self.__clientCertDNMatchList.append(X500DN(dn=dn))
213                elif isinstance(dn, X500DN):
214                    self.__clientCertDNMatchList.append(dn)
215                else:
216                    raise TypeError('Expecting a string, or %r type for "%s" '
217                                    'list item; got %r' % 
218                (X500DN,
219                 ApacheSSLAuthnMiddleware.CLIENT_CERT_DN_MATCH_LIST_OPTNAME,
220                 type(dn)))
221                   
222        else:
223            raise TypeError('Expecting a string, list or tuple for "%s"; got '
224                            '%r' % 
225                (ApacheSSLAuthnMiddleware.CLIENT_CERT_DN_MATCH_LIST_OPTNAME,
226                 type(value)))
227   
228    def _getClientCertDNMatchList(self):
229        return self.__clientCertDNMatchList
230
231    clientCertDNMatchList = property(fset=_setClientCertDNMatchList,
232                                     fget=_getClientCertDNMatchList,
233                                     doc="List of acceptable Distinguished "
234                                         "Names for client certificates")
235       
236    def _getClientCert(self):
237        return self.__clientCert
238
239    clientCert = property(fget=_getClientCert,
240                          doc="Client certificate for verification set by "
241                              "isValidClientCert()")
242
243   
244    @NDGSecurityMiddlewareBase.initCall         
245    def __call__(self, environ, start_response):
246        '''Check for peer certificate in environment and if present carry out
247        authentication
248       
249        @type environ: dict
250        @param environ: WSGI environment variables dictionary
251        @type start_response: function
252        @param start_response: standard WSGI start response function
253        '''
254        log.debug("ApacheSSLAuthnMiddleware.__call__ ...")
255       
256        if not self._pathMatch():
257            log.debug("ApacheSSLAuthnMiddleware: ignoring path [%s]", 
258                      self.pathInfo)
259            return self._setResponse()
260       
261        elif not self.isSSLRequest:
262            log.warning("ApacheSSLAuthnMiddleware: 'HTTPS' environment "
263                        "variable not found in environment; ignoring request")
264            return self._setResponse()
265                       
266        elif not self.isSSLClientCertSet:
267            log.error("ApacheSSLAuthnMiddleware: No SSL Client certificate "
268                      "for request to [%s]; setting HTTP 401 Unauthorized", 
269                      self.pathInfo)
270            return self._setErrorResponse(code=401,
271                                          msg='No client SSL Certificate set')
272           
273        if self.isValidClientCert():
274            self._setUser()         
275            return self._setResponse()
276        else:
277            return self._setErrorResponse(code=401)
278
279    def _setResponse(self, 
280                     notFoundMsg='No application set for '
281                                 'ApacheSSLAuthnMiddleware',
282                     **kw):
283        return super(ApacheSSLAuthnMiddleware, 
284                     self)._setResponse(notFoundMsg=notFoundMsg, **kw)
285
286    def _setErrorResponse(self, msg='Invalid SSL client certificate', **kw):
287        return super(ApacheSSLAuthnMiddleware, self)._setErrorResponse(msg=msg,
288                                                                       **kw)
289
290    def _pathMatch(self):
291        """Apply a list of regular expression matching patterns to the contents
292        of environ['PATH_INFO'], if any match, return True.  This method is
293        used to determine whether to apply SSL client authentication
294        """
295        path = self.pathInfo
296        for regEx in self.rePathMatchList:
297            if regEx.match(path):
298                return True
299           
300        return False
301       
302    def _isSSLClientCertSet(self):
303        """Check for SSL Certificate set in environ"""
304        sslClientCert = self.environ.get(
305                        self.sslClientCertKeyName, '')
306        return sslClientCert.startswith(
307                                    ApacheSSLAuthnMiddleware.PEM_CERT_PREFIX) 
308       
309    isSSLClientCertSet = property(fget=_isSSLClientCertSet,
310                                  doc="Check for client X.509 certificate "
311                                      "%r setting in environ" %
312                                      SSL_CLIENT_CERT_KEYNAME)
313   
314    def isValidClientCert(self):
315        sslClientCert = self.environ[self.sslClientCertKeyName]
316       
317        # Certificate string passed through a proxy has spaces in place of
318        # newline delimiters.  Fix by re-organising the string into a single
319        # line and remove the BEGIN CERTIFICATE / END CERTIFICATE delimiters.
320        # Then, treat as a base64 encoded string decoding and passing as DER
321        # format to the X.509 parser
322        x509CertPat = re.compile('(\s?-----[A-Z]+\sCERTIFICATE-----\s?)|\s+')
323        cert = x509CertPat.sub('', sslClientCert)
324        derCert = base64.decodestring(cert)
325        self.__clientCert = X509Cert.Parse(derCert, format=X509Cert.formatDER)
326       
327        if len(self.caCertStack) == 0:
328            log.warning("No CA certificates set for Client certificate "
329                        "signature verification")
330        else:
331            try:
332                self.caCertStack.verifyCertChain(
333                                            x509Cert2Verify=self.__clientCert)
334
335            except X509CertError, e:
336                log.info("Client certificate verification failed with %s "
337                         "exception: %s" % (type(e), e))
338                return False
339           
340            except Exception, e:
341                log.error("Client certificate verification failed with "
342                          "unexpected exception type %s: %s" % (type(e), e))
343                return False
344           
345        if len(self.clientCertDNMatchList) > 0:
346            dn = self.__clientCert.dn
347            for expectedDN in self.clientCertDNMatchList: 
348                if dn == expectedDN:
349                    return True
350               
351            return False
352           
353        return True
354
355    def _setUser(self):
356        """Interface hook for a derived class to set user ID from certificate
357        set or other context info.
358        """
359
360
361class AuthKitSSLAuthnMiddleware(ApacheSSLAuthnMiddleware):
362    """Update REMOTE_USER AuthKit environ key with certificate CommonName to
363    flag logged in status to other middleware
364    """
365    SET_USER_ENVIRON_KEYNAME = 'paste.auth_tkt.set_user'
366   
367    @NDGSecurityMiddlewareBase.initCall         
368    def __call__(self, environ, start_response):
369        '''Check for peer certificate in environment and if present carry out
370        authentication.  Overrides parent class behaviour to set REMOTE_USER
371        AuthKit environ key based on the client certificate's Distinguished
372        Name CommonName field.  If no certificate is present or it is
373        present but invalid no 401 response is set.  Instead, it is left to
374        the following middleware in the chain to deal with this.  When used
375        in conjunction with
376        ndg.security.server.wsgi.openid.relyingparty.OpenIDRelyingPartyMiddleware,
377        this will result in the display of the Relying Party interface but with
378        a 401 status set.
379       
380        @type environ: dict
381        @param environ: WSGI environment variables dictionary
382        @type start_response: function
383        @param start_response: standard WSGI start response function
384        '''
385        if not self._pathMatch():
386            log.debug("AuthKitSSLAuthnMiddleware: ignoring path [%s]", 
387                      self.pathInfo)
388
389        elif not self.isSSLRequest:
390            log.debug("AuthKitSSLAuthnMiddleware: 'HTTPS' environment "
391                        "variable not found in environment; ignoring request")
392                       
393        elif not self.isSSLClientCertSet:
394            log.debug("AuthKitSSLAuthnMiddleware: no client certificate set - "
395                      "passing request to next middleware in the chain ...")
396           
397        elif self.isValidClientCert():
398            # Update session cookie with user ID
399            self._setUser()
400           
401        # ... isValidCert will log warnings/errors no need to flag the False
402        # condition
403           
404        # Pass request to next middleware in the chain without setting an
405        # error response - see method doc string for explanation.
406        return self._setResponse()
407
408    def _setUser(self):
409        """Set user ID in AuthKit cookie from client certificate submitted
410        """
411        userId = self.clientCert.dn['CN']
412       
413        self.environ[AuthKitSSLAuthnMiddleware.USERNAME_ENVIRON_KEYNAME]=userId
414        self.environ[AuthKitSSLAuthnMiddleware.SET_USER_ENVIRON_KEYNAME](userId)
Note: See TracBrowser for help on using the repository browser.