From 5b9e79e733108bf983d22237587d7b6e782af0db Mon Sep 17 00:00:00 2001 From: Joe Bowman Date: Tue, 31 Aug 2010 11:38:06 -0400 Subject: [PATCH] auth.py updates for OAuth 1.0a and 2.0. OAuthMixin now supports both 1.0a and 1.0 (defaults to 1.0a). TwitterMixin and GoogleMixin now use 1.0a, FriendfeedMixin remains at 1.0. This adds support for the callback_uri parameter to TwitterMixin. New classes OAuth2Mixin and FacebookGraphMixin are introduced for the new Facebook APIs. Backwards compatibility notes: Pending authorizations begun prior to this change will not be able to be completed after it (tokens issued under prior versions of this code will still work; this warning applies only to users who have started but not yet completed their initial authorizations). If you have used OAuthMixin directly (not one of the subclasses in this module) to access a service that does not support OAuth 1.0a, set _OAUTH_VERSION = "1.0" in your subclass. Pull requests: http://github.com/facebook/tornado/pull/126 http://github.com/facebook/tornado/pull/128 http://github.com/facebook/tornado/pull/130 Written by Joe Bowman, rebased from http://github.com/joerussbowman/tornado/commit/1572ecc6726f1ab33d2153798af0f13ad9e75ab6 by Ben Darnell. --- tornado/auth.py | 264 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 248 insertions(+), 16 deletions(-) diff --git a/tornado/auth.py b/tornado/auth.py index 2695cc1f1..d77e5c479 100644 --- a/tornado/auth.py +++ b/tornado/auth.py @@ -45,6 +45,7 @@ class GoogleHandler(tornado.web.RequestHandler, tornado.auth.GoogleMixin): """ +import base64 import binascii import cgi import hashlib @@ -57,6 +58,7 @@ import uuid from tornado import httpclient from tornado import escape +from tornado.ioloop import IOLoop class OpenIdMixin(object): """Abstract implementation of OpenID and Attribute Exchange. @@ -200,7 +202,8 @@ class OAuthMixin(object): See TwitterMixin and FriendFeedMixin below for example implementations. """ - def authorize_redirect(self, callback_uri=None): + + def authorize_redirect(self, callback_uri=None, extra_params=None): """Redirects the user to obtain OAuth authorization for this service. Twitter and FriendFeed both require that you register a Callback @@ -216,8 +219,17 @@ class OAuthMixin(object): if callback_uri and getattr(self, "_OAUTH_NO_CALLBACKS", False): raise Exception("This service does not support oauth_callback") http = httpclient.AsyncHTTPClient() - http.fetch(self._oauth_request_token_url(), self.async_callback( - self._on_request_token, self._OAUTH_AUTHORIZE_URL, callback_uri)) + if getattr(self, "_OAUTH_VERSION", "1.0a") == "1.0a": + http.fetch(self._oauth_request_token_url(callback_uri=callback_uri, + extra_params=extra_params), + self.async_callback( + self._on_request_token, + self._OAUTH_AUTHORIZE_URL, + callback_uri)) + else: + http.fetch(self._oauth_request_token_url(), self.async_callback( + self._on_request_token, self._OAUTH_AUTHORIZE_URL, callback_uri)) + def get_authenticated_user(self, callback): """Gets the OAuth authorized user and access token on callback. @@ -228,24 +240,29 @@ class OAuthMixin(object): attributes like 'name' includes the 'access_key' attribute, which contains the OAuth access you can use to make authorized requests to this service on behalf of the user. + """ request_key = self.get_argument("oauth_token") + oauth_verifier = self.get_argument("oauth_verifier", None) request_cookie = self.get_cookie("_oauth_request_token") if not request_cookie: logging.warning("Missing OAuth request token cookie") callback(None) return - cookie_key, cookie_secret = request_cookie.split("|") + self.clear_cookie("_oauth_request_token") + cookie_key, cookie_secret = [base64.b64decode(i) for i in request_cookie.split("|")] if cookie_key != request_key: logging.warning("Request token does not match cookie") callback(None) return token = dict(key=cookie_key, secret=cookie_secret) + if oauth_verifier: + token["verifier"] = oauth_verifier http = httpclient.AsyncHTTPClient() http.fetch(self._oauth_access_token_url(token), self.async_callback( self._on_access_token, callback)) - def _oauth_request_token_url(self): + def _oauth_request_token_url(self, callback_uri= None, extra_params=None): consumer_token = self._oauth_consumer_token() url = self._OAUTH_REQUEST_TOKEN_URL args = dict( @@ -253,9 +270,17 @@ class OAuthMixin(object): oauth_signature_method="HMAC-SHA1", oauth_timestamp=str(int(time.time())), oauth_nonce=binascii.b2a_hex(uuid.uuid4().bytes), - oauth_version="1.0", + oauth_version=getattr(self, "_OAUTH_VERSION", "1.0a"), ) - signature = _oauth_signature(consumer_token, "GET", url, args) + if getattr(self, "_OAUTH_VERSION", "1.0a") == "1.0a": + if callback_uri: + args["oauth_callback"] = urlparse.urljoin( + self.request.full_url(), callback_uri) + if extra_params: args.update(extra_params) + signature = _oauth10a_signature(consumer_token, "GET", url, args) + else: + signature = _oauth_signature(consumer_token, "GET", url, args) + args["oauth_signature"] = signature return url + "?" + urllib.urlencode(args) @@ -263,7 +288,8 @@ class OAuthMixin(object): if response.error: raise Exception("Could not get request token") request_token = _oauth_parse_response(response.body) - data = "|".join([request_token["key"], request_token["secret"]]) + data = "|".join([base64.b64encode(request_token["key"]), + base64.b64encode(request_token["secret"])]) self.set_cookie("_oauth_request_token", data) args = dict(oauth_token=request_token["key"]) if callback_uri: @@ -280,10 +306,18 @@ class OAuthMixin(object): oauth_signature_method="HMAC-SHA1", oauth_timestamp=str(int(time.time())), oauth_nonce=binascii.b2a_hex(uuid.uuid4().bytes), - oauth_version="1.0", + oauth_version=getattr(self, "_OAUTH_VERSION", "1.0a"), ) - signature = _oauth_signature(consumer_token, "GET", url, args, - request_token) + if "verifier" in request_token: + args["oauth_verifier"]=request_token["verifier"] + + if getattr(self, "_OAUTH_VERSION", "1.0a") == "1.0a": + signature = _oauth10a_signature(consumer_token, "GET", url, args, + request_token) + else: + signature = _oauth_signature(consumer_token, "GET", url, args, + request_token) + args["oauth_signature"] = signature return url + "?" + urllib.urlencode(args) @@ -292,6 +326,7 @@ class OAuthMixin(object): logging.warning("Could not fetch access token") callback(None) return + access_token = _oauth_parse_response(response.body) user = self._oauth_get_user(access_token, self.async_callback( self._on_oauth_get_user, access_token, callback)) @@ -320,16 +355,53 @@ class OAuthMixin(object): oauth_signature_method="HMAC-SHA1", oauth_timestamp=str(int(time.time())), oauth_nonce=binascii.b2a_hex(uuid.uuid4().bytes), - oauth_version="1.0", + oauth_version=getattr(self, "_OAUTH_VERSION", "1.0a"), ) args = {} args.update(base_args) args.update(parameters) - signature = _oauth_signature(consumer_token, method, url, args, - access_token) + if getattr(self, "_OAUTH_VERSION", "1.0a") == "1.0a": + signature = _oauth10a_signature(consumer_token, method, url, args, + access_token) + else: + signature = _oauth_signature(consumer_token, method, url, args, + access_token) base_args["oauth_signature"] = signature return base_args +class OAuth2Mixin(object): + """Abstract implementation of OAuth v 2.""" + + def authorize_redirect(self, redirect_uri=None, client_id=None, + client_secret=None, extra_params=None ): + """Redirects the user to obtain OAuth authorization for this service. + + Some providers require that you register a Callback + URL with your application. You should call this method to log the + user in, and then call get_authenticated_user() in the handler + you registered as your Callback URL to complete the authorization + process. + """ + args = { + "redirect_uri": redirect_uri, + "client_id": client_id + } + if extra_params: args.update(extra_params) + self.redirect(self._OAUTH_AUTHORIZE_URL + + urllib.urlencode(args)) + + def _oauth_request_token_url(self, redirect_uri= None, client_id = None, + client_secret=None, code=None, + extra_params=None): + url = self._OAUTH_ACCESS_TOKEN_URL + args = dict( + redirect_uri=redirect_uri, + code=code, + client_id=client_id, + client_secret=client_secret, + ) + if extra_params: args.update(extra_params) + return url + urllib.urlencode(args) class TwitterMixin(OAuthMixin): """Twitter OAuth authentication. @@ -369,7 +441,8 @@ class TwitterMixin(OAuthMixin): _OAUTH_ACCESS_TOKEN_URL = "http://api.twitter.com/oauth/access_token" _OAUTH_AUTHORIZE_URL = "http://api.twitter.com/oauth/authorize" _OAUTH_AUTHENTICATE_URL = "http://api.twitter.com/oauth/authenticate" - _OAUTH_NO_CALLBACKS = True + _OAUTH_NO_CALLBACKS = False + def authenticate_redirect(self): """Just like authorize_redirect(), but auto-redirects if authorized. @@ -499,10 +572,13 @@ class FriendFeedMixin(OAuthMixin): it is required to make requests on behalf of the user later with friendfeed_request(). """ + _OAUTH_VERSION = "1.0" _OAUTH_REQUEST_TOKEN_URL = "https://friendfeed.com/account/oauth/request_token" _OAUTH_ACCESS_TOKEN_URL = "https://friendfeed.com/account/oauth/access_token" _OAUTH_AUTHORIZE_URL = "https://friendfeed.com/account/oauth/authorize" _OAUTH_NO_CALLBACKS = True + _OAUTH_VERSION = "1.0" + def friendfeed_request(self, path, callback, access_token=None, post_args=None, **args): @@ -660,7 +736,6 @@ class GoogleMixin(OpenIdMixin, OAuthMixin): def _oauth_get_user(self, access_token, callback): OpenIdMixin.get_authenticated_user(self, callback) - class FacebookMixin(object): """Facebook Connect authentication. @@ -841,6 +916,139 @@ class FacebookMixin(object): if isinstance(body, unicode): body = body.encode("utf-8") return hashlib.md5(body).hexdigest() +class FacebookGraphMixin(OAuth2Mixin): + _OAUTH_ACCESS_TOKEN_URL = "https://graph.facebook.com/oauth/access_token?" + _OAUTH_AUTHORIZE_URL = "https://graph.facebook.com/oauth/authorize?" + _OAUTH_NO_CALLBACKS = False + + def get_authenticated_user(self, redirect_uri, client_id, client_secret, + code, callback): + """ Handles the login for the Facebook user, returning a user object. + + Example usage: + class FacebookGraphLoginHandler(LoginHandler, tornado.auth.FacebookGraphMixin): + @tornado.web.asynchronous + def get(self): + if self.get_argument("code", False): + self.get_authenticated_user( + redirect_uri='/auth/facebookgraph/', + client_id=self.settings["facebook_api_key"], + client_secret=self.settings["facebook_secret"], + code=self.get_argument("code"), + callback=self.async_callback( + self._on_login)) + return + self.authorize_redirect(redirect_uri='/auth/facebookgraph/', + client_id=self.settings["facebook_api_key"], + extra_params={"scope": "read_stream,offline_access"}) + + def _on_login(self, user): + logging.error(user) + self.finish() + + """ + http = httpclient.AsyncHTTPClient() + args = { + "redirect_uri": redirect_uri, + "code": code, + "client_id": client_id, + "client_secret": client_secret, + } + + http.fetch(self._oauth_request_token_url(**args), + self.async_callback(self._on_access_token, redirect_uri, client_id, + client_secret, callback)) + + def _on_access_token(self, redirect_uri, client_id, client_secret, + callback, response): + session = { + "access_token": cgi.parse_qs(response.body)["access_token"][-1], + "expires": cgi.parse_qs(response.body)["expires"] + } + + self.facebook_request( + path="/me", + callback=self.async_callback( + self._on_get_user_info, callback, session), + access_token=session["access_token"], + fields="picture" + ) + + + def _on_get_user_info(self, callback, session, user): + if user is None: + callback(None) + return + callback({ + "name": user["name"], + "first_name": user["first_name"], + "last_name": user["last_name"], + "id": user["id"], + "locale": user["locale"], + "picture": user.get("picture"), + "link": user["link"], + "username": user.get("username"), + "access_token": session["access_token"], + "session_expires": session.get("expires"), + }) + + def facebook_request(self, path, callback, access_token=None, + post_args=None, **args): + """Fetches the given relative API path, e.g., "/btaylor/picture" + + If the request is a POST, post_args should be provided. Query + string arguments should be given as keyword arguments. + + An introduction to the Facebook Graph API can be found at + http://developers.facebook.com/docs/api + + Many methods require an OAuth access token which you can obtain + through authorize_redirect() and get_authenticated_user(). The + user returned through that process includes an 'access_token' + attribute that can be used to make authenticated requests via + this method. Example usage: + + class MainHandler(tornado.web.RequestHandler, + tornado.auth.FacebookGraphMixin): + @tornado.web.authenticated + @tornado.web.asynchronous + def get(self): + self.facebook_request( + "/me/feed", + post_args={"message": "I am posting from my Tornado application!"}, + access_token=self.current_user["access_token"], + callback=self.async_callback(self._on_post)) + + def _on_post(self, new_entry): + if not new_entry: + # Call failed; perhaps missing permission? + self.authorize_redirect() + return + self.finish("Posted a message!") + + """ + url = "https://graph.facebook.com" + path + all_args = {} + if access_token: + all_args["access_token"] = access_token + all_args.update(args) + all_args.update(post_args or {}) + if all_args: url += "?" + urllib.urlencode(all_args) + callback = self.async_callback(self._on_facebook_request, callback) + http = httpclient.AsyncHTTPClient() + if post_args is not None: + http.fetch(url, method="POST", body=urllib.urlencode(post_args), + callback=callback) + else: + http.fetch(url, callback=callback) + + def _on_facebook_request(self, callback, response): + if response.error: + logging.warning("Error response %s fetching %s", response.error, + response.request.url) + callback(None) + return + callback(escape.json_decode(response.body)) def _oauth_signature(consumer_token, method, url, parameters={}, token=None): """Calculates the HMAC-SHA1 OAuth signature for the given request. @@ -865,6 +1073,28 @@ def _oauth_signature(consumer_token, method, url, parameters={}, token=None): hash = hmac.new(key, base_string, hashlib.sha1) return binascii.b2a_base64(hash.digest())[:-1] +def _oauth10a_signature(consumer_token, method, url, parameters={}, token=None): + """Calculates the HMAC-SHA1 OAuth 1.0a signature for the given request. + + See http://oauth.net/core/1.0a/#signing_process + """ + parts = urlparse.urlparse(url) + scheme, netloc, path = parts[:3] + normalized_url = scheme.lower() + "://" + netloc.lower() + path + + base_elems = [] + base_elems.append(method.upper()) + base_elems.append(normalized_url) + base_elems.append("&".join("%s=%s" % (k, _oauth_escape(str(v))) + for k, v in sorted(parameters.items()))) + + base_string = "&".join(_oauth_escape(e) for e in base_elems) + key_elems = [urllib.quote(consumer_token["secret"], safe='~')] + key_elems.append(urllib.quote(token["secret"], safe='~') if token else "") + key = "&".join(key_elems) + + hash = hmac.new(key, base_string, hashlib.sha1) + return binascii.b2a_base64(hash.digest())[:-1] def _oauth_escape(val): if isinstance(val, unicode): @@ -880,3 +1110,5 @@ def _oauth_parse_response(body): special = ("oauth_token", "oauth_token_secret") token.update((k, p[k][0]) for k in p if k not in special) return token + + -- 2.47.2