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