From eed4df3d918939b2b2d8db21014c31fe2efe2494 Mon Sep 17 00:00:00 2001 From: "vadimsh@chromium.org" Date: Fri, 10 Apr 2015 21:30:20 +0000 Subject: [PATCH] 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 '. 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 --- .gitignore | 4 + auth.py | 482 +++++++++++++++++++- depot-tools-auth | 8 + depot-tools-auth.bat | 11 + depot-tools-auth.py | 102 +++++ git_cl.py | 11 + oauth2.py | 103 ----- tests/git_cl_test.py | 16 +- third_party/oauth2client/MODIFICATIONS.diff | 34 +- third_party/oauth2client/locked_file.py | 2 +- third_party/oauth2client/multistore_file.py | 6 +- third_party/upload.py | 251 ++-------- 12 files changed, 683 insertions(+), 347 deletions(-) create mode 100755 depot-tools-auth create mode 100644 depot-tools-auth.bat create mode 100755 depot-tools-auth.py delete mode 100644 oauth2.py diff --git a/.gitignore b/.gitignore index 4db19bfbb..5061e26a7 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 97520deca..789db6a4f 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)://[/] 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('Authentication Status') + self.wfile.write('

The authentication flow has completed.

') + self.wfile.write('') + + 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 000000000..9233c92ed --- /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 000000000..fe13f9338 --- /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 000000000..3ebc239d8 --- /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('') +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('') +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('') +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 5484713c5..d4486e5b1 100755 --- a/git_cl.py +++ b/git_cl.py @@ -2062,6 +2062,15 @@ def CMDupload(parser, args): 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 @@ def main(argv): 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 08f0abf29..000000000 --- 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 b7d41f7e5..ed1d7a563 100755 --- a/tests/git_cl_test.py +++ b/tests/git_cl_test.py @@ -66,6 +66,13 @@ class CodereviewSettingsFileMock(object): "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 @@ class TestGitCl(TestCase): 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 @@ class TestGitCl(TestCase): ((['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 @@ class TestGitCl(TestCase): '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 @@ class TestGitCl(TestCase): 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 @@ class TestGitCl(TestCase): ((['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 @@ class TestGitCl(TestCase): @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 2dfb0dae6..7490d914b 100644 --- a/third_party/oauth2client/MODIFICATIONS.diff +++ b/third_party/oauth2client/MODIFICATIONS.diff @@ -14,7 +14,7 @@ index 4e8e616..6901f3f 100644 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 @@ index 4e8e616..6901f3f 100644 +from . import GOOGLE_TOKEN_URI +from . import util +from .anyjson import simplejson - + HAS_OPENSSL = False HAS_CRYPTO = False try: @@ -34,3 +34,33 @@ index 4e8e616..6901f3f 100644 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 31514dcf3..858b70281 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 ce7a5194f..ea89027f2 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/upload.py b/third_party/upload.py index ca8c7b3db..142789f8e 100755 --- a/third_party/upload.py +++ b/third_party/upload.py @@ -34,7 +34,6 @@ against by using the '--rev' option. # 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 sys import urllib import urllib2 import urlparse -import webbrowser from multiprocessing.pool import ThreadPool @@ -126,48 +124,6 @@ for vcs in VCS: 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 = """\ - - - Authentication Status - - - -

The authentication flow has completed.

- - -""" -# 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 @@ class AbstractRpcServer(object): 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 @@ class AbstractRpcServer(object): # 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 @@ class AbstractRpcServer(object): 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 AbstractRpcServer(object): 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 @@ group.add_option("--p4_user", action="store", dest="p4_user", 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 @@ class KeyringCreds(object): 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 @@ def GetRpcServer(server, auth_config=None, email=None): 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 @@ def GetRpcServer(server, auth_config=None, email=None): 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 @@ def main(): print StatusUpdate("Interrupted.") sys.exit(1) + except auth.AuthenticationError as e: + print >> sys.stderr, e + sys.exit(1) if __name__ == "__main__":