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

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/TI12-security/trunk/NDGSecurity/python/ndg_security_server/ndg/security/server/wsgi/__init__.py@7077
Revision 7077, 17.0 KB checked in by pjkersha, 9 years ago (diff)
  • Property svn:keywords set to Id
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        # Comment out as it breaks standard for URL trailing slash
259#        if self._mountPath != '/':
260#            self._mountPath = self._mountPath.rstrip('/')
261       
262    def _getMountPath(self):
263        return self._mountPath
264   
265    mountPath = property(fget=_getMountPath,
266                        fset=setMountPath,
267                        doc="URL path as assigned to SCRIPT_URL environ key")
268
269    def setPathInfo(self, pathInfo=None, environ=None):
270        if pathInfo:
271            self._pathInfo = pathInfo
272        else:
273            if environ is None:
274                environ = self._environ
275           
276            self._pathInfo = environ['PATH_INFO']
277           
278        if self._pathInfo != '/':
279            self._pathInfo = self._pathInfo.rstrip('/')
280       
281    def _getPathInfo(self):
282        return self._pathInfo
283   
284    pathInfo = property(fget=_getPathInfo,
285                        fset=setPathInfo,
286                        doc="URL path as assigned to PATH_INFO environ key")
287
288
289    def setPath(self, path=None):
290        if path:
291            self._path = path
292        else:
293            self._path = self.mountPath.rstrip('/') + self._pathInfo
294           
295        if self._path != '/':
296            self._path = self._path.rstrip('/')
297       
298    def _getPath(self):
299        return self._path
300   
301    path = property(fget=_getPath,
302                        fset=setPath,
303                        doc="Full URL path minus domain name - equivalent to "
304                            "self.mountPath PATH_INFO environ setting")
305
306    def _setEnviron(self, environ):
307        self._environ = environ
308       
309    def _getEnviron(self):
310        return self._environ
311   
312    environ = property(fget=_getEnviron,
313                       fset=_setEnviron,
314                       doc="Reference to WSGI environ dict")
315   
316    def _setStart_response(self, start_response):
317        self._start_response = start_response
318       
319    def _getStart_response(self):
320        return self._start_response
321   
322    start_response = property(fget=_getStart_response,
323                              fset=_setStart_response,
324                              doc="Reference to WSGI start_response function")
325       
326       
327    def redirect(self, url, start_response=None):
328        """Do a HTTP 302 redirect
329       
330        @type start_response: callable following WSGI start_response convention
331        @param start_response: WSGI start response callable
332        @type url: basestring
333        @param url: URL to redirect to
334        @rtype: list
335        @return: empty HTML body
336        """
337        if start_response is None:
338            # self.start_response will be None if initCall decorator wasn't
339            # applied to __call__
340            if self.start_response is None:
341                raise NDGSecurityMiddlewareConfigError("No start_response "
342                                                       "function set.")
343            start_response = self.start_response
344           
345        start_response(NDGSecurityMiddlewareBase.getStatusMessage(302), 
346                       [('Content-type', 'text/html'),
347                        ('Content-length', '0'),
348                        ('Location', url)])
349        return []
350
351    @staticmethod
352    def parseListItem(item):
353        """Utility method for parsing a space separate list of items in a
354        string.  Items may be quoted.  This method is useful for parsing items
355        assigned to a parameter in a config file e.g.
356        fileList: "a.txt" b.csv 'My File'
357        @type item: basestring
358        @param item: list of space separated items in a string.  These may be
359        quoted
360        """
361        return [i.strip("\"'") for i in item.split()] 
362
363   
364class NDGSecurityPathFilter(NDGSecurityMiddlewareBase):
365    """Specialisation of NDG Security Middleware to enable filtering based on
366    PATH_INFO
367    """
368    propertyDefaults = {
369        'errorResponseCode': 401,
370        'serverName': None,
371        'pathMatchList': ['/']
372    }
373    propertyDefaults.update(NDGSecurityMiddlewareBase.propertyDefaults)
374   
375    CSV_PAT = re.compile(',\s*')
376   
377    # TODO: refactor to:
378    # * enable reading of path list from a database or some other
379    # configuration source.
380    # * enable some kind of pattern matching for paths   
381    _pathMatch = lambda self: self._pathInfo in self.pathMatchList
382    pathMatch = property(fget=_pathMatch,
383                         doc="Check for input path match to list of paths"
384                             "to which this middleware is to be applied")
385
386    def __init__(self, *arg, **kw):
387        '''See NDGSecurityMiddlewareBase for explanation of args
388        @type arg: tuple
389        @param arg: single element contains next middleware application in the
390        chain and app_conf dict     
391        @type kw: dict       
392        @param kw: prefix for app_conf parameters and local_conf dict       
393        '''
394        super(NDGSecurityPathFilter, self).__init__(*arg, **kw)
395       
396    def _getPathMatchList(self):
397        return self.__pathMatchList
398   
399    def _setPathMatchList(self, pathList):
400        '''
401        @type pathList: list or tuple
402        @param pathList: list of URL paths to apply this middleware
403        to. Paths are relative to the point at which this middleware is mounted
404        as set in environ['PATH_INFO']
405        '''
406       
407        if isinstance(pathList, basestring):
408            # Try parsing a space separated list of file paths
409             self.__pathMatchList=NDGSecurityPathFilter.CSV_PAT.split(pathList)
410           
411        elif not isinstance(pathList, (list, tuple)):
412            raise TypeError('Expecting a list or tuple for "pathMatchList"')
413        else:
414            self.__pathMatchList = list(pathList)
415           
416    pathMatchList = property(fget=_getPathMatchList,
417                             fset=_setPathMatchList,
418                             doc='List of URL paths to which to apply SSL '
419                                 'client authentication')
420       
421    def _getErrorResponseCode(self):
422        """Error response code getter
423        @rtype: int
424        @return: HTTP error code set by this middleware on client cert.
425        verification error
426        """
427        return self._errorResponseCode
428           
429    def _setErrorResponseCode(self, code):
430        """Error response code setter
431        @type code: int or basestring
432        @param code: error response code set if client cert. verification
433        fails"""
434        if isinstance(code, int):
435            self._errorResponseCode = code
436        elif isinstance(code, basestring):
437            self._errorResponseCode = int(code)
438        else:
439            raise TypeError('Expecting int or string type for '
440                            '"errorResponseCode" attribute')
441           
442        if self._errorResponseCode not in httplib.responses: 
443            raise ValueError("Error response code [%d] is not recognised "
444                             "standard HTTP response code" % 
445                             self._errorResponseCode) 
446           
447    errorResponseCode = property(fget=_getErrorResponseCode,
448                                 fset=_setErrorResponseCode,
449                                 doc="Response code raised if client "
450                                     "certificate verification fails")
Note: See TracBrowser for help on using the repository browser.