Merge lp:~alecu/ubuntu-sso-client/timestamp-autofix into lp:ubuntu-sso-client

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
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
Diego Sarmentero (diegosarmentero) wrote :

+1

review: Approve
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)

Subscribers

People subscribed via source and target branches