1 """ 2 The MIT License 3 4 Copyright (c) 2007-2010 Leah Culver, Joe Stump, Mark Paschal, Vic Fryzel 5 6 Permission is hereby granted, free of charge, to any person obtaining a copy 7 of this software and associated documentation files (the "Software"), to deal 8 in the Software without restriction, including without limitation the rights 9 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 copies of the Software, and to permit persons to whom the Software is 11 furnished to do so, subject to the following conditions: 12 13 The above copyright notice and this permission notice shall be included in 14 all copies or substantial portions of the Software. 15 16 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 THE SOFTWARE. 23 """ 24 25 import urllib 26 import time 27 import random 28 import urlparse 29 import hmac 30 import binascii 31 import httplib2 32 33 try: 34 from urlparse import parse_qs, parse_qsl 35 except ImportError: 36 from cgi import parse_qs, parse_qsl 37 38 39 VERSION = '1.0' # Hi Blaine! 40 HTTP_METHOD = 'GET' 41 SIGNATURE_METHOD = 'PLAINTEXT' 42 43 44 class Error(RuntimeError): 45 """Generic exception class.""" 46 47 def __init__(self, message='OAuth error occurred.'): 48 self._message = message 49 50 @property 51 def message(self): 52 """A hack to get around the deprecation errors in 2.6.""" 53 return self._message 54 55 def __str__(self): 56 return self._message 57 58 59 class MissingSignature(Error): 60 pass 61 62 63 def build_authenticate_header(realm=''): 64 """Optional WWW-Authenticate header (401 error)""" 65 return {'WWW-Authenticate': 'OAuth realm="%s"' % realm} 66 67 68 def build_xoauth_string(url, consumer, token=None): 69 """Build an XOAUTH string for use in SMTP/IMPA authentication.""" 70 request = Request.from_consumer_and_token(consumer, token, 71 "GET", url) 72 73 signing_method = SignatureMethod_HMAC_SHA1() 74 request.sign_request(signing_method, consumer, token) 75 76 params = [] 77 for k, v in sorted(request.iteritems()): 78 if v is not None: 79 params.append('%s="%s"' % (k, escape(v))) 80 81 return "%s %s %s" % ("GET", url, ','.join(params)) 82 83 84 def escape(s): 85 """Escape a URL including any /.""" 86 return urllib.quote(s, safe='~') 87 88 89 def generate_timestamp(): 90 """Get seconds since epoch (UTC).""" 91 return int(time.time()) 92 93 94 def generate_nonce(length=8): 95 """Generate pseudorandom number.""" 96 return ''.join([str(random.randint(0, 9)) for i in range(length)]) 97 98 99 def generate_verifier(length=8): 100 """Generate pseudorandom number.""" 101 return ''.join([str(random.randint(0, 9)) for i in range(length)]) 102 103 104 class Consumer(object): 105 """A consumer of OAuth-protected services. 106 107 The OAuth consumer is a "third-party" service that wants to access 108 protected resources from an OAuth service provider on behalf of an end 109 user. It's kind of the OAuth client. 110 111 Usually a consumer must be registered with the service provider by the 112 developer of the consumer software. As part of that process, the service 113 provider gives the consumer a *key* and a *secret* with which the consumer 114 software can identify itself to the service. The consumer will include its 115 key in each request to identify itself, but will use its secret only when 116 signing requests, to prove that the request is from that particular 117 registered consumer. 118 119 Once registered, the consumer can then use its consumer credentials to ask 120 the service provider for a request token, kicking off the OAuth 121 authorization process. 122 """ 123 124 key = None 125 secret = None 126 127 def __init__(self, key, secret): 128 self.key = key 129 self.secret = secret 130 131 if self.key is None or self.secret is None: 132 raise ValueError("Key and secret must be set.") 133 134 def __str__(self): 135 data = {'oauth_consumer_key': self.key, 136 'oauth_consumer_secret': self.secret} 137 138 return urllib.urlencode(data) 139 140 141 class Token(object): 142 """An OAuth credential used to request authorization or a protected 143 resource. 144 145 Tokens in OAuth comprise a *key* and a *secret*. The key is included in 146 requests to identify the token being used, but the secret is used only in 147 the signature, to prove that the requester is who the server gave the 148 token to. 149 150 When first negotiating the authorization, the consumer asks for a *request 151 token* that the live user authorizes with the service provider. The 152 consumer then exchanges the request token for an *access token* that can 153 be used to access protected resources. 154 """ 155 156 key = None 157 secret = None 158 callback = None 159 callback_confirmed = None 160 verifier = None 161 162 def __init__(self, key, secret): 163 self.key = key 164 self.secret = secret 165 166 if self.key is None or self.secret is None: 167 raise ValueError("Key and secret must be set.") 168 169 def set_callback(self, callback): 170 self.callback = callback 171 self.callback_confirmed = 'true' 172 173 def set_verifier(self, verifier=None): 174 if verifier is not None: 175 self.verifier = verifier 176 else: 177 self.verifier = generate_verifier() 178 179 def get_callback_url(self): 180 if self.callback and self.verifier: 181 # Append the oauth_verifier. 182 parts = urlparse.urlparse(self.callback) 183 scheme, netloc, path, params, query, fragment = parts[:6] 184 if query: 185 query = '%s&oauth_verifier=%s' % (query, self.verifier) 186 else: 187 query = 'oauth_verifier=%s' % self.verifier 188 return urlparse.urlunparse((scheme, netloc, path, params, 189 query, fragment)) 190 return self.callback 191 192 def to_string(self): 193 """Returns this token as a plain string, suitable for storage. 194 195 The resulting string includes the token's secret, so you should never 196 send or store this string where a third party can read it. 197 """ 198 199 data = { 200 'oauth_token': self.key, 201 'oauth_token_secret': self.secret, 202 } 203 204 if self.callback_confirmed is not None: 205 data['oauth_callback_confirmed'] = self.callback_confirmed 206 return urllib.urlencode(data) 207 208 @staticmethod 209 def from_string(s): 210 """Deserializes a token from a string like one returned by 211 `to_string()`.""" 212 213 if not len(s): 214 raise ValueError("Invalid parameter string.") 215 216 params = parse_qs(s, keep_blank_values=False) 217 if not len(params): 218 raise ValueError("Invalid parameter string.") 219 220 try: 221 key = params['oauth_token'][0] 222 except Exception: 223 raise ValueError("'oauth_token' not found in OAuth request.") 224 225 try: 226 secret = params['oauth_token_secret'][0] 227 except Exception: 228 raise ValueError("'oauth_token_secret' not found in " 229 "OAuth request.") 230 231 token = Token(key, secret) 232 try: 233 token.callback_confirmed = params['oauth_callback_confirmed'][0] 234 except KeyError: 235 pass # 1.0, no callback confirmed. 236 return token 237 238 def __str__(self): 239 return self.to_string() 240 241 242 def setter(attr): 243 name = attr.__name__ 244 245 def getter(self): 246 try: 247 return self.__dict__[name] 248 except KeyError: 249 raise AttributeError(name) 250 251 def deleter(self): 252 del self.__dict__[name] 253 254 return property(getter, attr, deleter) 255 256 257 class Request(dict): 258 259 """The parameters and information for an HTTP request, suitable for 260 authorizing with OAuth credentials. 261 262 When a consumer wants to access a service's protected resources, it does 263 so using a signed HTTP request identifying itself (the consumer) with its 264 key, and providing an access token authorized by the end user to access 265 those resources. 266 267 """ 268 269 version = VERSION 270 271 def __init__(self, method=HTTP_METHOD, url=None, parameters=None): 272 self.method = method 273 self.url = url 274 if parameters is not None: 275 self.update(parameters) 276 277 @setter 278 def url(self, value): 279 self.__dict__['url'] = value 280 if value is not None: 281 scheme, netloc, path, params, query, fragment = urlparse.urlparse(value) 282 283 # Exclude default port numbers. 284 if scheme == 'http' and netloc[-3:] == ':80': 285 netloc = netloc[:-3] 286 elif scheme == 'https' and netloc[-4:] == ':443': 287 netloc = netloc[:-4] 288 if scheme not in ('http', 'https'): 289 raise ValueError("Unsupported URL %s (%s)." % (value, scheme)) 290 291 # Normalized URL excludes params, query, and fragment. 292 self.normalized_url = urlparse.urlunparse((scheme, netloc, path, None, None, None)) 293 else: 294 self.normalized_url = None 295 self.__dict__['url'] = None 296 297 @setter 298 def method(self, value): 299 self.__dict__['method'] = value.upper() 300 301 def _get_timestamp_nonce(self): 302 return self['oauth_timestamp'], self['oauth_nonce'] 303 304 def get_nonoauth_parameters(self): 305 """Get any non-OAuth parameters.""" 306 return dict([(k, v) for k, v in self.iteritems() 307 if not k.startswith('oauth_')]) 308 309 def to_header(self, realm=''): 310 """Serialize as a header for an HTTPAuth request.""" 311 oauth_params = ((k, v) for k, v in self.items() 312 if k.startswith('oauth_')) 313 stringy_params = ((k, escape(str(v))) for k, v in oauth_params) 314 header_params = ('%s="%s"' % (k, v) for k, v in stringy_params) 315 params_header = ', '.join(header_params) 316 317 auth_header = 'OAuth realm="%s"' % realm 318 if params_header: 319 auth_header = "%s, %s" % (auth_header, params_header) 320 321 return {'Authorization': auth_header} 322 323 def to_postdata(self): 324 """Serialize as post data for a POST request.""" 325 # tell urlencode to deal with sequence values and map them correctly 326 # to resulting querystring. for example self["k"] = ["v1", "v2"] will 327 # result in 'k=v1&k=v2' and not k=%5B%27v1%27%2C+%27v2%27%5D 328 return urllib.urlencode(self, True).replace('+', '%20') 329 330 def to_url(self): 331 """Serialize as a URL for a GET request.""" 332 base_url = urlparse.urlparse(self.url) 333 try: 334 query = base_url.query 335 except AttributeError: 336 # must be python <2.5 337 query = base_url[4] 338 query = parse_qs(query) 339 for k, v in self.items(): 340 query.setdefault(k, []).append(v) 341 342 try: 343 scheme = base_url.scheme 344 netloc = base_url.netloc 345 path = base_url.path 346 params = base_url.params 347 fragment = base_url.fragment 348 except AttributeError: 349 # must be python <2.5 350 scheme = base_url[0] 351 netloc = base_url[1] 352 path = base_url[2] 353 params = base_url[3] 354 fragment = base_url[5] 355 356 url = (scheme, netloc, path, params, 357 urllib.urlencode(query, True), fragment) 358 return urlparse.urlunparse(url) 359 360 def get_parameter(self, parameter): 361 ret = self.get(parameter) 362 if ret is None: 363 raise Error('Parameter not found: %s' % parameter) 364 365 return ret 366 367 def get_normalized_parameters(self): 368 """Return a string that contains the parameters that must be signed.""" 369 items = [] 370 for key, value in self.iteritems(): 371 if key == 'oauth_signature': 372 continue 373 # 1.0a/9.1.1 states that kvp must be sorted by key, then by value, 374 # so we unpack sequence values into multiple items for sorting. 375 if hasattr(value, '__iter__'): 376 items.extend((key, item) for item in value) 377 else: 378 items.append((key, value)) 379 380 # Include any query string parameters from the provided URL 381 query = urlparse.urlparse(self.url)[4] 382 383 url_items = self._split_url_string(query).items() 384 non_oauth_url_items = list([(k, v) for k, v in url_items if not k.startswith('oauth_')]) 385 items.extend(non_oauth_url_items) 386 387 encoded_str = urllib.urlencode(sorted(items)) 388 # Encode signature parameters per Oauth Core 1.0 protocol 389 # spec draft 7, section 3.6 390 # (http://tools.ietf.org/html/draft-hammer-oauth-07#section-3.6) 391 # Spaces must be encoded with "%20" instead of "+" 392 return encoded_str.replace('+', '%20').replace('%7E', '~') 393 394 def sign_request(self, signature_method, consumer, token): 395 """Set the signature parameter to the result of sign.""" 396 397 if 'oauth_consumer_key' not in self: 398 self['oauth_consumer_key'] = consumer.key 399 400 if token and 'oauth_token' not in self: 401 self['oauth_token'] = token.key 402 403 self['oauth_signature_method'] = signature_method.name 404 self['oauth_signature'] = signature_method.sign(self, consumer, token) 405 406 @classmethod 407 def make_timestamp(cls): 408 """Get seconds since epoch (UTC).""" 409 return str(int(time.time())) 410 411 @classmethod 412 def make_nonce(cls): 413 """Generate pseudorandom number.""" 414 return str(random.randint(0, 100000000)) 415 416 @classmethod 417 def from_request(cls, http_method, http_url, headers=None, parameters=None, 418 query_string=None): 419 """Combines multiple parameter sources.""" 420 if parameters is None: 421 parameters = {} 422 423 # Headers 424 if headers and 'Authorization' in headers: 425 auth_header = headers['Authorization'] 426 # Check that the authorization header is OAuth. 427 if auth_header[:6] == 'OAuth ': 428 auth_header = auth_header[6:] 429 try: 430 # Get the parameters from the header. 431 header_params = cls._split_header(auth_header) 432 parameters.update(header_params) 433 except: 434 raise Error('Unable to parse OAuth parameters from ' 435 'Authorization header.') 436 437 # GET or POST query string. 438 if query_string: 439 query_params = cls._split_url_string(query_string) 440 parameters.update(query_params) 441 442 # URL parameters. 443 param_str = urlparse.urlparse(http_url)[4] # query 444 url_params = cls._split_url_string(param_str) 445 parameters.update(url_params) 446 447 if parameters: 448 return cls(http_method, http_url, parameters) 449 450 return None 451 452 @classmethod 453 def from_consumer_and_token(cls, consumer, token=None, 454 http_method=HTTP_METHOD, http_url=None, parameters=None): 455 if not parameters: 456 parameters = {} 457 458 defaults = { 459 'oauth_consumer_key': consumer.key, 460 'oauth_timestamp': cls.make_timestamp(), 461 'oauth_nonce': cls.make_nonce(), 462 'oauth_version': cls.version, 463 } 464 465 defaults.update(parameters) 466 parameters = defaults 467 468 if token: 469 parameters['oauth_token'] = token.key 470 if token.verifier: 471 parameters['oauth_verifier'] = token.verifier 472 473 return Request(http_method, http_url, parameters) 474 475 @classmethod 476 def from_token_and_callback(cls, token, callback=None, 477 http_method=HTTP_METHOD, http_url=None, parameters=None): 478 479 if not parameters: 480 parameters = {} 481 482 parameters['oauth_token'] = token.key 483 484 if callback: 485 parameters['oauth_callback'] = callback 486 487 return cls(http_method, http_url, parameters) 488 489 @staticmethod 490 def _split_header(header): 491 """Turn Authorization: header into parameters.""" 492 params = {} 493 parts = header.split(',') 494 for param in parts: 495 # Ignore realm parameter. 496 if param.find('realm') > -1: 497 continue 498 # Remove whitespace. 499 param = param.strip() 500 # Split key-value. 501 param_parts = param.split('=', 1) 502 # Remove quotes and unescape the value. 503 params[param_parts[0]] = urllib.unquote(param_parts[1].strip('\"')) 504 return params 505 506 @staticmethod 507 def _split_url_string(param_str): 508 """Turn URL string into parameters.""" 509 parameters = parse_qs(param_str, keep_blank_values=False) 510 for k, v in parameters.iteritems(): 511 parameters[k] = urllib.unquote(v[0]) 512 return parameters 513 514 515 class Client(httplib2.Http): 516 """OAuthClient is a worker to attempt to execute a request.""" 517 518 def __init__(self, consumer, token=None, cache=None, timeout=None, 519 proxy_info=None): 520 521 if consumer is not None and not isinstance(consumer, Consumer): 522 raise ValueError("Invalid consumer.") 523 524 if token is not None and not isinstance(token, Token): 525 raise ValueError("Invalid token.") 526 527 self.consumer = consumer 528 self.token = token 529 self.method = SignatureMethod_HMAC_SHA1() 530 531 httplib2.Http.__init__(self, cache=cache, timeout=timeout, 532 proxy_info=proxy_info) 533 534 def set_signature_method(self, method): 535 if not isinstance(method, SignatureMethod): 536 raise ValueError("Invalid signature method.") 537 538 self.method = method 539 540 def request(self, uri, method="GET", body=None, headers=None, 541 redirections=httplib2.DEFAULT_MAX_REDIRECTS, connection_type=None): 542 DEFAULT_CONTENT_TYPE = 'application/x-www-form-urlencoded' 543 544 if not isinstance(headers, dict): 545 headers = {} 546 547 is_multipart = method == 'POST' and headers.get('Content-Type', 548 DEFAULT_CONTENT_TYPE) != DEFAULT_CONTENT_TYPE 549 550 if body and method == "POST" and not is_multipart: 551 parameters = dict(parse_qsl(body)) 552 else: 553 parameters = None 554 555 req = Request.from_consumer_and_token(self.consumer, 556 token=self.token, http_method=method, http_url=uri, 557 parameters=parameters) 558 559 req.sign_request(self.method, self.consumer, self.token) 560 561 if method == "POST": 562 headers['Content-Type'] = headers.get('Content-Type', 563 DEFAULT_CONTENT_TYPE) 564 if is_multipart: 565 headers.update(req.to_header()) 566 else: 567 body = req.to_postdata() 568 elif method == "GET": 569 uri = req.to_url() 570 else: 571 headers.update(req.to_header()) 572 573 return httplib2.Http.request(self, uri, method=method, body=body, 574 headers=headers, redirections=redirections, 575 connection_type=connection_type) 576 577 578 class Server(object): 579 """A skeletal implementation of a service provider, providing protected 580 resources to requests from authorized consumers. 581 582 This class implements the logic to check requests for authorization. You 583 can use it with your web server or web framework to protect certain 584 resources with OAuth. 585 """ 586 587 timestamp_threshold = 300 # In seconds, five minutes. 588 version = VERSION 589 signature_methods = None 590 591 def __init__(self, signature_methods=None): 592 self.signature_methods = signature_methods or {} 593 594 def add_signature_method(self, signature_method): 595 self.signature_methods[signature_method.name] = signature_method 596 return self.signature_methods 597 598 def verify_request(self, request, consumer, token): 599 """Verifies an api call and checks all the parameters.""" 600 601 version = self._get_version(request) 602 self._check_signature(request, consumer, token) 603 parameters = request.get_nonoauth_parameters() 604 return parameters 605 606 def build_authenticate_header(self, realm=''): 607 """Optional support for the authenticate header.""" 608 return {'WWW-Authenticate': 'OAuth realm="%s"' % realm} 609 610 def _get_version(self, request): 611 """Verify the correct version request for this server.""" 612 try: 613 version = request.get_parameter('oauth_version') 614 except: 615 version = VERSION 616 617 if version and version != self.version: 618 raise Error('OAuth version %s not supported.' % str(version)) 619 620 return version 621 622 def _get_signature_method(self, request): 623 """Figure out the signature with some defaults.""" 624 try: 625 signature_method = request.get_parameter('oauth_signature_method') 626 except: 627 signature_method = SIGNATURE_METHOD 628 629 try: 630 # Get the signature method object. 631 signature_method = self.signature_methods[signature_method] 632 except: 633 signature_method_names = ', '.join(self.signature_methods.keys()) 634 raise Error('Signature method %s not supported try one of the following: %s' % (signature_method, signature_method_names)) 635 636 return signature_method 637 638 def _get_verifier(self, request): 639 return request.get_parameter('oauth_verifier') 640 641 def _check_signature(self, request, consumer, token): 642 timestamp, nonce = request._get_timestamp_nonce() 643 self._check_timestamp(timestamp) 644 signature_method = self._get_signature_method(request) 645 646 try: 647 signature = request.get_parameter('oauth_signature') 648 except: 649 raise MissingSignature('Missing oauth_signature.') 650 651 # Validate the signature. 652 valid = signature_method.check(request, consumer, token, signature) 653 654 if not valid: 655 key, base = signature_method.signing_base(request, consumer, token) 656 657 raise Error('Invalid signature. Expected signature base ' 658 'string: %s' % base) 659 660 built = signature_method.sign(request, consumer, token) 661 662 def _check_timestamp(self, timestamp): 663 """Verify that timestamp is recentish.""" 664 timestamp = int(timestamp) 665 now = int(time.time()) 666 lapsed = now - timestamp 667 if lapsed > self.timestamp_threshold: 668 raise Error('Expired timestamp: given %d and now %s has a ' 669 'greater difference than threshold %d' % (timestamp, now, 670 self.timestamp_threshold)) 671 672 673 class SignatureMethod(object): 674 """A way of signing requests. 675 676 The OAuth protocol lets consumers and service providers pick a way to sign 677 requests. This interface shows the methods expected by the other `oauth` 678 modules for signing requests. Subclass it and implement its methods to 679 provide a new way to sign requests. 680 """ 681 682 def signing_base(self, request, consumer, token): 683 """Calculates the string that needs to be signed. 684 685 This method returns a 2-tuple containing the starting key for the 686 signing and the message to be signed. The latter may be used in error 687 messages to help clients debug their software. 688 689 """ 690 raise NotImplementedError 691 692 def sign(self, request, consumer, token): 693 """Returns the signature for the given request, based on the consumer 694 and token also provided. 695 696 You should use your implementation of `signing_base()` to build the 697 message to sign. Otherwise it may be less useful for debugging. 698 699 """ 700 raise NotImplementedError 701 702 def check(self, request, consumer, token, signature): 703 """Returns whether the given signature is the correct signature for 704 the given consumer and token signing the given request.""" 705 built = self.sign(request, consumer, token) 706 return built == signature 707 708 709 class SignatureMethod_HMAC_SHA1(SignatureMethod): 710 name = 'HMAC-SHA1' 711 712 def signing_base(self, request, consumer, token): 713 if request.normalized_url is None: 714 raise ValueError("Base URL for request is not set.") 715 716 sig = ( 717 escape(request.method), 718 escape(request.normalized_url), 719 escape(request.get_normalized_parameters()), 720 ) 721 722 key = '%s&' % escape(consumer.secret) 723 if token: 724 key += escape(token.secret) 725 raw = '&'.join(sig) 726 return key, raw 727 728 def sign(self, request, consumer, token): 729 """Builds the base signature string.""" 730 key, raw = self.signing_base(request, consumer, token) 731 732 # HMAC object. 733 try: 734 from hashlib import sha1 as sha 735 except ImportError: 736 import sha # Deprecated 737 738 hashed = hmac.new(key, raw, sha) 739 740 # Calculate the digest base 64. 741 return binascii.b2a_base64(hashed.digest())[:-1] 742 743 744 class SignatureMethod_PLAINTEXT(SignatureMethod): 745 746 name = 'PLAINTEXT' 747 748 def signing_base(self, request, consumer, token): 749 """Concatenates the consumer key and secret with the token's 750 secret.""" 751 sig = '%s&' % escape(consumer.secret) 752 if token: 753 sig = sig + escape(token.secret) 754 return sig, sig 755 756 def sign(self, request, consumer, token): 757 key, raw = self.signing_base(request, consumer, token) 758 return raw 759