source: TI12-security/trunk/python/ndg.security.server/ndg/security/server/wsgi/__init__.py @ 5091

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

Fix to logout handling for secured app middleware stack: added LogoutHandlerMiddleware? and placed in top level AuthenticationMiddleware? WSGI. This class wraps logout and OpenID sign in redirects.

Line 
1"""WSGI Middleware components
2
3NERC Data Grid Project"""
4__author__ = "P J Kershaw"
5__date__ = "27/05/08"
6__copyright__ = "(C) 2009 Science and Technology Facilities Council"
7__license__ = "BSD - see LICENSE file in top-level directory"
8__contact__ = "Philip.Kershaw@stfc.ac.uk"
9__revision__ = '$Id$'
10import logging
11log = logging.getLogger(__name__)
12import httplib
13
14class NDGSecurityMiddlewareError(Exception):
15    '''Base exception class for NDG Security middleware'''
16   
17class NDGSecurityMiddlewareConfigError(NDGSecurityMiddlewareError):
18    '''NDG Security Middleware Configuration error'''
19   
20class NDGSecurityMiddlewareBase(object):
21    """Base class for NDG Security Middleware classes"""
22    propertyDefaults = {
23        'mountPath': '/',
24    }
25   
26    def __init__(self, app, app_conf, prefix='', **local_conf):
27        '''
28        @type app: callable following WSGI interface
29        @param app: next middleware application in the chain     
30        @type app_conf: dict       
31        @param app_conf: PasteDeploy global configuration dictionary
32        @type prefix: basestring
33        @param prefix: prefix for app_conf parameters e.g. 'ndgsecurity.' -
34        enables other global configuration parameters to be filtered out
35        @type local_conf: dict       
36        @param local_conf: PasteDeploy application specific configuration
37        dictionary
38        '''
39        self._app = app
40        self._environ = {}
41        self._start_response = None
42        self._pathInfo = None
43        self._path = None
44        self._mountPath = '/'
45       
46        opt = self.__class__.propertyDefaults.copy()
47       
48        # If no prefix is set, there is no way to distinguish options set for
49        # this app and those applying to other applications
50        if app_conf is not None and prefix:
51            # Update from application config dictionary - filter using prefix
52            self.__class__._filterOpts(opt, app_conf, prefix=prefix)
53                       
54        # Similarly, filter keyword input                 
55        self.__class__._filterOpts(opt, local_conf, prefix=prefix)
56       
57        # Set options as object attributes
58        for name, val in opt.items():
59            if not name.startswith('_'):
60                setattr(self, name, val)
61
62    def _initCall(self, environ, start_response):
63        """Call from derived class' __call__() to set environ and path
64        attributes"""
65        self.environ = environ
66        self.start_response = start_response
67        self.setPathInfo()
68        self.setPath()
69
70    @staticmethod
71    def initCall(__call__):
72        '''Decorator to __call__ to enable convenient attribute initialisation
73        '''
74        def __call__wrapper(self, environ, start_response):
75            self._initCall(environ, start_response)
76            return __call__(self, environ, start_response)
77
78        return __call__wrapper
79
80
81    def __call__(self, environ, start_response):
82        self._initCall(environ, start_response)
83        return self._setResponse(environ, start_response)
84   
85    def _setResponse(self, 
86                     environ, 
87                     start_response, 
88                     notFoundMsg=None,
89                     notFoundMsgContentType=None):
90        if self._app:
91            return self._app(environ, start_response)
92        else:
93            return self._setErrorResponse(environ, 
94                                          start_response, 
95                                          msg=notFoundMsg,
96                                          code=404,
97                                          contentType=notFoundMsgContentType)
98           
99    def _setErrorResponse(self, environ=None, start_response=None, msg=None, 
100                          code=500, contentType=None):
101        '''Convenience method to set a simple error response
102       
103        @type environ: dict
104        @param environ: standard WSGI environ parameter
105        @type start_response: builtin_function_or_method
106        @param start_response: standard WSGI callable to set the HTTP header
107        @type msg: basestring
108        @param msg: optional error message
109        @type code: int
110        @param code: standard HTTP error response code
111        @type contentType: basestring
112        @param contentType: set 'Content-type' HTTP header field - defaults to
113        'text/plain'
114        '''
115        if environ is None:
116            environ = self.environ
117           
118        if start_response is None:
119            start_response = self.start_response
120           
121        status = '%d %s' % (code, httplib.responses[code])
122        if msg is None:
123            response = status
124        else:
125            response = msg
126       
127        if contentType is None:
128            contentType = 'text/plain'
129               
130        start_response(status,
131                       [('Content-type', contentType),
132                        ('Content-Length', str(len(response)))])
133        return response
134       
135    @staticmethod
136    def getStatusMessage(statusCode):
137        '''Make a standard status message for use with start_response
138        @type statusCode: int
139        @param statusCode: HTTP status code
140        @rtype: str
141        @return: status code with standard message
142        @raise KeyError: for invalid status code
143        '''
144        return '%d %s' % (statusCode, httplib.responses[statusCode])
145   
146    # Utility functions to support Paste Deploy application and filter function
147    # signatures
148    @classmethod       
149    def filter_app_factory(cls, app, app_conf, **local_conf):
150        '''Function signature for Paste Deploy filter'''
151        return cls(app, app_conf, **local_conf)
152       
153    @classmethod
154    def app_factory(cls, app_conf, **local_conf):
155        '''Function Signature for Paste Deploy app'''
156        return cls(None, app_conf, **local_conf)
157   
158    @classmethod
159    def _filterOpts(cls, opt, newOpt, prefix='', propertyDefaults=None):
160        '''Convenience utility to filter input options set in __init__ via
161        app_conf or keywords
162       
163        @type opt: dict
164        @param opt: existing options set.  These will be updated by this
165        method based on the content of newOpt
166        @type newOpt: dict
167        @param newOpt: new options to update opt with
168        @type prefix: basestring
169        @param prefix: if set, remove the given prefix from the input options
170        @raise KeyError: if an option is set that is not in the classes
171        defOpt class variable
172        '''
173        if propertyDefaults is None:
174            propertyDefaults = cls.propertyDefaults
175           
176        badOpt = []
177        for k,v in newOpt.items():
178            if prefix and k.startswith(prefix):
179                subK = k.replace(prefix, '')                   
180                filtK = '_'.join(subK.split('.')) 
181            else:
182                #filtK = k
183                continue
184                   
185            if filtK not in propertyDefaults:
186                badOpt += [k]               
187            else:
188                opt[filtK] = v
189               
190        if len(badOpt) > 0:
191            raise TypeError("Invalid input option(s) set: %s" % 
192                            (", ".join(badOpt)))
193
194    def setMountPath(self, mountPath=None, environ=None):
195        if mountPath:
196            self._mountPath = mountPath
197        else:
198            if environ is None:
199                environ = self._environ
200           
201            self._mountPath = environ.get('SCRIPT_URL')
202            if self._mountPath is None:
203                raise AttributeError("SCRIPT_URL key not set in environ: "
204                                     "'mountPath' is set to None")
205           
206        if self._mountPath != '/':
207            self._mountPath = self._mountPath.rstrip('/')
208       
209    def _getMountPath(self):
210        return self._mountPath
211   
212    mountPath = property(fget=_getMountPath,
213                        fset=setMountPath,
214                        doc="URL path as assigned to SCRIPT_URL environ key")
215
216    def setPathInfo(self, pathInfo=None, environ=None):
217        if pathInfo:
218            self._pathInfo = pathInfo
219        else:
220            if environ is None:
221                environ = self._environ
222           
223            self._pathInfo = environ['PATH_INFO']
224           
225        if self._pathInfo != '/':
226            self._pathInfo = self._pathInfo.rstrip('/')
227       
228    def _getPathInfo(self):
229        return self._pathInfo
230   
231    pathInfo = property(fget=_getPathInfo,
232                        fset=setPathInfo,
233                        doc="URL path as assigned to PATH_INFO environ key")
234
235
236    def setPath(self, path=None):
237        if path:
238            self._path = path
239        else:
240            self._path = self.mountPath.rstrip('/') + self._pathInfo
241           
242        if self._path != '/':
243            self._path = self._path.rstrip('/')
244       
245    def _getPath(self):
246        return self._path
247   
248    path = property(fget=_getPath,
249                        fset=setPath,
250                        doc="Full URL path minus domain name - equivalent to "
251                            "self.mountPath PATH_INFO environ setting")
252
253    def _setEnviron(self, environ):
254        self._environ = environ
255       
256    def _getEnviron(self):
257        return self._environ
258   
259    environ = property(fget=_getEnviron,
260                       fset=_setEnviron,
261                       doc="Reference to WSGI environ dict")
262   
263    def _setStart_response(self, start_response):
264        self._start_response = start_response
265       
266    def _getStart_response(self):
267        return self._start_response
268   
269    start_response = property(fget=_getStart_response,
270                              fset=_setStart_response,
271                              doc="Reference to WSGI start_response function")
272       
273       
274    def _redirect(self, url, start_response=None):
275        """Do a HTTP 302 redirect
276       
277        @type start_response: callable following WSGI start_response convention
278        @param start_response: WSGI start response callable
279        @type url: basestring
280        @param url: URL to redirect to
281        @rtype: list
282        @return: empty HTML body
283        """
284        if start_response is None:
285            # self.start_response will be None if initCall decorator wasn't
286            # applied to __call__
287            if self.start_response is None:
288                raise NDGSecurityMiddlewareConfigError("No start_response "
289                                                       "function set.")
290            start_response = self.start_response
291           
292        start_response(NDGSecurityMiddlewareBase.getStatusMessage(302), 
293                       [('Content-type', 'text/html'),
294                        ('Content-length', '0'),
295                        ('Location', url)])
296        return []
297   
298   
299class NDGSecurityPathFilter(NDGSecurityMiddlewareBase):
300    """Specialisation of NDG Security Middleware to enable filtering based on
301    PATH_INFO
302   
303    B{This class must be run under Apache mod_wsgi}
304
305    - Apache SSLOptions directive StdEnvVars option must be set
306    """
307    propertyDefaults = {
308        'errorResponseCode': 401,
309        'serverName': None,
310        'pathMatchList': ['/']
311    }
312    propertyDefaults.update(NDGSecurityMiddlewareBase.propertyDefaults)
313   
314    _pathMatch = lambda self: self._pathInfo in self.pathMatchList
315    pathMatch = property(fget=_pathMatch,
316                         doc="Check for input path match to list of paths"
317                             "to which this middleware is to be applied")
318   
319    sslKeyName = 'HTTPS'
320
321    _isSSLRequest = lambda self: self.environ.get(
322                                    NDGSecurityPathFilter.sslKeyName) == '1'
323    isSSLRequest = property(fget=_isSSLRequest,
324                            doc="Is an SSL request boolean "
325                                "- depends on Apache config")
326   
327    def __init__(self, *arg, **kw):
328        '''See NDGSecurityMiddlewareBase for explanation of args
329        @type arg: tuple
330        @param arg: single element contains next middleware application in the
331        chain and app_conf dict     
332        @type kw: dict       
333        @param kw: prefix for app_conf parameters and local_conf dict       
334        '''
335        super(NDGSecurityPathFilter, self).__init__(*arg, **kw)
336       
337    def _getPathMatchList(self):
338        return self._pathMatchList
339   
340    def _setPathMatchList(self, pathList):
341        '''
342        @type pathList: list or tuple
343        @param pathList: list of URL paths to apply this middleware
344        to. Paths are relative to the point at which this middleware is mounted
345        as set in environ['PATH_INFO']
346        '''
347        # TODO: refactor to:
348        # * enable reading of path list from a database or some other
349        # configuration source.
350        # * enable some kind of pattern matching for paths
351       
352        if isinstance(pathList, basestring):
353            # Try parsing a space separated list of file paths
354             self._pathMatchList=[path.strip() for path in pathList.split(',')]
355           
356        elif not isinstance(pathList, (list, tuple)):
357            raise TypeError('Expecting a list or tuple for "pathMatchList"')
358        else:
359            self._pathMatchList = pathList
360           
361    pathMatchList = property(fget=_getPathMatchList,
362                             fset=_setPathMatchList,
363                             doc='List of URL paths to which to apply SSL '
364                                 'client authentication')
365       
366    def _getErrorResponseCode(self):
367        """Error response code getter
368        @rtype: int
369        @return: HTTP error code set by this middleware on client cert.
370        verification error
371        """
372        return self._errorResponseCode
373           
374    def _setErrorResponseCode(self, code):
375        """Error response code setter
376        @type code: int or basestring
377        @param code: error response code set if client cert. verification
378        fails"""
379        if isinstance(code, int):
380            self._errorResponseCode = code
381        elif isinstance(code, basestring):
382            self._errorResponseCode = int(code)
383        else:
384            raise TypeError('Expecting int or string type for '
385                            '"errorResponseCode" attribute')
386           
387        if self._errorResponseCode not in httplib.responses: 
388            raise ValueError("Error response code [%d] is not recognised "
389                             "standard HTTP response code" % 
390                             self._errorResponseCode) 
391           
392    errorResponseCode = property(fget=_getErrorResponseCode,
393                                 fset=_setErrorResponseCode,
394                                 doc="Response code raised if client "
395                                     "certificate verification fails")
Note: See TracBrowser for help on using the repository browser.