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

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

Fixes for integration testing with OAI editor:

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