Package restkit :: Module oauth2
[hide private]
[frames] | no frames]

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