Merge lp:~alecu/ubuntu-sso-client/timestamp-autofix into lp:ubuntu-sso-client
- timestamp-autofix
- Merge into trunk
Proposed by
Alejandro J. Cura
Status: | Merged |
---|---|
Approved by: | Alejandro J. Cura |
Approved revision: | 796 |
Merged at revision: | 802 |
Proposed branch: | lp:~alecu/ubuntu-sso-client/timestamp-autofix |
Merge into: | lp:ubuntu-sso-client |
Diff against target: |
469 lines (+310/-17) 5 files modified
ubuntu_sso/account.py (+32/-6) ubuntu_sso/tests/test_account.py (+59/-7) ubuntu_sso/tests/test_credentials.py (+3/-0) ubuntu_sso/utils/__init__.py (+64/-1) ubuntu_sso/utils/tests/test_oauth_headers.py (+152/-3) |
To merge this branch: | bzr merge lp:~alecu/ubuntu-sso-client/timestamp-autofix |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Natalia Bidart (community) | Approve | ||
Diego Sarmentero (community) | Approve | ||
Review via email: mp+78507@code.launchpad.net |
Commit message
Do a HEAD request on the server to get accurate timestamp (LP: #692597)
Description of the change
Do a HEAD request on the server to get accurate timestamp (LP: #692597)
To post a comment you must log in.
- 796. By Alejandro J. Cura
-
fixing punctuation.
Revision history for this message
Natalia Bidart (nataliabidart) wrote : | # |
Looks good, I have the same concerns I pointed out in the storage-protocol branch.
review:
Approve
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'ubuntu_sso/account.py' |
2 | --- ubuntu_sso/account.py 2011-03-16 01:36:07 +0000 |
3 | +++ ubuntu_sso/account.py 2011-10-06 21:50:30 +0000 |
4 | @@ -1,6 +1,7 @@ |
5 | # -*- coding: utf-8 -*- |
6 | |
7 | # Author: Natalia Bidart <natalia.bidart@canonical.com> |
8 | +# Author: Alejandro J. Cura <alecu@canonical.com> |
9 | # |
10 | # Copyright 2010 Canonical Ltd. |
11 | # |
12 | @@ -31,6 +32,7 @@ |
13 | from oauth import oauth |
14 | |
15 | from ubuntu_sso.logger import setup_logging |
16 | +from ubuntu_sso.utils import timestamp_checker |
17 | |
18 | |
19 | logger = setup_logging("ubuntu_sso.account") |
20 | @@ -39,6 +41,27 @@ |
21 | SSO_STATUS_ERROR = 'error' |
22 | |
23 | |
24 | +class TimestampedAuthorizer(OAuthAuthorizer): |
25 | + """Includes a custom timestamp on OAuth signatures.""" |
26 | + |
27 | + def __init__(self, get_timestamp, *args, **kwargs): |
28 | + """Store the get_timestamp method, and move on.""" |
29 | + super(TimestampedAuthorizer, self).__init__(*args, **kwargs) |
30 | + self.get_timestamp = get_timestamp |
31 | + |
32 | + # pylint: disable=C0103 |
33 | + def authorizeRequest(self, absolute_uri, method, body, headers): |
34 | + """Override authorizeRequest including the timestamp.""" |
35 | + parameters = {"oauth_timestamp": self.get_timestamp()} |
36 | + oauth_request = oauth.OAuthRequest.from_consumer_and_token( |
37 | + self.consumer, self.access_token, http_url=absolute_uri, |
38 | + parameters=parameters) |
39 | + oauth_request.sign_request( |
40 | + oauth.OAuthSignatureMethod_PLAINTEXT(), |
41 | + self.consumer, self.access_token) |
42 | + headers.update(oauth_request.to_header(self.oauth_realm)) |
43 | + |
44 | + |
45 | class InvalidEmailError(Exception): |
46 | """The email is not valid.""" |
47 | |
48 | @@ -181,9 +204,11 @@ |
49 | if sso_service is None: |
50 | oauth_token = oauth.OAuthToken(token['token'], |
51 | token['token_secret']) |
52 | - authorizer = OAuthAuthorizer(token['consumer_key'], |
53 | - token['consumer_secret'], |
54 | - oauth_token) |
55 | + authorizer = TimestampedAuthorizer( |
56 | + timestamp_checker.get_faithful_time, |
57 | + token['consumer_key'], |
58 | + token['consumer_secret'], |
59 | + oauth_token) |
60 | sso_service = self.sso_service_class(authorizer, self.service_url) |
61 | |
62 | me_info = sso_service.accounts.me() |
63 | @@ -203,9 +228,10 @@ |
64 | token_name=token_name) |
65 | |
66 | oauth_token = oauth.OAuthToken(token['token'], token['token_secret']) |
67 | - authorizer = OAuthAuthorizer(token['consumer_key'], |
68 | - token['consumer_secret'], |
69 | - oauth_token) |
70 | + authorizer = TimestampedAuthorizer(timestamp_checker.get_faithful_time, |
71 | + token['consumer_key'], |
72 | + token['consumer_secret'], |
73 | + oauth_token) |
74 | sso_service = self.sso_service_class(authorizer, self.service_url) |
75 | result = sso_service.accounts.validate_email(email_token=email_token) |
76 | logger.info('validate_email: email: %r result: %r', email, result) |
77 | |
78 | === modified file 'ubuntu_sso/tests/test_account.py' |
79 | --- ubuntu_sso/tests/test_account.py 2011-09-02 13:05:06 +0000 |
80 | +++ ubuntu_sso/tests/test_account.py 2011-10-06 21:50:30 +0000 |
81 | @@ -1,6 +1,7 @@ |
82 | # -*- coding: utf-8 -*- |
83 | # |
84 | # Author: Natalia Bidart <natalia.bidart@canonical.com> |
85 | +# Author: Alejandro J. Cura <alecu@canonical.com> |
86 | # |
87 | # Copyright 2010 Canonical Ltd. |
88 | # |
89 | @@ -24,15 +25,36 @@ |
90 | # pylint: disable=F0401 |
91 | from lazr.restfulclient.errors import HTTPError |
92 | # pylint: enable=F0401 |
93 | +from oauth import oauth |
94 | from twisted.trial.unittest import TestCase |
95 | |
96 | -from ubuntu_sso.account import (Account, AuthenticationError, EmailTokenError, |
97 | - InvalidEmailError, InvalidPasswordError, NewPasswordError, SERVICE_URL, |
98 | - RegistrationError, ResetPasswordTokenError, |
99 | - SSO_STATUS_OK, SSO_STATUS_ERROR) |
100 | -from ubuntu_sso.tests import (APP_NAME, CAPTCHA_ID, CAPTCHA_PATH, |
101 | - CAPTCHA_SOLUTION, EMAIL, EMAIL_TOKEN, NAME, PASSWORD, RESET_PASSWORD_TOKEN, |
102 | - TOKEN, TOKEN_NAME) |
103 | +from ubuntu_sso.account import ( |
104 | + Account, |
105 | + AuthenticationError, |
106 | + EmailTokenError, |
107 | + InvalidEmailError, |
108 | + InvalidPasswordError, |
109 | + NewPasswordError, |
110 | + SERVICE_URL, |
111 | + RegistrationError, |
112 | + ResetPasswordTokenError, |
113 | + SSO_STATUS_OK, |
114 | + SSO_STATUS_ERROR, |
115 | + TimestampedAuthorizer, |
116 | +) |
117 | +from ubuntu_sso.tests import ( |
118 | + APP_NAME, |
119 | + CAPTCHA_ID, |
120 | + CAPTCHA_PATH, |
121 | + CAPTCHA_SOLUTION, |
122 | + EMAIL, |
123 | + EMAIL_TOKEN, |
124 | + NAME, |
125 | + PASSWORD, |
126 | + RESET_PASSWORD_TOKEN, |
127 | + TOKEN, |
128 | + TOKEN_NAME, |
129 | +) |
130 | |
131 | |
132 | CANT_RESET_PASSWORD_CONTENT = "CanNotResetPassowrdError: " \ |
133 | @@ -145,6 +167,36 @@ |
134 | self.accounts = FakedAccounts() |
135 | |
136 | |
137 | +class TimestampedAuthorizerTestCase(TestCase): |
138 | + """Test suite for the TimestampedAuthorizer.""" |
139 | + |
140 | + def test_authorize_request_includes_timestamp(self): |
141 | + """The authorizeRequest method includes the timestamp.""" |
142 | + fromcandt_call = [] |
143 | + fake_uri = "http://protocultura.net" |
144 | + fake_timestamp = 1234 |
145 | + get_fake_timestamp = lambda: fake_timestamp |
146 | + original_oauthrequest = oauth.OAuthRequest |
147 | + |
148 | + class FakeOAuthRequest(oauth.OAuthRequest): |
149 | + """A Fake OAuthRequest class.""" |
150 | + |
151 | + @staticmethod |
152 | + def from_consumer_and_token(*args, **kwargs): |
153 | + """A fake from_consumer_and_token.""" |
154 | + fromcandt_call.append((args, kwargs)) |
155 | + builder = original_oauthrequest.from_consumer_and_token |
156 | + return builder(*args, **kwargs) |
157 | + |
158 | + self.patch(oauth, "OAuthRequest", FakeOAuthRequest) |
159 | + |
160 | + authorizer = TimestampedAuthorizer(get_fake_timestamp, "ubuntuone") |
161 | + authorizer.authorizeRequest(fake_uri, "POST", None, {}) |
162 | + call_kwargs = fromcandt_call[0][1] |
163 | + parameters = call_kwargs["parameters"] |
164 | + self.assertEqual(parameters["oauth_timestamp"], fake_timestamp) |
165 | + |
166 | + |
167 | class AccountTestCase(TestCase): |
168 | """Test suite for the SSO login processor.""" |
169 | |
170 | |
171 | === modified file 'ubuntu_sso/tests/test_credentials.py' |
172 | --- ubuntu_sso/tests/test_credentials.py 2011-08-22 16:21:36 +0000 |
173 | +++ ubuntu_sso/tests/test_credentials.py 2011-10-06 21:50:30 +0000 |
174 | @@ -20,6 +20,7 @@ |
175 | |
176 | import logging |
177 | import os |
178 | +import time |
179 | import urllib2 |
180 | |
181 | from twisted.internet import defer |
182 | @@ -373,6 +374,8 @@ |
183 | return response |
184 | |
185 | self.patch(credentials.urllib2, 'urlopen', faked_urlopen) |
186 | + self.patch(credentials.utils.timestamp_checker, "get_faithful_time", |
187 | + time.time) |
188 | |
189 | @defer.inlineCallbacks |
190 | def test_ping_url_if_url_is_none(self): |
191 | |
192 | === modified file 'ubuntu_sso/utils/__init__.py' |
193 | --- ubuntu_sso/utils/__init__.py 2011-08-12 12:54:36 +0000 |
194 | +++ ubuntu_sso/utils/__init__.py 2011-10-06 21:50:30 +0000 |
195 | @@ -2,7 +2,7 @@ |
196 | |
197 | # Author: Alejandro J. Cura <alecu@canonical.com> |
198 | # |
199 | -# Copyright 2010 Canonical Ltd. |
200 | +# Copyright 2010, 2011 Canonical Ltd. |
201 | # |
202 | # This program is free software: you can redistribute it and/or modify it |
203 | # under the terms of the GNU General Public License version 3, as published |
204 | @@ -19,9 +19,71 @@ |
205 | """Utility modules that may find use outside ubuntu_sso.""" |
206 | |
207 | import cgi |
208 | +import time |
209 | +import urllib2 |
210 | |
211 | from oauth import oauth |
212 | from urlparse import urlparse |
213 | +from twisted.web import http |
214 | + |
215 | +from ubuntu_sso.logger import setup_logging |
216 | +logger = setup_logging("ubuntu_sso.utils") |
217 | + |
218 | + |
219 | +class RequestHead(urllib2.Request): |
220 | + """A request with the method set to HEAD.""" |
221 | + |
222 | + _request_method = "HEAD" |
223 | + |
224 | + def get_method(self): |
225 | + """Return the desired method.""" |
226 | + return self._request_method |
227 | + |
228 | + |
229 | +class SyncTimestampChecker(object): |
230 | + """A timestamp that's regularly checked with a server.""" |
231 | + |
232 | + CHECKING_INTERVAL = 60 * 60 # in seconds |
233 | + ERROR_INTERVAL = 30 # in seconds |
234 | + SERVER_URL = "http://one.ubuntu.com/" |
235 | + |
236 | + def __init__(self): |
237 | + """Initialize this instance.""" |
238 | + self.next_check = time.time() |
239 | + self.skew = 0 |
240 | + |
241 | + def get_server_time(self): |
242 | + """Get the time at the server.""" |
243 | + headers = {"Cache-Control": "no-cache"} |
244 | + request = RequestHead(self.SERVER_URL, headers=headers) |
245 | + response = urllib2.urlopen(request) |
246 | + date_string = response.info()["Date"] |
247 | + timestamp = http.stringToDatetime(date_string) |
248 | + return timestamp |
249 | + |
250 | + def get_faithful_time(self): |
251 | + """Get an accurate timestamp.""" |
252 | + local_time = time.time() |
253 | + if local_time >= self.next_check: |
254 | + try: |
255 | + server_time = self.get_server_time() |
256 | + self.next_check = local_time + self.CHECKING_INTERVAL |
257 | + self.skew = server_time - local_time |
258 | + logger.debug("Calculated server-local time skew: %r", |
259 | + self.skew) |
260 | + #pylint: disable=W0703 |
261 | + except Exception, server_error: |
262 | + logger.debug("Error while verifying the server time skew: %r", |
263 | + server_error) |
264 | + self.next_check = local_time + self.ERROR_INTERVAL |
265 | + logger.debug("Using corrected timestamp: %r", |
266 | + http.datetimeToString(local_time + self.skew)) |
267 | + return int(local_time + self.skew) |
268 | + |
269 | + |
270 | +# pylint: disable=C0103 |
271 | +timestamp_checker = SyncTimestampChecker() |
272 | +# pylint: enable=C0103 |
273 | |
274 | |
275 | def oauth_headers(url, credentials, http_method='GET'): |
276 | @@ -37,6 +99,7 @@ |
277 | url = url.encode('utf-8') |
278 | _, _, _, _, query, _ = urlparse(url) |
279 | parameters = dict(cgi.parse_qsl(query)) |
280 | + parameters["oauth_timestamp"] = timestamp_checker.get_faithful_time() |
281 | |
282 | consumer = oauth.OAuthConsumer(credentials['consumer_key'], |
283 | credentials['consumer_secret']) |
284 | |
285 | === modified file 'ubuntu_sso/utils/tests/test_oauth_headers.py' |
286 | --- ubuntu_sso/utils/tests/test_oauth_headers.py 2011-08-12 12:54:36 +0000 |
287 | +++ ubuntu_sso/utils/tests/test_oauth_headers.py 2011-10-06 21:50:30 +0000 |
288 | @@ -1,6 +1,7 @@ |
289 | # -*- coding: utf-8 -*- |
290 | |
291 | # Author: Natalia B. Bidart <natalia.bidart@canonical.com> |
292 | +# Author: Alejandro J. Cura <alecu@canonical.com> |
293 | # |
294 | # Copyright 2011 Canonical Ltd. |
295 | # |
296 | @@ -18,9 +19,16 @@ |
297 | |
298 | """Tests for the oauth_headers helper function.""" |
299 | |
300 | +import time |
301 | + |
302 | +from twisted.application import internet, service |
303 | +from twisted.internet import defer |
304 | +from twisted.internet.threads import deferToThread |
305 | from twisted.trial.unittest import TestCase |
306 | +from twisted.web import server, resource |
307 | |
308 | -from ubuntu_sso.utils import oauth, oauth_headers |
309 | +from ubuntu_sso import utils |
310 | +from ubuntu_sso.utils import oauth, oauth_headers, SyncTimestampChecker |
311 | from ubuntu_sso.tests import TOKEN |
312 | |
313 | |
314 | @@ -44,6 +52,21 @@ |
315 | """Test suite for the oauth_headers method.""" |
316 | |
317 | url = u'http://example.com' |
318 | + fake_timestamp_value = 1 |
319 | + |
320 | + @defer.inlineCallbacks |
321 | + def setUp(self): |
322 | + """Initialize this test suite.""" |
323 | + yield super(SignWithCredentialsTestCase, self).setUp() |
324 | + self.timestamp_called = False |
325 | + |
326 | + def fake_timestamp(): |
327 | + """A fake timestamp that records the call.""" |
328 | + self.timestamp_called = True |
329 | + return self.fake_timestamp_value |
330 | + |
331 | + self.patch(utils.timestamp_checker, "get_faithful_time", |
332 | + fake_timestamp) |
333 | |
334 | def build_header(self, url, http_method='GET'): |
335 | """Build an Oauth header for comparison.""" |
336 | @@ -105,5 +128,131 @@ |
337 | oauth_headers(url=self.url + path, credentials=TOKEN) |
338 | |
339 | self.assertIn('parameters', FakedOAuthRequest.params) |
340 | - self.assertEqual(FakedOAuthRequest.params['parameters'], |
341 | - {'foo': 'bar'}) |
342 | + params = FakedOAuthRequest.params['parameters'] |
343 | + del(params["oauth_timestamp"]) |
344 | + self.assertEqual(params, {'foo': 'bar'}) |
345 | + |
346 | + def test_oauth_headers_uses_timestamp_checker(self): |
347 | + """The oauth_headers function uses the timestamp_checker.""" |
348 | + oauth_headers(u"http://protocultura.net", TOKEN) |
349 | + self.assertTrue(self.timestamp_called, |
350 | + "the timestamp MUST be requested.") |
351 | + |
352 | + |
353 | +class RootResource(resource.Resource): |
354 | + """A root resource that logs the number of calls.""" |
355 | + |
356 | + isLeaf = True |
357 | + |
358 | + def __init__(self, *args, **kwargs): |
359 | + """Initialize this fake instance.""" |
360 | + resource.Resource.__init__(self, *args, **kwargs) |
361 | + self.count = 0 |
362 | + self.request_headers = [] |
363 | + |
364 | + # pylint: disable=C0103 |
365 | + def render_HEAD(self, request): |
366 | + """Increase the counter on each render.""" |
367 | + self.count += 1 |
368 | + self.request_headers.append(request.requestHeaders) |
369 | + return "" |
370 | + |
371 | + |
372 | +class MockWebServer(object): |
373 | + """A mock webserver for testing.""" |
374 | + |
375 | + def __init__(self): |
376 | + """Start up this instance.""" |
377 | + # pylint: disable=E1101 |
378 | + self.root = RootResource() |
379 | + site = server.Site(self.root) |
380 | + application = service.Application('web') |
381 | + self.service_collection = service.IServiceCollection(application) |
382 | + self.tcpserver = internet.TCPServer(0, site) |
383 | + self.tcpserver.setServiceParent(self.service_collection) |
384 | + self.service_collection.startService() |
385 | + |
386 | + def get_url(self): |
387 | + """Build the url for this mock server.""" |
388 | + # pylint: disable=W0212 |
389 | + port_num = self.tcpserver._port.getHost().port |
390 | + return "http://localhost:%d/" % port_num |
391 | + |
392 | + def stop(self): |
393 | + """Shut it down.""" |
394 | + # pylint: disable=E1101 |
395 | + self.service_collection.stopService() |
396 | + |
397 | + |
398 | +class FakedError(Exception): |
399 | + """A mock, test, sample, and fake exception.""" |
400 | + |
401 | + |
402 | +class TimestampCheckerTestCase(TestCase): |
403 | + """Tests for the timestamp checker.""" |
404 | + |
405 | + def setUp(self): |
406 | + """Initialize a fake webserver.""" |
407 | + self.ws = MockWebServer() |
408 | + self.addCleanup(self.ws.stop) |
409 | + self.patch(SyncTimestampChecker, "SERVER_URL", self.ws.get_url()) |
410 | + |
411 | + @defer.inlineCallbacks |
412 | + def test_returned_value_is_int(self): |
413 | + """The returned value is an integer.""" |
414 | + checker = SyncTimestampChecker() |
415 | + timestamp = yield deferToThread(checker.get_faithful_time) |
416 | + self.assertEqual(type(timestamp), int) |
417 | + |
418 | + @defer.inlineCallbacks |
419 | + def test_first_call_does_head(self): |
420 | + """The first call gets the clock from our web.""" |
421 | + checker = SyncTimestampChecker() |
422 | + yield deferToThread(checker.get_faithful_time) |
423 | + self.assertEqual(self.ws.root.count, 1) |
424 | + |
425 | + @defer.inlineCallbacks |
426 | + def test_second_call_is_cached(self): |
427 | + """For the second call, the time is cached.""" |
428 | + checker = SyncTimestampChecker() |
429 | + yield deferToThread(checker.get_faithful_time) |
430 | + yield deferToThread(checker.get_faithful_time) |
431 | + self.assertEqual(self.ws.root.count, 1) |
432 | + |
433 | + @defer.inlineCallbacks |
434 | + def test_after_timeout_cache_expires(self): |
435 | + """After some time, the cache expires.""" |
436 | + fake_timestamp = 1 |
437 | + self.patch(time, "time", lambda: fake_timestamp) |
438 | + checker = SyncTimestampChecker() |
439 | + yield deferToThread(checker.get_faithful_time) |
440 | + fake_timestamp += SyncTimestampChecker.CHECKING_INTERVAL |
441 | + yield deferToThread(checker.get_faithful_time) |
442 | + self.assertEqual(self.ws.root.count, 2) |
443 | + |
444 | + @defer.inlineCallbacks |
445 | + def test_server_date_sends_nocache_headers(self): |
446 | + """Getting the server date sends the no-cache headers.""" |
447 | + checker = SyncTimestampChecker() |
448 | + yield deferToThread(checker.get_server_time) |
449 | + assert len(self.ws.root.request_headers) == 1 |
450 | + headers = self.ws.root.request_headers[0] |
451 | + result = headers.getRawHeaders("Cache-Control") |
452 | + self.assertEqual(result, ["no-cache"]) |
453 | + |
454 | + @defer.inlineCallbacks |
455 | + def test_server_error_means_skew_not_updated(self): |
456 | + """When server can't be reached, the skew is not updated.""" |
457 | + fake_timestamp = 1 |
458 | + self.patch(time, "time", lambda: fake_timestamp) |
459 | + checker = SyncTimestampChecker() |
460 | + |
461 | + def failing_get_server_time(): |
462 | + """Let's fail while retrieving the server time.""" |
463 | + raise FakedError() |
464 | + |
465 | + self.patch(checker, "get_server_time", failing_get_server_time) |
466 | + yield deferToThread(checker.get_faithful_time) |
467 | + self.assertEqual(checker.skew, 0) |
468 | + self.assertEqual(checker.next_check, |
469 | + fake_timestamp + SyncTimestampChecker.ERROR_INTERVAL) |
+1