blob: 3adbd2090d0e0fe6a6b0357818a01daf31f4645f [file] [log] [blame]
Joe Gregorio48d361f2010-08-18 13:19:21 -04001"""
2The MIT License
3
4Copyright (c) 2007-2010 Leah Culver, Joe Stump, Mark Paschal, Vic Fryzel
5
6Permission is hereby granted, free of charge, to any person obtaining a copy
7of this software and associated documentation files (the "Software"), to deal
8in the Software without restriction, including without limitation the rights
9to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10copies of the Software, and to permit persons to whom the Software is
11furnished to do so, subject to the following conditions:
12
13The above copyright notice and this permission notice shall be included in
14all copies or substantial portions of the Software.
15
16THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22THE SOFTWARE.
23"""
24
25import urllib
26import time
27import random
28import urlparse
29import hmac
30import binascii
31import httplib2
32
33try:
34 from urlparse import parse_qs, parse_qsl
35except ImportError:
36 from cgi import parse_qs, parse_qsl
37
38
39VERSION = '1.0' # Hi Blaine!
40HTTP_METHOD = 'GET'
41SIGNATURE_METHOD = 'PLAINTEXT'
42
43
44class 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
59class MissingSignature(Error):
60 pass
61
62
63def build_authenticate_header(realm=''):
64 """Optional WWW-Authenticate header (401 error)"""
65 return {'WWW-Authenticate': 'OAuth realm="%s"' % realm}
66
67
68def 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
84def escape(s):
85 """Escape a URL including any /."""
86 return urllib.quote(s, safe='~')
87
88
89def generate_timestamp():
90 """Get seconds since epoch (UTC)."""
91 return int(time.time())
92
93
94def generate_nonce(length=8):
95 """Generate pseudorandom number."""
96 return ''.join([str(random.randint(0, 9)) for i in range(length)])
97
98
99def generate_verifier(length=8):
100 """Generate pseudorandom number."""
101 return ''.join([str(random.randint(0, 9)) for i in range(length)])
102
103
104class 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
141class 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
242def 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
257class 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)
329
330 def to_url(self):
331 """Serialize as a URL for a GET request."""
332 base_url = urlparse.urlparse(self.url)
333 query = parse_qs(base_url.query)
334 for k, v in self.items():
335 query.setdefault(k, []).append(v)
336 url = (base_url.scheme, base_url.netloc, base_url.path, base_url.params,
337 urllib.urlencode(query, True), base_url.fragment)
338 return urlparse.urlunparse(url)
339
340 def get_parameter(self, parameter):
341 ret = self.get(parameter)
342 if ret is None:
343 raise Error('Parameter not found: %s' % parameter)
344
345 return ret
346
347 def get_normalized_parameters(self):
348 """Return a string that contains the parameters that must be signed."""
349 items = []
350 for key, value in self.iteritems():
351 if key == 'oauth_signature':
352 continue
353 # 1.0a/9.1.1 states that kvp must be sorted by key, then by value,
354 # so we unpack sequence values into multiple items for sorting.
355 if hasattr(value, '__iter__'):
356 items.extend((key, item) for item in value)
357 else:
358 items.append((key, value))
359
360 # Include any query string parameters from the provided URL
361 query = urlparse.urlparse(self.url)[4]
362 items.extend(self._split_url_string(query).items())
363
364 encoded_str = urllib.urlencode(sorted(items))
365 # Encode signature parameters per Oauth Core 1.0 protocol
366 # spec draft 7, section 3.6
367 # (http://tools.ietf.org/html/draft-hammer-oauth-07#section-3.6)
368 # Spaces must be encoded with "%20" instead of "+"
369 return encoded_str.replace('+', '%20')
370
371 def sign_request(self, signature_method, consumer, token):
372 """Set the signature parameter to the result of sign."""
373
374 if 'oauth_consumer_key' not in self:
375 self['oauth_consumer_key'] = consumer.key
376
377 if token and 'oauth_token' not in self:
378 self['oauth_token'] = token.key
379
380 self['oauth_signature_method'] = signature_method.name
381 self['oauth_signature'] = signature_method.sign(self, consumer, token)
382
383 @classmethod
384 def make_timestamp(cls):
385 """Get seconds since epoch (UTC)."""
386 return str(int(time.time()))
387
388 @classmethod
389 def make_nonce(cls):
390 """Generate pseudorandom number."""
391 return str(random.randint(0, 100000000))
392
393 @classmethod
394 def from_request(cls, http_method, http_url, headers=None, parameters=None,
395 query_string=None):
396 """Combines multiple parameter sources."""
397 if parameters is None:
398 parameters = {}
399
400 # Headers
401 if headers and 'Authorization' in headers:
402 auth_header = headers['Authorization']
403 # Check that the authorization header is OAuth.
404 if auth_header[:6] == 'OAuth ':
405 auth_header = auth_header[6:]
406 try:
407 # Get the parameters from the header.
408 header_params = cls._split_header(auth_header)
409 parameters.update(header_params)
410 except:
411 raise Error('Unable to parse OAuth parameters from '
412 'Authorization header.')
413
414 # GET or POST query string.
415 if query_string:
416 query_params = cls._split_url_string(query_string)
417 parameters.update(query_params)
418
419 # URL parameters.
420 param_str = urlparse.urlparse(http_url)[4] # query
421 url_params = cls._split_url_string(param_str)
422 parameters.update(url_params)
423
424 if parameters:
425 return cls(http_method, http_url, parameters)
426
427 return None
428
429 @classmethod
430 def from_consumer_and_token(cls, consumer, token=None,
431 http_method=HTTP_METHOD, http_url=None, parameters=None):
432 if not parameters:
433 parameters = {}
434
435 defaults = {
436 'oauth_consumer_key': consumer.key,
437 'oauth_timestamp': cls.make_timestamp(),
438 'oauth_nonce': cls.make_nonce(),
439 'oauth_version': cls.version,
440 }
441
442 defaults.update(parameters)
443 parameters = defaults
444
445 if token:
446 parameters['oauth_token'] = token.key
447 if token.verifier:
448 parameters['oauth_verifier'] = token.verifier
449
450 return Request(http_method, http_url, parameters)
451
452 @classmethod
453 def from_token_and_callback(cls, token, callback=None,
454 http_method=HTTP_METHOD, http_url=None, parameters=None):
455
456 if not parameters:
457 parameters = {}
458
459 parameters['oauth_token'] = token.key
460
461 if callback:
462 parameters['oauth_callback'] = callback
463
464 return cls(http_method, http_url, parameters)
465
466 @staticmethod
467 def _split_header(header):
468 """Turn Authorization: header into parameters."""
469 params = {}
470 parts = header.split(',')
471 for param in parts:
472 # Ignore realm parameter.
473 if param.find('realm') > -1:
474 continue
475 # Remove whitespace.
476 param = param.strip()
477 # Split key-value.
478 param_parts = param.split('=', 1)
479 # Remove quotes and unescape the value.
480 params[param_parts[0]] = urllib.unquote(param_parts[1].strip('\"'))
481 return params
482
483 @staticmethod
484 def _split_url_string(param_str):
485 """Turn URL string into parameters."""
486 parameters = parse_qs(param_str, keep_blank_values=False)
487 for k, v in parameters.iteritems():
488 parameters[k] = urllib.unquote(v[0])
489 return parameters
490
491
492class Client(httplib2.Http):
493 """OAuthClient is a worker to attempt to execute a request."""
494
495 def __init__(self, consumer, token=None, cache=None, timeout=None,
496 proxy_info=None):
497
498 if consumer is not None and not isinstance(consumer, Consumer):
499 raise ValueError("Invalid consumer.")
500
501 if token is not None and not isinstance(token, Token):
502 raise ValueError("Invalid token.")
503
504 self.consumer = consumer
505 self.token = token
506 self.method = SignatureMethod_HMAC_SHA1()
507
508 httplib2.Http.__init__(self, cache=cache, timeout=timeout,
509 proxy_info=proxy_info)
510
511 def set_signature_method(self, method):
512 if not isinstance(method, SignatureMethod):
513 raise ValueError("Invalid signature method.")
514
515 self.method = method
516
517 def request(self, uri, method="GET", body=None, headers=None,
518 redirections=httplib2.DEFAULT_MAX_REDIRECTS, connection_type=None):
519 DEFAULT_CONTENT_TYPE = 'application/x-www-form-urlencoded'
520
521 if not isinstance(headers, dict):
522 headers = {}
523
524 is_multipart = method == 'POST' and headers.get('Content-Type',
525 DEFAULT_CONTENT_TYPE) != DEFAULT_CONTENT_TYPE
526
527 if body and method == "POST" and not is_multipart:
528 parameters = dict(parse_qsl(body))
529 else:
530 parameters = None
531
532 req = Request.from_consumer_and_token(self.consumer,
533 token=self.token, http_method=method, http_url=uri,
534 parameters=parameters)
535
536 req.sign_request(self.method, self.consumer, self.token)
537
538 if method == "POST":
539 headers['Content-Type'] = headers.get('Content-Type',
540 DEFAULT_CONTENT_TYPE)
541 if is_multipart:
542 headers.update(req.to_header())
543 else:
544 body = req.to_postdata()
545 elif method == "GET":
546 uri = req.to_url()
547 else:
548 headers.update(req.to_header())
549
550 return httplib2.Http.request(self, uri, method=method, body=body,
551 headers=headers, redirections=redirections,
552 connection_type=connection_type)
553
554
555class Server(object):
556 """A skeletal implementation of a service provider, providing protected
557 resources to requests from authorized consumers.
558
559 This class implements the logic to check requests for authorization. You
560 can use it with your web server or web framework to protect certain
561 resources with OAuth.
562 """
563
564 timestamp_threshold = 300 # In seconds, five minutes.
565 version = VERSION
566 signature_methods = None
567
568 def __init__(self, signature_methods=None):
569 self.signature_methods = signature_methods or {}
570
571 def add_signature_method(self, signature_method):
572 self.signature_methods[signature_method.name] = signature_method
573 return self.signature_methods
574
575 def verify_request(self, request, consumer, token):
576 """Verifies an api call and checks all the parameters."""
577
578 version = self._get_version(request)
579 self._check_signature(request, consumer, token)
580 parameters = request.get_nonoauth_parameters()
581 return parameters
582
583 def build_authenticate_header(self, realm=''):
584 """Optional support for the authenticate header."""
585 return {'WWW-Authenticate': 'OAuth realm="%s"' % realm}
586
587 def _get_version(self, request):
588 """Verify the correct version request for this server."""
589 try:
590 version = request.get_parameter('oauth_version')
591 except:
592 version = VERSION
593
594 if version and version != self.version:
595 raise Error('OAuth version %s not supported.' % str(version))
596
597 return version
598
599 def _get_signature_method(self, request):
600 """Figure out the signature with some defaults."""
601 try:
602 signature_method = request.get_parameter('oauth_signature_method')
603 except:
604 signature_method = SIGNATURE_METHOD
605
606 try:
607 # Get the signature method object.
608 signature_method = self.signature_methods[signature_method]
609 except:
610 signature_method_names = ', '.join(self.signature_methods.keys())
611 raise Error('Signature method %s not supported try one of the following: %s' % (signature_method, signature_method_names))
612
613 return signature_method
614
615 def _get_verifier(self, request):
616 return request.get_parameter('oauth_verifier')
617
618 def _check_signature(self, request, consumer, token):
619 timestamp, nonce = request._get_timestamp_nonce()
620 self._check_timestamp(timestamp)
621 signature_method = self._get_signature_method(request)
622
623 try:
624 signature = request.get_parameter('oauth_signature')
625 except:
626 raise MissingSignature('Missing oauth_signature.')
627
628 # Validate the signature.
629 valid = signature_method.check(request, consumer, token, signature)
630
631 if not valid:
632 key, base = signature_method.signing_base(request, consumer, token)
633
634 raise Error('Invalid signature. Expected signature base '
635 'string: %s' % base)
636
637 built = signature_method.sign(request, consumer, token)
638
639 def _check_timestamp(self, timestamp):
640 """Verify that timestamp is recentish."""
641 timestamp = int(timestamp)
642 now = int(time.time())
643 lapsed = now - timestamp
644 if lapsed > self.timestamp_threshold:
645 raise Error('Expired timestamp: given %d and now %s has a '
646 'greater difference than threshold %d' % (timestamp, now,
647 self.timestamp_threshold))
648
649
650class SignatureMethod(object):
651 """A way of signing requests.
652
653 The OAuth protocol lets consumers and service providers pick a way to sign
654 requests. This interface shows the methods expected by the other `oauth`
655 modules for signing requests. Subclass it and implement its methods to
656 provide a new way to sign requests.
657 """
658
659 def signing_base(self, request, consumer, token):
660 """Calculates the string that needs to be signed.
661
662 This method returns a 2-tuple containing the starting key for the
663 signing and the message to be signed. The latter may be used in error
664 messages to help clients debug their software.
665
666 """
667 raise NotImplementedError
668
669 def sign(self, request, consumer, token):
670 """Returns the signature for the given request, based on the consumer
671 and token also provided.
672
673 You should use your implementation of `signing_base()` to build the
674 message to sign. Otherwise it may be less useful for debugging.
675
676 """
677 raise NotImplementedError
678
679 def check(self, request, consumer, token, signature):
680 """Returns whether the given signature is the correct signature for
681 the given consumer and token signing the given request."""
682 built = self.sign(request, consumer, token)
683 return built == signature
684
685
686class SignatureMethod_HMAC_SHA1(SignatureMethod):
687 name = 'HMAC-SHA1'
688
689 def signing_base(self, request, consumer, token):
690 if request.normalized_url is None:
691 raise ValueError("Base URL for request is not set.")
692
693 sig = (
694 escape(request.method),
695 escape(request.normalized_url),
696 escape(request.get_normalized_parameters()),
697 )
698
699 key = '%s&' % escape(consumer.secret)
700 if token:
701 key += escape(token.secret)
702 raw = '&'.join(sig)
703 return key, raw
704
705 def sign(self, request, consumer, token):
706 """Builds the base signature string."""
707 key, raw = self.signing_base(request, consumer, token)
708
709 # HMAC object.
710 try:
711 from hashlib import sha1 as sha
712 except ImportError:
713 import sha # Deprecated
714
715 hashed = hmac.new(key, raw, sha)
716
717 # Calculate the digest base 64.
718 return binascii.b2a_base64(hashed.digest())[:-1]
719
720
721class SignatureMethod_PLAINTEXT(SignatureMethod):
722
723 name = 'PLAINTEXT'
724
725 def signing_base(self, request, consumer, token):
726 """Concatenates the consumer key and secret with the token's
727 secret."""
728 sig = '%s&' % escape(consumer.secret)
729 if token:
730 sig = sig + escape(token.secret)
731 return sig, sig
732
733 def sign(self, request, consumer, token):
734 key, raw = self.signing_base(request, consumer, token)
735 return raw