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

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

Powered by Google App Engine
This is Rietveld 408576698