From 617d41f216ae195d630897d89cdf2fc9fc6fe91b Mon Sep 17 00:00:00 2001 From: Ben Darnell Date: Sun, 28 Jun 2015 16:41:46 -0400 Subject: [PATCH] Introduce OAuth2Mixin.oauth2_request. This method can serve as a general replacement for methods like FacebookGraphMixin.facebook_request. Update docs for GoogleOAuth2Mixin.get_authenticated_user to indicate that only the tokens are returned, not other user information. Add some basic tests for GoogleOAuth2Mixin. This commit incorporates code from FanFani4 in #1212 and kippandrew in #1454. --- tornado/auth.py | 120 +++++++++++++++++++++++++------------- tornado/test/auth_test.py | 70 +++++++++++++++++++++- 2 files changed, 150 insertions(+), 40 deletions(-) diff --git a/tornado/auth.py b/tornado/auth.py index 800b10afe..ebf0ecdd7 100644 --- a/tornado/auth.py +++ b/tornado/auth.py @@ -621,6 +621,72 @@ class OAuth2Mixin(object): args.update(extra_params) return url_concat(url, args) + @_auth_return_future + def oauth2_request(self, url, callback, access_token=None, + post_args=None, **args): + """Fetches the given URL auth an OAuth2 access token. + + If the request is a POST, ``post_args`` should be provided. Query + string arguments should be given as keyword arguments. + + Example usage: + + ..testcode:: + + class MainHandler(tornado.web.RequestHandler, + tornado.auth.FacebookGraphMixin): + @tornado.web.authenticated + @tornado.gen.coroutine + def get(self): + new_entry = yield self.oauth2_request( + "https://graph.facebook.com/me/feed", + post_args={"message": "I am posting from my Tornado application!"}, + access_token=self.current_user["access_token"]) + + if not new_entry: + # Call failed; perhaps missing permission? + yield self.authorize_redirect() + return + self.finish("Posted a message!") + + .. testoutput:: + :hide: + + .. versionadded:: 4.3 + """ + all_args = {} + if access_token: + all_args["access_token"] = access_token + all_args.update(args) + + if all_args: + url += "?" + urllib_parse.urlencode(all_args) + callback = functools.partial(self._on_facebook_request, callback) + http = self.get_auth_http_client() + if post_args is not None: + http.fetch(url, method="POST", body=urllib_parse.urlencode(post_args), + callback=callback) + else: + http.fetch(url, callback=callback) + + def _on_oauth2_request(self, future, response): + if response.error: + future.set_exception(AuthError("Error response %s fetching %s" % + (response.error, response.request.url))) + return + + future.set_result(escape.json_decode(response.body)) + + def get_auth_http_client(self): + """Returns the `.AsyncHTTPClient` instance to be used for auth requests. + + May be overridden by subclasses to use an HTTP client other than + the default. + + .. versionadded:: 4.3 + """ + return httpclient.AsyncHTTPClient() + class TwitterMixin(OAuthMixin): """Twitter OAuth authentication. @@ -796,7 +862,15 @@ class GoogleOAuth2Mixin(OAuth2Mixin): @_auth_return_future def get_authenticated_user(self, redirect_uri, code, callback): - """Handles the login for the Google user, returning a user object. + """Handles the login for the Google user, returning an access token. + + The result is a dictionary containing an ``access_token`` field + ([among others](https://developers.google.com/identity/protocols/OAuth2WebServer#handlingtheresponse)). + Unlike other ``get_authenticated_user`` methods in this package, + this method does not return any additional information about the user. + The returned access token can be used with `OAuth2Mixin.oauth2_request` + to request additional information (perhaps from + ``https://www.googleapis.com/oauth2/v2/userinfo``) Example usage: @@ -845,14 +919,6 @@ class GoogleOAuth2Mixin(OAuth2Mixin): args = escape.json_decode(response.body) future.set_result(args) - def get_auth_http_client(self): - """Returns the `.AsyncHTTPClient` instance to be used for auth requests. - - May be overridden by subclasses to use an HTTP client other than - the default. - """ - return httpclient.AsyncHTTPClient() - class FacebookGraphMixin(OAuth2Mixin): """Facebook authentication using the new Graph API and OAuth2.""" @@ -983,40 +1049,16 @@ class FacebookGraphMixin(OAuth2Mixin): The given path is relative to ``self._FACEBOOK_BASE_URL``, by default "https://graph.facebook.com". + This method is a wrapper around `OAuth2Mixin.oauth2_request`; + the only difference is that this method takes a relative path, + while ``oauth2_request`` takes a complete url. + .. versionchanged:: 3.1 Added the ability to override ``self._FACEBOOK_BASE_URL``. """ url = self._FACEBOOK_BASE_URL + path - all_args = {} - if access_token: - all_args["access_token"] = access_token - all_args.update(args) - - if all_args: - url += "?" + urllib_parse.urlencode(all_args) - callback = functools.partial(self._on_facebook_request, callback) - http = self.get_auth_http_client() - if post_args is not None: - http.fetch(url, method="POST", body=urllib_parse.urlencode(post_args), - callback=callback) - else: - http.fetch(url, callback=callback) - - def _on_facebook_request(self, future, response): - if response.error: - future.set_exception(AuthError("Error response %s fetching %s" % - (response.error, response.request.url))) - return - - future.set_result(escape.json_decode(response.body)) - - def get_auth_http_client(self): - """Returns the `.AsyncHTTPClient` instance to be used for auth requests. - - May be overridden by subclasses to use an HTTP client other than - the default. - """ - return httpclient.AsyncHTTPClient() + return self.oauth2_request(url, callback, access_token, + post_args, **args) def _oauth_signature(consumer_token, method, url, parameters={}, token=None): diff --git a/tornado/test/auth_test.py b/tornado/test/auth_test.py index 541ecf16f..fee797799 100644 --- a/tornado/test/auth_test.py +++ b/tornado/test/auth_test.py @@ -5,10 +5,11 @@ from __future__ import absolute_import, division, print_function, with_statement -from tornado.auth import OpenIdMixin, OAuthMixin, OAuth2Mixin, TwitterMixin, AuthError +from tornado.auth import OpenIdMixin, OAuthMixin, OAuth2Mixin, TwitterMixin, AuthError, GoogleOAuth2Mixin from tornado.concurrent import Future from tornado.escape import json_decode from tornado import gen +from tornado.httputil import url_concat from tornado.log import gen_log from tornado.testing import AsyncHTTPTestCase, ExpectLog from tornado.util import u @@ -413,3 +414,70 @@ class AuthTest(AsyncHTTPTestCase): response = self.fetch('/twitter/client/show_user_future?name=error') self.assertEqual(response.code, 500) self.assertIn(b'Error response HTTP 500', response.body) + + +class GoogleLoginHandler(RequestHandler, GoogleOAuth2Mixin): + def initialize(self, test): + self._OAUTH_REDIRECT_URI = test.get_url('/client/login') + self._OAUTH_AUTHORIZE_URL = test.get_url('/google/oauth2/authorize') + self._OAUTH_ACCESS_TOKEN_URL = test.get_url('/google/oauth2/token') + + @gen.coroutine + def get(self): + code = self.get_argument('code', None) + if code is not None: + # retrieve authenticate google user + user = yield self.get_authenticated_user(self._OAUTH_REDIRECT_URI, + code) + # return the user as json + self.write(user) + else: + yield self.authorize_redirect( + redirect_uri=self._OAUTH_REDIRECT_URI, + client_id=self.settings['google_oauth']['key'], + client_secret=self.settings['google_oauth']['secret'], + scope=['profile', 'email'], + response_type='code', + extra_params={'prompt': 'select_account'}) + + +class GoogleOAuth2AuthorizeHandler(RequestHandler): + def get(self): + # issue a fake auth code and redirect to redirect_uri + code = 'fake-authorization-code' + self.redirect(url_concat(self.get_argument('redirect_uri'), + dict(code=code))) + + +class GoogleOAuth2TokenHandler(RequestHandler): + def post(self): + assert self.get_argument('code') == 'fake-authorization-code' + # issue a fake token + self.finish({ + 'access_token': 'fake-access-token', + 'expires_in': 'never-expires' + }) + + +class GoogleOAuth2Test(AsyncHTTPTestCase): + def get_app(self): + return Application( + [ + # test endpoints + ('/client/login', GoogleLoginHandler, dict(test=self)), + + # simulated google authorization server endpoints + ('/google/oauth2/authorize', GoogleOAuth2AuthorizeHandler), + ('/google/oauth2/token', GoogleOAuth2TokenHandler), + ], + google_oauth={ + "key": 'fake_google_client_id', + "secret": 'fake_google_client_secret' + }) + + def test_google_login(self): + response = self.fetch('/client/login') + self.assertDictEqual({ + u('access_token'): u('fake-access-token'), + u('expires_in'): u('never-expires'), + }, json_decode(response.body)) -- 2.47.2