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

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

Adding SSL Client authentication step into authz_lite integration test. Broken redirecting back from authn step to requested resource.

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