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

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

Initial code for a HTTPS WSGI interface to front a MyProxy? service.

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 urllib
19from urlparse import urlsplit
20from paste.request import construct_url
21from paste.request import parse_querystring
22from beaker.middleware import SessionMiddleware
23import authkit.authenticate
24
25from ndg.security.server.wsgi import NDGSecurityMiddlewareBase, \
26    NDGSecurityMiddlewareConfigError       
27
28
29class SessionMiddlewareBase(NDGSecurityMiddlewareBase):
30    """Base class for Authentication redirect middleware and Session Handler
31    middleware
32   
33    @type propertyDefaults: dict
34    @cvar propertyDefaults: valid configuration property keywords
35    """   
36    propertyDefaults = {
37        'sessionKey': 'beaker.session.ndg.security'
38    }
39   
40    _isAuthenticated = lambda self: \
41        SessionMiddlewareBase.USERNAME_SESSION_KEYNAME in \
42        self.environ.get(self.sessionKey, ())
43       
44    isAuthenticated = property(fget=_isAuthenticated,
45                               doc='boolean to indicate is user logged in')
46
47       
48class AuthnRedirectMiddleware(SessionMiddlewareBase):
49    """Base class for Authentication HTTP redirect initiator and redirect
50    response WSGI middleware
51
52    @type RETURN2URI_ARGNAME: basestring
53    @cvar RETURN2URI_ARGNAME: name of URI query argument used to pass the
54    return to URI between initiator and consumer classes"""
55    RETURN2URI_ARGNAME = 'ndg.security.r'
56
57
58class AuthnRedirectInitiatorMiddleware(AuthnRedirectMiddleware):
59    '''Middleware to initiate a redirect to another URI if a user is not
60    authenticated i.e. security cookie is not set
61   
62    AuthKit.authenticate.middleware must be in place upstream of this
63    middleware.  AuthenticationMiddleware wrapper handles this.
64   
65    @type propertyDefaults: dict
66    @cvar propertyDefaults: valid configuration property keywords   
67    '''
68    propertyDefaults = {
69        'redirectURI': None,
70    }
71    propertyDefaults.update(AuthnRedirectMiddleware.propertyDefaults)
72   
73
74    TRIGGER_HTTP_STATUS_CODE = '401'
75    MIDDLEWARE_ID = 'AuthnRedirectInitiatorMiddleware'
76
77    def __init__(self, app, global_conf, **app_conf):
78        '''
79        @type app: callable following WSGI interface
80        @param app: next middleware application in the chain     
81        @type global_conf: dict       
82        @param global_conf: PasteDeploy global configuration dictionary
83        @type prefix: basestring
84        @param prefix: prefix for configuration items
85        @type app_conf: dict       
86        @param app_conf: PasteDeploy application specific configuration
87        dictionary
88        '''
89        self._redirectURI = None
90        super(AuthnRedirectInitiatorMiddleware, self).__init__(app, 
91                                                               global_conf, 
92                                                               **app_conf)
93       
94    @NDGSecurityMiddlewareBase.initCall
95    def __call__(self, environ, start_response):
96        '''Invoke redirect if user is not authenticated'''
97       
98        log.debug("AuthnRedirectInitiatorMiddleware.__call__ ...")
99       
100        if self.isAuthenticated:
101            # Call next app in stack
102            return self._app(environ, start_response)       
103        else:
104            # User is not authenticated - Redirect to OpenID Relying Party URI
105            # for user OpenID entry
106            return self._setRedirectResponse()
107   
108    def _setRedirectURI(self, uri):
109        if not isinstance(uri, basestring):
110            raise TypeError("Redirect URI must be set to string type")   
111         
112        self._redirectURI = uri
113       
114    def _getRedirectURI(self):
115        return self._redirectURI
116   
117    redirectURI = property(fget=_getRedirectURI,
118                       fset=_setRedirectURI,
119                       doc="URI to redirect to if user is not authenticated")
120
121    def _setRedirectResponse(self):
122        """Construct a redirect response adding in a return to address in a
123        URI query argument
124       
125        @rtype: basestring
126        @return: redirect response
127        """       
128        return2URI = construct_url(self.environ)
129        quotedReturn2URI = urllib.quote(return2URI, safe='')
130        return2URIQueryArg = urllib.urlencode(
131                    {AuthnRedirectInitiatorMiddleware.RETURN2URI_ARGNAME: 
132                     quotedReturn2URI})
133
134        redirectURI = self.redirectURI
135       
136        if '?' in redirectURI:
137            if redirectURI.endswith('&'):
138                redirectURI += return2URIQueryArg
139            else:
140                redirectURI += '&' + return2URIQueryArg
141        else:
142            if redirectURI.endswith('?'):
143                redirectURI += return2URIQueryArg
144            else:
145                redirectURI += '?' + return2URIQueryArg
146         
147        # Call NDGSecurityMiddlewareBase.redirect utility method     
148        return self.redirect(redirectURI)
149       
150    @classmethod
151    def checker(cls, environ, status, headers):
152        """Set the MultiHandler checker function for triggering this
153        middleware.  In this case, it's a HTTP 401 Unauthorized response
154        detected in the middleware chain
155        """
156        if status.startswith(cls.TRIGGER_HTTP_STATUS_CODE):
157            log.debug("%s.checker caught status [%s]: invoking authentication"
158                      " handler", cls.__name__, cls.TRIGGER_HTTP_STATUS_CODE)
159            return True
160        else:
161            log.debug("%s.checker skipping status [%s]", cls.__name__, status)
162            return False
163
164
165class AuthnRedirectResponseMiddleware(AuthnRedirectMiddleware):
166    """Compliment to AuthnRedirectInitiatorMiddleware
167    functioning as the opposite end of the HTTP redirect interface.  It
168    performs the following tasks:
169    - Detect a redirect URI set in a URI query argument and copy it into
170    a user session object.
171    - Redirect back to the redirect URI once a user is authenticated
172   
173    Also see,
174    ndg.security.server.wsgi.openid.relyingparty.OpenIDRelyingPartyMiddleware
175    which performs a similar function.
176    """
177    @NDGSecurityMiddlewareBase.initCall
178    def __call__(self, environ, start_response):
179        session = environ[self.sessionKey]
180       
181        # Check for return to address in URI query args set by
182        # AuthnRedirectInitiatorMiddleware in application code stack
183        if environ['REQUEST_METHOD'] == "GET":
184            params = dict(parse_querystring(environ))
185        else:
186            params = {}
187       
188        # Store the return URI query argument in a beaker session
189        quotedReferrer = params.get(self.__class__.RETURN2URI_ARGNAME, '')
190        referrerURI = urllib.unquote(quotedReferrer)
191        if referrerURI:
192            session[self.__class__.RETURN2URI_ARGNAME] = referrerURI
193            session.save()
194           
195        # Check for a return URI setting in the beaker session and if the user
196        # is authenticated, redirect to this URL deleting the beaker session
197        # URL setting
198        return2URI = session.get(self.__class__.RETURN2URI_ARGNAME)   
199        if self.isAuthenticated and return2URI:
200            del session[self.__class__.RETURN2URI_ARGNAME]
201            session.save()
202            return self.redirect(return2URI)
203
204        return self._app(environ, start_response)
205
206
207class AuthKitRedirectResponseMiddleware(AuthnRedirectResponseMiddleware):
208    """Overload isAuthenticated method in parent class to set Authenticated
209    state based on presence of AuthKit 'REMOTE_USER' environ variable
210    """
211    _isAuthenticated = lambda self: \
212        AuthnRedirectResponseMiddleware.USERNAME_ENVIRON_KEYNAME in self.environ
213       
214    isAuthenticated = property(fget=_isAuthenticated,
215                               doc="Boolean indicating if AuthKit "
216                                   "'REMOTE_USER' environment variable is set")
217    def __init__(self, app, app_conf, **local_conf):
218        super(AuthKitRedirectResponseMiddleware, self).__init__(app, app_conf,
219                                                                **local_conf)
220    @NDGSecurityMiddlewareBase.initCall
221    def __call__(self, environ, start_response):
222        return super(AuthKitRedirectResponseMiddleware, self).__call__(environ,
223                                                                start_response)
224       
225
226class SessionHandlerMiddlewareError(Exception):
227    """Base exception for SessionHandlerMiddleware"""
228           
229class SessionHandlerMiddlewareConfigError(SessionHandlerMiddlewareError):
230    """Configuration errors from SessionHandlerMiddleware"""
231   
232class OpenIdAXConfigError(SessionHandlerMiddlewareError):
233    """Error parsing OpenID Ax (Attribute Exchange) parameters"""
234   
235   
236class SessionHandlerMiddleware(SessionMiddlewareBase):
237    '''Middleware to:
238    - establish user session details following redirect from OpenID Relying
239    Party sign-in or SSL Client authentication
240    - end session redirecting back to referrer URI following call to a logout
241    URI as implemented in AuthKit
242    '''
243    AX_SESSION_KEYNAME = 'openid.ax'
244    SM_URI_SESSION_KEYNAME = 'sessionManagerURI'
245    ID_SESSION_KEYNAME = 'sessionId'
246    PEP_CTX_SESSION_KEYNAME = 'pepCtx'
247    CREDENTIAL_WALLET_SESSION_KEYNAME = 'credentialWallet'
248   
249    SESSION_KEYNAMES = (
250        SessionMiddlewareBase.USERNAME_SESSION_KEYNAME, 
251        SM_URI_SESSION_KEYNAME, 
252        ID_SESSION_KEYNAME, 
253        PEP_CTX_SESSION_KEYNAME, 
254        CREDENTIAL_WALLET_SESSION_KEYNAME
255    )
256   
257    AX_KEYNAME = 'ax'
258    SM_URI_AX_KEYNAME = 'value.sessionManagerURI.1'
259    SESSION_ID_AX_KEYNAME = 'value.sessionId.1'
260   
261    AUTHKIT_COOKIE_SIGNOUT_PARAMNAME = 'authkit.cookie.signoutpath'
262    SIGNOUT_PATH_PARAMNAME = 'signoutPath'
263    SESSION_KEY_PARAMNAME = 'sessionKey'
264    propertyDefaults = {
265        SIGNOUT_PATH_PARAMNAME: None,
266        SESSION_KEY_PARAMNAME: 'beaker.session.ndg.security'
267    }
268   
269    AUTH_TKT_SET_USER_ENVIRON_KEYNAME = 'paste.auth_tkt.set_user'
270   
271    PARAM_PREFIX = 'sessionHandler.'
272   
273    def __init__(self, app, global_conf, prefix=PARAM_PREFIX, **app_conf):
274        '''
275        @type app: callable following WSGI interface
276        @param app: next middleware application in the chain     
277        @type global_conf: dict       
278        @param global_conf: PasteDeploy global configuration dictionary
279        @type prefix: basestring
280        @param prefix: prefix for configuration items
281        @type app_conf: dict       
282        @param app_conf: PasteDeploy application specific configuration
283        dictionary
284        '''
285        signoutPathParamName = prefix + \
286                                SessionHandlerMiddleware.SIGNOUT_PATH_PARAMNAME
287       
288        if signoutPathParamName not in app_conf:
289            authKitSignOutPath = app_conf.get(
290                    SessionHandlerMiddleware.AUTHKIT_COOKIE_SIGNOUT_PARAMNAME)
291           
292            if authKitSignOutPath:
293                app_conf[signoutPathParamName] = authKitSignOutPath
294               
295                log.info('Set signoutPath=%s from "%s" setting', 
296                     authKitSignOutPath,
297                     SessionHandlerMiddleware.AUTHKIT_COOKIE_SIGNOUT_PARAMNAME)
298            else:
299                raise SessionHandlerMiddlewareConfigError(
300                                        '"signoutPath" parameter is not set')
301           
302        super(SessionHandlerMiddleware, self).__init__(app,
303                                                       global_conf,
304                                                       prefix=prefix, 
305                                                       **app_conf)
306       
307    @NDGSecurityMiddlewareBase.initCall
308    def __call__(self, environ, start_response):
309        """Manage setting of session from AuthKit following OpenID Relying
310        Party sign in and manage logout
311       
312        @type environ: dict
313        @param environ: WSGI environment variables dictionary
314        @type start_response: function
315        @param start_response: standard WSGI start response function
316        """
317        log.debug("SessionHandlerMiddleware.__call__ ...")
318       
319        session = environ.get(self.sessionKey)
320        if session is None:
321            raise SessionHandlerMiddlewareConfigError(
322                   'SessionHandlerMiddleware.__call__: No beaker session key '
323                   '"%s" found in environ' % self.sessionKey)
324       
325        if self.signoutPath and self.pathInfo == self.signoutPath:
326            log.debug("SessionHandlerMiddleware.__call__: caught sign out "
327                      "path [%s]", self.signoutPath)
328           
329            referrer = environ.get('HTTP_REFERER')
330            if referrer is not None:
331                def _start_response(status, header, exc_info=None):
332                    """Alter the header to send a redirect to the logout
333                    referrer address"""
334                    filteredHeader = [(field, val) for field, val in header
335                                      if field.lower() != 'location']       
336                    filteredHeader.extend([('Location', referrer)])
337                    return start_response(self.getStatusMessage(302), 
338                                          filteredHeader,
339                                          exc_info)
340                   
341            else:
342                log.error('No referrer set for redirect following logout')
343                _start_response = start_response
344               
345            # Clear user details from beaker session
346            for keyName in self.__class__.SESSION_KEYNAMES:
347                session.pop(keyName, None)
348            session.save()
349        else:
350            log.debug("SessionHandlerMiddleware.__call__: checking for "
351                      "REMOTE_* environment variable settings set by OpenID "
352                      "Relying Party signin...")
353           
354            if SessionHandlerMiddleware.USERNAME_SESSION_KEYNAME not in session\
355               and SessionHandlerMiddleware.USERNAME_ENVIRON_KEYNAME in environ:
356                log.debug("SessionHandlerMiddleware: updating session "
357                          "username=%s", environ[
358                            SessionHandlerMiddleware.USERNAME_ENVIRON_KEYNAME])
359               
360                session[SessionHandlerMiddleware.USERNAME_SESSION_KEYNAME
361                        ] = environ[
362                            SessionHandlerMiddleware.USERNAME_ENVIRON_KEYNAME]
363                session.save()
364               
365            remoteUserData = environ.get(
366                        SessionHandlerMiddleware.USERDATA_ENVIRON_KEYNAME, '')   
367            if remoteUserData:
368                log.debug("SessionHandlerMiddleware: found REMOTE_USER_DATA="
369                          "%s, set from OpenID Relying Party signin",
370                          environ[
371                          SessionHandlerMiddleware.USERDATA_ENVIRON_KEYNAME])
372               
373                # eval is safe here because AuthKit cookie is signed and
374                # AuthKit middleware checks for tampering
375                if SessionHandlerMiddleware.SM_URI_SESSION_KEYNAME not in \
376                   session or \
377                   SessionHandlerMiddleware.ID_SESSION_KEYNAME not in session:
378                   
379                    axData = eval(remoteUserData)
380                    if isinstance(axData, dict) and \
381                       SessionHandlerMiddleware.AX_KEYNAME in axData:
382                       
383                        ax = axData[SessionHandlerMiddleware.AX_KEYNAME]
384                       
385                        # Save attributes keyed by attribute name
386                        session[
387                            SessionHandlerMiddleware.AX_SESSION_KEYNAME
388                        ] = SessionHandlerMiddleware._parseOpenIdAX(ax)
389                       
390                        log.debug("SessionHandlerMiddleware: updated session "
391                                  "with OpenID AX values: %r" % 
392                                  session[
393                                    SessionHandlerMiddleware.AX_SESSION_KEYNAME
394                                  ])
395                           
396                        # Save Session Manager specific attributes
397                        sessionManagerURI = ax.get(
398                                SessionHandlerMiddleware.SM_URI_AX_KEYNAME)
399                           
400                        session[SessionHandlerMiddleware.SM_URI_SESSION_KEYNAME
401                                ] = sessionManagerURI
402   
403                        sessionId = ax.get(
404                                SessionHandlerMiddleware.SESSION_ID_AX_KEYNAME)
405                        session[SessionHandlerMiddleware.ID_SESSION_KEYNAME
406                                ] = sessionId
407                               
408                        session.save()
409                       
410                        log.debug("SessionHandlerMiddleware: updated session "
411                                  "with sessionManagerURI=%s and "
412                                  "sessionId=%s", 
413                                  sessionManagerURI, 
414                                  sessionId)
415                   
416                # Reset cookie removing user data
417                setUser = environ[
418                    SessionHandlerMiddleware.AUTH_TKT_SET_USER_ENVIRON_KEYNAME]
419                setUser(
420                    session[SessionHandlerMiddleware.USERNAME_SESSION_KEYNAME])
421
422            _start_response = start_response
423           
424        return self._app(environ, _start_response)
425   
426    @staticmethod                   
427    def _parseOpenIdAX(ax):
428        """Return a dictionary of attribute exchange attributes parsed from the
429        OpenID Provider response set in the REMOTE_USER_DATA AuthKit environ
430        key
431       
432        @param ax: dictionary of AX parameters - format of keys is e.g.
433        count.paramName, value.paramName.<n>, type.paramName
434        @type ax: dict
435        @return: dictionary of parameters keyed by parameter with values for
436        each parameter a tuple of count.paramName values
437        @rtype: dict
438        """
439       
440        # Copy Attributes into session
441        outputKeys = [k.replace('type.', '') for k in ax.keys()
442                      if k.startswith('type.')]
443       
444        output = {}
445        for outputKey in outputKeys:
446            axCountKeyName = 'count.' + outputKey
447            axCount = int(ax[axCountKeyName])
448           
449            axValueKeyPrefix = 'value.%s.' % outputKey
450            output[outputKey] = tuple([v for k, v in ax.items() 
451                                       if k.startswith(axValueKeyPrefix)])
452           
453            nVals = len(output[outputKey])
454            if nVals != axCount:
455                raise OpenIdAXConfigError('Got %d parameters for AX attribute '
456                                          '"%s"; but "%s" AX key is set to %d'
457                                          % (nVals,
458                                             axCountKeyName,
459                                             axCountKeyName,
460                                             axCount))
461                                             
462        return output
463
464
465from authkit.authenticate.multi import MultiHandler
466
467class AuthenticationMiddlewareConfigError(NDGSecurityMiddlewareConfigError):
468    '''Authentication Middleware Configuration error'''
469
470
471class AuthenticationMiddleware(MultiHandler, NDGSecurityMiddlewareBase):
472    '''Top-level class encapsulates session and authentication handlers
473    in this module
474   
475    Handler to intercept 401 Unauthorized HTTP responses and redirect to an
476    authentication URI.  This class also implements a redirect handler to
477    redirect back to the referrer if logout is invoked.
478    '''
479
480    def __init__(self, app, global_conf, prefix='', **app_conf):
481        '''
482        @type app: callable following WSGI interface
483        @param app: next middleware application in the chain     
484        @type global_conf: dict       
485        @param global_conf: PasteDeploy global configuration dictionary
486        @type prefix: basestring
487        @param prefix: prefix for configuration items
488        @type app_conf: dict       
489        @param app_conf: PasteDeploy application specific configuration
490        dictionary
491        '''
492       
493        # Set logout URI parameter from AuthKit settings if not otherwise set
494        sessionHandlerPrefix = prefix + SessionHandlerMiddleware.PARAM_PREFIX       
495        app = SessionHandlerMiddleware(app, 
496                                       global_conf, 
497                                       prefix=sessionHandlerPrefix,
498                                       **app_conf)
499       
500        # Remove session handler middleware specific parameters
501        for k in app_conf.keys():
502            if k.startswith(sessionHandlerPrefix):
503                del app_conf[k]
504       
505        app = authkit.authenticate.middleware(app, app_conf)       
506       
507        MultiHandler.__init__(self, app)
508
509        # Redirection middleware is invoked based on a check method which
510        # catches HTTP 401 responses.
511        self.add_method(AuthnRedirectInitiatorMiddleware.MIDDLEWARE_ID, 
512                        AuthnRedirectInitiatorMiddleware.filter_app_factory, 
513                        global_conf,
514                        prefix=prefix,
515                        **app_conf)
516       
517        self.add_checker(AuthnRedirectInitiatorMiddleware.MIDDLEWARE_ID, 
518                         AuthnRedirectInitiatorMiddleware.checker)
519
520
521class AuthnInterfaceBase(object):
522    def logon(self, environ, username, password):
523        raise NotImplementedError()
524   
525 
526class EnvironAuthnInterface(AuthnInterfaceBase):
527    """Implementation of HTTP Basic Auth authentication plugin which simply
528    passes the username to downstream middleware via environ
529    """
530    def logon(self, environ, username, password):
531        # TODO: implement...
532        raise NotImplementedError()
533   
534   
535class HTTPBasicAuthMiddleware(NDGSecurityMiddlewareBase):
536    '''HTTP Basic Authentication Middleware
537   
538    TODO: implement authN interface and username/password retrieval from
539    HTTP header.'''
540   
541    def __init__(self, app, app_conf, **local_conf):
542
543        self.__authnInterface = None
544        super(HTTPBasicAuthMiddleware).__init__(self, 
545                                                app, 
546                                                app_conf, 
547                                                **local_conf)
548       
549    def __call__(self, environ, start_response):
550        """Authenticate based HTTP header elements as specified by the HTTP
551        Basic Authentication spec."""
552        log.debug("HTTPBasicAuthNMiddleware.__call__ ...")
553       
554        try:
555            self.authnInterface.logon(environ, username, password)
556               
557        except Exception, e:
558            return self._errorResponse(code=401)
559        else:
560            return self._app(environ, start_response)
561
562# AuthKit based HTTP basic authentication not currently needed but may need
563# resurrecting
564
565#from authkit.permissions import UserIn
566#from ndg.security.server.wsgi.utils.sessionmanagerclient import \
567#    WSGISessionManagerClient
568#           
569#class HTTPBasicAuthentication(object):
570#    '''Authkit based HTTP Basic Authentication.   __call__ defines a
571#    validation function to fit with the pattern for the AuthKit interface
572#    '''
573#   
574#    def __init__(self):
575#        self._userIn = UserIn([])
576#       
577#    def __call__(self, environ, username, password):
578#        """validation function"""
579#        try:
580#            client = WSGISessionManagerClient(environ=environ,
581#                                environKeyName=self.sessionManagerFilterID)
582#            res = client.connect(username, passphrase=password)
583#
584#            if username not in self._userIn.users:
585#                self._userIn.users += [username]
586#           
587#            # TODO: set session
588#               
589#        except Exception, e:
590#            return False
591#        else:
592#            return True
Note: See TracBrowser for help on using the repository browser.