Package cherrypy :: Package lib :: Module auth_digest
[hide private]
[frames] | no frames]

Source Code for Module cherrypy.lib.auth_digest

  1  # This file is part of CherryPy <http://www.cherrypy.org/> 
  2  # -*- coding: utf-8 -*- 
  3  # vim:ts=4:sw=4:expandtab:fileencoding=utf-8 
  4   
  5  __doc__ = """An implementation of the server-side of HTTP Digest Access 
  6  Authentication, which is described in :rfc:`2617`. 
  7   
  8  Example usage, using the built-in get_ha1_dict_plain function which uses a dict 
  9  of plaintext passwords as the credentials store:: 
 10   
 11      userpassdict = {'alice' : '4x5istwelve'} 
 12      get_ha1 = cherrypy.lib.auth_digest.get_ha1_dict_plain(userpassdict) 
 13      digest_auth = {'tools.auth_digest.on': True, 
 14                     'tools.auth_digest.realm': 'wonderland', 
 15                     'tools.auth_digest.get_ha1': get_ha1, 
 16                     'tools.auth_digest.key': 'a565c27146791cfb', 
 17      } 
 18      app_config = { '/' : digest_auth } 
 19  """ 
 20   
 21  __author__ = 'visteya' 
 22  __date__ = 'April 2009' 
 23   
 24   
 25  import time 
 26  from cherrypy._cpcompat import parse_http_list, parse_keqv_list 
 27   
 28  import cherrypy 
 29  from cherrypy._cpcompat import md5, ntob 
 30  md5_hex = lambda s: md5(ntob(s)).hexdigest() 
 31   
 32  qop_auth = 'auth' 
 33  qop_auth_int = 'auth-int' 
 34  valid_qops = (qop_auth, qop_auth_int) 
 35   
 36  valid_algorithms = ('MD5', 'MD5-sess') 
 37   
 38   
39 -def TRACE(msg):
40 cherrypy.log(msg, context='TOOLS.AUTH_DIGEST')
41 42 # Three helper functions for users of the tool, providing three variants 43 # of get_ha1() functions for three different kinds of credential stores. 44 45
46 -def get_ha1_dict_plain(user_password_dict):
47 """Returns a get_ha1 function which obtains a plaintext password from a 48 dictionary of the form: {username : password}. 49 50 If you want a simple dictionary-based authentication scheme, with plaintext 51 passwords, use get_ha1_dict_plain(my_userpass_dict) as the value for the 52 get_ha1 argument to digest_auth(). 53 """ 54 def get_ha1(realm, username): 55 password = user_password_dict.get(username) 56 if password: 57 return md5_hex('%s:%s:%s' % (username, realm, password)) 58 return None
59 60 return get_ha1 61 62
63 -def get_ha1_dict(user_ha1_dict):
64 """Returns a get_ha1 function which obtains a HA1 password hash from a 65 dictionary of the form: {username : HA1}. 66 67 If you want a dictionary-based authentication scheme, but with 68 pre-computed HA1 hashes instead of plain-text passwords, use 69 get_ha1_dict(my_userha1_dict) as the value for the get_ha1 70 argument to digest_auth(). 71 """ 72 def get_ha1(realm, username): 73 return user_ha1_dict.get(username)
74 75 return get_ha1 76 77
78 -def get_ha1_file_htdigest(filename):
79 """Returns a get_ha1 function which obtains a HA1 password hash from a 80 flat file with lines of the same format as that produced by the Apache 81 htdigest utility. For example, for realm 'wonderland', username 'alice', 82 and password '4x5istwelve', the htdigest line would be:: 83 84 alice:wonderland:3238cdfe91a8b2ed8e39646921a02d4c 85 86 If you want to use an Apache htdigest file as the credentials store, 87 then use get_ha1_file_htdigest(my_htdigest_file) as the value for the 88 get_ha1 argument to digest_auth(). It is recommended that the filename 89 argument be an absolute path, to avoid problems. 90 """ 91 def get_ha1(realm, username): 92 result = None 93 f = open(filename, 'r') 94 for line in f: 95 u, r, ha1 = line.rstrip().split(':') 96 if u == username and r == realm: 97 result = ha1 98 break 99 f.close() 100 return result
101 102 return get_ha1 103 104
105 -def synthesize_nonce(s, key, timestamp=None):
106 """Synthesize a nonce value which resists spoofing and can be checked 107 for staleness. Returns a string suitable as the value for 'nonce' in 108 the www-authenticate header. 109 110 s 111 A string related to the resource, such as the hostname of the server. 112 113 key 114 A secret string known only to the server. 115 116 timestamp 117 An integer seconds-since-the-epoch timestamp 118 119 """ 120 if timestamp is None: 121 timestamp = int(time.time()) 122 h = md5_hex('%s:%s:%s' % (timestamp, s, key)) 123 nonce = '%s:%s' % (timestamp, h) 124 return nonce
125 126
127 -def H(s):
128 """The hash function H""" 129 return md5_hex(s)
130 131
132 -class HttpDigestAuthorization (object):
133 134 """Class to parse a Digest Authorization header and perform re-calculation 135 of the digest. 136 """ 137
138 - def errmsg(self, s):
139 return 'Digest Authorization header: %s' % s
140
141 - def __init__(self, auth_header, http_method, debug=False):
142 self.http_method = http_method 143 self.debug = debug 144 scheme, params = auth_header.split(" ", 1) 145 self.scheme = scheme.lower() 146 if self.scheme != 'digest': 147 raise ValueError('Authorization scheme is not "Digest"') 148 149 self.auth_header = auth_header 150 151 # make a dict of the params 152 items = parse_http_list(params) 153 paramsd = parse_keqv_list(items) 154 155 self.realm = paramsd.get('realm') 156 self.username = paramsd.get('username') 157 self.nonce = paramsd.get('nonce') 158 self.uri = paramsd.get('uri') 159 self.method = paramsd.get('method') 160 self.response = paramsd.get('response') # the response digest 161 self.algorithm = paramsd.get('algorithm', 'MD5').upper() 162 self.cnonce = paramsd.get('cnonce') 163 self.opaque = paramsd.get('opaque') 164 self.qop = paramsd.get('qop') # qop 165 self.nc = paramsd.get('nc') # nonce count 166 167 # perform some correctness checks 168 if self.algorithm not in valid_algorithms: 169 raise ValueError( 170 self.errmsg("Unsupported value for algorithm: '%s'" % 171 self.algorithm)) 172 173 has_reqd = ( 174 self.username and 175 self.realm and 176 self.nonce and 177 self.uri and 178 self.response 179 ) 180 if not has_reqd: 181 raise ValueError( 182 self.errmsg("Not all required parameters are present.")) 183 184 if self.qop: 185 if self.qop not in valid_qops: 186 raise ValueError( 187 self.errmsg("Unsupported value for qop: '%s'" % self.qop)) 188 if not (self.cnonce and self.nc): 189 raise ValueError( 190 self.errmsg("If qop is sent then " 191 "cnonce and nc MUST be present")) 192 else: 193 if self.cnonce or self.nc: 194 raise ValueError( 195 self.errmsg("If qop is not sent, " 196 "neither cnonce nor nc can be present"))
197
198 - def __str__(self):
199 return 'authorization : %s' % self.auth_header
200
201 - def validate_nonce(self, s, key):
202 """Validate the nonce. 203 Returns True if nonce was generated by synthesize_nonce() and the 204 timestamp is not spoofed, else returns False. 205 206 s 207 A string related to the resource, such as the hostname of 208 the server. 209 210 key 211 A secret string known only to the server. 212 213 Both s and key must be the same values which were used to synthesize 214 the nonce we are trying to validate. 215 """ 216 try: 217 timestamp, hashpart = self.nonce.split(':', 1) 218 s_timestamp, s_hashpart = synthesize_nonce( 219 s, key, timestamp).split(':', 1) 220 is_valid = s_hashpart == hashpart 221 if self.debug: 222 TRACE('validate_nonce: %s' % is_valid) 223 return is_valid 224 except ValueError: # split() error 225 pass 226 return False
227
228 - def is_nonce_stale(self, max_age_seconds=600):
229 """Returns True if a validated nonce is stale. The nonce contains a 230 timestamp in plaintext and also a secure hash of the timestamp. 231 You should first validate the nonce to ensure the plaintext 232 timestamp is not spoofed. 233 """ 234 try: 235 timestamp, hashpart = self.nonce.split(':', 1) 236 if int(timestamp) + max_age_seconds > int(time.time()): 237 return False 238 except ValueError: # int() error 239 pass 240 if self.debug: 241 TRACE("nonce is stale") 242 return True
243
244 - def HA2(self, entity_body=''):
245 """Returns the H(A2) string. See :rfc:`2617` section 3.2.2.3.""" 246 # RFC 2617 3.2.2.3 247 # If the "qop" directive's value is "auth" or is unspecified, 248 # then A2 is: 249 # A2 = method ":" digest-uri-value 250 # 251 # If the "qop" value is "auth-int", then A2 is: 252 # A2 = method ":" digest-uri-value ":" H(entity-body) 253 if self.qop is None or self.qop == "auth": 254 a2 = '%s:%s' % (self.http_method, self.uri) 255 elif self.qop == "auth-int": 256 a2 = "%s:%s:%s" % (self.http_method, self.uri, H(entity_body)) 257 else: 258 # in theory, this should never happen, since I validate qop in 259 # __init__() 260 raise ValueError(self.errmsg("Unrecognized value for qop!")) 261 return H(a2)
262
263 - def request_digest(self, ha1, entity_body=''):
264 """Calculates the Request-Digest. See :rfc:`2617` section 3.2.2.1. 265 266 ha1 267 The HA1 string obtained from the credentials store. 268 269 entity_body 270 If 'qop' is set to 'auth-int', then A2 includes a hash 271 of the "entity body". The entity body is the part of the 272 message which follows the HTTP headers. See :rfc:`2617` section 273 4.3. This refers to the entity the user agent sent in the 274 request which has the Authorization header. Typically GET 275 requests don't have an entity, and POST requests do. 276 277 """ 278 ha2 = self.HA2(entity_body) 279 # Request-Digest -- RFC 2617 3.2.2.1 280 if self.qop: 281 req = "%s:%s:%s:%s:%s" % ( 282 self.nonce, self.nc, self.cnonce, self.qop, ha2) 283 else: 284 req = "%s:%s" % (self.nonce, ha2) 285 286 # RFC 2617 3.2.2.2 287 # 288 # If the "algorithm" directive's value is "MD5" or is unspecified, 289 # then A1 is: 290 # A1 = unq(username-value) ":" unq(realm-value) ":" passwd 291 # 292 # If the "algorithm" directive's value is "MD5-sess", then A1 is 293 # calculated only once - on the first request by the client following 294 # receipt of a WWW-Authenticate challenge from the server. 295 # A1 = H( unq(username-value) ":" unq(realm-value) ":" passwd ) 296 # ":" unq(nonce-value) ":" unq(cnonce-value) 297 if self.algorithm == 'MD5-sess': 298 ha1 = H('%s:%s:%s' % (ha1, self.nonce, self.cnonce)) 299 300 digest = H('%s:%s' % (ha1, req)) 301 return digest
302 303
304 -def www_authenticate(realm, key, algorithm='MD5', nonce=None, qop=qop_auth, 305 stale=False):
306 """Constructs a WWW-Authenticate header for Digest authentication.""" 307 if qop not in valid_qops: 308 raise ValueError("Unsupported value for qop: '%s'" % qop) 309 if algorithm not in valid_algorithms: 310 raise ValueError("Unsupported value for algorithm: '%s'" % algorithm) 311 312 if nonce is None: 313 nonce = synthesize_nonce(realm, key) 314 s = 'Digest realm="%s", nonce="%s", algorithm="%s", qop="%s"' % ( 315 realm, nonce, algorithm, qop) 316 if stale: 317 s += ', stale="true"' 318 return s
319 320
321 -def digest_auth(realm, get_ha1, key, debug=False):
322 """A CherryPy tool which hooks at before_handler to perform 323 HTTP Digest Access Authentication, as specified in :rfc:`2617`. 324 325 If the request has an 'authorization' header with a 'Digest' scheme, 326 this tool authenticates the credentials supplied in that header. 327 If the request has no 'authorization' header, or if it does but the 328 scheme is not "Digest", or if authentication fails, the tool sends 329 a 401 response with a 'WWW-Authenticate' Digest header. 330 331 realm 332 A string containing the authentication realm. 333 334 get_ha1 335 A callable which looks up a username in a credentials store 336 and returns the HA1 string, which is defined in the RFC to be 337 MD5(username : realm : password). The function's signature is: 338 ``get_ha1(realm, username)`` 339 where username is obtained from the request's 'authorization' header. 340 If username is not found in the credentials store, get_ha1() returns 341 None. 342 343 key 344 A secret string known only to the server, used in the synthesis 345 of nonces. 346 347 """ 348 request = cherrypy.serving.request 349 350 auth_header = request.headers.get('authorization') 351 nonce_is_stale = False 352 if auth_header is not None: 353 try: 354 auth = HttpDigestAuthorization( 355 auth_header, request.method, debug=debug) 356 except ValueError: 357 raise cherrypy.HTTPError( 358 400, "The Authorization header could not be parsed.") 359 360 if debug: 361 TRACE(str(auth)) 362 363 if auth.validate_nonce(realm, key): 364 ha1 = get_ha1(realm, auth.username) 365 if ha1 is not None: 366 # note that for request.body to be available we need to 367 # hook in at before_handler, not on_start_resource like 368 # 3.1.x digest_auth does. 369 digest = auth.request_digest(ha1, entity_body=request.body) 370 if digest == auth.response: # authenticated 371 if debug: 372 TRACE("digest matches auth.response") 373 # Now check if nonce is stale. 374 # The choice of ten minutes' lifetime for nonce is somewhat 375 # arbitrary 376 nonce_is_stale = auth.is_nonce_stale(max_age_seconds=600) 377 if not nonce_is_stale: 378 request.login = auth.username 379 if debug: 380 TRACE("authentication of %s successful" % 381 auth.username) 382 return 383 384 # Respond with 401 status and a WWW-Authenticate header 385 header = www_authenticate(realm, key, stale=nonce_is_stale) 386 if debug: 387 TRACE(header) 388 cherrypy.serving.response.headers['WWW-Authenticate'] = header 389 raise cherrypy.HTTPError( 390 401, "You are not authorized to access that resource")
391