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 import re |
| 16 import time |
| 17 import urllib |
| 18 from urlparse import urlparse |
15 | 19 |
16 import gdata.gauth | |
17 import gdata.client | |
18 | 20 |
19 from google.appengine.ext import db | 21 from google.appengine.ext import db |
| 22 from google.appengine.api import urlfetch |
20 from google.appengine.api import users | 23 from google.appengine.api import users |
21 from google.appengine.ext import webapp | 24 from google.appengine.ext import webapp |
22 from google.appengine.ext.webapp import template | 25 from google.appengine.ext.webapp import template |
23 from google.appengine.ext.webapp import util | 26 from google.appengine.ext.webapp import util |
24 from google.appengine.ext.webapp.util import login_required | 27 from google.appengine.ext.webapp.util import login_required |
25 | 28 |
| 29 from django.utils import simplejson as json |
| 30 |
26 | 31 |
27 SCOPES = ['https://www.googleapis.com/auth/chromoting', | 32 SCOPES = ['https://www.googleapis.com/auth/chromoting', |
28 'https://www.googleapis.com/auth/googletalk' ] | 33 'https://www.googleapis.com/auth/googletalk' ] |
29 | 34 |
| 35 # Development OAuth2 ID and keys. |
| 36 CLIENT_ID = ('440925447803-d9u05st5jjm3gbe865l0jeaujqfrufrn.' |
| 37 'apps.googleusercontent.com') |
| 38 CLIENT_SECRET = 'Nl4vSQEgDpPMP-1rDEsgs3V7' |
| 39 |
30 | 40 |
31 class NotAuthenticated(Exception): | 41 class NotAuthenticated(Exception): |
32 """API requiring authentication is called with credentials.""" | 42 """API requiring authentication is called with credentials.""" |
33 pass | 43 pass |
34 | 44 |
35 | 45 |
36 class OAuthInvalidSetup(Exception): | 46 class XmppToken(db.Model): |
37 """OAuth configuration on app is not complete.""" | 47 auth_token = db.StringProperty() |
38 pass | |
39 | 48 |
40 | 49 |
41 class OAuthConfig(db.Model): | 50 class OAuth2Tokens(db.Model): |
42 """Stores the configuration data for OAuth. | 51 """Stores the Refresh and Access token information for OAuth2.""" |
43 | 52 refresh_token = db.StringProperty() |
44 Currently used to store the consumer key and secret so that it does not need | 53 access_token = db.StringProperty() |
45 to be checked into the source tree. | 54 access_token_expiration = db.IntegerProperty() |
46 """ | |
47 consumer_key = db.StringProperty() | |
48 consumer_secret = db.StringProperty() | |
49 httpxmppproxy = db.StringProperty() | |
50 | 55 |
51 | 56 |
52 def GetChromotingToken(throws=True): | 57 def HasOAuth2Tokens(throws=True): |
53 """Retrieves the Chromoting OAuth token for the user. | 58 oauth2_tokens = OAuth2Tokens.get_or_insert(GetUserId()) |
| 59 if oauth2_tokens.refresh_token: |
| 60 return True; |
| 61 return False; |
54 | 62 |
55 Args: | |
56 throws: bool (optional) Default is True. Throws if no token. | |
57 | 63 |
58 Returns: | 64 def GetAccessToken(throws=True): |
59 An gdata.gauth.OAuthHmacToken for the current user. | 65 oauth2_tokens = OAuth2Tokens.get_or_insert(GetUserId()) |
60 """ | 66 |
61 user = users.get_current_user() | 67 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() | 68 raise NotAuthenticated() |
67 return access_token | 69 |
| 70 if time.time() > oauth2_tokens.access_token_expiration: |
| 71 form_fields = { |
| 72 'client_id' : CLIENT_ID, |
| 73 'client_secret' : CLIENT_SECRET, |
| 74 'refresh_token' : oauth2_tokens.refresh_token, |
| 75 'grant_type' : 'refresh_token' |
| 76 } |
| 77 form_data = urllib.urlencode(form_fields) |
| 78 result = urlfetch.fetch( |
| 79 url = 'https://accounts.google.com/o/oauth2/token', |
| 80 payload = form_data, |
| 81 method = urlfetch.POST, |
| 82 headers = {'Content-Type': 'application/x-www-form-urlencoded'}) |
| 83 if result.status_code != 200: |
| 84 raise 'something went wrong %d, %s <br />' % ( |
| 85 result.status_code, result.content) |
| 86 oauth_json = json.loads(result.content) |
| 87 oauth2_tokens.access_token = oauth_json['access_token'] |
| 88 # Give us 30 second buffer to hackily account for RTT on network request. |
| 89 oauth2_tokens.access_token_expiration = ( |
| 90 int(oauth_json['expires_in'] + time.time() - 30)) |
| 91 oauth2_tokens.put() |
| 92 |
| 93 return oauth2_tokens.access_token |
68 | 94 |
69 | 95 |
70 def GetXmppToken(throws=True): | 96 def GetXmppToken(throws=True): |
71 """Retrieves the XMPP for Chromoting. | 97 """Retrieves the XMPP for Chromoting. |
72 | 98 |
73 Args: | 99 Args: |
74 throws: bool (optional) Default is True. Throws if no token. | 100 throws: bool (optional) Default is True. Throws if no token. |
75 | 101 |
76 Returns: | 102 Returns: |
77 An gdata.gauth.ClientLoginToken for the current user. | 103 The auth token for the current user. |
78 """ | 104 """ |
79 user = users.get_current_user() | 105 xmpp_token = XmppToken.get_or_insert(GetUserId()) |
80 access_token = None | 106 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() | 107 raise NotAuthenticated() |
85 return access_token | 108 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 | 109 |
93 | 110 |
94 def ClearXmppToken(): | 111 def ClearXmppToken(): |
95 """Clears all Chromoting ClientLogin token state from the datastore.""" | 112 """Clears all Chromoting ClientLogin token state from the datastore.""" |
96 DeleteToken('xmpp_token') | 113 db.delete(db.Key.from_path('XmppToken', GetUserId())) |
| 114 |
| 115 |
| 116 def ClearOAuth2Token(): |
| 117 """Clears all Chromoting ClientLogin token state from the datastore.""" |
| 118 db.delete(db.Key.from_path('OAuth2Tokens', GetUserId())) |
97 | 119 |
98 | 120 |
99 def GetUserId(): | 121 def GetUserId(): |
100 """Retrieves the user id for the current user. | 122 """Retrieves the user id for the current user. |
101 | 123 |
102 Returns: | 124 Returns: |
103 A string with the user id of the logged in user. | 125 A string with the user id of the logged in user. |
104 | 126 |
105 Raises: | 127 Raises: |
106 NotAuthenticated if the user is not logged in, or missing an id. | 128 NotAuthenticated if the user is not logged in, or missing an id. |
107 """ | 129 """ |
108 user = users.get_current_user() | 130 user = users.get_current_user() |
109 if not user: | 131 if not user: |
110 raise NotAuthenticated() | 132 raise NotAuthenticated() |
111 | 133 |
112 if not user.user_id(): | 134 if not user.user_id(): |
113 raise NotAuthenticated('no e-mail with google account!') | 135 raise NotAuthenticated('no e-mail with google account!') |
114 | 136 |
115 return user.user_id() | 137 return user.user_id() |
116 | 138 |
117 | 139 |
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(): | |
158 """Generates a standard key path for this app's OAuth configuration.""" | |
159 return db.Key.from_path('OAuthConfig', 'oauth_config') | |
160 | |
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): | 140 class XmppAuthHandler(webapp.RequestHandler): |
229 """Prompts Google Accounts credentials and retrieves a ClientLogin token. | 141 """Prompts Google Accounts credentials and retrieves a ClientLogin token. |
230 | 142 |
231 This class takes the user's plaintext username and password, and then | 143 This class takes the user's plaintext username and password, and then |
232 posts a request to ClientLogin to get the access token. | 144 posts a request to ClientLogin to get the access token. |
233 | 145 |
234 THIS CLASS SHOULD NOT EXIST. | 146 THIS CLASS SHOULD NOT EXIST. |
235 | 147 |
236 We should NOT be taking a user's Google Accounts credentials in our webapp. | 148 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 | 149 However, we need a ClientLogin token for jingle, and this is currently the |
238 only known workaround. | 150 only known workaround. |
239 """ | 151 """ |
240 @login_required | 152 @login_required |
241 def get(self): | 153 def get(self): |
242 ClearXmppToken() | 154 ClearXmppToken() |
243 path = os.path.join(os.path.dirname(__file__), 'client_login.html') | 155 path = os.path.join(os.path.dirname(__file__), 'client_login.html') |
244 self.response.out.write(template.render(path, {})) | 156 self.response.out.write(template.render(path, {})) |
245 | 157 |
246 def post(self): | 158 def post(self): |
247 client = gdata.client.GDClient() | |
248 email = self.request.get('username') | 159 email = self.request.get('username') |
249 password = self.request.get('password') | 160 password = self.request.get('password') |
250 try: | 161 form_fields = { |
251 client.ClientLogin( | 162 'accountType' : 'HOSTED_OR_GOOGLE', |
252 email, password, 'chromoclient', 'chromiumsync') | 163 'Email' : self.request.get('username'), |
253 SaveToken('xmpp_token', client.auth_token) | 164 'Passwd' : self.request.get('password'), |
254 except gdata.client.CaptchaChallenge: | 165 'service' : 'chromiumsync', |
255 self.response.out.write('You need to solve a Captcha. ' | 166 'source' : 'chromoplex' |
256 'Unforutnately, we still have to implement that.') | 167 } |
257 self.redirect('/') | 168 form_data = urllib.urlencode(form_fields) |
| 169 result = urlfetch.fetch( |
| 170 url = 'https://www.google.com/accounts/ClientLogin', |
| 171 payload = form_data, |
| 172 method = urlfetch.POST, |
| 173 headers = {'Content-Type': 'application/x-www-form-urlencoded'}) |
| 174 if result.status_code != 200: |
| 175 self.response.out.write(result.content) |
| 176 for i in result.headers: |
| 177 self.response.headers[i] = result.headers[i] |
| 178 self.response.set_status(result.status_code) |
| 179 return |
258 | 180 |
259 | 181 xmpp_token = XmppToken(key_name = GetUserId()) |
260 class ClearChromotingTokenHandler(webapp.RequestHandler): | 182 xmpp_token.auth_token = re.search("Auth=(.*)", result.content).group(1) |
261 """Endpoint for dropping the user's Chromoting token.""" | 183 xmpp_token.put() |
262 @login_required | |
263 def get(self): | |
264 ClearChromotingToken() | |
265 self.redirect('/') | 184 self.redirect('/') |
266 | 185 |
267 | 186 |
268 class ClearXmppTokenHandler(webapp.RequestHandler): | 187 class ClearXmppTokenHandler(webapp.RequestHandler): |
269 """Endpoint for dropping the user's Xmpp token.""" | 188 """Endpoint for dropping the user's Xmpp token.""" |
270 @login_required | 189 @login_required |
271 def get(self): | 190 def get(self): |
272 ClearXmppToken() | 191 ClearXmppToken() |
273 self.redirect('/') | 192 self.redirect('/') |
274 | 193 |
275 | 194 |
276 class SetupOAuthHandler(webapp.RequestHandler): | 195 class ClearOAuth2TokenHandler(webapp.RequestHandler): |
277 """Administrative page for specifying the OAuth consumer key/secret.""" | 196 """Endpoint for dropping the user's OAuth2 token.""" |
278 @login_required | 197 @login_required |
279 def get(self): | 198 def get(self): |
280 path = os.path.join(os.path.dirname(__file__), | 199 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('/') | 200 self.redirect('/') |
312 | 201 |
313 def GetHttpXmppProxy(): | 202 |
314 config = GetOAuthConfig(throws=True) | 203 class OAuth2ReturnHandler(webapp.RequestHandler): |
315 if not config.httpxmppproxy: | 204 """Handles the redirect in the OAuth dance.""" |
316 raise OAuthInvalidSetup() | 205 @login_required |
317 return config.httpxmppproxy | 206 def get(self): |
| 207 code = self.request.get('code') |
| 208 state = self.request.get('state') |
| 209 parsed_url = urlparse(self.request.url) |
| 210 server = parsed_url.scheme + '://' + parsed_url.netloc |
| 211 form_fields = { |
| 212 'client_id' : CLIENT_ID, |
| 213 'client_secret' : CLIENT_SECRET, |
| 214 'redirect_uri' : server + '/auth/oauth2_return', |
| 215 'code' : code, |
| 216 'grant_type' : 'authorization_code' |
| 217 } |
| 218 form_data = urllib.urlencode(form_fields) |
| 219 result = urlfetch.fetch( |
| 220 url = 'https://accounts.google.com/o/oauth2/token', |
| 221 payload = form_data, |
| 222 method = urlfetch.POST, |
| 223 headers = {'Content-Type': 'application/x-www-form-urlencoded'}) |
| 224 |
| 225 if result.status_code != 200: |
| 226 self.response.out.write('something went wrong %d, %s <br />' % |
| 227 (result.status_code, result.content)) |
| 228 self.response.out.write( |
| 229 'We tried posting %s code(%s) [%s]' % (form_data, code, form_fields)) |
| 230 self.response.set_status(400) |
| 231 return |
| 232 |
| 233 oauth_json = json.loads(result.content) |
| 234 oauth2_tokens = OAuth2Tokens(key_name = GetUserId()) |
| 235 oauth2_tokens.refresh_token = oauth_json['refresh_token'] |
| 236 oauth2_tokens.access_token = oauth_json['access_token'] |
| 237 # Give us 30 second buffer to hackily account for RTT on network request. |
| 238 oauth2_tokens.access_token_expiration = ( |
| 239 int(oauth_json['expires_in'] + time.time() - 30)) |
| 240 oauth2_tokens.put() |
| 241 |
| 242 if state: |
| 243 self.redirect(state) |
| 244 else: |
| 245 self.redirect('/') |
| 246 |
318 | 247 |
319 def main(): | 248 def main(): |
320 application = webapp.WSGIApplication( | 249 application = webapp.WSGIApplication( |
321 [ | 250 [ |
322 ('/auth/chromoting_auth', ChromotingAuthHandler), | |
323 ('/auth/xmpp_auth', XmppAuthHandler), | 251 ('/auth/xmpp_auth', XmppAuthHandler), |
324 ('/auth/chromoting_auth_return', ChromotingAuthReturnHandler), | |
325 ('/auth/clear_xmpp_token', ClearXmppTokenHandler), | 252 ('/auth/clear_xmpp_token', ClearXmppTokenHandler), |
326 ('/auth/clear_chromoting_token', ClearChromotingTokenHandler), | 253 ('/auth/clear_oauth2_token', ClearOAuth2TokenHandler), |
327 ('/auth/setup_oauth', SetupOAuthHandler) | 254 ('/auth/oauth2_return', OAuth2ReturnHandler) |
328 ], | 255 ], |
329 debug=True) | 256 debug=True) |
330 util.run_wsgi_app(application) | 257 util.run_wsgi_app(application) |
331 | 258 |
332 | 259 |
333 if __name__ == '__main__': | 260 if __name__ == '__main__': |
334 main() | 261 main() |
OLD | NEW |