From: Tommy Guy Date: Mon, 20 Apr 2015 03:23:39 +0000 (-0700) Subject: Updated packages and code to python3. Won't work with python 2 X-Git-Url: https://projects.mako.cc/source/twitter-api-cdsw-solutions/commitdiff_plain/043b63c67557591f1e85b53d17fc8a1a797f48ca Updated packages and code to python3. Won't work with python 2 --- diff --git a/oauth/._oauth.py b/oauth/._oauth.py deleted file mode 100644 index 02899ca..0000000 Binary files a/oauth/._oauth.py and /dev/null differ diff --git a/oauth/__init__.py b/oauth/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/oauth/example/._server.py b/oauth/example/._server.py deleted file mode 100644 index f4dda4e..0000000 Binary files a/oauth/example/._server.py and /dev/null differ diff --git a/oauth/example/client.py b/oauth/example/client.py deleted file mode 100644 index 34f7dcb..0000000 --- a/oauth/example/client.py +++ /dev/null @@ -1,165 +0,0 @@ -""" -The MIT License - -Copyright (c) 2007 Leah Culver - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - -Example consumer. This is not recommended for production. -Instead, you'll want to create your own subclass of OAuthClient -or find one that works with your web framework. -""" - -import httplib -import time -import oauth.oauth as oauth - -# settings for the local test consumer -SERVER = 'localhost' -PORT = 8080 - -# fake urls for the test server (matches ones in server.py) -REQUEST_TOKEN_URL = 'https://photos.example.net/request_token' -ACCESS_TOKEN_URL = 'https://photos.example.net/access_token' -AUTHORIZATION_URL = 'https://photos.example.net/authorize' -CALLBACK_URL = 'http://printer.example.com/request_token_ready' -RESOURCE_URL = 'http://photos.example.net/photos' - -# key and secret granted by the service provider for this consumer application - same as the MockOAuthDataStore -CONSUMER_KEY = 'key' -CONSUMER_SECRET = 'secret' - -# example client using httplib with headers -class SimpleOAuthClient(oauth.OAuthClient): - - def __init__(self, server, port=httplib.HTTP_PORT, request_token_url='', access_token_url='', authorization_url=''): - self.server = server - self.port = port - self.request_token_url = request_token_url - self.access_token_url = access_token_url - self.authorization_url = authorization_url - self.connection = httplib.HTTPConnection("%s:%d" % (self.server, self.port)) - - def fetch_request_token(self, oauth_request): - # via headers - # -> OAuthToken - self.connection.request(oauth_request.http_method, self.request_token_url, headers=oauth_request.to_header()) - response = self.connection.getresponse() - return oauth.OAuthToken.from_string(response.read()) - - def fetch_access_token(self, oauth_request): - # via headers - # -> OAuthToken - self.connection.request(oauth_request.http_method, self.access_token_url, headers=oauth_request.to_header()) - response = self.connection.getresponse() - return oauth.OAuthToken.from_string(response.read()) - - def authorize_token(self, oauth_request): - # via url - # -> typically just some okay response - self.connection.request(oauth_request.http_method, oauth_request.to_url()) - response = self.connection.getresponse() - return response.read() - - def access_resource(self, oauth_request): - # via post body - # -> some protected resources - headers = {'Content-Type' :'application/x-www-form-urlencoded'} - self.connection.request('POST', RESOURCE_URL, body=oauth_request.to_postdata(), headers=headers) - response = self.connection.getresponse() - return response.read() - -def run_example(): - - # setup - print '** OAuth Python Library Example **' - client = SimpleOAuthClient(SERVER, PORT, REQUEST_TOKEN_URL, ACCESS_TOKEN_URL, AUTHORIZATION_URL) - consumer = oauth.OAuthConsumer(CONSUMER_KEY, CONSUMER_SECRET) - signature_method_plaintext = oauth.OAuthSignatureMethod_PLAINTEXT() - signature_method_hmac_sha1 = oauth.OAuthSignatureMethod_HMAC_SHA1() - pause() - - # get request token - print '* Obtain a request token ...' - pause() - oauth_request = oauth.OAuthRequest.from_consumer_and_token(consumer, callback=CALLBACK_URL, http_url=client.request_token_url) - oauth_request.sign_request(signature_method_plaintext, consumer, None) - print 'REQUEST (via headers)' - print 'parameters: %s' % str(oauth_request.parameters) - pause() - token = client.fetch_request_token(oauth_request) - print 'GOT' - print 'key: %s' % str(token.key) - print 'secret: %s' % str(token.secret) - print 'callback confirmed? %s' % str(token.callback_confirmed) - pause() - - print '* Authorize the request token ...' - pause() - oauth_request = oauth.OAuthRequest.from_token_and_callback(token=token, http_url=client.authorization_url) - print 'REQUEST (via url query string)' - print 'parameters: %s' % str(oauth_request.parameters) - pause() - # this will actually occur only on some callback - response = client.authorize_token(oauth_request) - print 'GOT' - print response - # sad way to get the verifier - import urlparse, cgi - query = urlparse.urlparse(response)[4] - params = cgi.parse_qs(query, keep_blank_values=False) - verifier = params['oauth_verifier'][0] - print 'verifier: %s' % verifier - pause() - - # get access token - print '* Obtain an access token ...' - pause() - oauth_request = oauth.OAuthRequest.from_consumer_and_token(consumer, token=token, verifier=verifier, http_url=client.access_token_url) - oauth_request.sign_request(signature_method_plaintext, consumer, token) - print 'REQUEST (via headers)' - print 'parameters: %s' % str(oauth_request.parameters) - pause() - token = client.fetch_access_token(oauth_request) - print 'GOT' - print 'key: %s' % str(token.key) - print 'secret: %s' % str(token.secret) - pause() - - # access some protected resources - print '* Access protected resources ...' - pause() - parameters = {'file': 'vacation.jpg', 'size': 'original'} # resource specific params - oauth_request = oauth.OAuthRequest.from_consumer_and_token(consumer, token=token, http_method='POST', http_url=RESOURCE_URL, parameters=parameters) - oauth_request.sign_request(signature_method_hmac_sha1, consumer, token) - print 'REQUEST (via post body)' - print 'parameters: %s' % str(oauth_request.parameters) - pause() - params = client.access_resource(oauth_request) - print 'GOT' - print 'non-oauth parameters: %s' % params - pause() - -def pause(): - print '' - time.sleep(1) - -if __name__ == '__main__': - run_example() - print 'Done.' \ No newline at end of file diff --git a/oauth/example/server.py b/oauth/example/server.py deleted file mode 100644 index 5986b0e..0000000 --- a/oauth/example/server.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -The MIT License - -Copyright (c) 2007 Leah Culver - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -""" - -from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer -import urllib - -import oauth.oauth as oauth - -# fake urls for the test server -REQUEST_TOKEN_URL = 'https://photos.example.net/request_token' -ACCESS_TOKEN_URL = 'https://photos.example.net/access_token' -AUTHORIZATION_URL = 'https://photos.example.net/authorize' -CALLBACK_URL = 'http://printer.example.com/request_token_ready' -RESOURCE_URL = 'http://photos.example.net/photos' -REALM = 'http://photos.example.net/' -VERIFIER = 'verifier' - -# example store for one of each thing -class MockOAuthDataStore(oauth.OAuthDataStore): - - def __init__(self): - self.consumer = oauth.OAuthConsumer('key', 'secret') - self.request_token = oauth.OAuthToken('requestkey', 'requestsecret') - self.access_token = oauth.OAuthToken('accesskey', 'accesssecret') - self.nonce = 'nonce' - self.verifier = VERIFIER - - def lookup_consumer(self, key): - if key == self.consumer.key: - return self.consumer - return None - - def lookup_token(self, token_type, token): - token_attrib = getattr(self, '%s_token' % token_type) - if token == token_attrib.key: - ## HACK - token_attrib.set_callback(CALLBACK_URL) - return token_attrib - return None - - def lookup_nonce(self, oauth_consumer, oauth_token, nonce): - if oauth_token and oauth_consumer.key == self.consumer.key and (oauth_token.key == self.request_token.key or oauth_token.key == self.access_token.key) and nonce == self.nonce: - return self.nonce - return None - - def fetch_request_token(self, oauth_consumer, oauth_callback): - if oauth_consumer.key == self.consumer.key: - if oauth_callback: - # want to check here if callback is sensible - # for mock store, we assume it is - self.request_token.set_callback(oauth_callback) - return self.request_token - return None - - def fetch_access_token(self, oauth_consumer, oauth_token, oauth_verifier): - if oauth_consumer.key == self.consumer.key and oauth_token.key == self.request_token.key and oauth_verifier == self.verifier: - # want to check here if token is authorized - # for mock store, we assume it is - return self.access_token - return None - - def authorize_request_token(self, oauth_token, user): - if oauth_token.key == self.request_token.key: - # authorize the request token in the store - # for mock store, do nothing - return self.request_token - return None - -class RequestHandler(BaseHTTPRequestHandler): - - def __init__(self, *args, **kwargs): - self.oauth_server = oauth.OAuthServer(MockOAuthDataStore()) - self.oauth_server.add_signature_method(oauth.OAuthSignatureMethod_PLAINTEXT()) - self.oauth_server.add_signature_method(oauth.OAuthSignatureMethod_HMAC_SHA1()) - BaseHTTPRequestHandler.__init__(self, *args, **kwargs) - - # example way to send an oauth error - def send_oauth_error(self, err=None): - # send a 401 error - self.send_error(401, str(err.message)) - # return the authenticate header - header = oauth.build_authenticate_header(realm=REALM) - for k, v in header.iteritems(): - self.send_header(k, v) - - def do_GET(self): - - # debug info - #print self.command, self.path, self.headers - - # get the post data (if any) - postdata = None - if self.command == 'POST': - try: - length = int(self.headers.getheader('content-length')) - postdata = self.rfile.read(length) - except: - pass - - # construct the oauth request from the request parameters - oauth_request = oauth.OAuthRequest.from_request(self.command, self.path, headers=self.headers, query_string=postdata) - - # request token - if self.path.startswith(REQUEST_TOKEN_URL): - try: - # create a request token - token = self.oauth_server.fetch_request_token(oauth_request) - # send okay response - self.send_response(200, 'OK') - self.end_headers() - # return the token - self.wfile.write(token.to_string()) - except oauth.OAuthError, err: - self.send_oauth_error(err) - return - - # user authorization - if self.path.startswith(AUTHORIZATION_URL): - try: - # get the request token - token = self.oauth_server.fetch_request_token(oauth_request) - # authorize the token (kind of does nothing for now) - token = self.oauth_server.authorize_token(token, None) - token.set_verifier(VERIFIER) - # send okay response - self.send_response(200, 'OK') - self.end_headers() - # return the callback url (to show server has it) - self.wfile.write(token.get_callback_url()) - except oauth.OAuthError, err: - self.send_oauth_error(err) - return - - # access token - if self.path.startswith(ACCESS_TOKEN_URL): - try: - # create an access token - token = self.oauth_server.fetch_access_token(oauth_request) - # send okay response - self.send_response(200, 'OK') - self.end_headers() - # return the token - self.wfile.write(token.to_string()) - except oauth.OAuthError, err: - self.send_oauth_error(err) - return - - # protected resources - if self.path.startswith(RESOURCE_URL): - try: - # verify the request has been oauth authorized - consumer, token, params = self.oauth_server.verify_request(oauth_request) - # send okay response - self.send_response(200, 'OK') - self.end_headers() - # return the extra parameters - just for something to return - self.wfile.write(str(params)) - except oauth.OAuthError, err: - self.send_oauth_error(err) - return - - def do_POST(self): - return self.do_GET() - -def main(): - try: - server = HTTPServer(('', 8080), RequestHandler) - print 'Test server running...' - server.serve_forever() - except KeyboardInterrupt: - server.socket.close() - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/oauth/oauth.py b/oauth/oauth.py deleted file mode 100644 index b6284c5..0000000 --- a/oauth/oauth.py +++ /dev/null @@ -1,655 +0,0 @@ -""" -The MIT License - -Copyright (c) 2007 Leah Culver - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -""" - -import cgi -import urllib -import time -import random -import urlparse -import hmac -import binascii - - -VERSION = '1.0' # Hi Blaine! -HTTP_METHOD = 'GET' -SIGNATURE_METHOD = 'PLAINTEXT' - - -class OAuthError(RuntimeError): - """Generic exception class.""" - def __init__(self, message='OAuth error occured.'): - self.message = message - -def build_authenticate_header(realm=''): - """Optional WWW-Authenticate header (401 error)""" - return {'WWW-Authenticate': 'OAuth realm="%s"' % realm} - -def escape(s): - """Escape a URL including any /.""" - return urllib.quote(s, safe='~') - -def _utf8_str(s): - """Convert unicode to utf-8.""" - if isinstance(s, unicode): - return s.encode("utf-8") - else: - return str(s) - -def generate_timestamp(): - """Get seconds since epoch (UTC).""" - return int(time.time()) - -def generate_nonce(length=8): - """Generate pseudorandom number.""" - return ''.join([str(random.randint(0, 9)) for i in range(length)]) - -def generate_verifier(length=8): - """Generate pseudorandom number.""" - return ''.join([str(random.randint(0, 9)) for i in range(length)]) - - -class OAuthConsumer(object): - """Consumer of OAuth authentication. - - OAuthConsumer is a data type that represents the identity of the Consumer - via its shared secret with the Service Provider. - - """ - key = None - secret = None - - def __init__(self, key, secret): - self.key = key - self.secret = secret - - -class OAuthToken(object): - """OAuthToken is a data type that represents an End User via either an access - or request token. - - key -- the token - secret -- the token secret - - """ - key = None - secret = None - callback = None - callback_confirmed = None - verifier = None - - def __init__(self, key, secret): - self.key = key - self.secret = secret - - def set_callback(self, callback): - self.callback = callback - self.callback_confirmed = 'true' - - def set_verifier(self, verifier=None): - if verifier is not None: - self.verifier = verifier - else: - self.verifier = generate_verifier() - - def get_callback_url(self): - if self.callback and self.verifier: - # Append the oauth_verifier. - parts = urlparse.urlparse(self.callback) - scheme, netloc, path, params, query, fragment = parts[:6] - if query: - query = '%s&oauth_verifier=%s' % (query, self.verifier) - else: - query = 'oauth_verifier=%s' % self.verifier - return urlparse.urlunparse((scheme, netloc, path, params, - query, fragment)) - return self.callback - - def to_string(self): - data = { - 'oauth_token': self.key, - 'oauth_token_secret': self.secret, - } - if self.callback_confirmed is not None: - data['oauth_callback_confirmed'] = self.callback_confirmed - return urllib.urlencode(data) - - def from_string(s): - """ Returns a token from something like: - oauth_token_secret=xxx&oauth_token=xxx - """ - params = cgi.parse_qs(s, keep_blank_values=False) - key = params['oauth_token'][0] - secret = params['oauth_token_secret'][0] - token = OAuthToken(key, secret) - try: - token.callback_confirmed = params['oauth_callback_confirmed'][0] - except KeyError: - pass # 1.0, no callback confirmed. - return token - from_string = staticmethod(from_string) - - def __str__(self): - return self.to_string() - - -class OAuthRequest(object): - """OAuthRequest represents the request and can be serialized. - - OAuth parameters: - - oauth_consumer_key - - oauth_token - - oauth_signature_method - - oauth_signature - - oauth_timestamp - - oauth_nonce - - oauth_version - - oauth_verifier - ... any additional parameters, as defined by the Service Provider. - """ - parameters = None # OAuth parameters. - http_method = HTTP_METHOD - http_url = None - version = VERSION - - def __init__(self, http_method=HTTP_METHOD, http_url=None, parameters=None): - self.http_method = http_method - self.http_url = http_url - self.parameters = parameters or {} - - def set_parameter(self, parameter, value): - self.parameters[parameter] = value - - def get_parameter(self, parameter): - try: - return self.parameters[parameter] - except: - raise OAuthError('Parameter not found: %s' % parameter) - - def _get_timestamp_nonce(self): - return self.get_parameter('oauth_timestamp'), self.get_parameter( - 'oauth_nonce') - - def get_nonoauth_parameters(self): - """Get any non-OAuth parameters.""" - parameters = {} - for k, v in self.parameters.iteritems(): - # Ignore oauth parameters. - if k.find('oauth_') < 0: - parameters[k] = v - return parameters - - def to_header(self, realm=''): - """Serialize as a header for an HTTPAuth request.""" - auth_header = 'OAuth realm="%s"' % realm - # Add the oauth parameters. - if self.parameters: - for k, v in self.parameters.iteritems(): - if k[:6] == 'oauth_': - auth_header += ', %s="%s"' % (k, escape(str(v))) - return {'Authorization': auth_header} - - def to_postdata(self): - """Serialize as post data for a POST request.""" - return '&'.join(['%s=%s' % (escape(str(k)), escape(str(v))) \ - for k, v in self.parameters.iteritems()]) - - def to_url(self): - """Serialize as a URL for a GET request.""" - return '%s?%s' % (self.get_normalized_http_url(), self.to_postdata()) - - def get_normalized_parameters(self): - """Return a string that contains the parameters that must be signed.""" - params = self.parameters - try: - # Exclude the signature if it exists. - del params['oauth_signature'] - except: - pass - # Escape key values before sorting. - key_values = [(escape(_utf8_str(k)), escape(_utf8_str(v))) \ - for k,v in params.items()] - # Sort lexicographically, first after key, then after value. - key_values.sort() - # Combine key value pairs into a string. - return '&'.join(['%s=%s' % (k, v) for k, v in key_values]) - - def get_normalized_http_method(self): - """Uppercases the http method.""" - return self.http_method.upper() - - def get_normalized_http_url(self): - """Parses the URL and rebuilds it to be scheme://host/path.""" - parts = urlparse.urlparse(self.http_url) - scheme, netloc, path = parts[:3] - # Exclude default port numbers. - if scheme == 'http' and netloc[-3:] == ':80': - netloc = netloc[:-3] - elif scheme == 'https' and netloc[-4:] == ':443': - netloc = netloc[:-4] - return '%s://%s%s' % (scheme, netloc, path) - - def sign_request(self, signature_method, consumer, token): - """Set the signature parameter to the result of build_signature.""" - # Set the signature method. - self.set_parameter('oauth_signature_method', - signature_method.get_name()) - # Set the signature. - self.set_parameter('oauth_signature', - self.build_signature(signature_method, consumer, token)) - - def build_signature(self, signature_method, consumer, token): - """Calls the build signature method within the signature method.""" - return signature_method.build_signature(self, consumer, token) - - def from_request(http_method, http_url, headers=None, parameters=None, - query_string=None): - """Combines multiple parameter sources.""" - if parameters is None: - parameters = {} - - # Headers - if headers and 'Authorization' in headers: - auth_header = headers['Authorization'] - # Check that the authorization header is OAuth. - if auth_header[:6] == 'OAuth ': - auth_header = auth_header[6:] - try: - # Get the parameters from the header. - header_params = OAuthRequest._split_header(auth_header) - parameters.update(header_params) - except: - raise OAuthError('Unable to parse OAuth parameters from ' - 'Authorization header.') - - # GET or POST query string. - if query_string: - query_params = OAuthRequest._split_url_string(query_string) - parameters.update(query_params) - - # URL parameters. - param_str = urlparse.urlparse(http_url)[4] # query - url_params = OAuthRequest._split_url_string(param_str) - parameters.update(url_params) - - if parameters: - return OAuthRequest(http_method, http_url, parameters) - - return None - from_request = staticmethod(from_request) - - def from_consumer_and_token(oauth_consumer, token=None, - callback=None, verifier=None, http_method=HTTP_METHOD, - http_url=None, parameters=None): - if not parameters: - parameters = {} - - defaults = { - 'oauth_consumer_key': oauth_consumer.key, - 'oauth_timestamp': generate_timestamp(), - 'oauth_nonce': generate_nonce(), - 'oauth_version': OAuthRequest.version, - } - - defaults.update(parameters) - parameters = defaults - - if token: - parameters['oauth_token'] = token.key - if token.callback: - parameters['oauth_callback'] = token.callback - # 1.0a support for verifier. - if verifier: - parameters['oauth_verifier'] = verifier - elif callback: - # 1.0a support for callback in the request token request. - parameters['oauth_callback'] = callback - - return OAuthRequest(http_method, http_url, parameters) - from_consumer_and_token = staticmethod(from_consumer_and_token) - - def from_token_and_callback(token, callback=None, http_method=HTTP_METHOD, - http_url=None, parameters=None): - if not parameters: - parameters = {} - - parameters['oauth_token'] = token.key - - if callback: - parameters['oauth_callback'] = callback - - return OAuthRequest(http_method, http_url, parameters) - from_token_and_callback = staticmethod(from_token_and_callback) - - def _split_header(header): - """Turn Authorization: header into parameters.""" - params = {} - parts = header.split(',') - for param in parts: - # Ignore realm parameter. - if param.find('realm') > -1: - continue - # Remove whitespace. - param = param.strip() - # Split key-value. - param_parts = param.split('=', 1) - # Remove quotes and unescape the value. - params[param_parts[0]] = urllib.unquote(param_parts[1].strip('\"')) - return params - _split_header = staticmethod(_split_header) - - def _split_url_string(param_str): - """Turn URL string into parameters.""" - parameters = cgi.parse_qs(param_str, keep_blank_values=False) - for k, v in parameters.iteritems(): - parameters[k] = urllib.unquote(v[0]) - return parameters - _split_url_string = staticmethod(_split_url_string) - -class OAuthServer(object): - """A worker to check the validity of a request against a data store.""" - timestamp_threshold = 300 # In seconds, five minutes. - version = VERSION - signature_methods = None - data_store = None - - def __init__(self, data_store=None, signature_methods=None): - self.data_store = data_store - self.signature_methods = signature_methods or {} - - def set_data_store(self, data_store): - self.data_store = data_store - - def get_data_store(self): - return self.data_store - - def add_signature_method(self, signature_method): - self.signature_methods[signature_method.get_name()] = signature_method - return self.signature_methods - - def fetch_request_token(self, oauth_request): - """Processes a request_token request and returns the - request token on success. - """ - try: - # Get the request token for authorization. - token = self._get_token(oauth_request, 'request') - except OAuthError: - # No token required for the initial token request. - version = self._get_version(oauth_request) - consumer = self._get_consumer(oauth_request) - try: - callback = self.get_callback(oauth_request) - except OAuthError: - callback = None # 1.0, no callback specified. - self._check_signature(oauth_request, consumer, None) - # Fetch a new token. - token = self.data_store.fetch_request_token(consumer, callback) - return token - - def fetch_access_token(self, oauth_request): - """Processes an access_token request and returns the - access token on success. - """ - version = self._get_version(oauth_request) - consumer = self._get_consumer(oauth_request) - try: - verifier = self._get_verifier(oauth_request) - except OAuthError: - verifier = None - # Get the request token. - token = self._get_token(oauth_request, 'request') - self._check_signature(oauth_request, consumer, token) - new_token = self.data_store.fetch_access_token(consumer, token, verifier) - return new_token - - def verify_request(self, oauth_request): - """Verifies an api call and checks all the parameters.""" - # -> consumer and token - version = self._get_version(oauth_request) - consumer = self._get_consumer(oauth_request) - # Get the access token. - token = self._get_token(oauth_request, 'access') - self._check_signature(oauth_request, consumer, token) - parameters = oauth_request.get_nonoauth_parameters() - return consumer, token, parameters - - def authorize_token(self, token, user): - """Authorize a request token.""" - return self.data_store.authorize_request_token(token, user) - - def get_callback(self, oauth_request): - """Get the callback URL.""" - return oauth_request.get_parameter('oauth_callback') - - def build_authenticate_header(self, realm=''): - """Optional support for the authenticate header.""" - return {'WWW-Authenticate': 'OAuth realm="%s"' % realm} - - def _get_version(self, oauth_request): - """Verify the correct version request for this server.""" - try: - version = oauth_request.get_parameter('oauth_version') - except: - version = VERSION - if version and version != self.version: - raise OAuthError('OAuth version %s not supported.' % str(version)) - return version - - def _get_signature_method(self, oauth_request): - """Figure out the signature with some defaults.""" - try: - signature_method = oauth_request.get_parameter( - 'oauth_signature_method') - except: - signature_method = SIGNATURE_METHOD - try: - # Get the signature method object. - signature_method = self.signature_methods[signature_method] - except: - signature_method_names = ', '.join(self.signature_methods.keys()) - raise OAuthError('Signature method %s not supported try one of the ' - 'following: %s' % (signature_method, signature_method_names)) - - return signature_method - - def _get_consumer(self, oauth_request): - consumer_key = oauth_request.get_parameter('oauth_consumer_key') - consumer = self.data_store.lookup_consumer(consumer_key) - if not consumer: - raise OAuthError('Invalid consumer.') - return consumer - - def _get_token(self, oauth_request, token_type='access'): - """Try to find the token for the provided request token key.""" - token_field = oauth_request.get_parameter('oauth_token') - token = self.data_store.lookup_token(token_type, token_field) - if not token: - raise OAuthError('Invalid %s token: %s' % (token_type, token_field)) - return token - - def _get_verifier(self, oauth_request): - return oauth_request.get_parameter('oauth_verifier') - - def _check_signature(self, oauth_request, consumer, token): - timestamp, nonce = oauth_request._get_timestamp_nonce() - self._check_timestamp(timestamp) - self._check_nonce(consumer, token, nonce) - signature_method = self._get_signature_method(oauth_request) - try: - signature = oauth_request.get_parameter('oauth_signature') - except: - raise OAuthError('Missing signature.') - # Validate the signature. - valid_sig = signature_method.check_signature(oauth_request, consumer, - token, signature) - if not valid_sig: - key, base = signature_method.build_signature_base_string( - oauth_request, consumer, token) - raise OAuthError('Invalid signature. Expected signature base ' - 'string: %s' % base) - built = signature_method.build_signature(oauth_request, consumer, token) - - def _check_timestamp(self, timestamp): - """Verify that timestamp is recentish.""" - timestamp = int(timestamp) - now = int(time.time()) - lapsed = now - timestamp - if lapsed > self.timestamp_threshold: - raise OAuthError('Expired timestamp: given %d and now %s has a ' - 'greater difference than threshold %d' % - (timestamp, now, self.timestamp_threshold)) - - def _check_nonce(self, consumer, token, nonce): - """Verify that the nonce is uniqueish.""" - nonce = self.data_store.lookup_nonce(consumer, token, nonce) - if nonce: - raise OAuthError('Nonce already used: %s' % str(nonce)) - - -class OAuthClient(object): - """OAuthClient is a worker to attempt to execute a request.""" - consumer = None - token = None - - def __init__(self, oauth_consumer, oauth_token): - self.consumer = oauth_consumer - self.token = oauth_token - - def get_consumer(self): - return self.consumer - - def get_token(self): - return self.token - - def fetch_request_token(self, oauth_request): - """-> OAuthToken.""" - raise NotImplementedError - - def fetch_access_token(self, oauth_request): - """-> OAuthToken.""" - raise NotImplementedError - - def access_resource(self, oauth_request): - """-> Some protected resource.""" - raise NotImplementedError - - -class OAuthDataStore(object): - """A database abstraction used to lookup consumers and tokens.""" - - def lookup_consumer(self, key): - """-> OAuthConsumer.""" - raise NotImplementedError - - def lookup_token(self, oauth_consumer, token_type, token_token): - """-> OAuthToken.""" - raise NotImplementedError - - def lookup_nonce(self, oauth_consumer, oauth_token, nonce): - """-> OAuthToken.""" - raise NotImplementedError - - def fetch_request_token(self, oauth_consumer, oauth_callback): - """-> OAuthToken.""" - raise NotImplementedError - - def fetch_access_token(self, oauth_consumer, oauth_token, oauth_verifier): - """-> OAuthToken.""" - raise NotImplementedError - - def authorize_request_token(self, oauth_token, user): - """-> OAuthToken.""" - raise NotImplementedError - - -class OAuthSignatureMethod(object): - """A strategy class that implements a signature method.""" - def get_name(self): - """-> str.""" - raise NotImplementedError - - def build_signature_base_string(self, oauth_request, oauth_consumer, oauth_token): - """-> str key, str raw.""" - raise NotImplementedError - - def build_signature(self, oauth_request, oauth_consumer, oauth_token): - """-> str.""" - raise NotImplementedError - - def check_signature(self, oauth_request, consumer, token, signature): - built = self.build_signature(oauth_request, consumer, token) - return built == signature - - -class OAuthSignatureMethod_HMAC_SHA1(OAuthSignatureMethod): - - def get_name(self): - return 'HMAC-SHA1' - - def build_signature_base_string(self, oauth_request, consumer, token): - sig = ( - escape(oauth_request.get_normalized_http_method()), - escape(oauth_request.get_normalized_http_url()), - escape(oauth_request.get_normalized_parameters()), - ) - - key = '%s&' % escape(consumer.secret) - if token: - key += escape(token.secret) - raw = '&'.join(sig) - return key, raw - - def build_signature(self, oauth_request, consumer, token): - """Builds the base signature string.""" - key, raw = self.build_signature_base_string(oauth_request, consumer, - token) - - # HMAC object. - try: - import hashlib # 2.5 - hashed = hmac.new(key, raw, hashlib.sha1) - except: - import sha # Deprecated - hashed = hmac.new(key, raw, sha) - - # Calculate the digest base 64. - return binascii.b2a_base64(hashed.digest())[:-1] - - -class OAuthSignatureMethod_PLAINTEXT(OAuthSignatureMethod): - - def get_name(self): - return 'PLAINTEXT' - - def build_signature_base_string(self, oauth_request, consumer, token): - """Concatenates the consumer key and secret.""" - sig = '%s&' % escape(consumer.secret) - if token: - sig = sig + escape(token.secret) - return sig, sig - - def build_signature(self, oauth_request, consumer, token): - key, raw = self.build_signature_base_string(oauth_request, consumer, - token) - return key \ No newline at end of file diff --git a/oauthlib/__init__.py b/oauthlib/__init__.py new file mode 100644 index 0000000..300bdc7 --- /dev/null +++ b/oauthlib/__init__.py @@ -0,0 +1,25 @@ +""" + oauthlib + ~~~~~~~~ + + A generic, spec-compliant, thorough implementation of the OAuth + request-signing logic. + + :copyright: (c) 2011 by Idan Gazit. + :license: BSD, see LICENSE for details. +""" + +__author__ = 'Idan Gazit ' +__version__ = '0.7.2' + + +import logging +try: # Python 2.7+ + from logging import NullHandler +except ImportError: + class NullHandler(logging.Handler): + + def emit(self, record): + pass + +logging.getLogger('oauthlib').addHandler(NullHandler()) diff --git a/oauthlib/common.py b/oauthlib/common.py new file mode 100644 index 0000000..0179b8e --- /dev/null +++ b/oauthlib/common.py @@ -0,0 +1,414 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.common +~~~~~~~~~~~~~~ + +This module provides data structures and utilities common +to all implementations of OAuth. +""" +from __future__ import absolute_import, unicode_literals + +import collections +import datetime +import logging +import random +import re +import sys +import time + +try: + from urllib import quote as _quote + from urllib import unquote as _unquote + from urllib import urlencode as _urlencode +except ImportError: + from urllib.parse import quote as _quote + from urllib.parse import unquote as _unquote + from urllib.parse import urlencode as _urlencode +try: + import urlparse +except ImportError: + import urllib.parse as urlparse + +UNICODE_ASCII_CHARACTER_SET = ('abcdefghijklmnopqrstuvwxyz' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + '0123456789') + +CLIENT_ID_CHARACTER_SET = (r' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMN' + 'OPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}') + + +always_safe = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ' + 'abcdefghijklmnopqrstuvwxyz' + '0123456789' '_.-') + +log = logging.getLogger('oauthlib') + +PY3 = sys.version_info[0] == 3 + +if PY3: + unicode_type = str + bytes_type = bytes +else: + unicode_type = unicode + bytes_type = str + + +# 'safe' must be bytes (Python 2.6 requires bytes, other versions allow either) +def quote(s, safe=b'/'): + s = s.encode('utf-8') if isinstance(s, unicode_type) else s + s = _quote(s, safe) + # PY3 always returns unicode. PY2 may return either, depending on whether + # it had to modify the string. + if isinstance(s, bytes_type): + s = s.decode('utf-8') + return s + + +def unquote(s): + s = _unquote(s) + # PY3 always returns unicode. PY2 seems to always return what you give it, + # which differs from quote's behavior. Just to be safe, make sure it is + # unicode before we return. + if isinstance(s, bytes_type): + s = s.decode('utf-8') + return s + + +def urlencode(params): + utf8_params = encode_params_utf8(params) + urlencoded = _urlencode(utf8_params) + if isinstance(urlencoded, unicode_type): # PY3 returns unicode + return urlencoded + else: + return urlencoded.decode("utf-8") + + +def encode_params_utf8(params): + """Ensures that all parameters in a list of 2-element tuples are encoded to + bytestrings using UTF-8 + """ + encoded = [] + for k, v in params: + encoded.append(( + k.encode('utf-8') if isinstance(k, unicode_type) else k, + v.encode('utf-8') if isinstance(v, unicode_type) else v)) + return encoded + + +def decode_params_utf8(params): + """Ensures that all parameters in a list of 2-element tuples are decoded to + unicode using UTF-8. + """ + decoded = [] + for k, v in params: + decoded.append(( + k.decode('utf-8') if isinstance(k, bytes_type) else k, + v.decode('utf-8') if isinstance(v, bytes_type) else v)) + return decoded + + +urlencoded = set(always_safe) | set('=&;%+~,*@') + + +def urldecode(query): + """Decode a query string in x-www-form-urlencoded format into a sequence + of two-element tuples. + + Unlike urlparse.parse_qsl(..., strict_parsing=True) urldecode will enforce + correct formatting of the query string by validation. If validation fails + a ValueError will be raised. urllib.parse_qsl will only raise errors if + any of name-value pairs omits the equals sign. + """ + # Check if query contains invalid characters + if query and not set(query) <= urlencoded: + error = ("Error trying to decode a non urlencoded string. " + "Found invalid characters: %s " + "in the string: '%s'. " + "Please ensure the request/response body is " + "x-www-form-urlencoded.") + raise ValueError(error % (set(query) - urlencoded, query)) + + # Check for correctly hex encoded values using a regular expression + # All encoded values begin with % followed by two hex characters + # correct = %00, %A0, %0A, %FF + # invalid = %G0, %5H, %PO + invalid_hex = '%[^0-9A-Fa-f]|%[0-9A-Fa-f][^0-9A-Fa-f]' + if len(re.findall(invalid_hex, query)): + raise ValueError('Invalid hex encoding in query string.') + + # We encode to utf-8 prior to parsing because parse_qsl behaves + # differently on unicode input in python 2 and 3. + # Python 2.7 + # >>> urlparse.parse_qsl(u'%E5%95%A6%E5%95%A6') + # u'\xe5\x95\xa6\xe5\x95\xa6' + # Python 2.7, non unicode input gives the same + # >>> urlparse.parse_qsl('%E5%95%A6%E5%95%A6') + # '\xe5\x95\xa6\xe5\x95\xa6' + # but now we can decode it to unicode + # >>> urlparse.parse_qsl('%E5%95%A6%E5%95%A6').decode('utf-8') + # u'\u5566\u5566' + # Python 3.3 however + # >>> urllib.parse.parse_qsl(u'%E5%95%A6%E5%95%A6') + # u'\u5566\u5566' + query = query.encode( + 'utf-8') if not PY3 and isinstance(query, unicode_type) else query + # We want to allow queries such as "c2" whereas urlparse.parse_qsl + # with the strict_parsing flag will not. + params = urlparse.parse_qsl(query, keep_blank_values=True) + + # unicode all the things + return decode_params_utf8(params) + + +def extract_params(raw): + """Extract parameters and return them as a list of 2-tuples. + + Will successfully extract parameters from urlencoded query strings, + dicts, or lists of 2-tuples. Empty strings/dicts/lists will return an + empty list of parameters. Any other input will result in a return + value of None. + """ + if isinstance(raw, bytes_type) or isinstance(raw, unicode_type): + try: + params = urldecode(raw) + except ValueError: + params = None + elif hasattr(raw, '__iter__'): + try: + dict(raw) + except ValueError: + params = None + except TypeError: + params = None + else: + params = list(raw.items() if isinstance(raw, dict) else raw) + params = decode_params_utf8(params) + else: + params = None + + return params + + +def generate_nonce(): + """Generate pseudorandom nonce that is unlikely to repeat. + + Per `section 3.3`_ of the OAuth 1 RFC 5849 spec. + Per `section 3.2.1`_ of the MAC Access Authentication spec. + + A random 64-bit number is appended to the epoch timestamp for both + randomness and to decrease the likelihood of collisions. + + .. _`section 3.2.1`: http://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01#section-3.2.1 + .. _`section 3.3`: http://tools.ietf.org/html/rfc5849#section-3.3 + """ + return unicode_type(unicode_type(random.getrandbits(64)) + generate_timestamp()) + + +def generate_timestamp(): + """Get seconds since epoch (UTC). + + Per `section 3.3`_ of the OAuth 1 RFC 5849 spec. + Per `section 3.2.1`_ of the MAC Access Authentication spec. + + .. _`section 3.2.1`: http://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01#section-3.2.1 + .. _`section 3.3`: http://tools.ietf.org/html/rfc5849#section-3.3 + """ + return unicode_type(int(time.time())) + + +def generate_token(length=30, chars=UNICODE_ASCII_CHARACTER_SET): + """Generates a non-guessable OAuth token + + OAuth (1 and 2) does not specify the format of tokens except that they + should be strings of random characters. Tokens should not be guessable + and entropy when generating the random characters is important. Which is + why SystemRandom is used instead of the default random.choice method. + """ + rand = random.SystemRandom() + return ''.join(rand.choice(chars) for x in range(length)) + + +def generate_signed_token(private_pem, request): + import jwt + + now = datetime.datetime.utcnow() + + claims = { + 'scope': request.scope, + 'exp': now + datetime.timedelta(seconds=request.expires_in) + } + + claims.update(request.claims) + + token = jwt.encode(claims, private_pem, 'RS256') + token = to_unicode(token, "UTF-8") + + return token + + +def verify_signed_token(public_pem, token): + import jwt + + return jwt.decode(token, public_pem, algorithms=['RS256']) + + +def generate_client_id(length=30, chars=CLIENT_ID_CHARACTER_SET): + """Generates an OAuth client_id + + OAuth 2 specify the format of client_id in + http://tools.ietf.org/html/rfc6749#appendix-A. + """ + return generate_token(length, chars) + + +def add_params_to_qs(query, params): + """Extend a query with a list of two-tuples.""" + if isinstance(params, dict): + params = params.items() + queryparams = urlparse.parse_qsl(query, keep_blank_values=True) + queryparams.extend(params) + return urlencode(queryparams) + + +def add_params_to_uri(uri, params, fragment=False): + """Add a list of two-tuples to the uri query components.""" + sch, net, path, par, query, fra = urlparse.urlparse(uri) + if fragment: + fra = add_params_to_qs(fra, params) + else: + query = add_params_to_qs(query, params) + return urlparse.urlunparse((sch, net, path, par, query, fra)) + + +def safe_string_equals(a, b): + """ Near-constant time string comparison. + + Used in order to avoid timing attacks on sensitive information such + as secret keys during request verification (`rootLabs`_). + + .. _`rootLabs`: http://rdist.root.org/2010/01/07/timing-independent-array-comparison/ + + """ + if len(a) != len(b): + return False + + result = 0 + for x, y in zip(a, b): + result |= ord(x) ^ ord(y) + return result == 0 + + +def to_unicode(data, encoding='UTF-8'): + """Convert a number of different types of objects to unicode.""" + if isinstance(data, unicode_type): + return data + + if isinstance(data, bytes_type): + return unicode_type(data, encoding=encoding) + + if hasattr(data, '__iter__'): + try: + dict(data) + except TypeError: + pass + except ValueError: + # Assume it's a one dimensional data structure + return (to_unicode(i, encoding) for i in data) + else: + # We support 2.6 which lacks dict comprehensions + if hasattr(data, 'items'): + data = data.items() + return dict(((to_unicode(k, encoding), to_unicode(v, encoding)) for k, v in data)) + + return data + + +class CaseInsensitiveDict(dict): + + """Basic case insensitive dict with strings only keys.""" + + proxy = {} + + def __init__(self, data): + self.proxy = dict((k.lower(), k) for k in data) + for k in data: + self[k] = data[k] + + def __contains__(self, k): + return k.lower() in self.proxy + + def __delitem__(self, k): + key = self.proxy[k.lower()] + super(CaseInsensitiveDict, self).__delitem__(key) + del self.proxy[k.lower()] + + def __getitem__(self, k): + key = self.proxy[k.lower()] + return super(CaseInsensitiveDict, self).__getitem__(key) + + def get(self, k, default=None): + return self[k] if k in self else default + + def __setitem__(self, k, v): + super(CaseInsensitiveDict, self).__setitem__(k, v) + self.proxy[k.lower()] = k + + +class Request(object): + + """A malleable representation of a signable HTTP request. + + Body argument may contain any data, but parameters will only be decoded if + they are one of: + + * urlencoded query string + * dict + * list of 2-tuples + + Anything else will be treated as raw body data to be passed through + unmolested. + """ + + def __init__(self, uri, http_method='GET', body=None, headers=None, + encoding='utf-8'): + # Convert to unicode using encoding if given, else assume unicode + encode = lambda x: to_unicode(x, encoding) if encoding else x + + self.uri = encode(uri) + self.http_method = encode(http_method) + self.headers = CaseInsensitiveDict(encode(headers or {})) + self.body = encode(body) + self.decoded_body = extract_params(encode(body)) + self.oauth_params = [] + + self._params = {} + self._params.update(dict(urldecode(self.uri_query))) + self._params.update(dict(self.decoded_body or [])) + self._params.update(self.headers) + + def __getattr__(self, name): + return self._params.get(name, None) + + def __repr__(self): + return '' % ( + self.uri, self.http_method, self.headers, self.body) + + @property + def uri_query(self): + return urlparse.urlparse(self.uri).query + + @property + def uri_query_params(self): + if not self.uri_query: + return [] + return urlparse.parse_qsl(self.uri_query, keep_blank_values=True, + strict_parsing=True) + + @property + def duplicate_params(self): + seen_keys = collections.defaultdict(int) + all_keys = (p[0] + for p in (self.decoded_body or []) + self.uri_query_params) + for k in all_keys: + seen_keys[k] += 1 + return [k for k, c in seen_keys.items() if c > 1] diff --git a/oauthlib/oauth1/__init__.py b/oauthlib/oauth1/__init__.py new file mode 100644 index 0000000..b2bc0f9 --- /dev/null +++ b/oauthlib/oauth1/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth1 +~~~~~~~~~~~~~~ + +This module is a wrapper for the most recent implementation of OAuth 1.0 Client +and Server classes. +""" +from __future__ import absolute_import, unicode_literals + +from .rfc5849 import Client +from .rfc5849 import SIGNATURE_HMAC, SIGNATURE_RSA, SIGNATURE_PLAINTEXT +from .rfc5849 import SIGNATURE_TYPE_AUTH_HEADER, SIGNATURE_TYPE_QUERY +from .rfc5849 import SIGNATURE_TYPE_BODY +from .rfc5849.request_validator import RequestValidator +from .rfc5849.endpoints import RequestTokenEndpoint, AuthorizationEndpoint +from .rfc5849.endpoints import AccessTokenEndpoint, ResourceEndpoint +from .rfc5849.endpoints import SignatureOnlyEndpoint, WebApplicationServer +from .rfc5849.errors import * diff --git a/oauthlib/oauth1/rfc5849/__init__.py b/oauthlib/oauth1/rfc5849/__init__.py new file mode 100644 index 0000000..ad9713c --- /dev/null +++ b/oauthlib/oauth1/rfc5849/__init__.py @@ -0,0 +1,320 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth1.rfc5849 +~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for signing and checking OAuth 1.0 RFC 5849 requests. +""" +from __future__ import absolute_import, unicode_literals + +import logging +log = logging.getLogger(__name__) + +import sys +try: + import urlparse +except ImportError: + import urllib.parse as urlparse + +if sys.version_info[0] == 3: + bytes_type = bytes +else: + bytes_type = str + +from oauthlib.common import Request, urlencode, generate_nonce +from oauthlib.common import generate_timestamp, to_unicode +from . import parameters, signature + +SIGNATURE_HMAC = "HMAC-SHA1" +SIGNATURE_RSA = "RSA-SHA1" +SIGNATURE_PLAINTEXT = "PLAINTEXT" +SIGNATURE_METHODS = (SIGNATURE_HMAC, SIGNATURE_RSA, SIGNATURE_PLAINTEXT) + +SIGNATURE_TYPE_AUTH_HEADER = 'AUTH_HEADER' +SIGNATURE_TYPE_QUERY = 'QUERY' +SIGNATURE_TYPE_BODY = 'BODY' + +CONTENT_TYPE_FORM_URLENCODED = 'application/x-www-form-urlencoded' + + +class Client(object): + + """A client used to sign OAuth 1.0 RFC 5849 requests.""" + SIGNATURE_METHODS = { + SIGNATURE_HMAC: signature.sign_hmac_sha1_with_client, + SIGNATURE_RSA: signature.sign_rsa_sha1_with_client, + SIGNATURE_PLAINTEXT: signature.sign_plaintext_with_client + } + + @classmethod + def register_signature_method(cls, method_name, method_callback): + cls.SIGNATURE_METHODS[method_name] = method_callback + + def __init__(self, client_key, + client_secret=None, + resource_owner_key=None, + resource_owner_secret=None, + callback_uri=None, + signature_method=SIGNATURE_HMAC, + signature_type=SIGNATURE_TYPE_AUTH_HEADER, + rsa_key=None, verifier=None, realm=None, + encoding='utf-8', decoding=None, + nonce=None, timestamp=None): + """Create an OAuth 1 client. + + :param client_key: Client key (consumer key), mandatory. + :param resource_owner_key: Resource owner key (oauth token). + :param resource_owner_secret: Resource owner secret (oauth token secret). + :param callback_uri: Callback used when obtaining request token. + :param signature_method: SIGNATURE_HMAC, SIGNATURE_RSA or SIGNATURE_PLAINTEXT. + :param signature_type: SIGNATURE_TYPE_AUTH_HEADER (default), + SIGNATURE_TYPE_QUERY or SIGNATURE_TYPE_BODY + depending on where you want to embed the oauth + credentials. + :param rsa_key: RSA key used with SIGNATURE_RSA. + :param verifier: Verifier used when obtaining an access token. + :param realm: Realm (scope) to which access is being requested. + :param encoding: If you provide non-unicode input you may use this + to have oauthlib automatically convert. + :param decoding: If you wish that the returned uri, headers and body + from sign be encoded back from unicode, then set + decoding to your preferred encoding, i.e. utf-8. + :param nonce: Use this nonce instead of generating one. (Mainly for testing) + :param timestamp: Use this timestamp instead of using current. (Mainly for testing) + """ + # Convert to unicode using encoding if given, else assume unicode + encode = lambda x: to_unicode(x, encoding) if encoding else x + + self.client_key = encode(client_key) + self.client_secret = encode(client_secret) + self.resource_owner_key = encode(resource_owner_key) + self.resource_owner_secret = encode(resource_owner_secret) + self.signature_method = encode(signature_method) + self.signature_type = encode(signature_type) + self.callback_uri = encode(callback_uri) + self.rsa_key = encode(rsa_key) + self.verifier = encode(verifier) + self.realm = encode(realm) + self.encoding = encode(encoding) + self.decoding = encode(decoding) + self.nonce = encode(nonce) + self.timestamp = encode(timestamp) + + if self.signature_method == SIGNATURE_RSA and self.rsa_key is None: + raise ValueError( + 'rsa_key is required when using RSA signature method.') + + def __repr__(self): + attrs = vars(self).copy() + attrs['client_secret'] = '****' if attrs['client_secret'] else None + attrs[ + 'resource_owner_secret'] = '****' if attrs['resource_owner_secret'] else None + attribute_str = ', '.join('%s=%s' % (k, v) for k, v in attrs.items()) + return '<%s %s>' % (self.__class__.__name__, attribute_str) + + def get_oauth_signature(self, request): + """Get an OAuth signature to be used in signing a request + + To satisfy `section 3.4.1.2`_ item 2, if the request argument's + headers dict attribute contains a Host item, its value will + replace any netloc part of the request argument's uri attribute + value. + + .. _`section 3.4.1.2`: http://tools.ietf.org/html/rfc5849#section-3.4.1.2 + """ + if self.signature_method == SIGNATURE_PLAINTEXT: + # fast-path + return signature.sign_plaintext(self.client_secret, + self.resource_owner_secret) + + uri, headers, body = self._render(request) + + collected_params = signature.collect_parameters( + uri_query=urlparse.urlparse(uri).query, + body=body, + headers=headers) + log.debug("Collected params: {0}".format(collected_params)) + + normalized_params = signature.normalize_parameters(collected_params) + normalized_uri = signature.normalize_base_string_uri(uri, + headers.get('Host', None)) + log.debug("Normalized params: {0}".format(normalized_params)) + log.debug("Normalized URI: {0}".format(normalized_uri)) + + base_string = signature.construct_base_string(request.http_method, + normalized_uri, normalized_params) + + log.debug("Base signing string: {0}".format(base_string)) + + if self.signature_method not in self.SIGNATURE_METHODS: + raise ValueError('Invalid signature method.') + + sig = self.SIGNATURE_METHODS[self.signature_method](base_string, self) + + log.debug("Signature: {0}".format(sig)) + return sig + + def get_oauth_params(self, request): + """Get the basic OAuth parameters to be used in generating a signature. + """ + nonce = (generate_nonce() + if self.nonce is None else self.nonce) + timestamp = (generate_timestamp() + if self.timestamp is None else self.timestamp) + params = [ + ('oauth_nonce', nonce), + ('oauth_timestamp', timestamp), + ('oauth_version', '1.0'), + ('oauth_signature_method', self.signature_method), + ('oauth_consumer_key', self.client_key), + ] + if self.resource_owner_key: + params.append(('oauth_token', self.resource_owner_key)) + if self.callback_uri: + params.append(('oauth_callback', self.callback_uri)) + if self.verifier: + params.append(('oauth_verifier', self.verifier)) + + return params + + def _render(self, request, formencode=False, realm=None): + """Render a signed request according to signature type + + Returns a 3-tuple containing the request URI, headers, and body. + + If the formencode argument is True and the body contains parameters, it + is escaped and returned as a valid formencoded string. + """ + # TODO what if there are body params on a header-type auth? + # TODO what if there are query params on a body-type auth? + + uri, headers, body = request.uri, request.headers, request.body + + # TODO: right now these prepare_* methods are very narrow in scope--they + # only affect their little thing. In some cases (for example, with + # header auth) it might be advantageous to allow these methods to touch + # other parts of the request, like the headers—so the prepare_headers + # method could also set the Content-Type header to x-www-form-urlencoded + # like the spec requires. This would be a fundamental change though, and + # I'm not sure how I feel about it. + if self.signature_type == SIGNATURE_TYPE_AUTH_HEADER: + headers = parameters.prepare_headers( + request.oauth_params, request.headers, realm=realm) + elif self.signature_type == SIGNATURE_TYPE_BODY and request.decoded_body is not None: + body = parameters.prepare_form_encoded_body( + request.oauth_params, request.decoded_body) + if formencode: + body = urlencode(body) + headers['Content-Type'] = 'application/x-www-form-urlencoded' + elif self.signature_type == SIGNATURE_TYPE_QUERY: + uri = parameters.prepare_request_uri_query( + request.oauth_params, request.uri) + else: + raise ValueError('Unknown signature type specified.') + + return uri, headers, body + + def sign(self, uri, http_method='GET', body=None, headers=None, realm=None): + """Sign a request + + Signs an HTTP request with the specified parts. + + Returns a 3-tuple of the signed request's URI, headers, and body. + Note that http_method is not returned as it is unaffected by the OAuth + signing process. Also worth noting is that duplicate parameters + will be included in the signature, regardless of where they are + specified (query, body). + + The body argument may be a dict, a list of 2-tuples, or a formencoded + string. The Content-Type header must be 'application/x-www-form-urlencoded' + if it is present. + + If the body argument is not one of the above, it will be returned + verbatim as it is unaffected by the OAuth signing process. Attempting to + sign a request with non-formencoded data using the OAuth body signature + type is invalid and will raise an exception. + + If the body does contain parameters, it will be returned as a properly- + formatted formencoded string. + + Body may not be included if the http_method is either GET or HEAD as + this changes the semantic meaning of the request. + + All string data MUST be unicode or be encoded with the same encoding + scheme supplied to the Client constructor, default utf-8. This includes + strings inside body dicts, for example. + """ + # normalize request data + request = Request(uri, http_method, body, headers, + encoding=self.encoding) + + # sanity check + content_type = request.headers.get('Content-Type', None) + multipart = content_type and content_type.startswith('multipart/') + should_have_params = content_type == CONTENT_TYPE_FORM_URLENCODED + has_params = request.decoded_body is not None + # 3.4.1.3.1. Parameter Sources + # [Parameters are collected from the HTTP request entity-body, but only + # if [...]: + # * The entity-body is single-part. + if multipart and has_params: + raise ValueError( + "Headers indicate a multipart body but body contains parameters.") + # * The entity-body follows the encoding requirements of the + # "application/x-www-form-urlencoded" content-type as defined by + # [W3C.REC-html40-19980424]. + elif should_have_params and not has_params: + raise ValueError( + "Headers indicate a formencoded body but body was not decodable.") + # * The HTTP request entity-header includes the "Content-Type" + # header field set to "application/x-www-form-urlencoded". + elif not should_have_params and has_params: + raise ValueError( + "Body contains parameters but Content-Type header was {0} " + "instead of {1}".format(content_type or "not set", + CONTENT_TYPE_FORM_URLENCODED)) + + # 3.5.2. Form-Encoded Body + # Protocol parameters can be transmitted in the HTTP request entity- + # body, but only if the following REQUIRED conditions are met: + # o The entity-body is single-part. + # o The entity-body follows the encoding requirements of the + # "application/x-www-form-urlencoded" content-type as defined by + # [W3C.REC-html40-19980424]. + # o The HTTP request entity-header includes the "Content-Type" header + # field set to "application/x-www-form-urlencoded". + elif self.signature_type == SIGNATURE_TYPE_BODY and not ( + should_have_params and has_params and not multipart): + raise ValueError( + 'Body signatures may only be used with form-urlencoded content') + + # We amend http://tools.ietf.org/html/rfc5849#section-3.4.1.3.1 + # with the clause that parameters from body should only be included + # in non GET or HEAD requests. Extracting the request body parameters + # and including them in the signature base string would give semantic + # meaning to the body, which it should not have according to the + # HTTP 1.1 spec. + elif http_method.upper() in ('GET', 'HEAD') and has_params: + raise ValueError('GET/HEAD requests should not include body.') + + # generate the basic OAuth parameters + request.oauth_params = self.get_oauth_params(request) + + # generate the signature + request.oauth_params.append( + ('oauth_signature', self.get_oauth_signature(request))) + + # render the signed request and return it + uri, headers, body = self._render(request, formencode=True, + realm=(realm or self.realm)) + + if self.decoding: + log.debug('Encoding URI, headers and body to %s.', self.decoding) + uri = uri.encode(self.decoding) + body = body.encode(self.decoding) if body else body + new_headers = {} + for k, v in headers.items(): + new_headers[k.encode(self.decoding)] = v.encode(self.decoding) + headers = new_headers + return uri, headers, body diff --git a/oauthlib/oauth1/rfc5849/endpoints/__init__.py b/oauthlib/oauth1/rfc5849/endpoints/__init__.py new file mode 100644 index 0000000..b16ccba --- /dev/null +++ b/oauthlib/oauth1/rfc5849/endpoints/__init__.py @@ -0,0 +1,9 @@ +from __future__ import absolute_import + +from .base import BaseEndpoint +from .request_token import RequestTokenEndpoint +from .authorization import AuthorizationEndpoint +from .access_token import AccessTokenEndpoint +from .resource import ResourceEndpoint +from .signature_only import SignatureOnlyEndpoint +from .pre_configured import WebApplicationServer diff --git a/oauthlib/oauth1/rfc5849/endpoints/access_token.py b/oauthlib/oauth1/rfc5849/endpoints/access_token.py new file mode 100644 index 0000000..26db919 --- /dev/null +++ b/oauthlib/oauth1/rfc5849/endpoints/access_token.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth1.rfc5849.endpoints.access_token +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of the access token provider logic of +OAuth 1.0 RFC 5849. It validates the correctness of access token requests, +creates and persists tokens as well as create the proper response to be +returned to the client. +""" +from __future__ import absolute_import, unicode_literals + +import logging + +from oauthlib.common import urlencode + +from .base import BaseEndpoint +from .. import errors + +log = logging.getLogger(__name__) + + +class AccessTokenEndpoint(BaseEndpoint): + + """An endpoint responsible for providing OAuth 1 access tokens. + + Typical use is to instantiate with a request validator and invoke the + ``create_access_token_response`` from a view function. The tuple returned + has all information necessary (body, status, headers) to quickly form + and return a proper response. See :doc:`/oauth1/validator` for details on which + validator methods to implement for this endpoint. + """ + + def create_access_token(self, request, credentials): + """Create and save a new access token. + + Similar to OAuth 2, indication of granted scopes will be included as a + space separated list in ``oauth_authorized_realms``. + + :param request: An oauthlib.common.Request object. + :returns: The token as an urlencoded string. + """ + request.realms = self.request_validator.get_realms( + request.resource_owner_key, request) + token = { + 'oauth_token': self.token_generator(), + 'oauth_token_secret': self.token_generator(), + # Backport the authorized scopes indication used in OAuth2 + 'oauth_authorized_realms': ' '.join(request.realms) + } + token.update(credentials) + self.request_validator.save_access_token(token, request) + return urlencode(token.items()) + + def create_access_token_response(self, uri, http_method='GET', body=None, + headers=None, credentials=None): + """Create an access token response, with a new request token if valid. + + :param uri: The full URI of the token request. + :param http_method: A valid HTTP verb, i.e. GET, POST, PUT, HEAD, etc. + :param body: The request body as a string. + :param headers: The request headers as a dict. + :param credentials: A list of extra credentials to include in the token. + :returns: A tuple of 3 elements. + 1. A dict of headers to set on the response. + 2. The response body as a string. + 3. The response status code as an integer. + + An example of a valid request:: + + >>> from your_validator import your_validator + >>> from oauthlib.oauth1 import AccessTokenEndpoint + >>> endpoint = AccessTokenEndpoint(your_validator) + >>> h, b, s = endpoint.create_access_token_response( + ... 'https://your.provider/access_token?foo=bar', + ... headers={ + ... 'Authorization': 'OAuth oauth_token=234lsdkf....' + ... }, + ... credentials={ + ... 'my_specific': 'argument', + ... }) + >>> h + {'Content-Type': 'application/x-www-form-urlencoded'} + >>> b + 'oauth_token=lsdkfol23w54jlksdef&oauth_token_secret=qwe089234lkjsdf&oauth_authorized_realms=movies+pics&my_specific=argument' + >>> s + 200 + + An response to invalid request would have a different body and status:: + + >>> b + 'error=invalid_request&description=missing+resource+owner+key' + >>> s + 400 + + The same goes for an an unauthorized request: + + >>> b + '' + >>> s + 401 + """ + resp_headers = {'Content-Type': 'application/x-www-form-urlencoded'} + try: + request = self._create_request(uri, http_method, body, headers) + valid, processed_request = self.validate_access_token_request( + request) + if valid: + token = self.create_access_token(request, credentials or {}) + self.request_validator.invalidate_request_token( + request.client_key, + request.resource_owner_key, + request) + return resp_headers, token, 200 + else: + return {}, None, 401 + except errors.OAuth1Error as e: + return resp_headers, e.urlencoded, e.status_code + + def validate_access_token_request(self, request): + """Validate an access token request. + + :param request: An oauthlib.common.Request object. + :raises: OAuth1Error if the request is invalid. + :returns: A tuple of 2 elements. + 1. The validation result (True or False). + 2. The request object. + """ + self._check_transport_security(request) + self._check_mandatory_parameters(request) + + if not request.resource_owner_key: + raise errors.InvalidRequestError( + description='Missing resource owner.') + + if not self.request_validator.check_request_token( + request.resource_owner_key): + raise errors.InvalidRequestError( + description='Invalid resource owner key format.') + + if not request.verifier: + raise errors.InvalidRequestError( + description='Missing verifier.') + + if not self.request_validator.check_verifier(request.verifier): + raise errors.InvalidRequestError( + description='Invalid verifier format.') + + if not self.request_validator.validate_timestamp_and_nonce( + request.client_key, request.timestamp, request.nonce, request, + request_token=request.resource_owner_key): + return False, request + + # The server SHOULD return a 401 (Unauthorized) status code when + # receiving a request with invalid client credentials. + # Note: This is postponed in order to avoid timing attacks, instead + # a dummy client is assigned and used to maintain near constant + # time request verification. + # + # Note that early exit would enable client enumeration + valid_client = self.request_validator.validate_client_key( + request.client_key, request) + if not valid_client: + request.client_key = self.request_validator.dummy_client + + # The server SHOULD return a 401 (Unauthorized) status code when + # receiving a request with invalid or expired token. + # Note: This is postponed in order to avoid timing attacks, instead + # a dummy token is assigned and used to maintain near constant + # time request verification. + # + # Note that early exit would enable resource owner enumeration + valid_resource_owner = self.request_validator.validate_request_token( + request.client_key, request.resource_owner_key, request) + if not valid_resource_owner: + request.resource_owner_key = self.request_validator.dummy_request_token + + # The server MUST verify (Section 3.2) the validity of the request, + # ensure that the resource owner has authorized the provisioning of + # token credentials to the client, and ensure that the temporary + # credentials have not expired or been used before. The server MUST + # also verify the verification code received from the client. + # .. _`Section 3.2`: http://tools.ietf.org/html/rfc5849#section-3.2 + # + # Note that early exit would enable resource owner authorization + # verifier enumertion. + valid_verifier = self.request_validator.validate_verifier( + request.client_key, + request.resource_owner_key, + request.verifier, + request) + + valid_signature = self._check_signature(request, is_token_request=True) + + # We delay checking validity until the very end, using dummy values for + # calculations and fetching secrets/keys to ensure the flow of every + # request remains almost identical regardless of whether valid values + # have been supplied. This ensures near constant time execution and + # prevents malicious users from guessing sensitive information + v = all((valid_client, valid_resource_owner, valid_verifier, + valid_signature)) + if not v: + log.info("[Failure] request verification failed.") + log.info("Valid client:, %s", valid_client) + log.info("Valid token:, %s", valid_resource_owner) + log.info("Valid verifier:, %s", valid_verifier) + log.info("Valid signature:, %s", valid_signature) + return v, request diff --git a/oauthlib/oauth1/rfc5849/endpoints/authorization.py b/oauthlib/oauth1/rfc5849/endpoints/authorization.py new file mode 100644 index 0000000..a93a517 --- /dev/null +++ b/oauthlib/oauth1/rfc5849/endpoints/authorization.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth1.rfc5849.endpoints.authorization +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for signing and checking OAuth 1.0 RFC 5849 requests. +""" +from __future__ import absolute_import, unicode_literals + +from oauthlib.common import Request, add_params_to_uri + +from .base import BaseEndpoint +from .. import errors +try: + from urllib import urlencode +except ImportError: + from urllib.parse import urlencode + + +class AuthorizationEndpoint(BaseEndpoint): + + """An endpoint responsible for letting authenticated users authorize access + to their protected resources to a client. + + Typical use would be to have two views, one for displaying the authorization + form and one to process said form on submission. + + The first view will want to utilize ``get_realms_and_credentials`` to fetch + requested realms and useful client credentials, such as name and + description, to be used when creating the authorization form. + + During form processing you can use ``create_authorization_response`` to + validate the request, create a verifier as well as prepare the final + redirection URI used to send the user back to the client. + + See :doc:`/oauth1/validator` for details on which validator methods to implement + for this endpoint. + """ + + def create_verifier(self, request, credentials): + """Create and save a new request token. + + :param request: An oauthlib.common.Request object. + :param credentials: A dict of extra token credentials. + :returns: The verifier as a dict. + """ + verifier = { + 'oauth_token': request.resource_owner_key, + 'oauth_verifier': self.token_generator(), + } + verifier.update(credentials) + self.request_validator.save_verifier( + request.resource_owner_key, verifier, request) + return verifier + + def create_authorization_response(self, uri, http_method='GET', body=None, + headers=None, realms=None, credentials=None): + """Create an authorization response, with a new request token if valid. + + :param uri: The full URI of the token request. + :param http_method: A valid HTTP verb, i.e. GET, POST, PUT, HEAD, etc. + :param body: The request body as a string. + :param headers: The request headers as a dict. + :param credentials: A list of credentials to include in the verifier. + :returns: A tuple of 3 elements. + 1. A dict of headers to set on the response. + 2. The response body as a string. + 3. The response status code as an integer. + + If the callback URI tied to the current token is "oob", a response with + a 200 status code will be returned. In this case, it may be desirable to + modify the response to better display the verifier to the client. + + An example of an authorization request:: + + >>> from your_validator import your_validator + >>> from oauthlib.oauth1 import AuthorizationEndpoint + >>> endpoint = AuthorizationEndpoint(your_validator) + >>> h, b, s = endpoint.create_authorization_response( + ... 'https://your.provider/authorize?oauth_token=...', + ... credentials={ + ... 'extra': 'argument', + ... }) + >>> h + {'Location': 'https://the.client/callback?oauth_verifier=...&extra=argument'} + >>> b + None + >>> s + 302 + + An example of a request with an "oob" callback:: + + >>> from your_validator import your_validator + >>> from oauthlib.oauth1 import AuthorizationEndpoint + >>> endpoint = AuthorizationEndpoint(your_validator) + >>> h, b, s = endpoint.create_authorization_response( + ... 'https://your.provider/authorize?foo=bar', + ... credentials={ + ... 'extra': 'argument', + ... }) + >>> h + {'Content-Type': 'application/x-www-form-urlencoded'} + >>> b + 'oauth_verifier=...&extra=argument' + >>> s + 200 + """ + request = self._create_request(uri, http_method=http_method, body=body, + headers=headers) + + if not request.resource_owner_key: + raise errors.InvalidRequestError( + 'Missing mandatory parameter oauth_token.') + if not self.request_validator.verify_request_token( + request.resource_owner_key, request): + raise errors.InvalidClientError() + + request.realms = realms + if (request.realms and not self.request_validator.verify_realms( + request.resource_owner_key, request.realms, request)): + raise errors.InvalidRequestError( + description=('User granted access to realms outside of ' + 'what the client may request.')) + + verifier = self.create_verifier(request, credentials or {}) + redirect_uri = self.request_validator.get_redirect_uri( + request.resource_owner_key, request) + if redirect_uri == 'oob': + response_headers = { + 'Content-Type': 'application/x-www-form-urlencoded'} + response_body = urlencode(verifier) + return response_headers, response_body, 200 + else: + populated_redirect = add_params_to_uri( + redirect_uri, verifier.items()) + return {'Location': populated_redirect}, None, 302 + + def get_realms_and_credentials(self, uri, http_method='GET', body=None, + headers=None): + """Fetch realms and credentials for the presented request token. + + :param uri: The full URI of the token request. + :param http_method: A valid HTTP verb, i.e. GET, POST, PUT, HEAD, etc. + :param body: The request body as a string. + :param headers: The request headers as a dict. + :returns: A tuple of 2 elements. + 1. A list of request realms. + 2. A dict of credentials which may be useful in creating the + authorization form. + """ + request = self._create_request(uri, http_method=http_method, body=body, + headers=headers) + + if not self.request_validator.verify_request_token( + request.resource_owner_key, request): + raise errors.InvalidClientError() + + realms = self.request_validator.get_realms( + request.resource_owner_key, request) + return realms, {'resource_owner_key': request.resource_owner_key} diff --git a/oauthlib/oauth1/rfc5849/endpoints/base.py b/oauthlib/oauth1/rfc5849/endpoints/base.py new file mode 100644 index 0000000..42006a1 --- /dev/null +++ b/oauthlib/oauth1/rfc5849/endpoints/base.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth1.rfc5849.endpoints.base +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for signing and checking OAuth 1.0 RFC 5849 requests. +""" +from __future__ import absolute_import, unicode_literals + +import time + +from oauthlib.common import Request, generate_token + +from .. import signature, utils, errors +from .. import CONTENT_TYPE_FORM_URLENCODED +from .. import SIGNATURE_HMAC, SIGNATURE_RSA +from .. import SIGNATURE_TYPE_AUTH_HEADER +from .. import SIGNATURE_TYPE_QUERY +from .. import SIGNATURE_TYPE_BODY + + +class BaseEndpoint(object): + + def __init__(self, request_validator, token_generator=None): + self.request_validator = request_validator + self.token_generator = token_generator or generate_token + + def _get_signature_type_and_params(self, request): + """Extracts parameters from query, headers and body. Signature type + is set to the source in which parameters were found. + """ + # Per RFC5849, only the Authorization header may contain the 'realm' + # optional parameter. + header_params = signature.collect_parameters(headers=request.headers, + exclude_oauth_signature=False, with_realm=True) + body_params = signature.collect_parameters(body=request.body, + exclude_oauth_signature=False) + query_params = signature.collect_parameters(uri_query=request.uri_query, + exclude_oauth_signature=False) + + params = [] + params.extend(header_params) + params.extend(body_params) + params.extend(query_params) + signature_types_with_oauth_params = list(filter(lambda s: s[2], ( + (SIGNATURE_TYPE_AUTH_HEADER, params, + utils.filter_oauth_params(header_params)), + (SIGNATURE_TYPE_BODY, params, + utils.filter_oauth_params(body_params)), + (SIGNATURE_TYPE_QUERY, params, + utils.filter_oauth_params(query_params)) + ))) + + if len(signature_types_with_oauth_params) > 1: + found_types = [s[0] for s in signature_types_with_oauth_params] + raise errors.InvalidRequestError( + description=('oauth_ params must come from only 1 signature' + 'type but were found in %s', + ', '.join(found_types))) + + try: + signature_type, params, oauth_params = signature_types_with_oauth_params[ + 0] + except IndexError: + raise errors.InvalidRequestError( + description='Missing mandatory OAuth parameters.') + + return signature_type, params, oauth_params + + def _create_request(self, uri, http_method, body, headers): + # Only include body data from x-www-form-urlencoded requests + headers = headers or {} + if ("Content-Type" in headers and + CONTENT_TYPE_FORM_URLENCODED in headers["Content-Type"]): + request = Request(uri, http_method, body, headers) + else: + request = Request(uri, http_method, '', headers) + + signature_type, params, oauth_params = ( + self._get_signature_type_and_params(request)) + + # The server SHOULD return a 400 (Bad Request) status code when + # receiving a request with duplicated protocol parameters. + if len(dict(oauth_params)) != len(oauth_params): + raise errors.InvalidRequestError( + description='Duplicate OAuth2 entries.') + + oauth_params = dict(oauth_params) + request.signature = oauth_params.get('oauth_signature') + request.client_key = oauth_params.get('oauth_consumer_key') + request.resource_owner_key = oauth_params.get('oauth_token') + request.nonce = oauth_params.get('oauth_nonce') + request.timestamp = oauth_params.get('oauth_timestamp') + request.redirect_uri = oauth_params.get('oauth_callback') + request.verifier = oauth_params.get('oauth_verifier') + request.signature_method = oauth_params.get('oauth_signature_method') + request.realm = dict(params).get('realm') + request.oauth_params = oauth_params + + # Parameters to Client depend on signature method which may vary + # for each request. Note that HMAC-SHA1 and PLAINTEXT share parameters + request.params = [(k, v) for k, v in params if k != "oauth_signature"] + + if 'realm' in request.headers.get('Authorization', ''): + request.params = [(k, v) + for k, v in request.params if k != "realm"] + + return request + + def _check_transport_security(self, request): + # TODO: move into oauthlib.common from oauth2.utils + if (self.request_validator.enforce_ssl and + not request.uri.lower().startswith("https://")): + raise errors.InsecureTransportError() + + def _check_mandatory_parameters(self, request): + # The server SHOULD return a 400 (Bad Request) status code when + # receiving a request with missing parameters. + if not all((request.signature, request.client_key, + request.nonce, request.timestamp, + request.signature_method)): + raise errors.InvalidRequestError( + description='Missing mandatory OAuth parameters.') + + # OAuth does not mandate a particular signature method, as each + # implementation can have its own unique requirements. Servers are + # free to implement and document their own custom methods. + # Recommending any particular method is beyond the scope of this + # specification. Implementers should review the Security + # Considerations section (`Section 4`_) before deciding on which + # method to support. + # .. _`Section 4`: http://tools.ietf.org/html/rfc5849#section-4 + if (not request.signature_method in + self.request_validator.allowed_signature_methods): + raise errors.InvalidSignatureMethodError( + description="Invalid signature, %s not in %r." % ( + request.signature_method, + self.request_validator.allowed_signature_methods)) + + # Servers receiving an authenticated request MUST validate it by: + # If the "oauth_version" parameter is present, ensuring its value is + # "1.0". + if ('oauth_version' in request.oauth_params and + request.oauth_params['oauth_version'] != '1.0'): + raise errors.InvalidRequestError( + description='Invalid OAuth version.') + + # The timestamp value MUST be a positive integer. Unless otherwise + # specified by the server's documentation, the timestamp is expressed + # in the number of seconds since January 1, 1970 00:00:00 GMT. + if len(request.timestamp) != 10: + raise errors.InvalidRequestError( + description='Invalid timestamp size') + + try: + ts = int(request.timestamp) + + except ValueError: + raise errors.InvalidRequestError( + description='Timestamp must be an integer.') + + else: + # To avoid the need to retain an infinite number of nonce values for + # future checks, servers MAY choose to restrict the time period after + # which a request with an old timestamp is rejected. + if abs(time.time() - ts) > self.request_validator.timestamp_lifetime: + raise errors.InvalidRequestError( + description=('Timestamp given is invalid, differ from ' + 'allowed by over %s seconds.' % ( + self.request_validator.timestamp_lifetime))) + + # Provider specific validation of parameters, used to enforce + # restrictions such as character set and length. + if not self.request_validator.check_client_key(request.client_key): + raise errors.InvalidRequestError( + description='Invalid client key format.') + + if not self.request_validator.check_nonce(request.nonce): + raise errors.InvalidRequestError( + description='Invalid nonce format.') + + def _check_signature(self, request, is_token_request=False): + # ---- RSA Signature verification ---- + if request.signature_method == SIGNATURE_RSA: + # The server verifies the signature per `[RFC3447] section 8.2.2`_ + # .. _`[RFC3447] section 8.2.2`: http://tools.ietf.org/html/rfc3447#section-8.2.1 + rsa_key = self.request_validator.get_rsa_key( + request.client_key, request) + valid_signature = signature.verify_rsa_sha1(request, rsa_key) + + # ---- HMAC or Plaintext Signature verification ---- + else: + # Servers receiving an authenticated request MUST validate it by: + # Recalculating the request signature independently as described in + # `Section 3.4`_ and comparing it to the value received from the + # client via the "oauth_signature" parameter. + # .. _`Section 3.4`: http://tools.ietf.org/html/rfc5849#section-3.4 + client_secret = self.request_validator.get_client_secret( + request.client_key, request) + resource_owner_secret = None + if request.resource_owner_key: + if is_token_request: + resource_owner_secret = self.request_validator.get_request_token_secret( + request.client_key, request.resource_owner_key, request) + else: + resource_owner_secret = self.request_validator.get_access_token_secret( + request.client_key, request.resource_owner_key, request) + + if request.signature_method == SIGNATURE_HMAC: + valid_signature = signature.verify_hmac_sha1(request, + client_secret, resource_owner_secret) + else: + valid_signature = signature.verify_plaintext(request, + client_secret, resource_owner_secret) + return valid_signature diff --git a/oauthlib/oauth1/rfc5849/endpoints/pre_configured.py b/oauthlib/oauth1/rfc5849/endpoints/pre_configured.py new file mode 100644 index 0000000..f0705a8 --- /dev/null +++ b/oauthlib/oauth1/rfc5849/endpoints/pre_configured.py @@ -0,0 +1,14 @@ +from __future__ import absolute_import, unicode_literals + +from . import RequestTokenEndpoint, AuthorizationEndpoint +from . import AccessTokenEndpoint, ResourceEndpoint + + +class WebApplicationServer(RequestTokenEndpoint, AuthorizationEndpoint, + AccessTokenEndpoint, ResourceEndpoint): + + def __init__(self, request_validator): + RequestTokenEndpoint.__init__(self, request_validator) + AuthorizationEndpoint.__init__(self, request_validator) + AccessTokenEndpoint.__init__(self, request_validator) + ResourceEndpoint.__init__(self, request_validator) diff --git a/oauthlib/oauth1/rfc5849/endpoints/request_token.py b/oauthlib/oauth1/rfc5849/endpoints/request_token.py new file mode 100644 index 0000000..e97c34b --- /dev/null +++ b/oauthlib/oauth1/rfc5849/endpoints/request_token.py @@ -0,0 +1,202 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth1.rfc5849.endpoints.request_token +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of the request token provider logic of +OAuth 1.0 RFC 5849. It validates the correctness of request token requests, +creates and persists tokens as well as create the proper response to be +returned to the client. +""" +from __future__ import absolute_import, unicode_literals + +import logging + +from oauthlib.common import urlencode + +from .base import BaseEndpoint +from .. import errors + +log = logging.getLogger(__name__) + + +class RequestTokenEndpoint(BaseEndpoint): + + """An endpoint responsible for providing OAuth 1 request tokens. + + Typical use is to instantiate with a request validator and invoke the + ``create_request_token_response`` from a view function. The tuple returned + has all information necessary (body, status, headers) to quickly form + and return a proper response. See :doc:`/oauth1/validator` for details on which + validator methods to implement for this endpoint. + """ + + def create_request_token(self, request, credentials): + """Create and save a new request token. + + :param request: An oauthlib.common.Request object. + :param credentials: A dict of extra token credentials. + :returns: The token as an urlencoded string. + """ + token = { + 'oauth_token': self.token_generator(), + 'oauth_token_secret': self.token_generator(), + 'oauth_callback_confirmed': 'true' + } + token.update(credentials) + self.request_validator.save_request_token(token, request) + return urlencode(token.items()) + + def create_request_token_response(self, uri, http_method='GET', body=None, + headers=None, credentials=None): + """Create a request token response, with a new request token if valid. + + :param uri: The full URI of the token request. + :param http_method: A valid HTTP verb, i.e. GET, POST, PUT, HEAD, etc. + :param body: The request body as a string. + :param headers: The request headers as a dict. + :param credentials: A list of extra credentials to include in the token. + :returns: A tuple of 3 elements. + 1. A dict of headers to set on the response. + 2. The response body as a string. + 3. The response status code as an integer. + + An example of a valid request:: + + >>> from your_validator import your_validator + >>> from oauthlib.oauth1 import RequestTokenEndpoint + >>> endpoint = RequestTokenEndpoint(your_validator) + >>> h, b, s = endpoint.create_request_token_response( + ... 'https://your.provider/request_token?foo=bar', + ... headers={ + ... 'Authorization': 'OAuth realm=movies user, oauth_....' + ... }, + ... credentials={ + ... 'my_specific': 'argument', + ... }) + >>> h + {'Content-Type': 'application/x-www-form-urlencoded'} + >>> b + 'oauth_token=lsdkfol23w54jlksdef&oauth_token_secret=qwe089234lkjsdf&oauth_callback_confirmed=true&my_specific=argument' + >>> s + 200 + + An response to invalid request would have a different body and status:: + + >>> b + 'error=invalid_request&description=missing+callback+uri' + >>> s + 400 + + The same goes for an an unauthorized request: + + >>> b + '' + >>> s + 401 + """ + resp_headers = {'Content-Type': 'application/x-www-form-urlencoded'} + try: + request = self._create_request(uri, http_method, body, headers) + valid, processed_request = self.validate_request_token_request( + request) + if valid: + token = self.create_request_token(request, credentials or {}) + return resp_headers, token, 200 + else: + return {}, None, 401 + except errors.OAuth1Error as e: + return resp_headers, e.urlencoded, e.status_code + + def validate_request_token_request(self, request): + """Validate a request token request. + + :param request: An oauthlib.common.Request object. + :raises: OAuth1Error if the request is invalid. + :returns: A tuple of 2 elements. + 1. The validation result (True or False). + 2. The request object. + """ + self._check_transport_security(request) + self._check_mandatory_parameters(request) + + if request.realm: + request.realms = request.realm.split(' ') + else: + request.realms = self.request_validator.get_default_realms( + request.client_key, request) + if not self.request_validator.check_realms(request.realms): + raise errors.InvalidRequestError( + description='Invalid realm %s. Allowed are %r.' % ( + request.realms, self.request_validator.realms)) + + if not request.redirect_uri: + raise errors.InvalidRequestError( + description='Missing callback URI.') + + if not self.request_validator.validate_timestamp_and_nonce( + request.client_key, request.timestamp, request.nonce, request, + request_token=request.resource_owner_key): + return False, request + + # The server SHOULD return a 401 (Unauthorized) status code when + # receiving a request with invalid client credentials. + # Note: This is postponed in order to avoid timing attacks, instead + # a dummy client is assigned and used to maintain near constant + # time request verification. + # + # Note that early exit would enable client enumeration + valid_client = self.request_validator.validate_client_key( + request.client_key, request) + if not valid_client: + request.client_key = self.request_validator.dummy_client + + # Note that `realm`_ is only used in authorization headers and how + # it should be interepreted is not included in the OAuth spec. + # However they could be seen as a scope or realm to which the + # client has access and as such every client should be checked + # to ensure it is authorized access to that scope or realm. + # .. _`realm`: http://tools.ietf.org/html/rfc2617#section-1.2 + # + # Note that early exit would enable client realm access enumeration. + # + # The require_realm indicates this is the first step in the OAuth + # workflow where a client requests access to a specific realm. + # This first step (obtaining request token) need not require a realm + # and can then be identified by checking the require_resource_owner + # flag and abscence of realm. + # + # Clients obtaining an access token will not supply a realm and it will + # not be checked. Instead the previously requested realm should be + # transferred from the request token to the access token. + # + # Access to protected resources will always validate the realm but note + # that the realm is now tied to the access token and not provided by + # the client. + valid_realm = self.request_validator.validate_requested_realms( + request.client_key, request.realms, request) + + # Callback is normally never required, except for requests for + # a Temporary Credential as described in `Section 2.1`_ + # .._`Section 2.1`: http://tools.ietf.org/html/rfc5849#section-2.1 + valid_redirect = self.request_validator.validate_redirect_uri( + request.client_key, request.redirect_uri, request) + if not request.redirect_uri: + raise NotImplementedError('Redirect URI must either be provided ' + 'or set to a default during validation.') + + valid_signature = self._check_signature(request) + + # We delay checking validity until the very end, using dummy values for + # calculations and fetching secrets/keys to ensure the flow of every + # request remains almost identical regardless of whether valid values + # have been supplied. This ensures near constant time execution and + # prevents malicious users from guessing sensitive information + v = all((valid_client, valid_realm, valid_redirect, valid_signature)) + if not v: + log.info("[Failure] request verification failed.") + log.info("Valid client: %s.", valid_client) + log.info("Valid realm: %s.", valid_realm) + log.info("Valid callback: %s.", valid_redirect) + log.info("Valid signature: %s.", valid_signature) + return v, request diff --git a/oauthlib/oauth1/rfc5849/endpoints/resource.py b/oauthlib/oauth1/rfc5849/endpoints/resource.py new file mode 100644 index 0000000..651a87c --- /dev/null +++ b/oauthlib/oauth1/rfc5849/endpoints/resource.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth1.rfc5849.endpoints.resource +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of the resource protection provider logic of +OAuth 1.0 RFC 5849. +""" +from __future__ import absolute_import, unicode_literals + +import logging + +from .base import BaseEndpoint +from .. import errors + +log = logging.getLogger(__name__) + + +class ResourceEndpoint(BaseEndpoint): + + """An endpoint responsible for protecting resources. + + Typical use is to instantiate with a request validator and invoke the + ``validate_protected_resource_request`` in a decorator around a view + function. If the request is valid, invoke and return the response of the + view. If invalid create and return an error response directly from the + decorator. + + See :doc:`/oauth1/validator` for details on which validator methods to implement + for this endpoint. + + An example decorator:: + + from functools import wraps + from your_validator import your_validator + from oauthlib.oauth1 import ResourceEndpoint + endpoint = ResourceEndpoint(your_validator) + + def require_oauth(realms=None): + def decorator(f): + @wraps(f) + def wrapper(request, *args, **kwargs): + v, r = provider.validate_protected_resource_request( + request.url, + http_method=request.method, + body=request.data, + headers=request.headers, + realms=realms or []) + if v: + return f(*args, **kwargs) + else: + return abort(403) + """ + + def validate_protected_resource_request(self, uri, http_method='GET', + body=None, headers=None, realms=None): + """Create a request token response, with a new request token if valid. + + :param uri: The full URI of the token request. + :param http_method: A valid HTTP verb, i.e. GET, POST, PUT, HEAD, etc. + :param body: The request body as a string. + :param headers: The request headers as a dict. + :param realms: A list of realms the resource is protected under. + This will be supplied to the ``validate_realms`` + method of the request validator. + :returns: A tuple of 2 elements. + 1. True if valid, False otherwise. + 2. An oauthlib.common.Request object. + """ + try: + request = self._create_request(uri, http_method, body, headers) + except errors.OAuth1Error: + return False, None + + try: + self._check_transport_security(request) + self._check_mandatory_parameters(request) + except errors.OAuth1Error: + return False, request + + if not request.resource_owner_key: + return False, request + + if not self.request_validator.check_access_token( + request.resource_owner_key): + return False, request + + if not self.request_validator.validate_timestamp_and_nonce( + request.client_key, request.timestamp, request.nonce, request, + access_token=request.resource_owner_key): + return False, request + + # The server SHOULD return a 401 (Unauthorized) status code when + # receiving a request with invalid client credentials. + # Note: This is postponed in order to avoid timing attacks, instead + # a dummy client is assigned and used to maintain near constant + # time request verification. + # + # Note that early exit would enable client enumeration + valid_client = self.request_validator.validate_client_key( + request.client_key, request) + if not valid_client: + request.client_key = self.request_validator.dummy_client + + # The server SHOULD return a 401 (Unauthorized) status code when + # receiving a request with invalid or expired token. + # Note: This is postponed in order to avoid timing attacks, instead + # a dummy token is assigned and used to maintain near constant + # time request verification. + # + # Note that early exit would enable resource owner enumeration + valid_resource_owner = self.request_validator.validate_access_token( + request.client_key, request.resource_owner_key, request) + if not valid_resource_owner: + request.resource_owner_key = self.request_validator.dummy_access_token + + # Note that `realm`_ is only used in authorization headers and how + # it should be interepreted is not included in the OAuth spec. + # However they could be seen as a scope or realm to which the + # client has access and as such every client should be checked + # to ensure it is authorized access to that scope or realm. + # .. _`realm`: http://tools.ietf.org/html/rfc2617#section-1.2 + # + # Note that early exit would enable client realm access enumeration. + # + # The require_realm indicates this is the first step in the OAuth + # workflow where a client requests access to a specific realm. + # This first step (obtaining request token) need not require a realm + # and can then be identified by checking the require_resource_owner + # flag and abscence of realm. + # + # Clients obtaining an access token will not supply a realm and it will + # not be checked. Instead the previously requested realm should be + # transferred from the request token to the access token. + # + # Access to protected resources will always validate the realm but note + # that the realm is now tied to the access token and not provided by + # the client. + valid_realm = self.request_validator.validate_realms(request.client_key, + request.resource_owner_key, request, uri=request.uri, + realms=realms) + + valid_signature = self._check_signature(request) + + # We delay checking validity until the very end, using dummy values for + # calculations and fetching secrets/keys to ensure the flow of every + # request remains almost identical regardless of whether valid values + # have been supplied. This ensures near constant time execution and + # prevents malicious users from guessing sensitive information + v = all((valid_client, valid_resource_owner, valid_realm, + valid_signature)) + if not v: + log.info("[Failure] request verification failed.") + log.info("Valid client: %s", valid_client) + log.info("Valid token: %s", valid_resource_owner) + log.info("Valid realm: %s", valid_realm) + log.info("Valid signature: %s", valid_signature) + return v, request diff --git a/oauthlib/oauth1/rfc5849/endpoints/signature_only.py b/oauthlib/oauth1/rfc5849/endpoints/signature_only.py new file mode 100644 index 0000000..2f8e7c9 --- /dev/null +++ b/oauthlib/oauth1/rfc5849/endpoints/signature_only.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth1.rfc5849.endpoints.signature_only +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of the signing logic of OAuth 1.0 RFC 5849. +""" + +from __future__ import absolute_import, unicode_literals + +import logging + +from .base import BaseEndpoint +from .. import errors + +log = logging.getLogger(__name__) + + +class SignatureOnlyEndpoint(BaseEndpoint): + + """An endpoint only responsible for verifying an oauth signature.""" + + def validate_request(self, uri, http_method='GET', + body=None, headers=None): + """Validate a signed OAuth request. + + :param uri: The full URI of the token request. + :param http_method: A valid HTTP verb, i.e. GET, POST, PUT, HEAD, etc. + :param body: The request body as a string. + :param headers: The request headers as a dict. + :returns: A tuple of 2 elements. + 1. True if valid, False otherwise. + 2. An oauthlib.common.Request object. + """ + try: + request = self._create_request(uri, http_method, body, headers) + except errors.OAuth1Error: + return False, None + + try: + self._check_transport_security(request) + self._check_mandatory_parameters(request) + except errors.OAuth1Error: + return False, request + + if not self.request_validator.validate_timestamp_and_nonce( + request.client_key, request.timestamp, request.nonce, request): + return False, request + + # The server SHOULD return a 401 (Unauthorized) status code when + # receiving a request with invalid client credentials. + # Note: This is postponed in order to avoid timing attacks, instead + # a dummy client is assigned and used to maintain near constant + # time request verification. + # + # Note that early exit would enable client enumeration + valid_client = self.request_validator.validate_client_key( + request.client_key, request) + if not valid_client: + request.client_key = self.request_validator.dummy_client + + valid_signature = self._check_signature(request) + + # We delay checking validity until the very end, using dummy values for + # calculations and fetching secrets/keys to ensure the flow of every + # request remains almost identical regardless of whether valid values + # have been supplied. This ensures near constant time execution and + # prevents malicious users from guessing sensitive information + v = all((valid_client, valid_signature)) + if not v: + log.info("[Failure] request verification failed.") + log.info("Valid client: %s", valid_client) + log.info("Valid signature: %s", valid_signature) + return v, request diff --git a/oauthlib/oauth1/rfc5849/errors.py b/oauthlib/oauth1/rfc5849/errors.py new file mode 100644 index 0000000..978035e --- /dev/null +++ b/oauthlib/oauth1/rfc5849/errors.py @@ -0,0 +1,79 @@ +# coding=utf-8 +""" +oauthlib.oauth1.rfc5849.errors +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Error used both by OAuth 1 clients and provicers to represent the spec +defined error responses for all four core grant types. +""" +from __future__ import unicode_literals + +from oauthlib.common import urlencode, add_params_to_uri + + +class OAuth1Error(Exception): + error = None + description = '' + + def __init__(self, description=None, uri=None, status_code=400, + request=None): + """ + description: A human-readable ASCII [USASCII] text providing + additional information, used to assist the client + developer in understanding the error that occurred. + Values for the "error_description" parameter MUST NOT + include characters outside the set + x20-21 / x23-5B / x5D-7E. + + uri: A URI identifying a human-readable web page with information + about the error, used to provide the client developer with + additional information about the error. Values for the + "error_uri" parameter MUST conform to the URI- Reference + syntax, and thus MUST NOT include characters outside the set + x21 / x23-5B / x5D-7E. + + state: A CSRF protection value received from the client. + + request: Oauthlib Request object + """ + self.description = description or self.description + message = '(%s) %s' % (self.error, self.description) + if request: + message += ' ' + repr(request) + super(OAuth1Error, self).__init__(message) + + self.uri = uri + self.status_code = status_code + + def in_uri(self, uri): + return add_params_to_uri(uri, self.twotuples) + + @property + def twotuples(self): + error = [('error', self.error)] + if self.description: + error.append(('error_description', self.description)) + if self.uri: + error.append(('error_uri', self.uri)) + return error + + @property + def urlencoded(self): + return urlencode(self.twotuples) + + +class InsecureTransportError(OAuth1Error): + error = 'insecure_transport_protocol' + description = 'Only HTTPS connections are permitted.' + + +class InvalidSignatureMethodError(OAuth1Error): + error = 'invalid_signature_method' + + +class InvalidRequestError(OAuth1Error): + error = 'invalid_request' + + +class InvalidClientError(OAuth1Error): + error = 'invalid_client' diff --git a/oauthlib/oauth1/rfc5849/parameters.py b/oauthlib/oauth1/rfc5849/parameters.py new file mode 100644 index 0000000..f0963ab --- /dev/null +++ b/oauthlib/oauth1/rfc5849/parameters.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.parameters +~~~~~~~~~~~~~~~~~~~ + +This module contains methods related to `section 3.5`_ of the OAuth 1.0a spec. + +.. _`section 3.5`: http://tools.ietf.org/html/rfc5849#section-3.5 +""" +from __future__ import absolute_import, unicode_literals + +try: + from urlparse import urlparse, urlunparse +except ImportError: + from urllib.parse import urlparse, urlunparse +from . import utils +from oauthlib.common import extract_params, urlencode + + +# TODO: do we need filter_params now that oauth_params are handled by Request? +# We can easily pass in just oauth protocol params. +@utils.filter_params +def prepare_headers(oauth_params, headers=None, realm=None): + """**Prepare the Authorization header.** + Per `section 3.5.1`_ of the spec. + + Protocol parameters can be transmitted using the HTTP "Authorization" + header field as defined by `RFC2617`_ with the auth-scheme name set to + "OAuth" (case insensitive). + + For example:: + + Authorization: OAuth realm="Example", + oauth_consumer_key="0685bd9184jfhq22", + oauth_token="ad180jjd733klru7", + oauth_signature_method="HMAC-SHA1", + oauth_signature="wOJIO9A2W5mFwDgiDvZbTSMK%2FPY%3D", + oauth_timestamp="137131200", + oauth_nonce="4572616e48616d6d65724c61686176", + oauth_version="1.0" + + + .. _`section 3.5.1`: http://tools.ietf.org/html/rfc5849#section-3.5.1 + .. _`RFC2617`: http://tools.ietf.org/html/rfc2617 + """ + headers = headers or {} + + # Protocol parameters SHALL be included in the "Authorization" header + # field as follows: + authorization_header_parameters_parts = [] + for oauth_parameter_name, value in oauth_params: + # 1. Parameter names and values are encoded per Parameter Encoding + # (`Section 3.6`_) + # + # .. _`Section 3.6`: http://tools.ietf.org/html/rfc5849#section-3.6 + escaped_name = utils.escape(oauth_parameter_name) + escaped_value = utils.escape(value) + + # 2. Each parameter's name is immediately followed by an "=" character + # (ASCII code 61), a """ character (ASCII code 34), the parameter + # value (MAY be empty), and another """ character (ASCII code 34). + part = '{0}="{1}"'.format(escaped_name, escaped_value) + + authorization_header_parameters_parts.append(part) + + # 3. Parameters are separated by a "," character (ASCII code 44) and + # OPTIONAL linear whitespace per `RFC2617`_. + # + # .. _`RFC2617`: http://tools.ietf.org/html/rfc2617 + authorization_header_parameters = ', '.join( + authorization_header_parameters_parts) + + # 4. The OPTIONAL "realm" parameter MAY be added and interpreted per + # `RFC2617 section 1.2`_. + # + # .. _`RFC2617 section 1.2`: http://tools.ietf.org/html/rfc2617#section-1.2 + if realm: + # NOTE: realm should *not* be escaped + authorization_header_parameters = ('realm="%s", ' % realm + + authorization_header_parameters) + + # the auth-scheme name set to "OAuth" (case insensitive). + authorization_header = 'OAuth %s' % authorization_header_parameters + + # contribute the Authorization header to the given headers + full_headers = {} + full_headers.update(headers) + full_headers['Authorization'] = authorization_header + return full_headers + + +def _append_params(oauth_params, params): + """Append OAuth params to an existing set of parameters. + + Both params and oauth_params is must be lists of 2-tuples. + + Per `section 3.5.2`_ and `3.5.3`_ of the spec. + + .. _`section 3.5.2`: http://tools.ietf.org/html/rfc5849#section-3.5.2 + .. _`3.5.3`: http://tools.ietf.org/html/rfc5849#section-3.5.3 + + """ + merged = list(params) + merged.extend(oauth_params) + # The request URI / entity-body MAY include other request-specific + # parameters, in which case, the protocol parameters SHOULD be appended + # following the request-specific parameters, properly separated by an "&" + # character (ASCII code 38) + merged.sort(key=lambda i: i[0].startswith('oauth_')) + return merged + + +def prepare_form_encoded_body(oauth_params, body): + """Prepare the Form-Encoded Body. + + Per `section 3.5.2`_ of the spec. + + .. _`section 3.5.2`: http://tools.ietf.org/html/rfc5849#section-3.5.2 + + """ + # append OAuth params to the existing body + return _append_params(oauth_params, body) + + +def prepare_request_uri_query(oauth_params, uri): + """Prepare the Request URI Query. + + Per `section 3.5.3`_ of the spec. + + .. _`section 3.5.3`: http://tools.ietf.org/html/rfc5849#section-3.5.3 + + """ + # append OAuth params to the existing set of query components + sch, net, path, par, query, fra = urlparse(uri) + query = urlencode( + _append_params(oauth_params, extract_params(query) or [])) + return urlunparse((sch, net, path, par, query, fra)) diff --git a/oauthlib/oauth1/rfc5849/request_validator.py b/oauthlib/oauth1/rfc5849/request_validator.py new file mode 100644 index 0000000..e722029 --- /dev/null +++ b/oauthlib/oauth1/rfc5849/request_validator.py @@ -0,0 +1,823 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth1.rfc5849 +~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for signing and checking OAuth 1.0 RFC 5849 requests. +""" +from __future__ import absolute_import, unicode_literals + +from . import SIGNATURE_METHODS, utils + + +class RequestValidator(object): + + """A validator/datastore interaction base class for OAuth 1 providers. + + OAuth providers should inherit from RequestValidator and implement the + methods and properties outlined below. Further details are provided in the + documentation for each method and property. + + Methods used to check the format of input parameters. Common tests include + length, character set, membership, range or pattern. These tests are + referred to as `whitelisting or blacklisting`_. Whitelisting is better + but blacklisting can be usefull to spot malicious activity. + The following have methods a default implementation: + + - check_client_key + - check_request_token + - check_access_token + - check_nonce + - check_verifier + - check_realms + + The methods above default to whitelist input parameters, checking that they + are alphanumerical and between a minimum and maximum length. Rather than + overloading the methods a few properties can be used to configure these + methods. + + * @safe_characters -> (character set) + * @client_key_length -> (min, max) + * @request_token_length -> (min, max) + * @access_token_length -> (min, max) + * @nonce_length -> (min, max) + * @verifier_length -> (min, max) + * @realms -> [list, of, realms] + + Methods used to validate/invalidate input parameters. These checks usually + hit either persistent or temporary storage such as databases or the + filesystem. See each methods documentation for detailed usage. + The following methods must be implemented: + + - validate_client_key + - validate_request_token + - validate_access_token + - validate_timestamp_and_nonce + - validate_redirect_uri + - validate_requested_realms + - validate_realms + - validate_verifier + - invalidate_request_token + + Methods used to retrieve sensitive information from storage. + The following methods must be implemented: + + - get_client_secret + - get_request_token_secret + - get_access_token_secret + - get_rsa_key + - get_realms + - get_default_realms + - get_redirect_uri + + Methods used to save credentials. + The following methods must be implemented: + + - save_request_token + - save_verifier + - save_access_token + + Methods used to verify input parameters. This methods are used during + authorizing request token by user (AuthorizationEndpoint), to check if + parameters are valid. During token authorization request is not signed, + thus 'validation' methods can not be used. The following methods must be + implemented: + + - verify_realms + - verify_request_token + + To prevent timing attacks it is necessary to not exit early even if the + client key or resource owner key is invalid. Instead dummy values should + be used during the remaining verification process. It is very important + that the dummy client and token are valid input parameters to the methods + get_client_secret, get_rsa_key and get_(access/request)_token_secret and + that the running time of those methods when given a dummy value remain + equivalent to the running time when given a valid client/resource owner. + The following properties must be implemented: + + * @dummy_client + * @dummy_request_token + * @dummy_access_token + + Example implementations have been provided, note that the database used is + a simple dictionary and serves only an illustrative purpose. Use whichever + database suits your project and how to access it is entirely up to you. + The methods are introduced in an order which should make understanding + their use more straightforward and as such it could be worth reading what + follows in chronological order. + + .. _`whitelisting or blacklisting`: http://www.schneier.com/blog/archives/2011/01/whitelisting_vs.html + """ + + def __init__(self): + pass + + @property + def allowed_signature_methods(self): + return SIGNATURE_METHODS + + @property + def safe_characters(self): + return set(utils.UNICODE_ASCII_CHARACTER_SET) + + @property + def client_key_length(self): + return 20, 30 + + @property + def request_token_length(self): + return 20, 30 + + @property + def access_token_length(self): + return 20, 30 + + @property + def timestamp_lifetime(self): + return 600 + + @property + def nonce_length(self): + return 20, 30 + + @property + def verifier_length(self): + return 20, 30 + + @property + def realms(self): + return [] + + @property + def enforce_ssl(self): + return True + + def check_client_key(self, client_key): + """Check that the client key only contains safe characters + and is no shorter than lower and no longer than upper. + """ + lower, upper = self.client_key_length + return (set(client_key) <= self.safe_characters and + lower <= len(client_key) <= upper) + + def check_request_token(self, request_token): + """Checks that the request token contains only safe characters + and is no shorter than lower and no longer than upper. + """ + lower, upper = self.request_token_length + return (set(request_token) <= self.safe_characters and + lower <= len(request_token) <= upper) + + def check_access_token(self, request_token): + """Checks that the token contains only safe characters + and is no shorter than lower and no longer than upper. + """ + lower, upper = self.access_token_length + return (set(request_token) <= self.safe_characters and + lower <= len(request_token) <= upper) + + def check_nonce(self, nonce): + """Checks that the nonce only contains only safe characters + and is no shorter than lower and no longer than upper. + """ + lower, upper = self.nonce_length + return (set(nonce) <= self.safe_characters and + lower <= len(nonce) <= upper) + + def check_verifier(self, verifier): + """Checks that the verifier contains only safe characters + and is no shorter than lower and no longer than upper. + """ + lower, upper = self.verifier_length + return (set(verifier) <= self.safe_characters and + lower <= len(verifier) <= upper) + + def check_realms(self, realms): + """Check that the realm is one of a set allowed realms.""" + return all((r in self.realms for r in realms)) + + @property + def dummy_client(self): + """Dummy client used when an invalid client key is supplied. + + :returns: The dummy client key string. + + The dummy client should be associated with either a client secret, + a rsa key or both depending on which signature methods are supported. + Providers should make sure that + + get_client_secret(dummy_client) + get_rsa_key(dummy_client) + + return a valid secret or key for the dummy client. + + This method is used by + + * AccessTokenEndpoint + * RequestTokenEndpoint + * ResourceEndpoint + * SignatureOnlyEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + @property + def dummy_request_token(self): + """Dummy request token used when an invalid token was supplied. + + :returns: The dummy request token string. + + The dummy request token should be associated with a request token + secret such that get_request_token_secret(.., dummy_request_token) + returns a valid secret. + + This method is used by + + * AccessTokenEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + @property + def dummy_access_token(self): + """Dummy access token used when an invalid token was supplied. + + :returns: The dummy access token string. + + The dummy access token should be associated with an access token + secret such that get_access_token_secret(.., dummy_access_token) + returns a valid secret. + + This method is used by + + * ResourceEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def get_client_secret(self, client_key, request): + """Retrieves the client secret associated with the client key. + + :param client_key: The client/consumer key. + :param request: An oauthlib.common.Request object. + :returns: The client secret as a string. + + This method must allow the use of a dummy client_key value. + Fetching the secret using the dummy key must take the same amount of + time as fetching a secret for a valid client:: + + # Unlikely to be near constant time as it uses two database + # lookups for a valid client, and only one for an invalid. + from your_datastore import ClientSecret + if ClientSecret.has(client_key): + return ClientSecret.get(client_key) + else: + return 'dummy' + + # Aim to mimic number of latency inducing operations no matter + # whether the client is valid or not. + from your_datastore import ClientSecret + return ClientSecret.get(client_key, 'dummy') + + Note that the returned key must be in plaintext. + + This method is used by + + * AccessTokenEndpoint + * RequestTokenEndpoint + * ResourceEndpoint + * SignatureOnlyEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def get_request_token_secret(self, client_key, token, request): + """Retrieves the shared secret associated with the request token. + + :param client_key: The client/consumer key. + :param token: The request token string. + :param request: An oauthlib.common.Request object. + :returns: The token secret as a string. + + This method must allow the use of a dummy values and the running time + must be roughly equivalent to that of the running time of valid values:: + + # Unlikely to be near constant time as it uses two database + # lookups for a valid client, and only one for an invalid. + from your_datastore import RequestTokenSecret + if RequestTokenSecret.has(client_key): + return RequestTokenSecret.get((client_key, request_token)) + else: + return 'dummy' + + # Aim to mimic number of latency inducing operations no matter + # whether the client is valid or not. + from your_datastore import RequestTokenSecret + return ClientSecret.get((client_key, request_token), 'dummy') + + Note that the returned key must be in plaintext. + + This method is used by + + * AccessTokenEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def get_access_token_secret(self, client_key, token, request): + """Retrieves the shared secret associated with the access token. + + :param client_key: The client/consumer key. + :param token: The access token string. + :param request: An oauthlib.common.Request object. + :returns: The token secret as a string. + + This method must allow the use of a dummy values and the running time + must be roughly equivalent to that of the running time of valid values:: + + # Unlikely to be near constant time as it uses two database + # lookups for a valid client, and only one for an invalid. + from your_datastore import AccessTokenSecret + if AccessTokenSecret.has(client_key): + return AccessTokenSecret.get((client_key, request_token)) + else: + return 'dummy' + + # Aim to mimic number of latency inducing operations no matter + # whether the client is valid or not. + from your_datastore import AccessTokenSecret + return ClientSecret.get((client_key, request_token), 'dummy') + + Note that the returned key must be in plaintext. + + This method is used by + + * ResourceEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def get_default_realms(self, client_key, request): + """Get the default realms for a client. + + :param client_key: The client/consumer key. + :param request: An oauthlib.common.Request object. + :returns: The list of default realms associated with the client. + + The list of default realms will be set during client registration and + is outside the scope of OAuthLib. + + This method is used by + + * RequestTokenEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def get_realms(self, token, request): + """Get realms associated with a request token. + + :param token: The request token string. + :param request: An oauthlib.common.Request object. + :returns: The list of realms associated with the request token. + + This method is used by + + * AuthorizationEndpoint + * AccessTokenEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def get_redirect_uri(self, token, request): + """Get the redirect URI associated with a request token. + + :param token: The request token string. + :param request: An oauthlib.common.Request object. + :returns: The redirect URI associated with the request token. + + It may be desirable to return a custom URI if the redirect is set to "oob". + In this case, the user will be redirected to the returned URI and at that + endpoint the verifier can be displayed. + + This method is used by + + * AuthorizationEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def get_rsa_key(self, client_key, request): + """Retrieves a previously stored client provided RSA key. + + :param client_key: The client/consumer key. + :param request: An oauthlib.common.Request object. + :returns: The rsa public key as a string. + + This method must allow the use of a dummy client_key value. Fetching + the rsa key using the dummy key must take the same amount of time + as fetching a key for a valid client. The dummy key must also be of + the same bit length as client keys. + + Note that the key must be returned in plaintext. + + This method is used by + + * AccessTokenEndpoint + * RequestTokenEndpoint + * ResourceEndpoint + * SignatureOnlyEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def invalidate_request_token(self, client_key, request_token, request): + """Invalidates a used request token. + + :param client_key: The client/consumer key. + :param request_token: The request token string. + :param request: An oauthlib.common.Request object. + :returns: The rsa public key as a string. + + Per `Section 2.3`__ of the spec: + + "The server MUST (...) ensure that the temporary + credentials have not expired or been used before." + + .. _`Section 2.3`: http://tools.ietf.org/html/rfc5849#section-2.3 + + This method should ensure that provided token won't validate anymore. + It can be simply removing RequestToken from storage or setting + specific flag that makes it invalid (note that such flag should be + also validated during request token validation). + + This method is used by + + * AccessTokenEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def validate_client_key(self, client_key, request): + """Validates that supplied client key is a registered and valid client. + + :param client_key: The client/consumer key. + :param request: An oauthlib.common.Request object. + :returns: True or False + + Note that if the dummy client is supplied it should validate in same + or nearly the same amount of time as a valid one. + + Ensure latency inducing tasks are mimiced even for dummy clients. + For example, use:: + + from your_datastore import Client + try: + return Client.exists(client_key, access_token) + except DoesNotExist: + return False + + Rather than:: + + from your_datastore import Client + if access_token == self.dummy_access_token: + return False + else: + return Client.exists(client_key, access_token) + + This method is used by + + * AccessTokenEndpoint + * RequestTokenEndpoint + * ResourceEndpoint + * SignatureOnlyEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def validate_request_token(self, client_key, token, request): + """Validates that supplied request token is registered and valid. + + :param client_key: The client/consumer key. + :param token: The request token string. + :param request: An oauthlib.common.Request object. + :returns: True or False + + Note that if the dummy request_token is supplied it should validate in + the same nearly the same amount of time as a valid one. + + Ensure latency inducing tasks are mimiced even for dummy clients. + For example, use:: + + from your_datastore import RequestToken + try: + return RequestToken.exists(client_key, access_token) + except DoesNotExist: + return False + + Rather than:: + + from your_datastore import RequestToken + if access_token == self.dummy_access_token: + return False + else: + return RequestToken.exists(client_key, access_token) + + This method is used by + + * AccessTokenEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def validate_access_token(self, client_key, token, request): + """Validates that supplied access token is registered and valid. + + :param client_key: The client/consumer key. + :param token: The access token string. + :param request: An oauthlib.common.Request object. + :returns: True or False + + Note that if the dummy access token is supplied it should validate in + the same or nearly the same amount of time as a valid one. + + Ensure latency inducing tasks are mimiced even for dummy clients. + For example, use:: + + from your_datastore import AccessToken + try: + return AccessToken.exists(client_key, access_token) + except DoesNotExist: + return False + + Rather than:: + + from your_datastore import AccessToken + if access_token == self.dummy_access_token: + return False + else: + return AccessToken.exists(client_key, access_token) + + This method is used by + + * ResourceEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def validate_timestamp_and_nonce(self, client_key, timestamp, nonce, + request, request_token=None, access_token=None): + """Validates that the nonce has not been used before. + + :param client_key: The client/consumer key. + :param timestamp: The ``oauth_timestamp`` parameter. + :param nonce: The ``oauth_nonce`` parameter. + :param request_token: Request token string, if any. + :param access_token: Access token string, if any. + :param request: An oauthlib.common.Request object. + :returns: True or False + + Per `Section 3.3`_ of the spec. + + "A nonce is a random string, uniquely generated by the client to allow + the server to verify that a request has never been made before and + helps prevent replay attacks when requests are made over a non-secure + channel. The nonce value MUST be unique across all requests with the + same timestamp, client credentials, and token combinations." + + .. _`Section 3.3`: http://tools.ietf.org/html/rfc5849#section-3.3 + + One of the first validation checks that will be made is for the validity + of the nonce and timestamp, which are associated with a client key and + possibly a token. If invalid then immediately fail the request + by returning False. If the nonce/timestamp pair has been used before and + you may just have detected a replay attack. Therefore it is an essential + part of OAuth security that you not allow nonce/timestamp reuse. + Note that this validation check is done before checking the validity of + the client and token.:: + + nonces_and_timestamps_database = [ + (u'foo', 1234567890, u'rannoMstrInghere', u'bar') + ] + + def validate_timestamp_and_nonce(self, client_key, timestamp, nonce, + request_token=None, access_token=None): + + return ((client_key, timestamp, nonce, request_token or access_token) + not in self.nonces_and_timestamps_database) + + This method is used by + + * AccessTokenEndpoint + * RequestTokenEndpoint + * ResourceEndpoint + * SignatureOnlyEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def validate_redirect_uri(self, client_key, redirect_uri, request): + """Validates the client supplied redirection URI. + + :param client_key: The client/consumer key. + :param redirect_uri: The URI the client which to redirect back to after + authorization is successful. + :param request: An oauthlib.common.Request object. + :returns: True or False + + It is highly recommended that OAuth providers require their clients + to register all redirection URIs prior to using them in requests and + register them as absolute URIs. See `CWE-601`_ for more information + about open redirection attacks. + + By requiring registration of all redirection URIs it should be + straightforward for the provider to verify whether the supplied + redirect_uri is valid or not. + + Alternatively per `Section 2.1`_ of the spec: + + "If the client is unable to receive callbacks or a callback URI has + been established via other means, the parameter value MUST be set to + "oob" (case sensitive), to indicate an out-of-band configuration." + + .. _`CWE-601`: http://cwe.mitre.org/top25/index.html#CWE-601 + .. _`Section 2.1`: https://tools.ietf.org/html/rfc5849#section-2.1 + + This method is used by + + * RequestTokenEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def validate_requested_realms(self, client_key, realms, request): + """Validates that the client may request access to the realm. + + :param client_key: The client/consumer key. + :param realms: The list of realms that client is requesting access to. + :param request: An oauthlib.common.Request object. + :returns: True or False + + This method is invoked when obtaining a request token and should + tie a realm to the request token and after user authorization + this realm restriction should transfer to the access token. + + This method is used by + + * RequestTokenEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def validate_realms(self, client_key, token, request, uri=None, + realms=None): + """Validates access to the request realm. + + :param client_key: The client/consumer key. + :param token: A request token string. + :param request: An oauthlib.common.Request object. + :param uri: The URI the realms is protecting. + :param realms: A list of realms that must have been granted to + the access token. + :returns: True or False + + How providers choose to use the realm parameter is outside the OAuth + specification but it is commonly used to restrict access to a subset + of protected resources such as "photos". + + realms is a convenience parameter which can be used to provide + a per view method pre-defined list of allowed realms. + + Can be as simple as:: + + from your_datastore import RequestToken + request_token = RequestToken.get(token, None) + + if not request_token: + return False + return set(request_token.realms).issuperset(set(realms)) + + This method is used by + + * ResourceEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def validate_verifier(self, client_key, token, verifier, request): + """Validates a verification code. + + :param client_key: The client/consumer key. + :param token: A request token string. + :param verifier: The authorization verifier string. + :param request: An oauthlib.common.Request object. + :returns: True or False + + OAuth providers issue a verification code to clients after the + resource owner authorizes access. This code is used by the client to + obtain token credentials and the provider must verify that the + verifier is valid and associated with the client as well as the + resource owner. + + Verifier validation should be done in near constant time + (to avoid verifier enumeration). To achieve this we need a + constant time string comparison which is provided by OAuthLib + in ``oauthlib.common.safe_string_equals``:: + + from your_datastore import Verifier + correct_verifier = Verifier.get(client_key, request_token) + from oauthlib.common import safe_string_equals + return safe_string_equals(verifier, correct_verifier) + + This method is used by + + * AccessTokenEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def verify_request_token(self, token, request): + """Verify that the given OAuth1 request token is valid. + + :param token: A request token string. + :param request: An oauthlib.common.Request object. + :returns: True or False + + This method is used only in AuthorizationEndpoint to check whether the + oauth_token given in the authorization URL is valid or not. + This request is not signed and thus similar ``validate_request_token`` + method can not be used. + + This method is used by + + * AuthorizationEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def verify_realms(self, token, realms, request): + """Verify authorized realms to see if they match those given to token. + + :param token: An access token string. + :param realms: A list of realms the client attempts to access. + :param request: An oauthlib.common.Request object. + :returns: True or False + + This prevents the list of authorized realms sent by the client during + the authorization step to be altered to include realms outside what + was bound with the request token. + + Can be as simple as:: + + valid_realms = self.get_realms(token) + return all((r in valid_realms for r in realms)) + + This method is used by + + * AuthorizationEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def save_access_token(self, token, request): + """Save an OAuth1 access token. + + :param token: A dict with token credentials. + :param request: An oauthlib.common.Request object. + + The token dictionary will at minimum include + + * ``oauth_token`` the access token string. + * ``oauth_token_secret`` the token specific secret used in signing. + * ``oauth_authorized_realms`` a space separated list of realms. + + Client key can be obtained from ``request.client_key``. + + The list of realms (not joined string) can be obtained from + ``request.realm``. + + This method is used by + + * AccessTokenEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def save_request_token(self, token, request): + """Save an OAuth1 request token. + + :param token: A dict with token credentials. + :param request: An oauthlib.common.Request object. + + The token dictionary will at minimum include + + * ``oauth_token`` the request token string. + * ``oauth_token_secret`` the token specific secret used in signing. + * ``oauth_callback_confirmed`` the string ``true``. + + Client key can be obtained from ``request.client_key``. + + This method is used by + + * RequestTokenEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") + + def save_verifier(self, token, verifier, request): + """Associate an authorization verifier with a request token. + + :param token: A request token string. + :param verifier A dictionary containing the oauth_verifier and + oauth_token + :param request: An oauthlib.common.Request object. + + We need to associate verifiers with tokens for validation during the + access token request. + + Note that unlike save_x_token token here is the ``oauth_token`` token + string from the request token saved previously. + + This method is used by + + * AuthorizationEndpoint + """ + raise NotImplementedError("Subclasses must implement this function.") diff --git a/oauthlib/oauth1/rfc5849/signature.py b/oauthlib/oauth1/rfc5849/signature.py new file mode 100644 index 0000000..f57d80a --- /dev/null +++ b/oauthlib/oauth1/rfc5849/signature.py @@ -0,0 +1,609 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth1.rfc5849.signature +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module represents a direct implementation of `section 3.4`_ of the spec. + +Terminology: + * Client: software interfacing with an OAuth API + * Server: the API provider + * Resource Owner: the user who is granting authorization to the client + +Steps for signing a request: + +1. Collect parameters from the uri query, auth header, & body +2. Normalize those parameters +3. Normalize the uri +4. Pass the normalized uri, normalized parameters, and http method to + construct the base string +5. Pass the base string and any keys needed to a signing function + +.. _`section 3.4`: http://tools.ietf.org/html/rfc5849#section-3.4 +""" +from __future__ import absolute_import, unicode_literals + +import binascii +import hashlib +import hmac +try: + import urlparse +except ImportError: + import urllib.parse as urlparse +from . import utils +from oauthlib.common import urldecode, extract_params, safe_string_equals +from oauthlib.common import bytes_type, unicode_type + + +def construct_base_string(http_method, base_string_uri, + normalized_encoded_request_parameters): + """**String Construction** + Per `section 3.4.1.1`_ of the spec. + + For example, the HTTP request:: + + POST /request?b5=%3D%253D&a3=a&c%40=&a2=r%20b HTTP/1.1 + Host: example.com + Content-Type: application/x-www-form-urlencoded + Authorization: OAuth realm="Example", + oauth_consumer_key="9djdj82h48djs9d2", + oauth_token="kkk9d7dh3k39sjv7", + oauth_signature_method="HMAC-SHA1", + oauth_timestamp="137131201", + oauth_nonce="7d8f3e4a", + oauth_signature="bYT5CMsGcbgUdFHObYMEfcx6bsw%3D" + + c2&a3=2+q + + is represented by the following signature base string (line breaks + are for display purposes only):: + + POST&http%3A%2F%2Fexample.com%2Frequest&a2%3Dr%2520b%26a3%3D2%2520q + %26a3%3Da%26b5%3D%253D%25253D%26c%2540%3D%26c2%3D%26oauth_consumer_ + key%3D9djdj82h48djs9d2%26oauth_nonce%3D7d8f3e4a%26oauth_signature_m + ethod%3DHMAC-SHA1%26oauth_timestamp%3D137131201%26oauth_token%3Dkkk + 9d7dh3k39sjv7 + + .. _`section 3.4.1.1`: http://tools.ietf.org/html/rfc5849#section-3.4.1.1 + """ + + # The signature base string is constructed by concatenating together, + # in order, the following HTTP request elements: + + # 1. The HTTP request method in uppercase. For example: "HEAD", + # "GET", "POST", etc. If the request uses a custom HTTP method, it + # MUST be encoded (`Section 3.6`_). + # + # .. _`Section 3.6`: http://tools.ietf.org/html/rfc5849#section-3.6 + base_string = utils.escape(http_method.upper()) + + # 2. An "&" character (ASCII code 38). + base_string += '&' + + # 3. The base string URI from `Section 3.4.1.2`_, after being encoded + # (`Section 3.6`_). + # + # .. _`Section 3.4.1.2`: http://tools.ietf.org/html/rfc5849#section-3.4.1.2 + # .. _`Section 3.4.6`: http://tools.ietf.org/html/rfc5849#section-3.4.6 + base_string += utils.escape(base_string_uri) + + # 4. An "&" character (ASCII code 38). + base_string += '&' + + # 5. The request parameters as normalized in `Section 3.4.1.3.2`_, after + # being encoded (`Section 3.6`). + # + # .. _`Section 3.4.1.3.2`: http://tools.ietf.org/html/rfc5849#section-3.4.1.3.2 + # .. _`Section 3.4.6`: http://tools.ietf.org/html/rfc5849#section-3.4.6 + base_string += utils.escape(normalized_encoded_request_parameters) + + return base_string + + +def normalize_base_string_uri(uri, host=None): + """**Base String URI** + Per `section 3.4.1.2`_ of the spec. + + For example, the HTTP request:: + + GET /r%20v/X?id=123 HTTP/1.1 + Host: EXAMPLE.COM:80 + + is represented by the base string URI: "http://example.com/r%20v/X". + + In another example, the HTTPS request:: + + GET /?q=1 HTTP/1.1 + Host: www.example.net:8080 + + is represented by the base string URI: "https://www.example.net:8080/". + + .. _`section 3.4.1.2`: http://tools.ietf.org/html/rfc5849#section-3.4.1.2 + + The host argument overrides the netloc part of the uri argument. + """ + if not isinstance(uri, unicode_type): + raise ValueError('uri must be a unicode object.') + + # FIXME: urlparse does not support unicode + scheme, netloc, path, params, query, fragment = urlparse.urlparse(uri) + + # The scheme, authority, and path of the request resource URI `RFC3986` + # are included by constructing an "http" or "https" URI representing + # the request resource (without the query or fragment) as follows: + # + # .. _`RFC3986`: http://tools.ietf.org/html/rfc3986 + + if not scheme or not netloc: + raise ValueError('uri must include a scheme and netloc') + + # Per `RFC 2616 section 5.1.2`_: + # + # Note that the absolute path cannot be empty; if none is present in + # the original URI, it MUST be given as "/" (the server root). + # + # .. _`RFC 2616 section 5.1.2`: http://tools.ietf.org/html/rfc2616#section-5.1.2 + if not path: + path = '/' + + # 1. The scheme and host MUST be in lowercase. + scheme = scheme.lower() + netloc = netloc.lower() + + # 2. The host and port values MUST match the content of the HTTP + # request "Host" header field. + if host is not None: + netloc = host.lower() + + # 3. The port MUST be included if it is not the default port for the + # scheme, and MUST be excluded if it is the default. Specifically, + # the port MUST be excluded when making an HTTP request `RFC2616`_ + # to port 80 or when making an HTTPS request `RFC2818`_ to port 443. + # All other non-default port numbers MUST be included. + # + # .. _`RFC2616`: http://tools.ietf.org/html/rfc2616 + # .. _`RFC2818`: http://tools.ietf.org/html/rfc2818 + default_ports = ( + ('http', '80'), + ('https', '443'), + ) + if ':' in netloc: + host, port = netloc.split(':', 1) + if (scheme, port) in default_ports: + netloc = host + + return urlparse.urlunparse((scheme, netloc, path, params, '', '')) + + +# ** Request Parameters ** +# +# Per `section 3.4.1.3`_ of the spec. +# +# In order to guarantee a consistent and reproducible representation of +# the request parameters, the parameters are collected and decoded to +# their original decoded form. They are then sorted and encoded in a +# particular manner that is often different from their original +# encoding scheme, and concatenated into a single string. +# +# .. _`section 3.4.1.3`: http://tools.ietf.org/html/rfc5849#section-3.4.1.3 + +def collect_parameters(uri_query='', body=[], headers=None, + exclude_oauth_signature=True, with_realm=False): + """**Parameter Sources** + + Parameters starting with `oauth_` will be unescaped. + + Body parameters must be supplied as a dict, a list of 2-tuples, or a + formencoded query string. + + Headers must be supplied as a dict. + + Per `section 3.4.1.3.1`_ of the spec. + + For example, the HTTP request:: + + POST /request?b5=%3D%253D&a3=a&c%40=&a2=r%20b HTTP/1.1 + Host: example.com + Content-Type: application/x-www-form-urlencoded + Authorization: OAuth realm="Example", + oauth_consumer_key="9djdj82h48djs9d2", + oauth_token="kkk9d7dh3k39sjv7", + oauth_signature_method="HMAC-SHA1", + oauth_timestamp="137131201", + oauth_nonce="7d8f3e4a", + oauth_signature="djosJKDKJSD8743243%2Fjdk33klY%3D" + + c2&a3=2+q + + contains the following (fully decoded) parameters used in the + signature base sting:: + + +------------------------+------------------+ + | Name | Value | + +------------------------+------------------+ + | b5 | =%3D | + | a3 | a | + | c@ | | + | a2 | r b | + | oauth_consumer_key | 9djdj82h48djs9d2 | + | oauth_token | kkk9d7dh3k39sjv7 | + | oauth_signature_method | HMAC-SHA1 | + | oauth_timestamp | 137131201 | + | oauth_nonce | 7d8f3e4a | + | c2 | | + | a3 | 2 q | + +------------------------+------------------+ + + Note that the value of "b5" is "=%3D" and not "==". Both "c@" and + "c2" have empty values. While the encoding rules specified in this + specification for the purpose of constructing the signature base + string exclude the use of a "+" character (ASCII code 43) to + represent an encoded space character (ASCII code 32), this practice + is widely used in "application/x-www-form-urlencoded" encoded values, + and MUST be properly decoded, as demonstrated by one of the "a3" + parameter instances (the "a3" parameter is used twice in this + request). + + .. _`section 3.4.1.3.1`: http://tools.ietf.org/html/rfc5849#section-3.4.1.3.1 + """ + headers = headers or {} + params = [] + + # The parameters from the following sources are collected into a single + # list of name/value pairs: + + # * The query component of the HTTP request URI as defined by + # `RFC3986, Section 3.4`_. The query component is parsed into a list + # of name/value pairs by treating it as an + # "application/x-www-form-urlencoded" string, separating the names + # and values and decoding them as defined by + # `W3C.REC-html40-19980424`_, Section 17.13.4. + # + # .. _`RFC3986, Section 3.4`: http://tools.ietf.org/html/rfc3986#section-3.4 + # .. _`W3C.REC-html40-19980424`: http://tools.ietf.org/html/rfc5849#ref-W3C.REC-html40-19980424 + if uri_query: + params.extend(urldecode(uri_query)) + + # * The OAuth HTTP "Authorization" header field (`Section 3.5.1`_) if + # present. The header's content is parsed into a list of name/value + # pairs excluding the "realm" parameter if present. The parameter + # values are decoded as defined by `Section 3.5.1`_. + # + # .. _`Section 3.5.1`: http://tools.ietf.org/html/rfc5849#section-3.5.1 + if headers: + headers_lower = dict((k.lower(), v) for k, v in headers.items()) + authorization_header = headers_lower.get('authorization') + if authorization_header is not None: + params.extend([i for i in utils.parse_authorization_header( + authorization_header) if with_realm or i[0] != 'realm']) + + # * The HTTP request entity-body, but only if all of the following + # conditions are met: + # * The entity-body is single-part. + # + # * The entity-body follows the encoding requirements of the + # "application/x-www-form-urlencoded" content-type as defined by + # `W3C.REC-html40-19980424`_. + + # * The HTTP request entity-header includes the "Content-Type" + # header field set to "application/x-www-form-urlencoded". + # + # .._`W3C.REC-html40-19980424`: http://tools.ietf.org/html/rfc5849#ref-W3C.REC-html40-19980424 + + # TODO: enforce header param inclusion conditions + bodyparams = extract_params(body) or [] + params.extend(bodyparams) + + # ensure all oauth params are unescaped + unescaped_params = [] + for k, v in params: + if k.startswith('oauth_'): + v = utils.unescape(v) + unescaped_params.append((k, v)) + + # The "oauth_signature" parameter MUST be excluded from the signature + # base string if present. + if exclude_oauth_signature: + unescaped_params = list(filter(lambda i: i[0] != 'oauth_signature', + unescaped_params)) + + return unescaped_params + + +def normalize_parameters(params): + """**Parameters Normalization** + Per `section 3.4.1.3.2`_ of the spec. + + For example, the list of parameters from the previous section would + be normalized as follows: + + Encoded:: + + +------------------------+------------------+ + | Name | Value | + +------------------------+------------------+ + | b5 | %3D%253D | + | a3 | a | + | c%40 | | + | a2 | r%20b | + | oauth_consumer_key | 9djdj82h48djs9d2 | + | oauth_token | kkk9d7dh3k39sjv7 | + | oauth_signature_method | HMAC-SHA1 | + | oauth_timestamp | 137131201 | + | oauth_nonce | 7d8f3e4a | + | c2 | | + | a3 | 2%20q | + +------------------------+------------------+ + + Sorted:: + + +------------------------+------------------+ + | Name | Value | + +------------------------+------------------+ + | a2 | r%20b | + | a3 | 2%20q | + | a3 | a | + | b5 | %3D%253D | + | c%40 | | + | c2 | | + | oauth_consumer_key | 9djdj82h48djs9d2 | + | oauth_nonce | 7d8f3e4a | + | oauth_signature_method | HMAC-SHA1 | + | oauth_timestamp | 137131201 | + | oauth_token | kkk9d7dh3k39sjv7 | + +------------------------+------------------+ + + Concatenated Pairs:: + + +-------------------------------------+ + | Name=Value | + +-------------------------------------+ + | a2=r%20b | + | a3=2%20q | + | a3=a | + | b5=%3D%253D | + | c%40= | + | c2= | + | oauth_consumer_key=9djdj82h48djs9d2 | + | oauth_nonce=7d8f3e4a | + | oauth_signature_method=HMAC-SHA1 | + | oauth_timestamp=137131201 | + | oauth_token=kkk9d7dh3k39sjv7 | + +-------------------------------------+ + + and concatenated together into a single string (line breaks are for + display purposes only):: + + a2=r%20b&a3=2%20q&a3=a&b5=%3D%253D&c%40=&c2=&oauth_consumer_key=9dj + dj82h48djs9d2&oauth_nonce=7d8f3e4a&oauth_signature_method=HMAC-SHA1 + &oauth_timestamp=137131201&oauth_token=kkk9d7dh3k39sjv7 + + .. _`section 3.4.1.3.2`: http://tools.ietf.org/html/rfc5849#section-3.4.1.3.2 + """ + + # The parameters collected in `Section 3.4.1.3`_ are normalized into a + # single string as follows: + # + # .. _`Section 3.4.1.3`: http://tools.ietf.org/html/rfc5849#section-3.4.1.3 + + # 1. First, the name and value of each parameter are encoded + # (`Section 3.6`_). + # + # .. _`Section 3.6`: http://tools.ietf.org/html/rfc5849#section-3.6 + key_values = [(utils.escape(k), utils.escape(v)) for k, v in params] + + # 2. The parameters are sorted by name, using ascending byte value + # ordering. If two or more parameters share the same name, they + # are sorted by their value. + key_values.sort() + + # 3. The name of each parameter is concatenated to its corresponding + # value using an "=" character (ASCII code 61) as a separator, even + # if the value is empty. + parameter_parts = ['{0}={1}'.format(k, v) for k, v in key_values] + + # 4. The sorted name/value pairs are concatenated together into a + # single string by using an "&" character (ASCII code 38) as + # separator. + return '&'.join(parameter_parts) + + +def sign_hmac_sha1_with_client(base_string, client): + return sign_hmac_sha1(base_string, + client.client_secret, + client.resource_owner_secret + ) + + +def sign_hmac_sha1(base_string, client_secret, resource_owner_secret): + """**HMAC-SHA1** + + The "HMAC-SHA1" signature method uses the HMAC-SHA1 signature + algorithm as defined in `RFC2104`_:: + + digest = HMAC-SHA1 (key, text) + + Per `section 3.4.2`_ of the spec. + + .. _`RFC2104`: http://tools.ietf.org/html/rfc2104 + .. _`section 3.4.2`: http://tools.ietf.org/html/rfc5849#section-3.4.2 + """ + + # The HMAC-SHA1 function variables are used in following way: + + # text is set to the value of the signature base string from + # `Section 3.4.1.1`_. + # + # .. _`Section 3.4.1.1`: http://tools.ietf.org/html/rfc5849#section-3.4.1.1 + text = base_string + + # key is set to the concatenated values of: + # 1. The client shared-secret, after being encoded (`Section 3.6`_). + # + # .. _`Section 3.6`: http://tools.ietf.org/html/rfc5849#section-3.6 + key = utils.escape(client_secret or '') + + # 2. An "&" character (ASCII code 38), which MUST be included + # even when either secret is empty. + key += '&' + + # 3. The token shared-secret, after being encoded (`Section 3.6`_). + # + # .. _`Section 3.6`: http://tools.ietf.org/html/rfc5849#section-3.6 + key += utils.escape(resource_owner_secret or '') + + # FIXME: HMAC does not support unicode! + key_utf8 = key.encode('utf-8') + text_utf8 = text.encode('utf-8') + signature = hmac.new(key_utf8, text_utf8, hashlib.sha1) + + # digest is used to set the value of the "oauth_signature" protocol + # parameter, after the result octet string is base64-encoded + # per `RFC2045, Section 6.8`. + # + # .. _`RFC2045, Section 6.8`: http://tools.ietf.org/html/rfc2045#section-6.8 + return binascii.b2a_base64(signature.digest())[:-1].decode('utf-8') + +_jwtrs1 = None + +#jwt has some nice pycrypto/cryptography abstractions +def _jwt_rs1_signing_algorithm(): + global _jwtrs1 + if _jwtrs1 is None: + import jwt.algorithms as jwtalgo + _jwtrs1 = jwtalgo.RSAAlgorithm(jwtalgo.hashes.SHA1) + return _jwtrs1 + +def sign_rsa_sha1(base_string, rsa_private_key): + """**RSA-SHA1** + + Per `section 3.4.3`_ of the spec. + + The "RSA-SHA1" signature method uses the RSASSA-PKCS1-v1_5 signature + algorithm as defined in `RFC3447, Section 8.2`_ (also known as + PKCS#1), using SHA-1 as the hash function for EMSA-PKCS1-v1_5. To + use this method, the client MUST have established client credentials + with the server that included its RSA public key (in a manner that is + beyond the scope of this specification). + + .. _`section 3.4.3`: http://tools.ietf.org/html/rfc5849#section-3.4.3 + .. _`RFC3447, Section 8.2`: http://tools.ietf.org/html/rfc3447#section-8.2 + + """ + if isinstance(base_string, unicode_type): + base_string = base_string.encode('utf-8') + # TODO: finish RSA documentation + alg = _jwt_rs1_signing_algorithm() + key = _prepare_key_plus(alg, rsa_private_key) + s=alg.sign(base_string, key) + return binascii.b2a_base64(s)[:-1].decode('utf-8') + + +def sign_rsa_sha1_with_client(base_string, client): + return sign_rsa_sha1(base_string, client.rsa_key) + + +def sign_plaintext(client_secret, resource_owner_secret): + """Sign a request using plaintext. + + Per `section 3.4.4`_ of the spec. + + The "PLAINTEXT" method does not employ a signature algorithm. It + MUST be used with a transport-layer mechanism such as TLS or SSL (or + sent over a secure channel with equivalent protections). It does not + utilize the signature base string or the "oauth_timestamp" and + "oauth_nonce" parameters. + + .. _`section 3.4.4`: http://tools.ietf.org/html/rfc5849#section-3.4.4 + + """ + + # The "oauth_signature" protocol parameter is set to the concatenated + # value of: + + # 1. The client shared-secret, after being encoded (`Section 3.6`_). + # + # .. _`Section 3.6`: http://tools.ietf.org/html/rfc5849#section-3.6 + signature = utils.escape(client_secret or '') + + # 2. An "&" character (ASCII code 38), which MUST be included even + # when either secret is empty. + signature += '&' + + # 3. The token shared-secret, after being encoded (`Section 3.6`_). + # + # .. _`Section 3.6`: http://tools.ietf.org/html/rfc5849#section-3.6 + signature += utils.escape(resource_owner_secret or '') + + return signature + + +def sign_plaintext_with_client(base_string, client): + return sign_plaintext(client.client_secret, client.resource_owner_secret) + + +def verify_hmac_sha1(request, client_secret=None, + resource_owner_secret=None): + """Verify a HMAC-SHA1 signature. + + Per `section 3.4`_ of the spec. + + .. _`section 3.4`: http://tools.ietf.org/html/rfc5849#section-3.4 + + To satisfy `RFC2616 section 5.2`_ item 1, the request argument's uri + attribute MUST be an absolute URI whose netloc part identifies the + origin server or gateway on which the resource resides. Any Host + item of the request argument's headers dict attribute will be + ignored. + + .. _`RFC2616 section 5.2`: http://tools.ietf.org/html/rfc2616#section-5.2 + + """ + norm_params = normalize_parameters(request.params) + uri = normalize_base_string_uri(request.uri) + base_string = construct_base_string(request.http_method, uri, norm_params) + signature = sign_hmac_sha1(base_string, client_secret, + resource_owner_secret) + return safe_string_equals(signature, request.signature) + +def _prepare_key_plus(alg, keystr): + if isinstance(keystr, bytes_type): + keystr = keystr.decode('utf-8') + return alg.prepare_key(keystr) + +def verify_rsa_sha1(request, rsa_public_key): + """Verify a RSASSA-PKCS #1 v1.5 base64 encoded signature. + + Per `section 3.4.3`_ of the spec. + + Note this method requires the jwt and cryptography libraries. + + .. _`section 3.4.3`: http://tools.ietf.org/html/rfc5849#section-3.4.3 + + To satisfy `RFC2616 section 5.2`_ item 1, the request argument's uri + attribute MUST be an absolute URI whose netloc part identifies the + origin server or gateway on which the resource resides. Any Host + item of the request argument's headers dict attribute will be + ignored. + + .. _`RFC2616 section 5.2`: http://tools.ietf.org/html/rfc2616#section-5.2 + """ + norm_params = normalize_parameters(request.params) + uri = normalize_base_string_uri(request.uri) + message = construct_base_string(request.http_method, uri, norm_params).encode('utf-8') + sig = binascii.a2b_base64(request.signature.encode('utf-8')) + + alg = _jwt_rs1_signing_algorithm() + key = _prepare_key_plus(alg, rsa_public_key) + return alg.verify(message, key, sig) + + +def verify_plaintext(request, client_secret=None, resource_owner_secret=None): + """Verify a PLAINTEXT signature. + + Per `section 3.4`_ of the spec. + + .. _`section 3.4`: http://tools.ietf.org/html/rfc5849#section-3.4 + """ + signature = sign_plaintext(client_secret, resource_owner_secret) + return safe_string_equals(signature, request.signature) diff --git a/oauthlib/oauth1/rfc5849/utils.py b/oauthlib/oauth1/rfc5849/utils.py new file mode 100644 index 0000000..eafb303 --- /dev/null +++ b/oauthlib/oauth1/rfc5849/utils.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.utils +~~~~~~~~~~~~~~ + +This module contains utility methods used by various parts of the OAuth +spec. +""" +from __future__ import absolute_import, unicode_literals + +try: + import urllib2 +except ImportError: + import urllib.request as urllib2 + +from oauthlib.common import quote, unquote, bytes_type, unicode_type + +UNICODE_ASCII_CHARACTER_SET = ('abcdefghijklmnopqrstuvwxyz' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + '0123456789') + + +def filter_params(target): + """Decorator which filters params to remove non-oauth_* parameters + + Assumes the decorated method takes a params dict or list of tuples as its + first argument. + """ + def wrapper(params, *args, **kwargs): + params = filter_oauth_params(params) + return target(params, *args, **kwargs) + + wrapper.__doc__ = target.__doc__ + return wrapper + + +def filter_oauth_params(params): + """Removes all non oauth parameters from a dict or a list of params.""" + is_oauth = lambda kv: kv[0].startswith("oauth_") + if isinstance(params, dict): + return list(filter(is_oauth, list(params.items()))) + else: + return list(filter(is_oauth, params)) + + +def escape(u): + """Escape a unicode string in an OAuth-compatible fashion. + + Per `section 3.6`_ of the spec. + + .. _`section 3.6`: http://tools.ietf.org/html/rfc5849#section-3.6 + + """ + if not isinstance(u, unicode_type): + raise ValueError('Only unicode objects are escapable. ' + + 'Got %s of type %s.' % (u, type(u))) + # Letters, digits, and the characters '_.-' are already treated as safe + # by urllib.quote(). We need to add '~' to fully support rfc5849. + return quote(u, safe=b'~') + + +def unescape(u): + if not isinstance(u, unicode_type): + raise ValueError('Only unicode objects are unescapable.') + return unquote(u) + + +def parse_keqv_list(l): + """A unicode-safe version of urllib2.parse_keqv_list""" + # With Python 2.6, parse_http_list handles unicode fine + return urllib2.parse_keqv_list(l) + + +def parse_http_list(u): + """A unicode-safe version of urllib2.parse_http_list""" + # With Python 2.6, parse_http_list handles unicode fine + return urllib2.parse_http_list(u) + + +def parse_authorization_header(authorization_header): + """Parse an OAuth authorization header into a list of 2-tuples""" + auth_scheme = 'OAuth '.lower() + if authorization_header[:len(auth_scheme)].lower().startswith(auth_scheme): + items = parse_http_list(authorization_header[len(auth_scheme):]) + try: + return list(parse_keqv_list(items).items()) + except (IndexError, ValueError): + pass + raise ValueError('Malformed authorization header') diff --git a/oauthlib/oauth2/__init__.py b/oauthlib/oauth2/__init__.py new file mode 100644 index 0000000..a13e484 --- /dev/null +++ b/oauthlib/oauth2/__init__.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2 +~~~~~~~~~~~~~~ + +This module is a wrapper for the most recent implementation of OAuth 2.0 Client +and Server classes. +""" +from __future__ import absolute_import, unicode_literals + +from .rfc6749.clients import Client +from .rfc6749.clients import WebApplicationClient +from .rfc6749.clients import MobileApplicationClient +from .rfc6749.clients import LegacyApplicationClient +from .rfc6749.clients import BackendApplicationClient +from .rfc6749.clients import ServiceApplicationClient +from .rfc6749.endpoints import AuthorizationEndpoint +from .rfc6749.endpoints import TokenEndpoint +from .rfc6749.endpoints import ResourceEndpoint +from .rfc6749.endpoints import RevocationEndpoint +from .rfc6749.endpoints import Server +from .rfc6749.endpoints import WebApplicationServer +from .rfc6749.endpoints import MobileApplicationServer +from .rfc6749.endpoints import LegacyApplicationServer +from .rfc6749.endpoints import BackendApplicationServer +from .rfc6749.errors import * +from .rfc6749.grant_types import AuthorizationCodeGrant +from .rfc6749.grant_types import ImplicitGrant +from .rfc6749.grant_types import ResourceOwnerPasswordCredentialsGrant +from .rfc6749.grant_types import ClientCredentialsGrant +from .rfc6749.grant_types import RefreshTokenGrant +from .rfc6749.request_validator import RequestValidator +from .rfc6749.tokens import BearerToken, OAuth2Token +from .rfc6749.utils import is_secure_transport diff --git a/oauthlib/oauth2/rfc6749/__init__.py b/oauthlib/oauth2/rfc6749/__init__.py new file mode 100644 index 0000000..aff0ed8 --- /dev/null +++ b/oauthlib/oauth2/rfc6749/__init__.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +from __future__ import absolute_import, unicode_literals + +import functools +import logging + +from .errors import TemporarilyUnavailableError, ServerError +from .errors import FatalClientError, OAuth2Error + + +log = logging.getLogger(__name__) + + +class BaseEndpoint(object): + + def __init__(self): + self._available = True + self._catch_errors = False + + @property + def available(self): + return self._available + + @available.setter + def available(self, available): + self._available = available + + @property + def catch_errors(self): + return self._catch_errors + + @catch_errors.setter + def catch_errors(self, catch_errors): + self._catch_errors = catch_errors + + +def catch_errors_and_unavailability(f): + @functools.wraps(f) + def wrapper(endpoint, uri, *args, **kwargs): + if not endpoint.available: + e = TemporarilyUnavailableError() + log.info('Endpoint unavailable, ignoring request %s.' % uri) + return {}, e.json, 503 + + if endpoint.catch_errors: + try: + return f(endpoint, uri, *args, **kwargs) + except OAuth2Error: + raise + except FatalClientError: + raise + except Exception as e: + error = ServerError() + log.warning( + 'Exception caught while processing request, %s.' % e) + return {}, error.json, 500 + else: + return f(endpoint, uri, *args, **kwargs) + return wrapper diff --git a/oauthlib/oauth2/rfc6749/clients/__init__.py b/oauthlib/oauth2/rfc6749/clients/__init__.py new file mode 100644 index 0000000..65bea50 --- /dev/null +++ b/oauthlib/oauth2/rfc6749/clients/__init__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming OAuth 2.0 RFC6749. +""" +from __future__ import absolute_import, unicode_literals + +from .base import * +from .web_application import WebApplicationClient +from .mobile_application import MobileApplicationClient +from .legacy_application import LegacyApplicationClient +from .backend_application import BackendApplicationClient +from .service_application import ServiceApplicationClient diff --git a/oauthlib/oauth2/rfc6749/clients/backend_application.py b/oauthlib/oauth2/rfc6749/clients/backend_application.py new file mode 100644 index 0000000..445bdd5 --- /dev/null +++ b/oauthlib/oauth2/rfc6749/clients/backend_application.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +from __future__ import absolute_import, unicode_literals + +from .base import Client +from ..parameters import prepare_token_request +from ..parameters import parse_token_response + + +class BackendApplicationClient(Client): + + """A public client utilizing the client credentials grant workflow. + + The client can request an access token using only its client + credentials (or other supported means of authentication) when the + client is requesting access to the protected resources under its + control, or those of another resource owner which has been previously + arranged with the authorization server (the method of which is beyond + the scope of this specification). + + The client credentials grant type MUST only be used by confidential + clients. + + Since the client authentication is used as the authorization grant, + no additional authorization request is needed. + """ + + def prepare_request_body(self, body='', scope=None, **kwargs): + """Add the client credentials to the request body. + + The client makes a request to the token endpoint by adding the + following parameters using the "application/x-www-form-urlencoded" + format per `Appendix B`_ in the HTTP request entity-body: + + :param scope: The scope of the access request as described by + `Section 3.3`_. + :param kwargs: Extra credentials to include in the token request. + + The client MUST authenticate with the authorization server as + described in `Section 3.2.1`_. + + The prepared body will include all provided credentials as well as + the ``grant_type`` parameter set to ``client_credentials``:: + + >>> from oauthlib.oauth2 import BackendApplicationClient + >>> client = BackendApplicationClient('your_id') + >>> client.prepare_request_body(scope=['hello', 'world']) + 'grant_type=client_credentials&scope=hello+world' + + .. _`Appendix B`: http://tools.ietf.org/html/rfc6749#appendix-B + .. _`Section 3.3`: http://tools.ietf.org/html/rfc6749#section-3.3 + .. _`Section 3.2.1`: http://tools.ietf.org/html/rfc6749#section-3.2.1 + """ + return prepare_token_request('client_credentials', body=body, + scope=scope, **kwargs) diff --git a/oauthlib/oauth2/rfc6749/clients/base.py b/oauthlib/oauth2/rfc6749/clients/base.py new file mode 100644 index 0000000..e53ccc1 --- /dev/null +++ b/oauthlib/oauth2/rfc6749/clients/base.py @@ -0,0 +1,490 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming OAuth 2.0 RFC6749. +""" +from __future__ import absolute_import, unicode_literals + +import time + +from oauthlib.common import generate_token +from oauthlib.oauth2.rfc6749 import tokens +from oauthlib.oauth2.rfc6749.parameters import parse_token_response +from oauthlib.oauth2.rfc6749.parameters import prepare_token_request +from oauthlib.oauth2.rfc6749.parameters import prepare_token_revocation_request +from oauthlib.oauth2.rfc6749.errors import TokenExpiredError +from oauthlib.oauth2.rfc6749.errors import InsecureTransportError +from oauthlib.oauth2.rfc6749.utils import is_secure_transport + + +AUTH_HEADER = 'auth_header' +URI_QUERY = 'query' +BODY = 'body' + +FORM_ENC_HEADERS = { + 'Content-Type': 'application/x-www-form-urlencoded' +} + +class Client(object): + + """Base OAuth2 client responsible for access token management. + + This class also acts as a generic interface providing methods common to all + client types such as ``prepare_authorization_request`` and + ``prepare_token_revocation_request``. The ``prepare_x_request`` methods are + the recommended way of interacting with clients (as opposed to the abstract + prepare uri/body/etc methods). They are recommended over the older set + because they are easier to use (more consistent) and add a few additional + security checks, such as HTTPS and state checking. + + Some of these methods require further implementation only provided by the + specific purpose clients such as + :py:class:`oauthlib.oauth2.MobileApplicationClient` and thus you should always + seek to use the client class matching the OAuth workflow you need. For + Python, this is usually :py:class:`oauthlib.oauth2.WebApplicationClient`. + + """ + + def __init__(self, client_id, + default_token_placement=AUTH_HEADER, + token_type='Bearer', + access_token=None, + refresh_token=None, + mac_key=None, + mac_algorithm=None, + token=None, + scope=None, + state=None, + redirect_url=None, + state_generator=generate_token, + **kwargs): + """Initialize a client with commonly used attributes. + + :param client_id: Client identifier given by the OAuth provider upon + registration. + + :param default_token_placement: Tokens can be supplied in the Authorization + header (default), the URL query component (``query``) or the request + body (``body``). + + :param token_type: OAuth 2 token type. Defaults to Bearer. Change this + if you specify the ``access_token`` parameter and know it is of a + different token type, such as a MAC, JWT or SAML token. Can + also be supplied as ``token_type`` inside the ``token`` dict parameter. + + :param access_token: An access token (string) used to authenticate + requests to protected resources. Can also be supplied inside the + ``token`` dict parameter. + + :param refresh_token: A refresh token (string) used to refresh expired + tokens. Can also be supplide inside the ``token`` dict parameter. + + :param mac_key: Encryption key used with MAC tokens. + + :param mac_algorithm: Hashing algorithm for MAC tokens. + + :param token: A dict of token attributes such as ``access_token``, + ``token_type`` and ``expires_at``. + + :param scope: A list of default scopes to request authorization for. + + :param state: A CSRF protection string used during authorization. + + :param redirect_url: The redirection endpoint on the client side to which + the user returns after authorization. + + :param state_generator: A no argument state generation callable. Defaults + to :py:meth:`oauthlib.common.generate_token`. + """ + + self.client_id = client_id + self.default_token_placement = default_token_placement + self.token_type = token_type + self.access_token = access_token + self.refresh_token = refresh_token + self.mac_key = mac_key + self.mac_algorithm = mac_algorithm + self.token = token or {} + self.scope = scope + self.state_generator = state_generator + self.state = state + self.redirect_url = redirect_url + self._expires_at = None + self._populate_attributes(self.token) + + @property + def token_types(self): + """Supported token types and their respective methods + + Additional tokens can be supported by extending this dictionary. + + The Bearer token spec is stable and safe to use. + + The MAC token spec is not yet stable and support for MAC tokens + is experimental and currently matching version 00 of the spec. + """ + return { + 'Bearer': self._add_bearer_token, + 'MAC': self._add_mac_token + } + + def prepare_request_uri(self, *args, **kwargs): + """Abstract method used to create request URIs.""" + raise NotImplementedError("Must be implemented by inheriting classes.") + + def prepare_request_body(self, *args, **kwargs): + """Abstract method used to create request bodies.""" + raise NotImplementedError("Must be implemented by inheriting classes.") + + def parse_request_uri_response(self, *args, **kwargs): + """Abstract method used to parse redirection responses.""" + + def add_token(self, uri, http_method='GET', body=None, headers=None, + token_placement=None, **kwargs): + """Add token to the request uri, body or authorization header. + + The access token type provides the client with the information + required to successfully utilize the access token to make a protected + resource request (along with type-specific attributes). The client + MUST NOT use an access token if it does not understand the token + type. + + For example, the "bearer" token type defined in + [`I-D.ietf-oauth-v2-bearer`_] is utilized by simply including the access + token string in the request: + + .. code-block:: http + + GET /resource/1 HTTP/1.1 + Host: example.com + Authorization: Bearer mF_9.B5f-4.1JqM + + while the "mac" token type defined in [`I-D.ietf-oauth-v2-http-mac`_] is + utilized by issuing a MAC key together with the access token which is + used to sign certain components of the HTTP requests: + + .. code-block:: http + + GET /resource/1 HTTP/1.1 + Host: example.com + Authorization: MAC id="h480djs93hd8", + nonce="274312:dj83hs9s", + mac="kDZvddkndxvhGRXZhvuDjEWhGeE=" + + .. _`I-D.ietf-oauth-v2-bearer`: http://tools.ietf.org/html/rfc6749#section-12.2 + .. _`I-D.ietf-oauth-v2-http-mac`: http://tools.ietf.org/html/rfc6749#section-12.2 + """ + if not is_secure_transport(uri): + raise InsecureTransportError() + + token_placement = token_placement or self.default_token_placement + + case_insensitive_token_types = dict( + (k.lower(), v) for k, v in self.token_types.items()) + if not self.token_type.lower() in case_insensitive_token_types: + raise ValueError("Unsupported token type: %s" % self.token_type) + + if not self.access_token: + raise ValueError("Missing access token.") + + if self._expires_at and self._expires_at < time.time(): + raise TokenExpiredError() + + return case_insensitive_token_types[self.token_type.lower()](uri, http_method, body, + headers, token_placement, **kwargs) + + def prepare_authorization_request(self, authorization_url, state=None, + redirect_url=None, scope=None, **kwargs): + """Prepare the authorization request. + + This is the first step in many OAuth flows in which the user is + redirected to a certain authorization URL. This method adds + required parameters to the authorization URL. + + :param authorization_url: Provider authorization endpoint URL. + + :param state: CSRF protection string. Will be automatically created if + not provided. The generated state is available via the ``state`` + attribute. Clients should verify that the state is unchanged and + present in the authorization response. This verification is done + automatically if using the ``authorization_response`` parameter + with ``prepare_token_request``. + + :param redirect_url: Redirect URL to which the user will be returned + after authorization. Must be provided unless previously setup with + the provider. If provided then it must also be provided in the + token request. + + :param kwargs: Additional parameters to included in the request. + + :returns: The prepared request tuple with (url, headers, body). + """ + if not is_secure_transport(authorization_url): + raise InsecureTransportError() + + self.state = state or self.state_generator() + self.redirect_url = redirect_url or self.redirect_url + self.scope = scope or self.scope + auth_url = self.prepare_request_uri( + authorization_url, redirect_uri=self.redirect_uri, + scope=self.scope, state=self.state, **kwargs) + return auth_url, FORM_ENC_HEADERS, '' + + def prepare_token_request(self, token_url, authorization_response=None, + redirect_url=None, state=None, body='', **kwargs): + """Prepare a token creation request. + + Note that these requests usually require client authentication, either + by including client_id or a set of provider specific authentication + credentials. + + :param token_url: Provider token creation endpoint URL. + + :param authorization_response: The full redirection URL string, i.e. + the location to which the user was redirected after successfull + authorization. Used to mine credentials needed to obtain a token + in this step, such as authorization code. + + :param redirect_url: The redirect_url supplied with the authorization + request (if there was one). + + :param body: Request body (URL encoded string). + + :param kwargs: Additional parameters to included in the request. + + :returns: The prepared request tuple with (url, headers, body). + """ + if not is_secure_transport(token_url): + raise InsecureTransportError() + + state = state or self.state + if authorization_response: + self.parse_request_uri_response( + authorization_response, state=state) + self.redirect_url = redirect_url or self.redirect_url + body = self.prepare_request_body(body=body, + redirect_uri=self.redirect_url, **kwargs) + + return token_url, FORM_ENC_HEADERS, body + + def prepare_refresh_token_request(self, token_url, refresh_token=None, + body='', scope=None, **kwargs): + """Prepare an access token refresh request. + + Expired access tokens can be replaced by new access tokens without + going through the OAuth dance if the client obtained a refresh token. + This refresh token and authentication credentials can be used to + obtain a new access token, and possibly a new refresh token. + + :param token_url: Provider token refresh endpoint URL. + + :param refresh_token: Refresh token string. + + :param body: Request body (URL encoded string). + + :param scope: List of scopes to request. Must be equal to + or a subset of the scopes granted when obtaining the refresh + token. + + :param kwargs: Additional parameters to included in the request. + + :returns: The prepared request tuple with (url, headers, body). + """ + if not is_secure_transport(token_url): + raise InsecureTransportError() + + self.scope = scope or self.scope + body = self._client.prepare_refresh_body(body=body, + refresh_token=refresh_token, scope=self.scope, **kwargs) + return token_url, FORM_ENC_HEADERS, body + + def prepare_token_revocation_request(self, revocation_url, token, + token_type_hint="access_token", body='', callback=None, **kwargs): + """Prepare a token revocation request. + + :param revocation_url: Provider token revocation endpoint URL. + + :param token: The access or refresh token to be revoked (string). + + :param token_type_hint: ``"access_token"`` (default) or + ``"refresh_token"``. This is optional and if you wish to not pass it you + must provide ``token_type_hint=None``. + + :param callback: A jsonp callback such as ``package.callback`` to be invoked + upon receiving the response. Not that it should not include a () suffix. + + :param kwargs: Additional parameters to included in the request. + + :returns: The prepared request tuple with (url, headers, body). + + Note that JSONP request may use GET requests as the parameters will + be added to the request URL query as opposed to the request body. + + An example of a revocation request + + .. code-block: http + + POST /revoke HTTP/1.1 + Host: server.example.com + Content-Type: application/x-www-form-urlencoded + Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW + + token=45ghiukldjahdnhzdauz&token_type_hint=refresh_token + + An example of a jsonp revocation request + + .. code-block: http + + GET /revoke?token=agabcdefddddafdd&callback=package.myCallback HTTP/1.1 + Host: server.example.com + Content-Type: application/x-www-form-urlencoded + Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW + + and an error response + + .. code-block: http + + package.myCallback({"error":"unsupported_token_type"}); + + Note that these requests usually require client credentials, client_id in + the case for public clients and provider specific authentication + credentials for confidential clients. + """ + if not is_secure_transport(revocation_url): + raise InsecureTransportError() + + return prepare_token_revocation_request(revocation_url, token, + token_type_hint=token_type_hint, body=body, callback=callback, + **kwargs) + + def parse_request_body_response(self, body, scope=None, **kwargs): + """Parse the JSON response body. + + If the access token request is valid and authorized, the + authorization server issues an access token as described in + `Section 5.1`_. A refresh token SHOULD NOT be included. If the request + failed client authentication or is invalid, the authorization server + returns an error response as described in `Section 5.2`_. + + :param body: The response body from the token request. + :param scope: Scopes originally requested. + :return: Dictionary of token parameters. + :raises: Warning if scope has changed. OAuth2Error if response is invalid. + + These response are json encoded and could easily be parsed without + the assistance of OAuthLib. However, there are a few subtle issues + to be aware of regarding the response which are helpfully addressed + through the raising of various errors. + + A successful response should always contain + + **access_token** + The access token issued by the authorization server. Often + a random string. + + **token_type** + The type of the token issued as described in `Section 7.1`_. + Commonly ``Bearer``. + + While it is not mandated it is recommended that the provider include + + **expires_in** + The lifetime in seconds of the access token. For + example, the value "3600" denotes that the access token will + expire in one hour from the time the response was generated. + If omitted, the authorization server SHOULD provide the + expiration time via other means or document the default value. + + **scope** + Providers may supply this in all responses but are required to only + if it has changed since the authorization request. + + .. _`Section 5.1`: http://tools.ietf.org/html/rfc6749#section-5.1 + .. _`Section 5.2`: http://tools.ietf.org/html/rfc6749#section-5.2 + .. _`Section 7.1`: http://tools.ietf.org/html/rfc6749#section-7.1 + """ + self.token = parse_token_response(body, scope=scope) + self._populate_attributes(self.token) + return self.token + + def prepare_refresh_body(self, body='', refresh_token=None, scope=None, **kwargs): + """Prepare an access token request, using a refresh token. + + If the authorization server issued a refresh token to the client, the + client makes a refresh request to the token endpoint by adding the + following parameters using the "application/x-www-form-urlencoded" + format in the HTTP request entity-body: + + grant_type + REQUIRED. Value MUST be set to "refresh_token". + refresh_token + REQUIRED. The refresh token issued to the client. + scope + OPTIONAL. The scope of the access request as described by + Section 3.3. The requested scope MUST NOT include any scope + not originally granted by the resource owner, and if omitted is + treated as equal to the scope originally granted by the + resource owner. + """ + refresh_token = refresh_token or self.refresh_token + return prepare_token_request('refresh_token', body=body, scope=scope, + refresh_token=refresh_token, **kwargs) + + def _add_bearer_token(self, uri, http_method='GET', body=None, + headers=None, token_placement=None): + """Add a bearer token to the request uri, body or authorization header.""" + if token_placement == AUTH_HEADER: + headers = tokens.prepare_bearer_headers(self.access_token, headers) + + elif token_placement == URI_QUERY: + uri = tokens.prepare_bearer_uri(self.access_token, uri) + + elif token_placement == BODY: + body = tokens.prepare_bearer_body(self.access_token, body) + + else: + raise ValueError("Invalid token placement.") + return uri, headers, body + + def _add_mac_token(self, uri, http_method='GET', body=None, + headers=None, token_placement=AUTH_HEADER, ext=None, **kwargs): + """Add a MAC token to the request authorization header. + + Warning: MAC token support is experimental as the spec is not yet stable. + """ + headers = tokens.prepare_mac_header(self.access_token, uri, + self.mac_key, http_method, headers=headers, body=body, ext=ext, + hash_algorithm=self.mac_algorithm, **kwargs) + return uri, headers, body + + def _populate_attributes(self, response): + """Add commonly used values such as access_token to self.""" + + if 'access_token' in response: + self.access_token = response.get('access_token') + + if 'refresh_token' in response: + self.refresh_token = response.get('refresh_token') + + if 'token_type' in response: + self.token_type = response.get('token_type') + + if 'expires_in' in response: + self.expires_in = response.get('expires_in') + self._expires_at = time.time() + int(self.expires_in) + + if 'expires_at' in response: + self._expires_at = int(response.get('expires_at')) + + if 'code' in response: + self.code = response.get('code') + + if 'mac_key' in response: + self.mac_key = response.get('mac_key') + + if 'mac_algorithm' in response: + self.mac_algorithm = response.get('mac_algorithm') + diff --git a/oauthlib/oauth2/rfc6749/clients/legacy_application.py b/oauthlib/oauth2/rfc6749/clients/legacy_application.py new file mode 100644 index 0000000..e4e522a --- /dev/null +++ b/oauthlib/oauth2/rfc6749/clients/legacy_application.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +from __future__ import absolute_import, unicode_literals + +from .base import Client +from ..parameters import prepare_token_request +from ..parameters import parse_token_response + + +class LegacyApplicationClient(Client): + + """A public client using the resource owner password and username directly. + + The resource owner password credentials grant type is suitable in + cases where the resource owner has a trust relationship with the + client, such as the device operating system or a highly privileged + application. The authorization server should take special care when + enabling this grant type, and only allow it when other flows are not + viable. + + The grant type is suitable for clients capable of obtaining the + resource owner's credentials (username and password, typically using + an interactive form). It is also used to migrate existing clients + using direct authentication schemes such as HTTP Basic or Digest + authentication to OAuth by converting the stored credentials to an + access token. + + The method through which the client obtains the resource owner + credentials is beyond the scope of this specification. The client + MUST discard the credentials once an access token has been obtained. + """ + + def __init__(self, client_id, **kwargs): + super(LegacyApplicationClient, self).__init__(client_id, **kwargs) + + def prepare_request_body(self, username, password, body='', scope=None, **kwargs): + """Add the resource owner password and username to the request body. + + The client makes a request to the token endpoint by adding the + following parameters using the "application/x-www-form-urlencoded" + format per `Appendix B`_ in the HTTP request entity-body: + + :param username: The resource owner username. + :param password: The resource owner password. + :param scope: The scope of the access request as described by + `Section 3.3`_. + :param kwargs: Extra credentials to include in the token request. + + If the client type is confidential or the client was issued client + credentials (or assigned other authentication requirements), the + client MUST authenticate with the authorization server as described + in `Section 3.2.1`_. + + The prepared body will include all provided credentials as well as + the ``grant_type`` parameter set to ``password``:: + + >>> from oauthlib.oauth2 import LegacyApplicationClient + >>> client = LegacyApplicationClient('your_id') + >>> client.prepare_request_body(username='foo', password='bar', scope=['hello', 'world']) + 'grant_type=password&username=foo&scope=hello+world&password=bar' + + .. _`Appendix B`: http://tools.ietf.org/html/rfc6749#appendix-B + .. _`Section 3.3`: http://tools.ietf.org/html/rfc6749#section-3.3 + .. _`Section 3.2.1`: http://tools.ietf.org/html/rfc6749#section-3.2.1 + """ + return prepare_token_request('password', body=body, username=username, + password=password, scope=scope, **kwargs) diff --git a/oauthlib/oauth2/rfc6749/clients/mobile_application.py b/oauthlib/oauth2/rfc6749/clients/mobile_application.py new file mode 100644 index 0000000..9e3ef21 --- /dev/null +++ b/oauthlib/oauth2/rfc6749/clients/mobile_application.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +from __future__ import absolute_import, unicode_literals + +from .base import Client +from ..parameters import prepare_grant_uri +from ..parameters import parse_implicit_response + + +class MobileApplicationClient(Client): + + """A public client utilizing the implicit code grant workflow. + + A user-agent-based application is a public client in which the + client code is downloaded from a web server and executes within a + user-agent (e.g. web browser) on the device used by the resource + owner. Protocol data and credentials are easily accessible (and + often visible) to the resource owner. Since such applications + reside within the user-agent, they can make seamless use of the + user-agent capabilities when requesting authorization. + + The implicit grant type is used to obtain access tokens (it does not + support the issuance of refresh tokens) and is optimized for public + clients known to operate a particular redirection URI. These clients + are typically implemented in a browser using a scripting language + such as JavaScript. + + As a redirection-based flow, the client must be capable of + interacting with the resource owner's user-agent (typically a web + browser) and capable of receiving incoming requests (via redirection) + from the authorization server. + + Unlike the authorization code grant type in which the client makes + separate requests for authorization and access token, the client + receives the access token as the result of the authorization request. + + The implicit grant type does not include client authentication, and + relies on the presence of the resource owner and the registration of + the redirection URI. Because the access token is encoded into the + redirection URI, it may be exposed to the resource owner and other + applications residing on the same device. + """ + + def prepare_request_uri(self, uri, redirect_uri=None, scope=None, + state=None, **kwargs): + """Prepare the implicit grant request URI. + + The client constructs the request URI by adding the following + parameters to the query component of the authorization endpoint URI + using the "application/x-www-form-urlencoded" format, per `Appendix B`_: + + :param redirect_uri: OPTIONAL. The redirect URI must be an absolute URI + and it should have been registerd with the OAuth + provider prior to use. As described in `Section 3.1.2`_. + + :param scope: OPTIONAL. The scope of the access request as described by + Section 3.3`_. These may be any string but are commonly + URIs or various categories such as ``videos`` or ``documents``. + + :param state: RECOMMENDED. An opaque value used by the client to maintain + state between the request and callback. The authorization + server includes this value when redirecting the user-agent back + to the client. The parameter SHOULD be used for preventing + cross-site request forgery as described in `Section 10.12`_. + + :param kwargs: Extra arguments to include in the request URI. + + In addition to supplied parameters, OAuthLib will append the ``client_id`` + that was provided in the constructor as well as the mandatory ``response_type`` + argument, set to ``token``:: + + >>> from oauthlib.oauth2 import MobileApplicationClient + >>> client = MobileApplicationClient('your_id') + >>> client.prepare_request_uri('https://example.com') + 'https://example.com?client_id=your_id&response_type=token' + >>> client.prepare_request_uri('https://example.com', redirect_uri='https://a.b/callback') + 'https://example.com?client_id=your_id&response_type=token&redirect_uri=https%3A%2F%2Fa.b%2Fcallback' + >>> client.prepare_request_uri('https://example.com', scope=['profile', 'pictures']) + 'https://example.com?client_id=your_id&response_type=token&scope=profile+pictures' + >>> client.prepare_request_uri('https://example.com', foo='bar') + 'https://example.com?client_id=your_id&response_type=token&foo=bar' + + .. _`Appendix B`: http://tools.ietf.org/html/rfc6749#appendix-B + .. _`Section 2.2`: http://tools.ietf.org/html/rfc6749#section-2.2 + .. _`Section 3.1.2`: http://tools.ietf.org/html/rfc6749#section-3.1.2 + .. _`Section 3.3`: http://tools.ietf.org/html/rfc6749#section-3.3 + .. _`Section 10.12`: http://tools.ietf.org/html/rfc6749#section-10.12 + """ + return prepare_grant_uri(uri, self.client_id, 'token', + redirect_uri=redirect_uri, state=state, scope=scope, **kwargs) + + def parse_request_uri_response(self, uri, state=None, scope=None): + """Parse the response URI fragment. + + If the resource owner grants the access request, the authorization + server issues an access token and delivers it to the client by adding + the following parameters to the fragment component of the redirection + URI using the "application/x-www-form-urlencoded" format: + + :param uri: The callback URI that resulted from the user being redirected + back from the provider to you, the client. + :param state: The state provided in the authorization request. + :param scope: The scopes provided in the authorization request. + :return: Dictionary of token parameters. + :raises: OAuth2Error if response is invalid. + + A successful response should always contain + + **access_token** + The access token issued by the authorization server. Often + a random string. + + **token_type** + The type of the token issued as described in `Section 7.1`_. + Commonly ``Bearer``. + + **state** + If you provided the state parameter in the authorization phase, then + the provider is required to include that exact state value in the + response. + + While it is not mandated it is recommended that the provider include + + **expires_in** + The lifetime in seconds of the access token. For + example, the value "3600" denotes that the access token will + expire in one hour from the time the response was generated. + If omitted, the authorization server SHOULD provide the + expiration time via other means or document the default value. + + **scope** + Providers may supply this in all responses but are required to only + if it has changed since the authorization request. + + A few example responses can be seen below:: + + >>> response_uri = 'https://example.com/callback#access_token=sdlfkj452&state=ss345asyht&token_type=Bearer&scope=hello+world' + >>> from oauthlib.oauth2 import MobileApplicationClient + >>> client = MobileApplicationClient('your_id') + >>> client.parse_request_uri_response(response_uri) + { + 'access_token': 'sdlfkj452', + 'token_type': 'Bearer', + 'state': 'ss345asyht', + 'scope': [u'hello', u'world'] + } + >>> client.parse_request_uri_response(response_uri, state='other') + Traceback (most recent call last): + File "", line 1, in + File "oauthlib/oauth2/rfc6749/__init__.py", line 598, in parse_request_uri_response + **scope** + File "oauthlib/oauth2/rfc6749/parameters.py", line 197, in parse_implicit_response + raise ValueError("Mismatching or missing state in params.") + ValueError: Mismatching or missing state in params. + >>> def alert_scope_changed(message, old, new): + ... print(message, old, new) + ... + >>> oauthlib.signals.scope_changed.connect(alert_scope_changed) + >>> client.parse_request_body_response(response_body, scope=['other']) + ('Scope has changed from "other" to "hello world".', ['other'], ['hello', 'world']) + + .. _`Section 7.1`: http://tools.ietf.org/html/rfc6749#section-7.1 + .. _`Section 3.3`: http://tools.ietf.org/html/rfc6749#section-3.3 + """ + self.token = parse_implicit_response(uri, state=state, scope=scope) + self._populate_attributes(self.token) + return self.token diff --git a/oauthlib/oauth2/rfc6749/clients/service_application.py b/oauthlib/oauth2/rfc6749/clients/service_application.py new file mode 100644 index 0000000..36da98b --- /dev/null +++ b/oauthlib/oauth2/rfc6749/clients/service_application.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +from __future__ import absolute_import, unicode_literals + +import time + +from oauthlib.common import to_unicode + +from .base import Client +from ..parameters import prepare_token_request +from ..parameters import parse_token_response + + +class ServiceApplicationClient(Client): + """A public client utilizing the JWT bearer grant. + + JWT bearer tokes can be used to request an access token when a client + wishes to utilize an existing trust relationship, expressed through the + semantics of (and digital signature or keyed message digest calculated + over) the JWT, without a direct user approval step at the authorization + server. + + This grant type does not involve an authorization step. It may be + used by both public and confidential clients. + """ + + grant_type = 'urn:ietf:params:oauth:grant-type:jwt-bearer' + + def __init__(self, client_id, private_key=None, subject=None, issuer=None, + audience=None, **kwargs): + """Initalize a JWT client with defaults for implicit use later. + + :param client_id: Client identifier given by the OAuth provider upon + registration. + + :param private_key: Private key used for signing and encrypting. + Must be given as a string. + + :param subject: The principal that is the subject of the JWT, i.e. + which user is the token requested on behalf of. + For example, ``foo@example.com. + + :param issuer: The JWT MUST contain an "iss" (issuer) claim that + contains a unique identifier for the entity that issued + the JWT. For example, ``your-client@provider.com``. + + :param audience: A value identifying the authorization server as an + intended audience, e.g. + ``https://provider.com/oauth2/token``. + + :param kwargs: Additional arguments to pass to base client, such as + state and token. See Client.__init__.__doc__ for + details. + """ + super(ServiceApplicationClient, self).__init__(client_id, **kwargs) + self.private_key = private_key + self.subject = subject + self.issuer = issuer + self.audience = audience + + def prepare_request_body(self, + private_key=None, + subject=None, + issuer=None, + audience=None, + expires_at=None, + issued_at=None, + extra_claims=None, + body='', + scope=None, + **kwargs): + """Create and add a JWT assertion to the request body. + + :param private_key: Private key used for signing and encrypting. + Must be given as a string. + + :param subject: (sub) The principal that is the subject of the JWT, + i.e. which user is the token requested on behalf of. + For example, ``foo@example.com. + + :param issuer: (iss) The JWT MUST contain an "iss" (issuer) claim that + contains a unique identifier for the entity that issued + the JWT. For example, ``your-client@provider.com``. + + :param audience: (aud) A value identifying the authorization server as an + intended audience, e.g. + ``https://provider.com/oauth2/token``. + + :param expires_at: A unix expiration timestamp for the JWT. Defaults + to an hour from now, i.e. ``time.time() + 3600``. + + :param issued_at: A unix timestamp of when the JWT was created. + Defaults to now, i.e. ``time.time()``. + + :param not_before: A unix timestamp after which the JWT may be used. + Not included unless provided. + + :param jwt_id: A unique JWT token identifier. Not included unless + provided. + + :param extra_claims: A dict of additional claims to include in the JWT. + + :param scope: The scope of the access request. + + :param body: Request body (string) with extra parameters. + + :param kwargs: Extra credentials to include in the token request. + + The "scope" parameter may be used, as defined in the Assertion + Framework for OAuth 2.0 Client Authentication and Authorization Grants + [I-D.ietf-oauth-assertions] specification, to indicate the requested + scope. + + Authentication of the client is optional, as described in + `Section 3.2.1`_ of OAuth 2.0 [RFC6749] and consequently, the + "client_id" is only needed when a form of client authentication that + relies on the parameter is used. + + The following non-normative example demonstrates an Access Token + Request with a JWT as an authorization grant (with extra line breaks + for display purposes only): + + .. code-block: http + + POST /token.oauth2 HTTP/1.1 + Host: as.example.com + Content-Type: application/x-www-form-urlencoded + + grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer + &assertion=eyJhbGciOiJFUzI1NiJ9. + eyJpc3Mi[...omitted for brevity...]. + J9l-ZhwP[...omitted for brevity...] + + .. _`Section 3.2.1`: http://tools.ietf.org/html/rfc6749#section-3.2.1 + """ + import jwt + + key = private_key or self.private_key + if not key: + raise ValueError('An encryption key must be supplied to make JWT' + ' token requests.') + claim = { + 'iss': issuer or self.issuer, + 'aud': audience or self.issuer, + 'sub': subject or self.issuer, + 'exp': int(expires_at or time.time() + 3600), + 'iat': int(issued_at or time.time()), + } + + for attr in ('iss', 'aud', 'sub'): + if claim[attr] is None: + raise ValueError( + 'Claim must include %s but none was given.' % attr) + + if 'not_before' in kwargs: + claim['nbf'] = kwargs.pop('not_before') + + if 'jwt_id' in kwargs: + claim['jti'] = kwargs.pop('jwt_id') + + claim.update(extra_claims or {}) + + assertion = jwt.encode(claim, key, 'RS256') + assertion = to_unicode(assertion) + + return prepare_token_request(self.grant_type, + body=body, + assertion=assertion, + scope=scope, + **kwargs) diff --git a/oauthlib/oauth2/rfc6749/clients/web_application.py b/oauthlib/oauth2/rfc6749/clients/web_application.py new file mode 100644 index 0000000..c685d3c --- /dev/null +++ b/oauthlib/oauth2/rfc6749/clients/web_application.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +from __future__ import absolute_import, unicode_literals + +from .base import Client +from ..parameters import prepare_grant_uri, prepare_token_request +from ..parameters import parse_authorization_code_response +from ..parameters import parse_token_response + + +class WebApplicationClient(Client): + + """A client utilizing the authorization code grant workflow. + + A web application is a confidential client running on a web + server. Resource owners access the client via an HTML user + interface rendered in a user-agent on the device used by the + resource owner. The client credentials as well as any access + token issued to the client are stored on the web server and are + not exposed to or accessible by the resource owner. + + The authorization code grant type is used to obtain both access + tokens and refresh tokens and is optimized for confidential clients. + As a redirection-based flow, the client must be capable of + interacting with the resource owner's user-agent (typically a web + browser) and capable of receiving incoming requests (via redirection) + from the authorization server. + """ + + def __init__(self, client_id, code=None, **kwargs): + super(WebApplicationClient, self).__init__(client_id, **kwargs) + self.code = code + + def prepare_request_uri(self, uri, redirect_uri=None, scope=None, + state=None, **kwargs): + """Prepare the authorization code request URI + + The client constructs the request URI by adding the following + parameters to the query component of the authorization endpoint URI + using the "application/x-www-form-urlencoded" format, per `Appendix B`_: + + :param redirect_uri: OPTIONAL. The redirect URI must be an absolute URI + and it should have been registerd with the OAuth + provider prior to use. As described in `Section 3.1.2`_. + + :param scope: OPTIONAL. The scope of the access request as described by + Section 3.3`_. These may be any string but are commonly + URIs or various categories such as ``videos`` or ``documents``. + + :param state: RECOMMENDED. An opaque value used by the client to maintain + state between the request and callback. The authorization + server includes this value when redirecting the user-agent back + to the client. The parameter SHOULD be used for preventing + cross-site request forgery as described in `Section 10.12`_. + + :param kwargs: Extra arguments to include in the request URI. + + In addition to supplied parameters, OAuthLib will append the ``client_id`` + that was provided in the constructor as well as the mandatory ``response_type`` + argument, set to ``code``:: + + >>> from oauthlib.oauth2 import WebApplicationClient + >>> client = WebApplicationClient('your_id') + >>> client.prepare_request_uri('https://example.com') + 'https://example.com?client_id=your_id&response_type=code' + >>> client.prepare_request_uri('https://example.com', redirect_uri='https://a.b/callback') + 'https://example.com?client_id=your_id&response_type=code&redirect_uri=https%3A%2F%2Fa.b%2Fcallback' + >>> client.prepare_request_uri('https://example.com', scope=['profile', 'pictures']) + 'https://example.com?client_id=your_id&response_type=code&scope=profile+pictures' + >>> client.prepare_request_uri('https://example.com', foo='bar') + 'https://example.com?client_id=your_id&response_type=code&foo=bar' + + .. _`Appendix B`: http://tools.ietf.org/html/rfc6749#appendix-B + .. _`Section 2.2`: http://tools.ietf.org/html/rfc6749#section-2.2 + .. _`Section 3.1.2`: http://tools.ietf.org/html/rfc6749#section-3.1.2 + .. _`Section 3.3`: http://tools.ietf.org/html/rfc6749#section-3.3 + .. _`Section 10.12`: http://tools.ietf.org/html/rfc6749#section-10.12 + """ + return prepare_grant_uri(uri, self.client_id, 'code', + redirect_uri=redirect_uri, scope=scope, state=state, **kwargs) + + def prepare_request_body(self, client_id=None, code=None, body='', + redirect_uri=None, **kwargs): + """Prepare the access token request body. + + The client makes a request to the token endpoint by adding the + following parameters using the "application/x-www-form-urlencoded" + format in the HTTP request entity-body: + + :param client_id: REQUIRED, if the client is not authenticating with the + authorization server as described in `Section 3.2.1`_. + + :param code: REQUIRED. The authorization code received from the + authorization server. + + :param redirect_uri: REQUIRED, if the "redirect_uri" parameter was included in the + authorization request as described in `Section 4.1.1`_, and their + values MUST be identical. + + :param kwargs: Extra parameters to include in the token request. + + In addition OAuthLib will add the ``grant_type`` parameter set to + ``authorization_code``. + + If the client type is confidential or the client was issued client + credentials (or assigned other authentication requirements), the + client MUST authenticate with the authorization server as described + in `Section 3.2.1`_:: + + >>> from oauthlib.oauth2 import WebApplicationClient + >>> client = WebApplicationClient('your_id') + >>> client.prepare_request_body(code='sh35ksdf09sf') + 'grant_type=authorization_code&code=sh35ksdf09sf' + >>> client.prepare_request_body(code='sh35ksdf09sf', foo='bar') + 'grant_type=authorization_code&code=sh35ksdf09sf&foo=bar' + + .. _`Section 4.1.1`: http://tools.ietf.org/html/rfc6749#section-4.1.1 + .. _`Section 3.2.1`: http://tools.ietf.org/html/rfc6749#section-3.2.1 + """ + code = code or self.code + return prepare_token_request('authorization_code', code=code, body=body, + client_id=self.client_id, redirect_uri=redirect_uri, **kwargs) + + def parse_request_uri_response(self, uri, state=None): + """Parse the URI query for code and state. + + If the resource owner grants the access request, the authorization + server issues an authorization code and delivers it to the client by + adding the following parameters to the query component of the + redirection URI using the "application/x-www-form-urlencoded" format: + + :param uri: The callback URI that resulted from the user being redirected + back from the provider to you, the client. + :param state: The state provided in the authorization request. + + **code** + The authorization code generated by the authorization server. + The authorization code MUST expire shortly after it is issued + to mitigate the risk of leaks. A maximum authorization code + lifetime of 10 minutes is RECOMMENDED. The client MUST NOT + use the authorization code more than once. If an authorization + code is used more than once, the authorization server MUST deny + the request and SHOULD revoke (when possible) all tokens + previously issued based on that authorization code. + The authorization code is bound to the client identifier and + redirection URI. + + **state** + If the "state" parameter was present in the authorization request. + + This method is mainly intended to enforce strict state checking with + the added benefit of easily extracting parameters from the URI:: + + >>> from oauthlib.oauth2 import WebApplicationClient + >>> client = WebApplicationClient('your_id') + >>> uri = 'https://example.com/callback?code=sdfkjh345&state=sfetw45' + >>> client.parse_request_uri_response(uri, state='sfetw45') + {'state': 'sfetw45', 'code': 'sdfkjh345'} + >>> client.parse_request_uri_response(uri, state='other') + Traceback (most recent call last): + File "", line 1, in + File "oauthlib/oauth2/rfc6749/__init__.py", line 357, in parse_request_uri_response + back from the provider to you, the client. + File "oauthlib/oauth2/rfc6749/parameters.py", line 153, in parse_authorization_code_response + raise MismatchingStateError() + oauthlib.oauth2.rfc6749.errors.MismatchingStateError + """ + response = parse_authorization_code_response(uri, state=state) + self._populate_attributes(response) + return response diff --git a/oauthlib/oauth2/rfc6749/endpoints/__init__.py b/oauthlib/oauth2/rfc6749/endpoints/__init__.py new file mode 100644 index 0000000..848bec6 --- /dev/null +++ b/oauthlib/oauth2/rfc6749/endpoints/__init__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +from __future__ import absolute_import, unicode_literals + +from .authorization import AuthorizationEndpoint +from .token import TokenEndpoint +from .resource import ResourceEndpoint +from .revocation import RevocationEndpoint +from .pre_configured import Server +from .pre_configured import WebApplicationServer +from .pre_configured import MobileApplicationServer +from .pre_configured import LegacyApplicationServer +from .pre_configured import BackendApplicationServer diff --git a/oauthlib/oauth2/rfc6749/endpoints/authorization.py b/oauthlib/oauth2/rfc6749/endpoints/authorization.py new file mode 100644 index 0000000..ada24d7 --- /dev/null +++ b/oauthlib/oauth2/rfc6749/endpoints/authorization.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +from __future__ import absolute_import, unicode_literals + +import logging + +from oauthlib.common import Request + +from .base import BaseEndpoint, catch_errors_and_unavailability + +log = logging.getLogger(__name__) + + +class AuthorizationEndpoint(BaseEndpoint): + + """Authorization endpoint - used by the client to obtain authorization + from the resource owner via user-agent redirection. + + The authorization endpoint is used to interact with the resource + owner and obtain an authorization grant. The authorization server + MUST first verify the identity of the resource owner. The way in + which the authorization server authenticates the resource owner (e.g. + username and password login, session cookies) is beyond the scope of + this specification. + + The endpoint URI MAY include an "application/x-www-form-urlencoded" + formatted (per `Appendix B`_) query component, + which MUST be retained when adding additional query parameters. The + endpoint URI MUST NOT include a fragment component:: + + https://example.com/path?query=component # OK + https://example.com/path?query=component#fragment # Not OK + + Since requests to the authorization endpoint result in user + authentication and the transmission of clear-text credentials (in the + HTTP response), the authorization server MUST require the use of TLS + as described in Section 1.6 when sending requests to the + authorization endpoint:: + + # We will deny any request which URI schema is not with https + + The authorization server MUST support the use of the HTTP "GET" + method [RFC2616] for the authorization endpoint, and MAY support the + use of the "POST" method as well:: + + # HTTP method is currently not enforced + + Parameters sent without a value MUST be treated as if they were + omitted from the request. The authorization server MUST ignore + unrecognized request parameters. Request and response parameters + MUST NOT be included more than once:: + + # Enforced through the design of oauthlib.common.Request + + .. _`Appendix B`: http://tools.ietf.org/html/rfc6749#appendix-B + """ + + def __init__(self, default_response_type, default_token_type, + response_types): + BaseEndpoint.__init__(self) + self._response_types = response_types + self._default_response_type = default_response_type + self._default_token_type = default_token_type + + @property + def response_types(self): + return self._response_types + + @property + def default_response_type(self): + return self._default_response_type + + @property + def default_response_type_handler(self): + return self.response_types.get(self.default_response_type) + + @property + def default_token_type(self): + return self._default_token_type + + @catch_errors_and_unavailability + def create_authorization_response(self, uri, http_method='GET', body=None, + headers=None, scopes=None, credentials=None): + """Extract response_type and route to the designated handler.""" + request = Request( + uri, http_method=http_method, body=body, headers=headers) + request.scopes = scopes + # TODO: decide whether this should be a required argument + request.user = None # TODO: explain this in docs + for k, v in (credentials or {}).items(): + setattr(request, k, v) + response_type_handler = self.response_types.get( + request.response_type, self.default_response_type_handler) + log.debug('Dispatching response_type %s request to %r.', + request.response_type, response_type_handler) + return response_type_handler.create_authorization_response( + request, self.default_token_type) + + @catch_errors_and_unavailability + def validate_authorization_request(self, uri, http_method='GET', body=None, + headers=None): + """Extract response_type and route to the designated handler.""" + request = Request( + uri, http_method=http_method, body=body, headers=headers) + request.scopes = None + response_type_handler = self.response_types.get( + request.response_type, self.default_response_type_handler) + return response_type_handler.validate_authorization_request(request) diff --git a/oauthlib/oauth2/rfc6749/endpoints/base.py b/oauthlib/oauth2/rfc6749/endpoints/base.py new file mode 100644 index 0000000..759c6c8 --- /dev/null +++ b/oauthlib/oauth2/rfc6749/endpoints/base.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +from __future__ import absolute_import, unicode_literals + +import functools +import logging + +from ..errors import TemporarilyUnavailableError, ServerError +from ..errors import FatalClientError, OAuth2Error + +log = logging.getLogger(__name__) + + +class BaseEndpoint(object): + + def __init__(self): + self._available = True + self._catch_errors = False + + @property + def available(self): + return self._available + + @available.setter + def available(self, available): + self._available = available + + @property + def catch_errors(self): + return self._catch_errors + + @catch_errors.setter + def catch_errors(self, catch_errors): + self._catch_errors = catch_errors + + +def catch_errors_and_unavailability(f): + @functools.wraps(f) + def wrapper(endpoint, uri, *args, **kwargs): + if not endpoint.available: + e = TemporarilyUnavailableError() + log.info('Endpoint unavailable, ignoring request %s.' % uri) + return {}, e.json, 503 + + if endpoint.catch_errors: + try: + return f(endpoint, uri, *args, **kwargs) + except OAuth2Error: + raise + except FatalClientError: + raise + except Exception as e: + error = ServerError() + log.warning( + 'Exception caught while processing request, %s.' % e) + return {}, error.json, 500 + else: + return f(endpoint, uri, *args, **kwargs) + return wrapper diff --git a/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py b/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py new file mode 100644 index 0000000..21ed2dd --- /dev/null +++ b/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +from __future__ import absolute_import, unicode_literals + +from ..tokens import BearerToken +from ..grant_types import AuthorizationCodeGrant +from ..grant_types import ImplicitGrant +from ..grant_types import ResourceOwnerPasswordCredentialsGrant +from ..grant_types import ClientCredentialsGrant +from ..grant_types import RefreshTokenGrant + +from .authorization import AuthorizationEndpoint +from .token import TokenEndpoint +from .resource import ResourceEndpoint +from .revocation import RevocationEndpoint + + +class Server(AuthorizationEndpoint, TokenEndpoint, ResourceEndpoint, + RevocationEndpoint): + + """An all-in-one endpoint featuring all four major grant types.""" + + def __init__(self, request_validator, token_expires_in=None, + token_generator=None, refresh_token_generator=None, + *args, **kwargs): + """Construct a new all-grants-in-one server. + + :param request_validator: An implementation of + oauthlib.oauth2.RequestValidator. + :param token_expires_in: An int or a function to generate a token + expiration offset (in seconds) given a + oauthlib.common.Request object. + :param token_generator: A function to generate a token from a request. + :param refresh_token_generator: A function to generate a token from a + request for the refresh token. + :param kwargs: Extra parameters to pass to authorization-, + token-, resource-, and revocation-endpoint constructors. + """ + auth_grant = AuthorizationCodeGrant(request_validator) + implicit_grant = ImplicitGrant(request_validator) + password_grant = ResourceOwnerPasswordCredentialsGrant( + request_validator) + credentials_grant = ClientCredentialsGrant(request_validator) + refresh_grant = RefreshTokenGrant(request_validator) + bearer = BearerToken(request_validator, token_generator, + token_expires_in, refresh_token_generator) + AuthorizationEndpoint.__init__(self, default_response_type='code', + response_types={ + 'code': auth_grant, + 'token': implicit_grant, + }, + default_token_type=bearer) + TokenEndpoint.__init__(self, default_grant_type='authorization_code', + grant_types={ + 'authorization_code': auth_grant, + 'password': password_grant, + 'client_credentials': credentials_grant, + 'refresh_token': refresh_grant, + }, + default_token_type=bearer) + ResourceEndpoint.__init__(self, default_token='Bearer', + token_types={'Bearer': bearer}) + RevocationEndpoint.__init__(self, request_validator) + + +class WebApplicationServer(AuthorizationEndpoint, TokenEndpoint, ResourceEndpoint, + RevocationEndpoint): + + """An all-in-one endpoint featuring Authorization code grant and Bearer tokens.""" + + def __init__(self, request_validator, token_generator=None, + token_expires_in=None, refresh_token_generator=None, **kwargs): + """Construct a new web application server. + + :param request_validator: An implementation of + oauthlib.oauth2.RequestValidator. + :param token_expires_in: An int or a function to generate a token + expiration offset (in seconds) given a + oauthlib.common.Request object. + :param token_generator: A function to generate a token from a request. + :param refresh_token_generator: A function to generate a token from a + request for the refresh token. + :param kwargs: Extra parameters to pass to authorization-, + token-, resource-, and revocation-endpoint constructors. + """ + auth_grant = AuthorizationCodeGrant(request_validator) + refresh_grant = RefreshTokenGrant(request_validator) + bearer = BearerToken(request_validator, token_generator, + token_expires_in, refresh_token_generator) + AuthorizationEndpoint.__init__(self, default_response_type='code', + response_types={'code': auth_grant}, + default_token_type=bearer) + TokenEndpoint.__init__(self, default_grant_type='authorization_code', + grant_types={ + 'authorization_code': auth_grant, + 'refresh_token': refresh_grant, + }, + default_token_type=bearer) + ResourceEndpoint.__init__(self, default_token='Bearer', + token_types={'Bearer': bearer}) + RevocationEndpoint.__init__(self, request_validator) + + +class MobileApplicationServer(AuthorizationEndpoint, ResourceEndpoint, + RevocationEndpoint): + + """An all-in-one endpoint featuring Implicit code grant and Bearer tokens.""" + + def __init__(self, request_validator, token_generator=None, + token_expires_in=None, refresh_token_generator=None, **kwargs): + """Construct a new implicit grant server. + + :param request_validator: An implementation of + oauthlib.oauth2.RequestValidator. + :param token_expires_in: An int or a function to generate a token + expiration offset (in seconds) given a + oauthlib.common.Request object. + :param token_generator: A function to generate a token from a request. + :param refresh_token_generator: A function to generate a token from a + request for the refresh token. + :param kwargs: Extra parameters to pass to authorization-, + token-, resource-, and revocation-endpoint constructors. + """ + implicit_grant = ImplicitGrant(request_validator) + bearer = BearerToken(request_validator, token_generator, + token_expires_in, refresh_token_generator) + AuthorizationEndpoint.__init__(self, default_response_type='token', + response_types={ + 'token': implicit_grant}, + default_token_type=bearer) + ResourceEndpoint.__init__(self, default_token='Bearer', + token_types={'Bearer': bearer}) + RevocationEndpoint.__init__(self, request_validator, + supported_token_types=['access_token']) + + +class LegacyApplicationServer(TokenEndpoint, ResourceEndpoint, + RevocationEndpoint): + + """An all-in-one endpoint featuring Resource Owner Password Credentials grant and Bearer tokens.""" + + def __init__(self, request_validator, token_generator=None, + token_expires_in=None, refresh_token_generator=None, **kwargs): + """Construct a resource owner password credentials grant server. + + :param request_validator: An implementation of + oauthlib.oauth2.RequestValidator. + :param token_expires_in: An int or a function to generate a token + expiration offset (in seconds) given a + oauthlib.common.Request object. + :param token_generator: A function to generate a token from a request. + :param refresh_token_generator: A function to generate a token from a + request for the refresh token. + :param kwargs: Extra parameters to pass to authorization-, + token-, resource-, and revocation-endpoint constructors. + """ + password_grant = ResourceOwnerPasswordCredentialsGrant( + request_validator) + refresh_grant = RefreshTokenGrant(request_validator) + bearer = BearerToken(request_validator, token_generator, + token_expires_in, refresh_token_generator) + TokenEndpoint.__init__(self, default_grant_type='password', + grant_types={ + 'password': password_grant, + 'refresh_token': refresh_grant, + }, + default_token_type=bearer) + ResourceEndpoint.__init__(self, default_token='Bearer', + token_types={'Bearer': bearer}) + RevocationEndpoint.__init__(self, request_validator) + + +class BackendApplicationServer(TokenEndpoint, ResourceEndpoint, + RevocationEndpoint): + + """An all-in-one endpoint featuring Client Credentials grant and Bearer tokens.""" + + def __init__(self, request_validator, token_generator=None, + token_expires_in=None, refresh_token_generator=None, **kwargs): + """Construct a client credentials grant server. + + :param request_validator: An implementation of + oauthlib.oauth2.RequestValidator. + :param token_expires_in: An int or a function to generate a token + expiration offset (in seconds) given a + oauthlib.common.Request object. + :param token_generator: A function to generate a token from a request. + :param refresh_token_generator: A function to generate a token from a + request for the refresh token. + :param kwargs: Extra parameters to pass to authorization-, + token-, resource-, and revocation-endpoint constructors. + """ + credentials_grant = ClientCredentialsGrant(request_validator) + bearer = BearerToken(request_validator, token_generator, + token_expires_in, refresh_token_generator) + TokenEndpoint.__init__(self, default_grant_type='client_credentials', + grant_types={ + 'client_credentials': credentials_grant}, + default_token_type=bearer) + ResourceEndpoint.__init__(self, default_token='Bearer', + token_types={'Bearer': bearer}) + RevocationEndpoint.__init__(self, request_validator, + supported_token_types=['access_token']) diff --git a/oauthlib/oauth2/rfc6749/endpoints/resource.py b/oauthlib/oauth2/rfc6749/endpoints/resource.py new file mode 100644 index 0000000..d03ed21 --- /dev/null +++ b/oauthlib/oauth2/rfc6749/endpoints/resource.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +from __future__ import absolute_import, unicode_literals + +import logging + +from oauthlib.common import Request + +from .base import BaseEndpoint, catch_errors_and_unavailability + +log = logging.getLogger(__name__) + + +class ResourceEndpoint(BaseEndpoint): + + """Authorizes access to protected resources. + + The client accesses protected resources by presenting the access + token to the resource server. The resource server MUST validate the + access token and ensure that it has not expired and that its scope + covers the requested resource. The methods used by the resource + server to validate the access token (as well as any error responses) + are beyond the scope of this specification but generally involve an + interaction or coordination between the resource server and the + authorization server:: + + # For most cases, returning a 403 should suffice. + + The method in which the client utilizes the access token to + authenticate with the resource server depends on the type of access + token issued by the authorization server. Typically, it involves + using the HTTP "Authorization" request header field [RFC2617] with an + authentication scheme defined by the specification of the access + token type used, such as [RFC6750]:: + + # Access tokens may also be provided in query and body + https://example.com/protected?access_token=kjfch2345sdf # Query + access_token=sdf23409df # Body + """ + + def __init__(self, default_token, token_types): + BaseEndpoint.__init__(self) + self._tokens = token_types + self._default_token = default_token + + @property + def default_token(self): + return self._default_token + + @property + def default_token_type_handler(self): + return self.tokens.get(self.default_token) + + @property + def tokens(self): + return self._tokens + + @catch_errors_and_unavailability + def verify_request(self, uri, http_method='GET', body=None, headers=None, + scopes=None): + """Validate client, code etc, return body + headers""" + request = Request(uri, http_method, body, headers) + request.token_type = self.find_token_type(request) + request.scopes = scopes + token_type_handler = self.tokens.get(request.token_type, + self.default_token_type_handler) + log.debug('Dispatching token_type %s request to %r.', + request.token_type, token_type_handler) + return token_type_handler.validate_request(request), request + + def find_token_type(self, request): + """Token type identification. + + RFC 6749 does not provide a method for easily differentiating between + different token types during protected resource access. We estimate + the most likely token type (if any) by asking each known token type + to give an estimation based on the request. + """ + estimates = sorted(((t.estimate_type(request), n) + for n, t in self.tokens.items())) + return estimates[0][1] if len(estimates) else None diff --git a/oauthlib/oauth2/rfc6749/endpoints/revocation.py b/oauthlib/oauth2/rfc6749/endpoints/revocation.py new file mode 100644 index 0000000..b73131c --- /dev/null +++ b/oauthlib/oauth2/rfc6749/endpoints/revocation.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749.endpoint.revocation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An implementation of the OAuth 2 `Token Revocation`_ spec (draft 11). + +.. _`Token Revocation`: http://tools.ietf.org/html/draft-ietf-oauth-revocation-11 +""" +from __future__ import absolute_import, unicode_literals + +import logging + +from oauthlib.common import Request + +from .base import BaseEndpoint, catch_errors_and_unavailability +from ..errors import InvalidClientError, UnsupportedTokenTypeError +from ..errors import InvalidRequestError, OAuth2Error + +log = logging.getLogger(__name__) + + +class RevocationEndpoint(BaseEndpoint): + + """Token revocation endpoint. + + Endpoint used by authenticated clients to revoke access and refresh tokens. + Commonly this will be part of the Authorization Endpoint. + """ + + valid_token_types = ('access_token', 'refresh_token') + + def __init__(self, request_validator, supported_token_types=None, + enable_jsonp=False): + BaseEndpoint.__init__(self) + self.request_validator = request_validator + self.supported_token_types = ( + supported_token_types or self.valid_token_types) + self.enable_jsonp = enable_jsonp + + @catch_errors_and_unavailability + def create_revocation_response(self, uri, http_method='POST', body=None, + headers=None): + """Revoke supplied access or refresh token. + + + The authorization server responds with HTTP status code 200 if the + token has been revoked sucessfully or if the client submitted an + invalid token. + + Note: invalid tokens do not cause an error response since the client + cannot handle such an error in a reasonable way. Moreover, the purpose + of the revocation request, invalidating the particular token, is + already achieved. + + The content of the response body is ignored by the client as all + necessary information is conveyed in the response code. + + An invalid token type hint value is ignored by the authorization server + and does not influence the revocation response. + """ + request = Request( + uri, http_method=http_method, body=body, headers=headers) + try: + self.validate_revocation_request(request) + log.debug('Token revocation valid for %r.', request) + except OAuth2Error as e: + log.debug('Client error during validation of %r. %r.', request, e) + response_body = e.json + if self.enable_jsonp and request.callback: + response_body = '%s(%s);' % (request.callback, response_body) + return {}, response_body, e.status_code + + self.request_validator.revoke_token(request.token, + request.token_type_hint, request) + + response_body = None + if self.enable_jsonp and request.callback: + response_body = request.callback + '();' + return {}, response_body, 200 + + def validate_revocation_request(self, request): + """Ensure the request is valid. + + The client constructs the request by including the following parameters + using the "application/x-www-form-urlencoded" format in the HTTP + request entity-body: + + token (REQUIRED). The token that the client wants to get revoked. + + token_type_hint (OPTIONAL). A hint about the type of the token + submitted for revocation. Clients MAY pass this parameter in order to + help the authorization server to optimize the token lookup. If the + server is unable to locate the token using the given hint, it MUST + extend its search accross all of its supported token types. An + authorization server MAY ignore this parameter, particularly if it is + able to detect the token type automatically. This specification + defines two such values: + + * access_token: An Access Token as defined in [RFC6749], + `section 1.4`_ + + * refresh_token: A Refresh Token as defined in [RFC6749], + `section 1.5`_ + + Specific implementations, profiles, and extensions of this + specification MAY define other values for this parameter using + the registry defined in `Section 4.1.2`_. + + The client also includes its authentication credentials as described in + `Section 2.3`_. of [`RFC6749`_]. + + .. _`section 1.4`: http://tools.ietf.org/html/rfc6749#section-1.4 + .. _`section 1.5`: http://tools.ietf.org/html/rfc6749#section-1.5 + .. _`section 2.3`: http://tools.ietf.org/html/rfc6749#section-2.3 + .. _`Section 4.1.2`: http://tools.ietf.org/html/draft-ietf-oauth-revocation-11#section-4.1.2 + .. _`RFC6749`: http://tools.ietf.org/html/rfc6749 + """ + if not request.token: + raise InvalidRequestError(request=request, + description='Missing token parameter.') + + if not self.request_validator.authenticate_client(request): + raise InvalidClientError(request=request) + + if (request.token_type_hint and + request.token_type_hint in self.valid_token_types and + request.token_type_hint not in self.supported_token_types): + raise UnsupportedTokenTypeError(request=request) diff --git a/oauthlib/oauth2/rfc6749/endpoints/token.py b/oauthlib/oauth2/rfc6749/endpoints/token.py new file mode 100644 index 0000000..cda56ad --- /dev/null +++ b/oauthlib/oauth2/rfc6749/endpoints/token.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC6749. +""" +from __future__ import absolute_import, unicode_literals + +import logging + +from oauthlib.common import Request + +from .base import BaseEndpoint, catch_errors_and_unavailability + + +log = logging.getLogger(__name__) + + +class TokenEndpoint(BaseEndpoint): + + """Token issuing endpoint. + + The token endpoint is used by the client to obtain an access token by + presenting its authorization grant or refresh token. The token + endpoint is used with every authorization grant except for the + implicit grant type (since an access token is issued directly). + + The means through which the client obtains the location of the token + endpoint are beyond the scope of this specification, but the location + is typically provided in the service documentation. + + The endpoint URI MAY include an "application/x-www-form-urlencoded" + formatted (per `Appendix B`_) query component, + which MUST be retained when adding additional query parameters. The + endpoint URI MUST NOT include a fragment component:: + + https://example.com/path?query=component # OK + https://example.com/path?query=component#fragment # Not OK + + Since requests to the authorization endpoint result in user + Since requests to the token endpoint result in the transmission of + clear-text credentials (in the HTTP request and response), the + authorization server MUST require the use of TLS as described in + Section 1.6 when sending requests to the token endpoint:: + + # We will deny any request which URI schema is not with https + + The client MUST use the HTTP "POST" method when making access token + requests:: + + # HTTP method is currently not enforced + + Parameters sent without a value MUST be treated as if they were + omitted from the request. The authorization server MUST ignore + unrecognized request parameters. Request and response parameters + MUST NOT be included more than once:: + + # Delegated to each grant type. + + .. _`Appendix B`: http://tools.ietf.org/html/rfc6749#appendix-B + """ + + def __init__(self, default_grant_type, default_token_type, grant_types): + BaseEndpoint.__init__(self) + self._grant_types = grant_types + self._default_token_type = default_token_type + self._default_grant_type = default_grant_type + + @property + def grant_types(self): + return self._grant_types + + @property + def default_grant_type(self): + return self._default_grant_type + + @property + def default_grant_type_handler(self): + return self.grant_types.get(self.default_grant_type) + + @property + def default_token_type(self): + return self._default_token_type + + @catch_errors_and_unavailability + def create_token_response(self, uri, http_method='GET', body=None, + headers=None, credentials=None): + """Extract grant_type and route to the designated handler.""" + request = Request( + uri, http_method=http_method, body=body, headers=headers) + request.scopes = None + request.extra_credentials = credentials + grant_type_handler = self.grant_types.get(request.grant_type, + self.default_grant_type_handler) + log.debug('Dispatching grant_type %s request to %r.', + request.grant_type, grant_type_handler) + return grant_type_handler.create_token_response( + request, self.default_token_type) diff --git a/oauthlib/oauth2/rfc6749/errors.py b/oauthlib/oauth2/rfc6749/errors.py new file mode 100644 index 0000000..a21d0bd --- /dev/null +++ b/oauthlib/oauth2/rfc6749/errors.py @@ -0,0 +1,259 @@ +# coding=utf-8 +""" +oauthlib.oauth2.rfc6749.errors +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Error used both by OAuth 2 clients and provicers to represent the spec +defined error responses for all four core grant types. +""" +from __future__ import unicode_literals +import json +from oauthlib.common import urlencode, add_params_to_uri + + +class OAuth2Error(Exception): + error = None + status_code = 400 + description = '' + + def __init__(self, description=None, uri=None, state=None, status_code=None, + request=None): + """ + description: A human-readable ASCII [USASCII] text providing + additional information, used to assist the client + developer in understanding the error that occurred. + Values for the "error_description" parameter MUST NOT + include characters outside the set + x20-21 / x23-5B / x5D-7E. + + uri: A URI identifying a human-readable web page with information + about the error, used to provide the client developer with + additional information about the error. Values for the + "error_uri" parameter MUST conform to the URI- Reference + syntax, and thus MUST NOT include characters outside the set + x21 / x23-5B / x5D-7E. + + state: A CSRF protection value received from the client. + + request: Oauthlib Request object + """ + self.description = description or self.description + message = '(%s) %s' % (self.error, self.description) + if request: + message += ' ' + repr(request) + super(OAuth2Error, self).__init__(message) + + self.uri = uri + self.state = state + + if status_code: + self.status_code = status_code + + if request: + self.redirect_uri = request.redirect_uri + self.client_id = request.client_id + self.scopes = request.scopes + self.response_type = request.response_type + self.grant_type = request.grant_type + if not state: + self.state = request.state + + def in_uri(self, uri): + return add_params_to_uri(uri, self.twotuples) + + @property + def twotuples(self): + error = [('error', self.error)] + if self.description: + error.append(('error_description', self.description)) + if self.uri: + error.append(('error_uri', self.uri)) + if self.state: + error.append(('state', self.state)) + return error + + @property + def urlencoded(self): + return urlencode(self.twotuples) + + @property + def json(self): + return json.dumps(dict(self.twotuples)) + + +class TokenExpiredError(OAuth2Error): + error = 'token_expired' + + +class InsecureTransportError(OAuth2Error): + error = 'insecure_transport' + description = 'OAuth 2 MUST utilize https.' + + +class MismatchingStateError(OAuth2Error): + error = 'mismatching_state' + description = 'CSRF Warning! State not equal in request and response.' + + +class MissingCodeError(OAuth2Error): + error = 'missing_code' + + +class MissingTokenError(OAuth2Error): + error = 'missing_token' + + +class MissingTokenTypeError(OAuth2Error): + error = 'missing_token_type' + + +class FatalClientError(OAuth2Error): + + """Errors during authorization where user should not be redirected back. + + If the request fails due to a missing, invalid, or mismatching + redirection URI, or if the client identifier is missing or invalid, + the authorization server SHOULD inform the resource owner of the + error and MUST NOT automatically redirect the user-agent to the + invalid redirection URI. + + Instead the user should be informed of the error by the provider itself. + """ + pass + + +class InvalidRedirectURIError(FatalClientError): + error = 'invalid_redirect_uri' + + +class MissingRedirectURIError(FatalClientError): + error = 'missing_redirect_uri' + + +class MismatchingRedirectURIError(FatalClientError): + error = 'mismatching_redirect_uri' + + +class MissingClientIdError(FatalClientError): + error = 'invalid_client_id' + + +class InvalidClientIdError(FatalClientError): + error = 'invalid_client_id' + + +class InvalidRequestError(OAuth2Error): + + """The request is missing a required parameter, includes an invalid + parameter value, includes a parameter more than once, or is + otherwise malformed. + """ + error = 'invalid_request' + + +class AccessDeniedError(OAuth2Error): + + """The resource owner or authorization server denied the request.""" + error = 'access_denied' + status_code = 401 + + +class UnsupportedResponseTypeError(OAuth2Error): + + """The authorization server does not support obtaining an authorization + code using this method. + """ + error = 'unsupported_response_type' + + +class InvalidScopeError(OAuth2Error): + + """The requested scope is invalid, unknown, or malformed.""" + error = 'invalid_scope' + status_code = 401 + + +class ServerError(OAuth2Error): + + """The authorization server encountered an unexpected condition that + prevented it from fulfilling the request. (This error code is needed + because a 500 Internal Server Error HTTP status code cannot be returned + to the client via a HTTP redirect.) + """ + error = 'server_error' + + +class TemporarilyUnavailableError(OAuth2Error): + + """The authorization server is currently unable to handle the request + due to a temporary overloading or maintenance of the server. + (This error code is needed because a 503 Service Unavailable HTTP + status code cannot be returned to the client via a HTTP redirect.) + """ + error = 'temporarily_unavailable' + + +class InvalidClientError(OAuth2Error): + + """Client authentication failed (e.g. unknown client, no client + authentication included, or unsupported authentication method). + The authorization server MAY return an HTTP 401 (Unauthorized) status + code to indicate which HTTP authentication schemes are supported. + If the client attempted to authenticate via the "Authorization" request + header field, the authorization server MUST respond with an + HTTP 401 (Unauthorized) status code, and include the "WWW-Authenticate" + response header field matching the authentication scheme used by the + client. + """ + error = 'invalid_client' + status_code = 401 + + +class InvalidGrantError(OAuth2Error): + + """The provided authorization grant (e.g. authorization code, resource + owner credentials) or refresh token is invalid, expired, revoked, does + not match the redirection URI used in the authorization request, or was + issued to another client. + """ + error = 'invalid_grant' + status_code = 401 + + +class UnauthorizedClientError(OAuth2Error): + + """The authenticated client is not authorized to use this authorization + grant type. + """ + error = 'unauthorized_client' + status_code = 401 + + +class UnsupportedGrantTypeError(OAuth2Error): + + """The authorization grant type is not supported by the authorization + server. + """ + error = 'unsupported_grant_type' + + +class UnsupportedTokenTypeError(OAuth2Error): + + """The authorization server does not support the revocation of the + presented token type. I.e. the client tried to revoke an access token + on a server not supporting this feature. + """ + error = 'unsupported_token_type' + + +def raise_from_error(error, params=None): + import inspect + import sys + kwargs = { + 'description': params.get('error_description'), + 'uri': params.get('error_uri'), + 'state': params.get('state') + } + for _, cls in inspect.getmembers(sys.modules[__name__], inspect.isclass): + if cls.error == error: + raise cls(**kwargs) diff --git a/oauthlib/oauth2/rfc6749/grant_types/__init__.py b/oauthlib/oauth2/rfc6749/grant_types/__init__.py new file mode 100644 index 0000000..2ec8e4f --- /dev/null +++ b/oauthlib/oauth2/rfc6749/grant_types/__init__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +from __future__ import unicode_literals, absolute_import + +from .authorization_code import AuthorizationCodeGrant +from .implicit import ImplicitGrant +from .resource_owner_password_credentials import ResourceOwnerPasswordCredentialsGrant +from .client_credentials import ClientCredentialsGrant +from .refresh_token import RefreshTokenGrant diff --git a/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py b/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py new file mode 100644 index 0000000..b6ff07c --- /dev/null +++ b/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py @@ -0,0 +1,393 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +from __future__ import unicode_literals, absolute_import + +import json +import logging + +from oauthlib import common +from oauthlib.uri_validate import is_absolute_uri + +from .base import GrantTypeBase +from .. import errors +from ..request_validator import RequestValidator + +log = logging.getLogger(__name__) + + +class AuthorizationCodeGrant(GrantTypeBase): + + """`Authorization Code Grant`_ + + The authorization code grant type is used to obtain both access + tokens and refresh tokens and is optimized for confidential clients. + Since this is a redirection-based flow, the client must be capable of + interacting with the resource owner's user-agent (typically a web + browser) and capable of receiving incoming requests (via redirection) + from the authorization server:: + + +----------+ + | Resource | + | Owner | + | | + +----------+ + ^ + | + (B) + +----|-----+ Client Identifier +---------------+ + | -+----(A)-- & Redirection URI ---->| | + | User- | | Authorization | + | Agent -+----(B)-- User authenticates --->| Server | + | | | | + | -+----(C)-- Authorization Code ---<| | + +-|----|---+ +---------------+ + | | ^ v + (A) (C) | | + | | | | + ^ v | | + +---------+ | | + | |>---(D)-- Authorization Code ---------' | + | Client | & Redirection URI | + | | | + | |<---(E)----- Access Token -------------------' + +---------+ (w/ Optional Refresh Token) + + Note: The lines illustrating steps (A), (B), and (C) are broken into + two parts as they pass through the user-agent. + + Figure 3: Authorization Code Flow + + The flow illustrated in Figure 3 includes the following steps: + + (A) The client initiates the flow by directing the resource owner's + user-agent to the authorization endpoint. The client includes + its client identifier, requested scope, local state, and a + redirection URI to which the authorization server will send the + user-agent back once access is granted (or denied). + + (B) The authorization server authenticates the resource owner (via + the user-agent) and establishes whether the resource owner + grants or denies the client's access request. + + (C) Assuming the resource owner grants access, the authorization + server redirects the user-agent back to the client using the + redirection URI provided earlier (in the request or during + client registration). The redirection URI includes an + authorization code and any local state provided by the client + earlier. + + (D) The client requests an access token from the authorization + server's token endpoint by including the authorization code + received in the previous step. When making the request, the + client authenticates with the authorization server. The client + includes the redirection URI used to obtain the authorization + code for verification. + + (E) The authorization server authenticates the client, validates the + authorization code, and ensures that the redirection URI + received matches the URI used to redirect the client in + step (C). If valid, the authorization server responds back with + an access token and, optionally, a refresh token. + + .. _`Authorization Code Grant`: http://tools.ietf.org/html/rfc6749#section-4.1 + """ + + def __init__(self, request_validator=None): + self.request_validator = request_validator or RequestValidator() + + def create_authorization_code(self, request): + """Generates an authorization grant represented as a dictionary.""" + grant = {'code': common.generate_token()} + if hasattr(request, 'state') and request.state: + grant['state'] = request.state + log.debug('Created authorization code grant %r for request %r.', + grant, request) + return grant + + def create_authorization_response(self, request, token_handler): + """ + The client constructs the request URI by adding the following + parameters to the query component of the authorization endpoint URI + using the "application/x-www-form-urlencoded" format, per `Appendix B`_: + + response_type + REQUIRED. Value MUST be set to "code". + client_id + REQUIRED. The client identifier as described in `Section 2.2`_. + redirect_uri + OPTIONAL. As described in `Section 3.1.2`_. + scope + OPTIONAL. The scope of the access request as described by + `Section 3.3`_. + state + RECOMMENDED. An opaque value used by the client to maintain + state between the request and callback. The authorization + server includes this value when redirecting the user-agent back + to the client. The parameter SHOULD be used for preventing + cross-site request forgery as described in `Section 10.12`_. + + The client directs the resource owner to the constructed URI using an + HTTP redirection response, or by other means available to it via the + user-agent. + + :param request: oauthlib.commong.Request + :param token_handler: A token handler instace, for example of type + oauthlib.oauth2.BearerToken. + :returns: headers, body, status + :raises: FatalClientError on invalid redirect URI or client id. + ValueError if scopes are not set on the request object. + + A few examples:: + + >>> from your_validator import your_validator + >>> request = Request('https://example.com/authorize?client_id=valid' + ... '&redirect_uri=http%3A%2F%2Fclient.com%2F') + >>> from oauthlib.common import Request + >>> from oauthlib.oauth2 import AuthorizationCodeGrant, BearerToken + >>> token = BearerToken(your_validator) + >>> grant = AuthorizationCodeGrant(your_validator) + >>> grant.create_authorization_response(request, token) + Traceback (most recent call last): + File "", line 1, in + File "oauthlib/oauth2/rfc6749/grant_types.py", line 513, in create_authorization_response + raise ValueError('Scopes must be set on post auth.') + ValueError: Scopes must be set on post auth. + >>> request.scopes = ['authorized', 'in', 'some', 'form'] + >>> grant.create_authorization_response(request, token) + (u'http://client.com/?error=invalid_request&error_description=Missing+response_type+parameter.', None, None, 400) + >>> request = Request('https://example.com/authorize?client_id=valid' + ... '&redirect_uri=http%3A%2F%2Fclient.com%2F' + ... '&response_type=code') + >>> request.scopes = ['authorized', 'in', 'some', 'form'] + >>> grant.create_authorization_response(request, token) + (u'http://client.com/?code=u3F05aEObJuP2k7DordviIgW5wl52N', None, None, 200) + >>> # If the client id or redirect uri fails validation + >>> grant.create_authorization_response(request, token) + Traceback (most recent call last): + File "", line 1, in + File "oauthlib/oauth2/rfc6749/grant_types.py", line 515, in create_authorization_response + >>> grant.create_authorization_response(request, token) + File "oauthlib/oauth2/rfc6749/grant_types.py", line 591, in validate_authorization_request + oauthlib.oauth2.rfc6749.errors.InvalidClientIdError + + .. _`Appendix B`: http://tools.ietf.org/html/rfc6749#appendix-B + .. _`Section 2.2`: http://tools.ietf.org/html/rfc6749#section-2.2 + .. _`Section 3.1.2`: http://tools.ietf.org/html/rfc6749#section-3.1.2 + .. _`Section 3.3`: http://tools.ietf.org/html/rfc6749#section-3.3 + .. _`Section 10.12`: http://tools.ietf.org/html/rfc6749#section-10.12 + """ + try: + # request.scopes is only mandated in post auth and both pre and + # post auth use validate_authorization_request + if not request.scopes: + raise ValueError('Scopes must be set on post auth.') + + self.validate_authorization_request(request) + log.debug('Pre resource owner authorization validation ok for %r.', + request) + + # If the request fails due to a missing, invalid, or mismatching + # redirection URI, or if the client identifier is missing or invalid, + # the authorization server SHOULD inform the resource owner of the + # error and MUST NOT automatically redirect the user-agent to the + # invalid redirection URI. + except errors.FatalClientError as e: + log.debug('Fatal client error during validation of %r. %r.', + request, e) + raise + + # If the resource owner denies the access request or if the request + # fails for reasons other than a missing or invalid redirection URI, + # the authorization server informs the client by adding the following + # parameters to the query component of the redirection URI using the + # "application/x-www-form-urlencoded" format, per Appendix B: + # http://tools.ietf.org/html/rfc6749#appendix-B + except errors.OAuth2Error as e: + log.debug('Client error during validation of %r. %r.', request, e) + request.redirect_uri = request.redirect_uri or self.error_uri + return {'Location': common.add_params_to_uri(request.redirect_uri, e.twotuples)}, None, 302 + + grant = self.create_authorization_code(request) + log.debug('Saving grant %r for %r.', grant, request) + self.request_validator.save_authorization_code( + request.client_id, grant, request) + return {'Location': common.add_params_to_uri(request.redirect_uri, grant.items())}, None, 302 + + def create_token_response(self, request, token_handler): + """Validate the authorization code. + + The client MUST NOT use the authorization code more than once. If an + authorization code is used more than once, the authorization server + MUST deny the request and SHOULD revoke (when possible) all tokens + previously issued based on that authorization code. The authorization + code is bound to the client identifier and redirection URI. + """ + headers = { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Pragma': 'no-cache', + } + try: + self.validate_token_request(request) + log.debug('Token request validation ok for %r.', request) + except errors.OAuth2Error as e: + log.debug('Client error during validation of %r. %r.', request, e) + return headers, e.json, e.status_code + + token = token_handler.create_token(request, refresh_token=True) + self.request_validator.invalidate_authorization_code( + request.client_id, request.code, request) + return headers, json.dumps(token), 200 + + def validate_authorization_request(self, request): + """Check the authorization request for normal and fatal errors. + + A normal error could be a missing response_type parameter or the client + attempting to access scope it is not allowed to ask authorization for. + Normal errors can safely be included in the redirection URI and + sent back to the client. + + Fatal errors occur when the client_id or redirect_uri is invalid or + missing. These must be caught by the provider and handled, how this + is done is outside of the scope of OAuthLib but showing an error + page describing the issue is a good idea. + """ + + # First check for fatal errors + + # If the request fails due to a missing, invalid, or mismatching + # redirection URI, or if the client identifier is missing or invalid, + # the authorization server SHOULD inform the resource owner of the + # error and MUST NOT automatically redirect the user-agent to the + # invalid redirection URI. + + # REQUIRED. The client identifier as described in Section 2.2. + # http://tools.ietf.org/html/rfc6749#section-2.2 + if not request.client_id: + raise errors.MissingClientIdError(request=request) + + if not self.request_validator.validate_client_id(request.client_id, request): + raise errors.InvalidClientIdError(request=request) + + # OPTIONAL. As described in Section 3.1.2. + # http://tools.ietf.org/html/rfc6749#section-3.1.2 + log.debug('Validating redirection uri %s for client %s.', + request.redirect_uri, request.client_id) + if request.redirect_uri is not None: + request.using_default_redirect_uri = False + log.debug('Using provided redirect_uri %s', request.redirect_uri) + if not is_absolute_uri(request.redirect_uri): + raise errors.InvalidRedirectURIError(request=request) + + if not self.request_validator.validate_redirect_uri( + request.client_id, request.redirect_uri, request): + raise errors.MismatchingRedirectURIError(request=request) + else: + request.redirect_uri = self.request_validator.get_default_redirect_uri( + request.client_id, request) + request.using_default_redirect_uri = True + log.debug('Using default redirect_uri %s.', request.redirect_uri) + if not request.redirect_uri: + raise errors.MissingRedirectURIError(request=request) + + # Then check for normal errors. + + # If the resource owner denies the access request or if the request + # fails for reasons other than a missing or invalid redirection URI, + # the authorization server informs the client by adding the following + # parameters to the query component of the redirection URI using the + # "application/x-www-form-urlencoded" format, per Appendix B. + # http://tools.ietf.org/html/rfc6749#appendix-B + + # Note that the correct parameters to be added are automatically + # populated through the use of specific exceptions. + if request.response_type is None: + raise errors.InvalidRequestError(description='Missing response_type parameter.', request=request) + + for param in ('client_id', 'response_type', 'redirect_uri', 'scope', 'state'): + if param in request.duplicate_params: + raise errors.InvalidRequestError(description='Duplicate %s parameter.' % param, request=request) + + if not self.request_validator.validate_response_type(request.client_id, + request.response_type, request.client, request): + log.debug('Client %s is not authorized to use response_type %s.', + request.client_id, request.response_type) + raise errors.UnauthorizedClientError(request=request) + + # REQUIRED. Value MUST be set to "code". + if request.response_type != 'code': + raise errors.UnsupportedResponseTypeError(request=request) + + # OPTIONAL. The scope of the access request as described by Section 3.3 + # http://tools.ietf.org/html/rfc6749#section-3.3 + self.validate_scopes(request) + + return request.scopes, { + 'client_id': request.client_id, + 'redirect_uri': request.redirect_uri, + 'response_type': request.response_type, + 'state': request.state, + 'request': request, + } + + def validate_token_request(self, request): + # REQUIRED. Value MUST be set to "authorization_code". + if request.grant_type != 'authorization_code': + raise errors.UnsupportedGrantTypeError(request=request) + + if request.code is None: + raise errors.InvalidRequestError( + description='Missing code parameter.', request=request) + + for param in ('client_id', 'grant_type', 'redirect_uri'): + if param in request.duplicate_params: + raise errors.InvalidRequestError(description='Duplicate %s parameter.' % param, + request=request) + + if self.request_validator.client_authentication_required(request): + # If the client type is confidential or the client was issued client + # credentials (or assigned other authentication requirements), the + # client MUST authenticate with the authorization server as described + # in Section 3.2.1. + # http://tools.ietf.org/html/rfc6749#section-3.2.1 + if not self.request_validator.authenticate_client(request): + log.debug('Client authentication failed, %r.', request) + raise errors.InvalidClientError(request=request) + elif not self.request_validator.authenticate_client_id(request.client_id, request): + # REQUIRED, if the client is not authenticating with the + # authorization server as described in Section 3.2.1. + # http://tools.ietf.org/html/rfc6749#section-3.2.1 + log.debug('Client authentication failed, %r.', request) + raise errors.InvalidClientError(request=request) + + if not hasattr(request.client, 'client_id'): + raise NotImplementedError('Authenticate client must set the ' + 'request.client.client_id attribute ' + 'in authenticate_client.') + + # Ensure client is authorized use of this grant type + self.validate_grant_type(request) + + # REQUIRED. The authorization code received from the + # authorization server. + if not self.request_validator.validate_code(request.client_id, + request.code, request.client, request): + log.debug('Client, %r (%r), is not allowed access to scopes %r.', + request.client_id, request.client, request.scopes) + raise errors.InvalidGrantError(request=request) + + for attr in ('user', 'state', 'scopes'): + if getattr(request, attr) is None: + log.debug('request.%s was not set on code validation.', attr) + + # REQUIRED, if the "redirect_uri" parameter was included in the + # authorization request as described in Section 4.1.1, and their + # values MUST be identical. + if not self.request_validator.confirm_redirect_uri(request.client_id, request.code, + request.redirect_uri, request.client): + log.debug('Redirect_uri (%r) invalid for client %r (%r).', + request.redirect_uri, request.client_id, request.client) + raise errors.AccessDeniedError(request=request) diff --git a/oauthlib/oauth2/rfc6749/grant_types/base.py b/oauthlib/oauth2/rfc6749/grant_types/base.py new file mode 100644 index 0000000..4a8017f --- /dev/null +++ b/oauthlib/oauth2/rfc6749/grant_types/base.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +from __future__ import unicode_literals, absolute_import + +import logging + +from oauthlib.oauth2.rfc6749 import errors, utils + +log = logging.getLogger(__name__) + + +class GrantTypeBase(object): + error_uri = None + request_validator = None + + def create_authorization_response(self, request, token_handler): + raise NotImplementedError('Subclasses must implement this method.') + + def create_token_response(self, request, token_handler): + raise NotImplementedError('Subclasses must implement this method.') + + def validate_grant_type(self, request): + if not self.request_validator.validate_grant_type(request.client_id, + request.grant_type, request.client, request): + log.debug('Unauthorized from %r (%r) access to grant type %s.', + request.client_id, request.client, request.grant_type) + raise errors.UnauthorizedClientError(request=request) + + def validate_scopes(self, request): + if not request.scopes: + request.scopes = utils.scope_to_list(request.scope) or utils.scope_to_list( + self.request_validator.get_default_scopes(request.client_id, request)) + log.debug('Validating access to scopes %r for client %r (%r).', + request.scopes, request.client_id, request.client) + if not self.request_validator.validate_scopes(request.client_id, + request.scopes, request.client, request): + raise errors.InvalidScopeError(request=request) diff --git a/oauthlib/oauth2/rfc6749/grant_types/client_credentials.py b/oauthlib/oauth2/rfc6749/grant_types/client_credentials.py new file mode 100644 index 0000000..30df247 --- /dev/null +++ b/oauthlib/oauth2/rfc6749/grant_types/client_credentials.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +from __future__ import unicode_literals, absolute_import + +import json +import logging + +from .base import GrantTypeBase +from .. import errors +from ..request_validator import RequestValidator + +log = logging.getLogger(__name__) + + +class ClientCredentialsGrant(GrantTypeBase): + + """`Client Credentials Grant`_ + + The client can request an access token using only its client + credentials (or other supported means of authentication) when the + client is requesting access to the protected resources under its + control, or those of another resource owner that have been previously + arranged with the authorization server (the method of which is beyond + the scope of this specification). + + The client credentials grant type MUST only be used by confidential + clients:: + + +---------+ +---------------+ + : : : : + : :>-- A - Client Authentication --->: Authorization : + : Client : : Server : + : :<-- B ---- Access Token ---------<: : + : : : : + +---------+ +---------------+ + + Figure 6: Client Credentials Flow + + The flow illustrated in Figure 6 includes the following steps: + + (A) The client authenticates with the authorization server and + requests an access token from the token endpoint. + + (B) The authorization server authenticates the client, and if valid, + issues an access token. + + .. _`Client Credentials Grant`: http://tools.ietf.org/html/rfc6749#section-4.4 + """ + + def __init__(self, request_validator=None): + self.request_validator = request_validator or RequestValidator() + + def create_token_response(self, request, token_handler): + """Return token or error in JSON format. + + If the access token request is valid and authorized, the + authorization server issues an access token as described in + `Section 5.1`_. A refresh token SHOULD NOT be included. If the request + failed client authentication or is invalid, the authorization server + returns an error response as described in `Section 5.2`_. + + .. _`Section 5.1`: http://tools.ietf.org/html/rfc6749#section-5.1 + .. _`Section 5.2`: http://tools.ietf.org/html/rfc6749#section-5.2 + """ + headers = { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Pragma': 'no-cache', + } + try: + log.debug('Validating access token request, %r.', request) + self.validate_token_request(request) + except errors.OAuth2Error as e: + log.debug('Client error in token request. %s.', e) + return headers, e.json, e.status_code + + token = token_handler.create_token(request, refresh_token=False) + log.debug('Issuing token to client id %r (%r), %r.', + request.client_id, request.client, token) + return headers, json.dumps(token), 200 + + def validate_token_request(self, request): + if not getattr(request, 'grant_type'): + raise errors.InvalidRequestError('Request is missing grant type.', + request=request) + + if not request.grant_type == 'client_credentials': + raise errors.UnsupportedGrantTypeError(request=request) + + for param in ('grant_type', 'scope'): + if param in request.duplicate_params: + raise errors.InvalidRequestError(description='Duplicate %s parameter.' % param, + request=request) + + log.debug('Authenticating client, %r.', request) + if not self.request_validator.authenticate_client(request): + log.debug('Client authentication failed, %r.', request) + raise errors.InvalidClientError(request=request) + else: + if not hasattr(request.client, 'client_id'): + raise NotImplementedError('Authenticate client must set the ' + 'request.client.client_id attribute ' + 'in authenticate_client.') + # Ensure client is authorized use of this grant type + self.validate_grant_type(request) + + log.debug('Authorizing access to user %r.', request.user) + request.client_id = request.client_id or request.client.client_id + self.validate_scopes(request) diff --git a/oauthlib/oauth2/rfc6749/grant_types/implicit.py b/oauthlib/oauth2/rfc6749/grant_types/implicit.py new file mode 100644 index 0000000..27bcb24 --- /dev/null +++ b/oauthlib/oauth2/rfc6749/grant_types/implicit.py @@ -0,0 +1,338 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +from __future__ import unicode_literals, absolute_import + +import logging + +from oauthlib import common +from oauthlib.uri_validate import is_absolute_uri + +from .base import GrantTypeBase +from .. import errors +from ..request_validator import RequestValidator + +log = logging.getLogger(__name__) + + +class ImplicitGrant(GrantTypeBase): + + """`Implicit Grant`_ + + The implicit grant type is used to obtain access tokens (it does not + support the issuance of refresh tokens) and is optimized for public + clients known to operate a particular redirection URI. These clients + are typically implemented in a browser using a scripting language + such as JavaScript. + + Unlike the authorization code grant type, in which the client makes + separate requests for authorization and for an access token, the + client receives the access token as the result of the authorization + request. + + The implicit grant type does not include client authentication, and + relies on the presence of the resource owner and the registration of + the redirection URI. Because the access token is encoded into the + redirection URI, it may be exposed to the resource owner and other + applications residing on the same device:: + + +----------+ + | Resource | + | Owner | + | | + +----------+ + ^ + | + (B) + +----|-----+ Client Identifier +---------------+ + | -+----(A)-- & Redirection URI --->| | + | User- | | Authorization | + | Agent -|----(B)-- User authenticates -->| Server | + | | | | + | |<---(C)--- Redirection URI ----<| | + | | with Access Token +---------------+ + | | in Fragment + | | +---------------+ + | |----(D)--- Redirection URI ---->| Web-Hosted | + | | without Fragment | Client | + | | | Resource | + | (F) |<---(E)------- Script ---------<| | + | | +---------------+ + +-|--------+ + | | + (A) (G) Access Token + | | + ^ v + +---------+ + | | + | Client | + | | + +---------+ + + Note: The lines illustrating steps (A) and (B) are broken into two + parts as they pass through the user-agent. + + Figure 4: Implicit Grant Flow + + The flow illustrated in Figure 4 includes the following steps: + + (A) The client initiates the flow by directing the resource owner's + user-agent to the authorization endpoint. The client includes + its client identifier, requested scope, local state, and a + redirection URI to which the authorization server will send the + user-agent back once access is granted (or denied). + + (B) The authorization server authenticates the resource owner (via + the user-agent) and establishes whether the resource owner + grants or denies the client's access request. + + (C) Assuming the resource owner grants access, the authorization + server redirects the user-agent back to the client using the + redirection URI provided earlier. The redirection URI includes + the access token in the URI fragment. + + (D) The user-agent follows the redirection instructions by making a + request to the web-hosted client resource (which does not + include the fragment per [RFC2616]). The user-agent retains the + fragment information locally. + + (E) The web-hosted client resource returns a web page (typically an + HTML document with an embedded script) capable of accessing the + full redirection URI including the fragment retained by the + user-agent, and extracting the access token (and other + parameters) contained in the fragment. + + (F) The user-agent executes the script provided by the web-hosted + client resource locally, which extracts the access token. + + (G) The user-agent passes the access token to the client. + + See `Section 10.3`_ and `Section 10.16`_ for important security considerations + when using the implicit grant. + + .. _`Implicit Grant`: http://tools.ietf.org/html/rfc6749#section-4.2 + .. _`Section 10.3`: http://tools.ietf.org/html/rfc6749#section-10.3 + .. _`Section 10.16`: http://tools.ietf.org/html/rfc6749#section-10.16 + """ + + def __init__(self, request_validator=None): + self.request_validator = request_validator or RequestValidator() + + def create_authorization_response(self, request, token_handler): + """Create an authorization response. + The client constructs the request URI by adding the following + parameters to the query component of the authorization endpoint URI + using the "application/x-www-form-urlencoded" format, per `Appendix B`_: + + response_type + REQUIRED. Value MUST be set to "token". + + client_id + REQUIRED. The client identifier as described in `Section 2.2`_. + + redirect_uri + OPTIONAL. As described in `Section 3.1.2`_. + + scope + OPTIONAL. The scope of the access request as described by + `Section 3.3`_. + + state + RECOMMENDED. An opaque value used by the client to maintain + state between the request and callback. The authorization + server includes this value when redirecting the user-agent back + to the client. The parameter SHOULD be used for preventing + cross-site request forgery as described in `Section 10.12`_. + + The authorization server validates the request to ensure that all + required parameters are present and valid. The authorization server + MUST verify that the redirection URI to which it will redirect the + access token matches a redirection URI registered by the client as + described in `Section 3.1.2`_. + + .. _`Section 2.2`: http://tools.ietf.org/html/rfc6749#section-2.2 + .. _`Section 3.1.2`: http://tools.ietf.org/html/rfc6749#section-3.1.2 + .. _`Section 3.3`: http://tools.ietf.org/html/rfc6749#section-3.3 + .. _`Section 10.12`: http://tools.ietf.org/html/rfc6749#section-10.12 + .. _`Appendix B`: http://tools.ietf.org/html/rfc6749#appendix-B + """ + return self.create_token_response(request, token_handler) + + def create_token_response(self, request, token_handler): + """Return token or error embedded in the URI fragment. + + If the resource owner grants the access request, the authorization + server issues an access token and delivers it to the client by adding + the following parameters to the fragment component of the redirection + URI using the "application/x-www-form-urlencoded" format, per + `Appendix B`_: + + access_token + REQUIRED. The access token issued by the authorization server. + + token_type + REQUIRED. The type of the token issued as described in + `Section 7.1`_. Value is case insensitive. + + expires_in + RECOMMENDED. The lifetime in seconds of the access token. For + example, the value "3600" denotes that the access token will + expire in one hour from the time the response was generated. + If omitted, the authorization server SHOULD provide the + expiration time via other means or document the default value. + + scope + OPTIONAL, if identical to the scope requested by the client; + otherwise, REQUIRED. The scope of the access token as + described by `Section 3.3`_. + + state + REQUIRED if the "state" parameter was present in the client + authorization request. The exact value received from the + client. + + The authorization server MUST NOT issue a refresh token. + + .. _`Appendix B`: http://tools.ietf.org/html/rfc6749#appendix-B + .. _`Section 3.3`: http://tools.ietf.org/html/rfc6749#section-3.3 + .. _`Section 7.1`: http://tools.ietf.org/html/rfc6749#section-7.1 + """ + try: + # request.scopes is only mandated in post auth and both pre and + # post auth use validate_authorization_request + if not request.scopes: + raise ValueError('Scopes must be set on post auth.') + + self.validate_token_request(request) + + # If the request fails due to a missing, invalid, or mismatching + # redirection URI, or if the client identifier is missing or invalid, + # the authorization server SHOULD inform the resource owner of the + # error and MUST NOT automatically redirect the user-agent to the + # invalid redirection URI. + except errors.FatalClientError as e: + log.debug('Fatal client error during validation of %r. %r.', + request, e) + raise + + # If the resource owner denies the access request or if the request + # fails for reasons other than a missing or invalid redirection URI, + # the authorization server informs the client by adding the following + # parameters to the fragment component of the redirection URI using the + # "application/x-www-form-urlencoded" format, per Appendix B: + # http://tools.ietf.org/html/rfc6749#appendix-B + except errors.OAuth2Error as e: + log.debug('Client error during validation of %r. %r.', request, e) + return {'Location': common.add_params_to_uri(request.redirect_uri, e.twotuples, + fragment=True)}, None, 302 + + token = token_handler.create_token(request, refresh_token=False) + return {'Location': common.add_params_to_uri(request.redirect_uri, token.items(), + fragment=True)}, None, 302 + + def validate_authorization_request(self, request): + return self.validate_token_request(request) + + def validate_token_request(self, request): + """Check the token request for normal and fatal errors. + + This method is very similar to validate_authorization_request in + the AuthorizationCodeGrant but differ in a few subtle areas. + + A normal error could be a missing response_type parameter or the client + attempting to access scope it is not allowed to ask authorization for. + Normal errors can safely be included in the redirection URI and + sent back to the client. + + Fatal errors occur when the client_id or redirect_uri is invalid or + missing. These must be caught by the provider and handled, how this + is done is outside of the scope of OAuthLib but showing an error + page describing the issue is a good idea. + """ + + # First check for fatal errors + + # If the request fails due to a missing, invalid, or mismatching + # redirection URI, or if the client identifier is missing or invalid, + # the authorization server SHOULD inform the resource owner of the + # error and MUST NOT automatically redirect the user-agent to the + # invalid redirection URI. + + # REQUIRED. The client identifier as described in Section 2.2. + # http://tools.ietf.org/html/rfc6749#section-2.2 + if not request.client_id: + raise errors.MissingClientIdError(request=request) + + if not self.request_validator.validate_client_id(request.client_id, request): + raise errors.InvalidClientIdError(request=request) + + # OPTIONAL. As described in Section 3.1.2. + # http://tools.ietf.org/html/rfc6749#section-3.1.2 + if request.redirect_uri is not None: + request.using_default_redirect_uri = False + log.debug('Using provided redirect_uri %s', request.redirect_uri) + if not is_absolute_uri(request.redirect_uri): + raise errors.InvalidRedirectURIError(request=request) + + # The authorization server MUST verify that the redirection URI + # to which it will redirect the access token matches a + # redirection URI registered by the client as described in + # Section 3.1.2. + # http://tools.ietf.org/html/rfc6749#section-3.1.2 + if not self.request_validator.validate_redirect_uri( + request.client_id, request.redirect_uri, request): + raise errors.MismatchingRedirectURIError(request=request) + else: + request.redirect_uri = self.request_validator.get_default_redirect_uri( + request.client_id, request) + request.using_default_redirect_uri = True + log.debug('Using default redirect_uri %s.', request.redirect_uri) + if not request.redirect_uri: + raise errors.MissingRedirectURIError(request=request) + if not is_absolute_uri(request.redirect_uri): + raise errors.InvalidRedirectURIError(request=request) + + # Then check for normal errors. + + # If the resource owner denies the access request or if the request + # fails for reasons other than a missing or invalid redirection URI, + # the authorization server informs the client by adding the following + # parameters to the fragment component of the redirection URI using the + # "application/x-www-form-urlencoded" format, per Appendix B. + # http://tools.ietf.org/html/rfc6749#appendix-B + + # Note that the correct parameters to be added are automatically + # populated through the use of specific exceptions. + if request.response_type is None: + raise errors.InvalidRequestError(description='Missing response_type parameter.', + request=request) + + for param in ('client_id', 'response_type', 'redirect_uri', 'scope', 'state'): + if param in request.duplicate_params: + raise errors.InvalidRequestError(description='Duplicate %s parameter.' % param, request=request) + + # REQUIRED. Value MUST be set to "token". + if request.response_type != 'token': + raise errors.UnsupportedResponseTypeError(request=request) + + log.debug('Validating use of response_type token for client %r (%r).', + request.client_id, request.client) + if not self.request_validator.validate_response_type(request.client_id, + request.response_type, request.client, request): + log.debug('Client %s is not authorized to use response_type %s.', + request.client_id, request.response_type) + raise errors.UnauthorizedClientError(request=request) + + # OPTIONAL. The scope of the access request as described by Section 3.3 + # http://tools.ietf.org/html/rfc6749#section-3.3 + self.validate_scopes(request) + + return request.scopes, { + 'client_id': request.client_id, + 'redirect_uri': request.redirect_uri, + 'response_type': request.response_type, + 'state': request.state, + 'request': request, + } diff --git a/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py b/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py new file mode 100644 index 0000000..0ab10c9 --- /dev/null +++ b/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +from __future__ import unicode_literals, absolute_import + +import json +import logging + +from .base import GrantTypeBase +from .. import errors, utils +from ..request_validator import RequestValidator + +log = logging.getLogger(__name__) + + +class RefreshTokenGrant(GrantTypeBase): + + """`Refresh token grant`_ + + .. _`Refresh token grant`: http://tools.ietf.org/html/rfc6749#section-6 + """ + + @property + def issue_new_refresh_tokens(self): + return True + + def __init__(self, request_validator=None, issue_new_refresh_tokens=True): + self.request_validator = request_validator or RequestValidator() + + def create_token_response(self, request, token_handler): + """Create a new access token from a refresh_token. + + If valid and authorized, the authorization server issues an access + token as described in `Section 5.1`_. If the request failed + verification or is invalid, the authorization server returns an error + response as described in `Section 5.2`_. + + The authorization server MAY issue a new refresh token, in which case + the client MUST discard the old refresh token and replace it with the + new refresh token. The authorization server MAY revoke the old + refresh token after issuing a new refresh token to the client. If a + new refresh token is issued, the refresh token scope MUST be + identical to that of the refresh token included by the client in the + request. + + .. _`Section 5.1`: http://tools.ietf.org/html/rfc6749#section-5.1 + .. _`Section 5.2`: http://tools.ietf.org/html/rfc6749#section-5.2 + """ + headers = { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Pragma': 'no-cache', + } + try: + log.debug('Validating refresh token request, %r.', request) + self.validate_token_request(request) + except errors.OAuth2Error as e: + return headers, e.json, e.status_code + + token = token_handler.create_token(request, + refresh_token=self.issue_new_refresh_tokens) + log.debug('Issuing new token to client id %r (%r), %r.', + request.client_id, request.client, token) + return headers, json.dumps(token), 200 + + def validate_token_request(self, request): + # REQUIRED. Value MUST be set to "refresh_token". + if request.grant_type != 'refresh_token': + raise errors.UnsupportedGrantTypeError(request=request) + + if request.refresh_token is None: + raise errors.InvalidRequestError( + description='Missing refresh token parameter.', + request=request) + + # Because refresh tokens are typically long-lasting credentials used to + # request additional access tokens, the refresh token is bound to the + # client to which it was issued. If the client type is confidential or + # the client was issued client credentials (or assigned other + # authentication requirements), the client MUST authenticate with the + # authorization server as described in Section 3.2.1. + # http://tools.ietf.org/html/rfc6749#section-3.2.1 + if self.request_validator.client_authentication_required(request): + log.debug('Authenticating client, %r.', request) + if not self.request_validator.authenticate_client(request): + log.debug('Invalid client (%r), denying access.', request) + raise errors.InvalidClientError(request=request) + elif not self.request_validator.authenticate_client_id(request.client_id, request): + log.debug('Client authentication failed, %r.', request) + raise errors.InvalidClientError(request=request) + + # Ensure client is authorized use of this grant type + self.validate_grant_type(request) + + # REQUIRED. The refresh token issued to the client. + log.debug('Validating refresh token %s for client %r.', + request.refresh_token, request.client) + if not self.request_validator.validate_refresh_token( + request.refresh_token, request.client, request): + log.debug('Invalid refresh token, %s, for client %r.', + request.refresh_token, request.client) + raise errors.InvalidGrantError(request=request) + + original_scopes = utils.scope_to_list( + self.request_validator.get_original_scopes( + request.refresh_token, request)) + + if request.scope: + request.scopes = utils.scope_to_list(request.scope) + if (not all((s in original_scopes for s in request.scopes)) + and not self.request_validator.is_within_original_scope( + request.scopes, request.refresh_token, request)): + log.debug('Refresh token %s lack requested scopes, %r.', + request.refresh_token, request.scopes) + raise errors.InvalidScopeError(request=request) + else: + request.scopes = original_scopes diff --git a/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py b/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py new file mode 100644 index 0000000..c19e6cf --- /dev/null +++ b/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py @@ -0,0 +1,194 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +from __future__ import unicode_literals, absolute_import + +import json +import logging + +from .base import GrantTypeBase +from .. import errors +from ..request_validator import RequestValidator + +log = logging.getLogger(__name__) + + +class ResourceOwnerPasswordCredentialsGrant(GrantTypeBase): + + """`Resource Owner Password Credentials Grant`_ + + The resource owner password credentials grant type is suitable in + cases where the resource owner has a trust relationship with the + client, such as the device operating system or a highly privileged + application. The authorization server should take special care when + enabling this grant type and only allow it when other flows are not + viable. + + This grant type is suitable for clients capable of obtaining the + resource owner's credentials (username and password, typically using + an interactive form). It is also used to migrate existing clients + using direct authentication schemes such as HTTP Basic or Digest + authentication to OAuth by converting the stored credentials to an + access token:: + + +----------+ + | Resource | + | Owner | + | | + +----------+ + v + | Resource Owner + (A) Password Credentials + | + v + +---------+ +---------------+ + | |>--(B)---- Resource Owner ------->| | + | | Password Credentials | Authorization | + | Client | | Server | + | |<--(C)---- Access Token ---------<| | + | | (w/ Optional Refresh Token) | | + +---------+ +---------------+ + + Figure 5: Resource Owner Password Credentials Flow + + The flow illustrated in Figure 5 includes the following steps: + + (A) The resource owner provides the client with its username and + password. + + (B) The client requests an access token from the authorization + server's token endpoint by including the credentials received + from the resource owner. When making the request, the client + authenticates with the authorization server. + + (C) The authorization server authenticates the client and validates + the resource owner credentials, and if valid, issues an access + token. + + .. _`Resource Owner Password Credentials Grant`: http://tools.ietf.org/html/rfc6749#section-4.3 + """ + + def __init__(self, request_validator=None, refresh_token=True): + """ + If the refresh_token keyword argument is False, do not return + a refresh token in the response. + """ + self.request_validator = request_validator or RequestValidator() + self.refresh_token = refresh_token + + def create_token_response(self, request, token_handler): + """Return token or error in json format. + + If the access token request is valid and authorized, the + authorization server issues an access token and optional refresh + token as described in `Section 5.1`_. If the request failed client + authentication or is invalid, the authorization server returns an + error response as described in `Section 5.2`_. + + .. _`Section 5.1`: http://tools.ietf.org/html/rfc6749#section-5.1 + .. _`Section 5.2`: http://tools.ietf.org/html/rfc6749#section-5.2 + """ + headers = { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Pragma': 'no-cache', + } + try: + if self.request_validator.client_authentication_required(request): + log.debug('Authenticating client, %r.', request) + if not self.request_validator.authenticate_client(request): + log.debug('Client authentication failed, %r.', request) + raise errors.InvalidClientError(request=request) + elif not self.request_validator.authenticate_client_id(request.client_id, request): + log.debug('Client authentication failed, %r.', request) + raise errors.InvalidClientError(request=request) + log.debug('Validating access token request, %r.', request) + self.validate_token_request(request) + except errors.OAuth2Error as e: + log.debug('Client error in token request, %s.', e) + return headers, e.json, e.status_code + + token = token_handler.create_token(request, self.refresh_token) + log.debug('Issuing token %r to client id %r (%r) and username %s.', + token, request.client_id, request.client, request.username) + return headers, json.dumps(token), 200 + + def validate_token_request(self, request): + """ + The client makes a request to the token endpoint by adding the + following parameters using the "application/x-www-form-urlencoded" + format per Appendix B with a character encoding of UTF-8 in the HTTP + request entity-body: + + grant_type + REQUIRED. Value MUST be set to "password". + + username + REQUIRED. The resource owner username. + + password + REQUIRED. The resource owner password. + + scope + OPTIONAL. The scope of the access request as described by + `Section 3.3`_. + + If the client type is confidential or the client was issued client + credentials (or assigned other authentication requirements), the + client MUST authenticate with the authorization server as described + in `Section 3.2.1`_. + + The authorization server MUST: + + o require client authentication for confidential clients or for any + client that was issued client credentials (or with other + authentication requirements), + + o authenticate the client if client authentication is included, and + + o validate the resource owner password credentials using its + existing password validation algorithm. + + Since this access token request utilizes the resource owner's + password, the authorization server MUST protect the endpoint against + brute force attacks (e.g., using rate-limitation or generating + alerts). + + .. _`Section 3.3`: http://tools.ietf.org/html/rfc6749#section-3.3 + .. _`Section 3.2.1`: http://tools.ietf.org/html/rfc6749#section-3.2.1 + """ + for param in ('grant_type', 'username', 'password'): + if not getattr(request, param): + raise errors.InvalidRequestError( + 'Request is missing %s parameter.' % param, request=request) + + for param in ('grant_type', 'username', 'password', 'scope'): + if param in request.duplicate_params: + raise errors.InvalidRequestError(description='Duplicate %s parameter.' % param, request=request) + + # This error should rarely (if ever) occur if requests are routed to + # grant type handlers based on the grant_type parameter. + if not request.grant_type == 'password': + raise errors.UnsupportedGrantTypeError(request=request) + + log.debug('Validating username %s.', request.username) + if not self.request_validator.validate_user(request.username, + request.password, request.client, request): + raise errors.InvalidGrantError( + 'Invalid credentials given.', request=request) + else: + if not hasattr(request.client, 'client_id'): + raise NotImplementedError( + 'Validate user must set the ' + 'request.client.client_id attribute ' + 'in authenticate_client.') + log.debug('Authorizing access to user %r.', request.user) + + # Ensure client is authorized use of this grant type + self.validate_grant_type(request) + + if request.client: + request.client_id = request.client_id or request.client.client_id + self.validate_scopes(request) diff --git a/oauthlib/oauth2/rfc6749/parameters.py b/oauthlib/oauth2/rfc6749/parameters.py new file mode 100644 index 0000000..b46889c --- /dev/null +++ b/oauthlib/oauth2/rfc6749/parameters.py @@ -0,0 +1,406 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749.parameters +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module contains methods related to `Section 4`_ of the OAuth 2 RFC. + +.. _`Section 4`: http://tools.ietf.org/html/rfc6749#section-4 +""" +from __future__ import absolute_import, unicode_literals + +import json +import os +import time +try: + import urlparse +except ImportError: + import urllib.parse as urlparse +from oauthlib.common import add_params_to_uri, add_params_to_qs, unicode_type +from oauthlib.signals import scope_changed +from .errors import raise_from_error, MissingTokenError, MissingTokenTypeError +from .errors import MismatchingStateError, MissingCodeError +from .errors import InsecureTransportError +from .tokens import OAuth2Token +from .utils import list_to_scope, scope_to_list, is_secure_transport + + +def prepare_grant_uri(uri, client_id, response_type, redirect_uri=None, + scope=None, state=None, **kwargs): + """Prepare the authorization grant request URI. + + The client constructs the request URI by adding the following + parameters to the query component of the authorization endpoint URI + using the ``application/x-www-form-urlencoded`` format as defined by + [`W3C.REC-html401-19991224`_]: + + :param response_type: To indicate which OAuth 2 grant/flow is required, + "code" and "token". + :param client_id: The client identifier as described in `Section 2.2`_. + :param redirect_uri: The client provided URI to redirect back to after + authorization as described in `Section 3.1.2`_. + :param scope: The scope of the access request as described by + `Section 3.3`_. + + :param state: An opaque value used by the client to maintain + state between the request and callback. The authorization + server includes this value when redirecting the user-agent + back to the client. The parameter SHOULD be used for + preventing cross-site request forgery as described in + `Section 10.12`_. + :param kwargs: Extra arguments to embed in the grant/authorization URL. + + An example of an authorization code grant authorization URL: + + .. code-block:: http + + GET /authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz + &redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1 + Host: server.example.com + + .. _`W3C.REC-html401-19991224`: http://tools.ietf.org/html/rfc6749#ref-W3C.REC-html401-19991224 + .. _`Section 2.2`: http://tools.ietf.org/html/rfc6749#section-2.2 + .. _`Section 3.1.2`: http://tools.ietf.org/html/rfc6749#section-3.1.2 + .. _`Section 3.3`: http://tools.ietf.org/html/rfc6749#section-3.3 + .. _`section 10.12`: http://tools.ietf.org/html/rfc6749#section-10.12 + """ + if not is_secure_transport(uri): + raise InsecureTransportError() + + params = [(('response_type', response_type)), + (('client_id', client_id))] + + if redirect_uri: + params.append(('redirect_uri', redirect_uri)) + if scope: + params.append(('scope', list_to_scope(scope))) + if state: + params.append(('state', state)) + + for k in kwargs: + if kwargs[k]: + params.append((unicode_type(k), kwargs[k])) + + return add_params_to_uri(uri, params) + + +def prepare_token_request(grant_type, body='', **kwargs): + """Prepare the access token request. + + The client makes a request to the token endpoint by adding the + following parameters using the ``application/x-www-form-urlencoded`` + format in the HTTP request entity-body: + + :param grant_type: To indicate grant type being used, i.e. "password", + "authorization_code" or "client_credentials". + :param body: Existing request body to embed parameters in. + :param code: If using authorization code grant, pass the previously + obtained authorization code as the ``code`` argument. + :param redirect_uri: If the "redirect_uri" parameter was included in the + authorization request as described in + `Section 4.1.1`_, and their values MUST be identical. + :param kwargs: Extra arguments to embed in the request body. + + An example of an authorization code token request body: + + .. code-block:: http + + grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA + &redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb + + .. _`Section 4.1.1`: http://tools.ietf.org/html/rfc6749#section-4.1.1 + """ + params = [('grant_type', grant_type)] + + if 'scope' in kwargs: + kwargs['scope'] = list_to_scope(kwargs['scope']) + + for k in kwargs: + if kwargs[k]: + params.append((unicode_type(k), kwargs[k])) + + return add_params_to_qs(body, params) + + +def prepare_token_revocation_request(url, token, token_type_hint="access_token", + callback=None, body='', **kwargs): + """Prepare a token revocation request. + + The client constructs the request by including the following parameters + using the "application/x-www-form-urlencoded" format in the HTTP request + entity-body: + + token REQUIRED. The token that the client wants to get revoked. + + token_type_hint OPTIONAL. A hint about the type of the token submitted + for revocation. Clients MAY pass this parameter in order to help the + authorization server to optimize the token lookup. If the server is unable + to locate the token using the given hint, it MUST extend its search across + all of its supported token types. An authorization server MAY ignore this + parameter, particularly if it is able to detect the token type + automatically. This specification defines two such values: + + * access_token: An access token as defined in [RFC6749], + `Section 1.4`_ + + * refresh_token: A refresh token as defined in [RFC6749], + `Section 1.5`_ + + Specific implementations, profiles, and extensions of this + specification MAY define other values for this parameter using the + registry defined in `Section 4.1.2`_. + + .. _`Section 1.4`: http://tools.ietf.org/html/rfc6749#section-1.4 + .. _`Section 1.5`: http://tools.ietf.org/html/rfc6749#section-1.5 + .. _`Section 4.1.2`: http://tools.ietf.org/html/rfc7009#section-4.1.2 + + """ + if not is_secure_transport(url): + raise InsecureTransportError() + + params = [('token', token)] + + if token_type_hint: + params.append(('token_type_hint', token_type_hint)) + + for k in kwargs: + if kwargs[k]: + params.append((unicode_type(k), kwargs[k])) + + headers = {'Content-Type': 'application/x-www-form-urlencoded'} + + if callback: + params.append(('callback', callback)) + return add_params_to_uri(url, params), headers, body + else: + return url, headers, add_params_to_qs(body, params) + + +def parse_authorization_code_response(uri, state=None): + """Parse authorization grant response URI into a dict. + + If the resource owner grants the access request, the authorization + server issues an authorization code and delivers it to the client by + adding the following parameters to the query component of the + redirection URI using the ``application/x-www-form-urlencoded`` format: + + **code** + REQUIRED. The authorization code generated by the + authorization server. The authorization code MUST expire + shortly after it is issued to mitigate the risk of leaks. A + maximum authorization code lifetime of 10 minutes is + RECOMMENDED. The client MUST NOT use the authorization code + more than once. If an authorization code is used more than + once, the authorization server MUST deny the request and SHOULD + revoke (when possible) all tokens previously issued based on + that authorization code. The authorization code is bound to + the client identifier and redirection URI. + + **state** + REQUIRED if the "state" parameter was present in the client + authorization request. The exact value received from the + client. + + :param uri: The full redirect URL back to the client. + :param state: The state parameter from the authorization request. + + For example, the authorization server redirects the user-agent by + sending the following HTTP response: + + .. code-block:: http + + HTTP/1.1 302 Found + Location: https://client.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA + &state=xyz + + """ + if not is_secure_transport(uri): + raise InsecureTransportError() + + query = urlparse.urlparse(uri).query + params = dict(urlparse.parse_qsl(query)) + + if not 'code' in params: + raise MissingCodeError("Missing code parameter in response.") + + if state and params.get('state', None) != state: + raise MismatchingStateError() + + return params + + +def parse_implicit_response(uri, state=None, scope=None): + """Parse the implicit token response URI into a dict. + + If the resource owner grants the access request, the authorization + server issues an access token and delivers it to the client by adding + the following parameters to the fragment component of the redirection + URI using the ``application/x-www-form-urlencoded`` format: + + **access_token** + REQUIRED. The access token issued by the authorization server. + + **token_type** + REQUIRED. The type of the token issued as described in + Section 7.1. Value is case insensitive. + + **expires_in** + RECOMMENDED. The lifetime in seconds of the access token. For + example, the value "3600" denotes that the access token will + expire in one hour from the time the response was generated. + If omitted, the authorization server SHOULD provide the + expiration time via other means or document the default value. + + **scope** + OPTIONAL, if identical to the scope requested by the client, + otherwise REQUIRED. The scope of the access token as described + by Section 3.3. + + **state** + REQUIRED if the "state" parameter was present in the client + authorization request. The exact value received from the + client. + + Similar to the authorization code response, but with a full token provided + in the URL fragment: + + .. code-block:: http + + HTTP/1.1 302 Found + Location: http://example.com/cb#access_token=2YotnFZFEjr1zCsicMWpAA + &state=xyz&token_type=example&expires_in=3600 + """ + if not is_secure_transport(uri): + raise InsecureTransportError() + + fragment = urlparse.urlparse(uri).fragment + params = dict(urlparse.parse_qsl(fragment, keep_blank_values=True)) + + if 'scope' in params: + params['scope'] = scope_to_list(params['scope']) + + if 'expires_in' in params: + params['expires_at'] = time.time() + int(params['expires_in']) + + if state and params.get('state', None) != state: + raise ValueError("Mismatching or missing state in params.") + + params = OAuth2Token(params, old_scope=scope) + validate_token_parameters(params) + return params + + +def parse_token_response(body, scope=None): + """Parse the JSON token response body into a dict. + + The authorization server issues an access token and optional refresh + token, and constructs the response by adding the following parameters + to the entity body of the HTTP response with a 200 (OK) status code: + + access_token + REQUIRED. The access token issued by the authorization server. + token_type + REQUIRED. The type of the token issued as described in + `Section 7.1`_. Value is case insensitive. + expires_in + RECOMMENDED. The lifetime in seconds of the access token. For + example, the value "3600" denotes that the access token will + expire in one hour from the time the response was generated. + If omitted, the authorization server SHOULD provide the + expiration time via other means or document the default value. + refresh_token + OPTIONAL. The refresh token which can be used to obtain new + access tokens using the same authorization grant as described + in `Section 6`_. + scope + OPTIONAL, if identical to the scope requested by the client, + otherwise REQUIRED. The scope of the access token as described + by `Section 3.3`_. + + The parameters are included in the entity body of the HTTP response + using the "application/json" media type as defined by [`RFC4627`_]. The + parameters are serialized into a JSON structure by adding each + parameter at the highest structure level. Parameter names and string + values are included as JSON strings. Numerical values are included + as JSON numbers. The order of parameters does not matter and can + vary. + + :param body: The full json encoded response body. + :param scope: The scope requested during authorization. + + For example: + + .. code-block:: http + + HTTP/1.1 200 OK + Content-Type: application/json + Cache-Control: no-store + Pragma: no-cache + + { + "access_token":"2YotnFZFEjr1zCsicMWpAA", + "token_type":"example", + "expires_in":3600, + "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA", + "example_parameter":"example_value" + } + + .. _`Section 7.1`: http://tools.ietf.org/html/rfc6749#section-7.1 + .. _`Section 6`: http://tools.ietf.org/html/rfc6749#section-6 + .. _`Section 3.3`: http://tools.ietf.org/html/rfc6749#section-3.3 + .. _`RFC4627`: http://tools.ietf.org/html/rfc4627 + """ + try: + params = json.loads(body) + except ValueError: + + # Fall back to URL-encoded string, to support old implementations, + # including (at time of writing) Facebook. See: + # https://github.com/idan/oauthlib/issues/267 + + params = dict(urlparse.parse_qsl(body)) + for key in ('expires_in', 'expires'): + if key in params: # cast a couple things to int + params[key] = int(params[key]) + + if 'scope' in params: + params['scope'] = scope_to_list(params['scope']) + + if 'expires' in params: + params['expires_in'] = params.pop('expires') + + if 'expires_in' in params: + params['expires_at'] = time.time() + int(params['expires_in']) + + params = OAuth2Token(params, old_scope=scope) + validate_token_parameters(params) + return params + + +def validate_token_parameters(params): + """Ensures token precence, token type, expiration and scope in params.""" + if 'error' in params: + raise_from_error(params.get('error'), params) + + if not 'access_token' in params: + raise MissingTokenError(description="Missing access token parameter.") + + if not 'token_type' in params: + if os.environ.get('OAUTHLIB_STRICT_TOKEN_TYPE'): + raise MissingTokenTypeError() + + # If the issued access token scope is different from the one requested by + # the client, the authorization server MUST include the "scope" response + # parameter to inform the client of the actual scope granted. + # http://tools.ietf.org/html/rfc6749#section-3.3 + if params.scope_changed: + message = 'Scope has changed from "{old}" to "{new}".'.format( + old=params.old_scope, new=params.scope, + ) + scope_changed.send(message=message, old=params.old_scopes, new=params.scopes) + if not os.environ.get('OAUTHLIB_RELAX_TOKEN_SCOPE', None): + w = Warning(message) + w.token = params + w.old_scope = params.old_scopes + w.new_scope = params.scopes + raise w diff --git a/oauthlib/oauth2/rfc6749/request_validator.py b/oauthlib/oauth2/rfc6749/request_validator.py new file mode 100644 index 0000000..e622ff1 --- /dev/null +++ b/oauthlib/oauth2/rfc6749/request_validator.py @@ -0,0 +1,460 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.oauth2.rfc6749.grant_types +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +""" +from __future__ import unicode_literals, absolute_import + +import logging + +log = logging.getLogger(__name__) + + +class RequestValidator(object): + + def client_authentication_required(self, request, *args, **kwargs): + """Determine if client authentication is required for current request. + + According to the rfc6749, client authentication is required in the following cases: + - Resource Owner Password Credentials Grant, when Client type is Confidential or when + Client was issued client credentials or whenever Client provided client + authentication, see `Section 4.3.2`_. + - Authorization Code Grant, when Client type is Confidential or when Client was issued + client credentials or whenever Client provided client authentication, + see `Section 4.1.3`_. + - Refresh Token Grant, when Client type is Confidential or when Client was issued + client credentials or whenever Client provided client authentication, see + `Section 6`_ + + :param request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Authorization Code Grant + - Resource Owner Password Credentials Grant + - Refresh Token Grant + + .. _`Section 4.3.2`: http://tools.ietf.org/html/rfc6749#section-4.3.2 + .. _`Section 4.1.3`: http://tools.ietf.org/html/rfc6749#section-4.1.3 + .. _`Section 6`: http://tools.ietf.org/html/rfc6749#section-6 + """ + return True + + def authenticate_client(self, request, *args, **kwargs): + """Authenticate client through means outside the OAuth 2 spec. + + Means of authentication is negotiated beforehand and may for example + be `HTTP Basic Authentication Scheme`_ which utilizes the Authorization + header. + + Headers may be accesses through request.headers and parameters found in + both body and query can be obtained by direct attribute access, i.e. + request.client_id for client_id in the URL query. + + OBS! Certain grant types rely on this authentication, possibly with + other fallbacks, and for them to recognize this authorization please + set the client attribute on the request (request.client). Note that + preferably this client object should have a client_id attribute of + unicode type (request.client.client_id). + + :param request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Authorization Code Grant + - Resource Owner Password Credentials Grant (may be disabled) + - Client Credentials Grant + - Refresh Token Grant + + .. _`HTTP Basic Authentication Scheme`: http://tools.ietf.org/html/rfc1945#section-11.1 + """ + raise NotImplementedError('Subclasses must implement this method.') + + def authenticate_client_id(self, client_id, request, *args, **kwargs): + """Ensure client_id belong to a non-confidential client. + + A non-confidential client is one that is not required to authenticate + through other means, such as using HTTP Basic. + + Note, while not strictly necessary it can often be very convenient + to set request.client to the client object associated with the + given client_id. + + :param request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Authorization Code Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def confirm_redirect_uri(self, client_id, code, redirect_uri, client, + *args, **kwargs): + """Ensure client is authorized to redirect to the redirect_uri requested. + + If the client specifies a redirect_uri when obtaining code then + that redirect URI must be bound to the code and verified equal + in this method. + + All clients should register the absolute URIs of all URIs they intend + to redirect to. The registration is outside of the scope of oauthlib. + + :param client_id: Unicode client identifier + :param code: Unicode authorization_code. + :param redirect_uri: Unicode absolute URI + :param client: Client object set by you, see authenticate_client. + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: True or False + + Method is used by: + - Authorization Code Grant (during token request) + """ + raise NotImplementedError('Subclasses must implement this method.') + + def get_default_redirect_uri(self, client_id, request, *args, **kwargs): + """Get the default redirect URI for the client. + + :param client_id: Unicode client identifier + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: The default redirect URI for the client + + Method is used by: + - Authorization Code Grant + - Implicit Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def get_default_scopes(self, client_id, request, *args, **kwargs): + """Get the default scopes for the client. + + :param client_id: Unicode client identifier + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: List of default scopes + + Method is used by all core grant types: + - Authorization Code Grant + - Implicit Grant + - Resource Owner Password Credentials Grant + - Client Credentials grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def get_original_scopes(self, refresh_token, request, *args, **kwargs): + """Get the list of scopes associated with the refresh token. + + :param refresh_token: Unicode refresh token + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: List of scopes. + + Method is used by: + - Refresh token grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def is_within_original_scope(self, request_scopes, refresh_token, request, *args, **kwargs): + """Check if requested scopes are within a scope of the refresh token. + + When access tokens are refreshed the scope of the new token + needs to be within the scope of the original token. This is + ensured by checking that all requested scopes strings are on + the list returned by the get_original_scopes. If this check + fails, is_within_original_scope is called. The method can be + used in situations where returning all valid scopes from the + get_original_scopes is not practical. + + :param request_scopes: A list of scopes that were requested by client + :param refresh_token: Unicode refresh_token + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: True or False + + Method is used by: + - Refresh token grant + """ + return False + + def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs): + """Invalidate an authorization code after use. + + :param client_id: Unicode client identifier + :param code: The authorization code grant (request.code). + :param request: The HTTP Request (oauthlib.common.Request) + + Method is used by: + - Authorization Code Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def revoke_token(self, token, token_type_hint, request, *args, **kwargs): + """Revoke an access or refresh token. + + :param token: The token string. + :param token_type_hint: access_token or refresh_token. + :param request: The HTTP Request (oauthlib.common.Request) + + Method is used by: + - Revocation Endpoint + """ + raise NotImplementedError('Subclasses must implement this method.') + + def rotate_refresh_token(self, request): + """Determine whether to rotate the refresh token. Default, yes. + + When access tokens are refreshed the old refresh token can be kept + or replaced with a new one (rotated). Return True to rotate and + and False for keeping original. + + :param request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Refresh Token Grant + """ + return True + + def save_authorization_code(self, client_id, code, request, *args, **kwargs): + """Persist the authorization_code. + + The code should at minimum be associated with: + - a client and it's client_id + - the redirect URI used (request.redirect_uri) + - whether the redirect URI used is the client default or not + - a resource owner / user (request.user) + - authorized scopes (request.scopes) + + The authorization code grant dict (code) holds at least the key 'code':: + + {'code': 'sdf345jsdf0934f'} + + :param client_id: Unicode client identifier + :param code: A dict of the authorization code grant. + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: The default redirect URI for the client + + Method is used by: + - Authorization Code Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def save_bearer_token(self, token, request, *args, **kwargs): + """Persist the Bearer token. + + The Bearer token should at minimum be associated with: + - a client and it's client_id, if available + - a resource owner / user (request.user) + - authorized scopes (request.scopes) + - an expiration time + - a refresh token, if issued + + The Bearer token dict may hold a number of items:: + + { + 'token_type': 'Bearer', + 'access_token': 'askfjh234as9sd8', + 'expires_in': 3600, + 'scope': 'string of space separated authorized scopes', + 'refresh_token': '23sdf876234', # if issued + 'state': 'given_by_client', # if supplied by client + } + + Note that while "scope" is a string-separated list of authorized scopes, + the original list is still available in request.scopes + + :param client_id: Unicode client identifier + :param token: A Bearer token dict + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: The default redirect URI for the client + + Method is used by all core grant types issuing Bearer tokens: + - Authorization Code Grant + - Implicit Grant + - Resource Owner Password Credentials Grant (might not associate a client) + - Client Credentials grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_bearer_token(self, token, scopes, request): + """Ensure the Bearer token is valid and authorized access to scopes. + + :param token: A string of random characters. + :param scopes: A list of scopes associated with the protected resource. + :param request: The HTTP Request (oauthlib.common.Request) + + A key to OAuth 2 security and restricting impact of leaked tokens is + the short expiration time of tokens, *always ensure the token has not + expired!*. + + Two different approaches to scope validation: + + 1) all(scopes). The token must be authorized access to all scopes + associated with the resource. For example, the + token has access to ``read-only`` and ``images``, + thus the client can view images but not upload new. + Allows for fine grained access control through + combining various scopes. + + 2) any(scopes). The token must be authorized access to one of the + scopes associated with the resource. For example, + token has access to ``read-only-images``. + Allows for fine grained, although arguably less + convenient, access control. + + A powerful way to use scopes would mimic UNIX ACLs and see a scope + as a group with certain privileges. For a restful API these might + map to HTTP verbs instead of read, write and execute. + + Note, the request.user attribute can be set to the resource owner + associated with this token. Similarly the request.client and + request.scopes attribute can be set to associated client object + and authorized scopes. If you then use a decorator such as the + one provided for django these attributes will be made available + in all protected views as keyword arguments. + + :param token: Unicode Bearer token + :param scopes: List of scopes (defined by you) + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: True or False + + Method is indirectly used by all core Bearer token issuing grant types: + - Authorization Code Grant + - Implicit Grant + - Resource Owner Password Credentials Grant + - Client Credentials Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_client_id(self, client_id, request, *args, **kwargs): + """Ensure client_id belong to a valid and active client. + + Note, while not strictly necessary it can often be very convenient + to set request.client to the client object associated with the + given client_id. + + :param request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Authorization Code Grant + - Implicit Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_code(self, client_id, code, client, request, *args, **kwargs): + """Ensure the authorization_code is valid and assigned to client. + + OBS! The request.user attribute should be set to the resource owner + associated with this authorization code. Similarly request.scopes and + request.state must also be set. + + :param client_id: Unicode client identifier + :param code: Unicode authorization code + :param client: Client object set by you, see authenticate_client. + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: True or False + + Method is used by: + - Authorization Code Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs): + """Ensure client is authorized to use the grant_type requested. + + :param client_id: Unicode client identifier + :param grant_type: Unicode grant type, i.e. authorization_code, password. + :param client: Client object set by you, see authenticate_client. + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: True or False + + Method is used by: + - Authorization Code Grant + - Resource Owner Password Credentials Grant + - Client Credentials Grant + - Refresh Token Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs): + """Ensure client is authorized to redirect to the redirect_uri requested. + + All clients should register the absolute URIs of all URIs they intend + to redirect to. The registration is outside of the scope of oauthlib. + + :param client_id: Unicode client identifier + :param redirect_uri: Unicode absolute URI + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: True or False + + Method is used by: + - Authorization Code Grant + - Implicit Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs): + """Ensure the Bearer token is valid and authorized access to scopes. + + OBS! The request.user attribute should be set to the resource owner + associated with this refresh token. + + :param refresh_token: Unicode refresh token + :param client: Client object set by you, see authenticate_client. + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: True or False + + Method is used by: + - Authorization Code Grant (indirectly by issuing refresh tokens) + - Resource Owner Password Credentials Grant (also indirectly) + - Refresh Token Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs): + """Ensure client is authorized to use the response_type requested. + + :param client_id: Unicode client identifier + :param response_type: Unicode response type, i.e. code, token. + :param client: Client object set by you, see authenticate_client. + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: True or False + + Method is used by: + - Authorization Code Grant + - Implicit Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): + """Ensure the client is authorized access to requested scopes. + + :param client_id: Unicode client identifier + :param scopes: List of scopes (defined by you) + :param client: Client object set by you, see authenticate_client. + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: True or False + + Method is used by all core grant types: + - Authorization Code Grant + - Implicit Grant + - Resource Owner Password Credentials Grant + - Client Credentials Grant + """ + raise NotImplementedError('Subclasses must implement this method.') + + def validate_user(self, username, password, client, request, *args, **kwargs): + """Ensure the username and password is valid. + + OBS! The validation should also set the user attribute of the request + to a valid resource owner, i.e. request.user = username or similar. If + not set you will be unable to associate a token with a user in the + persistance method used (commonly, save_bearer_token). + + :param username: Unicode username + :param password: Unicode password + :param client: Client object set by you, see authenticate_client. + :param request: The HTTP Request (oauthlib.common.Request) + :rtype: True or False + + Method is used by: + - Resource Owner Password Credentials Grant + """ + raise NotImplementedError('Subclasses must implement this method.') diff --git a/oauthlib/oauth2/rfc6749/tokens.py b/oauthlib/oauth2/rfc6749/tokens.py new file mode 100644 index 0000000..3252e90 --- /dev/null +++ b/oauthlib/oauth2/rfc6749/tokens.py @@ -0,0 +1,294 @@ +""" +oauthlib.oauth2.rfc6749.tokens +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module contains methods for adding two types of access tokens to requests. + +- Bearer http://tools.ietf.org/html/rfc6750 +- MAC http://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01 +""" +from __future__ import absolute_import, unicode_literals + +from binascii import b2a_base64 +import hashlib +import hmac +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse + +from oauthlib.common import add_params_to_uri, add_params_to_qs, unicode_type +from oauthlib import common + +from . import utils + + +class OAuth2Token(dict): + + def __init__(self, params, old_scope=None): + super(OAuth2Token, self).__init__(params) + self._new_scope = None + if 'scope' in params: + self._new_scope = set(utils.scope_to_list(params['scope'])) + if old_scope is not None: + self._old_scope = set(utils.scope_to_list(old_scope)) + if self._new_scope is None: + # the rfc says that if the scope hasn't changed, it's optional + # in params so set the new scope to the old scope + self._new_scope = self._old_scope + else: + self._old_scope = self._new_scope + + @property + def scope_changed(self): + return self._new_scope != self._old_scope + + @property + def old_scope(self): + return utils.list_to_scope(self._old_scope) + + @property + def old_scopes(self): + return list(self._old_scope) + + @property + def scope(self): + return utils.list_to_scope(self._new_scope) + + @property + def scopes(self): + return list(self._new_scope) + + @property + def missing_scopes(self): + return list(self._old_scope - self._new_scope) + + @property + def additional_scopes(self): + return list(self._new_scope - self._old_scope) + + +def prepare_mac_header(token, uri, key, http_method, + nonce=None, + headers=None, + body=None, + ext='', + hash_algorithm='hmac-sha-1', + issue_time=None, + draft=0): + """Add an `MAC Access Authentication`_ signature to headers. + + Unlike OAuth 1, this HMAC signature does not require inclusion of the + request payload/body, neither does it use a combination of client_secret + and token_secret but rather a mac_key provided together with the access + token. + + Currently two algorithms are supported, "hmac-sha-1" and "hmac-sha-256", + `extension algorithms`_ are not supported. + + Example MAC Authorization header, linebreaks added for clarity + + Authorization: MAC id="h480djs93hd8", + nonce="1336363200:dj83hs9s", + mac="bhCQXTVyfj5cmA9uKkPFx1zeOXM=" + + .. _`MAC Access Authentication`: http://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01 + .. _`extension algorithms`: http://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-01#section-7.1 + + :param uri: Request URI. + :param headers: Request headers as a dictionary. + :param http_method: HTTP Request method. + :param key: MAC given provided by token endpoint. + :param hash_algorithm: HMAC algorithm provided by token endpoint. + :param issue_time: Time when the MAC credentials were issued (datetime). + :param draft: MAC authentication specification version. + :return: headers dictionary with the authorization field added. + """ + http_method = http_method.upper() + host, port = utils.host_from_uri(uri) + + if hash_algorithm.lower() == 'hmac-sha-1': + h = hashlib.sha1 + elif hash_algorithm.lower() == 'hmac-sha-256': + h = hashlib.sha256 + else: + raise ValueError('unknown hash algorithm') + + if draft == 0: + nonce = nonce or '{0}:{1}'.format(utils.generate_age(issue_time), + common.generate_nonce()) + else: + ts = common.generate_timestamp() + nonce = common.generate_nonce() + + sch, net, path, par, query, fra = urlparse(uri) + + if query: + request_uri = path + '?' + query + else: + request_uri = path + + # Hash the body/payload + if body is not None and draft == 0: + body = body.encode('utf-8') + bodyhash = b2a_base64(h(body).digest())[:-1].decode('utf-8') + else: + bodyhash = '' + + # Create the normalized base string + base = [] + if draft == 0: + base.append(nonce) + else: + base.append(ts) + base.append(nonce) + base.append(http_method.upper()) + base.append(request_uri) + base.append(host) + base.append(port) + if draft == 0: + base.append(bodyhash) + base.append(ext or '') + base_string = '\n'.join(base) + '\n' + + # hmac struggles with unicode strings - http://bugs.python.org/issue5285 + if isinstance(key, unicode_type): + key = key.encode('utf-8') + sign = hmac.new(key, base_string.encode('utf-8'), h) + sign = b2a_base64(sign.digest())[:-1].decode('utf-8') + + header = [] + header.append('MAC id="%s"' % token) + if draft != 0: + header.append('ts="%s"' % ts) + header.append('nonce="%s"' % nonce) + if bodyhash: + header.append('bodyhash="%s"' % bodyhash) + if ext: + header.append('ext="%s"' % ext) + header.append('mac="%s"' % sign) + + headers = headers or {} + headers['Authorization'] = ', '.join(header) + return headers + + +def prepare_bearer_uri(token, uri): + """Add a `Bearer Token`_ to the request URI. + Not recommended, use only if client can't use authorization header or body. + + http://www.example.com/path?access_token=h480djs93hd8 + + .. _`Bearer Token`: http://tools.ietf.org/html/rfc6750 + """ + return add_params_to_uri(uri, [(('access_token', token))]) + + +def prepare_bearer_headers(token, headers=None): + """Add a `Bearer Token`_ to the request URI. + Recommended method of passing bearer tokens. + + Authorization: Bearer h480djs93hd8 + + .. _`Bearer Token`: http://tools.ietf.org/html/rfc6750 + """ + headers = headers or {} + headers['Authorization'] = 'Bearer %s' % token + return headers + + +def prepare_bearer_body(token, body=''): + """Add a `Bearer Token`_ to the request body. + + access_token=h480djs93hd8 + + .. _`Bearer Token`: http://tools.ietf.org/html/rfc6750 + """ + return add_params_to_qs(body, [(('access_token', token))]) + + +def random_token_generator(request, refresh_token=False): + return common.generate_token() + + +def signed_token_generator(private_pem, **kwargs): + def signed_token_generator(request): + request.claims = kwargs + return common.generate_signed_token(private_pem, request) + + return signed_token_generator + + +class TokenBase(object): + + def __call__(self, request, refresh_token=False): + raise NotImplementedError('Subclasses must implement this method.') + + def validate_request(self, request): + raise NotImplementedError('Subclasses must implement this method.') + + def estimate_type(self, request): + raise NotImplementedError('Subclasses must implement this method.') + + +class BearerToken(TokenBase): + + def __init__(self, request_validator=None, token_generator=None, + expires_in=None, refresh_token_generator=None): + self.request_validator = request_validator + self.token_generator = token_generator or random_token_generator + self.refresh_token_generator = ( + refresh_token_generator or self.token_generator + ) + self.expires_in = expires_in or 3600 + + def create_token(self, request, refresh_token=False): + """Create a BearerToken, by default without refresh token.""" + + if callable(self.expires_in): + expires_in = self.expires_in(request) + else: + expires_in = self.expires_in + + request.expires_in = expires_in + + token = { + 'access_token': self.token_generator(request), + 'expires_in': expires_in, + 'token_type': 'Bearer', + } + + if request.scopes is not None: + token['scope'] = ' '.join(request.scopes) + + if request.state is not None: + token['state'] = request.state + + if refresh_token: + if (request.refresh_token and + not self.request_validator.rotate_refresh_token(request)): + token['refresh_token'] = request.refresh_token + else: + token['refresh_token'] = self.refresh_token_generator(request) + + token.update(request.extra_credentials or {}) + token = OAuth2Token(token) + self.request_validator.save_bearer_token(token, request) + return token + + def validate_request(self, request): + token = None + if 'Authorization' in request.headers: + token = request.headers.get('Authorization')[7:] + else: + token = request.access_token + return self.request_validator.validate_bearer_token( + token, request.scopes, request) + + def estimate_type(self, request): + if request.headers.get('Authorization', '').startswith('Bearer'): + return 9 + elif request.access_token is not None: + return 5 + else: + return 0 diff --git a/oauthlib/oauth2/rfc6749/utils.py b/oauthlib/oauth2/rfc6749/utils.py new file mode 100644 index 0000000..6a8e24b --- /dev/null +++ b/oauthlib/oauth2/rfc6749/utils.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +""" +oauthlib.utils +~~~~~~~~~~~~~~ + +This module contains utility methods used by various parts of the OAuth 2 spec. +""" +from __future__ import absolute_import, unicode_literals + +import os +import datetime +try: + from urllib import quote +except ImportError: + from urllib.parse import quote +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse +from oauthlib.common import unicode_type, urldecode + + +def list_to_scope(scope): + """Convert a list of scopes to a space separated string.""" + if isinstance(scope, unicode_type) or scope is None: + return scope + elif isinstance(scope, (tuple, list)): + return " ".join([unicode_type(s) for s in scope]) + elif isinstance(scope, set): + return list_to_scope(list(scope)) + else: + raise ValueError("Invalid scope, must be string or list.") + + +def scope_to_list(scope): + """Convert a space separated string to a list of scopes.""" + if isinstance(scope, list): + return [unicode_type(s) for s in scope] + if isinstance(scope, set): + scope_to_list(list(scope)) + elif scope is None: + return None + else: + return scope.strip().split(" ") + + +def params_from_uri(uri): + params = dict(urldecode(urlparse(uri).query)) + if 'scope' in params: + params['scope'] = scope_to_list(params['scope']) + return params + + +def host_from_uri(uri): + """Extract hostname and port from URI. + + Will use default port for HTTP and HTTPS if none is present in the URI. + """ + default_ports = { + 'HTTP': '80', + 'HTTPS': '443', + } + + sch, netloc, path, par, query, fra = urlparse(uri) + if ':' in netloc: + netloc, port = netloc.split(':', 1) + else: + port = default_ports.get(sch.upper()) + + return netloc, port + + +def escape(u): + """Escape a string in an OAuth-compatible fashion. + + TODO: verify whether this can in fact be used for OAuth 2 + + """ + if not isinstance(u, unicode_type): + raise ValueError('Only unicode objects are escapable.') + return quote(u.encode('utf-8'), safe=b'~') + + +def generate_age(issue_time): + """Generate a age parameter for MAC authentication draft 00.""" + td = datetime.datetime.now() - issue_time + age = (td.microseconds + (td.seconds + td.days * 24 * 3600) + * 10 ** 6) / 10 ** 6 + return unicode_type(age) + + +def is_secure_transport(uri): + """Check if the uri is over ssl.""" + if os.environ.get('OAUTHLIB_INSECURE_TRANSPORT'): + return True + return uri.lower().startswith('https://') diff --git a/oauthlib/signals.py b/oauthlib/signals.py new file mode 100644 index 0000000..2f86650 --- /dev/null +++ b/oauthlib/signals.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +""" + Implements signals based on blinker if available, otherwise + falls silently back to a noop. Shamelessly stolen from flask.signals: + https://github.com/mitsuhiko/flask/blob/master/flask/signals.py +""" +signals_available = False +try: + from blinker import Namespace + signals_available = True +except ImportError: + class Namespace(object): + def signal(self, name, doc=None): + return _FakeSignal(name, doc) + + class _FakeSignal(object): + """If blinker is unavailable, create a fake class with the same + interface that allows sending of signals but will fail with an + error on anything else. Instead of doing anything on send, it + will just ignore the arguments and do nothing instead. + """ + + def __init__(self, name, doc=None): + self.name = name + self.__doc__ = doc + def _fail(self, *args, **kwargs): + raise RuntimeError('signalling support is unavailable ' + 'because the blinker library is ' + 'not installed.') + send = lambda *a, **kw: None + connect = disconnect = has_receivers_for = receivers_for = \ + temporarily_connected_to = connected_to = _fail + del _fail + +# The namespace for code signals. If you are not oauthlib code, do +# not put signals in here. Create your own namespace instead. +_signals = Namespace() + + +# Core signals. +scope_changed = _signals.signal('scope-changed') diff --git a/oauthlib/uri_validate.py b/oauthlib/uri_validate.py new file mode 100644 index 0000000..e553f32 --- /dev/null +++ b/oauthlib/uri_validate.py @@ -0,0 +1,215 @@ +""" +Regex for URIs + +These regex are directly derived from the collected ABNF in RFC3986 +(except for DIGIT, ALPHA and HEXDIG, defined by RFC2234). + +They should be processed with re.VERBOSE. + +Thanks Mark Nottingham for this code - https://gist.github.com/138549 +""" +from __future__ import unicode_literals +import re + +# basics + +DIGIT = r"[\x30-\x39]" + +ALPHA = r"[\x41-\x5A\x61-\x7A]" + +HEXDIG = r"[\x30-\x39A-Fa-f]" + +# pct-encoded = "%" HEXDIG HEXDIG +pct_encoded = r" %% %(HEXDIG)s %(HEXDIG)s" % locals() + +# unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" +unreserved = r"(?: %(ALPHA)s | %(DIGIT)s | \- | \. | _ | ~ )" % locals() + +# gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@" +gen_delims = r"(?: : | / | \? | \# | \[ | \] | @ )" + +# sub-delims = "!" / "$" / "&" / "'" / "(" / ")" +# / "*" / "+" / "," / ";" / "=" +sub_delims = r"""(?: ! | \$ | & | ' | \( | \) | + \* | \+ | , | ; | = )""" + +# pchar = unreserved / pct-encoded / sub-delims / ":" / "@" +pchar = r"(?: %(unreserved)s | %(pct_encoded)s | %(sub_delims)s | : | @ )" % locals( +) + +# reserved = gen-delims / sub-delims +reserved = r"(?: %(gen_delims)s | %(sub_delims)s )" % locals() + + +# scheme + +# scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) +scheme = r"%(ALPHA)s (?: %(ALPHA)s | %(DIGIT)s | \+ | \- | \. )*" % locals() + + +# authority + +# dec-octet = DIGIT ; 0-9 +# / %x31-39 DIGIT ; 10-99 +# / "1" 2DIGIT ; 100-199 +# / "2" %x30-34 DIGIT ; 200-249 +# / "25" %x30-35 ; 250-255 +dec_octet = r"""(?: %(DIGIT)s | + [\x31-\x39] %(DIGIT)s | + 1 %(DIGIT)s{2} | + 2 [\x30-\x34] %(DIGIT)s | + 25 [\x30-\x35] + ) +""" % locals() + +# IPv4address = dec-octet "." dec-octet "." dec-octet "." dec-octet +IPv4address = r"%(dec_octet)s \. %(dec_octet)s \. %(dec_octet)s \. %(dec_octet)s" % locals( +) + +# h16 = 1*4HEXDIG +h16 = r"(?: %(HEXDIG)s ){1,4}" % locals() + +# ls32 = ( h16 ":" h16 ) / IPv4address +ls32 = r"(?: (?: %(h16)s : %(h16)s ) | %(IPv4address)s )" % locals() + +# IPv6address = 6( h16 ":" ) ls32 +# / "::" 5( h16 ":" ) ls32 +# / [ h16 ] "::" 4( h16 ":" ) ls32 +# / [ *1( h16 ":" ) h16 ] "::" 3( h16 ":" ) ls32 +# / [ *2( h16 ":" ) h16 ] "::" 2( h16 ":" ) ls32 +# / [ *3( h16 ":" ) h16 ] "::" h16 ":" ls32 +# / [ *4( h16 ":" ) h16 ] "::" ls32 +# / [ *5( h16 ":" ) h16 ] "::" h16 +# / [ *6( h16 ":" ) h16 ] "::" +IPv6address = r"""(?: (?: %(h16)s : ){6} %(ls32)s | + :: (?: %(h16)s : ){5} %(ls32)s | + %(h16)s :: (?: %(h16)s : ){4} %(ls32)s | + (?: %(h16)s : ) %(h16)s :: (?: %(h16)s : ){3} %(ls32)s | + (?: %(h16)s : ){2} %(h16)s :: (?: %(h16)s : ){2} %(ls32)s | + (?: %(h16)s : ){3} %(h16)s :: %(h16)s : %(ls32)s | + (?: %(h16)s : ){4} %(h16)s :: %(ls32)s | + (?: %(h16)s : ){5} %(h16)s :: %(h16)s | + (?: %(h16)s : ){6} %(h16)s :: + ) +""" % locals() + +# IPvFuture = "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" ) +IPvFuture = r"v %(HEXDIG)s+ \. (?: %(unreserved)s | %(sub_delims)s | : )+" % locals() + +# IP-literal = "[" ( IPv6address / IPvFuture ) "]" +IP_literal = r"\[ (?: %(IPv6address)s | %(IPvFuture)s ) \]" % locals() + +# reg-name = *( unreserved / pct-encoded / sub-delims ) +reg_name = r"(?: %(unreserved)s | %(pct_encoded)s | %(sub_delims)s )*" % locals() + +# userinfo = *( unreserved / pct-encoded / sub-delims / ":" ) +userinfo = r"(?: %(unreserved)s | %(pct_encoded)s | %(sub_delims)s | : )" % locals( +) + +# host = IP-literal / IPv4address / reg-name +host = r"(?: %(IP_literal)s | %(IPv4address)s | %(reg_name)s )" % locals() + +# port = *DIGIT +port = r"(?: %(DIGIT)s )*" % locals() + +# authority = [ userinfo "@" ] host [ ":" port ] +authority = r"(?: %(userinfo)s @)? %(host)s (?: : %(port)s)?" % locals() + +# Path + +# segment = *pchar +segment = r"%(pchar)s*" % locals() + +# segment-nz = 1*pchar +segment_nz = r"%(pchar)s+" % locals() + +# segment-nz-nc = 1*( unreserved / pct-encoded / sub-delims / "@" ) +# ; non-zero-length segment without any colon ":" +segment_nz_nc = r"(?: %(unreserved)s | %(pct_encoded)s | %(sub_delims)s | @ )+" % locals() + +# path-abempty = *( "/" segment ) +path_abempty = r"(?: / %(segment)s )*" % locals() + +# path-absolute = "/" [ segment-nz *( "/" segment ) ] +path_absolute = r"/ (?: %(segment_nz)s (?: / %(segment)s )* )?" % locals() + +# path-noscheme = segment-nz-nc *( "/" segment ) +path_noscheme = r"%(segment_nz_nc)s (?: / %(segment)s )*" % locals() + +# path-rootless = segment-nz *( "/" segment ) +path_rootless = r"%(segment_nz)s (?: / %(segment)s )*" % locals() + +# path-empty = 0 +path_empty = r"" # FIXME + +# path = path-abempty ; begins with "/" or is empty +# / path-absolute ; begins with "/" but not "//" +# / path-noscheme ; begins with a non-colon segment +# / path-rootless ; begins with a segment +# / path-empty ; zero characters +path = r"""(?: %(path_abempty)s | + %(path_absolute)s | + %(path_noscheme)s | + %(path_rootless)s | + %(path_empty)s + ) +""" % locals() + +### Query and Fragment + +# query = *( pchar / "/" / "?" ) +query = r"(?: %(pchar)s | / | \? )*" % locals() + +# fragment = *( pchar / "/" / "?" ) +fragment = r"(?: %(pchar)s | / | \? )*" % locals() + +# URIs + +# hier-part = "//" authority path-abempty +# / path-absolute +# / path-rootless +# / path-empty +hier_part = r"""(?: (?: // %(authority)s %(path_abempty)s ) | + %(path_absolute)s | + %(path_rootless)s | + %(path_empty)s + ) +""" % locals() + +# relative-part = "//" authority path-abempty +# / path-absolute +# / path-noscheme +# / path-empty +relative_part = r"""(?: (?: // %(authority)s %(path_abempty)s ) | + %(path_absolute)s | + %(path_noscheme)s | + %(path_empty)s + ) +""" % locals() + +# relative-ref = relative-part [ "?" query ] [ "#" fragment ] +relative_ref = r"%(relative_part)s (?: \? %(query)s)? (?: \# %(fragment)s)?" % locals( +) + +# URI = scheme ":" hier-part [ "?" query ] [ "#" fragment ] +URI = r"^(?: %(scheme)s : %(hier_part)s (?: \? %(query)s )? (?: \# %(fragment)s )? )$" % locals( +) + +# URI-reference = URI / relative-ref +URI_reference = r"^(?: %(URI)s | %(relative_ref)s )$" % locals() + +# absolute-URI = scheme ":" hier-part [ "?" query ] +absolute_URI = r"^(?: %(scheme)s : %(hier_part)s (?: \? %(query)s )? )$" % locals( +) + + +def is_uri(uri): + return re.match(URI, uri, re.VERBOSE) + + +def is_uri_reference(uri): + return re.match(URI_reference, uri, re.VERBOSE) + + +def is_absolute_uri(uri): + return re.match(absolute_URI, uri, re.VERBOSE) diff --git a/requests_oauthlib/__init__.py b/requests_oauthlib/__init__.py new file mode 100644 index 0000000..89f668b --- /dev/null +++ b/requests_oauthlib/__init__.py @@ -0,0 +1,22 @@ +from .oauth1_auth import OAuth1 +from .oauth1_session import OAuth1Session +from .oauth2_auth import OAuth2 +from .oauth2_session import OAuth2Session, TokenUpdated + +__version__ = '0.4.2' + +import requests +if requests.__version__ < '2.0.0': + msg = ('You are using requests version %s, which is older than ' + 'requests-oauthlib expects, please upgrade to 2.0.0 or later.') + raise Warning(msg % requests.__version__) + +import logging +try: # Python 2.7+ + from logging import NullHandler +except ImportError: + class NullHandler(logging.Handler): + def emit(self, record): + pass + +logging.getLogger('requests_oauthlib').addHandler(NullHandler()) diff --git a/requests_oauthlib/compliance_fixes/__init__.py b/requests_oauthlib/compliance_fixes/__init__.py new file mode 100644 index 0000000..30cdd10 --- /dev/null +++ b/requests_oauthlib/compliance_fixes/__init__.py @@ -0,0 +1,5 @@ +from __future__ import absolute_import + +from .facebook import facebook_compliance_fix +from .linkedin import linkedin_compliance_fix +from .weibo import weibo_compliance_fix diff --git a/requests_oauthlib/compliance_fixes/douban.py b/requests_oauthlib/compliance_fixes/douban.py new file mode 100644 index 0000000..2e45b3b --- /dev/null +++ b/requests_oauthlib/compliance_fixes/douban.py @@ -0,0 +1,18 @@ +import json + +from oauthlib.common import to_unicode + + +def douban_compliance_fix(session): + + def fix_token_type(r): + token = json.loads(r.text) + token.setdefault('token_type', 'Bearer') + fixed_token = json.dumps(token) + r._content = to_unicode(fixed_token).encode('utf-8') + return r + + session._client_default_token_placement = 'query' + session.register_compliance_hook('access_token_response', fix_token_type) + + return session diff --git a/requests_oauthlib/compliance_fixes/facebook.py b/requests_oauthlib/compliance_fixes/facebook.py new file mode 100644 index 0000000..07181c3 --- /dev/null +++ b/requests_oauthlib/compliance_fixes/facebook.py @@ -0,0 +1,33 @@ +from json import dumps +try: + from urlparse import parse_qsl +except ImportError: + from urllib.parse import parse_qsl + +from oauthlib.common import to_unicode + + +def facebook_compliance_fix(session): + + def _compliance_fix(r): + # if Facebook claims to be sending us json, let's trust them. + if 'application/json' in r.headers.get('content-type', {}): + return r + + # Facebook returns a content-type of text/plain when sending their + # x-www-form-urlencoded responses, along with a 200. If not, let's + # assume we're getting JSON and bail on the fix. + if 'text/plain' in r.headers.get('content-type', {}) and r.status_code == 200: + token = dict(parse_qsl(r.text, keep_blank_values=True)) + else: + return r + + expires = token.get('expires') + if expires is not None: + token['expires_in'] = expires + token['token_type'] = 'Bearer' + r._content = to_unicode(dumps(token)).encode('UTF-8') + return r + + session.register_compliance_hook('access_token_response', _compliance_fix) + return session diff --git a/requests_oauthlib/compliance_fixes/linkedin.py b/requests_oauthlib/compliance_fixes/linkedin.py new file mode 100644 index 0000000..c6e4d68 --- /dev/null +++ b/requests_oauthlib/compliance_fixes/linkedin.py @@ -0,0 +1,24 @@ +from json import loads, dumps + +from oauthlib.common import add_params_to_uri, to_unicode + + +def linkedin_compliance_fix(session): + + def _missing_token_type(r): + token = loads(r.text) + token['token_type'] = 'Bearer' + r._content = to_unicode(dumps(token)).encode('UTF-8') + return r + + def _non_compliant_param_name(url, headers, data): + token = [('oauth2_access_token', session._client.access_token)] + url = add_params_to_uri(url, token) + return url, headers, data + + session._client.default_token_placement = 'query' + session.register_compliance_hook('access_token_response', + _missing_token_type) + session.register_compliance_hook('protected_request', + _non_compliant_param_name) + return session diff --git a/requests_oauthlib/compliance_fixes/weibo.py b/requests_oauthlib/compliance_fixes/weibo.py new file mode 100644 index 0000000..28aca32 --- /dev/null +++ b/requests_oauthlib/compliance_fixes/weibo.py @@ -0,0 +1,17 @@ +from json import loads, dumps + +from oauthlib.common import to_unicode + + +def weibo_compliance_fix(session): + + def _missing_token_type(r): + token = loads(r.text) + token['token_type'] = 'Bearer' + r._content = to_unicode(dumps(token)).encode('UTF-8') + return r + + session._client.default_token_placement = 'query' + session.register_compliance_hook('access_token_response', + _missing_token_type) + return session diff --git a/requests_oauthlib/oauth1_auth.py b/requests_oauthlib/oauth1_auth.py new file mode 100644 index 0000000..303ecd2 --- /dev/null +++ b/requests_oauthlib/oauth1_auth.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import logging + +from oauthlib.common import extract_params +from oauthlib.oauth1 import Client, SIGNATURE_HMAC, SIGNATURE_TYPE_AUTH_HEADER +from oauthlib.oauth1 import SIGNATURE_TYPE_BODY +from requests.compat import is_py3 +from requests.utils import to_native_string + +CONTENT_TYPE_FORM_URLENCODED = 'application/x-www-form-urlencoded' +CONTENT_TYPE_MULTI_PART = 'multipart/form-data' + +if is_py3: + unicode = str + +log = logging.getLogger(__name__) + +# OBS!: Correct signing of requests are conditional on invoking OAuth1 +# as the last step of preparing a request, or at least having the +# content-type set properly. +class OAuth1(object): + """Signs the request using OAuth 1 (RFC5849)""" + + client_class = Client + + def __init__(self, client_key, + client_secret=None, + resource_owner_key=None, + resource_owner_secret=None, + callback_uri=None, + signature_method=SIGNATURE_HMAC, + signature_type=SIGNATURE_TYPE_AUTH_HEADER, + rsa_key=None, verifier=None, + decoding='utf-8', + client_class=None, + force_include_body=False, + **kwargs): + + try: + signature_type = signature_type.upper() + except AttributeError: + pass + + client_class = client_class or self.client_class + + self.force_include_body = force_include_body + + self.client = client_class(client_key, client_secret, resource_owner_key, + resource_owner_secret, callback_uri, signature_method, + signature_type, rsa_key, verifier, decoding=decoding, **kwargs) + + def __call__(self, r): + """Add OAuth parameters to the request. + + Parameters may be included from the body if the content-type is + urlencoded, if no content type is set a guess is made. + """ + # Overwriting url is safe here as request will not modify it past + # this point. + log.debug('Signing request %s using client %s', r, self.client) + + content_type = r.headers.get('Content-Type', '') + if (not content_type and extract_params(r.body) + or self.client.signature_type == SIGNATURE_TYPE_BODY): + content_type = CONTENT_TYPE_FORM_URLENCODED + if not isinstance(content_type, unicode): + content_type = content_type.decode('utf-8') + + is_form_encoded = (CONTENT_TYPE_FORM_URLENCODED in content_type) + + log.debug('Including body in call to sign: %s', + is_form_encoded or self.force_include_body) + + if is_form_encoded: + r.headers['Content-Type'] = CONTENT_TYPE_FORM_URLENCODED + r.url, headers, r.body = self.client.sign( + unicode(r.url), unicode(r.method), r.body or '', r.headers) + elif self.force_include_body: + # To allow custom clients to work on non form encoded bodies. + r.url, headers, r.body = self.client.sign( + unicode(r.url), unicode(r.method), r.body or '', r.headers) + else: + # Omit body data in the signing of non form-encoded requests + r.url, headers, _ = self.client.sign( + unicode(r.url), unicode(r.method), None, r.headers) + + r.prepare_headers(headers) + r.url = to_native_string(r.url) + log.debug('Updated url: %s', r.url) + log.debug('Updated headers: %s', headers) + log.debug('Updated body: %r', r.body) + return r diff --git a/requests_oauthlib/oauth1_session.py b/requests_oauthlib/oauth1_session.py new file mode 100644 index 0000000..3d129ac --- /dev/null +++ b/requests_oauthlib/oauth1_session.py @@ -0,0 +1,371 @@ +from __future__ import unicode_literals + +try: + from urlparse import urlparse +except ImportError: + from urllib.parse import urlparse + +import logging + +from oauthlib.common import add_params_to_uri +from oauthlib.common import urldecode as _urldecode +from oauthlib.oauth1 import ( + SIGNATURE_HMAC, SIGNATURE_RSA, SIGNATURE_TYPE_AUTH_HEADER +) +import requests + +from . import OAuth1 + +import sys +if sys.version > "3": + unicode = str + + +log = logging.getLogger(__name__) + + +def urldecode(body): + """Parse query or json to python dictionary""" + try: + return _urldecode(body) + except: + import json + return json.loads(body) + + +class TokenRequestDenied(ValueError): + + def __init__(self, message, status_code): + super(TokenRequestDenied, self).__init__(message) + self.status_code = status_code + + +class TokenMissing(ValueError): + def __init__(self, message, response): + super(TokenMissing, self).__init__(message) + self.response = response + + +class VerifierMissing(ValueError): + pass + + +class OAuth1Session(requests.Session): + """Request signing and convenience methods for the oauth dance. + + What is the difference between OAuth1Session and OAuth1? + + OAuth1Session actually uses OAuth1 internally and its purpose is to assist + in the OAuth workflow through convenience methods to prepare authorization + URLs and parse the various token and redirection responses. It also provide + rudimentary validation of responses. + + An example of the OAuth workflow using a basic CLI app and Twitter. + + >>> # Credentials obtained during the registration. + >>> client_key = 'client key' + >>> client_secret = 'secret' + >>> callback_uri = 'https://127.0.0.1/callback' + >>> + >>> # Endpoints found in the OAuth provider API documentation + >>> request_token_url = 'https://api.twitter.com/oauth/request_token' + >>> authorization_url = 'https://api.twitter.com/oauth/authorize' + >>> access_token_url = 'https://api.twitter.com/oauth/access_token' + >>> + >>> oauth_session = OAuth1Session(client_key,client_secret=client_secret, callback_uri=callback_uri) + >>> + >>> # First step, fetch the request token. + >>> oauth_session.fetch_request_token(request_token_url) + { + 'oauth_token': 'kjerht2309u', + 'oauth_token_secret': 'lsdajfh923874', + } + >>> + >>> # Second step. Follow this link and authorize + >>> oauth_session.authorization_url(authorization_url) + 'https://api.twitter.com/oauth/authorize?oauth_token=sdf0o9823sjdfsdf&oauth_callback=https%3A%2F%2F127.0.0.1%2Fcallback' + >>> + >>> # Third step. Fetch the access token + >>> redirect_response = raw_input('Paste the full redirect URL here.') + >>> oauth_session.parse_authorization_response(redirect_response) + { + 'oauth_token: 'kjerht2309u', + 'oauth_token_secret: 'lsdajfh923874', + 'oauth_verifier: 'w34o8967345', + } + >>> oauth_session.fetch_access_token(access_token_url) + { + 'oauth_token': 'sdf0o9823sjdfsdf', + 'oauth_token_secret': '2kjshdfp92i34asdasd', + } + >>> # Done. You can now make OAuth requests. + >>> status_url = 'http://api.twitter.com/1/statuses/update.json' + >>> new_status = {'status': 'hello world!'} + >>> oauth_session.post(status_url, data=new_status) + + """ + + def __init__(self, client_key, + client_secret=None, + resource_owner_key=None, + resource_owner_secret=None, + callback_uri=None, + signature_method=SIGNATURE_HMAC, + signature_type=SIGNATURE_TYPE_AUTH_HEADER, + rsa_key=None, + verifier=None, + client_class=None, + force_include_body=False, + **kwargs): + """Construct the OAuth 1 session. + + :param client_key: A client specific identifier. + :param client_secret: A client specific secret used to create HMAC and + plaintext signatures. + :param resource_owner_key: A resource owner key, also referred to as + request token or access token depending on + when in the workflow it is used. + :param resource_owner_secret: A resource owner secret obtained with + either a request or access token. Often + referred to as token secret. + :param callback_uri: The URL the user is redirect back to after + authorization. + :param signature_method: Signature methods determine how the OAuth + signature is created. The three options are + oauthlib.oauth1.SIGNATURE_HMAC (default), + oauthlib.oauth1.SIGNATURE_RSA and + oauthlib.oauth1.SIGNATURE_PLAIN. + :param signature_type: Signature type decides where the OAuth + parameters are added. Either in the + Authorization header (default) or to the URL + query parameters or the request body. Defined as + oauthlib.oauth1.SIGNATURE_TYPE_AUTH_HEADER, + oauthlib.oauth1.SIGNATURE_TYPE_QUERY and + oauthlib.oauth1.SIGNATURE_TYPE_BODY + respectively. + :param rsa_key: The private RSA key as a string. Can only be used with + signature_method=oauthlib.oauth1.SIGNATURE_RSA. + :param verifier: A verifier string to prove authorization was granted. + :param client_class: A subclass of `oauthlib.oauth1.Client` to use with + `requests_oauthlib.OAuth1` instead of the default + :param force_include_body: Always include the request body in the + signature creation. + :param **kwargs: Additional keyword arguments passed to `OAuth1` + """ + super(OAuth1Session, self).__init__() + self._client = OAuth1(client_key, + client_secret=client_secret, + resource_owner_key=resource_owner_key, + resource_owner_secret=resource_owner_secret, + callback_uri=callback_uri, + signature_method=signature_method, + signature_type=signature_type, + rsa_key=rsa_key, + verifier=verifier, + client_class=client_class, + force_include_body=force_include_body, + **kwargs) + self.auth = self._client + + @property + def authorized(self): + """Boolean that indicates whether this session has an OAuth token + or not. If `self.authorized` is True, you can reasonably expect + OAuth-protected requests to the resource to succeed. If + `self.authorized` is False, you need the user to go through the OAuth + authentication dance before OAuth-protected requests to the resource + will succeed. + """ + if self._client.client.signature_method == SIGNATURE_RSA: + # RSA only uses resource_owner_key + return bool(self._client.client.resource_owner_key) + else: + # other methods of authentication use all three pieces + return ( + bool(self._client.client.client_secret) and + bool(self._client.client.resource_owner_key) and + bool(self._client.client.resource_owner_secret) + ) + + def authorization_url(self, url, request_token=None, **kwargs): + """Create an authorization URL by appending request_token and optional + kwargs to url. + + This is the second step in the OAuth 1 workflow. The user should be + redirected to this authorization URL, grant access to you, and then + be redirected back to you. The redirection back can either be specified + during client registration or by supplying a callback URI per request. + + :param url: The authorization endpoint URL. + :param request_token: The previously obtained request token. + :param kwargs: Optional parameters to append to the URL. + :returns: The authorization URL with new parameters embedded. + + An example using a registered default callback URI. + + >>> request_token_url = 'https://api.twitter.com/oauth/request_token' + >>> authorization_url = 'https://api.twitter.com/oauth/authorize' + >>> oauth_session = OAuth1Session('client-key', client_secret='secret') + >>> oauth_session.fetch_request_token(request_token_url) + { + 'oauth_token': 'sdf0o9823sjdfsdf', + 'oauth_token_secret': '2kjshdfp92i34asdasd', + } + >>> oauth_session.authorization_url(authorization_url) + 'https://api.twitter.com/oauth/authorize?oauth_token=sdf0o9823sjdfsdf' + >>> oauth_session.authorization_url(authorization_url, foo='bar') + 'https://api.twitter.com/oauth/authorize?oauth_token=sdf0o9823sjdfsdf&foo=bar' + + An example using an explicit callback URI. + + >>> request_token_url = 'https://api.twitter.com/oauth/request_token' + >>> authorization_url = 'https://api.twitter.com/oauth/authorize' + >>> oauth_session = OAuth1Session('client-key', client_secret='secret', callback_uri='https://127.0.0.1/callback') + >>> oauth_session.fetch_request_token(request_token_url) + { + 'oauth_token': 'sdf0o9823sjdfsdf', + 'oauth_token_secret': '2kjshdfp92i34asdasd', + } + >>> oauth_session.authorization_url(authorization_url) + 'https://api.twitter.com/oauth/authorize?oauth_token=sdf0o9823sjdfsdf&oauth_callback=https%3A%2F%2F127.0.0.1%2Fcallback' + """ + kwargs['oauth_token'] = request_token or self._client.client.resource_owner_key + log.debug('Adding parameters %s to url %s', kwargs, url) + return add_params_to_uri(url, kwargs.items()) + + def fetch_request_token(self, url, realm=None): + """Fetch a request token. + + This is the first step in the OAuth 1 workflow. A request token is + obtained by making a signed post request to url. The token is then + parsed from the application/x-www-form-urlencoded response and ready + to be used to construct an authorization url. + + :param url: The request token endpoint URL. + :param realm: A list of realms to request access to. + :returns: The response in dict format. + + Note that a previously set callback_uri will be reset for your + convenience, or else signature creation will be incorrect on + consecutive requests. + + >>> request_token_url = 'https://api.twitter.com/oauth/request_token' + >>> oauth_session = OAuth1Session('client-key', client_secret='secret') + >>> oauth_session.fetch_request_token(request_token_url) + { + 'oauth_token': 'sdf0o9823sjdfsdf', + 'oauth_token_secret': '2kjshdfp92i34asdasd', + } + """ + self._client.client.realm = ' '.join(realm) if realm else None + token = self._fetch_token(url) + log.debug('Resetting callback_uri and realm (not needed in next phase).') + self._client.client.callback_uri = None + self._client.client.realm = None + return token + + def fetch_access_token(self, url, verifier=None): + """Fetch an access token. + + This is the final step in the OAuth 1 workflow. An access token is + obtained using all previously obtained credentials, including the + verifier from the authorization step. + + Note that a previously set verifier will be reset for your + convenience, or else signature creation will be incorrect on + consecutive requests. + + >>> access_token_url = 'https://api.twitter.com/oauth/access_token' + >>> redirect_response = 'https://127.0.0.1/callback?oauth_token=kjerht2309uf&oauth_token_secret=lsdajfh923874&oauth_verifier=w34o8967345' + >>> oauth_session = OAuth1Session('client-key', client_secret='secret') + >>> oauth_session.parse_authorization_response(redirect_response) + { + 'oauth_token: 'kjerht2309u', + 'oauth_token_secret: 'lsdajfh923874', + 'oauth_verifier: 'w34o8967345', + } + >>> oauth_session.fetch_access_token(access_token_url) + { + 'oauth_token': 'sdf0o9823sjdfsdf', + 'oauth_token_secret': '2kjshdfp92i34asdasd', + } + """ + if verifier: + self._client.client.verifier = verifier + if not getattr(self._client.client, 'verifier', None): + raise VerifierMissing('No client verifier has been set.') + token = self._fetch_token(url) + log.debug('Resetting verifier attribute, should not be used anymore.') + self._client.client.verifier = None + return token + + def parse_authorization_response(self, url): + """Extract parameters from the post authorization redirect response URL. + + :param url: The full URL that resulted from the user being redirected + back from the OAuth provider to you, the client. + :returns: A dict of parameters extracted from the URL. + + >>> redirect_response = 'https://127.0.0.1/callback?oauth_token=kjerht2309uf&oauth_token_secret=lsdajfh923874&oauth_verifier=w34o8967345' + >>> oauth_session = OAuth1Session('client-key', client_secret='secret') + >>> oauth_session.parse_authorization_response(redirect_response) + { + 'oauth_token: 'kjerht2309u', + 'oauth_token_secret: 'lsdajfh923874', + 'oauth_verifier: 'w34o8967345', + } + """ + log.debug('Parsing token from query part of url %s', url) + token = dict(urldecode(urlparse(url).query)) + log.debug('Updating internal client token attribute.') + self._populate_attributes(token) + return token + + def _populate_attributes(self, token): + if 'oauth_token' in token: + self._client.client.resource_owner_key = token['oauth_token'] + else: + raise TokenMissing( + 'Response does not contain a token: {resp}'.format(resp=token), + token, + ) + if 'oauth_token_secret' in token: + self._client.client.resource_owner_secret = ( + token['oauth_token_secret']) + if 'oauth_verifier' in token: + self._client.client.verifier = token['oauth_verifier'] + + def _fetch_token(self, url): + log.debug('Fetching token from %s using client %s', url, self._client.client) + r = self.post(url) + + if r.status_code >= 400: + error = "Token request failed with code %s, response was '%s'." + raise TokenRequestDenied(error % (r.status_code, r.text), r.status_code) + + log.debug('Decoding token from response "%s"', r.text) + try: + token = dict(urldecode(r.text)) + except ValueError as e: + error = ("Unable to decode token from token response. " + "This is commonly caused by an unsuccessful request where" + " a non urlencoded error message is returned. " + "The decoding error was %s""" % e) + raise ValueError(error) + + log.debug('Obtained token %s', token) + log.debug('Updating internal client attributes from token data.') + self._populate_attributes(token) + return token + + def rebuild_auth(self, prepared_request, response): + """ + When being redirected we should always strip Authorization + header, since nonce may not be reused as per OAuth spec. + """ + if 'Authorization' in prepared_request.headers: + # If we get redirected to a new host, we should strip out + # any authentication headers. + prepared_request.headers.pop('Authorization', True) + prepared_request.prepare_auth(self.auth) + return diff --git a/requests_oauthlib/oauth2_auth.py b/requests_oauthlib/oauth2_auth.py new file mode 100644 index 0000000..42366e7 --- /dev/null +++ b/requests_oauthlib/oauth2_auth.py @@ -0,0 +1,35 @@ +from __future__ import unicode_literals +from oauthlib.oauth2 import WebApplicationClient, InsecureTransportError +from oauthlib.oauth2 import is_secure_transport + + +class OAuth2(object): + """Adds proof of authorization (OAuth2 token) to the request.""" + + def __init__(self, client_id=None, client=None, token=None): + """Construct a new OAuth 2 authorization object. + + :param client_id: Client id obtained during registration + :param client: :class:`oauthlib.oauth2.Client` to be used. Default is + WebApplicationClient which is useful for any + hosted application but not mobile or desktop. + :param token: Token dictionary, must include access_token + and token_type. + """ + self._client = client or WebApplicationClient(client_id, token=token) + if token: + for k, v in token.items(): + setattr(self._client, k, v) + + def __call__(self, r): + """Append an OAuth 2 token to the request. + + Note that currently HTTPS is required for all requests. There may be + a token type that allows for plain HTTP in the future and then this + should be updated to allow plain HTTP on a white list basis. + """ + if not is_secure_transport(r.url): + raise InsecureTransportError() + r.url, r.headers, r.body = self._client.add_token(r.url, + http_method=r.method, body=r.body, headers=r.headers) + return r diff --git a/requests_oauthlib/oauth2_session.py b/requests_oauthlib/oauth2_session.py new file mode 100644 index 0000000..245cb8a --- /dev/null +++ b/requests_oauthlib/oauth2_session.py @@ -0,0 +1,317 @@ +from __future__ import unicode_literals + +import logging + +from oauthlib.common import generate_token, urldecode +from oauthlib.oauth2 import WebApplicationClient, InsecureTransportError +from oauthlib.oauth2 import TokenExpiredError, is_secure_transport +import requests + +log = logging.getLogger(__name__) + + +class TokenUpdated(Warning): + def __init__(self, token): + super(TokenUpdated, self).__init__() + self.token = token + + +class OAuth2Session(requests.Session): + """Versatile OAuth 2 extension to :class:`requests.Session`. + + Supports any grant type adhering to :class:`oauthlib.oauth2.Client` spec + including the four core OAuth 2 grants. + + Can be used to create authorization urls, fetch tokens and access protected + resources using the :class:`requests.Session` interface you are used to. + + - :class:`oauthlib.oauth2.WebApplicationClient` (default): Authorization Code Grant + - :class:`oauthlib.oauth2.MobileApplicationClient`: Implicit Grant + - :class:`oauthlib.oauth2.LegacyApplicationClient`: Password Credentials Grant + - :class:`oauthlib.oauth2.BackendApplicationClient`: Client Credentials Grant + + Note that the only time you will be using Implicit Grant from python is if + you are driving a user agent able to obtain URL fragments. + """ + + def __init__(self, client_id=None, client=None, auto_refresh_url=None, + auto_refresh_kwargs=None, scope=None, redirect_uri=None, token=None, + state=None, token_updater=None, **kwargs): + """Construct a new OAuth 2 client session. + + :param client_id: Client id obtained during registration + :param client: :class:`oauthlib.oauth2.Client` to be used. Default is + WebApplicationClient which is useful for any + hosted application but not mobile or desktop. + :param scope: List of scopes you wish to request access to + :param redirect_uri: Redirect URI you registered as callback + :param token: Token dictionary, must include access_token + and token_type. + :param state: State string used to prevent CSRF. This will be given + when creating the authorization url and must be supplied + when parsing the authorization response. + Can be either a string or a no argument callable. + :auto_refresh_url: Refresh token endpoint URL, must be HTTPS. Supply + this if you wish the client to automatically refresh + your access tokens. + :auto_refresh_kwargs: Extra arguments to pass to the refresh token + endpoint. + :token_updater: Method with one argument, token, to be used to update + your token databse on automatic token refresh. If not + set a TokenUpdated warning will be raised when a token + has been refreshed. This warning will carry the token + in its token argument. + :param kwargs: Arguments to pass to the Session constructor. + """ + super(OAuth2Session, self).__init__(**kwargs) + self.client_id = client_id or client.client_id + self.scope = scope + self.redirect_uri = redirect_uri + self.token = token or {} + self.state = state or generate_token + self._state = state + self.auto_refresh_url = auto_refresh_url + self.auto_refresh_kwargs = auto_refresh_kwargs or {} + self.token_updater = token_updater + self._client = client or WebApplicationClient(client_id, token=token) + self._client._populate_attributes(token or {}) + + # Allow customizations for non compliant providers through various + # hooks to adjust requests and responses. + self.compliance_hook = { + 'access_token_response': set([]), + 'refresh_token_response': set([]), + 'protected_request': set([]), + } + + def new_state(self): + """Generates a state string to be used in authorizations.""" + try: + self._state = self.state() + log.debug('Generated new state %s.', self._state) + except TypeError: + self._state = self.state + log.debug('Re-using previously supplied state %s.', self._state) + return self._state + + @property + def authorized(self): + """Boolean that indicates whether this session has an OAuth token + or not. If `self.authorized` is True, you can reasonably expect + OAuth-protected requests to the resource to succeed. If + `self.authorized` is False, you need the user to go through the OAuth + authentication dance before OAuth-protected requests to the resource + will succeed. + """ + return bool(self._client.access_token) + + def authorization_url(self, url, state=None, **kwargs): + """Form an authorization URL. + + :param url: Authorization endpoint url, must be HTTPS. + :param state: An optional state string for CSRF protection. If not + given it will be generated for you. + :param kwargs: Extra parameters to include. + :return: authorization_url, state + """ + state = state or self.new_state() + return self._client.prepare_request_uri(url, + redirect_uri=self.redirect_uri, + scope=self.scope, + state=state, + **kwargs), state + + def fetch_token(self, token_url, code=None, authorization_response=None, + body='', auth=None, username=None, password=None, method='POST', + timeout=None, headers=None, verify=True, **kwargs): + """Generic method for fetching an access token from the token endpoint. + + If you are using the MobileApplicationClient you will want to use + token_from_fragment instead of fetch_token. + + :param token_url: Token endpoint URL, must use HTTPS. + :param code: Authorization code (used by WebApplicationClients). + :param authorization_response: Authorization response URL, the callback + URL of the request back to you. Used by + WebApplicationClients instead of code. + :param body: Optional application/x-www-form-urlencoded body to add the + include in the token request. Prefer kwargs over body. + :param auth: An auth tuple or method as accepted by requests. + :param username: Username used by LegacyApplicationClients. + :param password: Password used by LegacyApplicationClients. + :param method: The HTTP method used to make the request. Defaults + to POST, but may also be GET. Other methods should + be added as needed. + :param headers: Dict to default request headers with. + :param timeout: Timeout of the request in seconds. + :param verify: Verify SSL certificate. + :param kwargs: Extra parameters to include in the token request. + :return: A token dict + """ + if not is_secure_transport(token_url): + raise InsecureTransportError() + + if not code and authorization_response: + self._client.parse_request_uri_response(authorization_response, + state=self._state) + code = self._client.code + elif not code and isinstance(self._client, WebApplicationClient): + code = self._client.code + if not code: + raise ValueError('Please supply either code or ' + 'authorization_code parameters.') + + + body = self._client.prepare_request_body(code=code, body=body, + redirect_uri=self.redirect_uri, username=username, + password=password, **kwargs) + + headers = headers or { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + } + if method.upper() == 'POST': + r = self.post(token_url, data=dict(urldecode(body)), + timeout=timeout, headers=headers, auth=auth, + verify=verify) + log.debug('Prepared fetch token request body %s', body) + elif method.upper() == 'GET': + # if method is not 'POST', switch body to querystring and GET + r = self.get(token_url, params=dict(urldecode(body)), + timeout=timeout, headers=headers, auth=auth, + verify=verify) + log.debug('Prepared fetch token request querystring %s', body) + else: + raise ValueError('The method kwarg must be POST or GET.') + + log.debug('Request to fetch token completed with status %s.', + r.status_code) + log.debug('Request headers were %s', r.request.headers) + log.debug('Request body was %s', r.request.body) + log.debug('Response headers were %s and content %s.', + r.headers, r.text) + log.debug('Invoking %d token response hooks.', + len(self.compliance_hook['access_token_response'])) + for hook in self.compliance_hook['access_token_response']: + log.debug('Invoking hook %s.', hook) + r = hook(r) + + r.raise_for_status() + + self._client.parse_request_body_response(r.text, scope=self.scope) + self.token = self._client.token + log.debug('Obtained token %s.', self.token) + return self.token + + def token_from_fragment(self, authorization_response): + """Parse token from the URI fragment, used by MobileApplicationClients. + + :param authorization_response: The full URL of the redirect back to you + :return: A token dict + """ + self._client.parse_request_uri_response(authorization_response, + state=self._state) + self.token = self._client.token + return self.token + + def refresh_token(self, token_url, refresh_token=None, body='', auth=None, + timeout=None, verify=True, **kwargs): + """Fetch a new access token using a refresh token. + + :param token_url: The token endpoint, must be HTTPS. + :param refresh_token: The refresh_token to use. + :param body: Optional application/x-www-form-urlencoded body to add the + include in the token request. Prefer kwargs over body. + :param auth: An auth tuple or method as accepted by requests. + :param timeout: Timeout of the request in seconds. + :param verify: Verify SSL certificate. + :param kwargs: Extra parameters to include in the token request. + :return: A token dict + """ + if not token_url: + raise ValueError('No token endpoint set for auto_refresh.') + + if not is_secure_transport(token_url): + raise InsecureTransportError() + + # Need to nullify token to prevent it from being added to the request + refresh_token = refresh_token or self.token.get('refresh_token') + self.token = {} + + log.debug('Adding auto refresh key word arguments %s.', + self.auto_refresh_kwargs) + kwargs.update(self.auto_refresh_kwargs) + body = self._client.prepare_refresh_body(body=body, + refresh_token=refresh_token, scope=self.scope, **kwargs) + log.debug('Prepared refresh token request body %s', body) + r = self.post(token_url, data=dict(urldecode(body)), auth=auth, + timeout=timeout, verify=verify) + log.debug('Request to refresh token completed with status %s.', + r.status_code) + log.debug('Response headers were %s and content %s.', + r.headers, r.text) + log.debug('Invoking %d token response hooks.', + len(self.compliance_hook['refresh_token_response'])) + for hook in self.compliance_hook['refresh_token_response']: + log.debug('Invoking hook %s.', hook) + r = hook(r) + + self.token = self._client.parse_request_body_response(r.text, scope=self.scope) + if not 'refresh_token' in self.token: + log.debug('No new refresh token given. Re-using old.') + self.token['refresh_token'] = refresh_token + return self.token + + def request(self, method, url, data=None, headers=None, **kwargs): + """Intercept all requests and add the OAuth 2 token if present.""" + if not is_secure_transport(url): + raise InsecureTransportError() + if self.token: + log.debug('Invoking %d protected resource request hooks.', + len(self.compliance_hook['protected_request'])) + for hook in self.compliance_hook['protected_request']: + log.debug('Invoking hook %s.', hook) + url, headers, data = hook(url, headers, data) + + log.debug('Adding token %s to request.', self.token) + try: + url, headers, data = self._client.add_token(url, + http_method=method, body=data, headers=headers) + # Attempt to retrieve and save new access token if expired + except TokenExpiredError: + if self.auto_refresh_url: + log.debug('Auto refresh is set, attempting to refresh at %s.', + self.auto_refresh_url) + token = self.refresh_token(self.auto_refresh_url) + if self.token_updater: + log.debug('Updating token to %s using %s.', + token, self.token_updater) + self.token_updater(token) + url, headers, data = self._client.add_token(url, + http_method=method, body=data, headers=headers) + else: + raise TokenUpdated(token) + else: + raise + + log.debug('Requesting url %s using method %s.', url, method) + log.debug('Supplying headers %s and data %s', headers, data) + log.debug('Passing through key word arguments %s.', kwargs) + return super(OAuth2Session, self).request(method, url, + headers=headers, data=data, **kwargs) + + def register_compliance_hook(self, hook_type, hook): + """Register a hook for request/response tweaking. + + Available hooks are: + access_token_response invoked before token parsing. + refresh_token_response invoked before refresh token parsing. + protected_request invoked before making a request. + + If you find a new hook is needed please send a GitHub PR request + or open an issue. + """ + if hook_type not in self.compliance_hook: + raise ValueError('Hook type %s is not in %s.', + hook_type, self.compliance_hook) + self.compliance_hook[hook_type].add(hook) diff --git a/tweepy/__init__.py b/tweepy/__init__.py index ff0005f..b691e34 100644 --- a/tweepy/__init__.py +++ b/tweepy/__init__.py @@ -5,7 +5,7 @@ """ Tweepy Twitter API library """ -__version__ = '2.3' +__version__ = '3.3.0' __author__ = 'Joshua Roesslein' __license__ = 'MIT' @@ -13,7 +13,7 @@ from tweepy.models import Status, User, DirectMessage, Friendship, SavedSearch, from tweepy.error import TweepError from tweepy.api import API from tweepy.cache import Cache, MemoryCache, FileCache -from tweepy.auth import OAuthHandler +from tweepy.auth import OAuthHandler, AppAuthHandler from tweepy.streaming import Stream, StreamListener from tweepy.cursor import Cursor @@ -21,7 +21,5 @@ from tweepy.cursor import Cursor api = API() def debug(enable=True, level=1): - - import httplib - httplib.HTTPConnection.debuglevel = level - + from six.moves.http_client import HTTPConnection + HTTPConnection.debuglevel = level diff --git a/tweepy/api.py b/tweepy/api.py index 1d53079..4744275 100644 --- a/tweepy/api.py +++ b/tweepy/api.py @@ -2,12 +2,16 @@ # Copyright 2009-2010 Joshua Roesslein # See LICENSE for details. +from __future__ import print_function + import os import mimetypes +import six + from tweepy.binder import bind_api from tweepy.error import TweepError -from tweepy.parsers import ModelParser +from tweepy.parsers import ModelParser, Parser from tweepy.utils import list_to_csv @@ -15,698 +19,1269 @@ class API(object): """Twitter API""" def __init__(self, auth_handler=None, - host='api.twitter.com', search_host='search.twitter.com', - cache=None, secure=True, api_root='/1.1', search_root='', - retry_count=0, retry_delay=0, retry_errors=None, timeout=60, - parser=None, compression=False): + host='api.twitter.com', search_host='search.twitter.com', + upload_host='upload.twitter.com', cache=None, api_root='/1.1', + search_root='', upload_root='/1.1', retry_count=0, + retry_delay=0, retry_errors=None, timeout=60, parser=None, + compression=False, wait_on_rate_limit=False, + wait_on_rate_limit_notify=False, proxy=''): + """ Api instance Constructor + + :param auth_handler: + :param host: url of the server of the rest api, default:'api.twitter.com' + :param search_host: url of the search server, default:'search.twitter.com' + :param upload_host: url of the upload server, default:'upload.twitter.com' + :param cache: Cache to query if a GET method is used, default:None + :param api_root: suffix of the api version, default:'/1.1' + :param search_root: suffix of the search version, default:'' + :param upload_root: suffix of the upload version, default:'/1.1' + :param retry_count: number of allowed retries, default:0 + :param retry_delay: delay in second between retries, default:0 + :param retry_errors: default:None + :param timeout: delay before to consider the request as timed out in seconds, default:60 + :param parser: ModelParser instance to parse the responses, default:None + :param compression: If the response is compressed, default:False + :param wait_on_rate_limit: If the api wait when it hits the rate limit, default:False + :param wait_on_rate_limit_notify: If the api print a notification when the rate limit is hit, default:False + :param proxy: Url to use as proxy during the HTTP request, default:'' + + :raise TypeError: If the given parser is not a ModelParser instance. + """ self.auth = auth_handler self.host = host self.search_host = search_host + self.upload_host = upload_host self.api_root = api_root self.search_root = search_root + self.upload_root = upload_root self.cache = cache - self.secure = secure self.compression = compression self.retry_count = retry_count self.retry_delay = retry_delay self.retry_errors = retry_errors self.timeout = timeout + self.wait_on_rate_limit = wait_on_rate_limit + self.wait_on_rate_limit_notify = wait_on_rate_limit_notify self.parser = parser or ModelParser() + self.proxy = {} + if proxy: + self.proxy['https'] = proxy + + # Attempt to explain more clearly the parser argument requirements + # https://github.com/tweepy/tweepy/issues/421 + # + parser_type = Parser + if not isinstance(self.parser, parser_type): + raise TypeError( + '"parser" argument has to be an instance of "{required}".' + ' It is currently a {actual}.'.format( + required=parser_type.__name__, + actual=type(self.parser) + ) + ) + + @property + def home_timeline(self): + """ :reference: https://dev.twitter.com/rest/reference/get/statuses/home_timeline + :allowed_param:'since_id', 'max_id', 'count' + """ + return bind_api( + api=self, + path='/statuses/home_timeline.json', + payload_type='status', payload_list=True, + allowed_param=['since_id', 'max_id', 'count'], + require_auth=True + ) + + def statuses_lookup(self, id_, include_entities=None, + trim_user=None, map_=None): + return self._statuses_lookup(list_to_csv(id_), include_entities, + trim_user, map_) + + @property + def _statuses_lookup(self): + """ :reference: https://dev.twitter.com/rest/reference/get/statuses/lookup + :allowed_param:'id', 'include_entities', 'trim_user', 'map' + """ + return bind_api( + api=self, + path='/statuses/lookup.json', + payload_type='status', payload_list=True, + allowed_param=['id', 'include_entities', 'trim_user', 'map'], + require_auth=True + ) + + @property + def user_timeline(self): + """ :reference: https://dev.twitter.com/rest/reference/get/statuses/user_timeline + :allowed_param:'id', 'user_id', 'screen_name', 'since_id' + """ + return bind_api( + api=self, + path='/statuses/user_timeline.json', + payload_type='status', payload_list=True, + allowed_param=['id', 'user_id', 'screen_name', 'since_id', + 'max_id', 'count', 'include_rts'] + ) + + @property + def mentions_timeline(self): + """ :reference: https://dev.twitter.com/rest/reference/get/statuses/mentions_timeline + :allowed_param:'since_id', 'max_id', 'count' + """ + return bind_api( + api=self, + path='/statuses/mentions_timeline.json', + payload_type='status', payload_list=True, + allowed_param=['since_id', 'max_id', 'count'], + require_auth=True + ) + + @property + def related_results(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/get/related_results/show/%3id.format + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/related_results/show/{id}.json', + payload_type='relation', payload_list=True, + allowed_param=['id'], + require_auth=False + ) + + @property + def retweets_of_me(self): + """ :reference: https://dev.twitter.com/rest/reference/get/statuses/retweets_of_me + :allowed_param:'since_id', 'max_id', 'count' + """ + return bind_api( + api=self, + path='/statuses/retweets_of_me.json', + payload_type='status', payload_list=True, + allowed_param=['since_id', 'max_id', 'count'], + require_auth=True + ) + + @property + def get_status(self): + """ :reference: https://dev.twitter.com/rest/reference/get/statuses/show/%3Aid + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/statuses/show.json', + payload_type='status', + allowed_param=['id'] + ) + + def update_status(self, media_ids=None, *args, **kwargs): + """ :reference: https://dev.twitter.com/rest/reference/post/statuses/update + :allowed_param:'status', 'in_reply_to_status_id', 'lat', 'long', 'source', 'place_id', 'display_coordinates', 'media_ids' + """ + post_data = {} + if media_ids is not None: + post_data["media_ids"] = list_to_csv(media_ids) + + return bind_api( + api=self, + path='/statuses/update.json', + method='POST', + payload_type='status', + allowed_param=['status', 'in_reply_to_status_id', 'lat', 'long', 'source', 'place_id', 'display_coordinates'], + require_auth=True + )(post_data=post_data, *args, **kwargs) + + def media_upload(self, filename, *args, **kwargs): + """ :reference: https://dev.twitter.com/rest/reference/post/media/upload + :allowed_param: + """ + f = kwargs.pop('file', None) + headers, post_data = API._pack_image(filename, 3072, form_field='media', f=f) + kwargs.update({'headers': headers, 'post_data': post_data}) + + return bind_api( + api=self, + path='/media/upload.json', + method='POST', + payload_type='media', + allowed_param=[], + require_auth=True, + upload_api=True + )(*args, **kwargs) - """ statuses/home_timeline """ - home_timeline = bind_api( - path = '/statuses/home_timeline.json', - payload_type = 'status', payload_list = True, - allowed_param = ['since_id', 'max_id', 'count'], - require_auth = True - ) - - """ statuses/user_timeline """ - user_timeline = bind_api( - path = '/statuses/user_timeline.json', - payload_type = 'status', payload_list = True, - allowed_param = ['id', 'user_id', 'screen_name', 'since_id', - 'max_id', 'count', 'include_rts'] - ) - - """ statuses/mentions """ - mentions_timeline = bind_api( - path = '/statuses/mentions_timeline.json', - payload_type = 'status', payload_list = True, - allowed_param = ['since_id', 'max_id', 'count'], - require_auth = True - ) - - """/related_results/show/:id.format""" - related_results = bind_api( - path = '/related_results/show/{id}.json', - payload_type = 'relation', payload_list = True, - allowed_param = ['id'], - require_auth = False - ) - - """ statuses/retweets_of_me """ - retweets_of_me = bind_api( - path = '/statuses/retweets_of_me.json', - payload_type = 'status', payload_list = True, - allowed_param = ['since_id', 'max_id', 'count'], - require_auth = True - ) - - """ statuses/show """ - get_status = bind_api( - path = '/statuses/show.json', - payload_type = 'status', - allowed_param = ['id'] - ) - - """ statuses/update """ - update_status = bind_api( - path = '/statuses/update.json', - method = 'POST', - payload_type = 'status', - allowed_param = ['status', 'in_reply_to_status_id', 'lat', 'long', 'source', 'place_id'], - require_auth = True - ) - - """ statuses/update_with_media """ def update_with_media(self, filename, *args, **kwargs): - headers, post_data = API._pack_image(filename, 3072, form_field='media[]') + """ :reference: https://dev.twitter.com/rest/reference/post/statuses/update_with_media + :allowed_param:'status', 'possibly_sensitive', 'in_reply_to_status_id', 'lat', 'long', 'place_id', 'display_coordinates' + """ + f = kwargs.pop('file', None) + headers, post_data = API._pack_image(filename, 3072, form_field='media[]', f=f) kwargs.update({'headers': headers, 'post_data': post_data}) return bind_api( + api=self, path='/statuses/update_with_media.json', - method = 'POST', + method='POST', payload_type='status', - allowed_param = [ + allowed_param=[ 'status', 'possibly_sensitive', 'in_reply_to_status_id', 'lat', 'long', 'place_id', 'display_coordinates' ], require_auth=True - )(self, *args, **kwargs) - - """ statuses/destroy """ - destroy_status = bind_api( - path = '/statuses/destroy/{id}.json', - method = 'POST', - payload_type = 'status', - allowed_param = ['id'], - require_auth = True - ) - - """ statuses/retweet """ - retweet = bind_api( - path = '/statuses/retweet/{id}.json', - method = 'POST', - payload_type = 'status', - allowed_param = ['id'], - require_auth = True - ) - - """ statuses/retweets """ - retweets = bind_api( - path = '/statuses/retweets/{id}.json', - payload_type = 'status', payload_list = True, - allowed_param = ['id', 'count'], - require_auth = True - ) - - retweeters = bind_api( - path = '/statuses/retweeters/ids.json', - payload_type = 'ids', - allowed_param = ['id', 'cursor', 'stringify_ids'] - ) - - """ users/show """ - get_user = bind_api( - path = '/users/show.json', - payload_type = 'user', - allowed_param = ['id', 'user_id', 'screen_name'] - ) - - ''' statuses/oembed ''' - get_oembed = bind_api( - path = '/statuses/oembed.json', - payload_type = 'json', - allowed_param = ['id', 'url', 'maxwidth', 'hide_media', 'omit_script', 'align', 'related', 'lang'] - ) - - """ Perform bulk look up of users from user ID or screenname """ - def lookup_users(self, user_ids=None, screen_names=None): - return self._lookup_users(list_to_csv(user_ids), list_to_csv(screen_names)) - - _lookup_users = bind_api( - path = '/users/lookup.json', - payload_type = 'user', payload_list = True, - allowed_param = ['user_id', 'screen_name'], - ) - - """ Get the authenticated user """ + )(*args, **kwargs) + + @property + def destroy_status(self): + """ :reference: https://dev.twitter.com/rest/reference/post/statuses/destroy/%3Aid + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/statuses/destroy/{id}.json', + method='POST', + payload_type='status', + allowed_param=['id'], + require_auth=True + ) + + @property + def retweet(self): + """ :reference: https://dev.twitter.com/rest/reference/post/statuses/retweet/%3Aid + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/statuses/retweet/{id}.json', + method='POST', + payload_type='status', + allowed_param=['id'], + require_auth=True + ) + + @property + def retweets(self): + """ :reference: https://dev.twitter.com/rest/reference/get/statuses/retweets/%3Aid + :allowed_param:'id', 'count' + """ + return bind_api( + api=self, + path='/statuses/retweets/{id}.json', + payload_type='status', payload_list=True, + allowed_param=['id', 'count'], + require_auth=True + ) + + @property + def retweeters(self): + """ :reference: https://dev.twitter.com/rest/reference/get/statuses/retweeters/ids + :allowed_param:'id', 'cursor', 'stringify_ids + """ + return bind_api( + api=self, + path='/statuses/retweeters/ids.json', + payload_type='ids', + allowed_param=['id', 'cursor', 'stringify_ids'] + ) + + @property + def get_user(self): + """ :reference: https://dev.twitter.com/rest/reference/get/users/show + :allowed_param:'id', 'user_id', 'screen_name' + """ + return bind_api( + api=self, + path='/users/show.json', + payload_type='user', + allowed_param=['id', 'user_id', 'screen_name'] + ) + + @property + def get_oembed(self): + """ :reference: https://dev.twitter.com/rest/reference/get/statuses/oembed + :allowed_param:'id', 'url', 'maxwidth', 'hide_media', 'omit_script', 'align', 'related', 'lang' + """ + return bind_api( + api=self, + path='/statuses/oembed.json', + payload_type='json', + allowed_param=['id', 'url', 'maxwidth', 'hide_media', 'omit_script', 'align', 'related', 'lang'] + ) + + def lookup_users(self, user_ids=None, screen_names=None, include_entities=None): + """ Perform bulk look up of users from user ID or screenname """ + post_data = {} + if include_entities is not None: + include_entities = 'true' if include_entities else 'false' + post_data['include_entities'] = include_entities + if user_ids: + post_data['user_id'] = list_to_csv(user_ids) + if screen_names: + post_data['screen_name'] = list_to_csv(screen_names) + + return self._lookup_users(post_data=post_data) + + @property + def _lookup_users(self): + """ :reference: https://dev.twitter.com/rest/reference/get/users/lookup + allowed_param='user_id', 'screen_name', 'include_entities' + """ + return bind_api( + api=self, + path='/users/lookup.json', + payload_type='user', payload_list=True, + method='POST', + ) + def me(self): + """ Get the authenticated user """ return self.get_user(screen_name=self.auth.get_username()) - """ users/search """ - search_users = bind_api( - path = '/users/search.json', - payload_type = 'user', payload_list = True, - require_auth = True, - allowed_param = ['q', 'count', 'page'] - ) - - """ users/suggestions/:slug """ - suggested_users = bind_api( - path = '/users/suggestions/{slug}.json', - payload_type = 'user', payload_list = True, - require_auth = True, - allowed_param = ['slug', 'lang'] - ) - - """ users/suggestions """ - suggested_categories = bind_api( - path = '/users/suggestions.json', - payload_type = 'category', payload_list = True, - allowed_param = ['lang'], - require_auth = True - ) - - """ users/suggestions/:slug/members """ - suggested_users_tweets = bind_api( - path = '/users/suggestions/{slug}/members.json', - payload_type = 'status', payload_list = True, - allowed_param = ['slug'], - require_auth = True - ) - - """ direct_messages """ - direct_messages = bind_api( - path = '/direct_messages.json', - payload_type = 'direct_message', payload_list = True, - allowed_param = ['since_id', 'max_id', 'count'], - require_auth = True - ) - - """ direct_messages/show """ - get_direct_message = bind_api( - path = '/direct_messages/show/{id}.json', - payload_type = 'direct_message', - allowed_param = ['id'], - require_auth = True - ) - - """ direct_messages/sent """ - sent_direct_messages = bind_api( - path = '/direct_messages/sent.json', - payload_type = 'direct_message', payload_list = True, - allowed_param = ['since_id', 'max_id', 'count', 'page'], - require_auth = True - ) - - """ direct_messages/new """ - send_direct_message = bind_api( - path = '/direct_messages/new.json', - method = 'POST', - payload_type = 'direct_message', - allowed_param = ['user', 'screen_name', 'user_id', 'text'], - require_auth = True - ) - - """ direct_messages/destroy """ - destroy_direct_message = bind_api( - path = '/direct_messages/destroy.json', - method = 'DELETE', - payload_type = 'direct_message', - allowed_param = ['id'], - require_auth = True - ) - - """ friendships/create """ - create_friendship = bind_api( - path = '/friendships/create.json', - method = 'POST', - payload_type = 'user', - allowed_param = ['id', 'user_id', 'screen_name', 'follow'], - require_auth = True - ) - - """ friendships/destroy """ - destroy_friendship = bind_api( - path = '/friendships/destroy.json', - method = 'DELETE', - payload_type = 'user', - allowed_param = ['id', 'user_id', 'screen_name'], - require_auth = True - ) - - """ friendships/show """ - show_friendship = bind_api( - path = '/friendships/show.json', - payload_type = 'friendship', - allowed_param = ['source_id', 'source_screen_name', - 'target_id', 'target_screen_name'] - ) - - """ Perform bulk look up of friendships from user ID or screenname """ + @property + def search_users(self): + """ :reference: https://dev.twitter.com/rest/reference/get/users/search + :allowed_param:'q', 'count', 'page' + """ + return bind_api( + api=self, + path='/users/search.json', + payload_type='user', payload_list=True, + require_auth=True, + allowed_param=['q', 'count', 'page'] + ) + + @property + def suggested_users(self): + """ :reference: https://dev.twitter.com/rest/reference/get/users/suggestions/%3Aslug + :allowed_param:'slug', 'lang' + """ + return bind_api( + api=self, + path='/users/suggestions/{slug}.json', + payload_type='user', payload_list=True, + require_auth=True, + allowed_param=['slug', 'lang'] + ) + + @property + def suggested_categories(self): + """ :reference: https://dev.twitter.com/rest/reference/get/users/suggestions + :allowed_param:'lang' + """ + return bind_api( + api=self, + path='/users/suggestions.json', + payload_type='category', payload_list=True, + allowed_param=['lang'], + require_auth=True + ) + + @property + def suggested_users_tweets(self): + """ :reference: https://dev.twitter.com/rest/reference/get/users/suggestions/%3Aslug/members + :allowed_param:'slug' + """ + return bind_api( + api=self, + path='/users/suggestions/{slug}/members.json', + payload_type='status', payload_list=True, + allowed_param=['slug'], + require_auth=True + ) + + @property + def direct_messages(self): + """ :reference: https://dev.twitter.com/rest/reference/get/direct_messages + :allowed_param:'since_id', 'max_id', 'count' + """ + return bind_api( + api=self, + path='/direct_messages.json', + payload_type='direct_message', payload_list=True, + allowed_param=['since_id', 'max_id', 'count'], + require_auth=True + ) + + @property + def get_direct_message(self): + """ :reference: https://dev.twitter.com/rest/reference/get/direct_messages/show + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/direct_messages/show/{id}.json', + payload_type='direct_message', + allowed_param=['id'], + require_auth=True + ) + + @property + def sent_direct_messages(self): + """ :reference: https://dev.twitter.com/rest/reference/get/direct_messages/sent + :allowed_param:'since_id', 'max_id', 'count', 'page' + """ + return bind_api( + api=self, + path='/direct_messages/sent.json', + payload_type='direct_message', payload_list=True, + allowed_param=['since_id', 'max_id', 'count', 'page'], + require_auth=True + ) + + @property + def send_direct_message(self): + """ :reference: https://dev.twitter.com/rest/reference/post/direct_messages/new + :allowed_param:'user', 'screen_name', 'user_id', 'text' + """ + return bind_api( + api=self, + path='/direct_messages/new.json', + method='POST', + payload_type='direct_message', + allowed_param=['user', 'screen_name', 'user_id', 'text'], + require_auth=True + ) + + @property + def destroy_direct_message(self): + """ :reference: https://dev.twitter.com/rest/reference/post/direct_messages/destroy + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/direct_messages/destroy.json', + method='POST', + payload_type='direct_message', + allowed_param=['id'], + require_auth=True + ) + + @property + def create_friendship(self): + """ :reference: https://dev.twitter.com/rest/reference/post/friendships/create + :allowed_param:'id', 'user_id', 'screen_name', 'follow' + """ + return bind_api( + api=self, + path='/friendships/create.json', + method='POST', + payload_type='user', + allowed_param=['id', 'user_id', 'screen_name', 'follow'], + require_auth=True + ) + + @property + def destroy_friendship(self): + """ :reference: https://dev.twitter.com/rest/reference/post/friendships/destroy + :allowed_param:'id', 'user_id', 'screen_name' + """ + return bind_api( + api=self, + path='/friendships/destroy.json', + method='POST', + payload_type='user', + allowed_param=['id', 'user_id', 'screen_name'], + require_auth=True + ) + + @property + def show_friendship(self): + """ :reference: https://dev.twitter.com/rest/reference/get/friendships/show + :allowed_param:'source_id', 'source_screen_name' + """ + return bind_api( + api=self, + path='/friendships/show.json', + payload_type='friendship', + allowed_param=['source_id', 'source_screen_name', + 'target_id', 'target_screen_name'] + ) + def lookup_friendships(self, user_ids=None, screen_names=None): + """ Perform bulk look up of friendships from user ID or screenname """ return self._lookup_friendships(list_to_csv(user_ids), list_to_csv(screen_names)) - _lookup_friendships = bind_api( - path = '/friendships/lookup.json', - payload_type = 'relationship', payload_list = True, - allowed_param = ['user_id', 'screen_name'], - require_auth = True - ) - - - """ friends/ids """ - friends_ids = bind_api( - path = '/friends/ids.json', - payload_type = 'ids', - allowed_param = ['id', 'user_id', 'screen_name', 'cursor'] - ) - - """ friends/list """ - friends = bind_api( - path = '/friends/list.json', - payload_type = 'user', payload_list = True, - allowed_param = ['id', 'user_id', 'screen_name', 'cursor'] - ) - - """ friendships/incoming """ - friendships_incoming = bind_api( - path = '/friendships/incoming.json', - payload_type = 'ids', - allowed_param = ['cursor'] - ) - - """ friendships/outgoing""" - friendships_outgoing = bind_api( - path = '/friendships/outgoing.json', - payload_type = 'ids', - allowed_param = ['cursor'] - ) - - """ followers/ids """ - followers_ids = bind_api( - path = '/followers/ids.json', - payload_type = 'ids', - allowed_param = ['id', 'user_id', 'screen_name', 'cursor'] - ) - - """ followers/list """ - followers = bind_api( - path = '/followers/list.json', - payload_type = 'user', payload_list = True, - allowed_param = ['id', 'user_id', 'screen_name', 'cursor', 'count', - 'skip_status', 'include_user_entities'] - ) - - """ account/verify_credentials """ + @property + def _lookup_friendships(self): + """ :reference: https://dev.twitter.com/rest/reference/get/friendships/lookup + :allowed_param:'user_id', 'screen_name' + """ + return bind_api( + api=self, + path='/friendships/lookup.json', + payload_type='relationship', payload_list=True, + allowed_param=['user_id', 'screen_name'], + require_auth=True + ) + + @property + def friends_ids(self): + """ :reference: https://dev.twitter.com/rest/reference/get/friends/ids + :allowed_param:'id', 'user_id', 'screen_name', 'cursor' + """ + return bind_api( + api=self, + path='/friends/ids.json', + payload_type='ids', + allowed_param=['id', 'user_id', 'screen_name', 'cursor'] + ) + + @property + def friends(self): + """ :reference: https://dev.twitter.com/rest/reference/get/friends/list + :allowed_param:'id', 'user_id', 'screen_name', 'cursor' + """ + return bind_api( + api=self, + path='/friends/list.json', + payload_type='user', payload_list=True, + allowed_param=['id', 'user_id', 'screen_name', 'cursor'] + ) + + @property + def friendships_incoming(self): + """ :reference: https://dev.twitter.com/rest/reference/get/friendships/incoming + :allowed_param:'cursor' + """ + return bind_api( + api=self, + path='/friendships/incoming.json', + payload_type='ids', + allowed_param=['cursor'] + ) + + @property + def friendships_outgoing(self): + """ :reference: https://dev.twitter.com/rest/reference/get/friendships/outgoing + :allowed_param:'cursor' + """ + return bind_api( + api=self, + path='/friendships/outgoing.json', + payload_type='ids', + allowed_param=['cursor'] + ) + + @property + def followers_ids(self): + """ :reference: https://dev.twitter.com/rest/reference/get/followers/ids + :allowed_param:'id', 'user_id', 'screen_name', 'cursor', 'count' + """ + return bind_api( + api=self, + path='/followers/ids.json', + payload_type='ids', + allowed_param=['id', 'user_id', 'screen_name', 'cursor', 'count'] + ) + + @property + def followers(self): + """ :reference: https://dev.twitter.com/rest/reference/get/followers/list + :allowed_param:'id', 'user_id', 'screen_name', 'cursor', 'count', 'skip_status', 'include_user_entities' + """ + return bind_api( + api=self, + path='/followers/list.json', + payload_type='user', payload_list=True, + allowed_param=['id', 'user_id', 'screen_name', 'cursor', 'count', + 'skip_status', 'include_user_entities'] + ) + def verify_credentials(self, **kargs): + """ :reference: https://dev.twitter.com/rest/reference/get/account/verify_credentials + :allowed_param:'include_entities', 'skip_status' + """ try: return bind_api( - path = '/account/verify_credentials.json', - payload_type = 'user', - require_auth = True, - allowed_param = ['include_entities', 'skip_status'], - )(self, **kargs) + api=self, + path='/account/verify_credentials.json', + payload_type='user', + require_auth=True, + allowed_param=['include_entities', 'skip_status'], + )(**kargs) except TweepError as e: if e.response and e.response.status == 401: return False raise - """ account/rate_limit_status """ - rate_limit_status = bind_api( - path = '/application/rate_limit_status.json', - payload_type = 'json', - allowed_param = ['resources'], - use_cache = False - ) - - """ account/update_delivery_device """ - set_delivery_device = bind_api( - path = '/account/update_delivery_device.json', - method = 'POST', - allowed_param = ['device'], - payload_type = 'user', - require_auth = True - ) - - """ account/update_profile_colors """ - update_profile_colors = bind_api( - path = '/account/update_profile_colors.json', - method = 'POST', - payload_type = 'user', - allowed_param = ['profile_background_color', 'profile_text_color', - 'profile_link_color', 'profile_sidebar_fill_color', - 'profile_sidebar_border_color'], - require_auth = True - ) - - """ account/update_profile_image """ - def update_profile_image(self, filename): - headers, post_data = API._pack_image(filename, 700) - return bind_api( - path = '/account/update_profile_image.json', - method = 'POST', - payload_type = 'user', - require_auth = True - )(self, post_data=post_data, headers=headers) + @property + def rate_limit_status(self): + """ :reference: https://dev.twitter.com/rest/reference/get/application/rate_limit_status + :allowed_param:'resources' + """ + return bind_api( + api=self, + path='/application/rate_limit_status.json', + payload_type='json', + allowed_param=['resources'], + use_cache=False + ) + + @property + def set_delivery_device(self): + """ :reference: https://dev.twitter.com/rest/reference/post/account/update_delivery_device + :allowed_param:'device' + """ + return bind_api( + api=self, + path='/account/update_delivery_device.json', + method='POST', + allowed_param=['device'], + payload_type='user', + require_auth=True + ) + + @property + def update_profile_colors(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/post/account/update_profile_colors + :allowed_param:'profile_background_color', 'profile_text_color', + 'profile_link_color', 'profile_sidebar_fill_color', + 'profile_sidebar_border_color'], + """ + return bind_api( + api=self, + path='/account/update_profile_colors.json', + method='POST', + payload_type='user', + allowed_param=['profile_background_color', 'profile_text_color', + 'profile_link_color', 'profile_sidebar_fill_color', + 'profile_sidebar_border_color'], + require_auth=True + ) - """ account/update_profile_background_image """ - def update_profile_background_image(self, filename, *args, **kargs): - headers, post_data = API._pack_image(filename, 800) - bind_api( - path = '/account/update_profile_background_image.json', - method = 'POST', - payload_type = 'user', - allowed_param = ['tile'], - require_auth = True + def update_profile_image(self, filename, file_=None): + """ :reference: https://dev.twitter.com/rest/reference/post/account/update_profile_image + :allowed_param:'include_entities', 'skip_status' + """ + headers, post_data = API._pack_image(filename, 700, f=file_) + return bind_api( + api=self, + path='/account/update_profile_image.json', + method='POST', + payload_type='user', + allowed_param=['include_entities', 'skip_status'], + require_auth=True )(self, post_data=post_data, headers=headers) - """ account/update_profile_banner """ - def update_profile_banner(self, filename, *args, **kargs): - headers, post_data = API._pack_image(filename, 700, form_field="banner") + def update_profile_background_image(self, filename, **kargs): + """ :reference: https://dev.twitter.com/rest/reference/post/account/update_profile_background_image + :allowed_param:'tile', 'include_entities', 'skip_status', 'use' + """ + f = kargs.pop('file', None) + headers, post_data = API._pack_image(filename, 800, f=f) bind_api( - path = '/account/update_profile_banner.json', - method = 'POST', - allowed_param = ['width', 'height', 'offset_left', 'offset_right'], - require_auth = True - )(self, post_data=post_data, headers=headers) + api=self, + path='/account/update_profile_background_image.json', + method='POST', + payload_type='user', + allowed_param=['tile', 'include_entities', 'skip_status', 'use'], + require_auth=True + )(post_data=post_data, headers=headers) + + def update_profile_banner(self, filename, **kargs): + """ :reference: https://dev.twitter.com/rest/reference/post/account/update_profile_banner + :allowed_param:'width', 'height', 'offset_left', 'offset_right' + """ + f = kargs.pop('file', None) + headers, post_data = API._pack_image(filename, 700, form_field="banner", f=f) + bind_api( + api=self, + path='/account/update_profile_banner.json', + method='POST', + allowed_param=['width', 'height', 'offset_left', 'offset_right'], + require_auth=True + )(post_data=post_data, headers=headers) + + @property + def update_profile(self): + """ :reference: https://dev.twitter.com/rest/reference/post/account/update_profile + :allowed_param:'name', 'url', 'location', 'description' + """ + return bind_api( + api=self, + path='/account/update_profile.json', + method='POST', + payload_type='user', + allowed_param=['name', 'url', 'location', 'description'], + require_auth=True + ) + + @property + def favorites(self): + """ :reference: https://dev.twitter.com/rest/reference/get/favorites/list + :allowed_param:'screen_name', 'user_id', 'max_id', 'count', 'since_id', 'max_id' + """ + return bind_api( + api=self, + path='/favorites/list.json', + payload_type='status', payload_list=True, + allowed_param=['screen_name', 'user_id', 'max_id', 'count', 'since_id', 'max_id'] + ) + + @property + def create_favorite(self): + """ :reference:https://dev.twitter.com/rest/reference/post/favorites/create + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/favorites/create.json', + method='POST', + payload_type='status', + allowed_param=['id'], + require_auth=True + ) + + @property + def destroy_favorite(self): + """ :reference: https://dev.twitter.com/rest/reference/post/favorites/destroy + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/favorites/destroy.json', + method='POST', + payload_type='status', + allowed_param=['id'], + require_auth=True + ) + + @property + def create_block(self): + """ :reference: https://dev.twitter.com/rest/reference/post/blocks/create + :allowed_param:'id', 'user_id', 'screen_name' + """ + return bind_api( + api=self, + path='/blocks/create.json', + method='POST', + payload_type='user', + allowed_param=['id', 'user_id', 'screen_name'], + require_auth=True + ) + + @property + def destroy_block(self): + """ :reference: https://dev.twitter.com/rest/reference/post/blocks/destroy + :allowed_param:'id', 'user_id', 'screen_name' + """ + return bind_api( + api=self, + path='/blocks/destroy.json', + method='POST', + payload_type='user', + allowed_param=['id', 'user_id', 'screen_name'], + require_auth=True + ) + + @property + def blocks(self): + """ :reference: https://dev.twitter.com/rest/reference/get/blocks/list + :allowed_param:'cursor' + """ + return bind_api( + api=self, + path='/blocks/list.json', + payload_type='user', payload_list=True, + allowed_param=['cursor'], + require_auth=True + ) + @property + def blocks_ids(self): + """ :reference: https://dev.twitter.com/rest/reference/get/blocks/ids """ + return bind_api( + api=self, + path='/blocks/ids.json', + payload_type='json', + require_auth=True + ) + + @property + def report_spam(self): + """ :reference: https://dev.twitter.com/rest/reference/post/users/report_spam + :allowed_param:'user_id', 'screen_name' + """ + return bind_api( + api=self, + path='/users/report_spam.json', + method='POST', + payload_type='user', + allowed_param=['user_id', 'screen_name'], + require_auth=True + ) + + @property + def saved_searches(self): + """ :reference: https://dev.twitter.com/rest/reference/get/saved_searches/show/%3Aid """ + return bind_api( + api=self, + path='/saved_searches/list.json', + payload_type='saved_search', payload_list=True, + require_auth=True + ) - """ account/update_profile """ - update_profile = bind_api( - path = '/account/update_profile.json', - method = 'POST', - payload_type = 'user', - allowed_param = ['name', 'url', 'location', 'description'], - require_auth = True - ) - - """ favorites """ - favorites = bind_api( - path = '/favorites/list.json', - payload_type = 'status', payload_list = True, - allowed_param = ['screen_name', 'user_id', 'max_id', 'count', 'since_id', 'max_id'] - ) - - """ favorites/create """ - create_favorite = bind_api( - path = '/favorites/create.json', - method = 'POST', - payload_type = 'status', - allowed_param = ['id'], - require_auth = True - ) - - """ favorites/destroy """ - destroy_favorite = bind_api( - path = '/favorites/destroy.json', - method = 'POST', - payload_type = 'status', - allowed_param = ['id'], - require_auth = True - ) - - """ blocks/create """ - create_block = bind_api( - path = '/blocks/create.json', - method = 'POST', - payload_type = 'user', - allowed_param = ['id', 'user_id', 'screen_name'], - require_auth = True - ) - - """ blocks/destroy """ - destroy_block = bind_api( - path = '/blocks/destroy.json', - method = 'DELETE', - payload_type = 'user', - allowed_param = ['id', 'user_id', 'screen_name'], - require_auth = True - ) - - """ blocks/blocking """ - blocks = bind_api( - path = '/blocks/list.json', - payload_type = 'user', payload_list = True, - allowed_param = ['cursor'], - require_auth = True - ) - - """ blocks/blocking/ids """ - blocks_ids = bind_api( - path = '/blocks/ids.json', - payload_type = 'json', - require_auth = True - ) - - """ report_spam """ - report_spam = bind_api( - path = '/users/report_spam.json', - method = 'POST', - payload_type = 'user', - allowed_param = ['user_id', 'screen_name'], - require_auth = True - ) - - """ saved_searches """ - saved_searches = bind_api( - path = '/saved_searches/list.json', - payload_type = 'saved_search', payload_list = True, - require_auth = True - ) - - """ saved_searches/show """ - get_saved_search = bind_api( - path = '/saved_searches/show/{id}.json', - payload_type = 'saved_search', - allowed_param = ['id'], - require_auth = True - ) - - """ saved_searches/create """ - create_saved_search = bind_api( - path = '/saved_searches/create.json', - method = 'POST', - payload_type = 'saved_search', - allowed_param = ['query'], - require_auth = True - ) - - """ saved_searches/destroy """ - destroy_saved_search = bind_api( - path = '/saved_searches/destroy/{id}.json', - method = 'POST', - payload_type = 'saved_search', - allowed_param = ['id'], - require_auth = True - ) - - create_list = bind_api( - path = '/lists/create.json', - method = 'POST', - payload_type = 'list', - allowed_param = ['name', 'mode', 'description'], - require_auth = True - ) - - destroy_list = bind_api( - path = '/lists/destroy.json', - method = 'POST', - payload_type = 'list', - allowed_param = ['owner_screen_name', 'owner_id', 'list_id', 'slug'], - require_auth = True - ) - - update_list = bind_api( - path = '/lists/update.json', - method = 'POST', - payload_type = 'list', - allowed_param = ['list_id', 'slug', 'name', 'mode', 'description', 'owner_screen_name', 'owner_id'], - require_auth = True - ) - - lists_all = bind_api( - path = '/lists/list.json', - payload_type = 'list', payload_list = True, - allowed_param = ['screen_name', 'user_id'], - require_auth = True - ) - - lists_memberships = bind_api( - path = '/lists/memberships.json', - payload_type = 'list', payload_list = True, - allowed_param = ['screen_name', 'user_id', 'filter_to_owned_lists', 'cursor'], - require_auth = True - ) - - lists_subscriptions = bind_api( - path = '/lists/subscriptions.json', - payload_type = 'list', payload_list = True, - allowed_param = ['screen_name', 'user_id', 'cursor'], - require_auth = True - ) - - list_timeline = bind_api( - path = '/lists/statuses.json', - payload_type = 'status', payload_list = True, - allowed_param = ['owner_screen_name', 'slug', 'owner_id', 'list_id', 'since_id', 'max_id', 'count', 'include_rts'] - ) - - get_list = bind_api( - path = '/lists/show.json', - payload_type = 'list', - allowed_param = ['owner_screen_name', 'owner_id', 'slug', 'list_id'] - ) - - add_list_member = bind_api( - path = '/lists/members/create.json', - method = 'POST', - payload_type = 'list', - allowed_param = ['screen_name', 'user_id', 'owner_screen_name', 'owner_id', 'slug', 'list_id'], - require_auth = True - ) - - remove_list_member = bind_api( - path = '/lists/members/destroy.json', - method = 'POST', - payload_type = 'list', - allowed_param = ['screen_name', 'user_id', 'owner_screen_name', 'owner_id', 'slug', 'list_id'], - require_auth = True - ) - - list_members = bind_api( - path = '/lists/members.json', - payload_type = 'user', payload_list = True, - allowed_param = ['owner_screen_name', 'slug', 'list_id', 'owner_id', 'cursor'] - ) - - show_list_member = bind_api( - path = '/lists/members/show.json', - payload_type = 'user', - allowed_param = ['list_id', 'slug', 'user_id', 'screen_name', 'owner_screen_name', 'owner_id'] - ) - - subscribe_list = bind_api( - path = '/lists/subscribers/create.json', - method = 'POST', - payload_type = 'list', - allowed_param = ['owner_screen_name', 'slug', 'owner_id', 'list_id'], - require_auth = True - ) - - unsubscribe_list = bind_api( - path = '/lists/subscribers/destroy.json', - method = 'POST', - payload_type = 'list', - allowed_param = ['owner_screen_name', 'slug', 'owner_id', 'list_id'], - require_auth = True - ) - - list_subscribers = bind_api( - path = '/lists/subscribers.json', - payload_type = 'user', payload_list = True, - allowed_param = ['owner_screen_name', 'slug', 'owner_id', 'list_id', 'cursor'] - ) - - show_list_subscriber = bind_api( - path = '/lists/subscribers/show.json', - payload_type = 'user', - allowed_param = ['owner_screen_name', 'slug', 'screen_name', 'owner_id', 'list_id', 'user_id'] - ) - - """ trends/available """ - trends_available = bind_api( - path = '/trends/available.json', - payload_type = 'json' - ) - - trends_place = bind_api( - path = '/trends/place.json', - payload_type = 'json', - allowed_param = ['id', 'exclude'] - ) - - trends_closest = bind_api( - path = '/trends/closest.json', - payload_type = 'json', - allowed_param = ['lat', 'long'] - ) - - """ search """ - search = bind_api( - path = '/search/tweets.json', - payload_type = 'search_results', - allowed_param = ['q', 'lang', 'locale', 'since_id', 'geocode', 'max_id', 'since', 'until', 'result_type', 'count', 'include_entities', 'from', 'to', 'source'] - ) - - """ trends/daily """ - trends_daily = bind_api( - path = '/trends/daily.json', - payload_type = 'json', - allowed_param = ['date', 'exclude'] - ) - - """ trends/weekly """ - trends_weekly = bind_api( - path = '/trends/weekly.json', - payload_type = 'json', - allowed_param = ['date', 'exclude'] - ) - - """ geo/reverse_geocode """ - reverse_geocode = bind_api( - path = '/geo/reverse_geocode.json', - payload_type = 'place', payload_list = True, - allowed_param = ['lat', 'long', 'accuracy', 'granularity', 'max_results'] - ) - - """ geo/id """ - geo_id = bind_api( - path = '/geo/id/{id}.json', - payload_type = 'place', - allowed_param = ['id'] - ) - - """ geo/search """ - geo_search = bind_api( - path = '/geo/search.json', - payload_type = 'place', payload_list = True, - allowed_param = ['lat', 'long', 'query', 'ip', 'granularity', 'accuracy', 'max_results', 'contained_within'] - ) - - """ geo/similar_places """ - geo_similar_places = bind_api( - path = '/geo/similar_places.json', - payload_type = 'place', payload_list = True, - allowed_param = ['lat', 'long', 'name', 'contained_within'] - ) - - """ help/languages.json """ - supported_languages = bind_api( - path = '/help/languages.json', - payload_type = 'json', - require_auth = True - ) - - """ help/configuration """ - configuration = bind_api( - path = '/help/configuration.json', - payload_type = 'json', - require_auth = True - ) + @property + def get_saved_search(self): + """ :reference: https://dev.twitter.com/rest/reference/get/saved_searches/show/%3Aid + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/saved_searches/show/{id}.json', + payload_type='saved_search', + allowed_param=['id'], + require_auth=True + ) + + @property + def create_saved_search(self): + """ :reference: https://dev.twitter.com/rest/reference/post/saved_searches/create + :allowed_param:'query' + """ + return bind_api( + api=self, + path='/saved_searches/create.json', + method='POST', + payload_type='saved_search', + allowed_param=['query'], + require_auth=True + ) + + @property + def destroy_saved_search(self): + """ :reference: https://dev.twitter.com/rest/reference/post/saved_searches/destroy/%3Aid + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/saved_searches/destroy/{id}.json', + method='POST', + payload_type='saved_search', + allowed_param=['id'], + require_auth=True + ) + + @property + def create_list(self): + """ :reference: https://dev.twitter.com/rest/reference/post/lists/create + :allowed_param:'name', 'mode', 'description' + """ + return bind_api( + api=self, + path='/lists/create.json', + method='POST', + payload_type='list', + allowed_param=['name', 'mode', 'description'], + require_auth=True + ) + + @property + def destroy_list(self): + """ :reference: https://dev.twitter.com/rest/reference/post/lists/destroy + :allowed_param:'owner_screen_name', 'owner_id', 'list_id', 'slug' + """ + return bind_api( + api=self, + path='/lists/destroy.json', + method='POST', + payload_type='list', + allowed_param=['owner_screen_name', 'owner_id', 'list_id', 'slug'], + require_auth=True + ) + + @property + def update_list(self): + """ :reference: https://dev.twitter.com/rest/reference/post/lists/update + :allowed_param: list_id', 'slug', 'name', 'mode', 'description', 'owner_screen_name', 'owner_id' + """ + return bind_api( + api=self, + path='/lists/update.json', + method='POST', + payload_type='list', + allowed_param=['list_id', 'slug', 'name', 'mode', 'description', 'owner_screen_name', 'owner_id'], + require_auth=True + ) + + @property + def lists_all(self): + """ :reference: https://dev.twitter.com/rest/reference/get/lists/list + :allowed_param:'screen_name', 'user_id' + """ + return bind_api( + api=self, + path='/lists/list.json', + payload_type='list', payload_list=True, + allowed_param=['screen_name', 'user_id'], + require_auth=True + ) + + @property + def lists_memberships(self): + """ :reference: https://dev.twitter.com/rest/reference/get/lists/memberships + :allowed_param:'screen_name', 'user_id', 'filter_to_owned_lists', 'cursor' + """ + return bind_api( + api=self, + path='/lists/memberships.json', + payload_type='list', payload_list=True, + allowed_param=['screen_name', 'user_id', 'filter_to_owned_lists', 'cursor'], + require_auth=True + ) + + @property + def lists_subscriptions(self): + """ :reference: https://dev.twitter.com/rest/reference/get/lists/subscriptions + :allowed_param:'screen_name', 'user_id', 'cursor' + """ + return bind_api( + api=self, + path='/lists/subscriptions.json', + payload_type='list', payload_list=True, + allowed_param=['screen_name', 'user_id', 'cursor'], + require_auth=True + ) + + @property + def list_timeline(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/get/lists/statuses + :allowed_param:'owner_screen_name', 'slug', 'owner_id', 'list_id', + 'since_id', 'max_id', 'count', 'include_rts + """ + return bind_api( + api=self, + path='/lists/statuses.json', + payload_type='status', payload_list=True, + allowed_param=['owner_screen_name', 'slug', 'owner_id', + 'list_id', 'since_id', 'max_id', 'count', + 'include_rts'] + ) + + @property + def get_list(self): + """ :reference: https://dev.twitter.com/rest/reference/get/lists/show + :allowed_param:'owner_screen_name', 'owner_id', 'slug', 'list_id' + """ + return bind_api( + api=self, + path='/lists/show.json', + payload_type='list', + allowed_param=['owner_screen_name', 'owner_id', 'slug', 'list_id'] + ) + + @property + def add_list_member(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/post/lists/members/create + :allowed_param:'screen_name', 'user_id', 'owner_screen_name', + 'owner_id', 'slug', 'list_id' + """ + return bind_api( + api=self, + path='/lists/members/create.json', + method='POST', + payload_type='list', + allowed_param=['screen_name', 'user_id', 'owner_screen_name', + 'owner_id', 'slug', 'list_id'], + require_auth=True + ) + + @property + def remove_list_member(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/post/lists/members/destroy + :allowed_param:'screen_name', 'user_id', 'owner_screen_name', + 'owner_id', 'slug', 'list_id' + """ + return bind_api( + api=self, + path='/lists/members/destroy.json', + method='POST', + payload_type='list', + allowed_param=['screen_name', 'user_id', 'owner_screen_name', + 'owner_id', 'slug', 'list_id'], + require_auth=True + ) + + def add_list_members(self, screen_name=None, user_id=None, slug=None, + list_id=None, owner_id=None, owner_screen_name=None): + """ Perform bulk add of list members from user ID or screenname """ + return self._add_list_members(list_to_csv(screen_name), + list_to_csv(user_id), + slug, list_id, owner_id, + owner_screen_name) + + @property + def _add_list_members(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/post/lists/members/create_all + :allowed_param:'screen_name', 'user_id', 'slug', 'lit_id', + 'owner_id', 'owner_screen_name' + + """ + return bind_api( + api=self, + path='/lists/members/create_all.json', + method='POST', + payload_type='list', + allowed_param=['screen_name', 'user_id', 'slug', 'lit_id', + 'owner_id', 'owner_screen_name'], + require_auth=True + ) + + def remove_list_members(self, screen_name=None, user_id=None, slug=None, + list_id=None, owner_id=None, owner_screen_name=None): + """ Perform bulk remove of list members from user ID or screenname """ + return self._remove_list_members(list_to_csv(screen_name), + list_to_csv(user_id), + slug, list_id, owner_id, + owner_screen_name) + + @property + def _remove_list_members(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/post/lists/members/destroy_all + :allowed_param:'screen_name', 'user_id', 'slug', 'lit_id', + 'owner_id', 'owner_screen_name' + + """ + return bind_api( + api=self, + path='/lists/members/destroy_all.json', + method='POST', + payload_type='list', + allowed_param=['screen_name', 'user_id', 'slug', 'lit_id', + 'owner_id', 'owner_screen_name'], + require_auth=True + ) + + @property + def list_members(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/get/lists/members + :allowed_param:'owner_screen_name', 'slug', 'list_id', + 'owner_id', 'cursor + """ + return bind_api( + api=self, + path='/lists/members.json', + payload_type='user', payload_list=True, + allowed_param=['owner_screen_name', 'slug', 'list_id', + 'owner_id', 'cursor'] + ) + + @property + def show_list_member(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/get/lists/members/show + :allowed_param:'list_id', 'slug', 'user_id', 'screen_name', + 'owner_screen_name', 'owner_id + """ + return bind_api( + api=self, + path='/lists/members/show.json', + payload_type='user', + allowed_param=['list_id', 'slug', 'user_id', 'screen_name', + 'owner_screen_name', 'owner_id'] + ) + + @property + def subscribe_list(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/post/lists/subscribers/create + :allowed_param:'owner_screen_name', 'slug', 'owner_id', + 'list_id' + """ + return bind_api( + api=self, + path='/lists/subscribers/create.json', + method='POST', + payload_type='list', + allowed_param=['owner_screen_name', 'slug', 'owner_id', + 'list_id'], + require_auth=True + ) + + @property + def unsubscribe_list(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/post/lists/subscribers/destroy + :allowed_param:'owner_screen_name', 'slug', 'owner_id', + 'list_id' + """ + return bind_api( + api=self, + path='/lists/subscribers/destroy.json', + method='POST', + payload_type='list', + allowed_param=['owner_screen_name', 'slug', 'owner_id', + 'list_id'], + require_auth=True + ) + + @property + def list_subscribers(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/get/lists/subscribers + :allowed_param:'owner_screen_name', 'slug', 'owner_id', + 'list_id', 'cursor + """ + return bind_api( + api=self, + path='/lists/subscribers.json', + payload_type='user', payload_list=True, + allowed_param=['owner_screen_name', 'slug', 'owner_id', + 'list_id', 'cursor'] + ) + + @property + def show_list_subscriber(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/get/lists/subscribers/show + :allowed_param:'owner_screen_name', 'slug', 'screen_name', + 'owner_id', 'list_id', 'user_id + """ + return bind_api( + api=self, + path='/lists/subscribers/show.json', + payload_type='user', + allowed_param=['owner_screen_name', 'slug', 'screen_name', + 'owner_id', 'list_id', 'user_id'] + ) + + @property + def trends_available(self): + """ :reference: https://dev.twitter.com/rest/reference/get/trends/available """ + return bind_api( + api=self, + path='/trends/available.json', + payload_type='json' + ) + + @property + def trends_place(self): + """ :reference: https://dev.twitter.com/rest/reference/get/trends/place + :allowed_param:'id', 'exclude' + """ + return bind_api( + api=self, + path='/trends/place.json', + payload_type='json', + allowed_param=['id', 'exclude'] + ) + + @property + def trends_closest(self): + """ :reference: https://dev.twitter.com/rest/reference/get/trends/closest + :allowed_param:'lat', 'long' + """ + return bind_api( + api=self, + path='/trends/closest.json', + payload_type='json', + allowed_param=['lat', 'long'] + ) + + @property + def search(self): + """ :reference: https://dev.twitter.com/rest/reference/get/search/tweets + :allowed_param:'q', 'lang', 'locale', 'since_id', 'geocode', + 'max_id', 'since', 'until', 'result_type', 'count', + 'include_entities', 'from', 'to', 'source'] + """ + return bind_api( + api=self, + path='/search/tweets.json', + payload_type='search_results', + allowed_param=['q', 'lang', 'locale', 'since_id', 'geocode', + 'max_id', 'since', 'until', 'result_type', + 'count', 'include_entities', 'from', + 'to', 'source'] + ) + + @property + def reverse_geocode(self): + """ :reference: https://dev.twitter.com/rest/reference/get/geo/reverse_geocode + :allowed_param:'lat', 'long', 'accuracy', 'granularity', 'max_results' + """ + return bind_api( + api=self, + path='/geo/reverse_geocode.json', + payload_type='place', payload_list=True, + allowed_param=['lat', 'long', 'accuracy', 'granularity', + 'max_results'] + ) + + @property + def geo_id(self): + """ :reference: https://dev.twitter.com/rest/reference/get/geo/id/%3Aplace_id + :allowed_param:'id' + """ + return bind_api( + api=self, + path='/geo/id/{id}.json', + payload_type='place', + allowed_param=['id'] + ) + + @property + def geo_search(self): + """ :reference: https://dev.twitter.com/docs/api/1.1/get/geo/search + :allowed_param:'lat', 'long', 'query', 'ip', 'granularity', + 'accuracy', 'max_results', 'contained_within + + """ + return bind_api( + api=self, + path='/geo/search.json', + payload_type='place', payload_list=True, + allowed_param=['lat', 'long', 'query', 'ip', 'granularity', + 'accuracy', 'max_results', 'contained_within'] + ) + + @property + def geo_similar_places(self): + """ :reference: https://dev.twitter.com/rest/reference/get/geo/similar_places + :allowed_param:'lat', 'long', 'name', 'contained_within' + """ + return bind_api( + api=self, + path='/geo/similar_places.json', + payload_type='place', payload_list=True, + allowed_param=['lat', 'long', 'name', 'contained_within'] + ) + + @property + def supported_languages(self): + """ :reference: https://dev.twitter.com/rest/reference/get/help/languages """ + return bind_api( + api=self, + path='/help/languages.json', + payload_type='json', + require_auth=True + ) + + @property + def configuration(self): + """ :reference: https://dev.twitter.com/rest/reference/get/help/configuration """ + return bind_api( + api=self, + path='/help/configuration.json', + payload_type='json', + require_auth=True + ) """ Internal use only """ + @staticmethod - def _pack_image(filename, max_size, form_field="image"): + def _pack_image(filename, max_size, form_field="image", f=None): """Pack image from file into multipart-formdata post body""" # image must be less than 700kb in size - try: - if os.path.getsize(filename) > (max_size * 1024): - raise TweepError('File is too big, must be less than 700kb.') - except os.error: - raise TweepError('Unable to access file') + if f is None: + try: + if os.path.getsize(filename) > (max_size * 1024): + raise TweepError('File is too big, must be less than %skb.' % max_size) + except os.error as e: + raise TweepError('Unable to access file: %s' % e.strerror) + + # build the mulitpart-formdata body + fp = open(filename, 'rb') + else: + f.seek(0, 2) # Seek to end of file + if f.tell() > (max_size * 1024): + raise TweepError('File is too big, must be less than %skb.' % max_size) + f.seek(0) # Reset to beginning of file + fp = f # image must be gif, jpeg, or png file_type = mimetypes.guess_type(filename) @@ -716,19 +1291,22 @@ class API(object): if file_type not in ['image/gif', 'image/jpeg', 'image/png']: raise TweepError('Invalid file type for image: %s' % file_type) - # build the mulitpart-formdata body - fp = open(filename, 'rb') - BOUNDARY = 'Tw3ePy' - body = [] - body.append('--' + BOUNDARY) - body.append('Content-Disposition: form-data; name="%s"; filename="%s"' % (form_field, filename)) - body.append('Content-Type: %s' % file_type) - body.append('') + if isinstance(filename, six.text_type): + filename = filename.encode("utf-8") + + BOUNDARY = b'Tw3ePy' + body = list() + body.append(b'--' + BOUNDARY) + body.append('Content-Disposition: form-data; name="{0}";' + ' filename="{1}"'.format(form_field, filename) + .encode('utf-8')) + body.append('Content-Type: {0}'.format(file_type).encode('utf-8')) + body.append(b'') body.append(fp.read()) - body.append('--' + BOUNDARY + '--') - body.append('') + body.append(b'--' + BOUNDARY + b'--') + body.append(b'') fp.close() - body = '\r\n'.join(body) + body = b'\r\n'.join(body) # build headers headers = { @@ -737,4 +1315,3 @@ class API(object): } return headers, body - diff --git a/tweepy/auth.py b/tweepy/auth.py index 86c4430..b15434b 100644 --- a/tweepy/auth.py +++ b/tweepy/auth.py @@ -1,13 +1,18 @@ -# Tweepy -# Copyright 2009-2010 Joshua Roesslein -# See LICENSE for details. +from __future__ import print_function -from urllib2 import Request, urlopen -import base64 +import six +import logging -from tweepy import oauth from tweepy.error import TweepError from tweepy.api import API +import requests +from requests_oauthlib import OAuth1Session, OAuth1 +from requests.auth import AuthBase +from six.moves.urllib.parse import parse_qs + +WARNING_MESSAGE = """Warning! Due to a Twitter API bug, signin_with_twitter +and access_type don't always play nice together. Details +https://dev.twitter.com/discussions/21281""" class AuthHandler(object): @@ -23,76 +28,64 @@ class AuthHandler(object): class OAuthHandler(AuthHandler): """OAuth authentication handler""" - OAUTH_HOST = 'api.twitter.com' OAUTH_ROOT = '/oauth/' - def __init__(self, consumer_key, consumer_secret, callback=None, secure=True): - if type(consumer_key) == unicode: - consumer_key = bytes(consumer_key) + def __init__(self, consumer_key, consumer_secret, callback=None): + if type(consumer_key) == six.text_type: + consumer_key = consumer_key.encode('ascii') - if type(consumer_secret) == unicode: - consumer_secret = bytes(consumer_secret) + if type(consumer_secret) == six.text_type: + consumer_secret = consumer_secret.encode('ascii') - self._consumer = oauth.OAuthConsumer(consumer_key, consumer_secret) - self._sigmethod = oauth.OAuthSignatureMethod_HMAC_SHA1() - self.request_token = None + self.consumer_key = consumer_key + self.consumer_secret = consumer_secret self.access_token = None + self.access_token_secret = None self.callback = callback self.username = None - self.secure = secure + self.oauth = OAuth1Session(consumer_key, + client_secret=consumer_secret, + callback_uri=self.callback) - def _get_oauth_url(self, endpoint, secure=True): - if self.secure or secure: - prefix = 'https://' - else: - prefix = 'http://' + def _get_oauth_url(self, endpoint): + return 'https://' + self.OAUTH_HOST + self.OAUTH_ROOT + endpoint - return prefix + self.OAUTH_HOST + self.OAUTH_ROOT + endpoint + def apply_auth(self): + return OAuth1(self.consumer_key, + client_secret=self.consumer_secret, + resource_owner_key=self.access_token, + resource_owner_secret=self.access_token_secret, + decoding=None) - def apply_auth(self, url, method, headers, parameters): - request = oauth.OAuthRequest.from_consumer_and_token( - self._consumer, http_url=url, http_method=method, - token=self.access_token, parameters=parameters - ) - request.sign_request(self._sigmethod, self._consumer, self.access_token) - headers.update(request.to_header()) - - def _get_request_token(self): + def _get_request_token(self, access_type=None): try: url = self._get_oauth_url('request_token') - request = oauth.OAuthRequest.from_consumer_and_token( - self._consumer, http_url=url, callback=self.callback - ) - request.sign_request(self._sigmethod, self._consumer, None) - resp = urlopen(Request(url, headers=request.to_header())) - return oauth.OAuthToken.from_string(resp.read()) + if access_type: + url += '?x_auth_access_type=%s' % access_type + return self.oauth.fetch_request_token(url) except Exception as e: raise TweepError(e) - def set_request_token(self, key, secret): - self.request_token = oauth.OAuthToken(key, secret) - def set_access_token(self, key, secret): - self.access_token = oauth.OAuthToken(key, secret) + self.access_token = key + self.access_token_secret = secret - def get_authorization_url(self, signin_with_twitter=False): + def get_authorization_url(self, + signin_with_twitter=False, + access_type=None): """Get the authorization URL to redirect the user""" try: - # get the request token - self.request_token = self._get_request_token() - - # build auth request and return as url if signin_with_twitter: url = self._get_oauth_url('authenticate') + if access_type: + logging.warning(WARNING_MESSAGE) else: url = self._get_oauth_url('authorize') - request = oauth.OAuthRequest.from_token_and_callback( - token=self.request_token, http_url=url - ) - - return request.to_url() + self.request_token = self._get_request_token(access_type=access_type) + return self.oauth.authorization_url(url) except Exception as e: + raise raise TweepError(e) def get_access_token(self, verifier=None): @@ -102,19 +95,15 @@ class OAuthHandler(AuthHandler): """ try: url = self._get_oauth_url('access_token') - - # build request - request = oauth.OAuthRequest.from_consumer_and_token( - self._consumer, - token=self.request_token, http_url=url, - verifier=str(verifier) - ) - request.sign_request(self._sigmethod, self._consumer, self.request_token) - - # send request - resp = urlopen(Request(url, headers=request.to_header())) - self.access_token = oauth.OAuthToken.from_string(resp.read()) - return self.access_token + self.oauth = OAuth1Session(self.consumer_key, + client_secret=self.consumer_secret, + resource_owner_key=self.request_token['oauth_token'], + resource_owner_secret=self.request_token['oauth_token_secret'], + verifier=verifier, callback_uri=self.callback) + resp = self.oauth.fetch_access_token(url) + self.access_token = resp['oauth_token'] + self.access_token_secret = resp['oauth_token_secret'] + return self.access_token, self.access_token_secret except Exception as e: raise TweepError(e) @@ -126,21 +115,18 @@ class OAuthHandler(AuthHandler): and request activation of xAuth for it. """ try: - url = self._get_oauth_url('access_token', secure=True) # must use HTTPS - request = oauth.OAuthRequest.from_consumer_and_token( - oauth_consumer=self._consumer, - http_method='POST', http_url=url, - parameters = { - 'x_auth_mode': 'client_auth', - 'x_auth_username': username, - 'x_auth_password': password - } - ) - request.sign_request(self._sigmethod, self._consumer, None) - - resp = urlopen(Request(url, data=request.to_postdata())) - self.access_token = oauth.OAuthToken.from_string(resp.read()) - return self.access_token + url = self._get_oauth_url('access_token') + oauth = OAuth1(self.consumer_key, + client_secret=self.consumer_secret) + r = requests.post(url=url, + auth=oauth, + headers={'x_auth_mode': 'client_auth', + 'x_auth_username': username, + 'x_auth_password': password}) + + print(r.content) + credentials = parse_qs(r.content) + return credentials.get('oauth_token')[0], credentials.get('oauth_token_secret')[0] except Exception as e: raise TweepError(e) @@ -151,6 +137,44 @@ class OAuthHandler(AuthHandler): if user: self.username = user.screen_name else: - raise TweepError("Unable to get username, invalid oauth token!") + raise TweepError('Unable to get username,' + ' invalid oauth token!') return self.username + +class OAuth2Bearer(AuthBase): + def __init__(self, bearer_token): + self.bearer_token = bearer_token + + def __call__(self, request): + request.headers['Authorization'] = 'Bearer ' + self.bearer_token + return request + + +class AppAuthHandler(AuthHandler): + """Application-only authentication handler""" + + OAUTH_HOST = 'api.twitter.com' + OAUTH_ROOT = '/oauth2/' + + def __init__(self, consumer_key, consumer_secret): + self.consumer_key = consumer_key + self.consumer_secret = consumer_secret + self._bearer_token = '' + + resp = requests.post(self._get_oauth_url('token'), + auth=(self.consumer_key, + self.consumer_secret), + data={'grant_type': 'client_credentials'}) + data = resp.json() + if data.get('token_type') != 'bearer': + raise TweepError('Expected token_type to equal "bearer", ' + 'but got %s instead' % data.get('token_type')) + + self._bearer_token = data['access_token'] + + def _get_oauth_url(self, endpoint): + return 'https://' + self.OAUTH_HOST + self.OAUTH_ROOT + endpoint + + def apply_auth(self): + return OAuth2Bearer(self._bearer_token) diff --git a/tweepy/binder.py b/tweepy/binder.py index 1f8c619..2ac6146 100644 --- a/tweepy/binder.py +++ b/tweepy/binder.py @@ -2,24 +2,30 @@ # Copyright 2009-2010 Joshua Roesslein # See LICENSE for details. -import httplib -import urllib +from __future__ import print_function + import time import re -from StringIO import StringIO -import gzip + +from six.moves.urllib.parse import quote +import requests + +import logging from tweepy.error import TweepError from tweepy.utils import convert_to_utf8_str from tweepy.models import Model + re_path_template = re.compile('{\w+}') +log = logging.getLogger('tweepy.binder') def bind_api(**config): class APIMethod(object): + api = config['api'] path = config['path'] payload_type = config.get('payload_type', None) payload_list = config.get('payload_list', False) @@ -27,38 +33,47 @@ def bind_api(**config): method = config.get('method', 'GET') require_auth = config.get('require_auth', False) search_api = config.get('search_api', False) + upload_api = config.get('upload_api', False) use_cache = config.get('use_cache', True) + session = requests.Session() - def __init__(self, api, args, kargs): + def __init__(self, args, kwargs): + api = self.api # If authentication is required and no credentials # are provided, throw an error. if self.require_auth and not api.auth: raise TweepError('Authentication required!') - self.api = api - self.post_data = kargs.pop('post_data', None) - self.retry_count = kargs.pop('retry_count', api.retry_count) - self.retry_delay = kargs.pop('retry_delay', api.retry_delay) - self.retry_errors = kargs.pop('retry_errors', api.retry_errors) - self.headers = kargs.pop('headers', {}) - self.build_parameters(args, kargs) + self.post_data = kwargs.pop('post_data', None) + self.retry_count = kwargs.pop('retry_count', + api.retry_count) + self.retry_delay = kwargs.pop('retry_delay', + api.retry_delay) + self.retry_errors = kwargs.pop('retry_errors', + api.retry_errors) + self.wait_on_rate_limit = kwargs.pop('wait_on_rate_limit', + api.wait_on_rate_limit) + self.wait_on_rate_limit_notify = kwargs.pop('wait_on_rate_limit_notify', + api.wait_on_rate_limit_notify) + self.parser = kwargs.pop('parser', api.parser) + self.session.headers = kwargs.pop('headers', {}) + self.build_parameters(args, kwargs) # Pick correct URL root to use if self.search_api: self.api_root = api.search_root + elif self.upload_api: + self.api_root = api.upload_root else: self.api_root = api.api_root # Perform any path variable substitution self.build_path() - if api.secure: - self.scheme = 'https://' - else: - self.scheme = 'http://' - if self.search_api: self.host = api.search_host + elif self.upload_api: + self.host = api.upload_host else: self.host = api.host @@ -66,40 +81,44 @@ def bind_api(**config): # or older where Host is set including the 443 port. # This causes Twitter to issue 301 redirect. # See Issue https://github.com/tweepy/tweepy/issues/12 - self.headers['Host'] = self.host + self.session.headers['Host'] = self.host + # Monitoring rate limits + self._remaining_calls = None + self._reset_time = None - def build_parameters(self, args, kargs): - self.parameters = {} + def build_parameters(self, args, kwargs): + self.session.params = {} for idx, arg in enumerate(args): if arg is None: continue - try: - self.parameters[self.allowed_param[idx]] = convert_to_utf8_str(arg) + self.session.params[self.allowed_param[idx]] = convert_to_utf8_str(arg) except IndexError: raise TweepError('Too many parameters supplied!') - for k, arg in kargs.items(): + for k, arg in kwargs.items(): if arg is None: continue - if k in self.parameters: + if k in self.session.params: raise TweepError('Multiple values for parameter %s supplied!' % k) - self.parameters[k] = convert_to_utf8_str(arg) + self.session.params[k] = convert_to_utf8_str(arg) + + log.info("PARAMS: %r", self.session.params) def build_path(self): for variable in re_path_template.findall(self.path): name = variable.strip('{}') - if name == 'user' and 'user' not in self.parameters and self.api.auth: + if name == 'user' and 'user' not in self.session.params and self.api.auth: # No 'user' parameter provided, fetch it from Auth instead. value = self.api.auth.get_username() else: try: - value = urllib.quote(self.parameters[name]) + value = quote(self.session.params[name]) except KeyError: raise TweepError('No parameter value found for path variable: %s' % name) - del self.parameters[name] + del self.session.params[name] self.path = self.path.replace(variable, value) @@ -108,8 +127,7 @@ def bind_api(**config): # Build the request URL url = self.api_root + self.path - if len(self.parameters): - url = '%s?%s' % (url, urllib.urlencode(self.parameters)) + full_url = 'https://' + self.host + url # Query the cache if one is available # and this request uses a GET method. @@ -132,60 +150,80 @@ def bind_api(**config): # or maximum number of retries is reached. retries_performed = 0 while retries_performed < self.retry_count + 1: - # Open connection - if self.api.secure: - conn = httplib.HTTPSConnection(self.host, timeout=self.api.timeout) - else: - conn = httplib.HTTPConnection(self.host, timeout=self.api.timeout) + # handle running out of api calls + if self.wait_on_rate_limit: + if self._reset_time is not None: + if self._remaining_calls is not None: + if self._remaining_calls < 1: + sleep_time = self._reset_time - int(time.time()) + if sleep_time > 0: + if self.wait_on_rate_limit_notify: + print("Rate limit reached. Sleeping for:", sleep_time) + time.sleep(sleep_time + 5) # sleep for few extra sec + + # if self.wait_on_rate_limit and self._reset_time is not None and \ + # self._remaining_calls is not None and self._remaining_calls < 1: + # sleep_time = self._reset_time - int(time.time()) + # if sleep_time > 0: + # if self.wait_on_rate_limit_notify: + # print("Rate limit reached. Sleeping for: " + str(sleep_time)) + # time.sleep(sleep_time + 5) # sleep for few extra sec # Apply authentication if self.api.auth: - self.api.auth.apply_auth( - self.scheme + self.host + url, - self.method, self.headers, self.parameters - ) + auth = self.api.auth.apply_auth() # Request compression if configured if self.api.compression: - self.headers['Accept-encoding'] = 'gzip' + self.session.headers['Accept-encoding'] = 'gzip' # Execute request try: - conn.request(self.method, url, headers=self.headers, body=self.post_data) - resp = conn.getresponse() + resp = self.session.request(self.method, + full_url, + data=self.post_data, + timeout=self.api.timeout, + auth=auth, + proxies=self.api.proxy) except Exception as e: raise TweepError('Failed to send request: %s' % e) - + rem_calls = resp.headers.get('x-rate-limit-remaining') + if rem_calls is not None: + self._remaining_calls = int(rem_calls) + elif isinstance(self._remaining_calls, int): + self._remaining_calls -= 1 + reset_time = resp.headers.get('x-rate-limit-reset') + if reset_time is not None: + self._reset_time = int(reset_time) + if self.wait_on_rate_limit and self._remaining_calls == 0 and ( + # if ran out of calls before waiting switching retry last call + resp.status_code == 429 or resp.status_code == 420): + continue + retry_delay = self.retry_delay # Exit request loop if non-retry error code - if self.retry_errors: - if resp.status not in self.retry_errors: break - else: - if resp.status == 200: break + if resp.status_code == 200: + break + elif (resp.status_code == 429 or resp.status_code == 420) and self.wait_on_rate_limit: + if 'retry-after' in resp.headers: + retry_delay = float(resp.headers['retry-after']) + elif self.retry_errors and resp.status_code not in self.retry_errors: + break # Sleep before retrying request again - time.sleep(self.retry_delay) + time.sleep(retry_delay) retries_performed += 1 # If an error was returned, throw an exception self.api.last_response = resp - if resp.status and not 200 <= resp.status < 300: + if resp.status_code and not 200 <= resp.status_code < 300: try: - error_msg = self.api.parser.parse_error(resp.read()) + error_msg = self.parser.parse_error(resp.text) except Exception: - error_msg = "Twitter error response: status code = %s" % resp.status + error_msg = "Twitter error response: status code = %s" % resp.status_code raise TweepError(error_msg, resp) # Parse the response payload - body = resp.read() - if resp.getheader('Content-Encoding', '') == 'gzip': - try: - zipper = gzip.GzipFile(fileobj=StringIO(body)) - body = zipper.read() - except Exception as e: - raise TweepError('Failed to decompress data: %s' % e) - result = self.api.parser.parse(self, body) - - conn.close() + result = self.parser.parse(self, resp.text) # Store result into cache if one is available. if self.use_cache and self.api.cache and self.method == 'GET' and result: @@ -193,21 +231,20 @@ def bind_api(**config): return result - - def _call(api, *args, **kargs): - - method = APIMethod(api, args, kargs) - return method.execute() - + def _call(*args, **kwargs): + method = APIMethod(args, kwargs) + if kwargs.get('create'): + return method + else: + return method.execute() # Set pagination mode if 'cursor' in APIMethod.allowed_param: _call.pagination_mode = 'cursor' - elif 'max_id' in APIMethod.allowed_param and \ - 'since_id' in APIMethod.allowed_param: - _call.pagination_mode = 'id' + elif 'max_id' in APIMethod.allowed_param: + if 'since_id' in APIMethod.allowed_param: + _call.pagination_mode = 'id' elif 'page' in APIMethod.allowed_param: _call.pagination_mode = 'page' return _call - diff --git a/tweepy/cache.py b/tweepy/cache.py index a50a349..1d6cb56 100644 --- a/tweepy/cache.py +++ b/tweepy/cache.py @@ -2,6 +2,8 @@ # Copyright 2009-2010 Joshua Roesslein # See LICENSE for details. +from __future__ import print_function + import time import datetime import threading @@ -119,7 +121,7 @@ class MemoryCache(Cache): def cleanup(self): self.lock.acquire() try: - for k, v in self._entries.items(): + for k, v in dict(self._entries).items(): if self._is_expired(v, self.timeout): del self._entries[k] finally: @@ -161,7 +163,7 @@ class FileCache(Cache): def _get_path(self, key): md5 = hashlib.md5() - md5.update(key) + md5.update(key.encode('utf-8')) return os.path.join(self.cache_dir, md5.hexdigest()) def _lock_file_dummy(self, path, exclusive=True): @@ -236,10 +238,11 @@ class FileCache(Cache): # check if value is expired if timeout is None: timeout = self.timeout - if timeout > 0 and (time.time() - created_time) >= timeout: - # expired! delete from cache - value = None - self._delete_file(path) + if timeout > 0: + if (time.time() - created_time) >= timeout: + # expired! delete from cache + value = None + self._delete_file(path) # unlock and return result self._unlock_file(f_lock) @@ -267,6 +270,7 @@ class FileCache(Cache): continue self._delete_file(os.path.join(self.cache_dir, entry)) + class MemCacheCache(Cache): """Cache interface""" @@ -288,7 +292,8 @@ class MemCacheCache(Cache): def get(self, key, timeout=None): """Get cached entry if exists and not expired key: which entry to get - timeout: override timeout with this value [optional]. DOES NOT WORK HERE + timeout: override timeout with this value [optional]. + DOES NOT WORK HERE """ return self.client.get(key) @@ -304,10 +309,14 @@ class MemCacheCache(Cache): """Delete all cached entries. NO-OP""" raise NotImplementedError + class RedisCache(Cache): - '''Cache running in a redis server''' + """Cache running in a redis server""" - def __init__(self, client, timeout=60, keys_container = 'tweepy:keys', pre_identifier = 'tweepy:'): + def __init__(self, client, + timeout=60, + keys_container='tweepy:keys', + pre_identifier='tweepy:'): Cache.__init__(self, timeout) self.client = client self.keys_container = keys_container @@ -318,8 +327,9 @@ class RedisCache(Cache): return timeout > 0 and (time.time() - entry[0]) >= timeout def store(self, key, value): - '''Store the key, value pair in our redis server''' - # Prepend tweepy to our key, this makes it easier to identify tweepy keys in our redis server + """Store the key, value pair in our redis server""" + # Prepend tweepy to our key, + # this makes it easier to identify tweepy keys in our redis server key = self.pre_identifier + key # Get a pipe (to execute several redis commands in one step) pipe = self.client.pipeline() @@ -333,7 +343,7 @@ class RedisCache(Cache): pipe.execute() def get(self, key, timeout=None): - '''Given a key, returns an element from the redis table''' + """Given a key, returns an element from the redis table""" key = self.pre_identifier + key # Check to see if we have this key unpickled_entry = self.client.get(key) @@ -356,19 +366,20 @@ class RedisCache(Cache): return entry[1] def count(self): - '''Note: This is not very efficient, since it retreives all the keys from the redis - server to know how many keys we have''' + """Note: This is not very efficient, + since it retreives all the keys from the redis + server to know how many keys we have""" return len(self.client.smembers(self.keys_container)) def delete_entry(self, key): - '''Delete an object from the redis table''' + """Delete an object from the redis table""" pipe = self.client.pipeline() pipe.srem(self.keys_container, key) pipe.delete(key) pipe.execute() def cleanup(self): - '''Cleanup all the expired keys''' + """Cleanup all the expired keys""" keys = self.client.smembers(self.keys_container) for key in keys: entry = self.client.get(key) @@ -378,7 +389,7 @@ class RedisCache(Cache): self.delete_entry(key) def flush(self): - '''Delete all entries from the cache''' + """Delete all entries from the cache""" keys = self.client.smembers(self.keys_container) for key in keys: self.delete_entry(key) diff --git a/tweepy/cursor.py b/tweepy/cursor.py index 4c06f17..1d63f49 100644 --- a/tweepy/cursor.py +++ b/tweepy/cursor.py @@ -2,7 +2,11 @@ # Copyright 2009-2010 Joshua Roesslein # See LICENSE for details. +from __future__ import print_function + from tweepy.error import TweepError +from tweepy.parsers import ModelParser, RawParser + class Cursor(object): """Pagination helper class""" @@ -32,6 +36,7 @@ class Cursor(object): i.limit = limit return i + class BaseIterator(object): def __init__(self, method, args, kargs): @@ -40,6 +45,9 @@ class BaseIterator(object): self.kargs = kargs self.limit = 0 + def __next__(self): + return self.next() + def next(self): raise NotImplementedError @@ -49,6 +57,7 @@ class BaseIterator(object): def __iter__(self): return self + class CursorIterator(BaseIterator): def __init__(self, method, args, kargs): @@ -56,67 +65,96 @@ class CursorIterator(BaseIterator): start_cursor = kargs.pop('cursor', None) self.next_cursor = start_cursor or -1 self.prev_cursor = start_cursor or 0 - self.count = 0 + self.num_tweets = 0 def next(self): - if self.next_cursor == 0 or (self.limit and self.count == self.limit): + if self.next_cursor == 0 or (self.limit and self.num_tweets == self.limit): raise StopIteration - data, cursors = self.method( - cursor=self.next_cursor, *self.args, **self.kargs - ) + data, cursors = self.method(cursor=self.next_cursor, + *self.args, + **self.kargs) self.prev_cursor, self.next_cursor = cursors if len(data) == 0: raise StopIteration - self.count += 1 + self.num_tweets += 1 return data def prev(self): if self.prev_cursor == 0: raise TweepError('Can not page back more, at first page') - data, self.next_cursor, self.prev_cursor = self.method( - cursor=self.prev_cursor, *self.args, **self.kargs - ) - self.count -= 1 + data, self.next_cursor, self.prev_cursor = self.method(cursor=self.prev_cursor, + *self.args, + **self.kargs) + self.num_tweets -= 1 return data + class IdIterator(BaseIterator): def __init__(self, method, args, kargs): BaseIterator.__init__(self, method, args, kargs) - self.max_id = kargs.get('max_id') - self.since_id = kargs.get('since_id') - self.count = 0 + self.max_id = kargs.pop('max_id', None) + self.num_tweets = 0 + self.results = [] + self.model_results = [] + self.index = 0 def next(self): """Fetch a set of items with IDs less than current set.""" - if self.limit and self.limit == self.count: + if self.limit and self.limit == self.num_tweets: raise StopIteration - # max_id is inclusive so decrement by one - # to avoid requesting duplicate items. - max_id = self.since_id - 1 if self.max_id else None - data = self.method(max_id = max_id, *self.args, **self.kargs) - if len(data) == 0: + if self.index >= len(self.results) - 1: + data = self.method(max_id=self.max_id, parser=RawParser(), *self.args, **self.kargs) + + if hasattr(self.method, '__self__'): + old_parser = self.method.__self__.parser + # Hack for models which expect ModelParser to be set + self.method.__self__.parser = ModelParser() + + # This is a special invocation that returns the underlying + # APIMethod class + model = ModelParser().parse(self.method(create=True), data) + if hasattr(self.method, '__self__'): + self.method.__self__.parser = old_parser + result = self.method.__self__.parser.parse(self.method(create=True), data) + else: + result = model + + if len(self.results) != 0: + self.index += 1 + self.results.append(result) + self.model_results.append(model) + else: + self.index += 1 + result = self.results[self.index] + model = self.model_results[self.index] + + if len(result) == 0: raise StopIteration - self.max_id = data.max_id - self.since_id = data.since_id - self.count += 1 - return data + # TODO: Make this not dependant on the parser making max_id and + # since_id available + self.max_id = model.max_id + self.num_tweets += 1 + return result def prev(self): """Fetch a set of items with IDs greater than current set.""" - if self.limit and self.limit == self.count: + if self.limit and self.limit == self.num_tweets: raise StopIteration - since_id = self.max_id - data = self.method(since_id = since_id, *self.args, **self.kargs) - if len(data) == 0: + self.index -= 1 + if self.index < 0: + # There's no way to fetch a set of tweets directly 'above' the + # current set raise StopIteration - self.max_id = data.max_id - self.since_id = data.since_id - self.count += 1 + + data = self.results[self.index] + self.max_id = self.model_results[self.index].max_id + self.num_tweets += 1 return data + class PageIterator(BaseIterator): def __init__(self, method, args, kargs): @@ -124,18 +162,23 @@ class PageIterator(BaseIterator): self.current_page = 0 def next(self): - self.current_page += 1 + if self.limit > 0: + if self.current_page > self.limit: + raise StopIteration + items = self.method(page=self.current_page, *self.args, **self.kargs) - if len(items) == 0 or (self.limit > 0 and self.current_page > self.limit): + if len(items) == 0: raise StopIteration + self.current_page += 1 return items def prev(self): - if (self.current_page == 1): + if self.current_page == 1: raise TweepError('Can not page back more, at first page') self.current_page -= 1 return self.method(page=self.current_page, *self.args, **self.kargs) + class ItemIterator(BaseIterator): def __init__(self, page_iterator): @@ -143,17 +186,18 @@ class ItemIterator(BaseIterator): self.limit = 0 self.current_page = None self.page_index = -1 - self.count = 0 + self.num_tweets = 0 def next(self): - if self.limit > 0 and self.count == self.limit: - raise StopIteration + if self.limit > 0: + if self.num_tweets == self.limit: + raise StopIteration if self.current_page is None or self.page_index == len(self.current_page) - 1: # Reached end of current page, get the next page... self.current_page = self.page_iterator.next() self.page_index = -1 self.page_index += 1 - self.count += 1 + self.num_tweets += 1 return self.current_page[self.page_index] def prev(self): @@ -166,6 +210,5 @@ class ItemIterator(BaseIterator): if self.page_index == 0: raise TweepError('No more items') self.page_index -= 1 - self.count -= 1 + self.num_tweets -= 1 return self.current_page[self.page_index] - diff --git a/tweepy/error.py b/tweepy/error.py index 753e2fe..1c47a5a 100644 --- a/tweepy/error.py +++ b/tweepy/error.py @@ -2,14 +2,18 @@ # Copyright 2009-2010 Joshua Roesslein # See LICENSE for details. +from __future__ import print_function + +import six + + class TweepError(Exception): """Tweepy exception""" def __init__(self, reason, response=None): - self.reason = unicode(reason) + self.reason = six.text_type(reason) self.response = response Exception.__init__(self, reason) def __str__(self): return self.reason - diff --git a/tweepy/models.py b/tweepy/models.py index 9b70070..09ed3eb 100644 --- a/tweepy/models.py +++ b/tweepy/models.py @@ -2,7 +2,8 @@ # Copyright 2009-2010 Joshua Roesslein # See LICENSE for details. -from tweepy.error import TweepError +from __future__ import absolute_import, print_function + from tweepy.utils import parse_datetime, parse_html_value, parse_a_href @@ -18,18 +19,21 @@ class ResultSet(list): if self._max_id: return self._max_id ids = self.ids() - return max(ids) if ids else None + # Max_id is always set to the *smallest* id, minus one, in the set + return (min(ids) - 1) if ids else None @property def since_id(self): if self._since_id: return self._since_id ids = self.ids() - return min(ids) if ids else None + # Since_id is always set to the *greatest* id in the set + return max(ids) if ids else None def ids(self): return [item.id for item in self if hasattr(item, 'id')] + class Model(object): def __init__(self, api=None): @@ -51,7 +55,10 @@ class Model(object): @classmethod def parse_list(cls, api, json_list): - """Parse a list of JSON objects into a result set of model instances.""" + """ + Parse a list of JSON objects into + a result set of model instances. + """ results = ResultSet() for obj in json_list: if obj: @@ -59,7 +66,7 @@ class Model(object): return results def __repr__(self): - state = ['%s=%s' % (k, repr(v)) for (k,v) in vars(self).items()] + state = ['%s=%s' % (k, repr(v)) for (k, v) in vars(self).items()] return '%s(%s)' % (self.__class__.__name__, ', '.join(state)) @@ -68,12 +75,13 @@ class Status(Model): @classmethod def parse(cls, api, json): status = cls(api) - for k, v in json.items(): - - # Hack by guyrt to fix unicode issues, which we do *not* want to teach. - if isinstance(v, basestring): - v = v.encode('ascii', errors='ignore') + # I'm not proud. Blame billg. + json['text'] = str(json['text'].encode('ascii', 'ignore'))[2:-1] + json['user']['screen_name'] = str(json['user']['screen_name'].encode('ascii', 'ignore'))[2:-1] + + setattr(status, '_json', json) + for k, v in json.items(): if k == 'user': user_model = getattr(api.parser.model_factory, 'user') if api else User user = user_model.parse(api, v) @@ -111,12 +119,27 @@ class Status(Model): def favorite(self): return self._api.create_favorite(self.id) + def __eq__(self, other): + if isinstance(other, Status): + return self.id == other.id + + return NotImplemented + + def __ne__(self, other): + result = self == other + + if result is NotImplemented: + return result + + return not result + class User(Model): @classmethod def parse(cls, api, json): user = cls(api) + setattr(user, '_json', json) for k, v in json.items(): if k == 'created_at': setattr(user, k, parse_datetime(v)) @@ -162,16 +185,24 @@ class User(Model): self.following = False def lists_memberships(self, *args, **kargs): - return self._api.lists_memberships(user=self.screen_name, *args, **kargs) + return self._api.lists_memberships(user=self.screen_name, + *args, + **kargs) def lists_subscriptions(self, *args, **kargs): - return self._api.lists_subscriptions(user=self.screen_name, *args, **kargs) + return self._api.lists_subscriptions(user=self.screen_name, + *args, + **kargs) def lists(self, *args, **kargs): - return self._api.lists_all(user=self.screen_name, *args, **kargs) + return self._api.lists_all(user=self.screen_name, + *args, + **kargs) def followers_ids(self, *args, **kargs): - return self._api.followers_ids(user_id=self.id, *args, **kargs) + return self._api.followers_ids(user_id=self.id, + *args, + **kargs) class DirectMessage(Model): @@ -242,15 +273,17 @@ class SearchResults(ResultSet): @classmethod def parse(cls, api, json): metadata = json['search_metadata'] - results = SearchResults(metadata.get('max_id'), metadata.get('since_id')) + results = SearchResults() results.refresh_url = metadata.get('refresh_url') results.completed_in = metadata.get('completed_in') results.query = metadata.get('query') results.count = metadata.get('count') results.next_results = metadata.get('next_results') + status_model = getattr(api.parser.model_factory, 'status') if api else Status + for status in json['statuses']: - results.append(Status.parse(api, status)) + results.append(status_model.parse(api, status)) return results @@ -259,7 +292,7 @@ class List(Model): @classmethod def parse(cls, api, json): lst = List(api) - for k,v in json.items(): + for k, v in json.items(): if k == 'user': setattr(lst, k, User.parse(api, v)) elif k == 'created_at': @@ -284,7 +317,9 @@ class List(Model): return self._api.destroy_list(self.slug) def timeline(self, **kargs): - return self._api.list_timeline(self.user.screen_name, self.slug, **kargs) + return self._api.list_timeline(self.user.screen_name, + self.slug, + **kargs) def add_member(self, id): return self._api.add_list_member(self.slug, id) @@ -293,10 +328,14 @@ class List(Model): return self._api.remove_list_member(self.slug, id) def members(self, **kargs): - return self._api.list_members(self.user.screen_name, self.slug, **kargs) + return self._api.list_members(self.user.screen_name, + self.slug, + **kargs) def is_member(self, id): - return self._api.is_list_member(self.user.screen_name, self.slug, id) + return self._api.is_list_member(self.user.screen_name, + self.slug, + id) def subscribe(self): return self._api.subscribe_list(self.user.screen_name, self.slug) @@ -305,16 +344,21 @@ class List(Model): return self._api.unsubscribe_list(self.user.screen_name, self.slug) def subscribers(self, **kargs): - return self._api.list_subscribers(self.user.screen_name, self.slug, **kargs) + return self._api.list_subscribers(self.user.screen_name, + self.slug, + **kargs) def is_subscribed(self, id): - return self._api.is_subscribed_list(self.user.screen_name, self.slug, id) + return self._api.is_subscribed_list(self.user.screen_name, + self.slug, + id) + class Relation(Model): @classmethod def parse(cls, api, json): result = cls(api) - for k,v in json.items(): + for k, v in json.items(): if k == 'value' and json['kind'] in ['Tweet', 'LookedupStatus']: setattr(result, k, Status.parse(api, v)) elif k == 'results': @@ -323,11 +367,12 @@ class Relation(Model): setattr(result, k, v) return result + class Relationship(Model): @classmethod def parse(cls, api, json): result = cls(api) - for k,v in json.items(): + for k, v in json.items(): if k == 'connections': setattr(result, 'is_following', 'following' in v) setattr(result, 'is_followed_by', 'followed_by' in v) @@ -335,6 +380,7 @@ class Relationship(Model): setattr(result, k, v) return result + class JSONModel(Model): @classmethod @@ -416,6 +462,17 @@ class Place(Model): results.append(cls.parse(api, obj)) return results + +class Media(Model): + + @classmethod + def parse(cls, api, json): + media = cls(api) + for k, v in json.items(): + setattr(media, k, v) + return media + + class ModelFactory(object): """ Used by parsers for creating instances @@ -433,9 +490,9 @@ class ModelFactory(object): list = List relation = Relation relationship = Relationship + media = Media json = JSONModel ids = IDModel place = Place bounding_box = BoundingBox - diff --git a/tweepy/oauth.py b/tweepy/oauth.py deleted file mode 100644 index 286de18..0000000 --- a/tweepy/oauth.py +++ /dev/null @@ -1,655 +0,0 @@ -""" -The MIT License - -Copyright (c) 2007 Leah Culver - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -""" - -import cgi -import urllib -import time -import random -import urlparse -import hmac -import binascii - - -VERSION = '1.0' # Hi Blaine! -HTTP_METHOD = 'GET' -SIGNATURE_METHOD = 'PLAINTEXT' - - -class OAuthError(RuntimeError): - """Generic exception class.""" - def __init__(self, message='OAuth error occured.'): - self.message = message - -def build_authenticate_header(realm=''): - """Optional WWW-Authenticate header (401 error)""" - return {'WWW-Authenticate': 'OAuth realm="%s"' % realm} - -def escape(s): - """Escape a URL including any /.""" - return urllib.quote(s, safe='~') - -def _utf8_str(s): - """Convert unicode to utf-8.""" - if isinstance(s, unicode): - return s.encode("utf-8") - else: - return str(s) - -def generate_timestamp(): - """Get seconds since epoch (UTC).""" - return int(time.time()) - -def generate_nonce(length=8): - """Generate pseudorandom number.""" - return ''.join([str(random.randint(0, 9)) for i in range(length)]) - -def generate_verifier(length=8): - """Generate pseudorandom number.""" - return ''.join([str(random.randint(0, 9)) for i in range(length)]) - - -class OAuthConsumer(object): - """Consumer of OAuth authentication. - - OAuthConsumer is a data type that represents the identity of the Consumer - via its shared secret with the Service Provider. - - """ - key = None - secret = None - - def __init__(self, key, secret): - self.key = key - self.secret = secret - - -class OAuthToken(object): - """OAuthToken is a data type that represents an End User via either an access - or request token. - - key -- the token - secret -- the token secret - - """ - key = None - secret = None - callback = None - callback_confirmed = None - verifier = None - - def __init__(self, key, secret): - self.key = key - self.secret = secret - - def set_callback(self, callback): - self.callback = callback - self.callback_confirmed = 'true' - - def set_verifier(self, verifier=None): - if verifier is not None: - self.verifier = verifier - else: - self.verifier = generate_verifier() - - def get_callback_url(self): - if self.callback and self.verifier: - # Append the oauth_verifier. - parts = urlparse.urlparse(self.callback) - scheme, netloc, path, params, query, fragment = parts[:6] - if query: - query = '%s&oauth_verifier=%s' % (query, self.verifier) - else: - query = 'oauth_verifier=%s' % self.verifier - return urlparse.urlunparse((scheme, netloc, path, params, - query, fragment)) - return self.callback - - def to_string(self): - data = { - 'oauth_token': self.key, - 'oauth_token_secret': self.secret, - } - if self.callback_confirmed is not None: - data['oauth_callback_confirmed'] = self.callback_confirmed - return urllib.urlencode(data) - - def from_string(s): - """ Returns a token from something like: - oauth_token_secret=xxx&oauth_token=xxx - """ - params = cgi.parse_qs(s, keep_blank_values=False) - key = params['oauth_token'][0] - secret = params['oauth_token_secret'][0] - token = OAuthToken(key, secret) - try: - token.callback_confirmed = params['oauth_callback_confirmed'][0] - except KeyError: - pass # 1.0, no callback confirmed. - return token - from_string = staticmethod(from_string) - - def __str__(self): - return self.to_string() - - -class OAuthRequest(object): - """OAuthRequest represents the request and can be serialized. - - OAuth parameters: - - oauth_consumer_key - - oauth_token - - oauth_signature_method - - oauth_signature - - oauth_timestamp - - oauth_nonce - - oauth_version - - oauth_verifier - ... any additional parameters, as defined by the Service Provider. - """ - parameters = None # OAuth parameters. - http_method = HTTP_METHOD - http_url = None - version = VERSION - - def __init__(self, http_method=HTTP_METHOD, http_url=None, parameters=None): - self.http_method = http_method - self.http_url = http_url - self.parameters = parameters or {} - - def set_parameter(self, parameter, value): - self.parameters[parameter] = value - - def get_parameter(self, parameter): - try: - return self.parameters[parameter] - except: - raise OAuthError('Parameter not found: %s' % parameter) - - def _get_timestamp_nonce(self): - return self.get_parameter('oauth_timestamp'), self.get_parameter( - 'oauth_nonce') - - def get_nonoauth_parameters(self): - """Get any non-OAuth parameters.""" - parameters = {} - for k, v in self.parameters.iteritems(): - # Ignore oauth parameters. - if k.find('oauth_') < 0: - parameters[k] = v - return parameters - - def to_header(self, realm=''): - """Serialize as a header for an HTTPAuth request.""" - auth_header = 'OAuth realm="%s"' % realm - # Add the oauth parameters. - if self.parameters: - for k, v in self.parameters.iteritems(): - if k[:6] == 'oauth_': - auth_header += ', %s="%s"' % (k, escape(str(v))) - return {'Authorization': auth_header} - - def to_postdata(self): - """Serialize as post data for a POST request.""" - return '&'.join(['%s=%s' % (escape(str(k)), escape(str(v))) \ - for k, v in self.parameters.iteritems()]) - - def to_url(self): - """Serialize as a URL for a GET request.""" - return '%s?%s' % (self.get_normalized_http_url(), self.to_postdata()) - - def get_normalized_parameters(self): - """Return a string that contains the parameters that must be signed.""" - params = self.parameters - try: - # Exclude the signature if it exists. - del params['oauth_signature'] - except: - pass - # Escape key values before sorting. - key_values = [(escape(_utf8_str(k)), escape(_utf8_str(v))) \ - for k,v in params.items()] - # Sort lexicographically, first after key, then after value. - key_values.sort() - # Combine key value pairs into a string. - return '&'.join(['%s=%s' % (k, v) for k, v in key_values]) - - def get_normalized_http_method(self): - """Uppercases the http method.""" - return self.http_method.upper() - - def get_normalized_http_url(self): - """Parses the URL and rebuilds it to be scheme://host/path.""" - parts = urlparse.urlparse(self.http_url) - scheme, netloc, path = parts[:3] - # Exclude default port numbers. - if scheme == 'http' and netloc[-3:] == ':80': - netloc = netloc[:-3] - elif scheme == 'https' and netloc[-4:] == ':443': - netloc = netloc[:-4] - return '%s://%s%s' % (scheme, netloc, path) - - def sign_request(self, signature_method, consumer, token): - """Set the signature parameter to the result of build_signature.""" - # Set the signature method. - self.set_parameter('oauth_signature_method', - signature_method.get_name()) - # Set the signature. - self.set_parameter('oauth_signature', - self.build_signature(signature_method, consumer, token)) - - def build_signature(self, signature_method, consumer, token): - """Calls the build signature method within the signature method.""" - return signature_method.build_signature(self, consumer, token) - - def from_request(http_method, http_url, headers=None, parameters=None, - query_string=None): - """Combines multiple parameter sources.""" - if parameters is None: - parameters = {} - - # Headers - if headers and 'Authorization' in headers: - auth_header = headers['Authorization'] - # Check that the authorization header is OAuth. - if auth_header[:6] == 'OAuth ': - auth_header = auth_header[6:] - try: - # Get the parameters from the header. - header_params = OAuthRequest._split_header(auth_header) - parameters.update(header_params) - except: - raise OAuthError('Unable to parse OAuth parameters from ' - 'Authorization header.') - - # GET or POST query string. - if query_string: - query_params = OAuthRequest._split_url_string(query_string) - parameters.update(query_params) - - # URL parameters. - param_str = urlparse.urlparse(http_url)[4] # query - url_params = OAuthRequest._split_url_string(param_str) - parameters.update(url_params) - - if parameters: - return OAuthRequest(http_method, http_url, parameters) - - return None - from_request = staticmethod(from_request) - - def from_consumer_and_token(oauth_consumer, token=None, - callback=None, verifier=None, http_method=HTTP_METHOD, - http_url=None, parameters=None): - if not parameters: - parameters = {} - - defaults = { - 'oauth_consumer_key': oauth_consumer.key, - 'oauth_timestamp': generate_timestamp(), - 'oauth_nonce': generate_nonce(), - 'oauth_version': OAuthRequest.version, - } - - defaults.update(parameters) - parameters = defaults - - if token: - parameters['oauth_token'] = token.key - if token.callback: - parameters['oauth_callback'] = token.callback - # 1.0a support for verifier. - if verifier: - parameters['oauth_verifier'] = verifier - elif callback: - # 1.0a support for callback in the request token request. - parameters['oauth_callback'] = callback - - return OAuthRequest(http_method, http_url, parameters) - from_consumer_and_token = staticmethod(from_consumer_and_token) - - def from_token_and_callback(token, callback=None, http_method=HTTP_METHOD, - http_url=None, parameters=None): - if not parameters: - parameters = {} - - parameters['oauth_token'] = token.key - - if callback: - parameters['oauth_callback'] = callback - - return OAuthRequest(http_method, http_url, parameters) - from_token_and_callback = staticmethod(from_token_and_callback) - - def _split_header(header): - """Turn Authorization: header into parameters.""" - params = {} - parts = header.split(',') - for param in parts: - # Ignore realm parameter. - if param.find('realm') > -1: - continue - # Remove whitespace. - param = param.strip() - # Split key-value. - param_parts = param.split('=', 1) - # Remove quotes and unescape the value. - params[param_parts[0]] = urllib.unquote(param_parts[1].strip('\"')) - return params - _split_header = staticmethod(_split_header) - - def _split_url_string(param_str): - """Turn URL string into parameters.""" - parameters = cgi.parse_qs(param_str, keep_blank_values=False) - for k, v in parameters.iteritems(): - parameters[k] = urllib.unquote(v[0]) - return parameters - _split_url_string = staticmethod(_split_url_string) - -class OAuthServer(object): - """A worker to check the validity of a request against a data store.""" - timestamp_threshold = 300 # In seconds, five minutes. - version = VERSION - signature_methods = None - data_store = None - - def __init__(self, data_store=None, signature_methods=None): - self.data_store = data_store - self.signature_methods = signature_methods or {} - - def set_data_store(self, data_store): - self.data_store = data_store - - def get_data_store(self): - return self.data_store - - def add_signature_method(self, signature_method): - self.signature_methods[signature_method.get_name()] = signature_method - return self.signature_methods - - def fetch_request_token(self, oauth_request): - """Processes a request_token request and returns the - request token on success. - """ - try: - # Get the request token for authorization. - token = self._get_token(oauth_request, 'request') - except OAuthError: - # No token required for the initial token request. - version = self._get_version(oauth_request) - consumer = self._get_consumer(oauth_request) - try: - callback = self.get_callback(oauth_request) - except OAuthError: - callback = None # 1.0, no callback specified. - self._check_signature(oauth_request, consumer, None) - # Fetch a new token. - token = self.data_store.fetch_request_token(consumer, callback) - return token - - def fetch_access_token(self, oauth_request): - """Processes an access_token request and returns the - access token on success. - """ - version = self._get_version(oauth_request) - consumer = self._get_consumer(oauth_request) - try: - verifier = self._get_verifier(oauth_request) - except OAuthError: - verifier = None - # Get the request token. - token = self._get_token(oauth_request, 'request') - self._check_signature(oauth_request, consumer, token) - new_token = self.data_store.fetch_access_token(consumer, token, verifier) - return new_token - - def verify_request(self, oauth_request): - """Verifies an api call and checks all the parameters.""" - # -> consumer and token - version = self._get_version(oauth_request) - consumer = self._get_consumer(oauth_request) - # Get the access token. - token = self._get_token(oauth_request, 'access') - self._check_signature(oauth_request, consumer, token) - parameters = oauth_request.get_nonoauth_parameters() - return consumer, token, parameters - - def authorize_token(self, token, user): - """Authorize a request token.""" - return self.data_store.authorize_request_token(token, user) - - def get_callback(self, oauth_request): - """Get the callback URL.""" - return oauth_request.get_parameter('oauth_callback') - - def build_authenticate_header(self, realm=''): - """Optional support for the authenticate header.""" - return {'WWW-Authenticate': 'OAuth realm="%s"' % realm} - - def _get_version(self, oauth_request): - """Verify the correct version request for this server.""" - try: - version = oauth_request.get_parameter('oauth_version') - except: - version = VERSION - if version and version != self.version: - raise OAuthError('OAuth version %s not supported.' % str(version)) - return version - - def _get_signature_method(self, oauth_request): - """Figure out the signature with some defaults.""" - try: - signature_method = oauth_request.get_parameter( - 'oauth_signature_method') - except: - signature_method = SIGNATURE_METHOD - try: - # Get the signature method object. - signature_method = self.signature_methods[signature_method] - except: - signature_method_names = ', '.join(self.signature_methods.keys()) - raise OAuthError('Signature method %s not supported try one of the ' - 'following: %s' % (signature_method, signature_method_names)) - - return signature_method - - def _get_consumer(self, oauth_request): - consumer_key = oauth_request.get_parameter('oauth_consumer_key') - consumer = self.data_store.lookup_consumer(consumer_key) - if not consumer: - raise OAuthError('Invalid consumer.') - return consumer - - def _get_token(self, oauth_request, token_type='access'): - """Try to find the token for the provided request token key.""" - token_field = oauth_request.get_parameter('oauth_token') - token = self.data_store.lookup_token(token_type, token_field) - if not token: - raise OAuthError('Invalid %s token: %s' % (token_type, token_field)) - return token - - def _get_verifier(self, oauth_request): - return oauth_request.get_parameter('oauth_verifier') - - def _check_signature(self, oauth_request, consumer, token): - timestamp, nonce = oauth_request._get_timestamp_nonce() - self._check_timestamp(timestamp) - self._check_nonce(consumer, token, nonce) - signature_method = self._get_signature_method(oauth_request) - try: - signature = oauth_request.get_parameter('oauth_signature') - except: - raise OAuthError('Missing signature.') - # Validate the signature. - valid_sig = signature_method.check_signature(oauth_request, consumer, - token, signature) - if not valid_sig: - key, base = signature_method.build_signature_base_string( - oauth_request, consumer, token) - raise OAuthError('Invalid signature. Expected signature base ' - 'string: %s' % base) - built = signature_method.build_signature(oauth_request, consumer, token) - - def _check_timestamp(self, timestamp): - """Verify that timestamp is recentish.""" - timestamp = int(timestamp) - now = int(time.time()) - lapsed = abs(now - timestamp) - if lapsed > self.timestamp_threshold: - raise OAuthError('Expired timestamp: given %d and now %s has a ' - 'greater difference than threshold %d' % - (timestamp, now, self.timestamp_threshold)) - - def _check_nonce(self, consumer, token, nonce): - """Verify that the nonce is uniqueish.""" - nonce = self.data_store.lookup_nonce(consumer, token, nonce) - if nonce: - raise OAuthError('Nonce already used: %s' % str(nonce)) - - -class OAuthClient(object): - """OAuthClient is a worker to attempt to execute a request.""" - consumer = None - token = None - - def __init__(self, oauth_consumer, oauth_token): - self.consumer = oauth_consumer - self.token = oauth_token - - def get_consumer(self): - return self.consumer - - def get_token(self): - return self.token - - def fetch_request_token(self, oauth_request): - """-> OAuthToken.""" - raise NotImplementedError - - def fetch_access_token(self, oauth_request): - """-> OAuthToken.""" - raise NotImplementedError - - def access_resource(self, oauth_request): - """-> Some protected resource.""" - raise NotImplementedError - - -class OAuthDataStore(object): - """A database abstraction used to lookup consumers and tokens.""" - - def lookup_consumer(self, key): - """-> OAuthConsumer.""" - raise NotImplementedError - - def lookup_token(self, oauth_consumer, token_type, token_token): - """-> OAuthToken.""" - raise NotImplementedError - - def lookup_nonce(self, oauth_consumer, oauth_token, nonce): - """-> OAuthToken.""" - raise NotImplementedError - - def fetch_request_token(self, oauth_consumer, oauth_callback): - """-> OAuthToken.""" - raise NotImplementedError - - def fetch_access_token(self, oauth_consumer, oauth_token, oauth_verifier): - """-> OAuthToken.""" - raise NotImplementedError - - def authorize_request_token(self, oauth_token, user): - """-> OAuthToken.""" - raise NotImplementedError - - -class OAuthSignatureMethod(object): - """A strategy class that implements a signature method.""" - def get_name(self): - """-> str.""" - raise NotImplementedError - - def build_signature_base_string(self, oauth_request, oauth_consumer, oauth_token): - """-> str key, str raw.""" - raise NotImplementedError - - def build_signature(self, oauth_request, oauth_consumer, oauth_token): - """-> str.""" - raise NotImplementedError - - def check_signature(self, oauth_request, consumer, token, signature): - built = self.build_signature(oauth_request, consumer, token) - return built == signature - - -class OAuthSignatureMethod_HMAC_SHA1(OAuthSignatureMethod): - - def get_name(self): - return 'HMAC-SHA1' - - def build_signature_base_string(self, oauth_request, consumer, token): - sig = ( - escape(oauth_request.get_normalized_http_method()), - escape(oauth_request.get_normalized_http_url()), - escape(oauth_request.get_normalized_parameters()), - ) - - key = '%s&' % escape(consumer.secret) - if token: - key += escape(token.secret) - raw = '&'.join(sig) - return key, raw - - def build_signature(self, oauth_request, consumer, token): - """Builds the base signature string.""" - key, raw = self.build_signature_base_string(oauth_request, consumer, - token) - - # HMAC object. - try: - import hashlib # 2.5 - hashed = hmac.new(key, raw, hashlib.sha1) - except: - import sha # Deprecated - hashed = hmac.new(key, raw, sha) - - # Calculate the digest base 64. - return binascii.b2a_base64(hashed.digest())[:-1] - - -class OAuthSignatureMethod_PLAINTEXT(OAuthSignatureMethod): - - def get_name(self): - return 'PLAINTEXT' - - def build_signature_base_string(self, oauth_request, consumer, token): - """Concatenates the consumer key and secret.""" - sig = '%s&' % escape(consumer.secret) - if token: - sig = sig + escape(token.secret) - return sig, sig - - def build_signature(self, oauth_request, consumer, token): - key, raw = self.build_signature_base_string(oauth_request, consumer, - token) - return key \ No newline at end of file diff --git a/tweepy/parsers.py b/tweepy/parsers.py index 31e0022..bccb032 100644 --- a/tweepy/parsers.py +++ b/tweepy/parsers.py @@ -2,6 +2,8 @@ # Copyright 2009-2010 Joshua Roesslein # See LICENSE for details. +from __future__ import print_function + from tweepy.models import ModelFactory from tweepy.utils import import_simplejson from tweepy.error import TweepError @@ -51,10 +53,12 @@ class JSONParser(Parser): except Exception as e: raise TweepError('Failed to parse JSON payload: %s' % e) - needsCursors = method.parameters.has_key('cursor') - if needsCursors and isinstance(json, dict) and 'previous_cursor' in json and 'next_cursor' in json: - cursors = json['previous_cursor'], json['next_cursor'] - return json, cursors + needs_cursors = 'cursor' in method.session.params + if needs_cursors and isinstance(json, dict): + if 'previous_cursor' in json: + if 'next_cursor' in json: + cursors = json['previous_cursor'], json['next_cursor'] + return json, cursors else: return json @@ -74,10 +78,12 @@ class ModelParser(JSONParser): def parse(self, method, payload): try: - if method.payload_type is None: return + if method.payload_type is None: + return model = getattr(self.model_factory, method.payload_type) except AttributeError: - raise TweepError('No model for this payload type: %s' % method.payload_type) + raise TweepError('No model for this payload type: ' + '%s' % method.payload_type) json = JSONParser.parse(self, method, payload) if isinstance(json, tuple): @@ -94,4 +100,3 @@ class ModelParser(JSONParser): return result, cursors else: return result - diff --git a/tweepy/streaming.py b/tweepy/streaming.py index be233ff..faf42ea 100644 --- a/tweepy/streaming.py +++ b/tweepy/streaming.py @@ -2,18 +2,25 @@ # Copyright 2009-2010 Joshua Roesslein # See LICENSE for details. +# Appengine users: https://developers.google.com/appengine/docs/python/sockets/#making_httplib_use_sockets + +from __future__ import absolute_import, print_function + import logging -import httplib -from socket import timeout +import requests +from requests.exceptions import Timeout from threading import Thread from time import sleep + +import six + import ssl from tweepy.models import Status from tweepy.api import API from tweepy.error import TweepError -from tweepy.utils import import_simplejson, urlencode_noplus +from tweepy.utils import import_simplejson json = import_simplejson() STREAM_VERSION = '1.1' @@ -57,15 +64,25 @@ class StreamListener(object): status = Status.parse(self.api, data) if self.on_direct_message(status) is False: return False + elif 'friends' in data: + if self.on_friends(data['friends']) is False: + return False elif 'limit' in data: if self.on_limit(data['limit']['track']) is False: return False elif 'disconnect' in data: if self.on_disconnect(data['disconnect']) is False: return False + elif 'warning' in data: + if self.on_warning(data['warning']) is False: + return False else: logging.error("Unknown message type: " + str(raw_data)) + def keep_alive(self): + """Called when a keep-alive arrived""" + return + def on_status(self, status): """Called when a new status arrives""" return @@ -86,8 +103,15 @@ class StreamListener(object): """Called when a new direct message arrives""" return + def on_friends(self, friends): + """Called when a friends list arrives. + + friends is a list that contains user_id + """ + return + def on_limit(self, track): - """Called when a limitation notice arrvies""" + """Called when a limitation notice arrives""" return def on_error(self, status_code): @@ -106,6 +130,51 @@ class StreamListener(object): """ return + def on_warning(self, notice): + """Called when a disconnection warning message arrives""" + return + + +class ReadBuffer(object): + """Buffer data from the response in a smarter way than httplib/requests can. + + Tweets are roughly in the 2-12kb range, averaging around 3kb. + Requests/urllib3/httplib/socket all use socket.read, which blocks + until enough data is returned. On some systems (eg google appengine), socket + reads are quite slow. To combat this latency we can read big chunks, + but the blocking part means we won't get results until enough tweets + have arrived. That may not be a big deal for high throughput systems. + For low throughput systems we don't want to sacrafice latency, so we + use small chunks so it can read the length and the tweet in 2 read calls. + """ + + def __init__(self, stream, chunk_size): + self._stream = stream + self._buffer = u"" + self._chunk_size = chunk_size + + def read_len(self, length): + while not self._stream.closed: + if len(self._buffer) >= length: + return self._pop(length) + read_len = max(self._chunk_size, length - len(self._buffer)) + self._buffer += self._stream.read(read_len).decode("ascii") + + def read_line(self, sep='\n'): + start = 0 + while not self._stream.closed: + loc = self._buffer.find(sep, start) + if loc >= 0: + return self._pop(loc + len(sep)) + else: + start = len(self._buffer) + self._buffer += self._stream.read(self._chunk_size).decode("ascii") + + def _pop(self, length): + r = self._buffer[:length] + self._buffer = self._buffer[length:] + return r + class Stream(object): @@ -117,82 +186,99 @@ class Stream(object): self.running = False self.timeout = options.get("timeout", 300.0) self.retry_count = options.get("retry_count") - # values according to https://dev.twitter.com/docs/streaming-apis/connecting#Reconnecting + # values according to + # https://dev.twitter.com/docs/streaming-apis/connecting#Reconnecting self.retry_time_start = options.get("retry_time", 5.0) self.retry_420_start = options.get("retry_420", 60.0) self.retry_time_cap = options.get("retry_time_cap", 320.0) self.snooze_time_step = options.get("snooze_time", 0.25) self.snooze_time_cap = options.get("snooze_time_cap", 16) - self.buffer_size = options.get("buffer_size", 1500) - if options.get("secure", True): - self.scheme = "https" - else: - self.scheme = "http" + + # The default socket.read size. Default to less than half the size of + # a tweet so that it reads tweets with the minimal latency of 2 reads + # per tweet. Values higher than ~1kb will increase latency by waiting + # for more data to arrive but may also increase throughput by doing + # fewer socket read calls. + self.chunk_size = options.get("chunk_size", 512) + + self.verify = options.get("verify", True) self.api = API() self.headers = options.get("headers") or {} - self.parameters = None + self.new_session() self.body = None self.retry_time = self.retry_time_start self.snooze_time = self.snooze_time_step + def new_session(self): + self.session = requests.Session() + self.session.headers = self.headers + self.session.params = None + def _run(self): # Authenticate - url = "%s://%s%s" % (self.scheme, self.host, self.url) + url = "https://%s%s" % (self.host, self.url) # Connect and process the stream error_counter = 0 - conn = None + resp = None exception = None while self.running: - if self.retry_count is not None and error_counter > self.retry_count: - # quit if error count greater than retry count - break + if self.retry_count is not None: + if error_counter > self.retry_count: + # quit if error count greater than retry count + break try: - if self.scheme == "http": - conn = httplib.HTTPConnection(self.host, timeout=self.timeout) - else: - conn = httplib.HTTPSConnection(self.host, timeout=self.timeout) - self.auth.apply_auth(url, 'POST', self.headers, self.parameters) - conn.connect() - conn.request('POST', self.url, self.body, headers=self.headers) - resp = conn.getresponse() - if resp.status != 200: - if self.listener.on_error(resp.status) is False: + auth = self.auth.apply_auth() + resp = self.session.request('POST', + url, + data=self.body, + timeout=self.timeout, + stream=True, + auth=auth, + verify=self.verify) + if resp.status_code != 200: + if self.listener.on_error(resp.status_code) is False: break error_counter += 1 - if resp.status == 420: - self.retry_time = max(self.retry_420_start, self.retry_time) + if resp.status_code == 420: + self.retry_time = max(self.retry_420_start, + self.retry_time) sleep(self.retry_time) - self.retry_time = min(self.retry_time * 2, self.retry_time_cap) + self.retry_time = min(self.retry_time * 2, + self.retry_time_cap) else: error_counter = 0 self.retry_time = self.retry_time_start self.snooze_time = self.snooze_time_step self.listener.on_connect() self._read_loop(resp) - except (timeout, ssl.SSLError) as exc: + except (Timeout, ssl.SSLError) as exc: + # This is still necessary, as a SSLError can actually be + # thrown when using Requests # If it's not time out treat it like any other exception - if isinstance(exc, ssl.SSLError) and not (exc.args and 'timed out' in str(exc.args[0])): - exception = exc - break - - if self.listener.on_timeout() == False: + if isinstance(exc, ssl.SSLError): + if not (exc.args and 'timed out' in str(exc.args[0])): + exception = exc + break + if self.listener.on_timeout() is False: break if self.running is False: break - conn.close() sleep(self.snooze_time) self.snooze_time = min(self.snooze_time + self.snooze_time_step, self.snooze_time_cap) - except Exception as exception: - # any other exception is fatal, so kill loop - break + # except Exception as exc: + # exception = exc + # # any other exception is fatal, so kill loop + # break # cleanup self.running = False - if conn: - conn.close() + if resp: + resp.close() + + self.new_session() if exception: # call a handler first so that the exception can be logged. @@ -204,34 +290,58 @@ class Stream(object): self.running = False def _read_loop(self, resp): + buf = ReadBuffer(resp.raw, self.chunk_size) + + while self.running and not resp.raw.closed: + length = 0 + while not resp.raw.closed: + line = buf.read_line().strip() + if not line: + self.listener.keep_alive() # keep-alive new lines are expected + elif line.isdigit(): + length = int(line) + break + else: + raise TweepError('Expecting length, unexpected value found') - while self.running and not resp.isclosed(): - - # Note: keep-alive newlines might be inserted before each length value. - # read until we get a digit... - c = '\n' - while c == '\n' and self.running and not resp.isclosed(): - c = resp.read(1) - delimited_string = c - - # read rest of delimiter length.. - d = '' - while d != '\n' and self.running and not resp.isclosed(): - d = resp.read(1) - delimited_string += d - - # read the next twitter status object - if delimited_string.strip().isdigit(): - next_status_obj = resp.read( int(delimited_string) ) + next_status_obj = buf.read_len(length) + if self.running: self._data(next_status_obj) - if resp.isclosed(): + # # Note: keep-alive newlines might be inserted before each length value. + # # read until we get a digit... + # c = b'\n' + # for c in resp.iter_content(decode_unicode=True): + # if c == b'\n': + # continue + # break + # + # delimited_string = c + # + # # read rest of delimiter length.. + # d = b'' + # for d in resp.iter_content(decode_unicode=True): + # if d != b'\n': + # delimited_string += d + # continue + # break + # + # # read the next twitter status object + # if delimited_string.decode('utf-8').strip().isdigit(): + # status_id = int(delimited_string) + # next_status_obj = resp.raw.read(status_id) + # if self.running: + # self._data(next_status_obj.decode('utf-8')) + + + if resp.raw.closed: self.on_closed(resp) def _start(self, async): self.running = True if async: - Thread(target=self._run).start() + self._thread = Thread(target=self._run) + self._thread.start() else: self._run() @@ -239,81 +349,101 @@ class Stream(object): """ Called when the response has been closed by Twitter """ pass - def userstream(self, stall_warnings=False, _with=None, replies=None, - track=None, locations=None, async=False, encoding='utf8'): - self.parameters = {'delimited': 'length'} + def userstream(self, + stall_warnings=False, + _with=None, + replies=None, + track=None, + locations=None, + async=False, + encoding='utf8'): + self.session.params = {'delimited': 'length'} if self.running: raise TweepError('Stream object already connected!') - self.url = '/%s/user.json?delimited=length' % STREAM_VERSION - self.host='userstream.twitter.com' + self.url = '/%s/user.json' % STREAM_VERSION + self.host = 'userstream.twitter.com' if stall_warnings: - self.parameters['stall_warnings'] = stall_warnings + self.session.params['stall_warnings'] = stall_warnings if _with: - self.parameters['with'] = _with + self.session.params['with'] = _with if replies: - self.parameters['replies'] = replies + self.session.params['replies'] = replies if locations and len(locations) > 0: - assert len(locations) % 4 == 0 - self.parameters['locations'] = ','.join(['%.2f' % l for l in locations]) + if len(locations) % 4 != 0: + raise TweepError("Wrong number of locations points, " + "it has to be a multiple of 4") + self.session.params['locations'] = ','.join(['%.2f' % l for l in locations]) if track: - encoded_track = [s.encode(encoding) for s in track] - self.parameters['track'] = ','.join(encoded_track) - self.body = urlencode_noplus(self.parameters) + self.session.params['track'] = u','.join(track).encode(encoding) + self._start(async) def firehose(self, count=None, async=False): - self.parameters = {'delimited': 'length'} + self.session.params = {'delimited': 'length'} if self.running: raise TweepError('Stream object already connected!') - self.url = '/%s/statuses/firehose.json?delimited=length' % STREAM_VERSION + self.url = '/%s/statuses/firehose.json' % STREAM_VERSION if count: self.url += '&count=%s' % count self._start(async) def retweet(self, async=False): - self.parameters = {'delimited': 'length'} + self.session.params = {'delimited': 'length'} if self.running: raise TweepError('Stream object already connected!') - self.url = '/%s/statuses/retweet.json?delimited=length' % STREAM_VERSION + self.url = '/%s/statuses/retweet.json' % STREAM_VERSION self._start(async) - def sample(self, count=None, async=False): - self.parameters = {'delimited': 'length'} + def sample(self, async=False, languages=None): + self.session.params = {'delimited': 'length'} if self.running: raise TweepError('Stream object already connected!') - self.url = '/%s/statuses/sample.json?delimited=length' % STREAM_VERSION - if count: - self.url += '&count=%s' % count + self.url = '/%s/statuses/sample.json' % STREAM_VERSION + if languages: + self.session.params['language'] = ','.join(map(str, languages)) self._start(async) def filter(self, follow=None, track=None, async=False, locations=None, - count=None, stall_warnings=False, languages=None, encoding='utf8'): - self.parameters = {} - self.headers['Content-type'] = "application/x-www-form-urlencoded" + stall_warnings=False, languages=None, encoding='utf8'): + self.body = {} + self.session.headers['Content-type'] = "application/x-www-form-urlencoded" if self.running: raise TweepError('Stream object already connected!') - self.url = '/%s/statuses/filter.json?delimited=length' % STREAM_VERSION + self.url = '/%s/statuses/filter.json' % STREAM_VERSION if follow: - encoded_follow = [s.encode(encoding) for s in follow] - self.parameters['follow'] = ','.join(encoded_follow) + self.body['follow'] = u','.join(follow).encode(encoding) if track: - encoded_track = [s.encode(encoding) for s in track] - self.parameters['track'] = ','.join(encoded_track) + self.body['track'] = u','.join(track).encode(encoding) if locations and len(locations) > 0: - assert len(locations) % 4 == 0 - self.parameters['locations'] = ','.join(['%.4f' % l for l in locations]) - if count: - self.parameters['count'] = count + if len(locations) % 4 != 0: + raise TweepError("Wrong number of locations points, " + "it has to be a multiple of 4") + self.body['locations'] = u','.join(['%.4f' % l for l in locations]) if stall_warnings: - self.parameters['stall_warnings'] = stall_warnings + self.body['stall_warnings'] = stall_warnings if languages: - self.parameters['language'] = ','.join(map(str, languages)) - self.body = urlencode_noplus(self.parameters) - self.parameters['delimited'] = 'length' + self.body['language'] = u','.join(map(str, languages)) + self.session.params = {'delimited': 'length'} + self.host = 'stream.twitter.com' + self._start(async) + + def sitestream(self, follow, stall_warnings=False, + with_='user', replies=False, async=False): + self.body = {} + if self.running: + raise TweepError('Stream object already connected!') + self.url = '/%s/site.json' % STREAM_VERSION + self.body['follow'] = u','.join(map(six.text_type, follow)) + self.body['delimited'] = 'length' + if stall_warnings: + self.body['stall_warnings'] = stall_warnings + if with_: + self.body['with'] = with_ + if replies: + self.body['replies'] = replies self._start(async) def disconnect(self): if self.running is False: return self.running = False - diff --git a/tweepy/utils.py b/tweepy/utils.py index e5d2a5e..36d3402 100644 --- a/tweepy/utils.py +++ b/tweepy/utils.py @@ -2,11 +2,13 @@ # Copyright 2010 Joshua Roesslein # See LICENSE for details. +from __future__ import print_function + from datetime import datetime -import time -import re -import locale -from urllib import quote + +import six +from six.moves.urllib.parse import quote + from email.utils import parsedate @@ -28,14 +30,13 @@ def parse_a_href(atag): def convert_to_utf8_str(arg): # written by Michael Norton (http://docondev.blogspot.com/) - if isinstance(arg, unicode): + if isinstance(arg, six.text_type): arg = arg.encode('utf-8') - elif not isinstance(arg, str): - arg = str(arg) + elif not isinstance(arg, bytes): + arg = six.text_type(arg).encode('utf-8') return arg - def import_simplejson(): try: import simplejson as json @@ -44,16 +45,14 @@ def import_simplejson(): import json # Python 2.6+ except ImportError: try: - from django.utils import simplejson as json # Google App Engine + # Google App Engine + from django.utils import simplejson as json except ImportError: raise ImportError("Can't load a json library") return json + def list_to_csv(item_list): if item_list: return ','.join([str(i) for i in item_list]) - -def urlencode_noplus(query): - return '&'.join(['%s=%s' % (quote(str(k), ''), quote(str(v), '')) \ - for k, v in query.iteritems()]) diff --git a/twitter-stream-raw1.py b/twitter-stream-raw1.py index 734a59f..2ed1403 100644 --- a/twitter-stream-raw1.py +++ b/twitter-stream-raw1.py @@ -5,7 +5,7 @@ from twitter_authentication import CONSUMER_KEY, CONSUMER_SECRET, ACCESS_TOKEN, auth = tweepy.OAuthHandler(CONSUMER_KEY, CONSUMER_SECRET) auth.set_access_token(ACCESS_TOKEN, ACCESS_TOKEN_SECRET) -api = tweepy.API(auth, parser=tweepy.parsers.RawParser) +api = tweepy.API(auth, parser=tweepy.parsers.RawParser()) @classmethod def parse(cls, api, raw): @@ -18,10 +18,10 @@ tweepy.models.Status.parse = parse class StreamListener(tweepy.StreamListener): def on_status(self, tweet): - print tweet.json + print(tweet.json) def on_error(self, status_code): - print 'Error: ' + repr(status_code) + print('Error: ' + repr(status_code)) return False l = StreamListener() diff --git a/twitter-stream1.py b/twitter-stream1.py index 06f7be0..87c548c 100644 --- a/twitter-stream1.py +++ b/twitter-stream1.py @@ -8,10 +8,10 @@ api = tweepy.API(auth) class StreamListener(tweepy.StreamListener): def on_status(self, tweet): - print tweet.user.screen_name + "\t" + tweet.text + print(tweet.author.screen_name + "\t" + tweet.text) def on_error(self, status_code): - print 'Error: ' + repr(status_code) + print('Error: ' + repr(status_code)) return False l = StreamListener() diff --git a/twitter-stream2.py b/twitter-stream2.py index ce905ea..8e1eee1 100644 --- a/twitter-stream2.py +++ b/twitter-stream2.py @@ -8,10 +8,10 @@ api = tweepy.API(auth) class StreamListener(tweepy.StreamListener): def on_status(self, tweet): - print tweet.user.screen_name + "\t" + tweet.text + print(tweet.author.screen_name + "\t" + tweet.text) def on_error(self, status_code): - print 'Error: ' + repr(status_code) + print( 'Error: ' + repr(status_code)) return False l = StreamListener() diff --git a/twitter1.py b/twitter1.py index cee5967..c1ec98f 100644 --- a/twitter1.py +++ b/twitter1.py @@ -9,4 +9,4 @@ api = tweepy.API(auth) public_tweets = api.home_timeline(count=100) for tweet in public_tweets: - print tweet.text + print(tweet.text) diff --git a/twitter2.py b/twitter2.py index b00b0f7..96a86fc 100644 --- a/twitter2.py +++ b/twitter2.py @@ -8,10 +8,10 @@ api = tweepy.API(auth) user = api.get_user('makoshark') -print user.screen_name + " has " + str(user.followers_count) + " followers." +print(user.screen_name + " has " + str(user.followers_count) + " followers.") -print "They include these 100 people:" +print("They include these 100 people:") for friend in user.friends(count=100): - print friend.screen_name + print(friend.screen_name) diff --git a/twitter3.py b/twitter3.py index 897b7b3..d9b7ab7 100644 --- a/twitter3.py +++ b/twitter3.py @@ -9,4 +9,4 @@ api = tweepy.API(auth) public_tweets = api.search("data science", count=20) for tweet in public_tweets: - print tweet.user.screen_name + "\t" + str(tweet.created_at) + "\t" + tweet.text + print(tweet.user.screen_name + "\t" + str(tweet.created_at) + "\t" + tweet.text) diff --git a/twitter4.py b/twitter4.py index 26d4027..2bf35a6 100644 --- a/twitter4.py +++ b/twitter4.py @@ -13,5 +13,5 @@ output_file = codecs.open("MY_DATA.tsv", "w", "utf-8") public_tweets = api.search("data science", count=10) for tweet in public_tweets: - print >>output_file, tweet.user.screen_name + "\t" + str(tweet.created_at) + "\t" + tweet.text + print(tweet.user.screen_name + "\t" + str(tweet.created_at) + "\t" + tweet.text, file=output_file)