Commit eed4df3d authored by vadimsh@chromium.org's avatar vadimsh@chromium.org

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
parent cf6a5d20
......@@ -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
This diff is collapsed.
#!/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" "$@"
@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" %*
#!/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
......
# 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__)
......
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment