Index: auth.py |
diff --git a/auth.py b/auth.py |
index 97520deca3cdf4f47300fa694194a7ae2dc2b031..789db6a4f45670e9a3b5cb28cab26de1a8f6d364 100644 |
--- a/auth.py |
+++ b/auth.py |
@@ -1,11 +1,58 @@ |
-# Copyright (c) 2015 The Chromium Authors. All rights reserved. |
+# Copyright 2015 The Chromium Authors. All rights reserved. |
# Use of this source code is governed by a BSD-style license that can be |
# found in the LICENSE file. |
-"""Authentication related functions.""" |
+"""Google OAuth2 related functions.""" |
+import BaseHTTPServer |
import collections |
+import datetime |
+import functools |
+import json |
+import logging |
import optparse |
+import os |
+import socket |
+import sys |
+import threading |
+import urllib |
+import urlparse |
+import webbrowser |
+ |
+from third_party import httplib2 |
+from third_party.oauth2client import client |
+from third_party.oauth2client import multistore_file |
+ |
+ |
+# depot_tools/. |
+DEPOT_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) |
+ |
+ |
+# Google OAuth2 clients always have a secret, even if the client is an installed |
+# application/utility such as this. Of course, in such cases the "secret" is |
+# actually publicly known; security depends entirely on the secrecy of refresh |
+# tokens, which effectively become bearer tokens. An attacker can impersonate |
+# service's identity in OAuth2 flow. But that's generally fine as long as a list |
+# of allowed redirect_uri's associated with client_id is limited to 'localhost' |
+# or 'urn:ietf:wg:oauth:2.0:oob'. In that case attacker needs some process |
+# running on user's machine to successfully complete the flow and grab refresh |
+# token. When you have a malicious code running on your machine, you're screwed |
+# anyway. |
+# This particular set is managed by API Console project "chrome-infra-auth". |
+OAUTH_CLIENT_ID = ( |
+ '446450136466-2hr92jrq8e6i4tnsa56b52vacp7t3936.apps.googleusercontent.com') |
+OAUTH_CLIENT_SECRET = 'uBfbay2KCy9t4QveJ-dOqHtp' |
+ |
+# List of space separated OAuth scopes for generated tokens. GAE apps usually |
+# use userinfo.email scope for authentication. |
+OAUTH_SCOPES = 'https://www.googleapis.com/auth/userinfo.email' |
+ |
+# Path to a file with cached OAuth2 credentials used by default. It should be |
+# a safe location accessible only to a current user: knowing content of this |
+# file is roughly equivalent to knowing account password. Single file can hold |
+# multiple independent tokens identified by token_cache_key (see Authenticator). |
+OAUTH_TOKENS_CACHE = os.path.join( |
+ os.path.expanduser('~'), '.depot_tools_oauth2_tokens') |
# Authentication configuration extracted from command line options. |
@@ -18,6 +65,28 @@ AuthConfig = collections.namedtuple('AuthConfig', [ |
]) |
+# OAuth access token with its expiration time (UTC datetime or None if unknown). |
+AccessToken = collections.namedtuple('AccessToken', [ |
+ 'token', |
+ 'expires_at', |
+]) |
+ |
+ |
+class AuthenticationError(Exception): |
+ """Raised on errors related to authentication.""" |
+ |
+ |
+class LoginRequiredError(AuthenticationError): |
+ """Interaction with the user is required to authenticate.""" |
+ |
+ def __init__(self, token_cache_key): |
+ # HACK(vadimsh): It is assumed here that the token cache key is a hostname. |
+ msg = ( |
+ 'You are not logged in. Please login first by running:\n' |
+ ' depot-tools-auth login %s' % token_cache_key) |
+ super(LoginRequiredError, self).__init__(msg) |
+ |
+ |
def make_auth_config( |
use_oauth2=None, |
save_cookies=None, |
@@ -31,34 +100,42 @@ def make_auth_config( |
""" |
default = lambda val, d: val if val is not None else d |
return AuthConfig( |
- default(use_oauth2, False), |
+ default(use_oauth2, _should_use_oauth2()), |
default(save_cookies, True), |
- default(use_local_webserver, True), |
+ default(use_local_webserver, not _is_headless()), |
default(webserver_port, 8090)) |
-def add_auth_options(parser): |
+def add_auth_options(parser, default_config=None): |
"""Appends OAuth related options to OptionParser.""" |
- default_config = make_auth_config() |
+ default_config = default_config or make_auth_config() |
parser.auth_group = optparse.OptionGroup(parser, 'Auth options') |
+ parser.add_option_group(parser.auth_group) |
+ |
+ # OAuth2 vs password switch. |
+ auth_default = 'use OAuth2' if default_config.use_oauth2 else 'use password' |
parser.auth_group.add_option( |
'--oauth2', |
action='store_true', |
dest='use_oauth2', |
default=default_config.use_oauth2, |
- help='Use OAuth 2.0 instead of a password.') |
+ help='Use OAuth 2.0 instead of a password. [default: %s]' % auth_default) |
parser.auth_group.add_option( |
'--no-oauth2', |
action='store_false', |
dest='use_oauth2', |
default=default_config.use_oauth2, |
- help='Use password instead of OAuth 2.0.') |
+ help='Use password instead of OAuth 2.0. [default: %s]' % auth_default) |
+ |
+ # Password related options, deprecated. |
parser.auth_group.add_option( |
'--no-cookies', |
action='store_false', |
dest='save_cookies', |
default=default_config.save_cookies, |
help='Do not save authentication cookies to local disk.') |
+ |
+ # OAuth2 related options. |
parser.auth_group.add_option( |
'--auth-no-local-webserver', |
action='store_false', |
@@ -71,7 +148,6 @@ def add_auth_options(parser): |
default=default_config.webserver_port, |
help='Port a local web server should listen on. Used only if ' |
'--auth-no-local-webserver is not set. [default: %default]') |
- parser.add_option_group(parser.auth_group) |
def extract_auth_config_from_options(options): |
@@ -87,13 +163,387 @@ def extract_auth_config_from_options(options): |
def auth_config_to_command_options(auth_config): |
- """AuthConfig -> list of strings with command line options.""" |
+ """AuthConfig -> list of strings with command line options. |
+ |
+ Omits options that are set to default values. |
+ """ |
if not auth_config: |
return [] |
- opts = ['--oauth2' if auth_config.use_oauth2 else '--no-oauth2'] |
- if not auth_config.save_cookies: |
- opts.append('--no-cookies') |
- if not auth_config.use_local_webserver: |
- opts.append('--auth-no-local-webserver') |
- opts.extend(['--auth-host-port', str(auth_config.webserver_port)]) |
+ defaults = make_auth_config() |
+ opts = [] |
+ if auth_config.use_oauth2 != defaults.use_oauth2: |
+ opts.append('--oauth2' if auth_config.use_oauth2 else '--no-oauth2') |
+ if auth_config.save_cookies != auth_config.save_cookies: |
+ if not auth_config.save_cookies: |
+ opts.append('--no-cookies') |
+ if auth_config.use_local_webserver != defaults.use_local_webserver: |
+ if not auth_config.use_local_webserver: |
+ opts.append('--auth-no-local-webserver') |
+ if auth_config.webserver_port != defaults.webserver_port: |
+ opts.extend(['--auth-host-port', str(auth_config.webserver_port)]) |
return opts |
+ |
+ |
+def get_authenticator_for_host(hostname, config): |
+ """Returns Authenticator instance to access given host. |
+ |
+ Args: |
+ hostname: a naked hostname or http(s)://<hostname>[/] URL. Used to derive |
+ a cache key for token cache. |
+ config: AuthConfig instance. |
+ |
+ Returns: |
+ Authenticator object. |
+ """ |
+ hostname = hostname.lower().rstrip('/') |
+ # Append some scheme, otherwise urlparse puts hostname into parsed.path. |
+ if '://' not in hostname: |
+ hostname = 'https://' + hostname |
+ parsed = urlparse.urlparse(hostname) |
+ if parsed.path or parsed.params or parsed.query or parsed.fragment: |
+ raise AuthenticationError( |
+ 'Expecting a hostname or root host URL, got %s instead' % hostname) |
+ return Authenticator(parsed.netloc, config) |
+ |
+ |
+class Authenticator(object): |
+ """Object that knows how to refresh access tokens when needed. |
+ |
+ Args: |
+ token_cache_key: string key of a section of the token cache file to use |
+ to keep the tokens. See hostname_to_token_cache_key. |
+ config: AuthConfig object that holds authentication configuration. |
+ """ |
+ |
+ def __init__(self, token_cache_key, config): |
+ assert isinstance(config, AuthConfig) |
+ assert config.use_oauth2 |
+ self._access_token = None |
+ self._config = config |
+ self._lock = threading.Lock() |
+ self._token_cache_key = token_cache_key |
+ |
+ def login(self): |
+ """Performs interactive login flow if necessary. |
+ |
+ Raises: |
+ AuthenticationError on error or if interrupted. |
+ """ |
+ return self.get_access_token( |
+ force_refresh=True, allow_user_interaction=True) |
+ |
+ def logout(self): |
+ """Revokes the refresh token and deletes it from the cache. |
+ |
+ Returns True if actually revoked a token. |
+ """ |
+ revoked = False |
+ with self._lock: |
+ self._access_token = None |
+ storage = self._get_storage() |
+ credentials = storage.get() |
+ if credentials: |
+ credentials.revoke(httplib2.Http()) |
+ revoked = True |
+ storage.delete() |
+ return revoked |
+ |
+ def has_cached_credentials(self): |
+ """Returns True if long term credentials (refresh token) are in cache. |
+ |
+ Doesn't make network calls. |
+ |
+ If returns False, get_access_token() later will ask for interactive login by |
+ raising LoginRequiredError. |
+ |
+ If returns True, most probably get_access_token() won't ask for interactive |
+ login, though it is not guaranteed, since cached token can be already |
+ revoked and there's no way to figure this out without actually trying to use |
+ it. |
+ """ |
+ with self._lock: |
+ credentials = self._get_storage().get() |
+ return credentials and not credentials.invalid |
+ |
+ def get_access_token(self, force_refresh=False, allow_user_interaction=False): |
+ """Returns AccessToken, refreshing it if necessary. |
+ |
+ Args: |
+ force_refresh: forcefully refresh access token even if it is not expired. |
+ allow_user_interaction: True to enable blocking for user input if needed. |
+ |
+ Raises: |
+ AuthenticationError on error or if authentication flow was interrupted. |
+ LoginRequiredError if user interaction is required, but |
+ allow_user_interaction is False. |
+ """ |
+ with self._lock: |
+ if force_refresh: |
+ self._access_token = self._create_access_token(allow_user_interaction) |
+ return self._access_token |
+ |
+ # Load from on-disk cache on a first access. |
+ if not self._access_token: |
+ self._access_token = self._load_access_token() |
+ |
+ # Refresh if expired or missing. |
+ if not self._access_token or _needs_refresh(self._access_token): |
+ # Maybe some other process already updated it, reload from the cache. |
+ self._access_token = self._load_access_token() |
+ # Nope, still expired, need to run the refresh flow. |
+ if not self._access_token or _needs_refresh(self._access_token): |
+ self._access_token = self._create_access_token(allow_user_interaction) |
+ |
+ return self._access_token |
+ |
+ def get_token_info(self): |
+ """Returns a result of /oauth2/v2/tokeninfo call with token info.""" |
+ access_token = self.get_access_token() |
+ resp, content = httplib2.Http().request( |
+ uri='https://www.googleapis.com/oauth2/v2/tokeninfo?%s' % ( |
+ urllib.urlencode({'access_token': access_token.token}))) |
+ if resp.status == 200: |
+ return json.loads(content) |
+ raise AuthenticationError('Failed to fetch the token info: %r' % content) |
+ |
+ def authorize(self, http): |
+ """Monkey patches authentication logic of httplib2.Http instance. |
+ |
+ The modified http.request method will add authentication headers to each |
+ request and will refresh access_tokens when a 401 is received on a |
+ request. |
+ |
+ Args: |
+ http: An instance of httplib2.Http. |
+ |
+ Returns: |
+ A modified instance of http that was passed in. |
+ """ |
+ # Adapted from oauth2client.OAuth2Credentials.authorize. |
+ |
+ request_orig = http.request |
+ |
+ @functools.wraps(request_orig) |
+ def new_request( |
+ uri, method='GET', body=None, headers=None, |
+ redirections=httplib2.DEFAULT_MAX_REDIRECTS, |
+ connection_type=None): |
+ headers = (headers or {}).copy() |
+ headers['Authorizaton'] = 'Bearer %s' % self.get_access_token().token |
+ resp, content = request_orig( |
+ uri, method, body, headers, redirections, connection_type) |
+ if resp.status in client.REFRESH_STATUS_CODES: |
+ logging.info('Refreshing due to a %s', resp.status) |
+ access_token = self.get_access_token(force_refresh=True) |
+ headers['Authorizaton'] = 'Bearer %s' % access_token.token |
+ return request_orig( |
+ uri, method, body, headers, redirections, connection_type) |
+ else: |
+ return (resp, content) |
+ |
+ http.request = new_request |
+ return http |
+ |
+ ## Private methods. |
+ |
+ def _get_storage(self): |
+ """Returns oauth2client.Storage with cached tokens.""" |
+ return multistore_file.get_credential_storage_custom_string_key( |
+ OAUTH_TOKENS_CACHE, self._token_cache_key) |
+ |
+ def _load_access_token(self): |
+ """Returns cached AccessToken if it is not expired yet.""" |
+ credentials = self._get_storage().get() |
+ if not credentials or credentials.invalid: |
+ return None |
+ if not credentials.access_token or credentials.access_token_expired: |
+ return None |
+ return AccessToken(credentials.access_token, credentials.token_expiry) |
+ |
+ def _create_access_token(self, allow_user_interaction=False): |
+ """Mints and caches a new access token, launching OAuth2 dance if necessary. |
+ |
+ Uses cached refresh token, if present. In that case user interaction is not |
+ required and function will finish quietly. Otherwise it will launch 3-legged |
+ OAuth2 flow, that needs user interaction. |
+ |
+ Args: |
+ allow_user_interaction: if True, allow interaction with the user (e.g. |
+ reading standard input, or launching a browser). |
+ |
+ Returns: |
+ AccessToken. |
+ |
+ Raises: |
+ AuthenticationError on error or if authentication flow was interrupted. |
+ LoginRequiredError if user interaction is required, but |
+ allow_user_interaction is False. |
+ """ |
+ storage = self._get_storage() |
+ credentials = None |
+ |
+ # 3-legged flow with (perhaps cached) refresh token. |
+ credentials = storage.get() |
+ refreshed = False |
+ if credentials and not credentials.invalid: |
+ try: |
+ credentials.refresh(httplib2.Http()) |
+ refreshed = True |
+ except client.Error as err: |
+ logging.warning( |
+ 'OAuth error during access token refresh: %s. ' |
+ 'Attempting a full authentication flow.', err) |
+ |
+ # Refresh token is missing or invalid, go through the full flow. |
+ if not refreshed: |
+ if not allow_user_interaction: |
+ raise LoginRequiredError(self._token_cache_key) |
+ credentials = _run_oauth_dance(self._config) |
+ |
+ logging.info( |
+ 'OAuth access_token refreshed. Expires in %s.', |
+ credentials.token_expiry - datetime.datetime.utcnow()) |
+ credentials.set_store(storage) |
+ storage.put(credentials) |
+ return AccessToken(credentials.access_token, credentials.token_expiry) |
+ |
+ |
+## Private functions. |
+ |
+ |
+def _should_use_oauth2(): |
+ """Default value for use_oauth2 config option. |
+ |
+ Used to selectively enable OAuth2 by default. |
+ """ |
+ return os.path.exists(os.path.join(DEPOT_TOOLS_DIR, 'USE_OAUTH2')) |
+ |
+ |
+def _is_headless(): |
+ """True if machine doesn't seem to have a display.""" |
+ return sys.platform == 'linux2' and not os.environ.get('DISPLAY') |
+ |
+ |
+def _needs_refresh(access_token): |
+ """True if AccessToken should be refreshed.""" |
+ if access_token.expires_at is not None: |
+ # Allow 5 min of clock skew between client and backend. |
+ now = datetime.datetime.utcnow() + datetime.timedelta(seconds=300) |
+ return now >= access_token.expires_at |
+ # Token without expiration time never expires. |
+ return False |
+ |
+ |
+def _run_oauth_dance(config): |
+ """Perform full 3-legged OAuth2 flow with the browser. |
+ |
+ Returns: |
+ oauth2client.Credentials. |
+ |
+ Raises: |
+ AuthenticationError on errors. |
+ """ |
+ flow = client.OAuth2WebServerFlow( |
+ OAUTH_CLIENT_ID, |
+ OAUTH_CLIENT_SECRET, |
+ OAUTH_SCOPES, |
+ approval_prompt='force') |
+ |
+ use_local_webserver = config.use_local_webserver |
+ port = config.webserver_port |
+ if config.use_local_webserver: |
+ success = False |
+ try: |
+ httpd = _ClientRedirectServer(('localhost', port), _ClientRedirectHandler) |
+ except socket.error: |
+ pass |
+ else: |
+ success = True |
+ use_local_webserver = success |
+ if not success: |
+ print( |
+ 'Failed to start a local webserver listening on port %d.\n' |
+ 'Please check your firewall settings and locally running programs that ' |
+ 'may be blocking or using those ports.\n\n' |
+ 'Falling back to --auth-no-local-webserver and continuing with ' |
+ 'authentication.\n' % port) |
+ |
+ if use_local_webserver: |
+ oauth_callback = 'http://localhost:%s/' % port |
+ else: |
+ oauth_callback = client.OOB_CALLBACK_URN |
+ flow.redirect_uri = oauth_callback |
+ authorize_url = flow.step1_get_authorize_url() |
+ |
+ if use_local_webserver: |
+ webbrowser.open(authorize_url, new=1, autoraise=True) |
+ print( |
+ 'Your browser has been opened to visit:\n\n' |
+ ' %s\n\n' |
+ 'If your browser is on a different machine then exit and re-run this ' |
+ 'application with the command-line parameter\n\n' |
+ ' --auth-no-local-webserver\n' % authorize_url) |
+ else: |
+ print( |
+ 'Go to the following link in your browser:\n\n' |
+ ' %s\n' % authorize_url) |
+ |
+ try: |
+ code = None |
+ if use_local_webserver: |
+ httpd.handle_request() |
+ if 'error' in httpd.query_params: |
+ raise AuthenticationError( |
+ 'Authentication request was rejected: %s' % |
+ httpd.query_params['error']) |
+ if 'code' not in httpd.query_params: |
+ raise AuthenticationError( |
+ 'Failed to find "code" in the query parameters of the redirect.\n' |
+ 'Try running with --auth-no-local-webserver.') |
+ code = httpd.query_params['code'] |
+ else: |
+ code = raw_input('Enter verification code: ').strip() |
+ except KeyboardInterrupt: |
+ raise AuthenticationError('Authentication was canceled.') |
+ |
+ try: |
+ return flow.step2_exchange(code) |
+ except client.FlowExchangeError as e: |
+ raise AuthenticationError('Authentication has failed: %s' % e) |
+ |
+ |
+class _ClientRedirectServer(BaseHTTPServer.HTTPServer): |
+ """A server to handle OAuth 2.0 redirects back to localhost. |
+ |
+ Waits for a single request and parses the query parameters |
+ into query_params and then stops serving. |
+ """ |
+ query_params = {} |
+ |
+ |
+class _ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler): |
+ """A handler for OAuth 2.0 redirects back to localhost. |
+ |
+ Waits for a single request and parses the query parameters |
+ into the servers query_params and then stops serving. |
+ """ |
+ |
+ def do_GET(self): |
+ """Handle a GET request. |
+ |
+ Parses the query parameters and prints a message |
+ if the flow has completed. Note that we can't detect |
+ if an error occurred. |
+ """ |
+ self.send_response(200) |
+ self.send_header('Content-type', 'text/html') |
+ self.end_headers() |
+ query = self.path.split('?', 1)[-1] |
+ query = dict(urlparse.parse_qsl(query)) |
+ self.server.query_params = query |
+ self.wfile.write('<html><head><title>Authentication Status</title></head>') |
+ self.wfile.write('<body><p>The authentication flow has completed.</p>') |
+ self.wfile.write('</body></html>') |
+ |
+ def log_message(self, _format, *args): |
+ """Do not log messages to stdout while running as command line program.""" |