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

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

Incomplete - task 16: NDG Security 2.x.x - incl. updated Paster templates

  • Property svn:keywords set to Id
Line 
1"""Module containing:
2 * HTTP Basic Authentication Middleware
3 * middleware to enable redirection to OpenID Relying Party for login
4 * logout middleware for deleting AuthKit cookie and redirecting back to
5   referrer
6 
7NERC DataGrid Project
8"""
9__author__ = "P J Kershaw"
10__date__ = "13/01/09"
11__copyright__ = "(C) 2009 Science and Technology Facilities Council"
12__license__ = "BSD - see LICENSE file in top-level directory"
13__contact__ = "Philip.Kershaw@stfc.ac.uk"
14__revision__ = "$Id$"
15import logging
16log = logging.getLogger(__name__)
17
18import re
19import base64
20import httplib
21import urllib
22from paste.request import construct_url, parse_querystring
23import authkit.authenticate
24from authkit.authenticate.multi import MultiHandler
25
26from ndg.security.server.wsgi import (NDGSecurityMiddlewareBase, 
27                                      NDGSecurityMiddlewareError, 
28                                      NDGSecurityMiddlewareConfigError) 
29from ndg.security.server.wsgi.session import (SessionMiddlewareBase,
30                                              SessionHandlerMiddleware) 
31
32from ndg.security.server.wsgi.ssl import AuthKitSSLAuthnMiddleware
33
34class AuthnException(NDGSecurityMiddlewareError):
35    """Base exception for this module"""
36   
37   
38class HTTPBasicAuthMiddlewareError(AuthnException):
39    """Base exception type for HTTPBasicAuthMiddleware"""
40   
41   
42class HTTPBasicAuthMiddlewareConfigError(NDGSecurityMiddlewareConfigError):
43    """Configuration error with HTTP Basic Auth middleware"""
44
45
46class HTTPBasicAuthUnauthorized(HTTPBasicAuthMiddlewareError): 
47    """Raise from custom authentication interface in order to set HTTP
48    401 Unuathorized response"""
49   
50   
51class HTTPBasicAuthMiddleware(NDGSecurityMiddlewareBase):
52    '''HTTP Basic Authentication Middleware
53    '''
54    AUTHN_FUNC_ENV_KEYNAME = ('ndg.security.server.wsgi.authn.'
55                              'HTTPBasicAuthMiddleware.authenticate')
56    AUTHN_FUNC_ENV_KEYNAME_OPTNAME = 'authnFuncEnvKeyName'       
57    PARAM_PREFIX = 'http.auth.basic.'
58    HTTP_HDR_FIELDNAME = 'basic'
59    FIELD_SEP = ':'
60    AUTHZ_ENV_KEYNAME = 'HTTP_AUTHORIZATION'
61   
62    RE_PATH_MATCH_LIST_OPTNAME = 'rePathMatchList'
63   
64    def __init__(self, app, app_conf, prefix=PARAM_PREFIX, **local_conf):
65        self.__rePathMatchList = None
66        self.__authnFuncEnvironKeyName = None
67       
68        super(HTTPBasicAuthMiddleware, self).__init__(app, app_conf, 
69                                                      **local_conf)
70
71        rePathMatchListOptName = prefix + \
72                            HTTPBasicAuthMiddleware.RE_PATH_MATCH_LIST_OPTNAME
73        rePathMatchListVal = app_conf.pop(rePathMatchListOptName, '')
74       
75        self.rePathMatchList = [re.compile(i) 
76                                for i in rePathMatchListVal.split()]
77
78        paramName = prefix + \
79                    HTTPBasicAuthMiddleware.AUTHN_FUNC_ENV_KEYNAME_OPTNAME
80                   
81        self.authnFuncEnvironKeyName = local_conf.get(paramName,
82                                HTTPBasicAuthMiddleware.AUTHN_FUNC_ENV_KEYNAME)
83
84    def _getAuthnFuncEnvironKeyName(self):
85        return self.__authnFuncEnvironKeyName
86
87    def _setAuthnFuncEnvironKeyName(self, value):
88        if not isinstance(value, basestring):
89            raise TypeError('Expecting string type for '
90                            '"authnFuncEnvironKeyName"; got %r type' % 
91                            type(value))
92        self.__authnFuncEnvironKeyName = value
93
94    authnFuncEnvironKeyName = property(fget=_getAuthnFuncEnvironKeyName, 
95                                       fset=_setAuthnFuncEnvironKeyName, 
96                                       doc="key name in environ for the "
97                                           "custom authentication function "
98                                           "used by this class")
99
100    def _getRePathMatchList(self):
101        return self.__rePathMatchList
102
103    def _setRePathMatchList(self, value):
104        if not isinstance(value, (list, tuple)):
105            raise TypeError('Expecting list or tuple type for '
106                            '"rePathMatchList"; got %r' % type(value))
107       
108        self.__rePathMatchList = value
109
110    rePathMatchList = property(fget=_getRePathMatchList, 
111                               fset=_setRePathMatchList, 
112                               doc="List of regular expressions determine the "
113                                   "URI paths intercepted by this middleware")
114
115    def _pathMatch(self):
116        """Apply a list of regular expression matching patterns to the contents
117        of environ['PATH_INFO'], if any match, return True.  This method is
118        used to determine whether to apply SSL client authentication
119        """
120        path = self.pathInfo
121        for regEx in self.rePathMatchList:
122            if regEx.match(path):
123                return True
124           
125        return False   
126
127    def _parseCredentials(self):
128        """Extract username and password from HTTP_AUTHORIZATION environ key
129       
130        @rtype: tuple
131        @return: username and password.  If the key is not set or the auth
132        method is not basic return a two element tuple with elements both set
133        to None
134        """
135        basicAuthHdr = self.environ.get(
136                                    HTTPBasicAuthMiddleware.AUTHZ_ENV_KEYNAME)
137        if basicAuthHdr is None:
138            log.debug("No %r setting in environ: skipping HTTP Basic Auth",
139                      HTTPBasicAuthMiddleware.AUTHZ_ENV_KEYNAME)
140            return None, None
141                       
142        method, encodedCreds = basicAuthHdr.split(None, 1)
143        if method.lower() != HTTPBasicAuthMiddleware.HTTP_HDR_FIELDNAME:
144            log.debug("Auth method is %r not %r: skipping request",
145                      method, HTTPBasicAuthMiddleware.HTTP_HDR_FIELDNAME)
146            return None, None
147           
148        creds = base64.decodestring(encodedCreds)
149        username, password = creds.split(HTTPBasicAuthMiddleware.FIELD_SEP, 1)
150        return username, password
151
152    @NDGSecurityMiddlewareBase.initCall
153    def __call__(self, environ, start_response):
154        """Authenticate based HTTP header elements as specified by the HTTP
155        Basic Authentication spec."""
156        log.debug("HTTPBasicAuthNMiddleware.__call__ ...")
157       
158        if not self._pathMatch():
159            return self._app(environ, start_response)
160       
161        authenticate = environ.get(self.authnFuncEnvironKeyName)
162        if authenticate is None:
163            # HTTP 500 default is right for this error
164            raise HTTPBasicAuthMiddlewareConfigError("No authentication "
165                                                     "function set in environ")
166           
167        username, password = self._parseCredentials()
168        if username is None:
169            return self._setErrorResponse(code=httplib.UNAUTHORIZED)
170       
171        # Call authentication application
172        try:
173            return authenticate(environ, start_response, username, password)
174       
175        except HTTPBasicAuthUnauthorized, e:
176            log.error(e)
177            return self._setErrorResponse(code=httplib.UNAUTHORIZED)
178        else:
179            return self._app(environ, start_response)
180
181
182# AuthKit based HTTP basic authentication plugin not currently needed but may
183# need resurrecting
184from authkit.permissions import UserIn
185           
186class HTTPBasicAuthentication(object):
187    '''Authkit based HTTP Basic Authentication.   __call__ defines a
188    validation function to fit with the pattern for the AuthKit interface
189    '''
190   
191    def __init__(self):
192        self._userIn = UserIn([])
193       
194    def __call__(self, environ, username, password):
195        """AuthKit HTTP Basic Auth validation function - return True/False"""
196        raise NotImplementedError()
197
198import webob
199
200class AuthenticationEnforcementFilter(object):
201    """Simple filter raises HTTP 401 response code if the requested URI matches
202    a fixed regular expression set in the start-up configuration.  If however,
203    REMOTE_USER is set in environ, the request is passed through to the next
204    middleware or terminating app
205    """
206    REMOTE_USER_ENVVAR_NAME = 'REMOTE_USER'
207    INTERCEPT_URI_PAT_OPTNAME = 'interceptUriPat'
208    DEFAULT_INTERCEPT_URI_PAT = re.compile(".*")
209    RE_PAT_TYPE = type(DEFAULT_INTERCEPT_URI_PAT)
210   
211    __slots__ = ('_app', '__interceptUriPat')
212   
213    def __init__(self, app):
214        """Create attributes, initialising intercept URI to match all incoming
215        requests
216        """
217        self.__interceptUriPat = self.__class__.DEFAULT_INTERCEPT_URI_PAT
218        self._app = app
219       
220    @property
221    def interceptUriPat(self):
222        return self.__interceptUriPat
223   
224    @interceptUriPat.setter
225    def interceptUriPat(self, value):
226        if isinstance(value, basestring):
227            self.__interceptUriPat = re.compile(value)
228           
229        elif isinstance(value, self.__class__.RE_PAT_TYPE):
230            self.__interceptUriPat = value
231           
232        else:
233            raise TypeError('Expecting string or RE pattern type for "'
234                            'RE_PAT_TYPE" attribute')
235   
236    @classmethod
237    def filter_app_factory(cls, app, global_conf, **app_conf):
238        filter = cls(app)
239        if cls.INTERCEPT_URI_PAT_OPTNAME in app_conf:
240            filter.interceptUriPat = app_conf[cls.INTERCEPT_URI_PAT_OPTNAME]
241           
242        return filter
243   
244    def __call__(self, environ, start_response):
245        request = webob.Request(environ)
246        if not self.interceptUriPat.match(request.url):
247            return self._app(environ, start_response)
248       
249        if self.__class__.REMOTE_USER_ENVVAR_NAME in environ:
250            return self._app(environ, start_response)
251        else:
252            response = webob.Response(body="401 Unauthorized", status=401)
253            return response(environ, start_response)
254       
255               
256class AuthnRedirectMiddleware(SessionMiddlewareBase):
257    """Base class for Authentication HTTP redirect initiator and redirect
258    response WSGI middleware
259
260    @type RETURN2URI_ARGNAME: basestring
261    @cvar RETURN2URI_ARGNAME: name of URI query argument used to pass the
262    return to URI between initiator and consumer classes"""
263    RETURN2URI_ARGNAME = 'ndg.security.r'
264
265
266class AuthnRedirectInitiatorMiddleware(AuthnRedirectMiddleware):
267    '''Middleware to initiate a redirect to another URI if a user is not
268    authenticated i.e. security cookie is not set
269   
270    AuthKit.authenticate.middleware must be in place upstream of this
271    middleware.  AuthenticationMiddleware wrapper handles this.
272   
273    @type propertyDefaults: dict
274    @cvar propertyDefaults: valid configuration property keywords   
275    '''
276    propertyDefaults = {
277        'redirectURI': None,
278    }
279    propertyDefaults.update(AuthnRedirectMiddleware.propertyDefaults)
280   
281    TRIGGER_HTTP_STATUS_CODE = '401'
282    MIDDLEWARE_ID = 'AuthnRedirectInitiatorMiddleware'
283
284    def __init__(self, app, global_conf, **app_conf):
285        '''
286        @type app: callable following WSGI interface
287        @param app: next middleware application in the chain     
288        @type global_conf: dict       
289        @param global_conf: PasteDeploy global configuration dictionary
290        @type prefix: basestring
291        @param prefix: prefix for configuration items
292        @type app_conf: dict       
293        @param app_conf: PasteDeploy application specific configuration
294        dictionary
295        '''
296        self.__redirectURI = None
297        super(AuthnRedirectInitiatorMiddleware, self).__init__(app, 
298                                                               global_conf, 
299                                                               **app_conf)
300
301    @NDGSecurityMiddlewareBase.initCall
302    def __call__(self, environ, start_response):
303        '''Invoke redirect if user is not authenticated'''
304       
305        log.debug("AuthnRedirectInitiatorMiddleware.__call__ ...")
306       
307        if self.isAuthenticated:
308            # Call next app in stack
309            return self._app(environ, start_response)       
310        else:
311            # User is not authenticated - Redirect to OpenID Relying Party URI
312            # for user OpenID entry
313            return self._setRedirectResponse()
314   
315    def _setRedirectURI(self, uri):
316        if not isinstance(uri, basestring):
317            raise TypeError("Redirect URI must be set to string type")   
318         
319        self.__redirectURI = uri
320       
321    def _getRedirectURI(self):
322        return self.__redirectURI
323   
324    redirectURI = property(fget=_getRedirectURI,
325                           fset=_setRedirectURI,
326                           doc="URI to redirect to if user is not "
327                               "authenticated")
328
329    def _setRedirectResponse(self):
330        """Construct a redirect response adding in a return to address in a
331        URI query argument
332       
333        @rtype: basestring
334        @return: redirect response
335        """       
336        return2URI = construct_url(self.environ)
337        quotedReturn2URI = urllib.quote(return2URI, safe='')
338        return2URIQueryArg = urllib.urlencode(
339            {AuthnRedirectInitiatorMiddleware.RETURN2URI_ARGNAME: 
340             quotedReturn2URI})
341
342        redirectURI = self.redirectURI
343       
344        if '?' in redirectURI:
345            if redirectURI.endswith('&'):
346                redirectURI += return2URIQueryArg
347            else:
348                redirectURI += '&' + return2URIQueryArg
349        else:
350            if redirectURI.endswith('?'):
351                redirectURI += return2URIQueryArg
352            else:
353                redirectURI += '?' + return2URIQueryArg
354         
355        # Call NDGSecurityMiddlewareBase.redirect utility method     
356        return self.redirect(redirectURI)
357       
358    @classmethod
359    def checker(cls, environ, status, headers):
360        """Set the MultiHandler checker function for triggering this
361        middleware.  In this case, it's a HTTP 401 Unauthorized response
362        detected in the middleware chain
363        """
364        if status.startswith(cls.TRIGGER_HTTP_STATUS_CODE):
365            log.debug("%s.checker caught status [%s]: invoking authentication "
366                      "handler", cls.__name__, cls.TRIGGER_HTTP_STATUS_CODE)
367            return True
368        else:
369            log.debug("%s.checker skipping status [%s]", cls.__name__, status)
370            return False
371
372
373class AuthnRedirectResponseMiddleware(AuthnRedirectMiddleware):
374    """Compliment to AuthnRedirectInitiatorMiddleware
375    functioning as the opposite end of the HTTP redirect interface.  It
376    performs the following tasks:
377    - Detect a redirect URI set in a URI query argument and copy it into
378    a user session object.
379    - Redirect back to the redirect URI once a user is authenticated
380   
381    Also see,
382    ndg.security.server.wsgi.openid.relyingparty.OpenIDRelyingPartyMiddleware
383    which performs a similar function.
384    """   
385    _sslAuthnSucceeded = lambda self: self.environ.get(
386                    AuthKitSSLAuthnMiddleware.AUTHN_SUCCEEDED_ENVIRON_KEYNAME,
387                    False)
388       
389    sslAuthnSucceeded = property(fget=_sslAuthnSucceeded,
390                                 doc="Boolean indicating SSL authentication "
391                                     "has succeeded in "
392                                     "AuthKitSSLAuthnMiddleware upstream of "
393                                     "this middleware")   
394    _sslAuthnSucceeded = lambda self: self.environ.get(
395                    AuthKitSSLAuthnMiddleware.AUTHN_SUCCEEDED_ENVIRON_KEYNAME,
396                    False)
397       
398    sslAuthnSucceeded = property(fget=_sslAuthnSucceeded,
399                                 doc="Boolean indicating SSL authentication "
400                                     "has succeeded in "
401                                     "AuthKitSSLAuthnMiddleware upstream of "
402                                     "this middleware")
403       
404    @NDGSecurityMiddlewareBase.initCall
405    def __call__(self, environ, start_response):
406        session = environ[self.sessionKey]
407       
408        # Check for return to address in URI query args set by
409        # AuthnRedirectInitiatorMiddleware in application code stack
410        if environ['REQUEST_METHOD'] == "GET":
411            params = dict(parse_querystring(environ))
412        else:
413            params = {}
414       
415        # Store the return URI query argument in a beaker session
416        quotedReferrer = params.get(self.__class__.RETURN2URI_ARGNAME, '')
417        referrerURI = urllib.unquote(quotedReferrer)
418        if referrerURI:
419            session[self.__class__.RETURN2URI_ARGNAME] = referrerURI
420            session.save()
421           
422        # Check for a return URI setting in the beaker session and if the user
423        # has just been authenticated by the AuthKit SSL Client authentication
424        # middleware.  If so, redirect to this URL deleting the beaker session
425        # URL setting
426        return2URI = session.get(self.__class__.RETURN2URI_ARGNAME)   
427        if self.sslAuthnSucceeded and return2URI:
428            del session[self.__class__.RETURN2URI_ARGNAME]
429            session.save()
430            return self.redirect(return2URI)
431
432        return self._app(environ, start_response)
433
434
435class AuthKitRedirectResponseMiddleware(AuthnRedirectResponseMiddleware):
436    """Overload isAuthenticated method in parent class to set Authenticated
437    state based on presence of AuthKit 'REMOTE_USER' environ variable
438    """
439    _isAuthenticated = lambda self: \
440        AuthnRedirectResponseMiddleware.USERNAME_ENVIRON_KEYNAME in self.environ
441       
442    isAuthenticated = property(fget=_isAuthenticated,
443                               doc="Boolean indicating if AuthKit "
444                                   "'REMOTE_USER' environment variable is set")
445   
446    def __init__(self, app, app_conf, **local_conf):
447        super(AuthKitRedirectResponseMiddleware, self).__init__(app, app_conf,
448                                                                **local_conf)
449    @NDGSecurityMiddlewareBase.initCall
450    def __call__(self, environ, start_response):
451        return super(AuthKitRedirectResponseMiddleware, self).__call__(environ,
452                                                                start_response)
453
454
455class AuthenticationMiddlewareConfigError(NDGSecurityMiddlewareConfigError):
456    '''Authentication Middleware Configuration error'''
457
458
459class AuthenticationMiddleware(MultiHandler, NDGSecurityMiddlewareBase):
460    '''Top-level class encapsulates session and authentication handlers
461    in this module
462   
463    Handler to intercept 401 Unauthorized HTTP responses and redirect to an
464    authentication URI.  This class also implements a redirect handler to
465    redirect back to the referrer if logout is invoked.
466    '''
467
468    def __init__(self, app, global_conf, prefix='', **app_conf):
469        '''
470        @type app: callable following WSGI interface
471        @param app: next middleware application in the chain     
472        @type global_conf: dict       
473        @param global_conf: PasteDeploy global configuration dictionary
474        @type prefix: basestring
475        @param prefix: prefix for configuration items
476        @type app_conf: dict       
477        @param app_conf: PasteDeploy application specific configuration
478        dictionary
479        '''
480       
481        # Set logout URI parameter from AuthKit settings if not otherwise set
482        sessionHandlerPrefix = prefix + SessionHandlerMiddleware.PARAM_PREFIX       
483        app = SessionHandlerMiddleware(app, 
484                                       global_conf, 
485                                       prefix=sessionHandlerPrefix,
486                                       **app_conf)
487       
488        # Remove session handler middleware specific parameters
489        for k in app_conf.keys():
490            if k.startswith(sessionHandlerPrefix):
491                del app_conf[k]
492       
493        app = authkit.authenticate.middleware(app, app_conf)       
494       
495        MultiHandler.__init__(self, app)
496
497        # Redirection middleware is invoked based on a check method which
498        # catches HTTP 401 responses.
499        self.add_method(AuthnRedirectInitiatorMiddleware.MIDDLEWARE_ID, 
500                        AuthnRedirectInitiatorMiddleware.filter_app_factory, 
501                        global_conf,
502                        prefix=prefix,
503                        **app_conf)
504       
505        self.add_checker(AuthnRedirectInitiatorMiddleware.MIDDLEWARE_ID, 
506                         AuthnRedirectInitiatorMiddleware.checker)
Note: See TracBrowser for help on using the repository browser.