Add OAuth2 support for end users (i.e. 3-legged flow with the browser).
This CL introduces new top level command for managing cached auth tokens:
$ depot-tools-auth login codereview.chromium.org
$ depot-tools-auth info codereview.chromium.org
$ depot-tools-auth logout codereview.chromium.org
All scripts that use rietveld.Rietveld internally should be able to use cached
credentials created by 'depot-tools-auth' subcommand. Also 'depot-tools-auth'
is the only way to run login flow. If some scripts stumbles over expired or
revoked token, it dies with the error, asking user to run
'depot-tools-auth login <hostname>'.
Password login is still default. OAuth2 can be enabled by passing --oauth2 to
all scripts.
R=maruel@chromium.org
BUG=356813
Review URL: https://codereview.chromium.org/1074673002
git-svn-id: svn://svn.chromium.org/chrome/trunk/tools/depot_tools@294764 0039d316-1c4b-4281-b951-d872f2087c98
diff --git a/.gitignore b/.gitignore
index 4db19bf..5061e26 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,3 +46,7 @@
/tests/subversion_config/servers
/tests/svn/
/tests/svnrepo/
+
+# Ignore "flag file" used by auth.py.
+# TODO(vadimsh): Remove this once OAuth2 is default.
+/USE_OAUTH2
diff --git a/auth.py b/auth.py
index 97520de..789db6a 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 @@
])
+# 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 @@
"""
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 @@
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 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."""
diff --git a/depot-tools-auth b/depot-tools-auth
new file mode 100755
index 0000000..9233c92
--- /dev/null
+++ b/depot-tools-auth
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+# 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.
+
+base_dir=$(dirname "$0")
+
+PYTHONDONTWRITEBYTECODE=1 exec python "$base_dir/depot-tools-auth.py" "$@"
diff --git a/depot-tools-auth.bat b/depot-tools-auth.bat
new file mode 100644
index 0000000..fe13f93
--- /dev/null
+++ b/depot-tools-auth.bat
@@ -0,0 +1,11 @@
+@echo off
+:: 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.
+setlocal
+
+:: This is required with cygwin only.
+PATH=%~dp0;%PATH%
+
+:: Defer control.
+%~dp0python "%~dp0\depot-tools-auth.py" %*
diff --git a/depot-tools-auth.py b/depot-tools-auth.py
new file mode 100755
index 0000000..3ebc239
--- /dev/null
+++ b/depot-tools-auth.py
@@ -0,0 +1,102 @@
+#!/usr/bin/env python
+# 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.
+
+"""Manages cached OAuth2 tokens used by other depot_tools scripts.
+
+Usage:
+ depot-tools-auth login codereview.chromium.org
+ depot-tools-auth info codereview.chromium.org
+ depot-tools-auth logout codereview.chromium.org
+"""
+
+import logging
+import optparse
+import sys
+
+from third_party import colorama
+
+import auth
+import subcommand
+
+__version__ = '1.0'
+
+
+@subcommand.usage('<hostname>')
+def CMDlogin(parser, args):
+ """Performs interactive login and caches authentication token."""
+ # Forcefully relogin, revoking previous token.
+ hostname, authenticator = parser.parse_args(args)
+ authenticator.logout()
+ authenticator.login()
+ print_token_info(hostname, authenticator)
+ return 0
+
+
+@subcommand.usage('<hostname>')
+def CMDlogout(parser, args):
+ """Revokes cached authentication token and removes it from disk."""
+ _, authenticator = parser.parse_args(args)
+ done = authenticator.logout()
+ print 'Done.' if done else 'Already logged out.'
+ return 0
+
+
+@subcommand.usage('<hostname>')
+def CMDinfo(parser, args):
+ """Shows email associated with a cached authentication token."""
+ # If no token is cached, AuthenticationError will be caught in 'main'.
+ hostname, authenticator = parser.parse_args(args)
+ print_token_info(hostname, authenticator)
+ return 0
+
+
+def print_token_info(hostname, authenticator):
+ token_info = authenticator.get_token_info()
+ print 'Logged in to %s as %s.' % (hostname, token_info['email'])
+ print ''
+ print 'To login with a different email run:'
+ print ' depot-tools-auth login %s' % hostname
+ print 'To logout and purge the authentication token run:'
+ print ' depot-tools-auth logout %s' % hostname
+
+
+class OptionParser(optparse.OptionParser):
+ def __init__(self, *args, **kwargs):
+ optparse.OptionParser.__init__(
+ self, *args, prog='depot-tools-auth', version=__version__, **kwargs)
+ self.add_option(
+ '-v', '--verbose', action='count', default=0,
+ help='Use 2 times for more debugging info')
+ auth.add_auth_options(self, auth.make_auth_config(use_oauth2=True))
+
+ def parse_args(self, args=None, values=None):
+ """Parses options and returns (hostname, auth.Authenticator object)."""
+ options, args = optparse.OptionParser.parse_args(self, args, values)
+ levels = [logging.WARNING, logging.INFO, logging.DEBUG]
+ logging.basicConfig(level=levels[min(options.verbose, len(levels) - 1)])
+ auth_config = auth.extract_auth_config_from_options(options)
+ if len(args) != 1:
+ self.error('Expecting single argument (hostname).')
+ if not auth_config.use_oauth2:
+ self.error('This command is only usable with OAuth2 authentication')
+ return args[0], auth.get_authenticator_for_host(args[0], auth_config)
+
+
+def main(argv):
+ dispatcher = subcommand.CommandDispatcher(__name__)
+ try:
+ return dispatcher.execute(OptionParser(), argv)
+ except auth.AuthenticationError as e:
+ print >> sys.stderr, e
+ return 1
+
+
+if __name__ == '__main__':
+ colorama.init()
+ try:
+ sys.exit(main(sys.argv[1:]))
+ except KeyboardInterrupt:
+ sys.stderr.write('interrupted\n')
+ sys.exit(1)
diff --git a/git_cl.py b/git_cl.py
index 5484713..d4486e5 100755
--- a/git_cl.py
+++ b/git_cl.py
@@ -2062,6 +2062,15 @@
base_branch = cl.GetCommonAncestorWithUpstream()
args = [base_branch, 'HEAD']
+ # Make sure authenticated to Rietveld before running expensive hooks. It is
+ # a fast, best efforts check. Rietveld still can reject the authentication
+ # during the actual upload.
+ if not settings.GetIsGerrit() and auth_config.use_oauth2:
+ authenticator = auth.get_authenticator_for_host(
+ cl.GetRietveldServer(), auth_config)
+ if not authenticator.has_cached_credentials():
+ raise auth.LoginRequiredError(cl.GetRietveldServer())
+
# Apply watchlists on upload.
change = cl.GetChange(base_branch, None)
watchlist = watchlists.Watchlists(change.RepositoryRoot())
@@ -3179,6 +3188,8 @@
dispatcher = subcommand.CommandDispatcher(__name__)
try:
return dispatcher.execute(OptionParser(), argv)
+ except auth.AuthenticationError as e:
+ DieWithError(str(e))
except urllib2.HTTPError, e:
if e.code != 500:
raise
diff --git a/oauth2.py b/oauth2.py
deleted file mode 100644
index 08f0abf..0000000
--- a/oauth2.py
+++ /dev/null
@@ -1,103 +0,0 @@
-# Copyright (c) 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.
-
-"""OAuth2 related utilities and implementation for git cl commands."""
-
-import copy
-import logging
-import optparse
-import os
-
-from third_party.oauth2client import tools
-from third_party.oauth2client.file import Storage
-import third_party.oauth2client.client as oa2client
-
-
-REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob'
-CLIENT_ID = ('174799409470-8k3b89iov4racu9jrf7if3k4591voig3'
- '.apps.googleusercontent.com')
-CLIENT_SECRET = 'DddcCK1d6_ADwxqGDEGlsisy'
-SCOPE = 'email'
-
-
-def _fetch_storage(code_review_server):
- storage_dir = os.path.expanduser(os.path.join('~', '.git_cl_credentials'))
- if not os.path.isdir(storage_dir):
- os.makedirs(storage_dir)
- storage_path = os.path.join(storage_dir, code_review_server)
- storage = Storage(storage_path)
- return storage
-
-
-def _fetch_creds_from_storage(storage):
- logging.debug('Fetching OAuth2 credentials from local storage ...')
- credentials = storage.get()
- if not credentials or credentials.invalid:
- return None
- if not credentials.access_token or credentials.access_token_expired:
- return None
- return credentials
-
-
-def add_oauth2_options(parser):
- """Add OAuth2-related options."""
- group = optparse.OptionGroup(parser, "OAuth2 options")
- group.add_option(
- '--auth-host-name',
- default='localhost',
- help='Host name to use when running a local web server '
- 'to handle redirects during OAuth authorization.'
- 'Default: localhost.'
- )
- group.add_option(
- '--auth-host-port',
- type=int,
- action='append',
- default=[8080, 8090],
- help='Port to use when running a local web server to handle '
- 'redirects during OAuth authorization. '
- 'Repeat this option to specify a list of values.'
- 'Default: [8080, 8090].'
- )
- group.add_option(
- '--noauth-local-webserver',
- action='store_true',
- default=False,
- help='Run a local web server to handle redirects '
- 'during OAuth authorization.'
- 'Default: False.'
- )
- group.add_option(
- '--no-cache',
- action='store_true',
- default=False,
- help='Get fresh credentials from web server instead of using '
- 'the crendentials stored on a local storage file.'
- 'Default: False.'
- )
- parser.add_option_group(group)
-
-
-def get_oauth2_creds(options, code_review_server):
- """Get OAuth2 credentials.
-
- Args:
- options: Command line options.
- code_review_server: Code review server name, e.g., codereview.chromium.org.
- """
- storage = _fetch_storage(code_review_server)
- creds = None
- if not options.no_cache:
- creds = _fetch_creds_from_storage(storage)
- if creds is None:
- logging.debug('Fetching OAuth2 credentials from web server...')
- flow = oa2client.OAuth2WebServerFlow(
- client_id=CLIENT_ID,
- client_secret=CLIENT_SECRET,
- scope=SCOPE,
- redirect_uri=REDIRECT_URI)
- flags = copy.deepcopy(options)
- flags.logging_level = 'WARNING'
- creds = tools.run_flow(flow, storage, flags)
- return creds
diff --git a/tests/git_cl_test.py b/tests/git_cl_test.py
index b7d41f7..ed1d7a5 100755
--- a/tests/git_cl_test.py
+++ b/tests/git_cl_test.py
@@ -66,6 +66,13 @@
"GERRIT_PORT: 29418\n")
+class AuthenticatorMock(object):
+ def __init__(self, *_args):
+ pass
+ def has_cached_credentials(self):
+ return True
+
+
class TestGitCl(TestCase):
def setUp(self):
super(TestGitCl, self).setUp()
@@ -88,6 +95,7 @@
self.mock(git_cl.rietveld, 'CachingRietveld', RietveldMock)
self.mock(git_cl.upload, 'RealMain', self.fail)
self.mock(git_cl.watchlists, 'Watchlists', WatchlistsMock)
+ self.mock(git_cl.auth, 'get_authenticator_for_host', AuthenticatorMock)
# It's important to reset settings to not have inter-tests interference.
git_cl.settings = None
@@ -161,13 +169,14 @@
((['git', 'config', 'branch.master.remote'],), 'origin'),
((['get_or_create_merge_base', 'master', 'master'],),
'fake_ancestor_sha'),
+ ((['git', 'config', 'gerrit.host'],), ''),
+ ((['git', 'config', 'branch.master.rietveldissue'],), ''),
] + cls._git_sanity_checks('fake_ancestor_sha', 'master') + [
((['git', 'rev-parse', '--show-cdup'],), ''),
((['git', 'rev-parse', 'HEAD'],), '12345'),
((['git', 'diff', '--name-status', '--no-renames', '-r',
'fake_ancestor_sha...', '.'],),
'M\t.gitignore\n'),
- ((['git', 'config', 'branch.master.rietveldissue'],), ''),
((['git', 'config', 'branch.master.rietveldpatchset'],),
''),
((['git', 'log', '--pretty=format:%s%n%n%b',
@@ -175,7 +184,6 @@
'foo'),
((['git', 'config', 'user.email'],), 'me@example.com'),
stat_call,
- ((['git', 'config', 'gerrit.host'],), ''),
((['git', 'log', '--pretty=format:%s\n\n%b',
'fake_ancestor_sha..HEAD'],),
'desc\n'),
@@ -361,7 +369,6 @@
return [
'upload', '--assume_yes', '--server',
'https://codereview.example.com',
- '--no-oauth2', '--auth-host-port', '8090',
'--message', description
] + args + [
'--cc', 'joe@example.com',
@@ -546,6 +553,7 @@
((['git', 'config', 'branch.master.remote'],), 'origin'),
((['get_or_create_merge_base', 'master', 'master'],),
'fake_ancestor_sha'),
+ ((['git', 'config', 'gerrit.host'],), 'gerrit.example.com'),
] + cls._git_sanity_checks('fake_ancestor_sha', 'master') + [
((['git', 'rev-parse', '--show-cdup'],), ''),
((['git', 'rev-parse', 'HEAD'],), '12345'),
@@ -569,8 +577,6 @@
@staticmethod
def _gerrit_upload_calls(description, reviewers, squash):
calls = [
- ((['git', 'config', 'gerrit.host'],),
- 'gerrit.example.com'),
((['git', 'log', '--pretty=format:%s\n\n%b',
'fake_ancestor_sha..HEAD'],),
description)
diff --git a/third_party/oauth2client/MODIFICATIONS.diff b/third_party/oauth2client/MODIFICATIONS.diff
index 2dfb0da..7490d91 100644
--- a/third_party/oauth2client/MODIFICATIONS.diff
+++ b/third_party/oauth2client/MODIFICATIONS.diff
@@ -14,7 +14,7 @@
import time
import urllib
import urlparse
-
+
-from oauth2client import GOOGLE_AUTH_URI
-from oauth2client import GOOGLE_REVOKE_URI
-from oauth2client import GOOGLE_TOKEN_URI
@@ -25,7 +25,7 @@
+from . import GOOGLE_TOKEN_URI
+from . import util
+from .anyjson import simplejson
-
+
HAS_OPENSSL = False
HAS_CRYPTO = False
try:
@@ -34,3 +34,33 @@
HAS_CRYPTO = True
if crypt.OpenSSLVerifier is not None:
HAS_OPENSSL = True
+diff --git a/third_party/oauth2client/locked_file.py b/third_party/oauth2client/locked_file.py
+index 31514dc..858b702 100644
+--- a/third_party/oauth2client/locked_file.py
++++ b/third_party/oauth2client/locked_file.py
+@@ -35,7 +35,7 @@ import logging
+ import os
+ import time
+
+-from oauth2client import util
++from . import util
+
+ logger = logging.getLogger(__name__)
+
+diff --git a/third_party/oauth2client/multistore_file.py b/third_party/oauth2client/multistore_file.py
+index ce7a519..ea89027 100644
+--- a/third_party/oauth2client/multistore_file.py
++++ b/third_party/oauth2client/multistore_file.py
+@@ -50,9 +50,9 @@ import os
+ import threading
+
+ from anyjson import simplejson
+-from oauth2client.client import Storage as BaseStorage
+-from oauth2client.client import Credentials
+-from oauth2client import util
++from .client import Storage as BaseStorage
++from .client import Credentials
++from . import util
+ from locked_file import LockedFile
+
+ logger = logging.getLogger(__name__)
diff --git a/third_party/oauth2client/locked_file.py b/third_party/oauth2client/locked_file.py
index 31514dc..858b702 100644
--- a/third_party/oauth2client/locked_file.py
+++ b/third_party/oauth2client/locked_file.py
@@ -35,7 +35,7 @@
import os
import time
-from oauth2client import util
+from . import util
logger = logging.getLogger(__name__)
diff --git a/third_party/oauth2client/multistore_file.py b/third_party/oauth2client/multistore_file.py
index ce7a519..ea89027 100644
--- a/third_party/oauth2client/multistore_file.py
+++ b/third_party/oauth2client/multistore_file.py
@@ -50,9 +50,9 @@
import threading
from anyjson import simplejson
-from oauth2client.client import Storage as BaseStorage
-from oauth2client.client import Credentials
-from oauth2client import util
+from .client import Storage as BaseStorage
+from .client import Credentials
+from . import util
from locked_file import LockedFile
logger = logging.getLogger(__name__)
diff --git a/third_party/upload.py b/third_party/upload.py
index ca8c7b3..142789f 100755
--- a/third_party/upload.py
+++ b/third_party/upload.py
@@ -34,7 +34,6 @@
# This code is derived from appcfg.py in the App Engine SDK (open source),
# and from ASPN recipe #146306.
-import BaseHTTPServer
import ConfigParser
import cookielib
import errno
@@ -52,7 +51,6 @@
import urllib
import urllib2
import urlparse
-import webbrowser
from multiprocessing.pool import ThreadPool
@@ -126,48 +124,6 @@
VCS_ABBREVIATIONS.update((alias, vcs['name']) for alias in vcs['aliases'])
-# OAuth 2.0-Related Constants
-LOCALHOST_IP = '127.0.0.1'
-DEFAULT_OAUTH2_PORT = 8001
-ACCESS_TOKEN_PARAM = 'access_token'
-ERROR_PARAM = 'error'
-OAUTH_DEFAULT_ERROR_MESSAGE = 'OAuth 2.0 error occurred.'
-OAUTH_PATH = '/get-access-token'
-OAUTH_PATH_PORT_TEMPLATE = OAUTH_PATH + '?port=%(port)d'
-AUTH_HANDLER_RESPONSE = """\
-<html>
- <head>
- <title>Authentication Status</title>
- <script>
- window.onload = function() {
- window.close();
- }
- </script>
- </head>
- <body>
- <p>The authentication flow has completed.</p>
- </body>
-</html>
-"""
-# Borrowed from google-api-python-client
-OPEN_LOCAL_MESSAGE_TEMPLATE = """\
-Your browser has been opened to visit:
-
- %s
-
-If your browser is on a different machine then exit and re-run
-upload.py with the command-line parameter
-
- --no_oauth2_webbrowser
-"""
-NO_OPEN_LOCAL_MESSAGE_TEMPLATE = """\
-Go to the following link in your browser:
-
- %s
-
-and copy the access token.
-"""
-
# The result of parsing Subversion's [auto-props] setting.
svn_auto_props_map = None
@@ -361,7 +317,7 @@
response.headers, response.fp)
self.authenticated = True
- def _Authenticate(self):
+ def _Authenticate(self, force_refresh):
"""Authenticates the user.
The authentication process works as follows:
@@ -466,10 +422,11 @@
# TODO: Don't require authentication. Let the server say
# whether it is necessary.
if not self.authenticated and self.auth_function:
- self._Authenticate()
+ self._Authenticate(force_refresh=False)
old_timeout = socket.getdefaulttimeout()
socket.setdefaulttimeout(timeout)
+ auth_attempted = False
try:
tries = 0
while True:
@@ -491,10 +448,16 @@
except urllib2.HTTPError, e:
if tries > 3:
raise
- elif e.code == 401 or e.code == 302:
+ elif e.code in (302, 401, 403):
if not self.auth_function:
raise
- self._Authenticate()
+ # Already tried force refresh, didn't help -> give up with error.
+ if auth_attempted:
+ raise auth.AuthenticationError(
+ 'Access to %s is denied (server returned HTTP %d).'
+ % (self.host, e.code))
+ self._Authenticate(force_refresh=True)
+ auth_attempted = True
elif e.code == 301:
# Handle permanent redirect manually.
url = e.info()["location"]
@@ -513,15 +476,22 @@
class HttpRpcServer(AbstractRpcServer):
"""Provides a simplified RPC-style interface for HTTP requests."""
- def _Authenticate(self):
+ def _Authenticate(self, force_refresh):
"""Save the cookie jar after authentication."""
- if isinstance(self.auth_function, OAuth2Creds):
- access_token = self.auth_function()
- if access_token is not None:
- self.extra_headers['Authorization'] = 'OAuth %s' % (access_token,)
- self.authenticated = True
+ if isinstance(self.auth_function, auth.Authenticator):
+ try:
+ access_token = self.auth_function.get_access_token(force_refresh)
+ except auth.LoginRequiredError:
+ # Attempt to make unauthenticated request first if there's no cached
+ # credentials. HttpRpcServer calls __Authenticate(force_refresh=True)
+ # again if unauthenticated request doesn't work.
+ if not force_refresh:
+ return
+ raise
+ self.extra_headers['Authorization'] = 'Bearer %s' % (
+ access_token.token,)
else:
- super(HttpRpcServer, self)._Authenticate()
+ super(HttpRpcServer, self)._Authenticate(force_refresh)
if self.save_cookies:
StatusUpdate("Saving authentication cookies to %s" % self.cookie_file)
self.cookie_jar.save()
@@ -714,150 +684,6 @@
help=("Perforce user"))
-# OAuth 2.0 Methods and Helpers
-class ClientRedirectServer(BaseHTTPServer.HTTPServer):
- """A server for redirects back to localhost from the associated server.
-
- Waits for a single request and parses the query parameters for an access token
- or an error and then stops serving.
- """
- access_token = None
- error = None
-
-
-class ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler):
- """A handler for redirects back to localhost from the associated server.
-
- Waits for a single request and parses the query parameters into the server's
- access_token or error and then stops serving.
- """
-
- def SetResponseValue(self):
- """Stores the access token or error from the request on the server.
-
- Will only do this if exactly one query parameter was passed in to the
- request and that query parameter used 'access_token' or 'error' as the key.
- """
- query_string = urlparse.urlparse(self.path).query
- query_params = urlparse.parse_qs(query_string)
-
- if len(query_params) == 1:
- if query_params.has_key(ACCESS_TOKEN_PARAM):
- access_token_list = query_params[ACCESS_TOKEN_PARAM]
- if len(access_token_list) == 1:
- self.server.access_token = access_token_list[0]
- else:
- error_list = query_params.get(ERROR_PARAM, [])
- if len(error_list) == 1:
- self.server.error = error_list[0]
-
- def do_GET(self):
- """Handle a GET request.
-
- Parses and saves the query parameters and prints a message that the server
- has completed its lone task (handling a redirect).
-
- Note that we can't detect if an error occurred.
- """
- self.send_response(200)
- self.send_header('Content-type', 'text/html')
- self.end_headers()
- self.SetResponseValue()
- self.wfile.write(AUTH_HANDLER_RESPONSE)
-
- def log_message(self, format, *args):
- """Do not log messages to stdout while running as command line program."""
- pass
-
-
-def OpenOAuth2ConsentPage(server=DEFAULT_REVIEW_SERVER,
- port=DEFAULT_OAUTH2_PORT):
- """Opens the OAuth 2.0 consent page or prints instructions how to.
-
- Uses the webbrowser module to open the OAuth server side page in a browser.
-
- Args:
- server: String containing the review server URL. Defaults to
- DEFAULT_REVIEW_SERVER.
- port: Integer, the port where the localhost server receiving the redirect
- is serving. Defaults to DEFAULT_OAUTH2_PORT.
-
- Returns:
- A boolean indicating whether the page opened successfully.
- """
- path = OAUTH_PATH_PORT_TEMPLATE % {'port': port}
- parsed_url = urlparse.urlparse(server)
- scheme = parsed_url[0] or 'https'
- if scheme != 'https':
- ErrorExit('Using OAuth requires a review server with SSL enabled.')
- # If no scheme was given on command line the server address ends up in
- # parsed_url.path otherwise in netloc.
- host = parsed_url[1] or parsed_url[2]
- page = '%s://%s%s' % (scheme, host, path)
- page_opened = webbrowser.open(page, new=1, autoraise=True)
- if page_opened:
- print OPEN_LOCAL_MESSAGE_TEMPLATE % (page,)
- return page_opened
-
-
-def WaitForAccessToken(port=DEFAULT_OAUTH2_PORT):
- """Spins up a simple HTTP Server to handle a single request.
-
- Intended to handle a single redirect from the production server after the
- user authenticated via OAuth 2.0 with the server.
-
- Args:
- port: Integer, the port where the localhost server receiving the redirect
- is serving. Defaults to DEFAULT_OAUTH2_PORT.
-
- Returns:
- The access token passed to the localhost server, or None if no access token
- was passed.
- """
- httpd = ClientRedirectServer((LOCALHOST_IP, port), ClientRedirectHandler)
- # Wait to serve just one request before deferring control back
- # to the caller of wait_for_refresh_token
- httpd.handle_request()
- if httpd.access_token is None:
- ErrorExit(httpd.error or OAUTH_DEFAULT_ERROR_MESSAGE)
- return httpd.access_token
-
-
-def GetAccessToken(server=DEFAULT_REVIEW_SERVER, port=DEFAULT_OAUTH2_PORT,
- open_local_webbrowser=True):
- """Gets an Access Token for the current user.
-
- Args:
- server: String containing the review server URL. Defaults to
- DEFAULT_REVIEW_SERVER.
- port: Integer, the port where the localhost server receiving the redirect
- is serving. Defaults to DEFAULT_OAUTH2_PORT.
- open_local_webbrowser: Boolean, defaults to True. If set, opens a page in
- the user's browser.
-
- Returns:
- A string access token that was sent to the local server. If the serving page
- via WaitForAccessToken does not receive an access token, this method
- returns None.
- """
- access_token = None
- if open_local_webbrowser:
- page_opened = OpenOAuth2ConsentPage(server=server, port=port)
- if page_opened:
- try:
- access_token = WaitForAccessToken(port=port)
- except socket.error, e:
- print 'Can\'t start local webserver. Socket Error: %s\n' % (e.strerror,)
-
- if access_token is None:
- # TODO(dhermes): Offer to add to clipboard using xsel, xclip, pbcopy, etc.
- page = 'https://%s%s' % (server, OAUTH_PATH)
- print NO_OPEN_LOCAL_MESSAGE_TEMPLATE % (page,)
- access_token = raw_input('Enter access token: ').strip()
-
- return access_token
-
-
class KeyringCreds(object):
def __init__(self, server, host, email):
self.server = server
@@ -903,20 +729,6 @@
return (email, password)
-class OAuth2Creds(object):
- """Simple object to hold server and port to be passed to GetAccessToken."""
-
- def __init__(self, server, port, open_local_webbrowser=True):
- self.server = server
- self.port = port
- self.open_local_webbrowser = open_local_webbrowser
-
- def __call__(self):
- """Uses stored server and port to retrieve OAuth 2.0 access token."""
- return GetAccessToken(server=self.server, port=self.port,
- open_local_webbrowser=self.open_local_webbrowser)
-
-
def GetRpcServer(server, auth_config=None, email=None):
"""Returns an instance of an AbstractRpcServer.
@@ -934,9 +746,6 @@
if email == '' or not auth_config:
return HttpRpcServer(server, None)
- if auth_config.use_oauth2:
- raise NotImplementedError('See https://crbug.com/356813')
-
# If this is the dev_appserver, use fake authentication.
host = server.lower()
if re.match(r'(http://)?localhost([:/]|$)', host):
@@ -954,9 +763,14 @@
server.authenticated = True
return server
+ if auth_config.use_oauth2:
+ auth_func = auth.get_authenticator_for_host(server, auth_config)
+ else:
+ auth_func = KeyringCreds(server, host, email).GetUserCredentials
+
return HttpRpcServer(
server,
- KeyringCreds(server, host, email).GetUserCredentials,
+ auth_func,
save_cookies=auth_config.save_cookies,
account_type=AUTH_ACCOUNT_TYPE)
@@ -2713,6 +2527,9 @@
print
StatusUpdate("Interrupted.")
sys.exit(1)
+ except auth.AuthenticationError as e:
+ print >> sys.stderr, e
+ sys.exit(1)
if __name__ == "__main__":