| Index: third_party/upload.py
|
| diff --git a/third_party/upload.py b/third_party/upload.py
|
| index c7899c70a93dbe87f48f20286434e1910e2d773a..895c8f59442e260f9944c5ed8e77f37347e385e8 100755
|
| --- a/third_party/upload.py
|
| +++ b/third_party/upload.py
|
| @@ -34,6 +34,7 @@ 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
|
| @@ -51,6 +52,7 @@ import sys
|
| import urllib
|
| import urllib2
|
| import urlparse
|
| +import webbrowser
|
|
|
| # The md5 module was deprecated in Python 2.5.
|
| try:
|
| @@ -106,6 +108,41 @@ VCS_ABBREVIATIONS = {
|
| VCS_CVS.lower(): VCS_CVS,
|
| }
|
|
|
| +# OAuth 2.0-Related Constants
|
| +LOCALHOST_IP = '127.0.0.1'
|
| +DEFAULT_OAUTH2_PORT = 8001
|
| +ACCESS_TOKEN_PARAM = 'access_token'
|
| +OAUTH_PATH = '/get-access-token'
|
| +OAUTH_PATH_PORT_TEMPLATE = OAUTH_PATH + '?port=%(port)d'
|
| +AUTH_HANDLER_RESPONSE = """\
|
| +<html>
|
| + <head>
|
| + <title>Authentication Status</title>
|
| + </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
|
|
|
| @@ -179,8 +216,9 @@ class ClientLoginError(urllib2.HTTPError):
|
| class AbstractRpcServer(object):
|
| """Provides a common interface for a simple RPC server."""
|
|
|
| - def __init__(self, host, auth_function, host_override=None, extra_headers={},
|
| - save_cookies=False, account_type=AUTH_ACCOUNT_TYPE):
|
| + def __init__(self, host, auth_function, host_override=None,
|
| + extra_headers=None, save_cookies=False,
|
| + account_type=AUTH_ACCOUNT_TYPE):
|
| """Creates a new AbstractRpcServer.
|
|
|
| Args:
|
| @@ -203,7 +241,7 @@ class AbstractRpcServer(object):
|
| self.host_override = host_override
|
| self.auth_function = auth_function
|
| self.authenticated = False
|
| - self.extra_headers = extra_headers
|
| + self.extra_headers = extra_headers or {}
|
| self.save_cookies = save_cookies
|
| self.account_type = account_type
|
| self.opener = self._GetOpener()
|
| @@ -425,10 +463,16 @@ class HttpRpcServer(AbstractRpcServer):
|
|
|
| def _Authenticate(self):
|
| """Save the cookie jar after authentication."""
|
| - super(HttpRpcServer, self)._Authenticate()
|
| - if self.save_cookies:
|
| - StatusUpdate("Saving authentication cookies to %s" % self.cookie_file)
|
| - self.cookie_jar.save()
|
| + 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
|
| + else:
|
| + super(HttpRpcServer, self)._Authenticate()
|
| + if self.save_cookies:
|
| + StatusUpdate("Saving authentication cookies to %s" % self.cookie_file)
|
| + self.cookie_jar.save()
|
|
|
| def _GetOpener(self):
|
| """Returns an OpenerDirector that supports cookies and ignores redirects.
|
| @@ -495,7 +539,8 @@ class CondensedHelpFormatter(optparse.IndentedHelpFormatter):
|
|
|
|
|
| parser = optparse.OptionParser(
|
| - usage="%prog [options] [-- diff_options] [path...]",
|
| + usage=("%prog [options] [-- diff_options] [path...]\n"
|
| + "See also: http://code.google.com/p/rietveld/wiki/UploadPyUsage"),
|
| add_help_option=False,
|
| formatter=CondensedHelpFormatter()
|
| )
|
| @@ -531,6 +576,17 @@ group.add_option("-H", "--host", action="store", dest="host",
|
| group.add_option("--no_cookies", action="store_false",
|
| dest="save_cookies", default=True,
|
| help="Do not save authentication cookies to local disk.")
|
| +group.add_option("--oauth2", action="store_true",
|
| + dest="use_oauth2", default=False,
|
| + help="Use OAuth 2.0 instead of a password.")
|
| +group.add_option("--oauth2_port", action="store", type="int",
|
| + dest="oauth2_port", default=DEFAULT_OAUTH2_PORT,
|
| + help=("Port to use to handle OAuth 2.0 redirect. Must be an "
|
| + "integer in the range 1024-49151, defaults to "
|
| + "'%default'."))
|
| +group.add_option("--no_oauth2_webbrowser", action="store_false",
|
| + dest="open_oauth2_local_webbrowser", default=True,
|
| + help="Don't open a browser window to get an access token.")
|
| group.add_option("--account_type", action="store", dest="account_type",
|
| metavar="TYPE", default=AUTH_ACCOUNT_TYPE,
|
| choices=["GOOGLE", "HOSTED"],
|
| @@ -612,10 +668,137 @@ 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
|
| + and then stops serving.
|
| + """
|
| + access_token = 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 and then stops serving.
|
| + """
|
| +
|
| + def SetAccessToken(self):
|
| + """Stores the access token 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' as the key.
|
| + """
|
| + query_string = urlparse.urlparse(self.path).query
|
| + query_params = urlparse.parse_qs(query_string)
|
| +
|
| + if len(query_params) == 1:
|
| + access_token_list = query_params.get(ACCESS_TOKEN_PARAM, [])
|
| + if len(access_token_list) == 1:
|
| + self.server.access_token = access_token_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.SetAccessToken()
|
| + 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.
|
| + """
|
| + path = OAUTH_PATH_PORT_TEMPLATE % {'port': port}
|
| + page = 'https://%s%s' % (server, path)
|
| + webbrowser.open(page, new=1, autoraise=True)
|
| + print OPEN_LOCAL_MESSAGE_TEMPLATE % (page,)
|
| +
|
| +
|
| +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()
|
| + 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:
|
| + OpenOAuth2ConsentPage(server=server, port=port)
|
| + 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
|
| - self.host = host
|
| + # Explicitly cast host to str to work around bug in old versions of Keyring
|
| + # (versions before 0.10). Even though newer versions of Keyring fix this,
|
| + # some modern linuxes (such as Ubuntu 12.04) still bundle a version with
|
| + # the bug.
|
| + self.host = str(host)
|
| self.email = email
|
| self.accounts_seen = set()
|
|
|
| @@ -653,8 +836,24 @@ 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, email=None, host_override=None, save_cookies=True,
|
| - account_type=AUTH_ACCOUNT_TYPE):
|
| + account_type=AUTH_ACCOUNT_TYPE, use_oauth2=False,
|
| + oauth2_port=DEFAULT_OAUTH2_PORT,
|
| + open_oauth2_local_webbrowser=True):
|
| """Returns an instance of an AbstractRpcServer.
|
|
|
| Args:
|
| @@ -665,11 +864,16 @@ def GetRpcServer(server, email=None, host_override=None, save_cookies=True,
|
| save_cookies: Whether authentication cookies should be saved to disk.
|
| account_type: Account type for authentication, either 'GOOGLE'
|
| or 'HOSTED'. Defaults to AUTH_ACCOUNT_TYPE.
|
| + use_oauth2: Boolean indicating whether OAuth 2.0 should be used for
|
| + authentication.
|
| + oauth2_port: Integer, the port where the localhost server receiving the
|
| + redirect is serving. Defaults to DEFAULT_OAUTH2_PORT.
|
| + open_oauth2_local_webbrowser: Boolean, defaults to True. If True and using
|
| + OAuth, this opens a page in the user's browser to obtain a token.
|
|
|
| Returns:
|
| A new HttpRpcServer, on which RPC calls can be made.
|
| """
|
| -
|
| # If this is the dev_appserver, use fake authentication.
|
| host = (host_override or server).lower()
|
| if re.match(r'(http://)?localhost([:/]|$)', host):
|
| @@ -688,10 +892,16 @@ def GetRpcServer(server, email=None, host_override=None, save_cookies=True,
|
| server.authenticated = True
|
| return server
|
|
|
| - return HttpRpcServer(server,
|
| - KeyringCreds(server, host, email).GetUserCredentials,
|
| + positional_args = [server]
|
| + if use_oauth2:
|
| + positional_args.append(
|
| + OAuth2Creds(server, oauth2_port, open_oauth2_local_webbrowser))
|
| + else:
|
| + positional_args.append(KeyringCreds(server, host, email).GetUserCredentials)
|
| + return HttpRpcServer(*positional_args,
|
| host_override=host_override,
|
| - save_cookies=save_cookies)
|
| + save_cookies=save_cookies,
|
| + account_type=account_type)
|
|
|
|
|
| def EncodeMultipartFormData(fields, files):
|
| @@ -2209,7 +2419,11 @@ def RealMain(argv, data=None):
|
| if options.help:
|
| if options.verbose < 2:
|
| # hide Perforce options
|
| - parser.epilog = "Use '--help -v' to show additional Perforce options."
|
| + parser.epilog = (
|
| + "Use '--help -v' to show additional Perforce options. "
|
| + "For more help, see "
|
| + "http://code.google.com/p/rietveld/wiki/CodeReviewHelp"
|
| + )
|
| parser.option_groups.remove(parser.get_option_group('--p4_port'))
|
| parser.print_help()
|
| sys.exit(0)
|
| @@ -2250,11 +2464,16 @@ def RealMain(argv, data=None):
|
| files = vcs.GetBaseFiles(data)
|
| if verbosity >= 1:
|
| print "Upload server:", options.server, "(change with -s/--server)"
|
| + if options.use_oauth2:
|
| + options.save_cookies = False
|
| rpc_server = GetRpcServer(options.server,
|
| options.email,
|
| options.host,
|
| options.save_cookies,
|
| - options.account_type)
|
| + options.account_type,
|
| + options.use_oauth2,
|
| + options.oauth2_port,
|
| + options.open_oauth2_local_webbrowser)
|
| form_fields = []
|
|
|
| repo_guid = vcs.GetGUID()
|
|
|