Chromium Code Reviews| OLD | NEW |
|---|---|
| 1 #!/usr/bin/env python | 1 #!/usr/bin/env python |
| 2 | 2 |
| 3 # Copyright (c) 2011 The Chromium Authors. All rights reserved. | 3 # Copyright (c) 2011 The Chromium Authors. All rights reserved. |
| 4 # Use of this source code is governed by a BSD-style license that can be | 4 # Use of this source code is governed by a BSD-style license that can be |
| 5 # found in the LICENSE file. | 5 # found in the LICENSE file. |
| 6 | 6 |
| 7 """Provides authentcation related utilities and endpoint handlers. | 7 """Provides authentcation related utilities and endpoint handlers. |
| 8 | 8 |
| 9 All authentication code for the webapp should go through this module. In | 9 All authentication code for the webapp should go through this module. In |
| 10 general, credentials should be used server-side. The URL endpoints are for | 10 general, credentials should be used server-side. The URL endpoints are for |
| 11 initiating authentication flows, and for managing credential storage per user. | 11 initiating authentication flows, and for managing credential storage per user. |
| 12 """ | 12 """ |
| 13 | 13 |
| 14 import os | 14 import os |
| 15 | 15 import re |
| 16 import gdata.gauth | 16 import time |
| 17 import gdata.client | 17 import urllib |
| 18 | 18 |
| 19 from google.appengine.ext import db | 19 from google.appengine.ext import db |
| 20 from google.appengine.api import urlfetch | |
| 20 from google.appengine.api import users | 21 from google.appengine.api import users |
| 21 from google.appengine.ext import webapp | 22 from google.appengine.ext import webapp |
| 22 from google.appengine.ext.webapp import template | 23 from google.appengine.ext.webapp import template |
| 23 from google.appengine.ext.webapp import util | 24 from google.appengine.ext.webapp import util |
| 24 from google.appengine.ext.webapp.util import login_required | 25 from google.appengine.ext.webapp.util import login_required |
| 25 | 26 |
| 27 from django.utils import simplejson as json | |
| 28 | |
| 26 | 29 |
| 27 SCOPES = ['https://www.googleapis.com/auth/chromoting', | 30 SCOPES = ['https://www.googleapis.com/auth/chromoting', |
| 28 'https://www.googleapis.com/auth/googletalk' ] | 31 'https://www.googleapis.com/auth/googletalk' ] |
| 29 | 32 |
| 33 # Development OAuth2 ID and keys. | |
| 34 CLIENT_ID = ('440925447803-d9u05st5jjm3gbe865l0jeaujqfrufrn.' | |
| 35 'apps.googleusercontent.com') | |
| 36 CLIENT_SECRET = 'Nl4vSQEgDpPMP-1rDEsgs3V7' | |
| 37 | |
| 30 | 38 |
| 31 class NotAuthenticated(Exception): | 39 class NotAuthenticated(Exception): |
| 32 """API requiring authentication is called with credentials.""" | 40 """API requiring authentication is called with credentials.""" |
| 33 pass | 41 pass |
| 34 | 42 |
| 35 | 43 |
| 36 class OAuthInvalidSetup(Exception): | 44 |
| 37 """OAuth configuration on app is not complete.""" | 45 class XmppToken(db.Model): |
| 38 pass | 46 auth_token = db.StringProperty() |
| 39 | 47 |
| 40 | 48 |
| 41 class OAuthConfig(db.Model): | 49 class OAuth2Tokens(db.Model): |
| 42 """Stores the configuration data for OAuth. | 50 """Stores the Refresh and Access token information for OAuth2.""" |
| 43 | 51 refresh_token = db.StringProperty() |
| 44 Currently used to store the consumer key and secret so that it does not need | 52 access_token = db.StringProperty() |
| 45 to be checked into the source tree. | 53 access_token_expiration = db.IntegerProperty() |
| 46 """ | |
| 47 consumer_key = db.StringProperty() | |
| 48 consumer_secret = db.StringProperty() | |
| 49 httpxmppproxy = db.StringProperty() | |
| 50 | 54 |
| 51 | 55 |
| 52 def GetChromotingToken(throws=True): | 56 def HasOAuth2Tokens(throws=True): |
| 53 """Retrieves the Chromoting OAuth token for the user. | 57 oauth2_tokens = OAuth2Tokens.get_or_insert(GetUserId()) |
| 58 if oauth2_tokens.refresh_token: | |
| 59 return True; | |
| 60 return False; | |
| 54 | 61 |
| 55 Args: | |
| 56 throws: bool (optional) Default is True. Throws if no token. | |
| 57 | 62 |
| 58 Returns: | 63 def GetAccessToken(throws=True): |
| 59 An gdata.gauth.OAuthHmacToken for the current user. | 64 oauth2_tokens = OAuth2Tokens.get_or_insert(GetUserId()) |
| 60 """ | 65 |
| 61 user = users.get_current_user() | 66 if not oauth2_tokens.refresh_token: |
| 62 access_token = None | |
| 63 if user: | |
| 64 access_token = LoadToken('chromoting_token') | |
| 65 if throws and not access_token: | |
| 66 raise NotAuthenticated() | 67 raise NotAuthenticated() |
| 67 return access_token | 68 |
| 69 if time.time() > oauth2_tokens.access_token_expiration: | |
| 70 form_fields = { | |
| 71 'client_id' : CLIENT_ID, | |
| 72 'client_secret' : CLIENT_SECRET, | |
| 73 'refresh_token' : oauth2_tokens.refresh_token, | |
| 74 'grant_type' : 'refresh_token' | |
| 75 } | |
| 76 result = urlfetch.fetch( | |
| 77 url = 'https://accounts.google.com/o/oauth2/token', | |
| 78 payload = form_data, | |
| 79 method = urlfetch.POST, | |
| 80 headers = {'Content-Type': 'application/x-www-form-urlencoded'}) | |
| 81 if result.status_code != 200: | |
| 82 raise 'something went wrong %d, %s <br />' % ( | |
| 83 result.status_code, result.content) | |
| 84 oauth_json = json.loads(result.content) | |
| 85 oauth2_tokens.access_token = oauth_json['access_token'] | |
| 86 # Give us 30 second buffer to hackily account for RTT on network request. | |
| 87 oauth2_tokens.access_token_expiration = ( | |
| 88 int(oauth_json['expires_in'] + time.time() - 30)) | |
| 89 oauth2_tokens.put() | |
| 90 | |
| 91 return oauth2_tokens.access_token | |
| 68 | 92 |
| 69 | 93 |
| 70 def GetXmppToken(throws=True): | 94 def GetXmppToken(throws=True): |
| 71 """Retrieves the XMPP for Chromoting. | 95 """Retrieves the XMPP for Chromoting. |
| 72 | 96 |
| 73 Args: | 97 Args: |
| 74 throws: bool (optional) Default is True. Throws if no token. | 98 throws: bool (optional) Default is True. Throws if no token. |
| 75 | 99 |
| 100 | |
|
Jamie
2011/05/20 16:46:47
Nit: No need for this blank line.
awong
2011/05/20 20:37:38
Done.
| |
| 76 Returns: | 101 Returns: |
| 77 An gdata.gauth.ClientLoginToken for the current user. | 102 The auth token for the current user. |
| 78 """ | 103 """ |
| 79 user = users.get_current_user() | 104 xmpp_token = XmppToken.get_or_insert(GetUserId()) |
| 80 access_token = None | 105 if throws and not xmpp_token.auth_token: |
| 81 if user: | |
| 82 access_token = LoadToken('xmpp_token') | |
| 83 if throws and not access_token: | |
| 84 raise NotAuthenticated() | 106 raise NotAuthenticated() |
| 85 return access_token | 107 return xmpp_token.auth_token |
| 86 | |
| 87 | |
| 88 def ClearChromotingToken(): | |
| 89 """Clears all Chromoting OAuth token state from the datastore.""" | |
| 90 DeleteToken('request_token') | |
| 91 DeleteToken('chromoting_token') | |
| 92 | 108 |
| 93 | 109 |
| 94 def ClearXmppToken(): | 110 def ClearXmppToken(): |
| 95 """Clears all Chromoting ClientLogin token state from the datastore.""" | 111 """Clears all Chromoting ClientLogin token state from the datastore.""" |
| 96 DeleteToken('xmpp_token') | 112 db.delete(db.Key.from_path('XmppToken', GetUserId())) |
| 113 | |
| 114 | |
| 115 def ClearOAuth2Token(): | |
| 116 """Clears all Chromoting ClientLogin token state from the datastore.""" | |
| 117 db.delete(db.Key.from_path('OAuth2Tokens', GetUserId())) | |
| 97 | 118 |
| 98 | 119 |
| 99 def GetUserId(): | 120 def GetUserId(): |
| 100 """Retrieves the user id for the current user. | 121 """Retrieves the user id for the current user. |
| 101 | 122 |
| 102 Returns: | 123 Returns: |
| 103 A string with the user id of the logged in user. | 124 A string with the user id of the logged in user. |
| 104 | 125 |
| 105 Raises: | 126 Raises: |
| 106 NotAuthenticated if the user is not logged in, or missing an id. | 127 NotAuthenticated if the user is not logged in, or missing an id. |
| 107 """ | 128 """ |
| 108 user = users.get_current_user() | 129 user = users.get_current_user() |
| 109 if not user: | 130 if not user: |
| 110 raise NotAuthenticated() | 131 raise NotAuthenticated() |
| 111 | 132 |
| 112 if not user.user_id(): | 133 if not user.user_id(): |
| 113 raise NotAuthenticated('no e-mail with google account!') | 134 raise NotAuthenticated('no e-mail with google account!') |
| 114 | 135 |
| 115 return user.user_id() | 136 return user.user_id() |
| 116 | 137 |
| 117 | 138 |
| 118 def LoadToken(name): | |
| 119 """Leads a gdata auth token for the current user. | |
| 120 | |
| 121 Tokens are scoped to each user, and retrieved by a name. | |
| 122 | |
| 123 Args: | |
| 124 name: A string with the name of the token for the current user. | |
| 125 | |
| 126 Returns: | |
| 127 The token associated with the name for the user. | |
| 128 """ | |
| 129 user_id = GetUserId(); | |
| 130 return gdata.gauth.AeLoad(user_id + name) | |
| 131 | |
| 132 | |
| 133 def SaveToken(name, token): | |
| 134 """Saves a gdata auth token for the current user. | |
| 135 | |
| 136 Tokens are scoped to each user, and stored by a name. | |
| 137 | |
| 138 Args: | |
| 139 name: A string with the name of the token. | |
| 140 """ | |
| 141 user_id = GetUserId(); | |
| 142 gdata.gauth.AeSave(token, user_id + name) | |
| 143 | |
| 144 | |
| 145 def DeleteToken(name): | |
| 146 """Deletes a stored gdata auth token for the current user. | |
| 147 | |
| 148 Tokens are scoped to each user, and stored by a name. | |
| 149 | |
| 150 Args: | |
| 151 name: A string with the name of the token. | |
| 152 """ | |
| 153 user_id = GetUserId(); | |
| 154 gdata.gauth.AeDelete(user_id + name) | |
| 155 | |
| 156 | |
| 157 def OAuthConfigKey(): | 139 def OAuthConfigKey(): |
| 158 """Generates a standard key path for this app's OAuth configuration.""" | 140 """Generates a standard key path for this app's OAuth configuration.""" |
| 159 return db.Key.from_path('OAuthConfig', 'oauth_config') | 141 return db.Key.from_path('OAuthConfig', 'oauth_config') |
| 160 | 142 |
| 161 | |
| 162 def GetOAuthConfig(throws=True): | |
| 163 """Retrieves the OAuthConfig for this app. | |
| 164 | |
| 165 Returns: | |
| 166 The OAuthConfig object for this app. | |
| 167 | |
| 168 Raises: | |
| 169 OAuthInvalidSetup if no OAuthConfig exists. | |
| 170 """ | |
| 171 config = db.get(OAuthConfigKey()) | |
| 172 if throws and not config: | |
| 173 raise OAuthInvalidSetup() | |
| 174 return config | |
| 175 | |
| 176 | |
| 177 class ChromotingAuthHandler(webapp.RequestHandler): | |
| 178 """Initiates getting the OAuth access token for the user. | |
| 179 | |
| 180 This webapp uses 3-legged OAuth. This handlers performs the first step | |
| 181 of getting the OAuth request token, and then forwarding on to the | |
| 182 Google Accounts authorization endpoint for the second step. The final | |
| 183 step is completed by the ChromotingAuthReturnHandler below. | |
| 184 | |
| 185 FYI, all three steps are collectively known as the "OAuth dance." | |
| 186 """ | |
| 187 @login_required | |
| 188 def get(self): | |
| 189 ClearChromotingToken() | |
| 190 client = gdata.client.GDClient() | |
| 191 | |
| 192 oauth_callback_url = ('http://%s/auth/chromoting_auth_return' % | |
| 193 self.request.host) | |
| 194 request_token = client.GetOAuthToken( | |
| 195 SCOPES, oauth_callback_url, GetOAuthConfig().consumer_key, | |
| 196 consumer_secret=GetOAuthConfig().consumer_secret) | |
| 197 | |
| 198 SaveToken('request_token', request_token) | |
| 199 domain = None # Not on an Google Apps domain. | |
| 200 auth_uri = request_token.generate_authorization_url() | |
| 201 self.redirect(str(auth_uri)) | |
| 202 | |
| 203 | |
| 204 class ChromotingAuthReturnHandler(webapp.RequestHandler): | |
| 205 """Finishes the authorization started in ChromotingAuthHandler.i | |
| 206 | |
| 207 After the user authorizes the OAuth request token at the OAuth request | |
| 208 URL they were redirected to in ChromotingAuthHandler, OAuth will send | |
| 209 them back here with an auth token in the URL. | |
| 210 | |
| 211 This handler retrievies the access token, and stores it completing the | |
| 212 OAuth dance. | |
| 213 """ | |
| 214 @login_required | |
| 215 def get(self): | |
| 216 saved_request_token = LoadToken('request_token') | |
| 217 DeleteToken('request_token') | |
| 218 request_token = gdata.gauth.AuthorizeRequestToken( | |
| 219 saved_request_token, self.request.uri) | |
| 220 | |
| 221 # Upgrade the token and save in the user's datastore | |
| 222 client = gdata.client.GDClient() | |
| 223 access_token = client.GetAccessToken(request_token) | |
| 224 SaveToken('chromoting_token', access_token) | |
| 225 self.redirect("/") | |
| 226 | |
| 227 | |
| 228 class XmppAuthHandler(webapp.RequestHandler): | 143 class XmppAuthHandler(webapp.RequestHandler): |
| 229 """Prompts Google Accounts credentials and retrieves a ClientLogin token. | 144 """Prompts Google Accounts credentials and retrieves a ClientLogin token. |
| 230 | 145 |
| 231 This class takes the user's plaintext username and password, and then | 146 This class takes the user's plaintext username and password, and then |
| 232 posts a request to ClientLogin to get the access token. | 147 posts a request to ClientLogin to get the access token. |
| 233 | 148 |
| 234 THIS CLASS SHOULD NOT EXIST. | 149 THIS CLASS SHOULD NOT EXIST. |
| 235 | 150 |
| 236 We should NOT be taking a user's Google Accounts credentials in our webapp. | 151 We should NOT be taking a user's Google Accounts credentials in our webapp. |
| 237 However, we need a ClientLogin token for jingle, and this is currently the | 152 However, we need a ClientLogin token for jingle, and this is currently the |
| 238 only known workaround. | 153 only known workaround. |
| 239 """ | 154 """ |
| 240 @login_required | 155 @login_required |
| 241 def get(self): | 156 def get(self): |
| 242 ClearXmppToken() | 157 ClearXmppToken() |
| 243 path = os.path.join(os.path.dirname(__file__), 'client_login.html') | 158 path = os.path.join(os.path.dirname(__file__), 'client_login.html') |
| 244 self.response.out.write(template.render(path, {})) | 159 self.response.out.write(template.render(path, {})) |
| 245 | 160 |
| 246 def post(self): | 161 def post(self): |
| 247 client = gdata.client.GDClient() | |
| 248 email = self.request.get('username') | 162 email = self.request.get('username') |
| 249 password = self.request.get('password') | 163 password = self.request.get('password') |
| 250 try: | 164 form_fields = { |
| 251 client.ClientLogin( | 165 'accountType' : 'HOSTED_OR_GOOGLE', |
| 252 email, password, 'chromoclient', 'chromiumsync') | 166 'Email' : self.request.get('username'), |
| 253 SaveToken('xmpp_token', client.auth_token) | 167 'Passwd' : self.request.get('password'), |
| 254 except gdata.client.CaptchaChallenge: | 168 'service' : 'chromiumsync', |
| 255 self.response.out.write('You need to solve a Captcha. ' | 169 'source' : 'chromoplex' |
| 256 'Unforutnately, we still have to implement that.') | 170 } |
| 257 self.redirect('/') | 171 form_data = urllib.urlencode(form_fields) |
| 172 result = urlfetch.fetch( | |
| 173 url = 'https://www.google.com/accounts/ClientLogin', | |
| 174 payload = form_data, | |
| 175 method = urlfetch.POST, | |
| 176 headers = {'Content-Type': 'application/x-www-form-urlencoded'}) | |
| 177 if result.status_code != 200: | |
| 178 self.response.out.write(result.content) | |
| 179 for i in result.headers: | |
| 180 self.response.headers[i] = result.headers[i] | |
| 181 self.response.set_status(result.status_code) | |
| 182 return | |
| 258 | 183 |
| 259 | 184 xmpp_token = XmppToken(key_name = GetUserId()) |
| 260 class ClearChromotingTokenHandler(webapp.RequestHandler): | 185 xmpp_token.auth_token = re.search("Auth=(.*)", result.content).group(1) |
| 261 """Endpoint for dropping the user's Chromoting token.""" | 186 xmpp_token.put() |
| 262 @login_required | |
| 263 def get(self): | |
| 264 ClearChromotingToken() | |
| 265 self.redirect('/') | 187 self.redirect('/') |
| 266 | 188 |
| 267 | 189 |
| 268 class ClearXmppTokenHandler(webapp.RequestHandler): | 190 class ClearXmppTokenHandler(webapp.RequestHandler): |
| 269 """Endpoint for dropping the user's Xmpp token.""" | 191 """Endpoint for dropping the user's Xmpp token.""" |
| 270 @login_required | 192 @login_required |
| 271 def get(self): | 193 def get(self): |
| 272 ClearXmppToken() | 194 ClearXmppToken() |
| 273 self.redirect('/') | 195 self.redirect('/') |
| 274 | 196 |
| 275 | 197 |
| 276 class SetupOAuthHandler(webapp.RequestHandler): | 198 class ClearOAuth2TokenHandler(webapp.RequestHandler): |
| 277 """Administrative page for specifying the OAuth consumer key/secret.""" | 199 """Endpoint for dropping the user's OAuth2 token.""" |
| 278 @login_required | 200 @login_required |
| 279 def get(self): | 201 def get(self): |
| 280 path = os.path.join(os.path.dirname(__file__), | 202 ClearOAuth2Token() |
| 281 'chromoting_oauth_setup.html') | |
| 282 self.response.out.write(template.render(path, {})) | |
| 283 | |
| 284 def post(self): | |
| 285 old_consumer_secret = self.request.get('old_consumer_secret') | |
| 286 | |
| 287 query = OAuthConfig.all() | |
| 288 | |
| 289 # If there is an existing key, only allow updating if you know the old | |
| 290 # key. This is a simple safeguard against random users hitting this page. | |
| 291 config = GetOAuthConfig(throws=False) | |
| 292 if config: | |
| 293 if config.consumer_secret != old_consumer_secret: | |
| 294 self.response.set_status(400) | |
| 295 self.response.out.write('Incorrect old consumer secret') | |
| 296 return | |
| 297 else: | |
| 298 config = OAuthConfig(key_name = OAuthConfigKey().id_or_name()) | |
| 299 | |
| 300 # TODO(ajwong): THIS IS A TOTAL HACK! FIX WITH OWN PAGE. | |
| 301 # Currently, this form has one submit button, and 3 pieces of input: | |
| 302 # consumer_key, oauth_secret, and the httpxmppproxy address. The | |
| 303 # HTTP/XMPP proxy should really have its own configuration page. | |
| 304 httpxmppproxy = self.request.get('httpxmppproxy') | |
| 305 if httpxmppproxy: | |
| 306 config.httpxmppproxy = httpxmppproxy | |
| 307 | |
| 308 config.consumer_key = self.request.get('consumer_key') | |
| 309 config.consumer_secret = self.request.get('new_consumer_secret') | |
| 310 config.put() | |
| 311 self.redirect('/') | 203 self.redirect('/') |
| 312 | 204 |
| 313 def GetHttpXmppProxy(): | 205 |
| 314 config = GetOAuthConfig(throws=True) | 206 class OAuth2ReturnHandler(webapp.RequestHandler): |
| 315 if not config.httpxmppproxy: | 207 """Handles the redirect in the OAuth dance.""" |
| 316 raise OAuthInvalidSetup() | 208 @login_required |
| 317 return config.httpxmppproxy | 209 def get(self): |
| 210 code = self.request.get('code') | |
| 211 state = self.request.get('state') | |
| 212 form_fields = { | |
| 213 'client_id' : CLIENT_ID, | |
| 214 'client_secret' : CLIENT_SECRET, | |
| 215 'redirect_uri' : 'http://localhost:8080/auth/oauth2_return', | |
| 216 'code' : code, | |
| 217 'grant_type' : 'authorization_code' | |
| 218 } | |
| 219 form_data = urllib.urlencode(form_fields) | |
| 220 result = urlfetch.fetch( | |
| 221 url = 'https://accounts.google.com/o/oauth2/token', | |
| 222 payload = form_data, | |
| 223 method = urlfetch.POST, | |
| 224 headers = {'Content-Type': 'application/x-www-form-urlencoded'}) | |
| 225 | |
| 226 if result.status_code != 200: | |
| 227 self.response.out.write('something went wrong %d, %s <br />' % | |
| 228 (result.status_code, result.content)) | |
| 229 self.response.out.write( | |
| 230 'We tried posting %s code(%s) [%s]' % (form_data, code, form_fields)) | |
| 231 self.response.set_status(400) | |
| 232 return | |
| 233 | |
| 234 oauth_json = json.loads(result.content) | |
| 235 oauth2_tokens = OAuth2Tokens(key_name = GetUserId()) | |
| 236 oauth2_tokens.refresh_token = oauth_json['refresh_token'] | |
| 237 oauth2_tokens.access_token = oauth_json['access_token'] | |
| 238 # Give us 30 second buffer to hackily account for RTT on network request. | |
| 239 oauth2_tokens.access_token_expiration = ( | |
| 240 int(oauth_json['expires_in'] + time.time() - 30)) | |
| 241 oauth2_tokens.put() | |
| 242 | |
| 243 if state: | |
| 244 self.redirect(state) | |
| 245 else: | |
| 246 self.redirect('/') | |
| 247 | |
| 318 | 248 |
| 319 def main(): | 249 def main(): |
| 320 application = webapp.WSGIApplication( | 250 application = webapp.WSGIApplication( |
| 321 [ | 251 [ |
| 322 ('/auth/chromoting_auth', ChromotingAuthHandler), | |
| 323 ('/auth/xmpp_auth', XmppAuthHandler), | 252 ('/auth/xmpp_auth', XmppAuthHandler), |
| 324 ('/auth/chromoting_auth_return', ChromotingAuthReturnHandler), | |
| 325 ('/auth/clear_xmpp_token', ClearXmppTokenHandler), | 253 ('/auth/clear_xmpp_token', ClearXmppTokenHandler), |
| 326 ('/auth/clear_chromoting_token', ClearChromotingTokenHandler), | 254 ('/auth/clear_oauth2_token', ClearOAuth2TokenHandler), |
| 327 ('/auth/setup_oauth', SetupOAuthHandler) | 255 ('/auth/oauth2_return', OAuth2ReturnHandler) |
| 328 ], | 256 ], |
| 329 debug=True) | 257 debug=True) |
| 330 util.run_wsgi_app(application) | 258 util.run_wsgi_app(application) |
| 331 | 259 |
| 332 | 260 |
| 333 if __name__ == '__main__': | 261 if __name__ == '__main__': |
| 334 main() | 262 main() |
| OLD | NEW |