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