|
1 #!/usr/bin/env python |
|
2 # |
|
3 # Copyright 2007 Google Inc. |
|
4 # |
|
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 |
|
8 # |
|
9 # http://www.apache.org/licenses/LICENSE-2.0 |
|
10 # |
|
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. |
|
16 # |
|
17 |
|
18 """Tool for performing authenticated RPCs against App Engine.""" |
|
19 |
|
20 |
|
21 import cookielib |
|
22 import logging |
|
23 import os |
|
24 import re |
|
25 import socket |
|
26 import sys |
|
27 import urllib |
|
28 import urllib2 |
|
29 |
|
30 |
|
31 https_handler = urllib2.HTTPSHandler |
|
32 uses_cert_verification = False |
|
33 certpath = os.path.join(os.path.dirname(__file__), "cacerts.txt") |
|
34 cert_file_available = os.path.exists(certpath) |
|
35 try: |
|
36 import https_wrapper |
|
37 if cert_file_available: |
|
38 https_handler = lambda: https_wrapper.CertValidatingHTTPSHandler( |
|
39 ca_certs=certpath) |
|
40 uses_cert_verification = True |
|
41 except ImportError: |
|
42 pass |
|
43 |
|
44 |
|
45 def GetPlatformToken(os_module=os, sys_module=sys, platform=sys.platform): |
|
46 """Returns a 'User-agent' token for the host system platform. |
|
47 |
|
48 Args: |
|
49 os_module, sys_module, platform: Used for testing. |
|
50 |
|
51 Returns: |
|
52 String containing the platform token for the host system. |
|
53 """ |
|
54 if hasattr(sys_module, "getwindowsversion"): |
|
55 windows_version = sys_module.getwindowsversion() |
|
56 version_info = ".".join(str(i) for i in windows_version[:4]) |
|
57 return platform + "/" + version_info |
|
58 elif hasattr(os_module, "uname"): |
|
59 uname = os_module.uname() |
|
60 return "%s/%s" % (uname[0], uname[2]) |
|
61 else: |
|
62 return "unknown" |
|
63 |
|
64 |
|
65 class ClientLoginError(urllib2.HTTPError): |
|
66 """Raised to indicate there was an error authenticating with ClientLogin.""" |
|
67 |
|
68 def __init__(self, url, code, msg, headers, args): |
|
69 urllib2.HTTPError.__init__(self, url, code, msg, headers, None) |
|
70 self.args = args |
|
71 self.reason = args["Error"] |
|
72 |
|
73 |
|
74 class AbstractRpcServer(object): |
|
75 """Provides a common interface for a simple RPC server.""" |
|
76 |
|
77 def __init__(self, host, auth_function, user_agent, source, |
|
78 host_override=None, extra_headers=None, save_cookies=False, |
|
79 auth_tries=3, account_type=None): |
|
80 """Creates a new HttpRpcServer. |
|
81 |
|
82 Args: |
|
83 host: The host to send requests to. |
|
84 auth_function: A function that takes no arguments and returns an |
|
85 (email, password) tuple when called. Will be called if authentication |
|
86 is required. |
|
87 user_agent: The user-agent string to send to the server. Specify None to |
|
88 omit the user-agent header. |
|
89 source: The source to specify in authentication requests. |
|
90 host_override: The host header to send to the server (defaults to host). |
|
91 extra_headers: A dict of extra headers to append to every request. Values |
|
92 supplied here will override other default headers that are supplied. |
|
93 save_cookies: If True, save the authentication cookies to local disk. |
|
94 If False, use an in-memory cookiejar instead. Subclasses must |
|
95 implement this functionality. Defaults to False. |
|
96 auth_tries: The number of times to attempt auth_function before failing. |
|
97 account_type: One of GOOGLE, HOSTED_OR_GOOGLE, or None for automatic. |
|
98 """ |
|
99 self.host = host |
|
100 self.host_override = host_override |
|
101 self.auth_function = auth_function |
|
102 self.source = source |
|
103 self.authenticated = False |
|
104 self.auth_tries = auth_tries |
|
105 |
|
106 self.account_type = account_type |
|
107 |
|
108 self.extra_headers = {} |
|
109 if user_agent: |
|
110 self.extra_headers["User-Agent"] = user_agent |
|
111 if extra_headers: |
|
112 self.extra_headers.update(extra_headers) |
|
113 |
|
114 self.save_cookies = save_cookies |
|
115 self.cookie_jar = cookielib.MozillaCookieJar() |
|
116 self.opener = self._GetOpener() |
|
117 if self.host_override: |
|
118 logging.info("Server: %s; Host: %s", self.host, self.host_override) |
|
119 else: |
|
120 logging.info("Server: %s", self.host) |
|
121 |
|
122 if ((self.host_override and self.host_override == "localhost") or |
|
123 self.host == "localhost" or self.host.startswith("localhost:")): |
|
124 self._DevAppServerAuthenticate() |
|
125 |
|
126 def _GetOpener(self): |
|
127 """Returns an OpenerDirector for making HTTP requests. |
|
128 |
|
129 Returns: |
|
130 A urllib2.OpenerDirector object. |
|
131 """ |
|
132 raise NotImplemented() |
|
133 |
|
134 def _CreateRequest(self, url, data=None): |
|
135 """Creates a new urllib request.""" |
|
136 req = urllib2.Request(url, data=data) |
|
137 if self.host_override: |
|
138 req.add_header("Host", self.host_override) |
|
139 for key, value in self.extra_headers.iteritems(): |
|
140 req.add_header(key, value) |
|
141 return req |
|
142 |
|
143 def _GetAuthToken(self, email, password): |
|
144 """Uses ClientLogin to authenticate the user, returning an auth token. |
|
145 |
|
146 Args: |
|
147 email: The user's email address |
|
148 password: The user's password |
|
149 |
|
150 Raises: |
|
151 ClientLoginError: If there was an error authenticating with ClientLogin. |
|
152 HTTPError: If there was some other form of HTTP error. |
|
153 |
|
154 Returns: |
|
155 The authentication token returned by ClientLogin. |
|
156 """ |
|
157 account_type = self.account_type |
|
158 if not account_type: |
|
159 if (self.host.split(':')[0].endswith(".google.com") |
|
160 or (self.host_override |
|
161 and self.host_override.split(':')[0].endswith(".google.com"))): |
|
162 account_type = "HOSTED_OR_GOOGLE" |
|
163 else: |
|
164 account_type = "GOOGLE" |
|
165 data = { |
|
166 "Email": email, |
|
167 "Passwd": password, |
|
168 "service": "ah", |
|
169 "source": self.source, |
|
170 "accountType": account_type |
|
171 } |
|
172 |
|
173 req = self._CreateRequest( |
|
174 url="https://www.google.com/accounts/ClientLogin", |
|
175 data=urllib.urlencode(data)) |
|
176 try: |
|
177 response = self.opener.open(req) |
|
178 response_body = response.read() |
|
179 response_dict = dict(x.split("=") |
|
180 for x in response_body.split("\n") if x) |
|
181 return response_dict["Auth"] |
|
182 except urllib2.HTTPError, e: |
|
183 if e.code == 403: |
|
184 body = e.read() |
|
185 response_dict = dict(x.split("=", 1) for x in body.split("\n") if x) |
|
186 raise ClientLoginError(req.get_full_url(), e.code, e.msg, |
|
187 e.headers, response_dict) |
|
188 else: |
|
189 raise |
|
190 |
|
191 def _GetAuthCookie(self, auth_token): |
|
192 """Fetches authentication cookies for an authentication token. |
|
193 |
|
194 Args: |
|
195 auth_token: The authentication token returned by ClientLogin. |
|
196 |
|
197 Raises: |
|
198 HTTPError: If there was an error fetching the authentication cookies. |
|
199 """ |
|
200 continue_location = "http://localhost/" |
|
201 args = {"continue": continue_location, "auth": auth_token} |
|
202 login_path = os.environ.get("APPCFG_LOGIN_PATH", "/_ah") |
|
203 req = self._CreateRequest("http://%s%s/login?%s" % |
|
204 (self.host, login_path, urllib.urlencode(args))) |
|
205 try: |
|
206 response = self.opener.open(req) |
|
207 except urllib2.HTTPError, e: |
|
208 response = e |
|
209 if (response.code != 302 or |
|
210 response.info()["location"] != continue_location): |
|
211 raise urllib2.HTTPError(req.get_full_url(), response.code, response.msg, |
|
212 response.headers, response.fp) |
|
213 self.authenticated = True |
|
214 |
|
215 def _Authenticate(self): |
|
216 """Authenticates the user. |
|
217 |
|
218 The authentication process works as follows: |
|
219 1) We get a username and password from the user |
|
220 2) We use ClientLogin to obtain an AUTH token for the user |
|
221 (see http://code.google.com/apis/accounts/AuthForInstalledApps.html). |
|
222 3) We pass the auth token to /_ah/login on the server to obtain an |
|
223 authentication cookie. If login was successful, it tries to redirect |
|
224 us to the URL we provided. |
|
225 |
|
226 If we attempt to access the upload API without first obtaining an |
|
227 authentication cookie, it returns a 401 response and directs us to |
|
228 authenticate ourselves with ClientLogin. |
|
229 """ |
|
230 for unused_i in range(self.auth_tries): |
|
231 credentials = self.auth_function() |
|
232 try: |
|
233 auth_token = self._GetAuthToken(credentials[0], credentials[1]) |
|
234 except ClientLoginError, e: |
|
235 if e.reason == "BadAuthentication": |
|
236 print >>sys.stderr, "Invalid username or password." |
|
237 continue |
|
238 if e.reason == "CaptchaRequired": |
|
239 print >>sys.stderr, ( |
|
240 "Please go to\n" |
|
241 "https://www.google.com/accounts/DisplayUnlockCaptcha\n" |
|
242 "and verify you are a human. Then try again.") |
|
243 break |
|
244 if e.reason == "NotVerified": |
|
245 print >>sys.stderr, "Account not verified." |
|
246 break |
|
247 if e.reason == "TermsNotAgreed": |
|
248 print >>sys.stderr, "User has not agreed to TOS." |
|
249 break |
|
250 if e.reason == "AccountDeleted": |
|
251 print >>sys.stderr, "The user account has been deleted." |
|
252 break |
|
253 if e.reason == "AccountDisabled": |
|
254 print >>sys.stderr, "The user account has been disabled." |
|
255 break |
|
256 if e.reason == "ServiceDisabled": |
|
257 print >>sys.stderr, ("The user's access to the service has been " |
|
258 "disabled.") |
|
259 break |
|
260 if e.reason == "ServiceUnavailable": |
|
261 print >>sys.stderr, "The service is not available; try again later." |
|
262 break |
|
263 raise |
|
264 self._GetAuthCookie(auth_token) |
|
265 return |
|
266 |
|
267 def _DevAppServerAuthenticate(self): |
|
268 """Authenticates the user on the dev_appserver.""" |
|
269 credentials = self.auth_function() |
|
270 self.extra_headers["Cookie"] = ('dev_appserver_login="%s:True"; Path=/;' % |
|
271 (credentials[0],)) |
|
272 |
|
273 def Send(self, request_path, payload="", |
|
274 content_type="application/octet-stream", |
|
275 timeout=None, |
|
276 **kwargs): |
|
277 """Sends an RPC and returns the response. |
|
278 |
|
279 Args: |
|
280 request_path: The path to send the request to, eg /api/appversion/create. |
|
281 payload: The body of the request, or None to send an empty request. |
|
282 content_type: The Content-Type header to use. |
|
283 timeout: timeout in seconds; default None i.e. no timeout. |
|
284 (Note: for large requests on OS X, the timeout doesn't work right.) |
|
285 kwargs: Any keyword arguments are converted into query string parameters. |
|
286 |
|
287 Returns: |
|
288 The response body, as a string. |
|
289 """ |
|
290 old_timeout = socket.getdefaulttimeout() |
|
291 socket.setdefaulttimeout(timeout) |
|
292 try: |
|
293 tries = 0 |
|
294 while True: |
|
295 tries += 1 |
|
296 args = dict(kwargs) |
|
297 url = "http://%s%s?%s" % (self.host, request_path, |
|
298 urllib.urlencode(args)) |
|
299 req = self._CreateRequest(url=url, data=payload) |
|
300 req.add_header("Content-Type", content_type) |
|
301 req.add_header("X-appcfg-api-version", "1") |
|
302 try: |
|
303 f = self.opener.open(req) |
|
304 response = f.read() |
|
305 f.close() |
|
306 return response |
|
307 except urllib2.HTTPError, e: |
|
308 logging.debug("Got http error, this is try #%s" % tries) |
|
309 if tries > self.auth_tries: |
|
310 raise |
|
311 elif e.code == 401: |
|
312 self._Authenticate() |
|
313 elif e.code >= 500 and e.code < 600: |
|
314 continue |
|
315 elif e.code == 302: |
|
316 loc = e.info()["location"] |
|
317 logging.debug("Got 302 redirect. Location: %s" % loc) |
|
318 if loc.startswith("https://www.google.com/accounts/ServiceLogin"): |
|
319 self._Authenticate() |
|
320 elif re.match(r"https://www.google.com/a/[a-z0-9.-]+/ServiceLogin", |
|
321 loc): |
|
322 self.account_type = "HOSTED" |
|
323 self._Authenticate() |
|
324 elif loc.startswith("http://%s/_ah/login" % (self.host,)): |
|
325 self._DevAppServerAuthenticate() |
|
326 else: |
|
327 raise |
|
328 finally: |
|
329 socket.setdefaulttimeout(old_timeout) |
|
330 |
|
331 |
|
332 class HttpRpcServer(AbstractRpcServer): |
|
333 """Provides a simplified RPC-style interface for HTTP requests.""" |
|
334 |
|
335 DEFAULT_COOKIE_FILE_PATH = "~/.appcfg_cookies" |
|
336 |
|
337 def _Authenticate(self): |
|
338 """Save the cookie jar after authentication.""" |
|
339 if cert_file_available and not uses_cert_verification: |
|
340 logging.warn("ssl module not found. Without this the identity of the " |
|
341 "remote host cannot be verified, and connections are NOT " |
|
342 "secure. To fix this, please install the ssl module from " |
|
343 "http://pypi.python.org/pypi/ssl") |
|
344 super(HttpRpcServer, self)._Authenticate() |
|
345 if self.cookie_jar.filename is not None and self.save_cookies: |
|
346 logging.info("Saving authentication cookies to %s" % |
|
347 self.cookie_jar.filename) |
|
348 self.cookie_jar.save() |
|
349 |
|
350 def _GetOpener(self): |
|
351 """Returns an OpenerDirector that supports cookies and ignores redirects. |
|
352 |
|
353 Returns: |
|
354 A urllib2.OpenerDirector object. |
|
355 """ |
|
356 opener = urllib2.OpenerDirector() |
|
357 opener.add_handler(urllib2.ProxyHandler()) |
|
358 opener.add_handler(urllib2.UnknownHandler()) |
|
359 opener.add_handler(urllib2.HTTPHandler()) |
|
360 opener.add_handler(urllib2.HTTPDefaultErrorHandler()) |
|
361 opener.add_handler(https_handler()) |
|
362 opener.add_handler(urllib2.HTTPErrorProcessor()) |
|
363 |
|
364 if self.save_cookies: |
|
365 self.cookie_jar.filename = os.path.expanduser( |
|
366 HttpRpcServer.DEFAULT_COOKIE_FILE_PATH) |
|
367 |
|
368 if os.path.exists(self.cookie_jar.filename): |
|
369 try: |
|
370 self.cookie_jar.load() |
|
371 self.authenticated = True |
|
372 logging.info("Loaded authentication cookies from %s" % |
|
373 self.cookie_jar.filename) |
|
374 except (OSError, IOError, cookielib.LoadError), e: |
|
375 logging.debug("Could not load authentication cookies; %s: %s", |
|
376 e.__class__.__name__, e) |
|
377 self.cookie_jar.filename = None |
|
378 else: |
|
379 try: |
|
380 fd = os.open(self.cookie_jar.filename, os.O_CREAT, 0600) |
|
381 os.close(fd) |
|
382 except (OSError, IOError), e: |
|
383 logging.debug("Could not create authentication cookies file; %s: %s", |
|
384 e.__class__.__name__, e) |
|
385 self.cookie_jar.filename = None |
|
386 |
|
387 opener.add_handler(urllib2.HTTPCookieProcessor(self.cookie_jar)) |
|
388 return opener |