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 | |
---|
7 | NERC 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: $" |
---|
15 | import logging |
---|
16 | log = logging.getLogger(__name__) |
---|
17 | |
---|
18 | import re |
---|
19 | import base64 |
---|
20 | import httplib |
---|
21 | import urllib |
---|
22 | from paste.request import construct_url, parse_querystring |
---|
23 | import authkit.authenticate |
---|
24 | from authkit.authenticate.multi import MultiHandler |
---|
25 | |
---|
26 | from ndg.security.server.wsgi import (NDGSecurityMiddlewareBase, |
---|
27 | NDGSecurityMiddlewareError, |
---|
28 | NDGSecurityMiddlewareConfigError) |
---|
29 | from ndg.security.server.wsgi.session import (SessionMiddlewareBase, |
---|
30 | SessionHandlerMiddleware) |
---|
31 | |
---|
32 | from ndg.security.server.wsgi.ssl import AuthKitSSLAuthnMiddleware |
---|
33 | |
---|
34 | class AuthnException(NDGSecurityMiddlewareError): |
---|
35 | """Base exception for this module""" |
---|
36 | |
---|
37 | |
---|
38 | class HTTPBasicAuthMiddlewareError(AuthnException): |
---|
39 | """Base exception type for HTTPBasicAuthMiddleware""" |
---|
40 | |
---|
41 | |
---|
42 | class HTTPBasicAuthMiddlewareConfigError(NDGSecurityMiddlewareConfigError): |
---|
43 | """Configuration error with HTTP Basic Auth middleware""" |
---|
44 | |
---|
45 | |
---|
46 | class HTTPBasicAuthUnauthorized(HTTPBasicAuthMiddlewareError): |
---|
47 | """Raise from custom authentication interface in order to set HTTP |
---|
48 | 401 Unuathorized response""" |
---|
49 | |
---|
50 | |
---|
51 | class 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 |
---|
184 | from authkit.permissions import UserIn |
---|
185 | |
---|
186 | class 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 | |
---|
199 | class 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 | |
---|
209 | class 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 | |
---|
316 | class 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 | |
---|
360 | class 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 | |
---|
390 | class AuthenticationMiddlewareConfigError(NDGSecurityMiddlewareConfigError): |
---|
391 | '''Authentication Middleware Configuration error''' |
---|
392 | |
---|
393 | |
---|
394 | class 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) |
---|