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

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