1"""
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).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
515class 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
578class 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
673class 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
709class 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
744class 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