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

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

Fixes for testing OpenID Relying Party running in the application code stack instead of the separate services stack:

  • Removed redirect start_response wrapper from ndg.security.server.wsgi.openid.relyingparty.OpenIDRelyingPartyMiddleware - ndg.security.server.wsgi.authn.SessionHandlerMiddleware? does this job. TODO: this needs checking with the alternate configuration of the Relying Party middleware set-up in the Security Services WSGI stack.
  • Tidied up ndg.security.server.wsgi.authn.SessionHandlerMiddleware? so that it can deployed as a standalone filter in a Paste ini file as required in this use case. It will also be needed for the non-browser SSL based authentication use case.
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        @type propertyDefaults: iterable/None
199        @param propertyDefaults: property names restricted to this dictionary
200        of names.  If None, default to propertyDefaults class variable setting
201        @raise KeyError: if an option is set that is not in the classes
202        defOpt class variable
203        '''
204        if propertyDefaults is None:
205            propertyDefaults = cls.propertyDefaults
206           
207        badOpt = []
208        for k,v in newOpt.items():
209            if prefix and k.startswith(prefix):
210                subK = k.replace(prefix, '')                   
211                filtK = '_'.join(subK.split('.')) 
212            else:
213                # Ignore items that are not prefixed
214                continue
215                   
216            if propertyDefaults is not None and filtK not in propertyDefaults:
217                badOpt += [k]               
218            else:
219                opt[filtK] = v
220               
221        if len(badOpt) > 0:
222            raise TypeError("Invalid input option(s) set: %s" % 
223                            (", ".join(badOpt)))
224
225    def setMountPath(self, mountPath=None, environ=None):
226        if mountPath:
227            self._mountPath = mountPath
228        else:
229            if environ is None:
230                environ = self._environ
231           
232            self._mountPath = environ.get('SCRIPT_URL')
233            if self._mountPath is None:
234                raise AttributeError("SCRIPT_URL key not set in environ: "
235                                     "'mountPath' is set to None")
236           
237        if self._mountPath != '/':
238            self._mountPath = self._mountPath.rstrip('/')
239       
240    def _getMountPath(self):
241        return self._mountPath
242   
243    mountPath = property(fget=_getMountPath,
244                        fset=setMountPath,
245                        doc="URL path as assigned to SCRIPT_URL environ key")
246
247    def setPathInfo(self, pathInfo=None, environ=None):
248        if pathInfo:
249            self._pathInfo = pathInfo
250        else:
251            if environ is None:
252                environ = self._environ
253           
254            self._pathInfo = environ['PATH_INFO']
255           
256        if self._pathInfo != '/':
257            self._pathInfo = self._pathInfo.rstrip('/')
258       
259    def _getPathInfo(self):
260        return self._pathInfo
261   
262    pathInfo = property(fget=_getPathInfo,
263                        fset=setPathInfo,
264                        doc="URL path as assigned to PATH_INFO environ key")
265
266
267    def setPath(self, path=None):
268        if path:
269            self._path = path
270        else:
271            self._path = self.mountPath.rstrip('/') + self._pathInfo
272           
273        if self._path != '/':
274            self._path = self._path.rstrip('/')
275       
276    def _getPath(self):
277        return self._path
278   
279    path = property(fget=_getPath,
280                        fset=setPath,
281                        doc="Full URL path minus domain name - equivalent to "
282                            "self.mountPath PATH_INFO environ setting")
283
284    def _setEnviron(self, environ):
285        self._environ = environ
286       
287    def _getEnviron(self):
288        return self._environ
289   
290    environ = property(fget=_getEnviron,
291                       fset=_setEnviron,
292                       doc="Reference to WSGI environ dict")
293   
294    def _setStart_response(self, start_response):
295        self._start_response = start_response
296       
297    def _getStart_response(self):
298        return self._start_response
299   
300    start_response = property(fget=_getStart_response,
301                              fset=_setStart_response,
302                              doc="Reference to WSGI start_response function")
303       
304       
305    def redirect(self, url, start_response=None):
306        """Do a HTTP 302 redirect
307       
308        @type start_response: callable following WSGI start_response convention
309        @param start_response: WSGI start response callable
310        @type url: basestring
311        @param url: URL to redirect to
312        @rtype: list
313        @return: empty HTML body
314        """
315        if start_response is None:
316            # self.start_response will be None if initCall decorator wasn't
317            # applied to __call__
318            if self.start_response is None:
319                raise NDGSecurityMiddlewareConfigError("No start_response "
320                                                       "function set.")
321            start_response = self.start_response
322           
323        start_response(NDGSecurityMiddlewareBase.getStatusMessage(302), 
324                       [('Content-type', 'text/html'),
325                        ('Content-length', '0'),
326                        ('Location', url)])
327        return []
328
329    @staticmethod
330    def parseListItem(item):
331        """Utility method for parsing a space separate list of items in a
332        string.  Items may be quoted.  This method is useful for parsing items
333        assigned to a parameter in a config file e.g.
334        fileList: "a.txt" b.csv 'My File'
335        @type item: basestring
336        @param item: list of space separated items in a string.  These may be
337        quoted
338        """
339        return [i.strip("\"'") for i in item.split()] 
340   
341class NDGSecurityPathFilter(NDGSecurityMiddlewareBase):
342    """Specialisation of NDG Security Middleware to enable filtering based on
343    PATH_INFO
344   
345    B{This class must be run under Apache mod_wsgi}
346
347    - Apache SSLOptions directive StdEnvVars option must be set
348    """
349    propertyDefaults = {
350        'errorResponseCode': 401,
351        'serverName': None,
352        'pathMatchList': ['/']
353    }
354    propertyDefaults.update(NDGSecurityMiddlewareBase.propertyDefaults)
355   
356    _pathMatch = lambda self: self._pathInfo in self.pathMatchList
357    pathMatch = property(fget=_pathMatch,
358                         doc="Check for input path match to list of paths"
359                             "to which this middleware is to be applied")
360   
361    sslKeyName = 'HTTPS'
362
363    _isSSLRequest = lambda self: self.environ.get(
364                                    NDGSecurityPathFilter.sslKeyName) == '1'
365    isSSLRequest = property(fget=_isSSLRequest,
366                            doc="Is an SSL request boolean "
367                                "- depends on Apache config")
368   
369    def __init__(self, *arg, **kw):
370        '''See NDGSecurityMiddlewareBase for explanation of args
371        @type arg: tuple
372        @param arg: single element contains next middleware application in the
373        chain and app_conf dict     
374        @type kw: dict       
375        @param kw: prefix for app_conf parameters and local_conf dict       
376        '''
377        super(NDGSecurityPathFilter, self).__init__(*arg, **kw)
378       
379    def _getPathMatchList(self):
380        return self._pathMatchList
381   
382    def _setPathMatchList(self, pathList):
383        '''
384        @type pathList: list or tuple
385        @param pathList: list of URL paths to apply this middleware
386        to. Paths are relative to the point at which this middleware is mounted
387        as set in environ['PATH_INFO']
388        '''
389        # TODO: refactor to:
390        # * enable reading of path list from a database or some other
391        # configuration source.
392        # * enable some kind of pattern matching for paths
393       
394        if isinstance(pathList, basestring):
395            # Try parsing a space separated list of file paths
396             self._pathMatchList=[path.strip() for path in pathList.split(',')]
397           
398        elif not isinstance(pathList, (list, tuple)):
399            raise TypeError('Expecting a list or tuple for "pathMatchList"')
400        else:
401            self._pathMatchList = pathList
402           
403    pathMatchList = property(fget=_getPathMatchList,
404                             fset=_setPathMatchList,
405                             doc='List of URL paths to which to apply SSL '
406                                 'client authentication')
407       
408    def _getErrorResponseCode(self):
409        """Error response code getter
410        @rtype: int
411        @return: HTTP error code set by this middleware on client cert.
412        verification error
413        """
414        return self._errorResponseCode
415           
416    def _setErrorResponseCode(self, code):
417        """Error response code setter
418        @type code: int or basestring
419        @param code: error response code set if client cert. verification
420        fails"""
421        if isinstance(code, int):
422            self._errorResponseCode = code
423        elif isinstance(code, basestring):
424            self._errorResponseCode = int(code)
425        else:
426            raise TypeError('Expecting int or string type for '
427                            '"errorResponseCode" attribute')
428           
429        if self._errorResponseCode not in httplib.responses: 
430            raise ValueError("Error response code [%d] is not recognised "
431                             "standard HTTP response code" % 
432                             self._errorResponseCode) 
433           
434    errorResponseCode = property(fget=_getErrorResponseCode,
435                                 fset=_setErrorResponseCode,
436                                 doc="Response code raised if client "
437                                     "certificate verification fails")
Note: See TracBrowser for help on using the repository browser.