source: TI12-security/trunk/python/ndg_security_server/ndg/security/server/wsgi/openid/relyingparty/__init__.py @ 5766

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

Working unit tests for Authentication redirect handler with SSL Client based authentication.

Line 
1"""NDG Security OpenID Relying Party Middleware
2
3Wrapper to AuthKit OpenID Middleware
4
5NERC DataGrid Project
6"""
7__author__ = "P J Kershaw"
8__date__ = "20/01/2009"
9__copyright__ = "(C) 2009 Science and Technology Facilities Council"
10__license__ = "BSD - see top-level directory for LICENSE file"
11__contact__ = "Philip.Kershaw@stfc.ac.uk"
12__revision__ = "$Id$"
13import logging
14log = logging.getLogger(__name__)
15
16import httplib # to get official status code messages
17import urllib # decode quoted URI in query arg
18from urlparse import urlsplit, urlunsplit
19
20
21from paste.request import parse_querystring, parse_formvars
22import authkit.authenticate
23from beaker.middleware import SessionMiddleware
24
25from ndg.security.server.wsgi import NDGSecurityMiddlewareBase
26from ndg.security.server.wsgi.authn import AuthnRedirectMiddleware
27from ndg.security.common.utils.classfactory import instantiateClass
28
29
30class OpenIDRelyingPartyMiddlewareError(Exception):
31    """OpenID Relying Party WSGI Middleware Error"""
32
33
34class OpenIDRelyingPartyConfigError(OpenIDRelyingPartyMiddlewareError):
35    """OpenID Relying Party Configuration Error"""
36 
37
38class OpenIDRelyingPartyMiddleware(NDGSecurityMiddlewareBase):
39    '''OpenID Relying Party middleware which wraps the AuthKit implementation.
40    This middleware is to be hosted in it's own security middleware stack.
41    WSGI middleware applications to be protected can be hosted in a separate
42    stack.  The AuthnRedirectMiddleware filter can respond to a HTTP
43    401 response from this stack and redirect to this middleware to initiate
44    OpenID based sign in.  AuthnRedirectMiddleware passes a query
45    argument in its request containing the URI return address for this
46    middleware to return to following OpenID sign in.
47    '''
48    sslPropertyDefaults = {
49        'certFilePath': '',
50        'priKeyFilePath': None,
51        'priKeyPwd': None,
52        'caCertDirPath': None,
53        'providerWhitelistFilePath': None
54    }
55    propertyDefaults = {
56        'sslPeerCertAuthN': True,
57        'signinInterfaceMiddlewareClass': None,
58        'baseURL': ''
59    }
60    propertyDefaults.update(sslPropertyDefaults)
61    propertyDefaults.update(NDGSecurityMiddlewareBase.propertyDefaults)
62   
63    def __init__(self, app, global_conf, prefix='openid.relyingparty.', 
64                 **app_conf):
65        """Add AuthKit and Beaker middleware dependencies to WSGI stack and
66        set-up SSL Peer Certificate Authentication of OpenID Provider set by
67        the user
68       
69        @type app: callable following WSGI interface signature
70        @param app: next middleware application in the chain     
71        @type global_conf: dict       
72        @param global_conf: PasteDeploy application global configuration -
73        must follow format of propertyDefaults class variable
74        @type prefix: basestring
75        @param prefix: prefix for OpenID Relying Party configuration items
76        @type app_conf: dict
77        @param app_conf: application specific configuration - must follow
78        format of propertyDefaults class variable"""   
79
80        # Default to set SSL peer cert authN where no flag is set in the config
81        # To override, it must explicitly be set to False in the config
82        if app_conf.get('sslPeerCertAuthN', 'true').lower() != 'false':
83           
84            # Set parameters for SSL client connection to OpenID Provider Yadis
85            # retrieval URI
86            for paramName in self.__class__.sslPropertyDefaults:
87                paramDefault = self.__class__.sslPropertyDefaults[paramName]
88                setattr(self, 
89                        paramName, 
90                        app_conf.get(prefix+paramName, paramDefault))
91               
92            self._initSSLPeerAuthN()
93       
94        # Check for sign in template settings
95        if prefix+'signinInterfaceMiddlewareClass' in app_conf:
96            if 'authkit.openid.template.obj' in app_conf or \
97               'authkit.openid.template.string' in app_conf or \
98               'authkit.openid.template.file' in app_conf:
99                log.warning("OpenID Relying Party "
100                            "'signinInterfaceMiddlewareClass' "
101                            "setting overrides 'authkit.openid.template.*' "
102                            "AuthKit settings")
103               
104            signinInterfacePrefix = prefix+'signinInterface.'
105            classProperties = {'prefix': signinInterfacePrefix}
106            classProperties.update(app_conf)
107            app = instantiateClass(
108                           app_conf[prefix+'signinInterfaceMiddlewareClass'], 
109                           None, 
110                           objectType=SigninInterface, 
111                           classArgs=(app, global_conf),
112                           classProperties=classProperties)           
113           
114            # Delete sign in interface middleware settings
115            for conf in app_conf, global_conf or {}:
116                for k in conf.keys():
117                    if k.startswith(signinInterfacePrefix):
118                        del conf[k]
119       
120            app_conf['authkit.openid.template.string'] = app.makeTemplate()
121               
122        self.signoutPath = app_conf.get('authkit.cookie.signoutpath')
123
124        app = authkit.authenticate.middleware(app, app_conf)
125        _app = app
126        while True:
127            if isinstance(_app,authkit.authenticate.open_id.AuthOpenIDHandler):
128                authOpenIDHandler = _app
129                self._authKitVerifyPath = authOpenIDHandler.path_verify
130                self._authKitProcessPath = authOpenIDHandler.path_process
131                break
132           
133            elif hasattr(_app, 'app'):
134                _app = _app.app
135            else:
136                break
137         
138        if not hasattr(self, '_authKitVerifyPath'):
139            raise OpenIDRelyingPartyConfigError("Error locating the AuthKit "
140                                                "AuthOpenIDHandler in the "
141                                                "WSGI stack")
142       
143        # Put this check in here after sessionKey has been set by the
144        # super class __init__ above
145        self.sessionKey = authOpenIDHandler.session_middleware
146           
147       
148        # Check for return to argument in query key value pairs
149        self._return2URIKey = AuthnRedirectMiddleware.RETURN2URI_ARGNAME + '='
150   
151        super(OpenIDRelyingPartyMiddleware, self).__init__(app, 
152                                                           global_conf, 
153                                                           prefix=prefix, 
154                                                           **app_conf)
155   
156    @NDGSecurityMiddlewareBase.initCall     
157    def __call__(self, environ, start_response):
158        '''
159        - Alter start_response to override the status code and force to 401.
160        This will enable non-browser based client code to bypass the OpenID
161        interface
162        - Manage AuthKit verify and process actions setting the referrer URI
163        to manage redirects
164       
165        @type environ: dict
166        @param environ: WSGI environment variables dictionary
167        @type start_response: function
168        @param start_response: standard WSGI start response function
169        @rtype: iterable
170        @return: response
171        '''
172        session = environ.get(self.sessionKey)
173        if session is None:
174            raise OpenIDRelyingPartyConfigError('No beaker session key "%s" '
175                                                'found in environ' % 
176                                                self.sessionKey)
177       
178        # Check for return to address in URI query args set by
179        # AuthnRedirectMiddleware in application code stack
180        if environ['REQUEST_METHOD'] == "GET":
181            params = dict(parse_querystring(environ))
182        else:
183            params = {}
184       
185        quotedReferrer=params.get(AuthnRedirectMiddleware.RETURN2URI_ARGNAME,'')
186        referrer = urllib.unquote(quotedReferrer)
187        referrerPathInfo = urlsplit(referrer)[2]
188
189        if referrer and \
190           not referrerPathInfo.endswith(self._authKitVerifyPath) and \
191           not referrerPathInfo.endswith(self._authKitProcessPath):
192            # Subvert authkit.authenticate.open_id.AuthOpenIDHandler.process
193            # reassigning it's session 'referer' key to the URI specified in
194            # the referrer query argument set in the request URI
195            session['referer'] = referrer
196            session.save()
197           
198        if self._return2URIKey in environ.get('HTTP_REFERER', ''):
199            # Remove return to arg to avoid interfering with AuthKit OpenID
200            # processing
201            splitURI = urlsplit(environ['HTTP_REFERER'])
202            query = splitURI[3]
203           
204            filteredQuery = '&'.join([arg for arg in query.split('&')
205                                if not arg.startswith(self._return2URIKey)])
206           
207            environ['HTTP_REFERER'] = urlunsplit(splitURI[:3] + \
208                                                 (filteredQuery,) + \
209                                                 splitURI[4:])
210       
211        # See _start_response doc for an explanation...
212        if environ['PATH_INFO'] == self._authKitVerifyPath: 
213            def _start_response(status, header, exc_info=None):
214                '''Make OpenID Relying Party OpenID prompt page return a 401
215                status to signal to non-browser based clients that
216                authentication is required.  Requests are filtered on content
217                type so that static content such as graphics and style sheets
218                associated with the page are let through unaltered
219               
220                @type status: str
221                @param status: HTTP status code and status message
222                @type header: list
223                @param header: list of field, value tuple HTTP header content
224                @type exc_info: Exception
225                @param exc_info: exception info
226                '''
227                _status = status
228                for name, val in header:
229                    if name.lower() == 'content-type' and \
230                       val.startswith('text/html'):
231                        _status = self.getStatusMessage(401)
232                        break
233                   
234                return start_response(_status, header, exc_info)
235        else:
236            _start_response = start_response
237
238        return self._app(environ, _start_response)
239
240    def _initSSLPeerAuthN(self):
241        """Initialise M2Crypto based urllib2 HTTPS handler to enable SSL
242        authentication of OpenID Providers"""
243        log.info("Setting parameters for SSL Authentication of OpenID "
244                 "Provider ...")
245       
246        def verifySSLPeerCertCallback(preVerifyOK, x509StoreCtx):
247            '''SSL verify callback function used to control the behaviour when
248            the SSL_VERIFY_PEER flag is set
249           
250            http://www.openssl.org/docs/ssl/SSL_CTX_set_verify.html
251           
252            @type preVerifyOK: int
253            @param preVerifyOK: If a verification error is found, this
254            parameter will be set to 0
255            @type x509StoreCtx: M2Crypto.X509_Store_Context
256            @param x509StoreCtx: locate the certificate to be verified and
257            perform additional verification steps as needed
258            @rtype: int
259            @return: controls the strategy of the further verification process.
260            - If verify_callback returns 0, the verification process is
261            immediately stopped with "verification failed" state. If
262            SSL_VERIFY_PEER is set, a verification failure alert is sent to the
263            peer and the TLS/SSL handshake is terminated.
264            - If verify_callback returns 1, the verification process is
265            continued.
266            If verify_callback always returns 1, the TLS/SSL handshake will not
267            be terminated with respect to verification failures and the
268            connection
269            will be established. The calling process can however retrieve the
270            error code of the last verification error using
271            SSL_get_verify_result or by maintaining its own error storage
272            managed by verify_callback.
273            '''
274            if preVerifyOK == 0:
275                # Something is wrong with the certificate don't bother
276                # proceeding any further
277                log.error("verifyCallback: pre-verify OK flagged an error "
278                          "with the peer certificate, returning error state "
279                          "to caller ...")
280                return preVerifyOK
281           
282            x509Cert = x509StoreCtx.get_current_cert()
283            x509Cert.get_subject()
284            x509CertChain = x509StoreCtx.get1_chain()
285            for cert in x509CertChain:
286                subject = cert.get_subject()
287                dn = subject.as_text()
288                log.debug("verifyCallback: dn = %r", dn)
289               
290            # If all is OK preVerifyOK will be 1.  Return this to the caller to
291            # that it's OK to proceed
292            return preVerifyOK
293           
294        # Imports here so that if SSL Auth is not set the app will not need
295        # these packages
296        import urllib2
297        from M2Crypto import SSL
298        from M2Crypto.m2urllib2 import build_opener
299        from openid.fetchers import setDefaultFetcher, Urllib2Fetcher
300       
301        # Create a context specifying verification of the peer but with an
302        # additional callback function
303        ctx = SSL.Context()
304        ctx.set_verify(SSL.verify_peer|SSL.verify_fail_if_no_peer_cert, 
305                       9, 
306                       callback=verifySSLPeerCertCallback)
307
308        # Point to a directory containing CA certificates.  These must be named
309        # in their hashed form as expected by the OpenSSL API.  Use c_rehash
310        # utility to generate names or in the CA directory:
311        #
312        # $ for i in *.crt *.pem; do ln -s $i $(openssl x509 -hash -noout -in $i).0; done
313        ctx.load_verify_locations(capath=self.caCertDirPath)
314       
315        # Load this client's certificate and private key to enable the peer
316        # OpenID Provider to authenticate it
317        ctx.load_cert(self.certFilePath, 
318                      keyfile=self.priKeyFilePath, 
319                      callback=lambda *arg, **kw: self.priKeyPwd)
320   
321        # Force Python OpenID library to use Urllib2 fetcher instead of the
322        # Curl based one otherwise the M2Crypto SSL handler will be ignored.
323        setDefaultFetcher(Urllib2Fetcher())
324       
325        log.debug("Adding the M2Crypto SSL handler to urllib2's list of "
326                  "handlers...")
327        urllib2.install_opener(build_opener(ssl_context=ctx))
328   
329class SigninInterfaceError(Exception):
330    """Base class for SigninInterface exceptions
331   
332    A standard message is raised set by the msg class variable but the actual
333    exception details are logged to the error log.  The use of a standard
334    message enables callers to use its content for user error messages.
335   
336    @type msg: basestring
337    @cvar msg: standard message to be raised for this exception"""
338    userMsg = ("An internal error occurred with the page layout,  Please "
339               "contact your system administrator")
340    errorMsg = "SigninInterface error"
341   
342    def __init__(self, *arg, **kw):
343        if len(arg) > 0:
344            msg = arg[0]
345        else:
346            msg = self.__class__.errorMsg
347           
348        log.error(msg)
349        Exception.__init__(self, msg, **kw)
350       
351class SigninInterfaceInitError(SigninInterfaceError):
352    """Error with initialisation of SigninInterface.  Raise from __init__"""
353    errorMsg = "SigninInterface initialisation error"
354   
355class SigninInterfaceConfigError(SigninInterfaceError):
356    """Error with configuration settings.  Raise from __init__"""
357    errorMsg = "SigninInterface configuration error"   
358
359class SigninInterface(NDGSecurityMiddlewareBase):
360    """Base class for sign in rendering.  This is implemented as WSGI
361    middleware to enable additional middleware to be added into the call
362    stack e.g. StaticFileParser to enable rendering of graphics and other
363    static content in the Sign In page"""
364   
365    def getTemplateFunc(self):
366        """Return template function for AuthKit to render OpenID Relying
367        Party Sign in page"""
368        raise NotImplementedError()
369   
370    def __call__(self, environ, start_response):
371        return self._app(self, environ, start_response)
372
Note: See TracBrowser for help on using the repository browser.