1 | """NDG Security OpenID Relying Party Middleware |
---|
2 | |
---|
3 | Wrapper to AuthKit OpenID Middleware |
---|
4 | |
---|
5 | NERC 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: $" |
---|
13 | import logging |
---|
14 | log = logging.getLogger(__name__) |
---|
15 | |
---|
16 | import httplib # to get official status code messages |
---|
17 | import urllib # decode quoted URI in query arg |
---|
18 | import urllib2 # SSL based whitelisting |
---|
19 | from urlparse import urlsplit, urlunsplit |
---|
20 | |
---|
21 | from paste.request import parse_querystring, parse_formvars |
---|
22 | import authkit.authenticate |
---|
23 | from authkit.authenticate.open_id import AuthOpenIDHandler |
---|
24 | from beaker.middleware import SessionMiddleware |
---|
25 | |
---|
26 | # SSL based whitelisting |
---|
27 | from M2Crypto import SSL |
---|
28 | from M2Crypto.m2urllib2 import build_opener, HTTPSHandler |
---|
29 | from openid.fetchers import setDefaultFetcher, Urllib2Fetcher |
---|
30 | |
---|
31 | from ndg.security.common.utils.classfactory import instantiateClass |
---|
32 | from ndg.security.server.wsgi import NDGSecurityMiddlewareBase |
---|
33 | from ndg.security.server.wsgi.authn import AuthnRedirectMiddleware |
---|
34 | from ndg.security.server.wsgi.openid.relyingparty.validation import ( |
---|
35 | SSLIdPValidationDriver) |
---|
36 | |
---|
37 | |
---|
38 | class OpenIDRelyingPartyMiddlewareError(Exception): |
---|
39 | """OpenID Relying Party WSGI Middleware Error""" |
---|
40 | |
---|
41 | |
---|
42 | class OpenIDRelyingPartyConfigError(OpenIDRelyingPartyMiddlewareError): |
---|
43 | """OpenID Relying Party Configuration Error""" |
---|
44 | |
---|
45 | |
---|
46 | class OpenIDRelyingPartyMiddleware(NDGSecurityMiddlewareBase): |
---|
47 | '''OpenID Relying Party middleware which wraps the AuthKit implementation. |
---|
48 | This middleware is to be hosted in it's own security middleware stack. |
---|
49 | WSGI middleware applications to be protected can be hosted in a separate |
---|
50 | stack. The AuthnRedirectMiddleware filter can respond to a HTTP |
---|
51 | 401 response from this stack and redirect to this middleware to initiate |
---|
52 | OpenID based sign in. AuthnRedirectMiddleware passes a query |
---|
53 | argument in its request containing the URI return address for this |
---|
54 | middleware to return to following OpenID sign in. |
---|
55 | ''' |
---|
56 | sslPropertyDefaults = { |
---|
57 | 'idpWhitelistConfigFilePath': None |
---|
58 | } |
---|
59 | propertyDefaults = { |
---|
60 | 'signinInterfaceMiddlewareClass': None, |
---|
61 | 'baseURL': '' |
---|
62 | } |
---|
63 | propertyDefaults.update(sslPropertyDefaults) |
---|
64 | propertyDefaults.update(NDGSecurityMiddlewareBase.propertyDefaults) |
---|
65 | |
---|
66 | def __init__(self, app, global_conf, prefix='openid.relyingparty.', |
---|
67 | **app_conf): |
---|
68 | """Add AuthKit and Beaker middleware dependencies to WSGI stack and |
---|
69 | set-up SSL Peer Certificate Authentication of OpenID Provider set by |
---|
70 | the user |
---|
71 | |
---|
72 | @type app: callable following WSGI interface signature |
---|
73 | @param app: next middleware application in the chain |
---|
74 | @type global_conf: dict |
---|
75 | @param global_conf: PasteDeploy application global configuration - |
---|
76 | must follow format of propertyDefaults class variable |
---|
77 | @type prefix: basestring |
---|
78 | @param prefix: prefix for OpenID Relying Party configuration items |
---|
79 | @type app_conf: dict |
---|
80 | @param app_conf: application specific configuration - must follow |
---|
81 | format of propertyDefaults class variable""" |
---|
82 | |
---|
83 | # Whitelisting of IDPs. If no config file is set, no validation is |
---|
84 | # executed |
---|
85 | idpWhitelistConfigFilePath = app_conf.get( |
---|
86 | prefix + 'idpWhitelistConfigFilePath') |
---|
87 | if idpWhitelistConfigFilePath is not None: |
---|
88 | self._initIdPValidation(idpWhitelistConfigFilePath) |
---|
89 | |
---|
90 | # Check for sign in template settings |
---|
91 | if prefix+'signinInterfaceMiddlewareClass' in app_conf: |
---|
92 | if 'authkit.openid.template.obj' in app_conf or \ |
---|
93 | 'authkit.openid.template.string' in app_conf or \ |
---|
94 | 'authkit.openid.template.file' in app_conf: |
---|
95 | log.warning("OpenID Relying Party " |
---|
96 | "'signinInterfaceMiddlewareClass' " |
---|
97 | "setting overrides 'authkit.openid.template.*' " |
---|
98 | "AuthKit settings") |
---|
99 | |
---|
100 | signinInterfacePrefix = prefix+'signinInterface.' |
---|
101 | classProperties = {'prefix': signinInterfacePrefix} |
---|
102 | classProperties.update(app_conf) |
---|
103 | app = instantiateClass( |
---|
104 | app_conf[prefix+'signinInterfaceMiddlewareClass'], |
---|
105 | None, |
---|
106 | objectType=SigninInterface, |
---|
107 | classArgs=(app, global_conf), |
---|
108 | classProperties=classProperties) |
---|
109 | |
---|
110 | # Delete sign in interface middleware settings |
---|
111 | for conf in app_conf, global_conf or {}: |
---|
112 | for k in conf.keys(): |
---|
113 | if k.startswith(signinInterfacePrefix): |
---|
114 | del conf[k] |
---|
115 | |
---|
116 | app_conf['authkit.openid.template.string'] = app.makeTemplate() |
---|
117 | |
---|
118 | self.signoutPath = app_conf.get('authkit.cookie.signoutpath') |
---|
119 | |
---|
120 | app = authkit.authenticate.middleware(app, app_conf) |
---|
121 | _app = app |
---|
122 | while True: |
---|
123 | if isinstance(_app, AuthOpenIDHandler): |
---|
124 | authOpenIDHandler = _app |
---|
125 | self._authKitVerifyPath = authOpenIDHandler.path_verify |
---|
126 | self._authKitProcessPath = authOpenIDHandler.path_process |
---|
127 | break |
---|
128 | |
---|
129 | elif hasattr(_app, 'app'): |
---|
130 | _app = _app.app |
---|
131 | else: |
---|
132 | break |
---|
133 | |
---|
134 | if not hasattr(self, '_authKitVerifyPath'): |
---|
135 | raise OpenIDRelyingPartyConfigError("Error locating the AuthKit " |
---|
136 | "AuthOpenIDHandler in the " |
---|
137 | "WSGI stack") |
---|
138 | |
---|
139 | # Put this check in here after sessionKey has been set by the |
---|
140 | # super class __init__ above |
---|
141 | self.sessionKey = authOpenIDHandler.session_middleware |
---|
142 | |
---|
143 | |
---|
144 | # Check for return to argument in query key value pairs |
---|
145 | self._return2URIKey = AuthnRedirectMiddleware.RETURN2URI_ARGNAME + '=' |
---|
146 | |
---|
147 | super(OpenIDRelyingPartyMiddleware, self).__init__(app, |
---|
148 | global_conf, |
---|
149 | prefix=prefix, |
---|
150 | **app_conf) |
---|
151 | |
---|
152 | @NDGSecurityMiddlewareBase.initCall |
---|
153 | def __call__(self, environ, start_response): |
---|
154 | ''' |
---|
155 | - Alter start_response to override the status code and force to 401. |
---|
156 | This will enable non-browser based client code to bypass the OpenID |
---|
157 | interface |
---|
158 | - Manage AuthKit verify and process actions setting the referrer URI |
---|
159 | to manage redirects |
---|
160 | |
---|
161 | @type environ: dict |
---|
162 | @param environ: WSGI environment variables dictionary |
---|
163 | @type start_response: function |
---|
164 | @param start_response: standard WSGI start response function |
---|
165 | @rtype: iterable |
---|
166 | @return: response |
---|
167 | ''' |
---|
168 | # Skip Relying Party interface set-up if user has been authenticated |
---|
169 | # by other middleware |
---|
170 | if 'REMOTE_USER' in environ: |
---|
171 | log.debug("Found REMOTE_USER=%s in environ, AuthKit " |
---|
172 | "based authentication has taken place in other " |
---|
173 | "middleware, skipping OpenID Relying Party interface" % |
---|
174 | environ['REMOTE_USER']) |
---|
175 | return self._app(environ, start_response) |
---|
176 | |
---|
177 | session = environ.get(self.sessionKey) |
---|
178 | if session is None: |
---|
179 | raise OpenIDRelyingPartyConfigError('No beaker session key "%s" ' |
---|
180 | 'found in environ' % |
---|
181 | self.sessionKey) |
---|
182 | |
---|
183 | # Check for return to address in URI query args set by |
---|
184 | # AuthnRedirectMiddleware in application code stack |
---|
185 | params = dict(parse_querystring(environ)) |
---|
186 | quotedReferrer = params.get(AuthnRedirectMiddleware.RETURN2URI_ARGNAME, |
---|
187 | '') |
---|
188 | |
---|
189 | referrer = urllib.unquote(quotedReferrer) |
---|
190 | referrerPathInfo = urlsplit(referrer)[2] |
---|
191 | |
---|
192 | if (referrer and |
---|
193 | not referrerPathInfo.endswith(self._authKitVerifyPath) and |
---|
194 | not referrerPathInfo.endswith(self._authKitProcessPath)): |
---|
195 | # Subvert authkit.authenticate.open_id.AuthOpenIDHandler.process |
---|
196 | # reassigning it's session 'referer' key to the URI specified in |
---|
197 | # the referrer query argument set in the request URI |
---|
198 | session['referer'] = referrer |
---|
199 | session.save() |
---|
200 | |
---|
201 | if self._return2URIKey in environ.get('HTTP_REFERER', ''): |
---|
202 | # Remove return to arg to avoid interfering with AuthKit OpenID |
---|
203 | # processing |
---|
204 | splitURI = urlsplit(environ['HTTP_REFERER']) |
---|
205 | query = splitURI[3] |
---|
206 | |
---|
207 | filteredQuery = '&'.join([arg for arg in query.split('&') |
---|
208 | if not arg.startswith(self._return2URIKey)]) |
---|
209 | |
---|
210 | environ['HTTP_REFERER'] = urlunsplit(splitURI[:3] + \ |
---|
211 | (filteredQuery,) + \ |
---|
212 | splitURI[4:]) |
---|
213 | |
---|
214 | # See _start_response doc for an explanation... |
---|
215 | if environ['PATH_INFO'] == self._authKitVerifyPath: |
---|
216 | def _start_response(status, header, exc_info=None): |
---|
217 | '''Make OpenID Relying Party OpenID prompt page return a 401 |
---|
218 | status to signal to non-browser based clients that |
---|
219 | authentication is required. Requests are filtered on content |
---|
220 | type so that static content such as graphics and style sheets |
---|
221 | associated with the page are let through unaltered |
---|
222 | |
---|
223 | @type status: str |
---|
224 | @param status: HTTP status code and status message |
---|
225 | @type header: list |
---|
226 | @param header: list of field, value tuple HTTP header content |
---|
227 | @type exc_info: Exception |
---|
228 | @param exc_info: exception info |
---|
229 | ''' |
---|
230 | _status = status |
---|
231 | for name, val in header: |
---|
232 | if name.lower() == 'content-type' and \ |
---|
233 | val.startswith('text/html'): |
---|
234 | _status = self.getStatusMessage(401) |
---|
235 | break |
---|
236 | |
---|
237 | return start_response(_status, header, exc_info) |
---|
238 | else: |
---|
239 | _start_response = start_response |
---|
240 | |
---|
241 | return self._app(environ, _start_response) |
---|
242 | |
---|
243 | def _initIdPValidation(self, idpWhitelistConfigFilePath): |
---|
244 | """Initialise M2Crypto based urllib2 HTTPS handler to enable SSL |
---|
245 | authentication of OpenID Providers""" |
---|
246 | log.info("Setting parameters for SSL Authentication of OpenID " |
---|
247 | "Provider ...") |
---|
248 | |
---|
249 | idPValidationDriver = SSLIdPValidationDriver( |
---|
250 | idpConfigFilePath=idpWhitelistConfigFilePath) |
---|
251 | |
---|
252 | # Force Python OpenID library to use Urllib2 fetcher instead of the |
---|
253 | # Curl based one otherwise the M2Crypto SSL handler will be ignored. |
---|
254 | setDefaultFetcher(Urllib2Fetcher()) |
---|
255 | |
---|
256 | log.debug("Setting the M2Crypto SSL handler ...") |
---|
257 | |
---|
258 | opener = urllib2.OpenerDirector() |
---|
259 | opener.add_handler(FlagHttpsOnlyHandler()) |
---|
260 | opener.add_handler(HTTPSHandler(idPValidationDriver.ctx)) |
---|
261 | |
---|
262 | urllib2.install_opener(opener) |
---|
263 | |
---|
264 | |
---|
265 | class FlagHttpsOnlyHandler(urllib2.AbstractHTTPHandler): |
---|
266 | '''Raise an exception for any other protocol than https''' |
---|
267 | def unknown_open(self, req): |
---|
268 | """Signal to caller that default handler is not supported""" |
---|
269 | raise urllib2.URLError("Only HTTPS based OpenID Providers " |
---|
270 | "are supported") |
---|
271 | |
---|
272 | |
---|
273 | class SigninInterfaceError(Exception): |
---|
274 | """Base class for SigninInterface exceptions |
---|
275 | |
---|
276 | A standard message is raised set by the msg class variable but the actual |
---|
277 | exception details are logged to the error log. The use of a standard |
---|
278 | message enables callers to use its content for user error messages. |
---|
279 | |
---|
280 | @type msg: basestring |
---|
281 | @cvar msg: standard message to be raised for this exception""" |
---|
282 | userMsg = ("An internal error occurred with the page layout, Please " |
---|
283 | "contact your system administrator") |
---|
284 | errorMsg = "SigninInterface error" |
---|
285 | |
---|
286 | def __init__(self, *arg, **kw): |
---|
287 | if len(arg) > 0: |
---|
288 | msg = arg[0] |
---|
289 | else: |
---|
290 | msg = self.__class__.errorMsg |
---|
291 | |
---|
292 | log.error(msg) |
---|
293 | Exception.__init__(self, msg, **kw) |
---|
294 | |
---|
295 | class SigninInterfaceInitError(SigninInterfaceError): |
---|
296 | """Error with initialisation of SigninInterface. Raise from __init__""" |
---|
297 | errorMsg = "SigninInterface initialisation error" |
---|
298 | |
---|
299 | class SigninInterfaceConfigError(SigninInterfaceError): |
---|
300 | """Error with configuration settings. Raise from __init__""" |
---|
301 | errorMsg = "SigninInterface configuration error" |
---|
302 | |
---|
303 | class SigninInterface(NDGSecurityMiddlewareBase): |
---|
304 | """Base class for sign in rendering. This is implemented as WSGI |
---|
305 | middleware to enable additional middleware to be added into the call |
---|
306 | stack e.g. StaticFileParser to enable rendering of graphics and other |
---|
307 | static content in the Sign In page""" |
---|
308 | |
---|
309 | def getTemplateFunc(self): |
---|
310 | """Return template function for AuthKit to render OpenID Relying |
---|
311 | Party Sign in page""" |
---|
312 | raise NotImplementedError() |
---|
313 | |
---|
314 | def __call__(self, environ, start_response): |
---|
315 | return self._app(self, environ, start_response) |
---|
316 | |
---|