From 285a00882abba22fee14b904db1b48a7c21371af Mon Sep 17 00:00:00 2001 From: "ajwong@chromium.org" Date: Sat, 21 May 2011 00:21:15 +0000 Subject: Update the appengine code to use OAuth2 and break the gdata dependency. BUG=none TEST=Connect still works. Review URL: http://codereview.chromium.org/7033042 git-svn-id: svn://svn.chromium.org/chrome/trunk/src@86183 0039d316-1c4b-4281-b951-d872f2087c98 --- remoting/client/appengine/api.py | 33 +- remoting/client/appengine/app.yaml | 2 +- remoting/client/appengine/auth.py | 337 ++++++++------------- .../client/appengine/chromoting_oauth_setup.html | 27 -- remoting/client/appengine/chromoting_session.html | 2 +- remoting/client/appengine/hostlist.html | 18 +- remoting/client/appengine/main.py | 7 +- remoting/client/appengine/static_files/client.js | 45 ++- remoting/client/extension/manifest.json | 15 - 9 files changed, 206 insertions(+), 280 deletions(-) delete mode 100644 remoting/client/appengine/chromoting_oauth_setup.html delete mode 100644 remoting/client/extension/manifest.json (limited to 'remoting/client') diff --git a/remoting/client/appengine/api.py b/remoting/client/appengine/api.py index ec16fc6..e1e2ddb 100644 --- a/remoting/client/appengine/api.py +++ b/remoting/client/appengine/api.py @@ -10,8 +10,7 @@ import logging from django.utils import simplejson as json -import gdata.client - +from google.appengine.api import urlfetch from google.appengine.ext import webapp from google.appengine.ext.webapp import util from google.appengine.ext.webapp.util import login_required @@ -30,26 +29,26 @@ class GetXmppTokenHandler(webapp.RequestHandler): self.response.out.write('User has not authenticated') self.set_status(400) + class GetHostListHandler(webapp.RequestHandler): """Proxies the host-list handlers on the Chromoting directory.""" @login_required def get(self): - try: - client = gdata.client.GDClient() - host_list_json = client.Request( - method='GET', - uri="https://www.googleapis.com/chromoting/v1/@me/hosts", - converter=None, - desired_class=None, - auth_token=auth.GetChromotingToken()) + if not auth.HasOAuth2Tokens(): + self.response.set_status(403) self.response.headers['Content-Type'] = 'application/json' - self.response.out.write(host_list_json.read()) - except auth.NotAuthenticated: - self.response.out.write('User has not authenticated') - self.response.set_status(400) - except (gdata.client.Unauthorized, gdata.client.RequestError), inst: - self.response.out.write(inst.reason) - self.response.set_status(inst.status) + self.response.out.write( + '{"error": { "code": -1, "message": "No OAuth2 token" }}') + return + result = urlfetch.fetch( + url = 'https://www.googleapis.com/chromoting/v1/@me/hosts', + method = urlfetch.GET, + headers = {'Authorization': 'OAuth ' + auth.GetAccessToken()}) + self.response.set_status(result.status_code) + for i in result.headers: + self.response.headers[i] = result.headers[i] + self.response.out.write(result.content) + def main(): application = webapp.WSGIApplication( diff --git a/remoting/client/appengine/app.yaml b/remoting/client/appengine/app.yaml index ff9db53..a5d7436 100644 --- a/remoting/client/appengine/app.yaml +++ b/remoting/client/appengine/app.yaml @@ -1,5 +1,5 @@ application: google.com:chromoting -version: 1 +version: 2 runtime: python api_version: 1 diff --git a/remoting/client/appengine/auth.py b/remoting/client/appengine/auth.py index 40433cc..54be383 100644 --- a/remoting/client/appengine/auth.py +++ b/remoting/client/appengine/auth.py @@ -12,59 +12,85 @@ initiating authentication flows, and for managing credential storage per user. """ import os +import re +import time +import urllib +from urlparse import urlparse -import gdata.gauth -import gdata.client from google.appengine.ext import db +from google.appengine.api import urlfetch from google.appengine.api import users from google.appengine.ext import webapp from google.appengine.ext.webapp import template from google.appengine.ext.webapp import util from google.appengine.ext.webapp.util import login_required +from django.utils import simplejson as json + SCOPES = ['https://www.googleapis.com/auth/chromoting', 'https://www.googleapis.com/auth/googletalk' ] +# Development OAuth2 ID and keys. +CLIENT_ID = ('440925447803-d9u05st5jjm3gbe865l0jeaujqfrufrn.' + 'apps.googleusercontent.com') +CLIENT_SECRET = 'Nl4vSQEgDpPMP-1rDEsgs3V7' + class NotAuthenticated(Exception): """API requiring authentication is called with credentials.""" pass -class OAuthInvalidSetup(Exception): - """OAuth configuration on app is not complete.""" - pass +class XmppToken(db.Model): + auth_token = db.StringProperty() -class OAuthConfig(db.Model): - """Stores the configuration data for OAuth. +class OAuth2Tokens(db.Model): + """Stores the Refresh and Access token information for OAuth2.""" + refresh_token = db.StringProperty() + access_token = db.StringProperty() + access_token_expiration = db.IntegerProperty() - Currently used to store the consumer key and secret so that it does not need - to be checked into the source tree. - """ - consumer_key = db.StringProperty() - consumer_secret = db.StringProperty() - httpxmppproxy = db.StringProperty() +def HasOAuth2Tokens(throws=True): + oauth2_tokens = OAuth2Tokens.get_or_insert(GetUserId()) + if oauth2_tokens.refresh_token: + return True; + return False; -def GetChromotingToken(throws=True): - """Retrieves the Chromoting OAuth token for the user. - Args: - throws: bool (optional) Default is True. Throws if no token. +def GetAccessToken(throws=True): + oauth2_tokens = OAuth2Tokens.get_or_insert(GetUserId()) - Returns: - An gdata.gauth.OAuthHmacToken for the current user. - """ - user = users.get_current_user() - access_token = None - if user: - access_token = LoadToken('chromoting_token') - if throws and not access_token: + if not oauth2_tokens.refresh_token: raise NotAuthenticated() - return access_token + + if time.time() > oauth2_tokens.access_token_expiration: + form_fields = { + 'client_id' : CLIENT_ID, + 'client_secret' : CLIENT_SECRET, + 'refresh_token' : oauth2_tokens.refresh_token, + 'grant_type' : 'refresh_token' + } + form_data = urllib.urlencode(form_fields) + result = urlfetch.fetch( + url = 'https://accounts.google.com/o/oauth2/token', + payload = form_data, + method = urlfetch.POST, + headers = {'Content-Type': 'application/x-www-form-urlencoded'}) + if result.status_code != 200: + raise 'something went wrong %d, %s
' % ( + result.status_code, result.content) + oauth_json = json.loads(result.content) + oauth2_tokens.access_token = oauth_json['access_token'] + # Give us 30 second buffer to hackily account for RTT on network request. + oauth2_tokens.access_token_expiration = ( + int(oauth_json['expires_in'] + time.time() - 30)) + oauth2_tokens.put() + + return oauth2_tokens.access_token def GetXmppToken(throws=True): @@ -74,26 +100,22 @@ def GetXmppToken(throws=True): throws: bool (optional) Default is True. Throws if no token. Returns: - An gdata.gauth.ClientLoginToken for the current user. + The auth token for the current user. """ - user = users.get_current_user() - access_token = None - if user: - access_token = LoadToken('xmpp_token') - if throws and not access_token: + xmpp_token = XmppToken.get_or_insert(GetUserId()) + if throws and not xmpp_token.auth_token: raise NotAuthenticated() - return access_token + return xmpp_token.auth_token -def ClearChromotingToken(): - """Clears all Chromoting OAuth token state from the datastore.""" - DeleteToken('request_token') - DeleteToken('chromoting_token') +def ClearXmppToken(): + """Clears all Chromoting ClientLogin token state from the datastore.""" + db.delete(db.Key.from_path('XmppToken', GetUserId())) -def ClearXmppToken(): +def ClearOAuth2Token(): """Clears all Chromoting ClientLogin token state from the datastore.""" - DeleteToken('xmpp_token') + db.delete(db.Key.from_path('OAuth2Tokens', GetUserId())) def GetUserId(): @@ -115,116 +137,6 @@ def GetUserId(): return user.user_id() -def LoadToken(name): - """Leads a gdata auth token for the current user. - - Tokens are scoped to each user, and retrieved by a name. - - Args: - name: A string with the name of the token for the current user. - - Returns: - The token associated with the name for the user. - """ - user_id = GetUserId(); - return gdata.gauth.AeLoad(user_id + name) - - -def SaveToken(name, token): - """Saves a gdata auth token for the current user. - - Tokens are scoped to each user, and stored by a name. - - Args: - name: A string with the name of the token. - """ - user_id = GetUserId(); - gdata.gauth.AeSave(token, user_id + name) - - -def DeleteToken(name): - """Deletes a stored gdata auth token for the current user. - - Tokens are scoped to each user, and stored by a name. - - Args: - name: A string with the name of the token. - """ - user_id = GetUserId(); - gdata.gauth.AeDelete(user_id + name) - - -def OAuthConfigKey(): - """Generates a standard key path for this app's OAuth configuration.""" - return db.Key.from_path('OAuthConfig', 'oauth_config') - - -def GetOAuthConfig(throws=True): - """Retrieves the OAuthConfig for this app. - - Returns: - The OAuthConfig object for this app. - - Raises: - OAuthInvalidSetup if no OAuthConfig exists. - """ - config = db.get(OAuthConfigKey()) - if throws and not config: - raise OAuthInvalidSetup() - return config - - -class ChromotingAuthHandler(webapp.RequestHandler): - """Initiates getting the OAuth access token for the user. - - This webapp uses 3-legged OAuth. This handlers performs the first step - of getting the OAuth request token, and then forwarding on to the - Google Accounts authorization endpoint for the second step. The final - step is completed by the ChromotingAuthReturnHandler below. - - FYI, all three steps are collectively known as the "OAuth dance." - """ - @login_required - def get(self): - ClearChromotingToken() - client = gdata.client.GDClient() - - oauth_callback_url = ('http://%s/auth/chromoting_auth_return' % - self.request.host) - request_token = client.GetOAuthToken( - SCOPES, oauth_callback_url, GetOAuthConfig().consumer_key, - consumer_secret=GetOAuthConfig().consumer_secret) - - SaveToken('request_token', request_token) - domain = None # Not on an Google Apps domain. - auth_uri = request_token.generate_authorization_url() - self.redirect(str(auth_uri)) - - -class ChromotingAuthReturnHandler(webapp.RequestHandler): - """Finishes the authorization started in ChromotingAuthHandler.i - - After the user authorizes the OAuth request token at the OAuth request - URL they were redirected to in ChromotingAuthHandler, OAuth will send - them back here with an auth token in the URL. - - This handler retrievies the access token, and stores it completing the - OAuth dance. - """ - @login_required - def get(self): - saved_request_token = LoadToken('request_token') - DeleteToken('request_token') - request_token = gdata.gauth.AuthorizeRequestToken( - saved_request_token, self.request.uri) - - # Upgrade the token and save in the user's datastore - client = gdata.client.GDClient() - access_token = client.GetAccessToken(request_token) - SaveToken('chromoting_token', access_token) - self.redirect("/") - - class XmppAuthHandler(webapp.RequestHandler): """Prompts Google Accounts credentials and retrieves a ClientLogin token. @@ -244,87 +156,102 @@ class XmppAuthHandler(webapp.RequestHandler): self.response.out.write(template.render(path, {})) def post(self): - client = gdata.client.GDClient() email = self.request.get('username') password = self.request.get('password') - try: - client.ClientLogin( - email, password, 'chromoclient', 'chromiumsync') - SaveToken('xmpp_token', client.auth_token) - except gdata.client.CaptchaChallenge: - self.response.out.write('You need to solve a Captcha. ' - 'Unforutnately, we still have to implement that.') + form_fields = { + 'accountType' : 'HOSTED_OR_GOOGLE', + 'Email' : self.request.get('username'), + 'Passwd' : self.request.get('password'), + 'service' : 'chromiumsync', + 'source' : 'chromoplex' + } + form_data = urllib.urlencode(form_fields) + result = urlfetch.fetch( + url = 'https://www.google.com/accounts/ClientLogin', + payload = form_data, + method = urlfetch.POST, + headers = {'Content-Type': 'application/x-www-form-urlencoded'}) + if result.status_code != 200: + self.response.out.write(result.content) + for i in result.headers: + self.response.headers[i] = result.headers[i] + self.response.set_status(result.status_code) + return + + xmpp_token = XmppToken(key_name = GetUserId()) + xmpp_token.auth_token = re.search("Auth=(.*)", result.content).group(1) + xmpp_token.put() self.redirect('/') -class ClearChromotingTokenHandler(webapp.RequestHandler): - """Endpoint for dropping the user's Chromoting token.""" +class ClearXmppTokenHandler(webapp.RequestHandler): + """Endpoint for dropping the user's Xmpp token.""" @login_required def get(self): - ClearChromotingToken() + ClearXmppToken() self.redirect('/') -class ClearXmppTokenHandler(webapp.RequestHandler): - """Endpoint for dropping the user's Xmpp token.""" +class ClearOAuth2TokenHandler(webapp.RequestHandler): + """Endpoint for dropping the user's OAuth2 token.""" @login_required def get(self): - ClearXmppToken() + ClearOAuth2Token() self.redirect('/') -class SetupOAuthHandler(webapp.RequestHandler): - """Administrative page for specifying the OAuth consumer key/secret.""" +class OAuth2ReturnHandler(webapp.RequestHandler): + """Handles the redirect in the OAuth dance.""" @login_required def get(self): - path = os.path.join(os.path.dirname(__file__), - 'chromoting_oauth_setup.html') - self.response.out.write(template.render(path, {})) - - def post(self): - old_consumer_secret = self.request.get('old_consumer_secret') - - query = OAuthConfig.all() - - # If there is an existing key, only allow updating if you know the old - # key. This is a simple safeguard against random users hitting this page. - config = GetOAuthConfig(throws=False) - if config: - if config.consumer_secret != old_consumer_secret: - self.response.set_status(400) - self.response.out.write('Incorrect old consumer secret') - return + code = self.request.get('code') + state = self.request.get('state') + parsed_url = urlparse(self.request.url) + server = parsed_url.scheme + '://' + parsed_url.netloc + form_fields = { + 'client_id' : CLIENT_ID, + 'client_secret' : CLIENT_SECRET, + 'redirect_uri' : server + '/auth/oauth2_return', + 'code' : code, + 'grant_type' : 'authorization_code' + } + form_data = urllib.urlencode(form_fields) + result = urlfetch.fetch( + url = 'https://accounts.google.com/o/oauth2/token', + payload = form_data, + method = urlfetch.POST, + headers = {'Content-Type': 'application/x-www-form-urlencoded'}) + + if result.status_code != 200: + self.response.out.write('something went wrong %d, %s
' % + (result.status_code, result.content)) + self.response.out.write( + 'We tried posting %s code(%s) [%s]' % (form_data, code, form_fields)) + self.response.set_status(400) + return + + oauth_json = json.loads(result.content) + oauth2_tokens = OAuth2Tokens(key_name = GetUserId()) + oauth2_tokens.refresh_token = oauth_json['refresh_token'] + oauth2_tokens.access_token = oauth_json['access_token'] + # Give us 30 second buffer to hackily account for RTT on network request. + oauth2_tokens.access_token_expiration = ( + int(oauth_json['expires_in'] + time.time() - 30)) + oauth2_tokens.put() + + if state: + self.redirect(state) else: - config = OAuthConfig(key_name = OAuthConfigKey().id_or_name()) - - # TODO(ajwong): THIS IS A TOTAL HACK! FIX WITH OWN PAGE. - # Currently, this form has one submit button, and 3 pieces of input: - # consumer_key, oauth_secret, and the httpxmppproxy address. The - # HTTP/XMPP proxy should really have its own configuration page. - httpxmppproxy = self.request.get('httpxmppproxy') - if httpxmppproxy: - config.httpxmppproxy = httpxmppproxy - - config.consumer_key = self.request.get('consumer_key') - config.consumer_secret = self.request.get('new_consumer_secret') - config.put() - self.redirect('/') + self.redirect('/') -def GetHttpXmppProxy(): - config = GetOAuthConfig(throws=True) - if not config.httpxmppproxy: - raise OAuthInvalidSetup() - return config.httpxmppproxy def main(): application = webapp.WSGIApplication( [ - ('/auth/chromoting_auth', ChromotingAuthHandler), ('/auth/xmpp_auth', XmppAuthHandler), - ('/auth/chromoting_auth_return', ChromotingAuthReturnHandler), ('/auth/clear_xmpp_token', ClearXmppTokenHandler), - ('/auth/clear_chromoting_token', ClearChromotingTokenHandler), - ('/auth/setup_oauth', SetupOAuthHandler) + ('/auth/clear_oauth2_token', ClearOAuth2TokenHandler), + ('/auth/oauth2_return', OAuth2ReturnHandler) ], debug=True) util.run_wsgi_app(application) diff --git a/remoting/client/appengine/chromoting_oauth_setup.html b/remoting/client/appengine/chromoting_oauth_setup.html deleted file mode 100644 index 1b1537c..0000000 --- a/remoting/client/appengine/chromoting_oauth_setup.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - Chromoting OAuth setup page. - - -

This is an administration page to setup the consumer key and secret - to be used with chromoting. Please don't play with this unless you are - on the Chromoting dev team. -

- Move the HttpXmppProxy setup out of this page, and make the semantics less - hacky! -

- Consumer Key: - New Consumer Secret: - Old Consumer Secret: - HttpXmppProxy Address: - -
- - diff --git a/remoting/client/appengine/chromoting_session.html b/remoting/client/appengine/chromoting_session.html index 8af2190..6197d6a 100644 --- a/remoting/client/appengine/chromoting_session.html +++ b/remoting/client/appengine/chromoting_session.html @@ -13,7 +13,7 @@ found in the LICENSE file.