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
changes/01/332501/1
vadimsh@chromium.org 10 years ago
parent cf6a5d2026
commit eed4df3d91

4
.gitignore vendored

@ -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

@ -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."""

@ -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" "$@"

@ -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" %*

@ -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)

@ -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

@ -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

@ -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)

@ -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__)

@ -35,7 +35,7 @@ import logging
import os
import time
from oauth2client import util
from . import util
logger = logging.getLogger(__name__)

@ -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__)

@ -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 = """\
<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 @@ 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__":

Loading…
Cancel
Save