Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(85)

Side by Side Diff: auth.py

Issue 1074673002: Add OAuth2 support for end users (i.e. 3-legged flow with the browser). (Closed) Base URL: https://chromium.googlesource.com/chromium/tools/depot_tools.git@master
Patch Set: Created 5 years, 8 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
« no previous file with comments | « no previous file | depot-tools-auth » ('j') | depot-tools-auth.py » ('J')
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
1 # Copyright (c) 2015 The Chromium Authors. All rights reserved. 1 # Copyright (c) 2015 The Chromium Authors. All rights reserved.
2 # Use of this source code is governed by a BSD-style license that can be 2 # Use of this source code is governed by a BSD-style license that can be
3 # found in the LICENSE file. 3 # found in the LICENSE file.
4 4
5 """Authentication related functions.""" 5 """Authentication related functions."""
6 6
7 import BaseHTTPServer
7 import collections 8 import collections
9 import datetime
10 import functools
11 import json
12 import logging
8 import optparse 13 import optparse
14 import os
15 import socket
16 import sys
17 import threading
18 import urllib
19 import urlparse
20 import webbrowser
21
22 from third_party import httplib2
23 from third_party.oauth2client import client
24 from third_party.oauth2client import multistore_file
25
26
27 # Google OAuth2 clients always have a secret, even if the client is an installed
28 # application/utility such as this. Of course, in such cases the "secret" is
29 # actually publicly known; security depends entirely on the secrecy of refresh
30 # tokens, which effectively become bearer tokens. An attacker can impersonate
31 # service's identity in OAuth2 flow. But that's generally fine as long as a list
32 # of allowed redirect_uri's associated with client_id is limited to 'localhost'
33 # or 'urn:ietf:wg:oauth:2.0:oob'. In that case attacker needs some process
34 # running on user's machine to successfully complete the flow and grab refresh
35 # token. When you have a malicious code running on your machine, you're screwed
36 # anyway.
37 # This particular set is managed by API Console project "chrome-infra-auth".
38 OAUTH_CLIENT_ID = (
39 '446450136466-2hr92jrq8e6i4tnsa56b52vacp7t3936.apps.googleusercontent.com')
40 OAUTH_CLIENT_SECRET = 'uBfbay2KCy9t4QveJ-dOqHtp'
Vadim Sh. 2015/04/09 06:21:59 same ones that in Go code. Application title is "C
M-A Ruel 2015/04/09 20:00:40 No, it's fine.
41
42 # List of space separated OAuth scopes for generated tokens. GAE apps usually
43 # use userinfo.email scope for authentication.
44 OAUTH_SCOPES = 'https://www.googleapis.com/auth/userinfo.email'
45
46 # Path to a file with cached OAuth2 credentials used by default. Can be
47 # overridden by command line option.
48 OAUTH_TOKENS_CACHE = os.path.join(
49 os.path.expanduser('~'), '.depot_tools_oauth2_tokens')
Vadim Sh. 2015/04/09 06:21:59 by analogy with ~/.appcfg_oauth2_tokens that appcf
M-A Ruel 2015/04/09 20:00:40 I'd prefer to put stuff in ~/.config/ on posix but
Vadim Sh. 2015/04/10 01:18:42 In presence of multiple platforms (Win, Mac), it i
9 50
10 51
11 # Authentication configuration extracted from command line options. 52 # Authentication configuration extracted from command line options.
12 # See doc string for 'make_auth_config' for meaning of fields. 53 # See doc string for 'make_auth_config' for meaning of fields.
13 AuthConfig = collections.namedtuple('AuthConfig', [ 54 AuthConfig = collections.namedtuple('AuthConfig', [
14 'use_oauth2', # deprecated, will be always True 55 'use_oauth2', # deprecated, will be always True
15 'save_cookies', # deprecated, will be removed 56 'save_cookies', # deprecated, will be removed
57 'tokens_cache',
16 'use_local_webserver', 58 'use_local_webserver',
17 'webserver_port', 59 'webserver_port',
18 ]) 60 ])
19 61
20 62
63 # OAuth access token with its expiration time (UTC datetime or None if unknown).
64 AccessToken = collections.namedtuple('AccessToken', [
65 'token',
66 'expires_at',
67 ])
68
69
70 class AuthenticationError(Exception):
71 """Raised on errors related to authentication."""
72
73
74 class LoginRequiredError(AuthenticationError):
75 """Interaction with the user is required to authenticate."""
76
77 def __init__(self, token_cache_key):
78 # HACK(vadimsh): It is assumed here that the token cache key is a hostname.
Vadim Sh. 2015/04/09 06:21:59 this hostname business is annoying, but needed to
79 msg = (
80 'You are not logged in. Please login first by running:\n'
81 ' depot-tools-auth login %s' % token_cache_key)
82 super(LoginRequiredError, self).__init__(msg)
83
84
21 def make_auth_config( 85 def make_auth_config(
22 use_oauth2=None, 86 use_oauth2=None,
23 save_cookies=None, 87 save_cookies=None,
88 tokens_cache=None,
24 use_local_webserver=None, 89 use_local_webserver=None,
25 webserver_port=None): 90 webserver_port=None):
26 """Returns new instance of AuthConfig. 91 """Returns new instance of AuthConfig.
27 92
28 If some config option is None, it will be set to a reasonable default value. 93 If some config option is None, it will be set to a reasonable default value.
29 This function also acts as an authoritative place for default values of 94 This function also acts as an authoritative place for default values of
30 corresponding command line options. 95 corresponding command line options.
31 """ 96 """
32 default = lambda val, d: val if val is not None else d 97 default = lambda val, d: val if val is not None else d
33 return AuthConfig( 98 return AuthConfig(
34 default(use_oauth2, False), 99 default(use_oauth2, False),
35 default(save_cookies, True), 100 default(save_cookies, True),
101 default(tokens_cache, OAUTH_TOKENS_CACHE),
36 default(use_local_webserver, True), 102 default(use_local_webserver, True),
M-A Ruel 2015/04/09 20:00:40 Can you use instead: bool(sys.platform != 'linux'
Vadim Sh. 2015/04/10 01:18:42 Done.
37 default(webserver_port, 8090)) 103 default(webserver_port, 8090))
38 104
39 105
40 def add_auth_options(parser): 106 def add_auth_options(parser, default_config=None):
41 """Appends OAuth related options to OptionParser.""" 107 """Appends OAuth related options to OptionParser."""
42 default_config = make_auth_config() 108 default_config = default_config or make_auth_config()
43 parser.auth_group = optparse.OptionGroup(parser, 'Auth options') 109 parser.auth_group = optparse.OptionGroup(parser, 'Auth options')
44 parser.auth_group.add_option( 110 parser.auth_group.add_option(
45 '--oauth2', 111 '--oauth2',
46 action='store_true', 112 action='store_true',
47 dest='use_oauth2', 113 dest='use_oauth2',
48 default=default_config.use_oauth2, 114 default=default_config.use_oauth2,
49 help='Use OAuth 2.0 instead of a password.') 115 help='Use OAuth 2.0 instead of a password.')
50 parser.auth_group.add_option( 116 parser.auth_group.add_option(
51 '--no-oauth2', 117 '--no-oauth2',
52 action='store_false', 118 action='store_false',
53 dest='use_oauth2', 119 dest='use_oauth2',
54 default=default_config.use_oauth2, 120 default=default_config.use_oauth2,
55 help='Use password instead of OAuth 2.0.') 121 help='Use password instead of OAuth 2.0.')
56 parser.auth_group.add_option( 122 parser.auth_group.add_option(
57 '--no-cookies', 123 '--no-cookies',
58 action='store_false', 124 action='store_false',
59 dest='save_cookies', 125 dest='save_cookies',
60 default=default_config.save_cookies, 126 default=default_config.save_cookies,
61 help='Do not save authentication cookies to local disk.') 127 help='Do not save authentication cookies to local disk.')
62 parser.auth_group.add_option( 128 parser.auth_group.add_option(
129 '--auth-tokens-cache',
M-A Ruel 2015/04/09 20:00:40 Why make this configurable?
Vadim Sh. 2015/04/10 01:18:42 Some folks wanted it for swarming.py for some reas
M-A Ruel 2015/04/10 01:25:56 Me? I don't recall. I don't think it's used anyway
130 default=default_config.tokens_cache,
131 help='Path to a file with OAuth2 tokens cache. It should be a safe '
132 'location accessible only to a current user: knowing content of this '
133 'file is roughly equivalent to knowing account password. Single file '
134 'can hold multiple tokens. [default: %default]')
135 parser.auth_group.add_option(
63 '--auth-no-local-webserver', 136 '--auth-no-local-webserver',
64 action='store_false', 137 action='store_false',
65 dest='use_local_webserver', 138 dest='use_local_webserver',
66 default=default_config.use_local_webserver, 139 default=default_config.use_local_webserver,
67 help='Do not run a local web server when performing OAuth2 login flow.') 140 help='Do not run a local web server when performing OAuth2 login flow.')
68 parser.auth_group.add_option( 141 parser.auth_group.add_option(
69 '--auth-host-port', 142 '--auth-host-port',
70 type=int, 143 type=int,
71 default=default_config.webserver_port, 144 default=default_config.webserver_port,
72 help='Port a local web server should listen on. Used only if ' 145 help='Port a local web server should listen on. Used only if '
73 '--auth-no-local-webserver is not set. [default: %default]') 146 '--auth-no-local-webserver is not set. [default: %default]')
74 parser.add_option_group(parser.auth_group) 147 parser.add_option_group(parser.auth_group)
75 148
76 149
77 def extract_auth_config_from_options(options): 150 def extract_auth_config_from_options(options):
78 """Given OptionParser parsed options, extracts AuthConfig from it. 151 """Given OptionParser parsed options, extracts AuthConfig from it.
79 152
80 OptionParser should be populated with auth options by 'add_auth_options'. 153 OptionParser should be populated with auth options by 'add_auth_options'.
81 """ 154 """
82 return make_auth_config( 155 return make_auth_config(
83 use_oauth2=options.use_oauth2, 156 use_oauth2=options.use_oauth2,
84 save_cookies=False if options.use_oauth2 else options.save_cookies, 157 save_cookies=False if options.use_oauth2 else options.save_cookies,
158 tokens_cache=options.auth_tokens_cache,
85 use_local_webserver=options.use_local_webserver, 159 use_local_webserver=options.use_local_webserver,
86 webserver_port=options.auth_host_port) 160 webserver_port=options.auth_host_port)
87 161
88 162
89 def auth_config_to_command_options(auth_config): 163 def auth_config_to_command_options(auth_config):
90 """AuthConfig -> list of strings with command line options.""" 164 """AuthConfig -> list of strings with command line options."""
91 if not auth_config: 165 if not auth_config:
92 return [] 166 return []
167 defaults = make_auth_config()
93 opts = ['--oauth2' if auth_config.use_oauth2 else '--no-oauth2'] 168 opts = ['--oauth2' if auth_config.use_oauth2 else '--no-oauth2']
94 if not auth_config.save_cookies: 169 if not auth_config.use_oauth2 and not auth_config.save_cookies:
95 opts.append('--no-cookies') 170 opts.append('--no-cookies')
171 if auth_config.tokens_cache != defaults.tokens_cache:
172 opts.extend(['--auth-tokens-cache', auth_config.tokens_cache])
96 if not auth_config.use_local_webserver: 173 if not auth_config.use_local_webserver:
97 opts.append('--auth-no-local-webserver') 174 opts.append('--auth-no-local-webserver')
98 opts.extend(['--auth-host-port', str(auth_config.webserver_port)]) 175 if auth_config.webserver_port != defaults.webserver_port:
176 opts.extend(['--auth-host-port', str(auth_config.webserver_port)])
99 return opts 177 return opts
178
179
180 def hostname_to_token_cache_key(hostname):
181 """Normalizes hostname to be used as cache key, strips unnecessary details."""
182 # Append some scheme, otherwise urlparse puts hostname into parsed.path.
183 hostname = hostname.lower()
M-A Ruel 2015/04/09 20:00:40 not a fan of doing a .lower() here. It should abor
Vadim Sh. 2015/04/10 01:18:42 1) DNS names are case insensitive. 2) Some coderev
184 if '://' not in hostname:
185 hostname = 'https://' + hostname
186 parsed = urlparse.urlparse(hostname)
187 return parsed.netloc
M-A Ruel 2015/04/09 20:00:40 It should abort if there's any .path, .params, .qu
Vadim Sh. 2015/04/10 01:18:42 Done.
188
189
190 class Authenticator(object):
191 """Object that knows how to refresh access tokens when needed.
192
193 Args:
194 token_cache_key: string key of a section of the token cache file to use
195 to keep the tokens. See hostname_to_token_cache_key.
196 config: AuthConfig object that holds authentication configuration.
197 """
198
199 def __init__(self, token_cache_key, config):
200 assert isinstance(config, AuthConfig)
201 assert config.use_oauth2
202 self._access_token = None
203 self._config = config
204 self._lock = threading.Lock()
205 self._token_cache_key = token_cache_key
206
207 def login(self):
208 """Performs interactive login flow if necessary.
209
210 Raises:
211 AuthenticationError on error or if interrupted.
212 """
213 return self.get_access_token(
214 force_refresh=True, allow_user_interaction=True)
215
216 def logout(self):
217 """Revokes the refresh token and deletes it from the cache.
218
219 Returns True if actually revoked a token.
220 """
221 revoked = False
222 with self._lock:
223 self._access_token = None
224 storage = self._get_storage()
225 credentials = storage.get()
226 if credentials:
227 credentials.revoke(httplib2.Http())
228 revoked = True
229 storage.delete()
230 return revoked
231
232 def has_cached_credentials(self):
Vadim Sh. 2015/04/09 06:21:59 I plan to use it to die fast in 'git cl upload' if
233 """Returns True if long term credentials are in cache.
234
235 Doesn't make network calls.
236
237 If returns False, get_access_token() later will ask for interactive login by
238 raising LoginRequiredError.
239
240 If returns True, most probably get_access_token() won't ask for interactive
241 login, though it is not guaranteed, since cached token can be already
242 revoked and there's no way to figure this out without actually trying to use
243 it.
244 """
245 with self._lock:
246 if not self._access_token:
247 self._access_token = self._load_access_token()
248 return bool(self._access_token)
249
250 def get_access_token(self, force_refresh=False, allow_user_interaction=False):
251 """Returns AccessToken, refreshing it if necessary.
252
253 Args:
254 force_refresh: forcefully refresh access token even if it is not expired.
255 allow_user_interaction: True to enable blocking for user input if needed.
256
257 Raises:
258 AuthenticationError on error or if authentication flow was interrupted.
259 LoginRequiredError if user interaction is required, but
260 allow_user_interaction is False.
261 """
262 with self._lock:
263 if force_refresh:
264 self._access_token = self._create_access_token(allow_user_interaction)
265 return self._access_token
266
267 # Load from on-disk cache on a first access.
268 if not self._access_token:
269 self._access_token = self._load_access_token()
270
271 # Refresh if expired or missing.
272 if not self._access_token or _needs_refresh(self._access_token):
273 # Maybe some other process already updated it, reload from the cache.
274 self._access_token = self._load_access_token()
275 # Nope, still expired, need to run the refresh flow.
276 if not self._access_token or _needs_refresh(self._access_token):
277 self._access_token = self._create_access_token(allow_user_interaction)
278
279 return self._access_token
280
281 def get_token_info(self):
282 """Returns a result of /oauth2/v2/tokeninfo call with token info."""
283 access_token = self.get_access_token()
284 http = httplib2.Http()
285 resp, content = httplib2.Http().request(
286 uri=(
M-A Ruel 2015/04/09 20:00:40 Extraneous () is not needed here
Vadim Sh. 2015/04/10 01:18:42 Done.
287 'https://www.googleapis.com/oauth2/v2/tokeninfo?%s' % (
288 urllib.urlencode({'access_token': access_token.token}))))
289 if resp.status == 200:
290 return json.loads(content)
291 raise AuthenticationError('Failed to fetch the token info: %r' % content)
292
293 def authorize(self, http):
Vadim Sh. 2015/04/09 06:21:59 Google APIs Client library likes Http2. This metho
294 """Monkey patches authentication logic of httplib2.Http instance.
295
296 The modified http.request method will add authentication headers to each
297 request and will refresh access_tokens when a 401 is received on a
298 request.
299
300 Args:
301 http: An instance of httplib2.Http.
302
303 Returns:
304 A modified instance of http that was passed in.
305 """
306 # Adapted from oauth2client.OAuth2Credentials.authorize.
307
308 request_orig = http.request
309
310 @functools.wraps(request_orig)
311 def new_request(
312 uri, method='GET', body=None, headers=None,
313 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
314 connection_type=None):
315 headers = (headers or {}).copy()
316 headers['Authorizaton'] = 'Bearer %s' % self.get_access_token().token
317 resp, content = request_orig(
318 uri, method, body, headers, redirections, connection_type)
319 if resp.status in client.REFRESH_STATUS_CODES:
320 logger.info('Refreshing due to a %s', resp.status)
321 access_token = self.get_access_token(force_refresh=True)
322 headers['Authorizaton'] = 'Bearer %s' % access_token.token
323 return request_orig(
324 uri, method, body, headers, redirections, connection_type)
325 else:
326 return (resp, content)
327
328 http.request = new_request
329 return http
330
331 ## Private methods.
332
333 def _get_storage(self):
334 """Returns oauth2client.Storage with cached tokens."""
335 return multistore_file.get_credential_storage_custom_string_key(
Vadim Sh. 2015/04/09 06:21:59 I vaguely remember mutlistore_file to blow up on W
M-A Ruel 2015/04/09 20:00:40 I still have a Windows box, I can test on it.
Vadim Sh. 2015/04/10 01:18:42 Acknowledged.
336 self._config.tokens_cache, self._token_cache_key)
337
338 def _load_access_token(self):
339 """Returns cached AccessToken if it is not expired yet."""
340 credentials = self._get_storage().get()
341 if not credentials or credentials.invalid:
342 return None
343 if not credentials.access_token or credentials.access_token_expired:
344 return None
345 return AccessToken(credentials.access_token, credentials.token_expiry)
346
347 def _create_access_token(self, allow_user_interaction=False):
348 """Mints and caches a new access token, launching OAuth2 dance if necessary.
349
350 Uses cached refresh token, if present. In that case user interaction is not
351 required and function will finish quietly. Otherwise it will launch 3-legged
352 OAuth2 flow, that needs user interaction.
353
354 Args:
355 allow_user_interaction: if True, allow interaction with the user (e.g.
356 reading standard input, or launching a browser).
357
358 Returns:
359 AccessToken.
360
361 Raises:
362 AuthenticationError on error or if authentication flow was interrupted.
363 LoginRequiredError if user interaction is required, but
364 allow_user_interaction is False.
365 """
366 storage = self._get_storage()
367 credentials = None
Vadim Sh. 2015/04/09 06:21:59 will eventually add service accounts support here
368
369 # 3-legged flow with (perhaps cached) refresh token.
370 credentials = storage.get()
371 refreshed = False
372 if credentials and not credentials.invalid:
373 try:
374 credentials.refresh(httplib2.Http())
375 refreshed = True
376 except client.Error as err:
377 logging.error(
M-A Ruel 2015/04/09 20:00:40 warning?
Vadim Sh. 2015/04/10 01:18:42 Done.
378 'OAuth error during access token refresh: %s. Using full flow.',
379 err)
380
381 # Refresh token is missing or invalid, go through the full flow.
382 if not refreshed:
383 if not allow_user_interaction:
384 raise LoginRequiredError(self._token_cache_key)
385 credentials = _run_oauth_dance(self._config)
386
387 logging.info('OAuth access_token refreshed. Expires in %s.',
388 credentials.token_expiry - datetime.datetime.utcnow())
389 credentials.set_store(storage)
390 storage.put(credentials)
391 return AccessToken(credentials.access_token, credentials.token_expiry)
392
393
394 ## Private functions.
395
396
397 def _needs_refresh(access_token):
398 """True if AccessToken should be refreshed."""
399 if access_token.expires_at is not None:
400 # Allow 5 min of clock skew between client and backend.
401 now = datetime.datetime.utcnow() + datetime.timedelta(seconds=300)
402 return now >= access_token.expires_at
403 # Token without expiration time never expires.
404 return False
405
406
407 def _run_oauth_dance(config):
408 """Perform full 3-legged OAuth2 flow with the browser.
409
410 Returns:
411 oauth2client.Credentials.
412
413 Raises:
414 AuthenticationError on errors.
415 """
416 flow = client.OAuth2WebServerFlow(
417 OAUTH_CLIENT_ID,
418 OAUTH_CLIENT_SECRET,
419 OAUTH_SCOPES,
420 approval_prompt='force')
421
422 use_local_webserver = config.use_local_webserver
423 port = config.webserver_port
424 if config.use_local_webserver:
425 success = False
426 try:
427 httpd = _ClientRedirectServer(('localhost', port), _ClientRedirectHandler)
428 except socket.error:
429 pass
430 else:
431 success = True
432 use_local_webserver = success
433 if not success:
434 print(
435 'Failed to start a local webserver listening on port %d.\n'
436 'Please check your firewall settings and locally running programs that '
437 'may be blocking or using those ports.\n\n'
438 'Falling back to --auth-no-local-webserver and continuing with '
439 'authentication.\n' % port)
440
441 if use_local_webserver:
442 oauth_callback = 'http://localhost:%s/' % port
443 else:
444 oauth_callback = client.OOB_CALLBACK_URN
445 flow.redirect_uri = oauth_callback
446 authorize_url = flow.step1_get_authorize_url()
447
448 if use_local_webserver:
449 webbrowser.open(authorize_url, new=1, autoraise=True)
450 print(
451 'Your browser has been opened to visit:\n\n'
452 ' %s\n\n'
453 'If your browser is on a different machine then exit and re-run this '
454 'application with the command-line parameter\n\n'
455 ' --auth-no-local-webserver\n' % authorize_url)
456 else:
457 print(
458 'Go to the following link in your browser:\n\n'
459 ' %s\n' % authorize_url)
460
461 try:
462 code = None
463 if use_local_webserver:
464 httpd.handle_request()
465 if 'error' in httpd.query_params:
466 raise AuthenticationError(
467 'Authentication request was rejected: %s' %
468 httpd.query_params['error'])
469 if 'code' not in httpd.query_params:
470 raise AuthenticationError(
471 'Failed to find "code" in the query parameters of the redirect.\n'
472 'Try running with --auth-no-local-webserver.')
473 code = httpd.query_params['code']
474 else:
475 code = raw_input('Enter verification code: ').strip()
476 except KeyboardInterrupt:
477 raise AuthenticationError('Authentication was canceled.')
478
479 try:
480 return flow.step2_exchange(code)
481 except client.FlowExchangeError as e:
482 raise AuthenticationError('Authentication has failed: %s' % e)
483
484
485 class _ClientRedirectServer(BaseHTTPServer.HTTPServer):
486 """A server to handle OAuth 2.0 redirects back to localhost.
487
488 Waits for a single request and parses the query parameters
489 into query_params and then stops serving.
490 """
491 query_params = {}
492
493
494 class _ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler):
495 """A handler for OAuth 2.0 redirects back to localhost.
496
497 Waits for a single request and parses the query parameters
498 into the servers query_params and then stops serving.
499 """
500
501 def do_GET(self):
502 """Handle a GET request.
503
504 Parses the query parameters and prints a message
505 if the flow has completed. Note that we can't detect
506 if an error occurred.
507 """
508 self.send_response(200)
509 self.send_header('Content-type', 'text/html')
510 self.end_headers()
511 query = self.path.split('?', 1)[-1]
512 query = dict(urlparse.parse_qsl(query))
513 self.server.query_params = query
514 self.wfile.write('<html><head><title>Authentication Status</title></head>')
515 self.wfile.write('<body><p>The authentication flow has completed.</p>')
516 self.wfile.write('</body></html>')
517
518 def log_message(self, _format, *args):
519 """Do not log messages to stdout while running as command line program."""
OLDNEW
« no previous file with comments | « no previous file | depot-tools-auth » ('j') | depot-tools-auth.py » ('J')

Powered by Google App Engine
This is Rietveld 408576698