Merge lp:~salgado/launchpad/separate-cookie-for-testopenid into lp:launchpad

Proposed by Guilherme Salgado
Status: Rejected
Rejected by: Guilherme Salgado
Proposed branch: lp:~salgado/launchpad/separate-cookie-for-testopenid
Merge into: lp:launchpad
Prerequisite: lp:~salgado/launchpad/lp-as-openid-rp
Diff against target: 493 lines (+292/-28)
9 files modified
lib/canonical/config/schema-lazr.conf (+5/-0)
lib/canonical/launchpad/webapp/authentication.py (+2/-2)
lib/canonical/launchpad/webapp/dbpolicy.py (+2/-2)
lib/canonical/launchpad/webapp/login.py (+3/-2)
lib/canonical/launchpad/webapp/pgsession.py (+3/-3)
lib/canonical/launchpad/webapp/session.py (+109/-17)
lib/canonical/launchpad/webapp/tests/test_cookie_client_id_manager.txt (+158/-0)
lib/canonical/launchpad/webapp/tests/test_dbpolicy.py (+1/-1)
lib/canonical/launchpad/webapp/tests/test_session.py (+9/-1)
To merge this branch: bzr merge lp:~salgado/launchpad/separate-cookie-for-testopenid
Reviewer Review Type Date Requested Status
Gary Poster (community) Approve
Review via email: mp+18018@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Guilherme Salgado (salgado) wrote :

This branch changes our custom CookieClientIDManager to allow us to use a separate cookie for requests in the TestOpenIDLayer. This is needed so that the cookie you get when logging into testopenid.lp.dev is not picked up in lp.dev

Revision history for this message
Guilherme Salgado (salgado) wrote :

This might not be needed, so not yet ready for review.

Revision history for this message
Gary Poster (gary) wrote :

Good.

As we discussed on IRC, we both agree that it will be nice to revert this once it is no longer necessary.

Because of that, and for general readability, I'd like to have the cut-n-paste blocks from Zope code more clearly marked. You have a nice comment at the end of one of those blocks ("# From here on it's our custom code...") but I'd like to see a start marker and end marker for these blocks, and a comment that this change is slated to be reverted.

Other than that, I'm happy with it.

Thanks,

Gary

review: Approve
Revision history for this message
Guilherme Salgado (salgado) wrote :

I've made it clear where the duplication is and filed bug 520582 (linking to this MP), so that I don't forget to revert the changes later on.

Unmerged revisions

10218. By Guilherme Salgado

merge from loomified branch

10217. By Guilherme Salgado

merge from mainline

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/config/schema-lazr.conf'
2--- lib/canonical/config/schema-lazr.conf 2010-02-11 16:33:20 +0000
3+++ lib/canonical/config/schema-lazr.conf 2010-02-11 16:33:22 +0000
4@@ -1114,6 +1114,11 @@
5 # datatype: string
6 cookie: launchpad
7
8+# The id of the cookie used to store the session token for the testopenid
9+# service.
10+# datatype: string
11+testopenid_cookie: testopenid
12+
13
14 [librarianlogparser]
15 logs_root = /srv/launchpadlibrarian.net-logs
16
17=== modified file 'lib/canonical/launchpad/webapp/authentication.py'
18--- lib/canonical/launchpad/webapp/authentication.py 2009-10-20 22:13:21 +0000
19+++ lib/canonical/launchpad/webapp/authentication.py 2010-02-11 16:33:22 +0000
20@@ -22,6 +22,7 @@
21 from zope.interface import implements
22 from zope.component import getUtility
23 from zope.event import notify
24+from zope.session.interfaces import IClientIdManager
25
26 from zope.security.proxy import removeSecurityProxy
27
28@@ -29,7 +30,6 @@
29 from zope.app.security.interfaces import ILoginPassword
30 from zope.app.security.principalregistry import UnauthenticatedPrincipal
31
32-from canonical.config import config
33 from canonical.launchpad.interfaces.account import IAccountSet
34 from canonical.launchpad.interfaces.launchpad import IPasswordEncryptor
35 from canonical.launchpad.interfaces.oauth import OAUTH_CHALLENGE
36@@ -119,7 +119,7 @@
37 # Hack to make us not even think of using a session if there
38 # isn't already a cookie in the request, or one waiting to be
39 # set in the response.
40- cookie_name = config.launchpad_session.cookie
41+ cookie_name = getUtility(IClientIdManager).getNamespace(request)
42 if (request.cookies.get(cookie_name) is not None or
43 request.response.getCookie(cookie_name) is not None):
44 return self._authenticateUsingCookieAuth(request)
45
46=== modified file 'lib/canonical/launchpad/webapp/dbpolicy.py'
47--- lib/canonical/launchpad/webapp/dbpolicy.py 2010-01-20 22:09:26 +0000
48+++ lib/canonical/launchpad/webapp/dbpolicy.py 2010-02-11 16:33:22 +0000
49@@ -184,7 +184,7 @@
50
51 def _hasSession(self):
52 "Is there is already a session cookie hanging around?"
53- cookie_name = getUtility(IClientIdManager).namespace
54+ cookie_name = getUtility(IClientIdManager).getNamespace(self.request)
55 return (
56 cookie_name in self.request.cookies or
57 self.request.response.getCookie(cookie_name) is not None)
58@@ -325,7 +325,7 @@
59 # standard Launchpad database policy for load balancing to
60 # the slave databases. The javascript web service libraries
61 # send the session cookie for authenticated users.
62- cookie_name = getUtility(IClientIdManager).namespace
63+ cookie_name = getUtility(IClientIdManager).getNamespace(request)
64 if cookie_name in request.cookies:
65 return LaunchpadDatabasePolicy(request)
66 # Otherwise, use the master only web service database policy.
67
68=== modified file 'lib/canonical/launchpad/webapp/login.py'
69--- lib/canonical/launchpad/webapp/login.py 2010-01-14 13:25:34 +0000
70+++ lib/canonical/launchpad/webapp/login.py 2010-02-11 16:33:22 +0000
71@@ -471,7 +471,7 @@
72 delta=timedelta(minutes=10)):
73 if client_id_manager is None:
74 client_id_manager = getUtility(IClientIdManager)
75- session_cookiename = client_id_manager.namespace
76+ session_cookiename = client_id_manager.getNamespace(request)
77 value = request.response.getCookie(session_cookiename)['value']
78 expiration = (datetime.utcnow() + delta).strftime(
79 '%a, %d %b %Y %H:%M:%S GMT')
80@@ -492,7 +492,8 @@
81 if not IUnauthenticatedPrincipal.providedBy(request.principal):
82 return
83 client_id_manager = getUtility(IClientIdManager)
84- if request.response.getCookie(client_id_manager.namespace) is None:
85+ cookie_name = client_id_manager.getNamespace(request)
86+ if request.response.getCookie(cookie_name) is None:
87 client_id_manager.setRequestId(
88 request, client_id_manager.getClientId(request))
89 expireSessionCookie(request, client_id_manager, duration)
90
91=== modified file 'lib/canonical/launchpad/webapp/pgsession.py'
92--- lib/canonical/launchpad/webapp/pgsession.py 2009-11-30 08:57:54 +0000
93+++ lib/canonical/launchpad/webapp/pgsession.py 2010-02-11 16:33:22 +0000
94@@ -115,9 +115,9 @@
95 if IUnauthenticatedPrincipal.providedBy(request.principal):
96 # it would be nice if this could be a monitored, logged
97 # message instead of an instant-OOPS.
98- assert (client_id_manager.namespace in request.cookies or
99- request.response.getCookie(
100- client_id_manager.namespace) is not None), (
101+ namespace = client_id_manager.getNamespace(request)
102+ assert (namespace in request.cookies or
103+ request.response.getCookie(namespace) is not None), (
104 'Session data should generally only be stored for '
105 'authenticated users, and for users who have just logged '
106 'out. If an unauthenticated user has just logged out, '
107
108=== modified file 'lib/canonical/launchpad/webapp/session.py'
109--- lib/canonical/launchpad/webapp/session.py 2009-06-25 05:30:52 +0000
110+++ lib/canonical/launchpad/webapp/session.py 2010-02-11 16:33:22 +0000
111@@ -5,13 +5,21 @@
112
113 __metaclass__ = type
114
115+import hmac
116+import logging
117+import time
118+from email.utils import formatdate
119+from hashlib import sha1
120+
121 from cookielib import domain_match
122 from zope.component import getUtility
123-from zope.session.http import CookieClientIdManager
124+from zope.publisher.interfaces.http import IHTTPApplicationRequest
125+from zope.session.http import CookieClientIdManager, digestEncode
126
127 from storm.zope.interfaces import IZStorm
128
129 from canonical.config import config
130+from canonical.launchpad.layers import TestOpenIDLayer
131 from canonical.launchpad.webapp.url import urlparse
132
133
134@@ -20,6 +28,7 @@
135 HOURS = 60 * MINUTES
136 DAYS = 24 * HOURS
137 YEARS = 365 * DAYS
138+logger = logging.getLogger()
139
140
141 def get_cookie_domain(request_domain):
142@@ -45,13 +54,33 @@
143 class LaunchpadCookieClientIdManager(CookieClientIdManager):
144
145 def __init__(self):
146- CookieClientIdManager.__init__(self)
147- self.namespace = config.launchpad_session.cookie
148+ # Don't upcall to the parent here as it will only set self.namespace
149+ # and self.secret, which we don't want as we need to use
150+ # getNamespace(request) and we have a @property for secret.
151+
152 # Set the cookie life time to something big.
153 # It should be larger than our session expiry time.
154 self.cookieLifetime = 1 * YEARS
155 self._secret = None
156
157+ @property
158+ def namespace(self):
159+ raise AttributeError(
160+ "This class doesn't provide a namespace attribute because it "
161+ "needs a request to figure out the correct namespace. Use "
162+ "getNamespace(request) instead.")
163+
164+ def getNamespace(self, request):
165+ """Return the correct namespace according to the given request.
166+
167+ This is needed so that we can use a separate cookie for the testopenid
168+ vhost.
169+ """
170+ if TestOpenIDLayer.providedBy(request):
171+ return config.launchpad_session.testopenid_cookie
172+ else:
173+ return config.launchpad_session.cookie
174+
175 def getClientId(self, request):
176 sid = self.getRequestId(request)
177 if sid is None:
178@@ -69,7 +98,8 @@
179 request.annotations[ANNOTATION_KEY] = sid
180 return sid
181
182- def _get_secret(self):
183+ @property
184+ def secret(self):
185 # Because our CookieClientIdManager is not persistent, we need to
186 # pull the secret from some other data store - failing to do this
187 # would mean a new secret is generated every time the server is
188@@ -82,16 +112,44 @@
189 self._secret = result.get_one()[0]
190 return self._secret
191
192- def _set_secret(self, value):
193- # Silently ignored so CookieClientIdManager's __init__ method
194- # doesn't die
195- pass
196+ # Re-implement the parent's method so that we can use
197+ # self.getNamespace(request) instead of self.namespace.
198+ def getRequestId(self, request):
199+ """Return the browser id encoded in request as a string
200
201- secret = property(_get_secret, _set_secret)
202+ Return None if an id is not set.
203+ """
204+ cookie_name = self.getNamespace(request)
205+ response_cookie = request.response.getCookie(cookie_name)
206+ if response_cookie:
207+ sid = response_cookie['value']
208+ else:
209+ request = IHTTPApplicationRequest(request)
210+ sid = request.getCookies().get(cookie_name, None)
211+ if self.thirdparty:
212+ return sid
213+ else:
214+ # If there is an id set on the response, use that but
215+ # don't trust it. We need to check the response in case
216+ # there has already been a new session created during the
217+ # course of this request.
218+ if sid is None or len(sid) != 54:
219+ return None
220+ s, mac = sid[:27], sid[27:]
221+
222+ # call s.encode() to workaround a bug where the hmac
223+ # module only accepts str() types in Python 2.6
224+ if (digestEncode(hmac.new(
225+ s.encode(), self.secret, digestmod=sha1
226+ ).digest()) != mac):
227+ return None
228+ else:
229+ return sid
230
231 def setRequestId(self, request, id):
232- """As per CookieClientIdManager.setRequestID, except
233- we force the domain key on the cookie to be set to allow our
234+ """Set cookie with id on request.
235+
236+ Also force the domain key on the cookie to be set to allow our
237 session to be shared between virtual hosts where possible, and
238 we set the secure key to stop the session key being sent to
239 insecure URLs like the Librarian.
240@@ -99,12 +157,46 @@
241 We also log the referrer url on creation of a new
242 requestid so we can track where first time users arrive from.
243 """
244- # XXX: SteveAlexander, 2007-04-01.
245- # This is on the codepath where anon users get a session cookie
246- # set unnecessarily.
247- CookieClientIdManager.setRequestId(self, request, id)
248-
249- cookie = request.response.getCookie(self.namespace)
250+ # TODO: Currently, the path is the ApplicationURL. This is reasonable,
251+ # and will be adequate for most purposes.
252+ # A better path to use would be that of the folder that contains
253+ # the site manager this service is registered within. However,
254+ # that would be expensive to look up on each request, and would
255+ # have to be altered to take virtual hosting into account.
256+ # Seeing as this utility instance has a unique namespace for its
257+ # cookie, using ApplicationURL shouldn't be a problem.
258+
259+ if self.thirdparty:
260+ logger.warning(
261+ 'ClientIdManager is using thirdparty cookies, ignoring '
262+ 'setRequestId call')
263+ return
264+
265+ response = request.response
266+ options = {}
267+ if self.cookieLifetime is not None:
268+ if self.cookieLifetime:
269+ expires = formatdate(time.time() + self.cookieLifetime,
270+ localtime=False, usegmt=True)
271+ else:
272+ expires = 'Tue, 19 Jan 2038 00:00:00 GMT'
273+
274+ options['expires'] = expires
275+
276+ response.setCookie(
277+ self.getNamespace(request), id,
278+ path=request.getApplicationURL(path_only=True),
279+ **options)
280+
281+ response.setHeader(
282+ 'Cache-Control', 'no-cache="Set-Cookie,Set-Cookie2"')
283+ response.setHeader('Pragma', 'no-cache')
284+ response.setHeader('Expires', 'Mon, 26 Jul 1997 05:00:00 GMT')
285+
286+ # From here on it's our custom code -- everything else in this method
287+ # is copied from zope.session.http, replacing just self.namespace with
288+ # self.getNamespace(request).
289+ cookie = request.response.getCookie(self.getNamespace(request))
290 protocol, request_domain = urlparse(request.getURL())[:2]
291
292 # Set secure flag on cookie.
293
294=== added file 'lib/canonical/launchpad/webapp/tests/test_cookie_client_id_manager.txt'
295--- lib/canonical/launchpad/webapp/tests/test_cookie_client_id_manager.txt 1970-01-01 00:00:00 +0000
296+++ lib/canonical/launchpad/webapp/tests/test_cookie_client_id_manager.txt 2010-02-11 16:33:22 +0000
297@@ -0,0 +1,158 @@
298+= Cookie Client ID Manager =
299+
300+We use a custom ICookieClientIdManager which instead of a 'namespace'
301+attribute provides a getNamespace(request) method. That's because we sometimes
302+need to change the namespace depending on the request's layer (e.g. when the
303+request is on the TestOpenID layer we want a separate cookie from the
304+Launchpad one).
305+
306+Thanks to this requirement, our custom class has to re-implement a couple
307+methods from CookieClientIdManager that expect the 'namespace' attribute to
308+be provided.
309+
310+ >>> from canonical.launchpad.webapp.session import (
311+ ... LaunchpadCookieClientIdManager)
312+ >>> bim = LaunchpadCookieClientIdManager()
313+ >>> bim.namespace
314+ Traceback (most recent call last):
315+ ...
316+ AttributeError: This class doesn't provide a namespace attribute...
317+
318+ >>> from StringIO import StringIO
319+ >>> from zope.publisher.http import HTTPRequest
320+ >>> environ = {'SERVER_URL': 'http://launchpad.dev'}
321+ >>> request = HTTPRequest(StringIO(''), environ, None)
322+ >>> bim.getNamespace(request)
323+ 'launchpad_tests'
324+
325+When the request is in the TestOpenIDLayer, a separate cookie is used.
326+
327+ >>> from canonical.launchpad.layers import setFirstLayer, TestOpenIDLayer
328+ >>> testopenid_request = HTTPRequest(StringIO(''), environ, None)
329+ >>> setFirstLayer(testopenid_request, TestOpenIDLayer)
330+ >>> bim.getNamespace(testopenid_request)
331+ 'testopenid'
332+
333+
334+== getRequestId(request) ==
335+
336+Because no cookie has been set, we get no id:
337+
338+ >>> bim.getRequestId(request) is None
339+ True
340+
341+We can set an id:
342+
343+ >>> id1 = bim.generateUniqueId()
344+ >>> bim.setRequestId(request, id1)
345+
346+And get it back:
347+
348+ >>> bim.getRequestId(request) == id1
349+ True
350+
351+When we set the request id, we also set a response cookie. We
352+can simulate getting this cookie back in a subsequent request:
353+
354+ >>> request2 = HTTPRequest(StringIO(''), environ, None)
355+ >>> request2._cookies = dict(
356+ ... [(name, cookie['value'])
357+ ... for (name, cookie) in request.response._cookies.items()
358+ ... ])
359+
360+And we get the same id back from the new request:
361+
362+ >>> bim.getRequestId(request) == bim.getRequestId(request2)
363+ True
364+
365+Test a corner case where Python 2.6 hmac module does not allow
366+unicode as input:
367+
368+ >>> id_uni = unicode(bim.generateUniqueId())
369+ >>> bim.setRequestId(request, id_uni)
370+ >>> bim.getRequestId(request) == id_uni
371+ True
372+
373+If another server is managing the ClientId cookies (Apache, Nginx)
374+we're returning their value without checking:
375+
376+ >>> bim.thirdparty = True
377+ >>> request3 = HTTPRequest(StringIO(''), environ, None)
378+ >>> request3._cookies = {
379+ ... bim.getNamespace(request3): 'AQAAf0Y4gjgAAAQ3AwMEAg=='}
380+ >>> bim.getRequestId(request3)
381+ 'AQAAf0Y4gjgAAAQ3AwMEAg=='
382+
383+
384+== setRequestId(request, id) ==
385+
386+Note that the id is checked for validity. Setting an
387+invalid value is silently ignored:
388+
389+ >>> request = HTTPRequest(StringIO(''), environ, None)
390+ >>> bim = LaunchpadCookieClientIdManager()
391+ >>> bim.getRequestId(request)
392+ >>> bim.setRequestId(request, 'invalid id')
393+ >>> bim.getRequestId(request)
394+
395+For now, the cookie path is the application URL:
396+
397+ >>> cookie = request.response.getCookie(bim.getNamespace(request))
398+ >>> cookie['path'] == request.getApplicationURL(path_only=True)
399+ True
400+
401+By default, session cookies have a lifetime of 1 year:
402+
403+ >>> bim.cookieLifetime # 1 year in seconds.
404+ 31536000
405+ >>> cookie.has_key('expires')
406+ True
407+
408+Expiry time of 0 means never (well - close enough)
409+
410+ >>> bim.cookieLifetime = 0
411+ >>> request = HTTPRequest(StringIO(''), environ, None)
412+ >>> sid = bim.getRequestId(request)
413+ >>> bim.setRequestId(request, sid)
414+ >>> cookie = request.response.getCookie(bim.getNamespace(request))
415+ >>> cookie['expires']
416+ 'Tue, 19 Jan 2038 00:00:00 GMT'
417+
418+If another server in front of Zope (Apache, Nginx) is managing the
419+cookies we won't set any ClientId cookies:
420+
421+ >>> request = HTTPRequest(StringIO(''), environ, None)
422+ >>> bim.thirdparty = True
423+ >>> bim.setRequestId(request, '1234')
424+ WARNING:root:ClientIdManager is using thirdparty cookies...
425+ >>> cookie = request.response.getCookie(bim.getNamespace(request))
426+ >>> print cookie
427+ None
428+
429+For https requests, the secure cookie option is included.
430+
431+ >>> bim.thirdparty = False
432+ >>> bim.cookieLifetime = None
433+ >>> request = HTTPRequest(
434+ ... StringIO(''), {'SERVER_URL': 'https://example.com'}, None)
435+ >>> bim.setRequestId(request, '1234')
436+ >>> print request.response.getCookie(bim.getNamespace(request))
437+ {'path': '/', 'secure': True, 'value': '1234'}
438+
439+But for http requests, it is not.
440+
441+ >>> request = HTTPRequest(
442+ ... StringIO(''), {'SERVER_URL': 'http://example.com'}, None)
443+ >>> bim.setRequestId(request, '1234')
444+ >>> print request.response.getCookie(bim.getNamespace(request))
445+ {'path': '/', 'secure': False, 'value': '1234'}
446+
447+When the cookie is set, cache headers are added to the
448+response to try to prevent the cookie header from being cached:
449+
450+ >>> request.response.getHeader('Cache-Control')
451+ 'no-cache="Set-Cookie,Set-Cookie2"'
452+ >>> request.response.getHeader('Pragma')
453+ 'no-cache'
454+ >>> request.response.getHeader('Expires')
455+ 'Mon, 26 Jul 1997 05:00:00 GMT'
456
457=== modified file 'lib/canonical/launchpad/webapp/tests/test_dbpolicy.py'
458--- lib/canonical/launchpad/webapp/tests/test_dbpolicy.py 2010-01-13 20:06:09 +0000
459+++ lib/canonical/launchpad/webapp/tests/test_dbpolicy.py 2010-02-11 16:33:22 +0000
460@@ -191,7 +191,7 @@
461 newInteraction(request)
462 try:
463 # First, generate a valid session cookie.
464- cookie_name = getUtility(IClientIdManager).namespace
465+ cookie_name = getUtility(IClientIdManager).getNamespace(request)
466 ISession(request)['whatever']['whatever'] = 'whatever'
467 # Then stuff it into the request where we expect to
468 # find it. The database policy is only interested if
469
470=== modified file 'lib/canonical/launchpad/webapp/tests/test_session.py'
471--- lib/canonical/launchpad/webapp/tests/test_session.py 2009-06-25 05:30:52 +0000
472+++ lib/canonical/launchpad/webapp/tests/test_session.py 2010-02-11 16:33:22 +0000
473@@ -3,6 +3,9 @@
474
475 import unittest
476
477+from canonical.testing import DatabaseFunctionalLayer
478+from canonical.launchpad.testing.systemdocs import (
479+ LayeredDocFileSuite, setUp, tearDown)
480 from canonical.launchpad.webapp.session import get_cookie_domain
481
482
483@@ -44,4 +47,9 @@
484
485
486 def test_suite():
487- return unittest.TestLoader().loadTestsFromName(__name__)
488+ suite = unittest.TestSuite()
489+ suite.addTest(LayeredDocFileSuite(
490+ 'test_cookie_client_id_manager.txt',
491+ layer=DatabaseFunctionalLayer, setUp=setUp, tearDown=tearDown))
492+ suite.addTests(unittest.TestLoader().loadTestsFromName(__name__))
493+ return suite