MythTV  0.26-pre
oauth_api.py
Go to the documentation of this file.
00001 """
00002 The MIT License
00003 
00004 Copyright (c) 2007 Leah Culver
00005 
00006 Permission is hereby granted, free of charge, to any person obtaining a copy
00007 of this software and associated documentation files (the "Software"), to deal
00008 in the Software without restriction, including without limitation the rights
00009 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
00010 copies of the Software, and to permit persons to whom the Software is
00011 furnished to do so, subject to the following conditions:
00012 
00013 The above copyright notice and this permission notice shall be included in
00014 all copies or substantial portions of the Software.
00015 
00016 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
00017 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
00018 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
00019 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
00020 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
00021 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
00022 THE SOFTWARE.
00023 """
00024 
00025 import cgi
00026 import urllib
00027 import time
00028 import random
00029 import urlparse
00030 import hmac
00031 import binascii
00032 
00033 
00034 VERSION = '1.0' # Hi Blaine!
00035 HTTP_METHOD = 'GET'
00036 SIGNATURE_METHOD = 'PLAINTEXT'
00037 
00038 
00039 class OAuthError(RuntimeError):
00040     """Generic exception class."""
00041     def __init__(self, message='OAuth error occured.'):
00042         self.message = message
00043 
00044 def build_authenticate_header(realm=''):
00045     """Optional WWW-Authenticate header (401 error)"""
00046     return {'WWW-Authenticate': 'OAuth realm="%s"' % realm}
00047 
00048 def escape(s):
00049     """Escape a URL including any /."""
00050     return urllib.quote(s, safe='~')
00051 
00052 def _utf8_str(s):
00053     """Convert unicode to utf-8."""
00054     if isinstance(s, unicode):
00055         return s.encode("utf-8")
00056     else:
00057         return str(s)
00058 
00059 def generate_timestamp():
00060     """Get seconds since epoch (UTC)."""
00061     return int(time.time())
00062 
00063 def generate_nonce(length=8):
00064     """Generate pseudorandom number."""
00065     return ''.join([str(random.randint(0, 9)) for i in range(length)])
00066 
00067 def generate_verifier(length=8):
00068     """Generate pseudorandom number."""
00069     return ''.join([str(random.randint(0, 9)) for i in range(length)])
00070 
00071 
00072 class OAuthConsumer(object):
00073     """Consumer of OAuth authentication.
00074 
00075     OAuthConsumer is a data type that represents the identity of the Consumer
00076     via its shared secret with the Service Provider.
00077 
00078     """
00079     key = None
00080     secret = None
00081 
00082     def __init__(self, key, secret):
00083         self.key = key
00084         self.secret = secret
00085 
00086 
00087 class OAuthToken(object):
00088     """OAuthToken is a data type that represents an End User via either an access
00089     or request token.
00090 
00091     key -- the token
00092     secret -- the token secret
00093 
00094     """
00095     key = None
00096     secret = None
00097     callback = None
00098     callback_confirmed = None
00099     verifier = None
00100 
00101     def __init__(self, key, secret):
00102         self.key = key
00103         self.secret = secret
00104 
00105     def set_callback(self, callback):
00106         self.callback = callback
00107         self.callback_confirmed = 'true'
00108 
00109     def set_verifier(self, verifier=None):
00110         if verifier is not None:
00111             self.verifier = verifier
00112         else:
00113             self.verifier = generate_verifier()
00114 
00115     def get_callback_url(self):
00116         if self.callback and self.verifier:
00117             # Append the oauth_verifier.
00118             parts = urlparse.urlparse(self.callback)
00119             scheme, netloc, path, params, query, fragment = parts[:6]
00120             if query:
00121                 query = '%s&oauth_verifier=%s' % (query, self.verifier)
00122             else:
00123                 query = 'oauth_verifier=%s' % self.verifier
00124             return urlparse.urlunparse((scheme, netloc, path, params,
00125                 query, fragment))
00126         return self.callback
00127 
00128     def to_string(self):
00129         data = {
00130             'oauth_token': self.key,
00131             'oauth_token_secret': self.secret,
00132         }
00133         if self.callback_confirmed is not None:
00134             data['oauth_callback_confirmed'] = self.callback_confirmed
00135         return urllib.urlencode(data)
00136 
00137     def from_string(s):
00138         """ Returns a token from something like:
00139         oauth_token_secret=xxx&oauth_token=xxx
00140         """
00141         params = cgi.parse_qs(s, keep_blank_values=False)
00142         key = params['oauth_token'][0]
00143         secret = params['oauth_token_secret'][0]
00144         token = OAuthToken(key, secret)
00145         try:
00146             token.callback_confirmed = params['oauth_callback_confirmed'][0]
00147         except KeyError:
00148             pass # 1.0, no callback confirmed.
00149         return token
00150     from_string = staticmethod(from_string)
00151 
00152     def __str__(self):
00153         return self.to_string()
00154 
00155 
00156 class OAuthRequest(object):
00157     """OAuthRequest represents the request and can be serialized.
00158 
00159     OAuth parameters:
00160         - oauth_consumer_key
00161         - oauth_token
00162         - oauth_signature_method
00163         - oauth_signature
00164         - oauth_timestamp
00165         - oauth_nonce
00166         - oauth_version
00167         - oauth_verifier
00168         ... any additional parameters, as defined by the Service Provider.
00169     """
00170     parameters = None # OAuth parameters.
00171     http_method = HTTP_METHOD
00172     http_url = None
00173     version = VERSION
00174 
00175     def __init__(self, http_method=HTTP_METHOD, http_url=None, parameters=None):
00176         self.http_method = http_method
00177         self.http_url = http_url
00178         self.parameters = parameters or {}
00179 
00180     def set_parameter(self, parameter, value):
00181         self.parameters[parameter] = value
00182 
00183     def get_parameter(self, parameter):
00184         try:
00185             return self.parameters[parameter]
00186         except:
00187             raise OAuthError('Parameter not found: %s' % parameter)
00188 
00189     def _get_timestamp_nonce(self):
00190         return self.get_parameter('oauth_timestamp'), self.get_parameter(
00191             'oauth_nonce')
00192 
00193     def get_nonoauth_parameters(self):
00194         """Get any non-OAuth parameters."""
00195         parameters = {}
00196         for k, v in self.parameters.iteritems():
00197             # Ignore oauth parameters.
00198             if k.find('oauth_') < 0:
00199                 parameters[k] = v
00200         return parameters
00201 
00202     def to_header(self, realm=''):
00203         """Serialize as a header for an HTTPAuth request."""
00204         auth_header = 'OAuth realm="%s"' % realm
00205         # Add the oauth parameters.
00206         if self.parameters:
00207             for k, v in self.parameters.iteritems():
00208                 if k[:6] == 'oauth_':
00209                     auth_header += ', %s="%s"' % (k, escape(str(v)))
00210         return {'Authorization': auth_header}
00211 
00212     def to_postdata(self):
00213         """Serialize as post data for a POST request."""
00214         return '&'.join(['%s=%s' % (escape(str(k)), escape(str(v))) \
00215             for k, v in self.parameters.iteritems()])
00216 
00217     def to_url(self):
00218         """Serialize as a URL for a GET request."""
00219         return '%s?%s' % (self.get_normalized_http_url(), self.to_postdata())
00220 
00221     def get_normalized_parameters(self):
00222         """Return a string that contains the parameters that must be signed."""
00223         params = self.parameters
00224         try:
00225             # Exclude the signature if it exists.
00226             del params['oauth_signature']
00227         except:
00228             pass
00229         # Escape key values before sorting.
00230         key_values = [(_utf8_str(k), _utf8_str(v)) \
00231             for k,v in params.items()]
00232         # Sort lexicographically, first after key, then after value.
00233         key_values.sort()
00234         # Combine key value pairs into a string.
00235         return '&'.join(['%s=%s' % (k, v) for k, v in key_values])
00236 
00237     def get_normalized_http_method(self):
00238         """Uppercases the http method."""
00239         return self.http_method.upper()
00240 
00241     def get_normalized_http_url(self):
00242         """Parses the URL and rebuilds it to be scheme://host/path."""
00243         parts = urlparse.urlparse(self.http_url)
00244         scheme, netloc, path = parts[:3]
00245         # Exclude default port numbers.
00246         if scheme == 'http' and netloc[-3:] == ':80':
00247             netloc = netloc[:-3]
00248         elif scheme == 'https' and netloc[-4:] == ':443':
00249             netloc = netloc[:-4]
00250         return '%s://%s%s' % (scheme, netloc, path)
00251 
00252     def sign_request(self, signature_method, consumer, token):
00253         """Set the signature parameter to the result of build_signature."""
00254         # Set the signature method.
00255         self.set_parameter('oauth_signature_method',
00256             signature_method.get_name())
00257         # Set the signature.
00258         self.set_parameter('oauth_signature',
00259             self.build_signature(signature_method, consumer, token))
00260 
00261     def build_signature(self, signature_method, consumer, token):
00262         """Calls the build signature method within the signature method."""
00263         return signature_method.build_signature(self, consumer, token)
00264 
00265     def from_request(http_method, http_url, headers=None, parameters=None,
00266             query_string=None):
00267         """Combines multiple parameter sources."""
00268         if parameters is None:
00269             parameters = {}
00270 
00271         # Headers
00272         if headers and 'Authorization' in headers:
00273             auth_header = headers['Authorization']
00274             # Check that the authorization header is OAuth.
00275             if auth_header[:6] == 'OAuth ':
00276                 auth_header = auth_header[6:]
00277                 try:
00278                     # Get the parameters from the header.
00279                     header_params = OAuthRequest._split_header(auth_header)
00280                     parameters.update(header_params)
00281                 except:
00282                     raise OAuthError('Unable to parse OAuth parameters from '
00283                         'Authorization header.')
00284 
00285         # GET or POST query string.
00286         if query_string:
00287             query_params = OAuthRequest._split_url_string(query_string)
00288             parameters.update(query_params)
00289 
00290         # URL parameters.
00291         param_str = urlparse.urlparse(http_url)[4] # query
00292         url_params = OAuthRequest._split_url_string(param_str)
00293         parameters.update(url_params)
00294 
00295         if parameters:
00296             return OAuthRequest(http_method, http_url, parameters)
00297 
00298         return None
00299     from_request = staticmethod(from_request)
00300 
00301     def from_consumer_and_token(oauth_consumer, token=None,
00302             callback=None, verifier=None, http_method=HTTP_METHOD,
00303             http_url=None, parameters=None):
00304         if not parameters:
00305             parameters = {}
00306 
00307         defaults = {
00308             'oauth_consumer_key': oauth_consumer.key,
00309             'oauth_timestamp': generate_timestamp(),
00310             'oauth_nonce': generate_nonce(),
00311             'oauth_version': OAuthRequest.version,
00312         }
00313 
00314         defaults.update(parameters)
00315         parameters = defaults
00316 
00317         if token:
00318             parameters['oauth_token'] = token.key
00319             if token.callback:
00320                 parameters['oauth_callback'] = token.callback
00321             # 1.0a support for verifier.
00322             if verifier:
00323                 parameters['oauth_verifier'] = verifier
00324         elif callback:
00325             # 1.0a support for callback in the request token request.
00326             parameters['oauth_callback'] = callback
00327         return OAuthRequest(http_method, http_url, parameters)
00328     from_consumer_and_token = staticmethod(from_consumer_and_token)
00329 
00330     def from_token_and_callback(token, callback=None, http_method=HTTP_METHOD,
00331             http_url=None, parameters=None):
00332         if not parameters:
00333             parameters = {}
00334 
00335         parameters['oauth_token'] = token.key
00336 
00337         if callback:
00338             parameters['oauth_callback'] = callback
00339 
00340         return OAuthRequest(http_method, http_url, parameters)
00341     from_token_and_callback = staticmethod(from_token_and_callback)
00342 
00343     def _split_header(header):
00344         """Turn Authorization: header into parameters."""
00345         params = {}
00346         parts = header.split(',')
00347         for param in parts:
00348             # Ignore realm parameter.
00349             if param.find('realm') > -1:
00350                 continue
00351             # Remove whitespace.
00352             param = param.strip()
00353             # Split key-value.
00354             param_parts = param.split('=', 1)
00355             # Remove quotes and unescape the value.
00356             params[param_parts[0]] = urllib.unquote(param_parts[1].strip('\"'))
00357         return params
00358     _split_header = staticmethod(_split_header)
00359 
00360     def _split_url_string(param_str):
00361         """Turn URL string into parameters."""
00362         parameters = cgi.parse_qs(param_str, keep_blank_values=False)
00363         for k, v in parameters.iteritems():
00364             parameters[k] = urllib.unquote(v[0])
00365         return parameters
00366     _split_url_string = staticmethod(_split_url_string)
00367 
00368 class OAuthServer(object):
00369     """A worker to check the validity of a request against a data store."""
00370     timestamp_threshold = 300 # In seconds, five minutes.
00371     version = VERSION
00372     signature_methods = None
00373     data_store = None
00374 
00375     def __init__(self, data_store=None, signature_methods=None):
00376         self.data_store = data_store
00377         self.signature_methods = signature_methods or {}
00378 
00379     def set_data_store(self, data_store):
00380         self.data_store = data_store
00381 
00382     def get_data_store(self):
00383         return self.data_store
00384 
00385     def add_signature_method(self, signature_method):
00386         self.signature_methods[signature_method.get_name()] = signature_method
00387         return self.signature_methods
00388 
00389     def fetch_request_token(self, oauth_request):
00390         """Processes a request_token request and returns the
00391         request token on success.
00392         """
00393         try:
00394             # Get the request token for authorization.
00395             token = self._get_token(oauth_request, 'request')
00396         except OAuthError:
00397             # No token required for the initial token request.
00398             version = self._get_version(oauth_request)
00399             consumer = self._get_consumer(oauth_request)
00400             try:
00401                 callback = self.get_callback(oauth_request)
00402             except OAuthError:
00403                 callback = None # 1.0, no callback specified.
00404             self._check_signature(oauth_request, consumer, None)
00405             # Fetch a new token.
00406             token = self.data_store.fetch_request_token(consumer, callback)
00407         return token
00408 
00409     def fetch_access_token(self, oauth_request):
00410         """Processes an access_token request and returns the
00411         access token on success.
00412         """
00413         version = self._get_version(oauth_request)
00414         consumer = self._get_consumer(oauth_request)
00415         try:
00416             verifier = self._get_verifier(oauth_request)
00417         except OAuthError:
00418             verifier = None
00419         # Get the request token.
00420         token = self._get_token(oauth_request, 'request')
00421         self._check_signature(oauth_request, consumer, token)
00422         new_token = self.data_store.fetch_access_token(consumer, token, verifier)
00423         return new_token
00424 
00425     def verify_request(self, oauth_request):
00426         """Verifies an api call and checks all the parameters."""
00427         # -> consumer and token
00428         version = self._get_version(oauth_request)
00429         consumer = self._get_consumer(oauth_request)
00430         # Get the access token.
00431         token = self._get_token(oauth_request, 'access')
00432         self._check_signature(oauth_request, consumer, token)
00433         parameters = oauth_request.get_nonoauth_parameters()
00434         return consumer, token, parameters
00435 
00436     def authorize_token(self, token, user):
00437         """Authorize a request token."""
00438         return self.data_store.authorize_request_token(token, user)
00439 
00440     def get_callback(self, oauth_request):
00441         """Get the callback URL."""
00442         return oauth_request.get_parameter('oauth_callback')
00443 
00444     def build_authenticate_header(self, realm=''):
00445         """Optional support for the authenticate header."""
00446         return {'WWW-Authenticate': 'OAuth realm="%s"' % realm}
00447 
00448     def _get_version(self, oauth_request):
00449         """Verify the correct version request for this server."""
00450         try:
00451             version = oauth_request.get_parameter('oauth_version')
00452         except:
00453             version = VERSION
00454         if version and version != self.version:
00455             raise OAuthError('OAuth version %s not supported.' % str(version))
00456         return version
00457 
00458     def _get_signature_method(self, oauth_request):
00459         """Figure out the signature with some defaults."""
00460         try:
00461             signature_method = oauth_request.get_parameter(
00462                 'oauth_signature_method')
00463         except:
00464             signature_method = SIGNATURE_METHOD
00465         try:
00466             # Get the signature method object.
00467             signature_method = self.signature_methods[signature_method]
00468         except:
00469             signature_method_names = ', '.join(self.signature_methods.keys())
00470             raise OAuthError('Signature method %s not supported try one of the '
00471                 'following: %s' % (signature_method, signature_method_names))
00472 
00473         return signature_method
00474 
00475     def _get_consumer(self, oauth_request):
00476         consumer_key = oauth_request.get_parameter('oauth_consumer_key')
00477         consumer = self.data_store.lookup_consumer(consumer_key)
00478         if not consumer:
00479             raise OAuthError('Invalid consumer.')
00480         return consumer
00481 
00482     def _get_token(self, oauth_request, token_type='access'):
00483         """Try to find the token for the provided request token key."""
00484         token_field = oauth_request.get_parameter('oauth_token')
00485         token = self.data_store.lookup_token(token_type, token_field)
00486         if not token:
00487             raise OAuthError('Invalid %s token: %s' % (token_type, token_field))
00488         return token
00489 
00490     def _get_verifier(self, oauth_request):
00491         return oauth_request.get_parameter('oauth_verifier')
00492 
00493     def _check_signature(self, oauth_request, consumer, token):
00494         timestamp, nonce = oauth_request._get_timestamp_nonce()
00495         self._check_timestamp(timestamp)
00496         self._check_nonce(consumer, token, nonce)
00497         signature_method = self._get_signature_method(oauth_request)
00498         try:
00499             signature = oauth_request.get_parameter('oauth_signature')
00500         except:
00501             raise OAuthError('Missing signature.')
00502         # Validate the signature.
00503         valid_sig = signature_method.check_signature(oauth_request, consumer,
00504             token, signature)
00505         if not valid_sig:
00506             key, base = signature_method.build_signature_base_string(
00507                 oauth_request, consumer, token)
00508             raise OAuthError('Invalid signature. Expected signature base '
00509                 'string: %s' % base)
00510         built = signature_method.build_signature(oauth_request, consumer, token)
00511 
00512     def _check_timestamp(self, timestamp):
00513         """Verify that timestamp is recentish."""
00514         timestamp = int(timestamp)
00515         now = int(time.time())
00516         lapsed = abs(now - timestamp)
00517         if lapsed > self.timestamp_threshold:
00518             raise OAuthError('Expired timestamp: given %d and now %s has a '
00519                 'greater difference than threshold %d' %
00520                 (timestamp, now, self.timestamp_threshold))
00521 
00522     def _check_nonce(self, consumer, token, nonce):
00523         """Verify that the nonce is uniqueish."""
00524         nonce = self.data_store.lookup_nonce(consumer, token, nonce)
00525         if nonce:
00526             raise OAuthError('Nonce already used: %s' % str(nonce))
00527 
00528 
00529 class OAuthClient(object):
00530     """OAuthClient is a worker to attempt to execute a request."""
00531     consumer = None
00532     token = None
00533 
00534     def __init__(self, oauth_consumer, oauth_token):
00535         self.consumer = oauth_consumer
00536         self.token = oauth_token
00537 
00538     def get_consumer(self):
00539         return self.consumer
00540 
00541     def get_token(self):
00542         return self.token
00543 
00544     def fetch_request_token(self, oauth_request):
00545         """-> OAuthToken."""
00546         raise NotImplementedError
00547 
00548     def fetch_access_token(self, oauth_request):
00549         """-> OAuthToken."""
00550         raise NotImplementedError
00551 
00552     def access_resource(self, oauth_request):
00553         """-> Some protected resource."""
00554         raise NotImplementedError
00555 
00556 
00557 class OAuthDataStore(object):
00558     """A database abstraction used to lookup consumers and tokens."""
00559 
00560     def lookup_consumer(self, key):
00561         """-> OAuthConsumer."""
00562         raise NotImplementedError
00563 
00564     def lookup_token(self, oauth_consumer, token_type, token_token):
00565         """-> OAuthToken."""
00566         raise NotImplementedError
00567 
00568     def lookup_nonce(self, oauth_consumer, oauth_token, nonce):
00569         """-> OAuthToken."""
00570         raise NotImplementedError
00571 
00572     def fetch_request_token(self, oauth_consumer, oauth_callback):
00573         """-> OAuthToken."""
00574         raise NotImplementedError
00575 
00576     def fetch_access_token(self, oauth_consumer, oauth_token, oauth_verifier):
00577         """-> OAuthToken."""
00578         raise NotImplementedError
00579 
00580     def authorize_request_token(self, oauth_token, user):
00581         """-> OAuthToken."""
00582         raise NotImplementedError
00583 
00584 
00585 class OAuthSignatureMethod(object):
00586     """A strategy class that implements a signature method."""
00587     def get_name(self):
00588         """-> str."""
00589         raise NotImplementedError
00590 
00591     def build_signature_base_string(self, oauth_request, oauth_consumer, oauth_token):
00592         """-> str key, str raw."""
00593         raise NotImplementedError
00594 
00595     def build_signature(self, oauth_request, oauth_consumer, oauth_token):
00596         """-> str."""
00597         raise NotImplementedError
00598 
00599     def check_signature(self, oauth_request, consumer, token, signature):
00600         built = self.build_signature(oauth_request, consumer, token)
00601         return built == signature
00602 
00603 
00604 class OAuthSignatureMethod_HMAC_SHA1(OAuthSignatureMethod):
00605 
00606     def get_name(self):
00607         return 'HMAC-SHA1'
00608 
00609     def build_signature_base_string(self, oauth_request, consumer, token):
00610         sig = (
00611             escape(oauth_request.get_normalized_http_method()),
00612             escape(oauth_request.get_normalized_http_url()),
00613             escape(oauth_request.get_normalized_parameters()),
00614         )
00615 
00616         key = '%s&' % escape(consumer.secret)
00617         if token:
00618             key += escape(token.secret)
00619         raw = '&'.join(sig)
00620         return key, raw
00621 
00622     def build_signature(self, oauth_request, consumer, token):
00623         """Builds the base signature string."""
00624         key, raw = self.build_signature_base_string(oauth_request, consumer,
00625             token)
00626         # HMAC object.
00627         try:
00628             import hashlib # 2.5
00629             hashed = hmac.new(key, raw, hashlib.sha1)
00630         except:
00631             import sha # Deprecated
00632             hashed = hmac.new(key, raw, sha)
00633 
00634         # Calculate the digest base 64.
00635         return binascii.b2a_base64(hashed.digest())[:-1]
00636 
00637 
00638 class OAuthSignatureMethod_PLAINTEXT(OAuthSignatureMethod):
00639 
00640     def get_name(self):
00641         return 'PLAINTEXT'
00642 
00643     def build_signature_base_string(self, oauth_request, consumer, token):
00644         """Concatenates the consumer key and secret."""
00645         sig = '%s&' % escape(consumer.secret)
00646         if token:
00647             sig = sig + escape(token.secret)
00648         return sig, sig
00649 
00650     def build_signature(self, oauth_request, consumer, token):
00651         key, raw = self.build_signature_base_string(oauth_request, consumer,
00652             token)
00653         return key
 All Classes Namespaces Files Functions Variables Typedefs Enumerations Enumerator Properties Friends