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

Subversion URL: http://proj.badc.rl.ac.uk/svn/ndg/TI12-security/trunk/NDGSecurity/python/ndg_security_server/ndg/security/server/wsgi/authn.py@6440
Revision 6440, 17.7 KB checked in by pjkersha, 10 years ago (diff)
  • #1088 Important fix to AuthnRedirectResponseMiddleware? to set redirect ONLY when SSL client authentication has just succeeded in the upstream middleware AuthKitSSLAuthnMiddleware. This bug was causing the browser to redirect to the wrong place following OpenID sign in in the case where the user is already logged into their provider and selects a new relying party to sign into.
    • Improvements to Provider decide page interface: leave out messages about attributes that the provider can't retrieve for the RP. Also included NDG style help icon.
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
198       
199class AuthnRedirectMiddleware(SessionMiddlewareBase):
200    """Base class for Authentication HTTP redirect initiator and redirect
201    response WSGI middleware
202
203    @type RETURN2URI_ARGNAME: basestring
204    @cvar RETURN2URI_ARGNAME: name of URI query argument used to pass the
205    return to URI between initiator and consumer classes"""
206    RETURN2URI_ARGNAME = 'ndg.security.r'
207
208
209class AuthnRedirectInitiatorMiddleware(AuthnRedirectMiddleware):
210    '''Middleware to initiate a redirect to another URI if a user is not
211    authenticated i.e. security cookie is not set
212   
213    AuthKit.authenticate.middleware must be in place upstream of this
214    middleware.  AuthenticationMiddleware wrapper handles this.
215   
216    @type propertyDefaults: dict
217    @cvar propertyDefaults: valid configuration property keywords   
218    '''
219    propertyDefaults = {
220        'redirectURI': None,
221    }
222    propertyDefaults.update(AuthnRedirectMiddleware.propertyDefaults)
223   
224    TRIGGER_HTTP_STATUS_CODE = '401'
225    MIDDLEWARE_ID = 'AuthnRedirectInitiatorMiddleware'
226
227    def __init__(self, app, global_conf, **app_conf):
228        '''
229        @type app: callable following WSGI interface
230        @param app: next middleware application in the chain     
231        @type global_conf: dict       
232        @param global_conf: PasteDeploy global configuration dictionary
233        @type prefix: basestring
234        @param prefix: prefix for configuration items
235        @type app_conf: dict       
236        @param app_conf: PasteDeploy application specific configuration
237        dictionary
238        '''
239        self.__redirectURI = None
240        super(AuthnRedirectInitiatorMiddleware, self).__init__(app, 
241                                                               global_conf, 
242                                                               **app_conf)
243
244    @NDGSecurityMiddlewareBase.initCall
245    def __call__(self, environ, start_response):
246        '''Invoke redirect if user is not authenticated'''
247       
248        log.debug("AuthnRedirectInitiatorMiddleware.__call__ ...")
249       
250        if self.isAuthenticated:
251            # Call next app in stack
252            return self._app(environ, start_response)       
253        else:
254            # User is not authenticated - Redirect to OpenID Relying Party URI
255            # for user OpenID entry
256            return self._setRedirectResponse()
257   
258    def _setRedirectURI(self, uri):
259        if not isinstance(uri, basestring):
260            raise TypeError("Redirect URI must be set to string type")   
261         
262        self.__redirectURI = uri
263       
264    def _getRedirectURI(self):
265        return self.__redirectURI
266   
267    redirectURI = property(fget=_getRedirectURI,
268                           fset=_setRedirectURI,
269                           doc="URI to redirect to if user is not "
270                               "authenticated")
271
272    def _setRedirectResponse(self):
273        """Construct a redirect response adding in a return to address in a
274        URI query argument
275       
276        @rtype: basestring
277        @return: redirect response
278        """       
279        return2URI = construct_url(self.environ)
280        quotedReturn2URI = urllib.quote(return2URI, safe='')
281        return2URIQueryArg = urllib.urlencode(
282            {AuthnRedirectInitiatorMiddleware.RETURN2URI_ARGNAME: 
283             quotedReturn2URI})
284
285        redirectURI = self.redirectURI
286       
287        if '?' in redirectURI:
288            if redirectURI.endswith('&'):
289                redirectURI += return2URIQueryArg
290            else:
291                redirectURI += '&' + return2URIQueryArg
292        else:
293            if redirectURI.endswith('?'):
294                redirectURI += return2URIQueryArg
295            else:
296                redirectURI += '?' + return2URIQueryArg
297         
298        # Call NDGSecurityMiddlewareBase.redirect utility method     
299        return self.redirect(redirectURI)
300       
301    @classmethod
302    def checker(cls, environ, status, headers):
303        """Set the MultiHandler checker function for triggering this
304        middleware.  In this case, it's a HTTP 401 Unauthorized response
305        detected in the middleware chain
306        """
307        if status.startswith(cls.TRIGGER_HTTP_STATUS_CODE):
308            log.debug("%s.checker caught status [%s]: invoking authentication "
309                      "handler", cls.__name__, cls.TRIGGER_HTTP_STATUS_CODE)
310            return True
311        else:
312            log.debug("%s.checker skipping status [%s]", cls.__name__, status)
313            return False
314
315
316class AuthnRedirectResponseMiddleware(AuthnRedirectMiddleware):
317    """Compliment to AuthnRedirectInitiatorMiddleware
318    functioning as the opposite end of the HTTP redirect interface.  It
319    performs the following tasks:
320    - Detect a redirect URI set in a URI query argument and copy it into
321    a user session object.
322    - Redirect back to the redirect URI once a user is authenticated
323   
324    Also see,
325    ndg.security.server.wsgi.openid.relyingparty.OpenIDRelyingPartyMiddleware
326    which performs a similar function.
327    """
328   
329    @NDGSecurityMiddlewareBase.initCall
330    def __call__(self, environ, start_response):
331        session = environ[self.sessionKey]
332       
333        # Check for return to address in URI query args set by
334        # AuthnRedirectInitiatorMiddleware in application code stack
335        if environ['REQUEST_METHOD'] == "GET":
336            params = dict(parse_querystring(environ))
337        else:
338            params = {}
339       
340        # Store the return URI query argument in a beaker session
341        quotedReferrer = params.get(self.__class__.RETURN2URI_ARGNAME, '')
342        referrerURI = urllib.unquote(quotedReferrer)
343        if referrerURI:
344            session[self.__class__.RETURN2URI_ARGNAME] = referrerURI
345            session.save()
346           
347        # Check for a return URI setting in the beaker session and if the user
348        # has just been authenticated by the AuthKit SSL Client authentication
349        # middleware.  If so, redirect to this URL deleting the beaker session
350        # URL setting
351        return2URI = session.get(self.__class__.RETURN2URI_ARGNAME)   
352        if self.sslAuthnSucceeded and return2URI:
353            del session[self.__class__.RETURN2URI_ARGNAME]
354            session.save()
355            return self.redirect(return2URI)
356
357        return self._app(environ, start_response)
358
359
360class AuthKitRedirectResponseMiddleware(AuthnRedirectResponseMiddleware):
361    """Overload isAuthenticated method in parent class to set Authenticated
362    state based on presence of AuthKit 'REMOTE_USER' environ variable
363    """
364    _isAuthenticated = lambda self: \
365        AuthnRedirectResponseMiddleware.USERNAME_ENVIRON_KEYNAME in self.environ
366       
367    isAuthenticated = property(fget=_isAuthenticated,
368                               doc="Boolean indicating if AuthKit "
369                                   "'REMOTE_USER' environment variable is set")
370   
371    _sslAuthnSucceeded = lambda self: self.environ.get(
372                    AuthKitSSLAuthnMiddleware.AUTHN_SUCCEEDED_ENVIRON_KEYNAME,
373                    False)
374       
375    sslAuthnSucceeded = property(fget=_sslAuthnSucceeded,
376                                 doc="Boolean indicating SSL authentication "
377                                     "has succeeded in "
378                                     "AuthKitSSLAuthnMiddleware upstream of "
379                                     "this middleware")
380   
381    def __init__(self, app, app_conf, **local_conf):
382        super(AuthKitRedirectResponseMiddleware, self).__init__(app, app_conf,
383                                                                **local_conf)
384    @NDGSecurityMiddlewareBase.initCall
385    def __call__(self, environ, start_response):
386        return super(AuthKitRedirectResponseMiddleware, self).__call__(environ,
387                                                                start_response)
388
389
390class AuthenticationMiddlewareConfigError(NDGSecurityMiddlewareConfigError):
391    '''Authentication Middleware Configuration error'''
392
393
394class AuthenticationMiddleware(MultiHandler, NDGSecurityMiddlewareBase):
395    '''Top-level class encapsulates session and authentication handlers
396    in this module
397   
398    Handler to intercept 401 Unauthorized HTTP responses and redirect to an
399    authentication URI.  This class also implements a redirect handler to
400    redirect back to the referrer if logout is invoked.
401    '''
402
403    def __init__(self, app, global_conf, prefix='', **app_conf):
404        '''
405        @type app: callable following WSGI interface
406        @param app: next middleware application in the chain     
407        @type global_conf: dict       
408        @param global_conf: PasteDeploy global configuration dictionary
409        @type prefix: basestring
410        @param prefix: prefix for configuration items
411        @type app_conf: dict       
412        @param app_conf: PasteDeploy application specific configuration
413        dictionary
414        '''
415       
416        # Set logout URI parameter from AuthKit settings if not otherwise set
417        sessionHandlerPrefix = prefix + SessionHandlerMiddleware.PARAM_PREFIX       
418        app = SessionHandlerMiddleware(app, 
419                                       global_conf, 
420                                       prefix=sessionHandlerPrefix,
421                                       **app_conf)
422       
423        # Remove session handler middleware specific parameters
424        for k in app_conf.keys():
425            if k.startswith(sessionHandlerPrefix):
426                del app_conf[k]
427       
428        app = authkit.authenticate.middleware(app, app_conf)       
429       
430        MultiHandler.__init__(self, app)
431
432        # Redirection middleware is invoked based on a check method which
433        # catches HTTP 401 responses.
434        self.add_method(AuthnRedirectInitiatorMiddleware.MIDDLEWARE_ID, 
435                        AuthnRedirectInitiatorMiddleware.filter_app_factory, 
436                        global_conf,
437                        prefix=prefix,
438                        **app_conf)
439       
440        self.add_checker(AuthnRedirectInitiatorMiddleware.MIDDLEWARE_ID, 
441                         AuthnRedirectInitiatorMiddleware.checker)
Note: See TracBrowser for help on using the repository browser.