3 # Copyright 2007 Google Inc.
5 # Licensed under the Apache License, Version 2.0 (the "License");
6 # you may not use this file except in compliance with the License.
7 # You may obtain a copy of the License at
9 # http://www.apache.org/licenses/LICENSE-2.0
11 # Unless required by applicable law or agreed to in writing, software
12 # distributed under the License is distributed on an "AS IS" BASIS,
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 # See the License for the specific language governing permissions and
15 # limitations under the License.
17 """Tool for performing authenticated RPCs against App Engine."""
36 from google
.appengine
.tools
import dev_appserver_login
38 _UPLOADING_APP_DOC_URLS
= {
39 "go": "https://developers.google.com/appengine/docs/go/tools/"
40 "uploadinganapp#Go_Password-less_login_with_OAuth2",
41 "php": "https://developers.google.com/appengine/docs/php/tools/"
42 "uploadinganapp#PHP_Password-less_login_with_OAuth2",
43 "python": "https://developers.google.com/appengine/docs/python/tools/"
44 "uploadinganapp#Python_Password-less_login_with_OAuth2",
45 "python27": "https://developers.google.com/appengine/docs/python/tools/"
46 "uploadinganapp#Python_Password-less_login_with_OAuth2",
47 "java": "https://developers.google.com/appengine/docs/java/tools/"
48 "uploadinganapp#Passwordless_Login_with_OAuth2",
49 "java7": "https://developers.google.com/appengine/docs/java/tools/"
50 "uploadinganapp#Passwordless_Login_with_OAuth2",
53 logger
= logging
.getLogger('google.appengine.tools.appengine_rpc')
55 def GetPlatformToken(os_module
=os
, sys_module
=sys
, platform
=sys
.platform
):
56 """Returns a 'User-agent' token for the host system platform.
59 os_module, sys_module, platform: Used for testing.
62 String containing the platform token for the host system.
64 if hasattr(sys_module
, "getwindowsversion"):
65 windows_version
= sys_module
.getwindowsversion()
66 version_info
= ".".join(str(i
) for i
in windows_version
[:4])
67 return platform
+ "/" + version_info
68 elif hasattr(os_module
, "uname"):
69 uname
= os_module
.uname()
70 return "%s/%s" % (uname
[0], uname
[2])
74 def HttpRequestToString(req
, include_data
=True):
75 """Converts a urllib2.Request to a string.
80 Multi-line string representing the request.
84 for header
in req
.header_items():
85 headers
+= "%s: %s\n" % (header
[0], header
[1])
87 template
= ("%(method)s %(selector)s %(type)s/1.1\n"
91 template
= template
+ "\n%(data)s"
94 'method': req
.get_method(),
95 'selector': req
.get_selector(),
96 'type': req
.get_type().upper(),
97 'host': req
.get_host(),
99 'data': req
.get_data(),
102 class ClientLoginError(urllib2
.HTTPError
):
103 """Raised to indicate there was an error authenticating with ClientLogin."""
105 def __init__(self
, url
, code
, msg
, headers
, args
):
106 urllib2
.HTTPError
.__init
__(self
, url
, code
, msg
, headers
, None)
108 self
._reason
= args
.get("Error")
109 self
.info
= args
.get("Info")
112 return '%d %s: %s' % (self
.code
, self
.msg
, self
.reason
)
121 class AbstractRpcServer(object):
122 """Provides a common interface for a simple RPC server."""
125 SUGGEST_OAUTH2
= False
130 def __init__(self
, host
, auth_function
, user_agent
, source
,
131 host_override
=None, extra_headers
=None, save_cookies
=False,
132 auth_tries
=3, account_type
=None, debug_data
=True, secure
=True,
133 ignore_certs
=False, rpc_tries
=3):
134 """Creates a new HttpRpcServer.
137 host: The host to send requests to.
138 auth_function: A function that takes no arguments and returns an
139 (email, password) tuple when called. Will be called if authentication
141 user_agent: The user-agent string to send to the server. Specify None to
142 omit the user-agent header.
143 source: The source to specify in authentication requests.
144 host_override: The host header to send to the server (defaults to host).
145 extra_headers: A dict of extra headers to append to every request. Values
146 supplied here will override other default headers that are supplied.
147 save_cookies: If True, save the authentication cookies to local disk.
148 If False, use an in-memory cookiejar instead. Subclasses must
149 implement this functionality. Defaults to False.
150 auth_tries: The number of times to attempt auth_function before failing.
151 account_type: One of GOOGLE, HOSTED_OR_GOOGLE, or None for automatic.
152 debug_data: Whether debugging output should include data contents.
153 secure: If the requests sent using Send should be sent over HTTPS.
154 ignore_certs: If the certificate mismatches should be ignored.
155 rpc_tries: The number of rpc retries upon http server error (i.e.
156 Response code >= 500 and < 600) before failing.
159 self
.scheme
= "https"
162 self
.ignore_certs
= ignore_certs
164 self
.host_override
= host_override
165 self
.auth_function
= auth_function
167 self
.authenticated
= False
168 self
.auth_tries
= auth_tries
169 self
.debug_data
= debug_data
170 self
.rpc_tries
= rpc_tries
173 self
.account_type
= account_type
175 self
.extra_headers
= {}
177 self
.extra_headers
["User-Agent"] = user_agent
179 self
.extra_headers
.update(extra_headers
)
181 self
.save_cookies
= save_cookies
183 self
.cookie_jar
= cookielib
.MozillaCookieJar()
184 self
.opener
= self
._GetOpener
()
185 if self
.host_override
:
186 logger
.debug("Server: %s; Host: %s", self
.host
, self
.host_override
)
188 logger
.debug("Server: %s", self
.host
)
191 if ((self
.host_override
and self
.host_override
== "localhost") or
192 self
.host
== "localhost" or self
.host
.startswith("localhost:")):
193 self
._DevAppServerAuthenticate
()
195 def _GetOpener(self
):
196 """Returns an OpenerDirector for making HTTP requests.
199 A urllib2.OpenerDirector object.
201 raise NotImplementedError
203 def _CreateRequest(self
, url
, data
=None):
204 """Creates a new urllib request."""
205 req
= fancy_urllib
.FancyRequest(url
, data
=data
)
206 if self
.host_override
:
207 req
.add_header("Host", self
.host_override
)
208 for key
, value
in self
.extra_headers
.iteritems():
209 req
.add_header(key
, value
)
212 def _GetAuthToken(self
, email
, password
):
213 """Uses ClientLogin to authenticate the user, returning an auth token.
216 email: The user's email address
217 password: The user's password
220 ClientLoginError: If there was an error authenticating with ClientLogin.
221 HTTPError: If there was some other form of HTTP error.
224 The authentication token returned by ClientLogin.
226 account_type
= self
.account_type
229 if (self
.host
.split(':')[0].endswith(".google.com")
230 or (self
.host_override
231 and self
.host_override
.split(':')[0].endswith(".google.com"))):
233 account_type
= "HOSTED_OR_GOOGLE"
235 account_type
= "GOOGLE"
240 "source": self
.source
,
241 "accountType": account_type
245 req
= self
._CreateRequest
(
246 url
=("https://%s/accounts/ClientLogin" %
247 os
.getenv("APPENGINE_AUTH_SERVER", "www.google.com")),
248 data
=urllib
.urlencode(data
))
250 response
= self
.opener
.open(req
)
251 response_body
= response
.read()
252 response_dict
= dict(x
.split("=")
253 for x
in response_body
.split("\n") if x
)
254 if os
.getenv("APPENGINE_RPC_USE_SID", "0") == "1":
255 self
.extra_headers
["Cookie"] = (
256 'SID=%s; Path=/;' % response_dict
["SID"])
257 return response_dict
["Auth"]
258 except urllib2
.HTTPError
, e
:
261 response_dict
= dict(x
.split("=", 1) for x
in body
.split("\n") if x
)
262 raise ClientLoginError(req
.get_full_url(), e
.code
, e
.msg
,
263 e
.headers
, response_dict
)
267 def _GetAuthCookie(self
, auth_token
):
268 """Fetches authentication cookies for an authentication token.
271 auth_token: The authentication token returned by ClientLogin.
274 HTTPError: If there was an error fetching the authentication cookies.
277 continue_location
= "http://localhost/"
278 args
= {"continue": continue_location
, "auth": auth_token
}
279 login_path
= os
.environ
.get("APPCFG_LOGIN_PATH", "/_ah")
280 req
= self
._CreateRequest
("%s://%s%s/login?%s" %
281 (self
.scheme
, self
.host
, login_path
,
282 urllib
.urlencode(args
)))
284 response
= self
.opener
.open(req
)
285 except urllib2
.HTTPError
, e
:
287 if (response
.code
!= 302 or
288 response
.info()["location"] != continue_location
):
289 raise urllib2
.HTTPError(req
.get_full_url(), response
.code
, response
.msg
,
290 response
.headers
, response
.fp
)
291 self
.authenticated
= True
293 def _Authenticate(self
):
294 """Authenticates the user.
296 The authentication process works as follows:
297 1) We get a username and password from the user
298 2) We use ClientLogin to obtain an AUTH token for the user
299 (see http://code.google.com/apis/accounts/AuthForInstalledApps.html).
300 3) We pass the auth token to /_ah/login on the server to obtain an
301 authentication cookie. If login was successful, it tries to redirect
302 us to the URL we provided.
304 If we attempt to access the upload API without first obtaining an
305 authentication cookie, it returns a 401 response and directs us to
306 authenticate ourselves with ClientLogin.
308 for unused_i
in range(self
.auth_tries
):
309 credentials
= self
.auth_function()
311 auth_token
= self
._GetAuthToken
(credentials
[0], credentials
[1])
312 if os
.getenv("APPENGINE_RPC_USE_SID", "0") == "1":
314 except ClientLoginError
, e
:
315 if e
.reason
== "BadAuthentication":
316 if e
.info
== "InvalidSecondFactor":
317 print >>sys
.stderr
, ("Use an application-specific password instead "
318 "of your regular account password.")
319 print >>sys
.stderr
, ("See http://www.google.com/"
320 "support/accounts/bin/answer.py?answer=185833")
324 if self
.SUGGEST_OAUTH2
:
325 print >>sys
.stderr
, ("However, now the recommended way to log in "
326 "is using OAuth2. See")
327 print >>sys
.stderr
, _UPLOADING_APP_DOC_URLS
[self
.RUNTIME
]
329 print >>sys
.stderr
, "Invalid username or password."
331 if e
.reason
== "CaptchaRequired":
332 print >>sys
.stderr
, (
334 "https://www.google.com/accounts/DisplayUnlockCaptcha\n"
335 "and verify you are a human. Then try again.")
337 if e
.reason
== "NotVerified":
338 print >>sys
.stderr
, "Account not verified."
340 if e
.reason
== "TermsNotAgreed":
341 print >>sys
.stderr
, "User has not agreed to TOS."
343 if e
.reason
== "AccountDeleted":
344 print >>sys
.stderr
, "The user account has been deleted."
346 if e
.reason
== "AccountDisabled":
347 print >>sys
.stderr
, "The user account has been disabled."
349 if e
.reason
== "ServiceDisabled":
350 print >>sys
.stderr
, ("The user's access to the service has been "
353 if e
.reason
== "ServiceUnavailable":
354 print >>sys
.stderr
, "The service is not available; try again later."
357 self
._GetAuthCookie
(auth_token
)
360 def _DevAppServerAuthenticate(self
):
361 """Authenticates the user on the dev_appserver."""
362 credentials
= self
.auth_function()
363 value
= dev_appserver_login
.CreateCookieData(credentials
[0], True)
364 self
.extra_headers
["Cookie"] = ('dev_appserver_login="%s"; Path=/;' % value
)
366 def Send(self
, request_path
, payload
="",
367 content_type
="application/octet-stream",
370 """Sends an RPC and returns the response.
373 request_path: The path to send the request to, eg /api/appversion/create.
374 payload: The body of the request, or None to send an empty request.
375 content_type: The Content-Type header to use.
376 timeout: timeout in seconds; default None i.e. no timeout.
377 (Note: for large requests on OS X, the timeout doesn't work right.)
378 kwargs: Any keyword arguments are converted into query string parameters.
381 The response body, as a string.
383 old_timeout
= socket
.getdefaulttimeout()
384 socket
.setdefaulttimeout(timeout
)
390 url
= "%s://%s%s" % (self
.scheme
, self
.host
, request_path
)
394 url
+= "?" + urllib
.urlencode(sorted(kwargs
.items()))
395 req
= self
._CreateRequest
(url
=url
, data
=payload
)
396 req
.add_header("Content-Type", content_type
)
400 req
.add_header("X-appcfg-api-version", "1")
403 logger
.debug('Sending %s request:\n%s',
405 HttpRequestToString(req
, include_data
=self
.debug_data
))
406 f
= self
.opener
.open(req
)
411 except urllib2
.HTTPError
, e
:
412 logger
.debug("Got http error, this is try #%s", tries
)
413 if tries
> self
.rpc_tries
:
421 elif e
.code
>= 500 and e
.code
< 600:
430 loc
= e
.info()["location"]
431 logger
.debug("Got 302 redirect. Location: %s", loc
)
432 if loc
.startswith("https://www.google.com/accounts/ServiceLogin"):
435 r
"https://www\.google\.com/a/[a-z0-9\.\-]+/ServiceLogin", loc
):
436 self
.account_type
= os
.getenv("APPENGINE_RPC_HOSTED_LOGIN_TYPE",
439 elif loc
.startswith("http://%s/_ah/login" % (self
.host
,)):
440 self
._DevAppServerAuthenticate
()
446 socket
.setdefaulttimeout(old_timeout
)
449 class ContentEncodingHandler(urllib2
.BaseHandler
):
450 """Request and handle HTTP Content-Encoding."""
451 def http_request(self
, request
):
453 request
.add_header("Accept-Encoding", "gzip")
466 for header
in request
.headers
:
467 if header
.lower() == "user-agent":
468 request
.headers
[header
] += " gzip"
472 https_request
= http_request
474 def http_response(self
, req
, resp
):
475 """Handle encodings in the order that they are encountered."""
477 headers
= resp
.headers
479 for header
in headers
:
480 if header
.lower() == "content-encoding":
481 for encoding
in headers
.get(header
, "").split(","):
482 encoding
= encoding
.strip()
484 encodings
.append(encoding
)
493 while encodings
and encodings
[-1].lower() == "gzip":
494 fp
= cStringIO
.StringIO(fp
.read())
495 fp
= gzip
.GzipFile(fileobj
=fp
, mode
="r")
503 headers
[header
] = ", ".join(encodings
)
504 logger
.warning("Unrecognized Content-Encoding: %s", encodings
[-1])
507 if sys
.version_info
>= (2, 6):
508 resp
= urllib2
.addinfourl(fp
, headers
, resp
.url
, resp
.code
)
510 response_code
= resp
.code
511 resp
= urllib2
.addinfourl(fp
, headers
, resp
.url
)
512 resp
.code
= response_code
517 https_response
= http_response
520 class HttpRpcServer(AbstractRpcServer
):
521 """Provides a simplified RPC-style interface for HTTP requests."""
523 DEFAULT_COOKIE_FILE_PATH
= "~/.appcfg_cookies"
525 def __init__(self
, *args
, **kwargs
):
526 self
.certpath
= os
.path
.normpath(os
.path
.join(
527 os
.path
.dirname(__file__
), '..', '..', '..', 'lib', 'cacerts',
529 self
.cert_file_available
= ((not kwargs
.get("ignore_certs", False))
530 and os
.path
.exists(self
.certpath
))
531 super(HttpRpcServer
, self
).__init
__(*args
, **kwargs
)
533 def _CreateRequest(self
, url
, data
=None):
534 """Creates a new urllib request."""
535 req
= super(HttpRpcServer
, self
)._CreateRequest
(url
, data
)
536 if self
.cert_file_available
and fancy_urllib
.can_validate_certs():
537 req
.set_ssl_info(ca_certs
=self
.certpath
)
540 def _CheckCookie(self
):
541 """Warn if cookie is not valid for at least one minute."""
542 min_expire
= time
.time() + 60
544 for cookie
in self
.cookie_jar
:
545 if cookie
.domain
== self
.host
and not cookie
.is_expired(min_expire
):
548 print >>sys
.stderr
, "\nError: Machine system clock is incorrect.\n"
551 def _Authenticate(self
):
552 """Save the cookie jar after authentication."""
553 if self
.cert_file_available
and not fancy_urllib
.can_validate_certs():
556 logger
.warn("""ssl module not found.
557 Without the ssl module, the identity of the remote host cannot be verified, and
558 connections may NOT be secure. To fix this, please install the ssl module from
559 http://pypi.python.org/pypi/ssl .
560 To learn more, see https://developers.google.com/appengine/kb/general#rpcssl""")
561 super(HttpRpcServer
, self
)._Authenticate
()
562 if self
.cookie_jar
.filename
is not None and self
.save_cookies
:
563 logger
.debug("Saving authentication cookies to %s",
564 self
.cookie_jar
.filename
)
565 self
.cookie_jar
.save()
568 def _GetOpener(self
):
569 """Returns an OpenerDirector that supports cookies and ignores redirects.
572 A urllib2.OpenerDirector object.
574 opener
= urllib2
.OpenerDirector()
575 opener
.add_handler(fancy_urllib
.FancyProxyHandler())
576 opener
.add_handler(urllib2
.UnknownHandler())
577 opener
.add_handler(urllib2
.HTTPHandler())
578 opener
.add_handler(urllib2
.HTTPDefaultErrorHandler())
579 opener
.add_handler(fancy_urllib
.FancyHTTPSHandler())
580 opener
.add_handler(urllib2
.HTTPErrorProcessor())
581 opener
.add_handler(ContentEncodingHandler())
583 if self
.save_cookies
:
584 self
.cookie_jar
.filename
= os
.path
.expanduser(
585 HttpRpcServer
.DEFAULT_COOKIE_FILE_PATH
)
587 if os
.path
.exists(self
.cookie_jar
.filename
):
589 self
.cookie_jar
.load()
590 self
.authenticated
= True
591 logger
.debug("Loaded authentication cookies from %s",
592 self
.cookie_jar
.filename
)
593 except (OSError, IOError, cookielib
.LoadError
), e
:
595 logger
.debug("Could not load authentication cookies; %s: %s",
596 e
.__class
__.__name
__, e
)
597 self
.cookie_jar
.filename
= None
602 fd
= os
.open(self
.cookie_jar
.filename
, os
.O_CREAT
, 0600)
604 except (OSError, IOError), e
:
606 logger
.debug("Could not create authentication cookies file; %s: %s",
607 e
.__class
__.__name
__, e
)
608 self
.cookie_jar
.filename
= None
610 opener
.add_handler(urllib2
.HTTPCookieProcessor(self
.cookie_jar
))
615 class HttpRpcServerWithOAuth2Suggestion(HttpRpcServer
):
616 """An HttpRpcServer variant which suggests using OAuth2 instead of ASP.
618 Not all systems which use HttpRpcServer can use OAuth2.
621 SUGGEST_OAUTH2
= True