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

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

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

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