OLD | NEW |
(Empty) | |
| 1 """ |
| 2 This module defines functions to implement HTTP Digest Authentication (:rfc:`261
7`). |
| 3 This has full compliance with 'Digest' and 'Basic' authentication methods. In |
| 4 'Digest' it supports both MD5 and MD5-sess algorithms. |
| 5 |
| 6 Usage: |
| 7 First use 'doAuth' to request the client authentication for a |
| 8 certain resource. You should send an httplib.UNAUTHORIZED response to the |
| 9 client so he knows he has to authenticate itself. |
| 10 |
| 11 Then use 'parseAuthorization' to retrieve the 'auth_map' used in |
| 12 'checkResponse'. |
| 13 |
| 14 To use 'checkResponse' you must have already verified the password associate
d |
| 15 with the 'username' key in 'auth_map' dict. Then you use the 'checkResponse' |
| 16 function to verify if the password matches the one sent by the client. |
| 17 |
| 18 SUPPORTED_ALGORITHM - list of supported 'Digest' algorithms |
| 19 SUPPORTED_QOP - list of supported 'Digest' 'qop'. |
| 20 """ |
| 21 __version__ = 1, 0, 1 |
| 22 __author__ = "Tiago Cogumbreiro <cogumbreiro@users.sf.net>" |
| 23 __credits__ = """ |
| 24 Peter van Kampen for its recipe which implement most of Digest authenticatio
n: |
| 25 http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/302378 |
| 26 """ |
| 27 |
| 28 __license__ = """ |
| 29 Copyright (c) 2005, Tiago Cogumbreiro <cogumbreiro@users.sf.net> |
| 30 All rights reserved. |
| 31 |
| 32 Redistribution and use in source and binary forms, with or without modification,
|
| 33 are permitted provided that the following conditions are met: |
| 34 |
| 35 * Redistributions of source code must retain the above copyright notice, |
| 36 this list of conditions and the following disclaimer. |
| 37 * Redistributions in binary form must reproduce the above copyright notice, |
| 38 this list of conditions and the following disclaimer in the documentation |
| 39 and/or other materials provided with the distribution. |
| 40 * Neither the name of Sylvain Hellegouarch nor the names of his contributors
|
| 41 may be used to endorse or promote products derived from this software |
| 42 without specific prior written permission. |
| 43 |
| 44 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND |
| 45 ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED |
| 46 WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE |
| 47 DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE |
| 48 FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL |
| 49 DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR |
| 50 SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER |
| 51 CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, |
| 52 OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
| 53 OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| 54 """ |
| 55 |
| 56 __all__ = ("digestAuth", "basicAuth", "doAuth", "checkResponse", |
| 57 "parseAuthorization", "SUPPORTED_ALGORITHM", "md5SessionKey", |
| 58 "calculateNonce", "SUPPORTED_QOP") |
| 59 |
| 60 ################################################################################ |
| 61 import time |
| 62 from cherrypy._cpcompat import base64_decode, ntob, md5 |
| 63 from cherrypy._cpcompat import parse_http_list, parse_keqv_list |
| 64 |
| 65 MD5 = "MD5" |
| 66 MD5_SESS = "MD5-sess" |
| 67 AUTH = "auth" |
| 68 AUTH_INT = "auth-int" |
| 69 |
| 70 SUPPORTED_ALGORITHM = (MD5, MD5_SESS) |
| 71 SUPPORTED_QOP = (AUTH, AUTH_INT) |
| 72 |
| 73 ################################################################################ |
| 74 # doAuth |
| 75 # |
| 76 DIGEST_AUTH_ENCODERS = { |
| 77 MD5: lambda val: md5(ntob(val)).hexdigest(), |
| 78 MD5_SESS: lambda val: md5(ntob(val)).hexdigest(), |
| 79 # SHA: lambda val: sha.new(ntob(val)).hexdigest (), |
| 80 } |
| 81 |
| 82 def calculateNonce (realm, algorithm = MD5): |
| 83 """This is an auxaliary function that calculates 'nonce' value. It is used |
| 84 to handle sessions.""" |
| 85 |
| 86 global SUPPORTED_ALGORITHM, DIGEST_AUTH_ENCODERS |
| 87 assert algorithm in SUPPORTED_ALGORITHM |
| 88 |
| 89 try: |
| 90 encoder = DIGEST_AUTH_ENCODERS[algorithm] |
| 91 except KeyError: |
| 92 raise NotImplementedError ("The chosen algorithm (%s) does not have "\ |
| 93 "an implementation yet" % algorithm) |
| 94 |
| 95 return encoder ("%d:%s" % (time.time(), realm)) |
| 96 |
| 97 def digestAuth (realm, algorithm = MD5, nonce = None, qop = AUTH): |
| 98 """Challenges the client for a Digest authentication.""" |
| 99 global SUPPORTED_ALGORITHM, DIGEST_AUTH_ENCODERS, SUPPORTED_QOP |
| 100 assert algorithm in SUPPORTED_ALGORITHM |
| 101 assert qop in SUPPORTED_QOP |
| 102 |
| 103 if nonce is None: |
| 104 nonce = calculateNonce (realm, algorithm) |
| 105 |
| 106 return 'Digest realm="%s", nonce="%s", algorithm="%s", qop="%s"' % ( |
| 107 realm, nonce, algorithm, qop |
| 108 ) |
| 109 |
| 110 def basicAuth (realm): |
| 111 """Challengenes the client for a Basic authentication.""" |
| 112 assert '"' not in realm, "Realms cannot contain the \" (quote) character." |
| 113 |
| 114 return 'Basic realm="%s"' % realm |
| 115 |
| 116 def doAuth (realm): |
| 117 """'doAuth' function returns the challenge string b giving priority over |
| 118 Digest and fallback to Basic authentication when the browser doesn't |
| 119 support the first one. |
| 120 |
| 121 This should be set in the HTTP header under the key 'WWW-Authenticate'.""" |
| 122 |
| 123 return digestAuth (realm) + " " + basicAuth (realm) |
| 124 |
| 125 |
| 126 ################################################################################ |
| 127 # Parse authorization parameters |
| 128 # |
| 129 def _parseDigestAuthorization (auth_params): |
| 130 # Convert the auth params to a dict |
| 131 items = parse_http_list(auth_params) |
| 132 params = parse_keqv_list(items) |
| 133 |
| 134 # Now validate the params |
| 135 |
| 136 # Check for required parameters |
| 137 required = ["username", "realm", "nonce", "uri", "response"] |
| 138 for k in required: |
| 139 if k not in params: |
| 140 return None |
| 141 |
| 142 # If qop is sent then cnonce and nc MUST be present |
| 143 if "qop" in params and not ("cnonce" in params \ |
| 144 and "nc" in params): |
| 145 return None |
| 146 |
| 147 # If qop is not sent, neither cnonce nor nc can be present |
| 148 if ("cnonce" in params or "nc" in params) and \ |
| 149 "qop" not in params: |
| 150 return None |
| 151 |
| 152 return params |
| 153 |
| 154 |
| 155 def _parseBasicAuthorization (auth_params): |
| 156 username, password = base64_decode(auth_params).split(":", 1) |
| 157 return {"username": username, "password": password} |
| 158 |
| 159 AUTH_SCHEMES = { |
| 160 "basic": _parseBasicAuthorization, |
| 161 "digest": _parseDigestAuthorization, |
| 162 } |
| 163 |
| 164 def parseAuthorization (credentials): |
| 165 """parseAuthorization will convert the value of the 'Authorization' key in |
| 166 the HTTP header to a map itself. If the parsing fails 'None' is returned. |
| 167 """ |
| 168 |
| 169 global AUTH_SCHEMES |
| 170 |
| 171 auth_scheme, auth_params = credentials.split(" ", 1) |
| 172 auth_scheme = auth_scheme.lower () |
| 173 |
| 174 parser = AUTH_SCHEMES[auth_scheme] |
| 175 params = parser (auth_params) |
| 176 |
| 177 if params is None: |
| 178 return |
| 179 |
| 180 assert "auth_scheme" not in params |
| 181 params["auth_scheme"] = auth_scheme |
| 182 return params |
| 183 |
| 184 |
| 185 ################################################################################ |
| 186 # Check provided response for a valid password |
| 187 # |
| 188 def md5SessionKey (params, password): |
| 189 """ |
| 190 If the "algorithm" directive's value is "MD5-sess", then A1 |
| 191 [the session key] is calculated only once - on the first request by the |
| 192 client following receipt of a WWW-Authenticate challenge from the server. |
| 193 |
| 194 This creates a 'session key' for the authentication of subsequent |
| 195 requests and responses which is different for each "authentication |
| 196 session", thus limiting the amount of material hashed with any one |
| 197 key. |
| 198 |
| 199 Because the server need only use the hash of the user |
| 200 credentials in order to create the A1 value, this construction could |
| 201 be used in conjunction with a third party authentication service so |
| 202 that the web server would not need the actual password value. The |
| 203 specification of such a protocol is beyond the scope of this |
| 204 specification. |
| 205 """ |
| 206 |
| 207 keys = ("username", "realm", "nonce", "cnonce") |
| 208 params_copy = {} |
| 209 for key in keys: |
| 210 params_copy[key] = params[key] |
| 211 |
| 212 params_copy["algorithm"] = MD5_SESS |
| 213 return _A1 (params_copy, password) |
| 214 |
| 215 def _A1(params, password): |
| 216 algorithm = params.get ("algorithm", MD5) |
| 217 H = DIGEST_AUTH_ENCODERS[algorithm] |
| 218 |
| 219 if algorithm == MD5: |
| 220 # If the "algorithm" directive's value is "MD5" or is |
| 221 # unspecified, then A1 is: |
| 222 # A1 = unq(username-value) ":" unq(realm-value) ":" passwd |
| 223 return "%s:%s:%s" % (params["username"], params["realm"], password) |
| 224 |
| 225 elif algorithm == MD5_SESS: |
| 226 |
| 227 # This is A1 if qop is set |
| 228 # A1 = H( unq(username-value) ":" unq(realm-value) ":" passwd ) |
| 229 # ":" unq(nonce-value) ":" unq(cnonce-value) |
| 230 h_a1 = H ("%s:%s:%s" % (params["username"], params["realm"], password)) |
| 231 return "%s:%s:%s" % (h_a1, params["nonce"], params["cnonce"]) |
| 232 |
| 233 |
| 234 def _A2(params, method, kwargs): |
| 235 # If the "qop" directive's value is "auth" or is unspecified, then A2 is: |
| 236 # A2 = Method ":" digest-uri-value |
| 237 |
| 238 qop = params.get ("qop", "auth") |
| 239 if qop == "auth": |
| 240 return method + ":" + params["uri"] |
| 241 elif qop == "auth-int": |
| 242 # If the "qop" value is "auth-int", then A2 is: |
| 243 # A2 = Method ":" digest-uri-value ":" H(entity-body) |
| 244 entity_body = kwargs.get ("entity_body", "") |
| 245 H = kwargs["H"] |
| 246 |
| 247 return "%s:%s:%s" % ( |
| 248 method, |
| 249 params["uri"], |
| 250 H(entity_body) |
| 251 ) |
| 252 |
| 253 else: |
| 254 raise NotImplementedError ("The 'qop' method is unknown: %s" % qop) |
| 255 |
| 256 def _computeDigestResponse(auth_map, password, method = "GET", A1 = None,**kwarg
s): |
| 257 """ |
| 258 Generates a response respecting the algorithm defined in RFC 2617 |
| 259 """ |
| 260 params = auth_map |
| 261 |
| 262 algorithm = params.get ("algorithm", MD5) |
| 263 |
| 264 H = DIGEST_AUTH_ENCODERS[algorithm] |
| 265 KD = lambda secret, data: H(secret + ":" + data) |
| 266 |
| 267 qop = params.get ("qop", None) |
| 268 |
| 269 H_A2 = H(_A2(params, method, kwargs)) |
| 270 |
| 271 if algorithm == MD5_SESS and A1 is not None: |
| 272 H_A1 = H(A1) |
| 273 else: |
| 274 H_A1 = H(_A1(params, password)) |
| 275 |
| 276 if qop in ("auth", "auth-int"): |
| 277 # If the "qop" value is "auth" or "auth-int": |
| 278 # request-digest = <"> < KD ( H(A1), unq(nonce-value) |
| 279 # ":" nc-value |
| 280 # ":" unq(cnonce-value) |
| 281 # ":" unq(qop-value) |
| 282 # ":" H(A2) |
| 283 # ) <"> |
| 284 request = "%s:%s:%s:%s:%s" % ( |
| 285 params["nonce"], |
| 286 params["nc"], |
| 287 params["cnonce"], |
| 288 params["qop"], |
| 289 H_A2, |
| 290 ) |
| 291 elif qop is None: |
| 292 # If the "qop" directive is not present (this construction is |
| 293 # for compatibility with RFC 2069): |
| 294 # request-digest = |
| 295 # <"> < KD ( H(A1), unq(nonce-value) ":" H(A2) ) > <"> |
| 296 request = "%s:%s" % (params["nonce"], H_A2) |
| 297 |
| 298 return KD(H_A1, request) |
| 299 |
| 300 def _checkDigestResponse(auth_map, password, method = "GET", A1 = None, **kwargs
): |
| 301 """This function is used to verify the response given by the client when |
| 302 he tries to authenticate. |
| 303 Optional arguments: |
| 304 entity_body - when 'qop' is set to 'auth-int' you MUST provide the |
| 305 raw data you are going to send to the client (usually the |
| 306 HTML page. |
| 307 request_uri - the uri from the request line compared with the 'uri' |
| 308 directive of the authorization map. They must represent |
| 309 the same resource (unused at this time). |
| 310 """ |
| 311 |
| 312 if auth_map['realm'] != kwargs.get('realm', None): |
| 313 return False |
| 314 |
| 315 response = _computeDigestResponse(auth_map, password, method, A1,**kwargs) |
| 316 |
| 317 return response == auth_map["response"] |
| 318 |
| 319 def _checkBasicResponse (auth_map, password, method='GET', encrypt=None, **kwarg
s): |
| 320 # Note that the Basic response doesn't provide the realm value so we cannot |
| 321 # test it |
| 322 try: |
| 323 return encrypt(auth_map["password"], auth_map["username"]) == password |
| 324 except TypeError: |
| 325 return encrypt(auth_map["password"]) == password |
| 326 |
| 327 AUTH_RESPONSES = { |
| 328 "basic": _checkBasicResponse, |
| 329 "digest": _checkDigestResponse, |
| 330 } |
| 331 |
| 332 def checkResponse (auth_map, password, method = "GET", encrypt=None, **kwargs): |
| 333 """'checkResponse' compares the auth_map with the password and optionally |
| 334 other arguments that each implementation might need. |
| 335 |
| 336 If the response is of type 'Basic' then the function has the following |
| 337 signature:: |
| 338 |
| 339 checkBasicResponse (auth_map, password) -> bool |
| 340 |
| 341 If the response is of type 'Digest' then the function has the following |
| 342 signature:: |
| 343 |
| 344 checkDigestResponse (auth_map, password, method = 'GET', A1 = None) -> b
ool |
| 345 |
| 346 The 'A1' argument is only used in MD5_SESS algorithm based responses. |
| 347 Check md5SessionKey() for more info. |
| 348 """ |
| 349 checker = AUTH_RESPONSES[auth_map["auth_scheme"]] |
| 350 return checker (auth_map, password, method=method, encrypt=encrypt, **kwargs
) |
| 351 |
| 352 |
| 353 |
| 354 |
OLD | NEW |