]> git.ipfire.org Git - thirdparty/tornado.git/commitdiff
auth.py updates for OAuth 1.0a and 2.0.
authorJoe Bowman <bowman.joseph@gmail.com>
Tue, 31 Aug 2010 15:38:06 +0000 (11:38 -0400)
committerBen Darnell <ben@bendarnell.com>
Sat, 4 Sep 2010 18:56:28 +0000 (11:56 -0700)
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

index 2695cc1f1e77bd18faf988c0b20d9c44eb34356b..d77e5c479cd0a6e277afc9cdcc3a24d7e5c9a72e 100644 (file)
@@ -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
+
+