1 """WSGI middleware implementing HTTP Basic Auth to support HTTPS proxy app to
2 MyProxy server.
3 """
4 __author__ = "P J Kershaw"
5 __date__ = "21/05/10"
6 __copyright__ = "(C) 2010 Science and Technology Facilities Council"
7 __license__ = "BSD - see LICENSE file in top-level directory"
8 __contact__ = "Philip.Kershaw@stfc.ac.uk"
9 __revision__ = "$Id$"
10 import logging
11 log = logging.getLogger(__name__)
12 import traceback
13 import re
14 import httplib
15 import base64
18 """Base exception type for HttpBasicAuthMiddleware"""
19
22 """Configuration error with HTTP Basic Auth middleware"""
23
26 """Exception class for use by the authentication function callback to
27 signal HTTP codes and messages back to HttpBasicAuthMiddleware. The code
28 can conceivably a non-error HTTP code such as 200
29 """
31 """Extend Exception type to accommodate an extra HTTP response code
32 argument
33 """
34 self.response = arg[0]
35 if len(arg) == 2:
36 argList = list(arg)
37 self.code = argList.pop()
38 arg = tuple(argList)
39 else:
40 self.code = httplib.UNAUTHORIZED
41
42 HttpBasicAuthMiddlewareError.__init__(self, *arg, **kw)
43
46 '''HTTP Basic Authentication Middleware
47
48 @cvar AUTHN_FUNC_ENV_KEYNAME: key name for referencing Authentication
49 callback function in environ. Upstream middleware must set this.
50 @type AUTHN_FUNC_ENV_KEYNAME: string
51 @cvar AUTHN_FUNC_ENV_KEYNAME_OPTNAME: in file option name for setting the
52 Authentication callback environ key
53 @type AUTHN_FUNC_ENV_KEYNAME_OPTNAME: string
54 @cvar REALM_OPTNAME: ini file option name for setting the HTTP Basic Auth
55 authentication realm
56 @type REALM_OPTNAME: string
57 @cvar PARAM_PREFIX: prefix for ini file options
58 @type PARAM_PREFIX: string
59 @cvar AUTHENTICATE_HDR_FIELDNAME: HTTP header field name 'WWW-Authenticate'
60 @type AUTHENTICATE_HDR_FIELDNAME: string
61 @cvar AUTHENTICATE_HDR_FIELDNAME_LOWER: lowercase version of
62 AUTHENTICATE_HDR_FIELDNAME class variable included for convenience with
63 string matching
64 @type AUTHENTICATE_HDR_FIELDNAME_LOWER: string
65 @cvar AUTHN_SCHEME_HDR_FIELDNAME: HTTP Authentication scheme identifier
66 @type AUTHN_SCHEME_HDR_FIELDNAME: string
67 @cvar FIELD_SEP: field separator for username/password header string
68 @type FIELD_SEP: string
69 @cvar AUTHZ_ENV_KEYNAME: WSGI environ key name for HTTP Basic Auth header
70 content
71 @type AUTHZ_ENV_KEYNAME: string
72 @cvar AUTHN_HDR_FORMAT: HTTP Basic Auth format string following RFC2617
73 @type AUTHN_HDR_FORMAT: string
74
75 @ivar __rePathMatchList: list of regular expression patterns used to match
76 incoming requests and enforce HTTP Basic Auth against
77 @type __rePathMatchList: list
78 @ivar __authnFuncEnvironKeyName: __authnFuncEnvironKeyName
79 @type __authnFuncEnvironKeyName: string
80 @ivar __realm: HTTP Basic Auth authentication realm
81 @type __realm: string
82 @ivar __app: next WSGI app/middleware in call chain
83 @type __app: function
84 '''
85 AUTHN_FUNC_ENV_KEYNAME = (
86 'myproxy.server.wsgi.httpbasicauth.HttpBasicAuthMiddleware.authenticate')
87
88
89 AUTHN_FUNC_ENV_KEYNAME_OPTNAME = 'authnFuncEnvKeyName'
90 RE_PATH_MATCH_LIST_OPTNAME = 'rePathMatchList'
91 REALM_OPTNAME = 'realm'
92
93 PARAM_PREFIX = 'http.auth.basic.'
94
95
96 AUTHENTICATE_HDR_FIELDNAME = 'WWW-Authenticate'
97
98
99 AUTHENTICATE_HDR_FIELDNAME_LOWER = AUTHENTICATE_HDR_FIELDNAME.lower()
100
101 AUTHN_SCHEME_HDR_FIELDNAME = 'Basic'
102 AUTHN_SCHEME_HDR_FIELDNAME_LOWER = AUTHN_SCHEME_HDR_FIELDNAME.lower()
103
104 FIELD_SEP = ':'
105 AUTHZ_ENV_KEYNAME = 'HTTP_AUTHORIZATION'
106
107 AUTHN_HDR_FORMAT = '%s ' + REALM_OPTNAME + '="%s"'
108
109 __slots__ = (
110 '__rePathMatchList',
111 '__authnFuncEnvironKeyName',
112 '__realm',
113 '__app'
114 )
115
125
126 @classmethod
129 """Function following Paste filter app factory signature
130
131 @type app: callable following WSGI interface
132 @param app: next middleware/application in the chain
133 @type global_conf: dict
134 @param global_conf: PasteDeploy global configuration dictionary
135 @type prefix: basestring
136 @param prefix: prefix for configuration items
137 @type local_conf: dict
138 @param local_conf: PasteDeploy application specific configuration
139 dictionary
140 @rtype: myproxy.server.wsgi.httpbasicauth.HttpBasicAuthMiddleware
141 @return: an instance of this middleware
142 """
143 httpBasicAuthFilter = cls(app)
144 httpBasicAuthFilter.parseConfig(prefix=prefix, **local_conf)
145
146 return httpBasicAuthFilter
147
170
172 """Get authentication callback function environ key name
173
174 @rtype: basestring
175 @return: callback function environ key name
176 """
177 return self.__authnFuncEnvironKeyName
178
180 """Set authentication callback environ key name
181
182 @type value: basestring
183 @param value: callback function environ key name
184 """
185 if not isinstance(value, basestring):
186 raise TypeError('Expecting string type for '
187 '"authnFuncEnvironKeyName"; got %r type' %
188 type(value))
189
190 self.__authnFuncEnvironKeyName = value
191
192 authnFuncEnvironKeyName = property(fget=_getAuthnFuncEnvironKeyName,
193 fset=_setAuthnFuncEnvironKeyName,
194 doc="key name in environ for the "
195 "custom authentication function "
196 "used by this class")
197
199 """Get regular expression path match list
200
201 @rtype: tuple or list
202 @return: list of regular expressions used to match incoming request
203 paths and apply HTTP Basic Auth to
204 """
205 return self.__rePathMatchList
206
208 """Set regular expression path match list
209
210 @type value: tuple or list
211 @param value: list of regular expressions used to match incoming request
212 paths and apply HTTP Basic Auth to
213 """
214 if not isinstance(value, (list, tuple)):
215 raise TypeError('Expecting list or tuple type for '
216 '"rePathMatchList"; got %r' % type(value))
217
218 self.__rePathMatchList = value
219
220 rePathMatchList = property(fget=_getRePathMatchList,
221 fset=_setRePathMatchList,
222 doc="List of regular expressions determine the "
223 "URI paths intercepted by this middleware")
224
226 """Get realm
227
228 @rtype: basestring
229 @return: HTTP Authentication realm to set in responses
230 """
231 return self.__realm
232
234 """Set realm
235
236 @type value: basestring
237 @param value: HTTP Authentication realm to set in responses
238 """
239 if not isinstance(value, basestring):
240 raise TypeError('Expecting string type for '
241 '"realm"; got %r' % type(value))
242
243 self.__realm = value
244
245 realm = property(fget=_getRealm, fset=_setRealm,
246 doc="HTTP Authentication realm to set in responses")
247
249 """Apply a list of regular expression matching patterns to the contents
250 of environ['PATH_INFO'], if any match, return True. This method is
251 used to determine whether to apply SSL client authentication
252
253 @param environ: WSGI environment variables dictionary
254 @type environ: dict
255 @return: True if request path matches the regular expression list set,
256 False otherwise
257 @rtype: bool
258 """
259 path = environ['PATH_INFO']
260 for regEx in self.rePathMatchList:
261 if regEx.match(path):
262 return True
263
264 return False
265
267 """Extract username and password from HTTP_AUTHORIZATION environ key
268
269 @param environ: WSGI environ dict
270 @type environ: dict
271
272 @rtype: tuple
273 @return: username and password. If the key is not set or the auth
274 method is not basic return a two element tuple with elements both set
275 to None
276 """
277 basicAuthHdr = environ.get(HttpBasicAuthMiddleware.AUTHZ_ENV_KEYNAME)
278 if basicAuthHdr is None:
279 log.debug("No %r setting in environ: skipping HTTP Basic Auth",
280 HttpBasicAuthMiddleware.AUTHZ_ENV_KEYNAME)
281 return None, None
282
283 method, encodedCreds = basicAuthHdr.split(None, 1)
284 if (method.lower() !=
285 HttpBasicAuthMiddleware.AUTHN_SCHEME_HDR_FIELDNAME_LOWER):
286 log.debug("Auth method is %r not %r: skipping request",
287 method,
288 HttpBasicAuthMiddleware.AUTHN_SCHEME_HDR_FIELDNAME)
289 return None, None
290
291 creds = base64.decodestring(encodedCreds)
292 username, password = creds.rsplit(HttpBasicAuthMiddleware.FIELD_SEP, 1)
293 return username, password
294
295 - def __call__(self, environ, start_response):
296 """Authenticate based HTTP header elements as specified by the HTTP
297 Basic Authentication spec.
298
299 @param environ: WSGI environ
300 @type environ: dict-like type
301 @param start_response: WSGI start response function
302 @type start_response: function
303 @return: response
304 @rtype: iterable
305 @raise HttpBasicAuthMiddlewareConfigError: no authentication callback
306 found in environ
307 """
308 log.debug("HttpBasicAuthNMiddleware.__call__ ...")
309
310 if not self._pathMatch(environ):
311 return self.__app(environ, start_response)
312
313 def start_response_wrapper(status, headers):
314 """Ensure Authentication realm is included with 401 responses"""
315 statusCode = int(status.split()[0])
316 if statusCode == httplib.UNAUTHORIZED:
317 authnRealmHdrFound = False
318 for name, val in headers:
319 if (name.lower() ==
320 self.__class__.AUTHENTICATE_HDR_FIELDNAME_LOWER):
321 authnRealmHdrFound = True
322 break
323
324 if not authnRealmHdrFound:
325
326 authnRealmHdr = (self.__class__.AUTHENTICATE_HDR_FIELDNAME,
327 self.__class__.AUTHN_HDR_FORMAT % (
328 self.__class__.AUTHN_SCHEME_HDR_FIELDNAME,
329 self.realm))
330 headers.append(authnRealmHdr)
331
332 return start_response(status, headers)
333
334 username, password = self._parseCredentials(environ)
335 if username is None:
336 log.error('No username set in HTTP Authorization header')
337 return self.setErrorResponse(start_response_wrapper,
338 msg="No username set\n")
339
340 authenticateFunc = environ.get(self.authnFuncEnvironKeyName)
341 if authenticateFunc is None:
342
343 raise HttpBasicAuthMiddlewareConfigError("No authentication "
344 "function set in environ")
345
346
347
348 try:
349 response = authenticateFunc(environ,
350 start_response_wrapper,
351 username,
352 password)
353 if response is None:
354 return self.__app(environ, start_response_wrapper)
355 else:
356 return response
357
358 except HttpBasicAuthResponseException, e:
359 log.error('Client authentication raised an exception: %s',
360 traceback.format_exc())
361 return self.setErrorResponse(start_response_wrapper,
362 msg=e.response,
363 code=e.code)
364
365 @classmethod
366 - def setErrorResponse(cls,
367 start_response,
368 msg=None,
369 code=httplib.UNAUTHORIZED,
370 contentType=None):
371 '''Convenience method to set a simple error response
372
373 @type start_response: function
374 @param start_response: standard WSGI callable to set the HTTP header
375 @type msg: basestring
376 @param msg: optional error message
377 @type code: int
378 @param code: standard HTTP error response code
379 @type contentType: basestring
380 @param contentType: set 'Content-type' HTTP header field - defaults to
381 'text/plain'
382 @rtype: list
383 @return: HTTP error response
384 '''
385 status = '%d %s' % (code, httplib.responses[code])
386 if msg is None:
387 response = "%s\n" % status
388 else:
389 response = msg
390
391 if contentType is None:
392 contentType = 'text/plain'
393
394 headers = [
395 ('Content-type', contentType),
396 ('Content-length', str(len(msg)))
397 ]
398 start_response(status, headers)
399 return [response]
400