1 from __future__ import generators 2 """ 3 httplib2 4 5 A caching http interface that supports ETags and gzip 6 to conserve bandwidth. 7 8 Requires Python 2.3 or later 9 10 Changelog: 11 2007-08-18, Rick: Modified so it's able to use a socks proxy if needed. 12 13 """ 14 15 __author__ = "Joe Gregorio (joe (at] bitworking.org)" 16 __copyright__ = "Copyright 2006, Joe Gregorio" 17 __contributors__ = ["Thomas Broyer (t.broyer (at] ltgt.net)", 18 "James Antill", 19 "Xavier Verges Farrero", 20 "Jonathan Feinberg", 21 "Blair Zajac", 22 "Sam Ruby", 23 "Louis Nyffenegger"] 24 __license__ = "MIT" 25 __version__ = "$Rev$" 26 27 import re 28 import sys 29 import email 30 import email.Utils 31 import email.Message 32 import email.FeedParser 33 import StringIO 34 import gzip 35 import zlib 36 import httplib 37 import urlparse 38 import base64 39 import os 40 import copy 41 import calendar 42 import time 43 import random 44 # remove depracated warning in python2.6 45 try: 46 from hashlib import sha1 as _sha, md5 as _md5 47 except ImportError: 48 import sha 49 import md5 50 _sha = sha.new 51 _md5 = md5.new 52 import hmac 53 from gettext import gettext as _ 54 import socket 55 56 try: 57 import socks 58 except ImportError: 59 socks = None 60 61 # Build the appropriate socket wrapper for ssl 62 try: 63 import ssl # python 2.6 64 _ssl_wrap_socket = ssl.wrap_socket 65 except ImportError: 66 def _ssl_wrap_socket(sock, key_file, cert_file): 67 ssl_sock = socket.ssl(sock, key_file, cert_file) 68 return httplib.FakeSocket(sock, ssl_sock) 69 70 71 if sys.version_info >= (2,3): 72 from iri2uri import iri2uri 73 else: 74 def iri2uri(uri): 75 return uri 76 77 def has_timeout(timeout): # python 2.6 78 if hasattr(socket, '_GLOBAL_DEFAULT_TIMEOUT'): 79 return (timeout is not None and timeout is not socket._GLOBAL_DEFAULT_TIMEOUT) 80 return (timeout is not None) 81 82 __all__ = ['Http', 'Response', 'ProxyInfo', 'HttpLib2Error', 83 'RedirectMissingLocation', 'RedirectLimit', 'FailedToDecompressContent', 84 'UnimplementedDigestAuthOptionError', 'UnimplementedHmacDigestAuthOptionError', 85 'debuglevel'] 86 87 88 # The httplib debug level, set to a non-zero value to get debug output 89 debuglevel = 0 90 91 92 # Python 2.3 support 93 if sys.version_info < (2,4): 94 def sorted(seq): 95 seq.sort() 96 return seq 97 98 # Python 2.3 support 99 def HTTPResponse__getheaders(self): 100 """Return list of (header, value) tuples.""" 101 if self.msg is None: 102 raise httplib.ResponseNotReady() 103 return self.msg.items() 104 105 if not hasattr(httplib.HTTPResponse, 'getheaders'): 106 httplib.HTTPResponse.getheaders = HTTPResponse__getheaders 107 108 # All exceptions raised here derive from HttpLib2Error 109 class HttpLib2Error(Exception): pass 110 111 # Some exceptions can be caught and optionally 112 # be turned back into responses. 113 class HttpLib2ErrorWithResponse(HttpLib2Error): 114 def __init__(self, desc, response, content): 115 self.response = response 116 self.content = content 117 HttpLib2Error.__init__(self, desc) 118 119 class RedirectMissingLocation(HttpLib2ErrorWithResponse): pass 120 class RedirectLimit(HttpLib2ErrorWithResponse): pass 121 class FailedToDecompressContent(HttpLib2ErrorWithResponse): pass 122 class UnimplementedDigestAuthOptionError(HttpLib2ErrorWithResponse): pass 123 class UnimplementedHmacDigestAuthOptionError(HttpLib2ErrorWithResponse): pass 124 125 class RelativeURIError(HttpLib2Error): pass 126 class ServerNotFoundError(HttpLib2Error): pass 127 128 # Open Items: 129 # ----------- 130 # Proxy support 131 132 # Are we removing the cached content too soon on PUT (only delete on 200 Maybe?) 133 134 # Pluggable cache storage (supports storing the cache in 135 # flat files by default. We need a plug-in architecture 136 # that can support Berkeley DB and Squid) 137 138 # == Known Issues == 139 # Does not handle a resource that uses conneg and Last-Modified but no ETag as a cache validator. 140 # Does not handle Cache-Control: max-stale 141 # Does not use Age: headers when calculating cache freshness. 142 143 144 # The number of redirections to follow before giving up. 145 # Note that only GET redirects are automatically followed. 146 # Will also honor 301 requests by saving that info and never 147 # requesting that URI again. 148 DEFAULT_MAX_REDIRECTS = 5 149 150 # Which headers are hop-by-hop headers by default 151 HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 'upgrade'] 152 153 def _get_end2end_headers(response): 154 hopbyhop = list(HOP_BY_HOP) 155 hopbyhop.extend([x.strip() for x in response.get('connection', '').split(',')]) 156 return [header for header in response.keys() if header not in hopbyhop] 157 158 URI = re.compile(r"^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?") 159 160 def parse_uri(uri): 161 """Parses a URI using the regex given in Appendix B of RFC 3986. 162 163 (scheme, authority, path, query, fragment) = parse_uri(uri) 164 """ 165 groups = URI.match(uri).groups() 166 return (groups[1], groups[3], groups[4], groups[6], groups[8]) 167 168 def urlnorm(uri): 169 (scheme, authority, path, query, fragment) = parse_uri(uri) 170 if not scheme or not authority: 171 raise RelativeURIError("Only absolute URIs are allowed. uri = %s" % uri) 172 authority = authority.lower() 173 scheme = scheme.lower() 174 if not path: 175 path = "/" 176 # Could do syntax based normalization of the URI before 177 # computing the digest. See Section 6.2.2 of Std 66. 178 request_uri = query and "?".join([path, query]) or path 179 scheme = scheme.lower() 180 defrag_uri = scheme + "://" + authority + request_uri 181 return scheme, authority, request_uri, defrag_uri 182 183 184 # Cache filename construction (original borrowed from Venus http://intertwingly.net/code/venus/) 185 re_url_scheme = re.compile(r'^\w+://') 186 re_slash = re.compile(r'[?/:|]+') 187 188 def safename(filename): 189 """Return a filename suitable for the cache. 190 191 Strips dangerous and common characters to create a filename we 192 can use to store the cache in. 193 """ 194 195 try: 196 if re_url_scheme.match(filename): 197 if isinstance(filename,str): 198 filename = filename.decode('utf-8') 199 filename = filename.encode('idna') 200 else: 201 filename = filename.encode('idna') 202 except UnicodeError: 203 pass 204 if isinstance(filename,unicode): 205 filename=filename.encode('utf-8') 206 filemd5 = _md5(filename).hexdigest() 207 filename = re_url_scheme.sub("", filename) 208 filename = re_slash.sub(",", filename) 209 210 # limit length of filename 211 if len(filename)>200: 212 filename=filename[:200] 213 return ",".join((filename, filemd5)) 214 215 NORMALIZE_SPACE = re.compile(r'(?:\r\n)?[ \t]+') 216 def _normalize_headers(headers): 217 return dict([ (key.lower(), NORMALIZE_SPACE.sub(value, ' ').strip()) for (key, value) in headers.iteritems()]) 218 219 def _parse_cache_control(headers): 220 retval = {} 221 if headers.has_key('cache-control'): 222 parts = headers['cache-control'].split(',') 223 parts_with_args = [tuple([x.strip().lower() for x in part.split("=", 1)]) for part in parts if -1 != part.find("=")] 224 parts_wo_args = [(name.strip().lower(), 1) for name in parts if -1 == name.find("=")] 225 retval = dict(parts_with_args + parts_wo_args) 226 return retval 227 228 # Whether to use a strict mode to parse WWW-Authenticate headers 229 # Might lead to bad results in case of ill-formed header value, 230 # so disabled by default, falling back to relaxed parsing. 231 # Set to true to turn on, usefull for testing servers. 232 USE_WWW_AUTH_STRICT_PARSING = 0 233 234 # In regex below: 235 # [^\0-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+ matches a "token" as defined by HTTP 236 # "(?:[^\0-\x08\x0A-\x1f\x7f-\xff\\\"]|\\[\0-\x7f])*?" matches a "quoted-string" as defined by HTTP, when LWS have already been replaced by a single space 237 # Actually, as an auth-param value can be either a token or a quoted-string, they are combined in a single pattern which matches both: 238 # \"?((?<=\")(?:[^\0-\x1f\x7f-\xff\\\"]|\\[\0-\x7f])*?(?=\")|(?<!\")[^\0-\x08\x0A-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+(?!\"))\"? 239 WWW_AUTH_STRICT = re.compile(r"^(?:\s*(?:,\s*)?([^\0-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+)\s*=\s*\"?((?<=\")(?:[^\0-\x08\x0A-\x1f\x7f-\xff\\\"]|\\[\0-\x7f])*?(?=\")|(?<!\")[^\0-\x1f\x7f-\xff()<>@,;:\\\"/[\]?={} \t]+(?!\"))\"?)(.*)$") 240 WWW_AUTH_RELAXED = re.compile(r"^(?:\s*(?:,\s*)?([^ \t\r\n=]+)\s*=\s*\"?((?<=\")(?:[^\\\"]|\\.)*?(?=\")|(?<!\")[^ \t\r\n,]+(?!\"))\"?)(.*)$") 241 UNQUOTE_PAIRS = re.compile(r'\\(.)') 242 def _parse_www_authenticate(headers, headername='www-authenticate'): 243 """Returns a dictionary of dictionaries, one dict 244 per auth_scheme.""" 245 retval = {} 246 if headers.has_key(headername): 247 authenticate = headers[headername].strip() 248 www_auth = USE_WWW_AUTH_STRICT_PARSING and WWW_AUTH_STRICT or WWW_AUTH_RELAXED 249 while authenticate: 250 # Break off the scheme at the beginning of the line 251 if headername == 'authentication-info': 252 (auth_scheme, the_rest) = ('digest', authenticate) 253 else: 254 (auth_scheme, the_rest) = authenticate.split(" ", 1) 255 # Now loop over all the key value pairs that come after the scheme, 256 # being careful not to roll into the next scheme 257 match = www_auth.search(the_rest) 258 auth_params = {} 259 while match: 260 if match and len(match.groups()) == 3: 261 (key, value, the_rest) = match.groups() 262 auth_params[key.lower()] = UNQUOTE_PAIRS.sub(r'\1', value) # '\\'.join([x.replace('\\', '') for x in value.split('\\\\')]) 263 match = www_auth.search(the_rest) 264 retval[auth_scheme.lower()] = auth_params 265 authenticate = the_rest.strip() 266 return retval 267 268 269 def _entry_disposition(response_headers, request_headers): 270 """Determine freshness from the Date, Expires and Cache-Control headers. 271 272 We don't handle the following: 273 274 1. Cache-Control: max-stale 275 2. Age: headers are not used in the calculations. 276 277 Not that this algorithm is simpler than you might think 278 because we are operating as a private (non-shared) cache. 279 This lets us ignore 's-maxage'. We can also ignore 280 'proxy-invalidate' since we aren't a proxy. 281 We will never return a stale document as 282 fresh as a design decision, and thus the non-implementation 283 of 'max-stale'. This also lets us safely ignore 'must-revalidate' 284 since we operate as if every server has sent 'must-revalidate'. 285 Since we are private we get to ignore both 'public' and 286 'private' parameters. We also ignore 'no-transform' since 287 we don't do any transformations. 288 The 'no-store' parameter is handled at a higher level. 289 So the only Cache-Control parameters we look at are: 290 291 no-cache 292 only-if-cached 293 max-age 294 min-fresh 295 """ 296 297 retval = "STALE" 298 cc = _parse_cache_control(request_headers) 299 cc_response = _parse_cache_control(response_headers) 300 301 if request_headers.has_key('pragma') and request_headers['pragma'].lower().find('no-cache') != -1: 302 retval = "TRANSPARENT" 303 if 'cache-control' not in request_headers: 304 request_headers['cache-control'] = 'no-cache' 305 elif cc.has_key('no-cache'): 306 retval = "TRANSPARENT" 307 elif cc_response.has_key('no-cache'): 308 retval = "STALE" 309 elif cc.has_key('only-if-cached'): 310 retval = "FRESH" 311 elif response_headers.has_key('date'): 312 date = calendar.timegm(email.Utils.parsedate_tz(response_headers['date'])) 313 now = time.time() 314 current_age = max(0, now - date) 315 if cc_response.has_key('max-age'): 316 try: 317 freshness_lifetime = int(cc_response['max-age']) 318 except ValueError: 319 freshness_lifetime = 0 320 elif response_headers.has_key('expires'): 321 expires = email.Utils.parsedate_tz(response_headers['expires']) 322 if None == expires: 323 freshness_lifetime = 0 324 else: 325 freshness_lifetime = max(0, calendar.timegm(expires) - date) 326 else: 327 freshness_lifetime = 0 328 if cc.has_key('max-age'): 329 try: 330 freshness_lifetime = int(cc['max-age']) 331 except ValueError: 332 freshness_lifetime = 0 333 if cc.has_key('min-fresh'): 334 try: 335 min_fresh = int(cc['min-fresh']) 336 except ValueError: 337 min_fresh = 0 338 current_age += min_fresh 339 if freshness_lifetime > current_age: 340 retval = "FRESH" 341 return retval 342 343 def _decompressContent(response, new_content): 344 content = new_content 345 try: 346 encoding = response.get('content-encoding', None) 347 if encoding in ['gzip', 'deflate']: 348 if encoding == 'gzip': 349 content = gzip.GzipFile(fileobj=StringIO.StringIO(new_content)).read() 350 if encoding == 'deflate': 351 content = zlib.decompress(content) 352 response['content-length'] = str(len(content)) 353 # Record the historical presence of the encoding in a way the won't interfere. 354 response['-content-encoding'] = response['content-encoding'] 355 del response['content-encoding'] 356 except IOError: 357 content = "" 358 raise FailedToDecompressContent(_("Content purported to be compressed with %s but failed to decompress.") % response.get('content-encoding'), response, content) 359 return content 360 361 def _updateCache(request_headers, response_headers, content, cache, cachekey): 362 if cachekey: 363 cc = _parse_cache_control(request_headers) 364 cc_response = _parse_cache_control(response_headers) 365 if cc.has_key('no-store') or cc_response.has_key('no-store'): 366 cache.delete(cachekey) 367 else: 368 info = email.Message.Message() 369 for key, value in response_headers.iteritems(): 370 if key not in ['status','content-encoding','transfer-encoding']: 371 info[key] = value 372 373 # Add annotations to the cache to indicate what headers 374 # are variant for this request. 375 vary = response_headers.get('vary', None) 376 if vary: 377 vary_headers = vary.lower().replace(' ', '').split(',') 378 for header in vary_headers: 379 key = '-varied-%s' % header 380 try: 381 info[key] = request_headers[header] 382 except KeyError: 383 pass 384 385 status = response_headers.status 386 if status == 304: 387 status = 200 388 389 status_header = 'status: %d\r\n' % response_headers.status 390 391 header_str = info.as_string() 392 393 header_str = re.sub("\r(?!\n)|(?<!\r)\n", "\r\n", header_str) 394 text = "".join([status_header, header_str, content]) 395 396 cache.set(cachekey, text) 397 398 def _cnonce(): 399 dig = _md5("%s:%s" % (time.ctime(), ["0123456789"[random.randrange(0, 9)] for i in range(20)])).hexdigest() 400 return dig[:16] 401 402 def _wsse_username_token(cnonce, iso_now, password): 403 return base64.b64encode(_sha("%s%s%s" % (cnonce, iso_now, password)).digest()).strip() 404 405 406 # For credentials we need two things, first 407 # a pool of credential to try (not necesarily tied to BAsic, Digest, etc.) 408 # Then we also need a list of URIs that have already demanded authentication 409 # That list is tricky since sub-URIs can take the same auth, or the 410 # auth scheme may change as you descend the tree. 411 # So we also need each Auth instance to be able to tell us 412 # how close to the 'top' it is. 413 414 class Authentication(object): 415 def __init__(self, credentials, host, request_uri, headers, response, content, http): 416 (scheme, authority, path, query, fragment) = parse_uri(request_uri) 417 self.path = path 418 self.host = host 419 self.credentials = credentials 420 self.http = http 421 422 def depth(self, request_uri): 423 (scheme, authority, path, query, fragment) = parse_uri(request_uri) 424 return request_uri[len(self.path):].count("/") 425 426 def inscope(self, host, request_uri): 427 # XXX Should we normalize the request_uri? 428 (scheme, authority, path, query, fragment) = parse_uri(request_uri) 429 return (host == self.host) and path.startswith(self.path) 430 431 def request(self, method, request_uri, headers, content): 432 """Modify the request headers to add the appropriate 433 Authorization header. Over-rise this in sub-classes.""" 434 pass 435 436 def response(self, response, content): 437 """Gives us a chance to update with new nonces 438 or such returned from the last authorized response. 439 Over-rise this in sub-classes if necessary. 440 441 Return TRUE is the request is to be retried, for 442 example Digest may return stale=true. 443 """ 444 return False 445 446 447 448 class BasicAuthentication(Authentication): 449 def __init__(self, credentials, host, request_uri, headers, response, content, http): 450 Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http) 451 452 def request(self, method, request_uri, headers, content): 453 """Modify the request headers to add the appropriate 454 Authorization header.""" 455 headers['authorization'] = 'Basic ' + base64.b64encode("%s:%s" % self.credentials).strip() 456 457 458 class DigestAuthentication(Authentication): 459 """Only do qop='auth' and MD5, since that 460 is all Apache currently implements""" 461 def __init__(self, credentials, host, request_uri, headers, response, content, http): 462 Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http) 463 challenge = _parse_www_authenticate(response, 'www-authenticate') 464 self.challenge = challenge['digest'] 465 qop = self.challenge.get('qop', 'auth') 466 self.challenge['qop'] = ('auth' in [x.strip() for x in qop.split()]) and 'auth' or None 467 if self.challenge['qop'] is None: 468 raise UnimplementedDigestAuthOptionError( _("Unsupported value for qop: %s." % qop)) 469 self.challenge['algorithm'] = self.challenge.get('algorithm', 'MD5').upper() 470 if self.challenge['algorithm'] != 'MD5': 471 raise UnimplementedDigestAuthOptionError( _("Unsupported value for algorithm: %s." % self.challenge['algorithm'])) 472 self.A1 = "".join([self.credentials[0], ":", self.challenge['realm'], ":", self.credentials[1]]) 473 self.challenge['nc'] = 1 474 475 def request(self, method, request_uri, headers, content, cnonce = None): 476 """Modify the request headers""" 477 H = lambda x: _md5(x).hexdigest() 478 KD = lambda s, d: H("%s:%s" % (s, d)) 479 A2 = "".join([method, ":", request_uri]) 480 self.challenge['cnonce'] = cnonce or _cnonce() 481 request_digest = '"%s"' % KD(H(self.A1), "%s:%s:%s:%s:%s" % (self.challenge['nonce'], 482 '%08x' % self.challenge['nc'], 483 self.challenge['cnonce'], 484 self.challenge['qop'], H(A2) 485 )) 486 headers['Authorization'] = 'Digest username="%s", realm="%s", nonce="%s", uri="%s", algorithm=%s, response=%s, qop=%s, nc=%08x, cnonce="%s"' % ( 487 self.credentials[0], 488 self.challenge['realm'], 489 self.challenge['nonce'], 490 request_uri, 491 self.challenge['algorithm'], 492 request_digest, 493 self.challenge['qop'], 494 self.challenge['nc'], 495 self.challenge['cnonce'], 496 ) 497 self.challenge['nc'] += 1 498 499 def response(self, response, content): 500 if not response.has_key('authentication-info'): 501 challenge = _parse_www_authenticate(response, 'www-authenticate').get('digest', {}) 502 if 'true' == challenge.get('stale'): 503 self.challenge['nonce'] = challenge['nonce'] 504 self.challenge['nc'] = 1 505 return True 506 else: 507 updated_challenge = _parse_www_authenticate(response, 'authentication-info').get('digest', {}) 508 509 if updated_challenge.has_key('nextnonce'): 510 self.challenge['nonce'] = updated_challenge['nextnonce'] 511 self.challenge['nc'] = 1 512 return False 513 514 515 class HmacDigestAuthentication(Authentication): 516 """Adapted from Robert Sayre's code and DigestAuthentication above.""" 517 __author__ = "Thomas Broyer (t.broyer (at] ltgt.net)" 518 519 def __init__(self, credentials, host, request_uri, headers, response, content, http): 520 Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http) 521 challenge = _parse_www_authenticate(response, 'www-authenticate') 522 self.challenge = challenge['hmacdigest'] 523 # TODO: self.challenge['domain'] 524 self.challenge['reason'] = self.challenge.get('reason', 'unauthorized') 525 if self.challenge['reason'] not in ['unauthorized', 'integrity']: 526 self.challenge['reason'] = 'unauthorized' 527 self.challenge['salt'] = self.challenge.get('salt', '') 528 if not self.challenge.get('snonce'): 529 raise UnimplementedHmacDigestAuthOptionError( _("The challenge doesn't contain a server nonce, or this one is empty.")) 530 self.challenge['algorithm'] = self.challenge.get('algorithm', 'HMAC-SHA-1') 531 if self.challenge['algorithm'] not in ['HMAC-SHA-1', 'HMAC-MD5']: 532 raise UnimplementedHmacDigestAuthOptionError( _("Unsupported value for algorithm: %s." % self.challenge['algorithm'])) 533 self.challenge['pw-algorithm'] = self.challenge.get('pw-algorithm', 'SHA-1') 534 if self.challenge['pw-algorithm'] not in ['SHA-1', 'MD5']: 535 raise UnimplementedHmacDigestAuthOptionError( _("Unsupported value for pw-algorithm: %s." % self.challenge['pw-algorithm'])) 536 if self.challenge['algorithm'] == 'HMAC-MD5': 537 self.hashmod = _md5 538 else: 539 self.hashmod = _sha 540 if self.challenge['pw-algorithm'] == 'MD5': 541 self.pwhashmod = _md5 542 else: 543 self.pwhashmod = _sha 544 self.key = "".join([self.credentials[0], ":", 545 self.pwhashmod.new("".join([self.credentials[1], self.challenge['salt']])).hexdigest().lower(), 546 ":", self.challenge['realm'] 547 ]) 548 self.key = self.pwhashmod.new(self.key).hexdigest().lower() 549 550 def request(self, method, request_uri, headers, content): 551 """Modify the request headers""" 552 keys = _get_end2end_headers(headers) 553 keylist = "".join(["%s " % k for k in keys]) 554 headers_val = "".join([headers[k] for k in keys]) 555 created = time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()) 556 cnonce = _cnonce() 557 request_digest = "%s:%s:%s:%s:%s" % (method, request_uri, cnonce, self.challenge['snonce'], headers_val) 558 request_digest = hmac.new(self.key, request_digest, self.hashmod).hexdigest().lower() 559 headers['Authorization'] = 'HMACDigest username="%s", realm="%s", snonce="%s", cnonce="%s", uri="%s", created="%s", response="%s", headers="%s"' % ( 560 self.credentials[0], 561 self.challenge['realm'], 562 self.challenge['snonce'], 563 cnonce, 564 request_uri, 565 created, 566 request_digest, 567 keylist, 568 ) 569 570 def response(self, response, content): 571 challenge = _parse_www_authenticate(response, 'www-authenticate').get('hmacdigest', {}) 572 if challenge.get('reason') in ['integrity', 'stale']: 573 return True 574 return False 575 576 577 class WsseAuthentication(Authentication): 578 """This is thinly tested and should not be relied upon. 579 At this time there isn't any third party server to test against. 580 Blogger and TypePad implemented this algorithm at one point 581 but Blogger has since switched to Basic over HTTPS and 582 TypePad has implemented it wrong, by never issuing a 401 583 challenge but instead requiring your client to telepathically know that 584 their endpoint is expecting WSSE profile="UsernameToken".""" 585 def __init__(self, credentials, host, request_uri, headers, response, content, http): 586 Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http) 587 588 def request(self, method, request_uri, headers, content): 589 """Modify the request headers to add the appropriate 590 Authorization header.""" 591 headers['Authorization'] = 'WSSE profile="UsernameToken"' 592 iso_now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) 593 cnonce = _cnonce() 594 password_digest = _wsse_username_token(cnonce, iso_now, self.credentials[1]) 595 headers['X-WSSE'] = 'UsernameToken Username="%s", PasswordDigest="%s", Nonce="%s", Created="%s"' % ( 596 self.credentials[0], 597 password_digest, 598 cnonce, 599 iso_now) 600 601 class GoogleLoginAuthentication(Authentication): 602 def __init__(self, credentials, host, request_uri, headers, response, content, http): 603 from urllib import urlencode 604 Authentication.__init__(self, credentials, host, request_uri, headers, response, content, http) 605 challenge = _parse_www_authenticate(response, 'www-authenticate') 606 service = challenge['googlelogin'].get('service', 'xapi') 607 # Bloggger actually returns the service in the challenge 608 # For the rest we guess based on the URI 609 if service == 'xapi' and request_uri.find("calendar") > 0: 610 service = "cl" 611 # No point in guessing Base or Spreadsheet 612 #elif request_uri.find("spreadsheets") > 0: 613 # service = "wise" 614 615 auth = dict(Email=credentials[0], Passwd=credentials[1], service=service, source=headers['user-agent']) 616 resp, content = self.http.request("https://www.google.com/accounts/ClientLogin", method="POST", body=urlencode(auth), headers={'Content-Type': 'application/x-www-form-urlencoded'}) 617 lines = content.split('\n') 618 d = dict([tuple(line.split("=", 1)) for line in lines if line]) 619 if resp.status == 403: 620 self.Auth = "" 621 else: 622 self.Auth = d['Auth'] 623 624 def request(self, method, request_uri, headers, content): 625 """Modify the request headers to add the appropriate 626 Authorization header.""" 627 headers['authorization'] = 'GoogleLogin Auth=' + self.Auth 628 629 630 AUTH_SCHEME_CLASSES = { 631 "basic": BasicAuthentication, 632 "wsse": WsseAuthentication, 633 "digest": DigestAuthentication, 634 "hmacdigest": HmacDigestAuthentication, 635 "googlelogin": GoogleLoginAuthentication 636 } 637 638 AUTH_SCHEME_ORDER = ["hmacdigest", "googlelogin", "digest", "wsse", "basic"] 639 640 class FileCache(object): 641 """Uses a local directory as a store for cached files. 642 Not really safe to use if multiple threads or processes are going to 643 be running on the same cache. 644 """ 645 def __init__(self, cache, safe=safename): # use safe=lambda x: md5.new(x).hexdigest() for the old behavior 646 self.cache = cache 647 self.safe = safe 648 if not os.path.exists(cache): 649 os.makedirs(self.cache) 650 651 def get(self, key): 652 retval = None 653 cacheFullPath = os.path.join(self.cache, self.safe(key)) 654 try: 655 f = file(cacheFullPath, "rb") 656 retval = f.read() 657 f.close() 658 except IOError: 659 pass 660 return retval 661 662 def set(self, key, value): 663 cacheFullPath = os.path.join(self.cache, self.safe(key)) 664 f = file(cacheFullPath, "wb") 665 f.write(value) 666 f.close() 667 668 def delete(self, key): 669 cacheFullPath = os.path.join(self.cache, self.safe(key)) 670 if os.path.exists(cacheFullPath): 671 os.remove(cacheFullPath) 672 673 class Credentials(object): 674 def __init__(self): 675 self.credentials = [] 676 677 def add(self, name, password, domain=""): 678 self.credentials.append((domain.lower(), name, password)) 679 680 def clear(self): 681 self.credentials = [] 682 683 def iter(self, domain): 684 for (cdomain, name, password) in self.credentials: 685 if cdomain == "" or domain == cdomain: 686 yield (name, password) 687 688 class KeyCerts(Credentials): 689 """Identical to Credentials except that 690 name/password are mapped to key/cert.""" 691 pass 692 693 694 class ProxyInfo(object): 695 """Collect information required to use a proxy.""" 696 def __init__(self, proxy_type, proxy_host, proxy_port, proxy_rdns=None, proxy_user=None, proxy_pass=None): 697 """The parameter proxy_type must be set to one of socks.PROXY_TYPE_XXX 698 constants. For example: 699 700 p = ProxyInfo(proxy_type=socks.PROXY_TYPE_HTTP, proxy_host='localhost', proxy_port=8000) 701 """ 702 self.proxy_type, self.proxy_host, self.proxy_port, self.proxy_rdns, self.proxy_user, self.proxy_pass = proxy_type, proxy_host, proxy_port, proxy_rdns, proxy_user, proxy_pass 703 704 def astuple(self): 705 return (self.proxy_type, self.proxy_host, self.proxy_port, self.proxy_rdns, 706 self.proxy_user, self.proxy_pass) 707 708 def isgood(self): 709 return socks and (self.proxy_host != None) and (self.proxy_port != None) 710 711 712 class HTTPConnectionWithTimeout(httplib.HTTPConnection): 713 """HTTPConnection subclass that supports timeouts""" 714 715 def __init__(self, host, port=None, strict=None, timeout=None, proxy_info=None): 716 httplib.HTTPConnection.__init__(self, host, port, strict) 717 self.timeout = timeout 718 self.proxy_info = proxy_info 719 720 def connect(self): 721 """Connect to the host and port specified in __init__.""" 722 # Mostly verbatim from httplib.py. 723 msg = "getaddrinfo returns an empty list" 724 for res in socket.getaddrinfo(self.host, self.port, 0, 725 socket.SOCK_STREAM): 726 af, socktype, proto, canonname, sa = res 727 try: 728 if self.proxy_info and self.proxy_info.isgood(): 729 self.sock = socks.socksocket(af, socktype, proto) 730 self.sock.setproxy(*self.proxy_info.astuple()) 731 else: 732 self.sock = socket.socket(af, socktype, proto) 733 # Different from httplib: support timeouts. 734 if has_timeout(self.timeout): 735 self.sock.settimeout(self.timeout) 736 # End of difference from httplib. 737 if self.debuglevel > 0: 738 print "connect: (%s, %s)" % (self.host, self.port) 739 740 self.sock.connect(sa) 741 except socket.error, msg: 742 if self.debuglevel > 0: 743 print 'connect fail:', (self.host, self.port) 744 if self.sock: 745 self.sock.close() 746 self.sock = None 747 continue 748 break 749 if not self.sock: 750 raise socket.error, msg 751 752 class HTTPSConnectionWithTimeout(httplib.HTTPSConnection): 753 "This class allows communication via SSL." 754 755 def __init__(self, host, port=None, key_file=None, cert_file=None, 756 strict=None, timeout=None, proxy_info=None): 757 httplib.HTTPSConnection.__init__(self, host, port=port, key_file=key_file, 758 cert_file=cert_file, strict=strict) 759 self.timeout = timeout 760 self.proxy_info = proxy_info 761 762 def connect(self): 763 "Connect to a host on a given (SSL) port." 764 765 if self.proxy_info and self.proxy_info.isgood(): 766 sock = socks.socksocket(socket.AF_INET, socket.SOCK_STREAM) 767 sock.setproxy(*self.proxy_info.astuple()) 768 else: 769 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 770 771 if has_timeout(self.timeout): 772 sock.settimeout(self.timeout) 773 sock.connect((self.host, self.port)) 774 self.sock =_ssl_wrap_socket(sock, self.key_file, self.cert_file) 775 776 777 778 class Http(object): 779 """An HTTP client that handles: 780 - all methods 781 - caching 782 - ETags 783 - compression, 784 - HTTPS 785 - Basic 786 - Digest 787 - WSSE 788 789 and more. 790 """ 791 def __init__(self, cache=None, timeout=None, proxy_info=None): 792 """The value of proxy_info is a ProxyInfo instance. 793 794 If 'cache' is a string then it is used as a directory name 795 for a disk cache. Otherwise it must be an object that supports 796 the same interface as FileCache.""" 797 self.proxy_info = proxy_info 798 # Map domain name to an httplib connection 799 self.connections = {} 800 # The location of the cache, for now a directory 801 # where cached responses are held. 802 if cache and isinstance(cache, str): 803 self.cache = FileCache(cache) 804 else: 805 self.cache = cache 806 807 # Name/password 808 self.credentials = Credentials() 809 810 # Key/cert 811 self.certificates = KeyCerts() 812 813 # authorization objects 814 self.authorizations = [] 815 816 # If set to False then no redirects are followed, even safe ones. 817 self.follow_redirects = True 818 819 # Which HTTP methods do we apply optimistic concurrency to, i.e. 820 # which methods get an "if-match:" etag header added to them. 821 self.optimistic_concurrency_methods = ["PUT"] 822 823 # If 'follow_redirects' is True, and this is set to True then 824 # all redirecs are followed, including unsafe ones. 825 self.follow_all_redirects = False 826 827 self.ignore_etag = False 828 829 self.force_exception_to_status_code = False 830 831 self.timeout = timeout 832 833 def _auth_from_challenge(self, host, request_uri, headers, response, content): 834 """A generator that creates Authorization objects 835 that can be applied to requests. 836 """ 837 challenges = _parse_www_authenticate(response, 'www-authenticate') 838 for cred in self.credentials.iter(host): 839 for scheme in AUTH_SCHEME_ORDER: 840 if challenges.has_key(scheme): 841 yield AUTH_SCHEME_CLASSES[scheme](cred, host, request_uri, headers, response, content, self) 842 843 def add_credentials(self, name, password, domain=""): 844 """Add a name and password that will be used 845 any time a request requires authentication.""" 846 self.credentials.add(name, password, domain) 847 848 def add_certificate(self, key, cert, domain): 849 """Add a key and cert that will be used 850 any time a request requires authentication.""" 851 self.certificates.add(key, cert, domain) 852 853 def clear_credentials(self): 854 """Remove all the names and passwords 855 that are used for authentication""" 856 self.credentials.clear() 857 self.authorizations = [] 858 859 def _conn_request(self, conn, request_uri, method, body, headers): 860 for i in range(2): 861 try: 862 conn.request(method, request_uri, body, headers) 863 except socket.gaierror: 864 conn.close() 865 raise ServerNotFoundError("Unable to find the server at %s" % conn.host) 866 except (socket.error, httplib.HTTPException): 867 # Just because the server closed the connection doesn't apparently mean 868 # that the server didn't send a response. 869 pass 870 try: 871 response = conn.getresponse() 872 except (socket.error, httplib.HTTPException): 873 if i == 0: 874 conn.close() 875 conn.connect() 876 continue 877 else: 878 raise 879 else: 880 content = "" 881 if method == "HEAD": 882 response.close() 883 else: 884 content = response.read() 885 response = Response(response) 886 if method != "HEAD": 887 content = _decompressContent(response, content) 888 break 889 return (response, content) 890 891 892 def _request(self, conn, host, absolute_uri, request_uri, method, body, headers, redirections, cachekey): 893 """Do the actual request using the connection object 894 and also follow one level of redirects if necessary""" 895 896 auths = [(auth.depth(request_uri), auth) for auth in self.authorizations if auth.inscope(host, request_uri)] 897 auth = auths and sorted(auths)[0][1] or None 898 if auth: 899 auth.request(method, request_uri, headers, body) 900 901 (response, content) = self._conn_request(conn, request_uri, method, body, headers) 902 903 if auth: 904 if auth.response(response, body): 905 auth.request(method, request_uri, headers, body) 906 (response, content) = self._conn_request(conn, request_uri, method, body, headers ) 907 response._stale_digest = 1 908 909 if response.status == 401: 910 for authorization in self._auth_from_challenge(host, request_uri, headers, response, content): 911 authorization.request(method, request_uri, headers, body) 912 (response, content) = self._conn_request(conn, request_uri, method, body, headers, ) 913 if response.status != 401: 914 self.authorizations.append(authorization) 915 authorization.response(response, body) 916 break 917 918 if (self.follow_all_redirects or (method in ["GET", "HEAD"]) or response.status == 303): 919 if self.follow_redirects and response.status in [300, 301, 302, 303, 307]: 920 # Pick out the location header and basically start from the beginning 921 # remembering first to strip the ETag header and decrement our 'depth' 922 if redirections: 923 if not response.has_key('location') and response.status != 300: 924 raise RedirectMissingLocation( _("Redirected but the response is missing a Location: header."), response, content) 925 # Fix-up relative redirects (which violate an RFC 2616 MUST) 926 if response.has_key('location'): 927 location = response['location'] 928 (scheme, authority, path, query, fragment) = parse_uri(location) 929 if authority == None: 930 response['location'] = urlparse.urljoin(absolute_uri, location) 931 if response.status == 301 and method in ["GET", "HEAD"]: 932 response['-x-permanent-redirect-url'] = response['location'] 933 if not response.has_key('content-location'): 934 response['content-location'] = absolute_uri 935 _updateCache(headers, response, content, self.cache, cachekey) 936 if headers.has_key('if-none-match'): 937 del headers['if-none-match'] 938 if headers.has_key('if-modified-since'): 939 del headers['if-modified-since'] 940 if response.has_key('location'): 941 location = response['location'] 942 old_response = copy.deepcopy(response) 943 if not old_response.has_key('content-location'): 944 old_response['content-location'] = absolute_uri 945 redirect_method = ((response.status == 303) and (method not in ["GET", "HEAD"])) and "GET" or method 946 (response, content) = self.request(location, redirect_method, body=body, headers = headers, redirections = redirections - 1) 947 response.previous = old_response 948 else: 949 raise RedirectLimit( _("Redirected more times than rediection_limit allows."), response, content) 950 elif response.status in [200, 203] and method == "GET": 951 # Don't cache 206's since we aren't going to handle byte range requests 952 if not response.has_key('content-location'): 953 response['content-location'] = absolute_uri 954 _updateCache(headers, response, content, self.cache, cachekey) 955 956 return (response, content) 957 958 def _normalize_headers(self, headers): 959 return _normalize_headers(headers) 960 961 # Need to catch and rebrand some exceptions 962 # Then need to optionally turn all exceptions into status codes 963 # including all socket.* and httplib.* exceptions. 964 965 966 def request(self, uri, method="GET", body=None, headers=None, redirections=DEFAULT_MAX_REDIRECTS, connection_type=None): 967 """ Performs a single HTTP request. 968 The 'uri' is the URI of the HTTP resource and can begin 969 with either 'http' or 'https'. The value of 'uri' must be an absolute URI. 970 971 The 'method' is the HTTP method to perform, such as GET, POST, DELETE, etc. 972 There is no restriction on the methods allowed. 973 974 The 'body' is the entity body to be sent with the request. It is a string 975 object. 976 977 Any extra headers that are to be sent with the request should be provided in the 978 'headers' dictionary. 979 980 The maximum number of redirect to follow before raising an 981 exception is 'redirections. The default is 5. 982 983 The return value is a tuple of (response, content), the first 984 being and instance of the 'Response' class, the second being 985 a string that contains the response entity body. 986 """ 987 try: 988 if headers is None: 989 headers = {} 990 else: 991 headers = self._normalize_headers(headers) 992 993 if not headers.has_key('user-agent'): 994 headers['user-agent'] = "Python-httplib2/%s" % __version__ 995 996 uri = iri2uri(uri) 997 998 (scheme, authority, request_uri, defrag_uri) = urlnorm(uri) 999 domain_port = authority.split(":")[0:2] 1000 if len(domain_port) == 2 and domain_port[1] == '443' and scheme == 'http': 1001 scheme = 'https' 1002 authority = domain_port[0] 1003 1004 conn_key = scheme+":"+authority 1005 if conn_key in self.connections: 1006 conn = self.connections[conn_key] 1007 else: 1008 if not connection_type: 1009 connection_type = (scheme == 'https') and HTTPSConnectionWithTimeout or HTTPConnectionWithTimeout 1010 certs = list(self.certificates.iter(authority)) 1011 if scheme == 'https' and certs: 1012 conn = self.connections[conn_key] = connection_type(authority, key_file=certs[0][0], 1013 cert_file=certs[0][1], timeout=self.timeout, proxy_info=self.proxy_info) 1014 else: 1015 conn = self.connections[conn_key] = connection_type(authority, timeout=self.timeout, proxy_info=self.proxy_info) 1016 conn.set_debuglevel(debuglevel) 1017 1018 if method in ["GET", "HEAD"] and 'range' not in headers and 'accept-encoding' not in headers: 1019 headers['accept-encoding'] = 'gzip, deflate' 1020 1021 info = email.Message.Message() 1022 cached_value = None 1023 if self.cache: 1024 cachekey = defrag_uri 1025 cached_value = self.cache.get(cachekey) 1026 if cached_value: 1027 # info = email.message_from_string(cached_value) 1028 # 1029 # Need to replace the line above with the kludge below 1030 # to fix the non-existent bug not fixed in this 1031 # bug report: http://mail.python.org/pipermail/python-bugs-list/2005-September/030289.html 1032 try: 1033 info, content = cached_value.split('\r\n\r\n', 1) 1034 feedparser = email.FeedParser.FeedParser() 1035 feedparser.feed(info) 1036 info = feedparser.close() 1037 feedparser._parse = None 1038 except IndexError: 1039 self.cache.delete(cachekey) 1040 cachekey = None 1041 cached_value = None 1042 else: 1043 cachekey = None 1044 1045 if method in self.optimistic_concurrency_methods and self.cache and info.has_key('etag') and not self.ignore_etag and 'if-match' not in headers: 1046 # http://www.w3.org/1999/04/Editing/ 1047 headers['if-match'] = info['etag'] 1048 1049 if method not in ["GET", "HEAD"] and self.cache and cachekey: 1050 # RFC 2616 Section 13.10 1051 self.cache.delete(cachekey) 1052 1053 # Check the vary header in the cache to see if this request 1054 # matches what varies in the cache. 1055 if method in ['GET', 'HEAD'] and 'vary' in info: 1056 vary = info['vary'] 1057 vary_headers = vary.lower().replace(' ', '').split(',') 1058 for header in vary_headers: 1059 key = '-varied-%s' % header 1060 value = info[key] 1061 if headers.get(header, '') != value: 1062 cached_value = None 1063 break 1064 1065 if cached_value and method in ["GET", "HEAD"] and self.cache and 'range' not in headers: 1066 if info.has_key('-x-permanent-redirect-url'): 1067 # Should cached permanent redirects be counted in our redirection count? For now, yes. 1068 (response, new_content) = self.request(info['-x-permanent-redirect-url'], "GET", headers = headers, redirections = redirections - 1) 1069 response.previous = Response(info) 1070 response.previous.fromcache = True 1071 else: 1072 # Determine our course of action: 1073 # Is the cached entry fresh or stale? 1074 # Has the client requested a non-cached response? 1075 # 1076 # There seems to be three possible answers: 1077 # 1. [FRESH] Return the cache entry w/o doing a GET 1078 # 2. [STALE] Do the GET (but add in cache validators if available) 1079 # 3. [TRANSPARENT] Do a GET w/o any cache validators (Cache-Control: no-cache) on the request 1080 entry_disposition = _entry_disposition(info, headers) 1081 1082 if entry_disposition == "FRESH": 1083 if not cached_value: 1084 info['status'] = '504' 1085 content = "" 1086 response = Response(info) 1087 if cached_value: 1088 response.fromcache = True 1089 return (response, content) 1090 1091 if entry_disposition == "STALE": 1092 if info.has_key('etag') and not self.ignore_etag and not 'if-none-match' in headers: 1093 headers['if-none-match'] = info['etag'] 1094 if info.has_key('last-modified') and not 'last-modified' in headers: 1095 headers['if-modified-since'] = info['last-modified'] 1096 elif entry_disposition == "TRANSPARENT": 1097 pass 1098 1099 (response, new_content) = self._request(conn, authority, uri, request_uri, method, body, headers, redirections, cachekey) 1100 1101 if response.status == 304 and method == "GET": 1102 # Rewrite the cache entry with the new end-to-end headers 1103 # Take all headers that are in response 1104 # and overwrite their values in info. 1105 # unless they are hop-by-hop, or are listed in the connection header. 1106 1107 for key in _get_end2end_headers(response): 1108 info[key] = response[key] 1109 merged_response = Response(info) 1110 if hasattr(response, "_stale_digest"): 1111 merged_response._stale_digest = response._stale_digest 1112 _updateCache(headers, merged_response, content, self.cache, cachekey) 1113 response = merged_response 1114 response.status = 200 1115 response.fromcache = True 1116 1117 elif response.status == 200: 1118 content = new_content 1119 else: 1120 self.cache.delete(cachekey) 1121 content = new_content 1122 else: 1123 cc = _parse_cache_control(headers) 1124 if cc.has_key('only-if-cached'): 1125 info['status'] = '504' 1126 response = Response(info) 1127 content = "" 1128 else: 1129 (response, content) = self._request(conn, authority, uri, request_uri, method, body, headers, redirections, cachekey) 1130 except Exception, e: 1131 if self.force_exception_to_status_code: 1132 if isinstance(e, HttpLib2ErrorWithResponse): 1133 response = e.response 1134 content = e.content 1135 response.status = 500 1136 response.reason = str(e) 1137 elif isinstance(e, socket.timeout): 1138 content = "Request Timeout" 1139 response = Response( { 1140 "content-type": "text/plain", 1141 "status": "408", 1142 "content-length": len(content) 1143 }) 1144 response.reason = "Request Timeout" 1145 else: 1146 content = str(e) 1147 response = Response( { 1148 "content-type": "text/plain", 1149 "status": "400", 1150 "content-length": len(content) 1151 }) 1152 response.reason = "Bad Request" 1153 else: 1154 raise 1155 1156 1157 return (response, content) 1158 1159 1160 1161 class Response(dict): 1162 """An object more like email.Message than httplib.HTTPResponse.""" 1163 1164 """Is this response from our local cache""" 1165 fromcache = False 1166 1167 """HTTP protocol version used by server. 10 for HTTP/1.0, 11 for HTTP/1.1. """ 1168 version = 11 1169 1170 "Status code returned by server. " 1171 status = 200 1172 1173 """Reason phrase returned by server.""" 1174 reason = "Ok" 1175 1176 previous = None 1177 1178 def __init__(self, info): 1179 # info is either an email.Message or 1180 # an httplib.HTTPResponse object. 1181 if isinstance(info, httplib.HTTPResponse): 1182 for key, value in info.getheaders(): 1183 self[key.lower()] = value 1184 self.status = info.status 1185 self['status'] = str(self.status) 1186 self.reason = info.reason 1187 self.version = info.version 1188 elif isinstance(info, email.Message.Message): 1189 for key, value in info.items(): 1190 self[key] = value 1191 self.status = int(self['status']) 1192 else: 1193 for key, value in info.iteritems(): 1194 self[key] = value 1195 self.status = int(self.get('status', self.status)) 1196 1197 1198 def __getattr__(self, name): 1199 if name == 'dict': 1200 return self 1201 else: 1202 raise AttributeError, name 1203