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