source: TI12-security/branches/ndg-security-1.5.x/ndg_security_server/ndg/security/server/wsgi/ssl.py @ 6633

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/ssl.py@6633
Revision 6633, 18.3 KB checked in by pjkersha, 10 years ago (diff)

Merging in changes from 6557

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