Package restkit :: Module oauth2
[hide private]

Source Code for Module restkit.oauth2

  1  # -*- coding: utf-8 - 
  2  # 
  3  # This file is part of restkit released under the MIT license.  
  4  # See the NOTICE for more information. 
  5   
  6  import base64 
  7  import urllib 
  8  import time 
  9  import random 
 10  import urlparse 
 11  import hmac 
 12  import binascii 
 13   
 14  try: 
 15      from urlparse import parse_qs, parse_qsl 
 16  except ImportError: 
 17      from cgi import parse_qs, parse_qsl 
 18   
 19  from restkit.util import to_bytestring 
 20   
 21   
 22  try: 
 23      from hashlib import sha1 
 24      sha = sha1 
 25  except ImportError: 
 26      # hashlib was added in Python 2.5 
 27      import sha 
 28   
 29  from restkit.version import __version__ 
 30   
 31  OAUTH_VERSION = '1.0'  # Hi Blaine! 
 32  HTTP_METHOD = 'GET' 
 33  SIGNATURE_METHOD = 'PLAINTEXT' 
34 35 36 -class Error(RuntimeError):
37 """Generic exception class.""" 38
39 - def __init__(self, message='OAuth error occurred.'):
40 self._message = message
41 42 @property
43 - def message(self):
44 """A hack to get around the deprecation errors in 2.6.""" 45 return self._message
46
47 - def __str__(self):
48 return self._message
49
50 51 -class MissingSignature(Error):
52 pass
53
54 55 -def build_authenticate_header(realm=''):
56 """Optional WWW-Authenticate header (401 error)""" 57 return {'WWW-Authenticate': 'OAuth realm="%s"' % realm}
58
59 60 -def build_xoauth_string(url, consumer, token=None):
61 """Build an XOAUTH string for use in SMTP/IMPA authentication.""" 62 request = Request.from_consumer_and_token(consumer, token, 63 "GET", url) 64 65 signing_method = SignatureMethod_HMAC_SHA1() 66 request.sign_request(signing_method, consumer, token) 67 68 params = [] 69 for k, v in sorted(request.iteritems()): 70 if v is not None: 71 params.append('%s="%s"' % (k, escape(v))) 72 73 return "%s %s %s" % ("GET", url, ','.join(params))
74
75 76 -def to_unicode(s):
77 """ Convert to unicode, raise exception with instructive error 78 message if s is not unicode, ascii, or utf-8. """ 79 if not isinstance(s, unicode): 80 if not isinstance(s, str): 81 raise TypeError('You are required to pass either unicode or string here, not: %r (%s)' % (type(s), s)) 82 try: 83 s = s.decode('utf-8') 84 except UnicodeDecodeError, le: 85 raise TypeError('You are required to pass either a unicode object or a utf-8 string here. You passed a Python string object which contained non-utf-8: %r. The UnicodeDecodeError that resulted from attempting to interpret it as utf-8 was: %s' % (s, le,)) 86 return s
87
88 -def to_utf8(s):
89 return to_unicode(s).encode('utf-8')
90
91 -def to_unicode_if_string(s):
92 if isinstance(s, basestring): 93 return to_unicode(s) 94 else: 95 return s
96
97 -def to_utf8_if_string(s):
98 if isinstance(s, basestring): 99 return to_utf8(s) 100 else: 101 return s
102
103 -def to_unicode_optional_iterator(x):
104 """ 105 Raise TypeError if x is a str containing non-utf8 bytes or if x is 106 an iterable which contains such a str. 107 """ 108 if isinstance(x, basestring): 109 return to_unicode(x) 110 111 try: 112 l = list(x) 113 except TypeError, e: 114 assert 'is not iterable' in str(e) 115 return x 116 else: 117 return [ to_unicode(e) for e in l ]
118
119 -def to_utf8_optional_iterator(x):
120 """ 121 Raise TypeError if x is a str or if x is an iterable which 122 contains a str. 123 """ 124 if isinstance(x, basestring): 125 return to_utf8(x) 126 127 try: 128 l = list(x) 129 except TypeError, e: 130 assert 'is not iterable' in str(e) 131 return x 132 else: 133 return [ to_utf8_if_string(e) for e in l ]
134
135 -def escape(s):
136 """Escape a URL including any /.""" 137 return urllib.quote(s.encode('utf-8'), safe='~')
138
139 -def generate_timestamp():
140 """Get seconds since epoch (UTC).""" 141 return int(time.time())
142
143 144 -def generate_nonce(length=8):
145 """Generate pseudorandom number.""" 146 return ''.join([str(random.randint(0, 9)) for i in range(length)])
147
148 149 -def generate_verifier(length=8):
150 """Generate pseudorandom number.""" 151 return ''.join([str(random.randint(0, 9)) for i in range(length)])
152
153 154 -class Consumer(object):
155 """A consumer of OAuth-protected services. 156 157 The OAuth consumer is a "third-party" service that wants to access 158 protected resources from an OAuth service provider on behalf of an end 159 user. It's kind of the OAuth client. 160 161 Usually a consumer must be registered with the service provider by the 162 developer of the consumer software. As part of that process, the service 163 provider gives the consumer a *key* and a *secret* with which the consumer 164 software can identify itself to the service. The consumer will include its 165 key in each request to identify itself, but will use its secret only when 166 signing requests, to prove that the request is from that particular 167 registered consumer. 168 169 Once registered, the consumer can then use its consumer credentials to ask 170 the service provider for a request token, kicking off the OAuth 171 authorization process. 172 """ 173 174 key = None 175 secret = None 176
177 - def __init__(self, key, secret):
178 self.key = key 179 self.secret = secret 180 181 if self.key is None or self.secret is None: 182 raise ValueError("Key and secret must be set.")
183
184 - def __str__(self):
185 data = {'oauth_consumer_key': self.key, 186 'oauth_consumer_secret': self.secret} 187 188 return urllib.urlencode(data)
189
190 191 -class Token(object):
192 """An OAuth credential used to request authorization or a protected 193 resource. 194 195 Tokens in OAuth comprise a *key* and a *secret*. The key is included in 196 requests to identify the token being used, but the secret is used only in 197 the signature, to prove that the requester is who the server gave the 198 token to. 199 200 When first negotiating the authorization, the consumer asks for a *request 201 token* that the live user authorizes with the service provider. The 202 consumer then exchanges the request token for an *access token* that can 203 be used to access protected resources. 204 """ 205 206 key = None 207 secret = None 208 callback = None 209 callback_confirmed = None 210 verifier = None 211
212 - def __init__(self, key, secret):
213 self.key = key 214 self.secret = secret 215 216 if self.key is None or self.secret is None: 217 raise ValueError("Key and secret must be set.")
218
219 - def set_callback(self, callback):
220 self.callback = callback 221 self.callback_confirmed = 'true'
222
223 - def set_verifier(self, verifier=None):
224 if verifier is not None: 225 self.verifier = verifier 226 else: 227 self.verifier = generate_verifier()
228
229 - def get_callback_url(self):
230 if self.callback and self.verifier: 231 # Append the oauth_verifier. 232 parts = urlparse.urlparse(self.callback) 233 scheme, netloc, path, params, query, fragment = parts[:6] 234 if query: 235 query = '%s&oauth_verifier=%s' % (query, self.verifier) 236 else: 237 query = 'oauth_verifier=%s' % self.verifier 238 return urlparse.urlunparse((scheme, netloc, path, params, 239 query, fragment)) 240 return self.callback
241
242 - def to_string(self):
243 """Returns this token as a plain string, suitable for storage. 244 245 The resulting string includes the token's secret, so you should never 246 send or store this string where a third party can read it. 247 """ 248 249 data = { 250 'oauth_token': self.key, 251 'oauth_token_secret': self.secret, 252 } 253 254 if self.callback_confirmed is not None: 255 data['oauth_callback_confirmed'] = self.callback_confirmed 256 return urllib.urlencode(data)
257 258 @staticmethod
259 - def from_string(s):
260 """Deserializes a token from a string like one returned by 261 `to_string()`.""" 262 263 if not len(s): 264 raise ValueError("Invalid parameter string.") 265 266 params = parse_qs(s, keep_blank_values=False) 267 if not len(params): 268 raise ValueError("Invalid parameter string.") 269 270 try: 271 key = params['oauth_token'][0] 272 except Exception: 273 raise ValueError("'oauth_token' not found in OAuth request.") 274 275 try: 276 secret = params['oauth_token_secret'][0] 277 except Exception: 278 raise ValueError("'oauth_token_secret' not found in " 279 "OAuth request.") 280 281 token = Token(key, secret) 282 try: 283 token.callback_confirmed = params['oauth_callback_confirmed'][0] 284 except KeyError: 285 pass # 1.0, no callback confirmed. 286 return token
287
288 - def __str__(self):
289 return self.to_string()
290
291 292 -def setter(attr):
293 name = attr.__name__ 294 295 def getter(self): 296 try: 297 return self.__dict__[name] 298 except KeyError: 299 raise AttributeError(name)
300 301 def deleter(self): 302 del self.__dict__[name] 303 304 return property(getter, attr, deleter) 305
306 307 -class Request(dict):
308 309 """The parameters and information for an HTTP request, suitable for 310 authorizing with OAuth credentials. 311 312 When a consumer wants to access a service's protected resources, it does 313 so using a signed HTTP request identifying itself (the consumer) with its 314 key, and providing an access token authorized by the end user to access 315 those resources. 316 317 """ 318 319 version = OAUTH_VERSION 320
321 - def __init__(self, method=HTTP_METHOD, url=None, parameters=None, 322 body='', is_form_encoded=False):
323 if url is not None: 324 self.url = to_unicode(url) 325 self.method = method 326 if parameters is not None: 327 for k, v in parameters.iteritems(): 328 k = to_unicode(k) 329 v = to_unicode_optional_iterator(v) 330 self[k] = v 331 self.body = body 332 self.is_form_encoded = is_form_encoded
333 334 335 @setter
336 - def url(self, value):
337 self.__dict__['url'] = value 338 if value is not None: 339 scheme, netloc, path, params, query, fragment = urlparse.urlparse(value) 340 341 # Exclude default port numbers. 342 if scheme == 'http' and netloc[-3:] == ':80': 343 netloc = netloc[:-3] 344 elif scheme == 'https' and netloc[-4:] == ':443': 345 netloc = netloc[:-4] 346 if scheme not in ('http', 'https'): 347 raise ValueError("Unsupported URL %s (%s)." % (value, scheme)) 348 349 # Normalized URL excludes params, query, and fragment. 350 self.normalized_url = urlparse.urlunparse((scheme, netloc, path, None, None, None)) 351 else: 352 self.normalized_url = None 353 self.__dict__['url'] = None
354 355 @setter
356 - def method(self, value):
357 self.__dict__['method'] = value.upper()
358
359 - def _get_timestamp_nonce(self):
360 return self['oauth_timestamp'], self['oauth_nonce']
361
362 - def get_nonoauth_parameters(self):
363 """Get any non-OAuth parameters.""" 364 return dict([(k, v) for k, v in self.iteritems() 365 if not k.startswith('oauth_')])
366
367 - def to_header(self, realm=''):
368 """Serialize as a header for an HTTPAuth request.""" 369 oauth_params = ((k, v) for k, v in self.items() 370 if k.startswith('oauth_')) 371 stringy_params = ((k, escape(str(v))) for k, v in oauth_params) 372 header_params = ('%s="%s"' % (k, v) for k, v in stringy_params) 373 params_header = ', '.join(header_params) 374 375 auth_header = 'OAuth realm="%s"' % realm 376 if params_header: 377 auth_header = "%s, %s" % (auth_header, params_header) 378 379 return {'Authorization': auth_header}
380
381 - def to_postdata(self):
382 """Serialize as post data for a POST request.""" 383 d = {} 384 for k, v in self.iteritems(): 385 d[k.encode('utf-8')] = to_utf8_optional_iterator(v) 386 387 # tell urlencode to deal with sequence values and map them correctly 388 # to resulting querystring. for example self["k"] = ["v1", "v2"] will 389 # result in 'k=v1&k=v2' and not k=%5B%27v1%27%2C+%27v2%27%5D 390 return urllib.urlencode(d, True).replace('+', '%20')
391
392 - def to_url(self):
393 """Serialize as a URL for a GET request.""" 394 base_url = urlparse.urlparse(self.url) 395 try: 396 query = base_url.query 397 except AttributeError: 398 # must be python <2.5 399 query = base_url[4] 400 query = parse_qs(query) 401 for k, v in self.items(): 402 if isinstance(v, unicode): 403 v = v.encode("utf-8") 404 query.setdefault(k, []).append(v) 405 406 try: 407 scheme = base_url.scheme 408 netloc = base_url.netloc 409 path = base_url.path 410 params = base_url.params 411 fragment = base_url.fragment 412 except AttributeError: 413 # must be python <2.5 414 scheme = base_url[0] 415 netloc = base_url[1] 416 path = base_url[2] 417 params = base_url[3] 418 fragment = base_url[5] 419 420 url = (scheme, netloc, path, params, 421 urllib.urlencode(query, True), fragment) 422 return urlparse.urlunparse(url)
423
424 - def get_parameter(self, parameter):
425 ret = self.get(parameter) 426 if ret is None: 427 raise Error('Parameter not found: %s' % parameter) 428 429 return ret
430
432 """Return a string that contains the parameters that must be signed.""" 433 items = [] 434 for key, value in self.iteritems(): 435 if key == 'oauth_signature': 436 continue 437 # 1.0a/9.1.1 states that kvp must be sorted by key, then by value, 438 # so we unpack sequence values into multiple items for sorting. 439 if isinstance(value, basestring): 440 items.append((to_utf8_if_string(key), to_utf8(value))) 441 else: 442 try: 443 value = list(value) 444 except TypeError, e: 445 assert 'is not iterable' in str(e) 446 items.append((to_utf8_if_string(key), to_utf8_if_string(value))) 447 else: 448 items.extend((to_utf8_if_string(key), to_utf8_if_string(item)) for item in value) 449 450 # Include any query string parameters from the provided URL 451 query = urlparse.urlparse(self.url)[4] 452 453 url_items = self._split_url_string(query).items() 454 url_items = [(to_utf8(k), to_utf8(v)) for k, v in url_items if k != 'oauth_signature' ] 455 items.extend(url_items) 456 457 items.sort() 458 encoded_str = urllib.urlencode(items) 459 # Encode signature parameters per Oauth Core 1.0 protocol 460 # spec draft 7, section 3.6 461 # (http://tools.ietf.org/html/draft-hammer-oauth-07#section-3.6) 462 # Spaces must be encoded with "%20" instead of "+" 463 return encoded_str.replace('+', '%20').replace('%7E', '~')
464
465 - def sign_request(self, signature_method, consumer, token):
466 """Set the signature parameter to the result of sign.""" 467 468 if not self.is_form_encoded: 469 # according to 470 # http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html 471 # section 4.1.1 "OAuth Consumers MUST NOT include an 472 # oauth_body_hash parameter on requests with form-encoded 473 # request bodies." 474 self['oauth_body_hash'] = base64.b64encode(sha(self.body).digest()) 475 476 if 'oauth_consumer_key' not in self: 477 self['oauth_consumer_key'] = consumer.key 478 479 if token and 'oauth_token' not in self: 480 self['oauth_token'] = token.key 481 482 self['oauth_signature_method'] = signature_method.name 483 self['oauth_signature'] = signature_method.sign(self, consumer, token)
484 485 @classmethod
486 - def make_timestamp(cls):
487 """Get seconds since epoch (UTC).""" 488 return str(int(time.time()))
489 490 @classmethod
491 - def make_nonce(cls):
492 """Generate pseudorandom number.""" 493 return str(random.randint(0, 100000000))
494 495 @classmethod
496 - def from_request(cls, http_method, http_url, headers=None, parameters=None, 497 query_string=None):
498 """Combines multiple parameter sources.""" 499 if parameters is None: 500 parameters = {} 501 502 # Headers 503 if headers and 'Authorization' in headers: 504 auth_header = headers['Authorization'] 505 # Check that the authorization header is OAuth. 506 if auth_header[:6] == 'OAuth ': 507 auth_header = auth_header[6:] 508 try: 509 # Get the parameters from the header. 510 header_params = cls._split_header(auth_header) 511 parameters.update(header_params) 512 except: 513 raise Error('Unable to parse OAuth parameters from ' 514 'Authorization header.') 515 516 # GET or POST query string. 517 if query_string: 518 query_params = cls._split_url_string(query_string) 519 parameters.update(query_params) 520 521 # URL parameters. 522 param_str = urlparse.urlparse(http_url)[4] # query 523 url_params = cls._split_url_string(param_str) 524 parameters.update(url_params) 525 526 if parameters: 527 return cls(http_method, http_url, parameters) 528 529 return None
530 531 @classmethod
532 - def from_consumer_and_token(cls, consumer, token=None, 533 http_method=HTTP_METHOD, http_url=None, parameters=None, 534 body='', is_form_encoded=False):
535 if not parameters: 536 parameters = {} 537 538 defaults = { 539 'oauth_consumer_key': consumer.key, 540 'oauth_timestamp': cls.make_timestamp(), 541 'oauth_nonce': cls.make_nonce(), 542 'oauth_version': cls.version, 543 } 544 545 defaults.update(parameters) 546 parameters = defaults 547 548 if token: 549 parameters['oauth_token'] = token.key 550 if token.verifier: 551 parameters['oauth_verifier'] = token.verifier 552 553 return Request(http_method, http_url, parameters, body=body, 554 is_form_encoded=is_form_encoded)
555 556 @classmethod
557 - def from_token_and_callback(cls, token, callback=None, 558 http_method=HTTP_METHOD, http_url=None, parameters=None):
559 560 if not parameters: 561 parameters = {} 562 563 parameters['oauth_token'] = token.key 564 565 if callback: 566 parameters['oauth_callback'] = callback 567 568 return cls(http_method, http_url, parameters)
569 570 @staticmethod
571 - def _split_header(header):
572 """Turn Authorization: header into parameters.""" 573 params = {} 574 parts = header.split(',') 575 for param in parts: 576 # Ignore realm parameter. 577 if param.find('realm') > -1: 578 continue 579 # Remove whitespace. 580 param = param.strip() 581 # Split key-value. 582 param_parts = param.split('=', 1) 583 # Remove quotes and unescape the value. 584 params[param_parts[0]] = urllib.unquote(param_parts[1].strip('\"')) 585 return params
586 587 @staticmethod
588 - def _split_url_string(param_str):
589 """Turn URL string into parameters.""" 590 parameters = parse_qs(param_str.encode('utf-8'), keep_blank_values=True) 591 for k, v in parameters.iteritems(): 592 parameters[k] = urllib.unquote(v[0]) 593 return parameters
594
595 596 -class SignatureMethod(object):
597 """A way of signing requests. 598 599 The OAuth protocol lets consumers and service providers pick a way to sign 600 requests. This interface shows the methods expected by the other `oauth` 601 modules for signing requests. Subclass it and implement its methods to 602 provide a new way to sign requests. 603 """ 604
605 - def signing_base(self, request, consumer, token):
606 """Calculates the string that needs to be signed. 607 608 This method returns a 2-tuple containing the starting key for the 609 signing and the message to be signed. The latter may be used in error 610 messages to help clients debug their software. 611 612 """ 613 raise NotImplementedError
614
615 - def sign(self, request, consumer, token):
616 """Returns the signature for the given request, based on the consumer 617 and token also provided. 618 619 You should use your implementation of `signing_base()` to build the 620 message to sign. Otherwise it may be less useful for debugging. 621 622 """ 623 raise NotImplementedError
624
625 - def check(self, request, consumer, token, signature):
626 """Returns whether the given signature is the correct signature for 627 the given consumer and token signing the given request.""" 628 built = self.sign(request, consumer, token) 629 return built == signature
630
631 632 -class SignatureMethod_HMAC_SHA1(SignatureMethod):
633 name = 'HMAC-SHA1' 634
635 - def signing_base(self, request, consumer, token):
636 if not hasattr(request, 'normalized_url') or request.normalized_url is None: 637 raise ValueError("Base URL for request is not set.") 638 639 sig = ( 640 escape(request.method), 641 escape(request.normalized_url), 642 escape(request.get_normalized_parameters()), 643 ) 644 645 key = '%s&' % escape(consumer.secret) 646 if token: 647 key += escape(token.secret) 648 raw = '&'.join(sig) 649 return to_bytestring(key), raw
650
651 - def sign(self, request, consumer, token):
652 """Builds the base signature string.""" 653 key, raw = self.signing_base(request, consumer, token) 654 655 hashed = hmac.new(to_bytestring(key), raw, sha) 656 657 # Calculate the digest base 64. 658 return binascii.b2a_base64(hashed.digest())[:-1]
659
660 661 -class SignatureMethod_PLAINTEXT(SignatureMethod):
662 663 name = 'PLAINTEXT' 664
665 - def signing_base(self, request, consumer, token):
666 """Concatenates the consumer key and secret with the token's 667 secret.""" 668 sig = '%s&' % escape(consumer.secret) 669 if token: 670 sig = sig + escape(token.secret) 671 return sig, sig
672
673 - def sign(self, request, consumer, token):
674 key, raw = self.signing_base(request, consumer, token) 675 return raw
676