Merge lp:~leonardr/launchpad/revert-oauth-aware-website into lp:launchpad

Proposed by Leonard Richardson
Status: Merged
Merged at revision: 11597
Proposed branch: lp:~leonardr/launchpad/revert-oauth-aware-website
Merge into: lp:launchpad
Diff against target: 1091 lines (+148/-550)
9 files modified
lib/canonical/launchpad/browser/oauth.py (+7/-106)
lib/canonical/launchpad/database/oauth.py (+8/-11)
lib/canonical/launchpad/pagetests/oauth/authorize-token.txt (+19/-207)
lib/canonical/launchpad/webapp/authentication.py (+5/-131)
lib/canonical/launchpad/webapp/servers.py (+94/-3)
lib/canonical/launchpad/zcml/launchpad.zcml (+2/-2)
lib/lp/services/job/runner.py (+2/-5)
lib/lp/testing/__init__.py (+1/-5)
lib/lp/testing/_webservice.py (+10/-80)
To merge this branch: bzr merge lp:~leonardr/launchpad/revert-oauth-aware-website
Reviewer Review Type Date Requested Status
Curtis Hovey (community) code Approve
Review via email: mp+36040@code.launchpad.net

Description of the change

This branch reverts my recent branch to make parts of the Launchpad website accept OAuth-signed requests. I'm reverting it not because the code is bad, but because the requirements changed immediately after I merged this branch, rendering it moot.

To post a comment you must log in.
Revision history for this message
Curtis Hovey (sinzui) wrote :

I'm sad so see the backed out. This is okay to land.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/browser/oauth.py'
2--- lib/canonical/launchpad/browser/oauth.py 2010-09-15 20:06:13 +0000
3+++ lib/canonical/launchpad/browser/oauth.py 2010-09-21 16:32:05 +0000
4@@ -11,13 +11,11 @@
5
6 from lazr.restful import HTTPResource
7 import simplejson
8-from zope.authentication.interfaces import IUnauthenticatedPrincipal
9 from zope.component import getUtility
10 from zope.formlib.form import (
11 Action,
12 Actions,
13 )
14-from zope.security.interfaces import Unauthorized
15
16 from canonical.launchpad.interfaces.oauth import (
17 IOAuthConsumerSet,
18@@ -31,15 +29,9 @@
19 )
20 from canonical.launchpad.webapp.authentication import (
21 check_oauth_signature,
22- extract_oauth_access_token,
23 get_oauth_authorization,
24- get_oauth_principal
25- )
26-from canonical.launchpad.webapp.interfaces import (
27- AccessLevel,
28- ILaunchBag,
29- OAuthPermission,
30- )
31+ )
32+from canonical.launchpad.webapp.interfaces import OAuthPermission
33 from lp.app.errors import UnexpectedFormData
34 from lp.registry.interfaces.distribution import IDistributionSet
35 from lp.registry.interfaces.pillar import IPillarNameSet
36@@ -106,7 +98,6 @@
37 return u'oauth_token=%s&oauth_token_secret=%s' % (
38 token.key, token.secret)
39
40-
41 def token_exists_and_is_not_reviewed(form, action):
42 return form.token is not None and not form.token.is_reviewed
43
44@@ -115,10 +106,8 @@
45 """Return a list of `Action`s for each possible `OAuthPermission`."""
46 actions = Actions()
47 actions_excluding_grant_permissions = Actions()
48-
49 def success(form, action, data):
50 form.reviewToken(action.permission)
51-
52 for permission in OAuthPermission.items:
53 action = Action(
54 permission.title, name=permission.name, success=success,
55@@ -129,86 +118,7 @@
56 actions_excluding_grant_permissions.append(action)
57 return actions, actions_excluding_grant_permissions
58
59-
60-class CredentialManagerAwareMixin:
61- """A view for which a browser may authenticate with an OAuth token.
62-
63- The OAuth token must be signed with a token that has the
64- GRANT_PERMISSIONS access level, and the browser must present
65- itself as the Launchpad Credentials Manager.
66- """
67- # A prefix identifying the Launchpad Credential Manager's
68- # User-Agent string.
69- GRANT_PERMISSIONS_USER_AGENT_PREFIX = "Launchpad Credentials Manager"
70-
71- def ensureRequestIsAuthorizedOrSigned(self):
72- """Find the user who initiated the request.
73-
74- This property is used by a view that wants to reject access
75- unless the end-user is authenticated with cookie auth, HTTP
76- Basic Auth, *or* a properly authorized OAuth token.
77-
78- If the user is logged in with cookie auth or HTTP Basic, then
79- other parts of Launchpad have taken care of the login and we
80- don't have to do anything. But if the user's browser has
81- signed the request with an OAuth token, other parts of
82- Launchpad won't recognize that as an attempt to authorize the
83- request.
84-
85- This method does the OAuth part of the work. It checks that
86- the OAuth token is valid, that it's got the correct access
87- level, and that the User-Agent is one that's allowed to sign
88- requests with OAuth tokens.
89-
90- :return: The user who Launchpad identifies as the principal.
91- Or, if Launchpad identifies no one as the principal, the user
92- whose valid GRANT_PERMISSIONS OAuth token was used to sign
93- the request.
94-
95- :raise Unauthorized: If the request is unauthorized and
96- unsigned, improperly signed, anonymously signed, or signed
97- with a token that does not have the right access level.
98- """
99- user = getUtility(ILaunchBag).user
100- if user is not None:
101- return user
102- # The normal Launchpad code was not able to identify any
103- # user, but we're going to try a little harder before
104- # concluding that no one's logged in. If the incoming
105- # request is signed by an OAuth access token with the
106- # GRANT_PERMISSIONS access level, we will force a
107- # temporary login with the user whose access token this
108- # is.
109- token = extract_oauth_access_token(self.request)
110- if token is None:
111- # The request is not OAuth-signed. The normal Launchpad
112- # code had it right: no one is authenticated.
113- raise Unauthorized("Anonymous access is not allowed.")
114- principal = get_oauth_principal(self.request)
115- if IUnauthenticatedPrincipal.providedBy(principal):
116- # The request is OAuth-signed, but as the anonymous
117- # user.
118- raise Unauthorized("Anonymous access is not allowed.")
119- if token.permission != AccessLevel.GRANT_PERMISSIONS:
120- # The request is OAuth-signed, but the token has
121- # the wrong access level.
122- raise Unauthorized("OAuth token has insufficient access level.")
123-
124- # Both the consumer key and the User-Agent must identify the
125- # Launchpad Credentials Manager.
126- must_start_with_prefix = [
127- token.consumer.key, self.request.getHeader("User-Agent")]
128- for string in must_start_with_prefix:
129- if not string.startswith(
130- self.GRANT_PERMISSIONS_USER_AGENT_PREFIX):
131- raise Unauthorized(
132- "Only the Launchpad Credentials Manager can access this "
133- "page by signing requests with an OAuth token.")
134- return principal.person
135-
136-
137-class OAuthAuthorizeTokenView(
138- LaunchpadFormView, JSONTokenMixin, CredentialManagerAwareMixin):
139+class OAuthAuthorizeTokenView(LaunchpadFormView, JSONTokenMixin):
140 """Where users authorize consumers to access Launchpad on their behalf."""
141
142 actions, actions_excluding_grant_permissions = (
143@@ -257,12 +167,6 @@
144 and len(allowed_permissions) > 1):
145 allowed_permissions.remove(OAuthPermission.GRANT_PERMISSIONS.name)
146
147- # GRANT_PERMISSIONS may only be requested by a specific User-Agent.
148- if (OAuthPermission.GRANT_PERMISSIONS.name in allowed_permissions
149- and not self.request.getHeader("User-Agent").startswith(
150- self.GRANT_PERMISSIONS_USER_AGENT_PREFIX)):
151- allowed_permissions.remove(OAuthPermission.GRANT_PERMISSIONS.name)
152-
153 for action in self.actions:
154 if (action.permission.name in allowed_permissions
155 or action.permission is OAuthPermission.UNAUTHORIZED):
156@@ -280,10 +184,9 @@
157 return actions
158
159 def initialize(self):
160- self.oauth_authorized_user = self.ensureRequestIsAuthorizedOrSigned()
161 self.storeTokenContext()
162-
163- key = self.request.form.get('oauth_token')
164+ form = get_oauth_authorization(self.request)
165+ key = form.get('oauth_token')
166 if key:
167 self.token = getUtility(IOAuthRequestTokenSet).getByKey(key)
168 super(OAuthAuthorizeTokenView, self).initialize()
169@@ -314,8 +217,7 @@
170 self.token_context = context
171
172 def reviewToken(self, permission):
173- self.token.review(self.user or self.oauth_authorized_user,
174- permission, self.token_context)
175+ self.token.review(self.user, permission, self.token_context)
176 callback = self.request.form.get('oauth_callback')
177 if callback:
178 self.next_url = callback
179@@ -343,7 +245,7 @@
180 return context
181
182
183-class OAuthTokenAuthorizedView(LaunchpadView, CredentialManagerAwareMixin):
184+class OAuthTokenAuthorizedView(LaunchpadView):
185 """Where users who reviewed tokens may get redirected to.
186
187 If the consumer didn't include an oauth_callback when sending the user to
188@@ -352,7 +254,6 @@
189 """
190
191 def initialize(self):
192- authorized_user = self.ensureRequestIsAuthorizedOrSigned()
193 key = self.request.form.get('oauth_token')
194 self.token = getUtility(IOAuthRequestTokenSet).getByKey(key)
195 assert self.token.is_reviewed, (
196
197=== modified file 'lib/canonical/launchpad/database/oauth.py'
198--- lib/canonical/launchpad/database/oauth.py 2010-09-15 20:55:03 +0000
199+++ lib/canonical/launchpad/database/oauth.py 2010-09-21 16:32:05 +0000
200@@ -60,14 +60,14 @@
201
202 # How many hours should a request token be valid for?
203 REQUEST_TOKEN_VALIDITY = 12
204-# The OAuth Core 1.0 spec (http://oauth.net/core/1.0/#nonce) says that
205-# a timestamp "MUST be equal or greater than the timestamp used in
206-# previous requests," but this is likely to cause problems if the
207-# client does request pipelining, so we use a time window (relative to
208-# the timestamp of the existing OAuthNonce) to check if the timestamp
209-# can is acceptable. As suggested by Robert, we use a window which is
210-# at least twice the size of our hard time out. This is a safe bet
211-# since no requests should take more than one hard time out.
212+# The OAuth Core 1.0 spec (http://oauth.net/core/1.0/#nonce) says that a
213+# timestamp "MUST be equal or greater than the timestamp used in previous
214+# requests," but this is likely to cause problems if the client does request
215+# pipelining, so we use a time window (relative to the timestamp of the
216+# existing OAuthNonce) to check if the timestamp can is acceptable. As
217+# suggested by Robert, we use a window which is at least twice the size of our
218+# hard time out. This is a safe bet since no requests should take more than
219+# one hard time out.
220 TIMESTAMP_ACCEPTANCE_WINDOW = 60 # seconds
221 # If the timestamp is far in the future because of a client's clock skew,
222 # it will effectively invalidate the authentication tokens when the clock is
223@@ -77,7 +77,6 @@
224 # amount.
225 TIMESTAMP_SKEW_WINDOW = 60*60 # seconds, +/-
226
227-
228 class OAuthBase(SQLBase):
229 """Base class for all OAuth database classes."""
230
231@@ -94,7 +93,6 @@
232
233 getStore = _get_store
234
235-
236 class OAuthConsumer(OAuthBase):
237 """See `IOAuthConsumer`."""
238 implements(IOAuthConsumer)
239@@ -325,7 +323,6 @@
240 The key will have a length of 20 and we'll make sure it's not yet in the
241 given table. The secret will have a length of 80.
242 """
243-
244 key_length = 20
245 key = create_unique_token_for_table(key_length, getattr(table, "key"))
246 secret_length = 80
247
248=== modified file 'lib/canonical/launchpad/pagetests/oauth/authorize-token.txt'
249--- lib/canonical/launchpad/pagetests/oauth/authorize-token.txt 2010-09-16 21:34:31 +0000
250+++ lib/canonical/launchpad/pagetests/oauth/authorize-token.txt 2010-09-21 16:32:05 +0000
251@@ -1,6 +1,4 @@
252-***************************
253-Authorizing a request token
254-***************************
255+= Authorizing a request token =
256
257 Once the consumer gets a request token, it must send the user to
258 Launchpad's +authorize-token page in order for the user to authenticate
259@@ -21,10 +19,9 @@
260 The oauth_token parameter, on the other hand, is required in the
261 Launchpad implementation.
262
263-Access to the page
264-==================
265-
266-The +authorize-token page is restricted to authenticated users.
267+The +authorize-token page is restricted to logged in users, so users will
268+first be asked to log in. (We won't show the actual login process because
269+it involves OpenID, which would complicate this test quite a bit.)
270
271 >>> from urllib import urlencode
272 >>> params = dict(
273@@ -33,18 +30,7 @@
274 >>> browser.open(url)
275 Traceback (most recent call last):
276 ...
277- Unauthorized: Anonymous access is not allowed.
278-
279-However, the details of the authentication are different than from any
280-other part of Launchpad. Unlike with other pages, a user can authorize
281-an OAuth token by signing their outgoing requests with an _existing_
282-OAuth token. This makes it possible for a desktop client to retrieve
283-this page without knowing the end-user's username and password, or
284-making them navigate the arbitrarily complex OpenID login procedure.
285-
286-But, let's deal with that a little later. First let's show how the
287-process works through HTTP Basic Auth (the testing equivalent of a
288-regular username-and-password login).
289+ Unauthorized:...
290
291 >>> browser = setupBrowser(auth='Basic no-priv@canonical.com:test')
292 >>> browser.open(url)
293@@ -58,10 +44,6 @@
294 ...
295 See all applications authorized to access Launchpad on your behalf.
296
297-
298-Using the page
299-==============
300-
301 This page contains one submit button for each item of OAuthPermission,
302 except for 'Grant Permissions', which must be specifically requested.
303
304@@ -92,34 +74,7 @@
305 that isn't enough for the application. The user always has the option
306 to deny permission altogether.
307
308- >>> def filter_user_agent(key, value, new_value):
309- ... """A filter to replace the User-Agent header in a list of headers.
310- ...
311- ... [XXX bug=638058] This is a hack to work around a bug in
312- ... zope.testbrowser.
313- ... """
314- ...
315- ... if key.lower() == "user-agent":
316- ... return (key, new_value)
317- ... return (key, value)
318-
319- >>> def print_access_levels(allow_permission, user_agent=None):
320- ... if user_agent is not None:
321- ... # [XXX bug=638058] This is a hack to work around a bug in
322- ... # zope.testbrowser which prevents browser.addHeader
323- ... # from working with User-Agent.
324- ... mech_browser = browser.mech_browser
325- ... # Store the original User-Agent for later.
326- ... old_user_agent = [
327- ... value for key, value in mech_browser.addheaders
328- ... if key.lower() == "user-agent"][0]
329- ... # Replace the User-Agent with the value passed into this
330- ... # function.
331- ... mech_browser.addheaders = [
332- ... filter_user_agent(key, value, user_agent)
333- ... for key, value in mech_browser.addheaders]
334- ...
335- ... # Okay, now we can make the request.
336+ >>> def print_access_levels(allow_permission):
337 ... browser.open(
338 ... "http://launchpad.dev/+authorize-token?%s&%s"
339 ... % (urlencode(params), allow_permission))
340@@ -127,13 +82,6 @@
341 ... actions = main_content.findAll('input', attrs={'type': 'submit'})
342 ... for action in actions:
343 ... print action['value']
344- ...
345- ... if user_agent is not None:
346- ... # Finally, restore the old User-Agent.
347- ... mech_browser.addheaders = [
348- ... filter_user_agent(key, value, old_user_agent)
349- ... for key, value in mech_browser.addheaders]
350-
351
352 >>> print_access_levels(
353 ... 'allow_permission=WRITE_PUBLIC&allow_permission=WRITE_PRIVATE')
354@@ -142,38 +90,23 @@
355 Change Anything
356
357 The only time the 'Grant Permissions' permission shows up in this list
358-is if a client identifying itself as the Launchpad Credentials Manager
359-specifically requests it, and no other permission. (Also requesting
360-UNAUTHORIZED is okay--it will show up anyway.)
361-
362- >>> USER_AGENT = "Launchpad Credentials Manager v1.0"
363- >>> print_access_levels(
364- ... 'allow_permission=GRANT_PERMISSIONS', USER_AGENT)
365- No Access
366- Grant Permissions
367-
368- >>> print_access_levels(
369- ... ('allow_permission=GRANT_PERMISSIONS&'
370- ... 'allow_permission=UNAUTHORIZED'),
371- ... USER_AGENT)
372- No Access
373- Grant Permissions
374-
375- >>> print_access_levels(
376- ... ('allow_permission=WRITE_PUBLIC&'
377- ... 'allow_permission=GRANT_PERMISSIONS'))
378- No Access
379- Change Non-Private Data
380-
381-If a client asks for GRANT_PERMISSIONS but doesn't claim to be the
382-Launchpad Credentials Manager, Launchpad will not show GRANT_PERMISSIONS.
383+is if the client specifically requests it, and no other
384+permission. (Also requesting UNAUTHORIZED is okay--it will show up
385+anyway.)
386
387 >>> print_access_levels('allow_permission=GRANT_PERMISSIONS')
388 No Access
389- Read Non-Private Data
390+ Grant Permissions
391+
392+ >>> print_access_levels(
393+ ... 'allow_permission=GRANT_PERMISSIONS&allow_permission=UNAUTHORIZED')
394+ No Access
395+ Grant Permissions
396+
397+ >>> print_access_levels(
398+ ... 'allow_permission=WRITE_PUBLIC&allow_permission=GRANT_PERMISSIONS')
399+ No Access
400 Change Non-Private Data
401- Read Anything
402- Change Anything
403
404 If an application doesn't specify any valid access levels, or only
405 specifies the UNAUTHORIZED access level, Launchpad will show all the
406@@ -330,124 +263,3 @@
407 This request for accessing Launchpad on your behalf has been
408 reviewed ... ago.
409 See all applications authorized to access Launchpad on your behalf.
410-
411-Access through OAuth
412-====================
413-
414-Now it's time to show how to go through the same process without
415-knowing the end-user's username and password. All you need is an OAuth
416-token issued with the GRANT_PERMISSIONS access level, in the name of
417-the Launchpad Credentials Manager.
418-
419-Let's go through the approval process again, without ever sending the
420-user's username or password over HTTP. First we'll create a new user,
421-and a GRANT_PERMISSIONS access token that they can use to sign
422-requests.
423-
424- >>> login(ANONYMOUS)
425- >>> user = factory.makePerson(name="test-user", password="never-used")
426- >>> logout()
427-
428- >>> from oauth.oauth import OAuthConsumer
429- >>> manager_consumer = OAuthConsumer("Launchpad Credentials Manager", "")
430-
431- >>> from lp.testing import oauth_access_token_for
432- >>> login_person(user)
433- >>> grant_permissions_token = oauth_access_token_for(
434- ... manager_consumer.key, user, "GRANT_PERMISSIONS")
435- >>> logout()
436-
437-Next, we'll give the new user an OAuth request token that needs to be
438-approved using a web browser.
439-
440- >>> login_person(user)
441- >>> consumer = getUtility(IOAuthConsumerSet).getByKey('foobar123451432')
442- >>> request_token = consumer.newRequestToken()
443- >>> logout()
444-
445- >>> params = dict(oauth_token=request_token.key)
446- >>> url = "http://launchpad.dev/+authorize-token?%s" % urlencode(params)
447-
448-Next, we'll create a browser object that knows how to sign requests
449-with the new user's existing access token.
450-
451- >>> from lp.testing import OAuthSigningBrowser
452- >>> browser = OAuthSigningBrowser(
453- ... manager_consumer, grant_permissions_token, USER_AGENT)
454- >>> browser.open(url)
455- >>> print browser.title
456- Authorize application to access Launchpad on your behalf
457-
458-The browser object can approve the request and see the appropriate
459-messages, even though we never gave it the user's password.
460-
461- >>> browser.getControl('Read Anything').click()
462-
463- >>> browser.url
464- 'http://launchpad.dev/+token-authorized?...'
465- >>> print extract_text(find_tag_by_id(browser.contents, 'maincontent'))
466- Almost finished ...
467- To finish authorizing the application identified as foobar123451432 to
468- access Launchpad on your behalf you should go back to the application
469- window in which you started the process and inform it that you have done
470- your part of the process.
471-
472-OAuth error conditions
473-----------------------
474-
475-The OAuth token used to sign the requests must have the
476-GRANT_PERMISSIONS access level; no other access level will work.
477-
478- >>> login(ANONYMOUS)
479- >>> insufficient_token = oauth_access_token_for(
480- ... manager_consumer.key, user, "WRITE_PRIVATE")
481- >>> logout()
482-
483- >>> browser = OAuthSigningBrowser(
484- ... manager_consumer, insufficient_token, USER_AGENT)
485- >>> browser.open(url)
486- Traceback (most recent call last):
487- ...
488- Unauthorized: OAuth token has insufficient access level.
489-
490-The OAuth token must be for the Launchpad Credentials Manager, or it
491-cannot be used. (Launchpad shouldn't even _issue_ a GRANT_PERMISSIONS
492-token for any other consumer, but even if it somehow does, that token
493-can't be used for this.)
494-
495- >>> login(ANONYMOUS)
496- >>> wrong_consumer = OAuthConsumer(
497- ... "Not the Launchpad Credentials Manager", "")
498- >>> wrong_consumer_token = oauth_access_token_for(
499- ... wrong_consumer.key, user, "GRANT_PERMISSIONS")
500- >>> logout()
501-
502- >>> browser = OAuthSigningBrowser(wrong_consumer, wrong_consumer_token)
503- >>> browser.open(url)
504- Traceback (most recent call last):
505- ...
506- Unauthorized: Only the Launchpad Credentials Manager can access
507- this page by signing requests with an OAuth token.
508-
509-Signing with an anonymous token will also not work.
510-
511- >>> from oauth.oauth import OAuthToken
512- >>> anonymous_token = OAuthToken(key="", secret="")
513- >>> browser = OAuthSigningBrowser(manager_consumer, anonymous_token)
514- >>> browser.open(url)
515- Traceback (most recent call last):
516- ...
517- Unauthorized: Anonymous access is not allowed.
518-
519-Even if it presents the right token, the user agent sending the signed
520-request must *also* identify *itself* as the Launchpad Credentials
521-Manager.
522-
523- >>> browser = OAuthSigningBrowser(
524- ... manager_consumer, grant_permissions_token,
525- ... "Not the Launchpad Credentials Manager")
526- >>> browser.open(url)
527- Traceback (most recent call last):
528- ...
529- Unauthorized: Only the Launchpad Credentials Manager can access
530- this page by signing requests with an OAuth token.
531
532=== modified file 'lib/canonical/launchpad/webapp/authentication.py'
533--- lib/canonical/launchpad/webapp/authentication.py 2010-09-16 15:40:56 +0000
534+++ lib/canonical/launchpad/webapp/authentication.py 2010-09-21 16:32:05 +0000
535@@ -5,21 +5,16 @@
536
537 __all__ = [
538 'check_oauth_signature',
539- 'extract_oauth_access_token',
540- 'get_oauth_principal',
541 'get_oauth_authorization',
542 'LaunchpadLoginSource',
543 'LaunchpadPrincipal',
544- 'OAuthSignedRequest',
545 'PlacelessAuthUtility',
546 'SSHADigestEncryptor',
547 ]
548
549
550 import binascii
551-from datetime import datetime
552 import hashlib
553-import pytz
554 import random
555 from UserDict import UserDict
556
557@@ -28,18 +23,13 @@
558 from zope.app.security.interfaces import ILoginPassword
559 from zope.app.security.principalregistry import UnauthenticatedPrincipal
560 from zope.authentication.interfaces import IUnauthenticatedPrincipal
561-
562 from zope.component import (
563 adapts,
564 getUtility,
565 )
566 from zope.event import notify
567-from zope.interface import (
568- alsoProvides,
569- implements,
570- )
571+from zope.interface import implements
572 from zope.preference.interfaces import IPreferenceGroup
573-from zope.security.interfaces import Unauthorized
574 from zope.security.proxy import removeSecurityProxy
575 from zope.session.interfaces import ISession
576
577@@ -54,14 +44,6 @@
578 ILaunchpadPrincipal,
579 IPlacelessAuthUtility,
580 IPlacelessLoginSource,
581- OAuthPermission,
582- )
583-from canonical.launchpad.interfaces.oauth import (
584- ClockSkew,
585- IOAuthConsumerSet,
586- IOAuthSignedRequest,
587- NonceAlreadyUsed,
588- TimestampOrderingError,
589 )
590 from lp.registry.interfaces.person import (
591 IPerson,
592@@ -69,113 +51,6 @@
593 )
594
595
596-def extract_oauth_access_token(request):
597- """Find the OAuth access token that signed the given request.
598-
599- :param request: An incoming request.
600-
601- :return: an IOAuthAccessToken, or None if the request is not
602- signed at all.
603-
604- :raise Unauthorized: If the token is invalid or the request is an
605- anonymously-signed request that doesn't meet our requirements.
606- """
607- # Fetch OAuth authorization information from the request.
608- form = get_oauth_authorization(request)
609-
610- consumer_key = form.get('oauth_consumer_key')
611- consumers = getUtility(IOAuthConsumerSet)
612- consumer = consumers.getByKey(consumer_key)
613- token_key = form.get('oauth_token')
614- anonymous_request = (token_key == '')
615-
616- if consumer_key is None:
617- # Either the client's OAuth implementation is broken, or
618- # the user is trying to make an unauthenticated request
619- # using wget or another OAuth-ignorant application.
620- # Try to retrieve a consumer based on the User-Agent
621- # header.
622- anonymous_request = True
623- consumer_key = request.getHeader('User-Agent', '')
624- if consumer_key == '':
625- raise Unauthorized(
626- 'Anonymous requests must provide a User-Agent.')
627- consumer = consumers.getByKey(consumer_key)
628-
629- if consumer is None:
630- if anonymous_request:
631- # This is the first time anyone has tried to make an
632- # anonymous request using this consumer name (or user
633- # agent). Dynamically create the consumer.
634- #
635- # In the normal website this wouldn't be possible
636- # because GET requests have their transactions rolled
637- # back. But webservice requests always have their
638- # transactions committed so that we can keep track of
639- # the OAuth nonces and prevent replay attacks.
640- if consumer_key == '' or consumer_key is None:
641- raise Unauthorized("No consumer key specified.")
642- consumer = consumers.new(consumer_key, '')
643- else:
644- # An unknown consumer can never make a non-anonymous
645- # request, because access tokens are registered with a
646- # specific, known consumer.
647- raise Unauthorized('Unknown consumer (%s).' % consumer_key)
648- if anonymous_request:
649- # Skip the OAuth verification step and let the user access the
650- # web service as an unauthenticated user.
651- #
652- # XXX leonardr 2009-12-15 bug=496964: Ideally we'd be
653- # auto-creating a token for the anonymous user the first
654- # time, passing it through the OAuth verification step,
655- # and using it on all subsequent anonymous requests.
656- return None
657-
658- token = consumer.getAccessToken(token_key)
659- if token is None:
660- raise Unauthorized('Unknown access token (%s).' % token_key)
661- return token
662-
663-
664-def get_oauth_principal(request):
665- """Find the principal to use for this OAuth-signed request.
666-
667- :param request: An incoming request.
668- :return: An ILaunchpadPrincipal with the appropriate access level.
669- """
670- token = extract_oauth_access_token(request)
671-
672- if token is None:
673- # The consumer is making an anonymous request. If there was a
674- # problem with the access token, extract_oauth_access_token
675- # would have raised Unauthorized.
676- alsoProvides(request, IOAuthSignedRequest)
677- auth_utility = getUtility(IPlacelessAuthUtility)
678- return auth_utility.unauthenticatedPrincipal()
679-
680- form = get_oauth_authorization(request)
681- nonce = form.get('oauth_nonce')
682- timestamp = form.get('oauth_timestamp')
683- try:
684- token.checkNonceAndTimestamp(nonce, timestamp)
685- except (NonceAlreadyUsed, TimestampOrderingError, ClockSkew), e:
686- raise Unauthorized('Invalid nonce/timestamp: %s' % e)
687- now = datetime.now(pytz.timezone('UTC'))
688- if token.permission == OAuthPermission.UNAUTHORIZED:
689- raise Unauthorized('Unauthorized token (%s).' % token.key)
690- elif token.date_expires is not None and token.date_expires <= now:
691- raise Unauthorized('Expired token (%s).' % token.key)
692- elif not check_oauth_signature(request, token.consumer, token):
693- raise Unauthorized('Invalid signature.')
694- else:
695- # Everything is fine, let's return the principal.
696- pass
697- alsoProvides(request, IOAuthSignedRequest)
698- return getUtility(IPlacelessLoginSource).getPrincipal(
699- token.person.account.id, access_level=token.permission,
700- scope=token.context)
701-
702-
703 class PlacelessAuthUtility:
704 """An authentication service which holds no state aside from its
705 ZCML configuration, implemented as a utility.
706@@ -200,8 +75,9 @@
707 # as the login form is never visited for BasicAuth.
708 # This we treat each request as a separate
709 # login/logout.
710- notify(
711- BasicAuthLoggedInEvent(request, login, principal))
712+ notify(BasicAuthLoggedInEvent(
713+ request, login, principal
714+ ))
715 return principal
716
717 def _authenticateUsingCookieAuth(self, request):
718@@ -314,8 +190,7 @@
719 plaintext = str(plaintext)
720 if salt is None:
721 salt = self.generate_salt()
722- v = binascii.b2a_base64(
723- hashlib.sha1(plaintext + salt).digest() + salt)
724+ v = binascii.b2a_base64(hashlib.sha1(plaintext + salt).digest() + salt)
725 return v[:-1]
726
727 def validate(self, plaintext, encrypted):
728@@ -459,7 +334,6 @@
729
730 # zope.app.apidoc expects our principals to be adaptable into IAnnotations, so
731 # we use these dummy adapters here just to make that code not OOPS.
732-
733 class TemporaryPrincipalAnnotations(UserDict):
734 implements(IAnnotations)
735 adapts(ILaunchpadPrincipal, IPreferenceGroup)
736
737=== modified file 'lib/canonical/launchpad/webapp/servers.py'
738--- lib/canonical/launchpad/webapp/servers.py 2010-09-16 21:08:51 +0000
739+++ lib/canonical/launchpad/webapp/servers.py 2010-09-21 16:32:05 +0000
740@@ -8,6 +8,7 @@
741 __metaclass__ = type
742
743 import cgi
744+from datetime import datetime
745 import threading
746 import xmlrpclib
747
748@@ -21,6 +22,7 @@
749 WebServiceRequestTraversal,
750 )
751 from lazr.uri import URI
752+import pytz
753 import transaction
754 from transaction.interfaces import ISynchronizer
755 from zc.zservertracelog.tracelog import Server as ZServerTracelogServer
756@@ -48,7 +50,10 @@
757 XMLRPCRequest,
758 XMLRPCResponse,
759 )
760-from zope.security.interfaces import IParticipation
761+from zope.security.interfaces import (
762+ IParticipation,
763+ Unauthorized,
764+ )
765 from zope.security.proxy import (
766 isinstance as zope_isinstance,
767 removeSecurityProxy,
768@@ -63,9 +68,17 @@
769 IPrivateApplication,
770 IWebServiceApplication,
771 )
772+from canonical.launchpad.interfaces.oauth import (
773+ ClockSkew,
774+ IOAuthConsumerSet,
775+ IOAuthSignedRequest,
776+ NonceAlreadyUsed,
777+ TimestampOrderingError,
778+ )
779 import canonical.launchpad.layers
780 from canonical.launchpad.webapp.authentication import (
781- get_oauth_principal,
782+ check_oauth_signature,
783+ get_oauth_authorization,
784 )
785 from canonical.launchpad.webapp.authorization import (
786 LAUNCHPAD_SECURITY_POLICY_CACHE_KEY,
787@@ -80,6 +93,8 @@
788 INotificationRequest,
789 INotificationResponse,
790 IPlacelessAuthUtility,
791+ IPlacelessLoginSource,
792+ OAuthPermission,
793 )
794 from canonical.launchpad.webapp.notifications import (
795 NotificationList,
796@@ -1201,7 +1216,83 @@
797 if request_path.startswith("/%s" % web_service_config.path_override):
798 return super(WebServicePublication, self).getPrincipal(request)
799
800- return get_oauth_principal(request)
801+ # Fetch OAuth authorization information from the request.
802+ form = get_oauth_authorization(request)
803+
804+ consumer_key = form.get('oauth_consumer_key')
805+ consumers = getUtility(IOAuthConsumerSet)
806+ consumer = consumers.getByKey(consumer_key)
807+ token_key = form.get('oauth_token')
808+ anonymous_request = (token_key == '')
809+
810+ if consumer_key is None:
811+ # Either the client's OAuth implementation is broken, or
812+ # the user is trying to make an unauthenticated request
813+ # using wget or another OAuth-ignorant application.
814+ # Try to retrieve a consumer based on the User-Agent
815+ # header.
816+ anonymous_request = True
817+ consumer_key = request.getHeader('User-Agent', '')
818+ if consumer_key == '':
819+ raise Unauthorized(
820+ 'Anonymous requests must provide a User-Agent.')
821+ consumer = consumers.getByKey(consumer_key)
822+
823+ if consumer is None:
824+ if anonymous_request:
825+ # This is the first time anyone has tried to make an
826+ # anonymous request using this consumer name (or user
827+ # agent). Dynamically create the consumer.
828+ #
829+ # In the normal website this wouldn't be possible
830+ # because GET requests have their transactions rolled
831+ # back. But webservice requests always have their
832+ # transactions committed so that we can keep track of
833+ # the OAuth nonces and prevent replay attacks.
834+ if consumer_key == '' or consumer_key is None:
835+ raise Unauthorized("No consumer key specified.")
836+ consumer = consumers.new(consumer_key, '')
837+ else:
838+ # An unknown consumer can never make a non-anonymous
839+ # request, because access tokens are registered with a
840+ # specific, known consumer.
841+ raise Unauthorized('Unknown consumer (%s).' % consumer_key)
842+ if anonymous_request:
843+ # Skip the OAuth verification step and let the user access the
844+ # web service as an unauthenticated user.
845+ #
846+ # XXX leonardr 2009-12-15 bug=496964: Ideally we'd be
847+ # auto-creating a token for the anonymous user the first
848+ # time, passing it through the OAuth verification step,
849+ # and using it on all subsequent anonymous requests.
850+ alsoProvides(request, IOAuthSignedRequest)
851+ auth_utility = getUtility(IPlacelessAuthUtility)
852+ return auth_utility.unauthenticatedPrincipal()
853+ token = consumer.getAccessToken(token_key)
854+ if token is None:
855+ raise Unauthorized('Unknown access token (%s).' % token_key)
856+ nonce = form.get('oauth_nonce')
857+ timestamp = form.get('oauth_timestamp')
858+ try:
859+ token.checkNonceAndTimestamp(nonce, timestamp)
860+ except (NonceAlreadyUsed, TimestampOrderingError, ClockSkew), e:
861+ raise Unauthorized('Invalid nonce/timestamp: %s' % e)
862+ now = datetime.now(pytz.timezone('UTC'))
863+ if token.permission == OAuthPermission.UNAUTHORIZED:
864+ raise Unauthorized('Unauthorized token (%s).' % token.key)
865+ elif token.date_expires is not None and token.date_expires <= now:
866+ raise Unauthorized('Expired token (%s).' % token.key)
867+ elif not check_oauth_signature(request, consumer, token):
868+ raise Unauthorized('Invalid signature.')
869+ else:
870+ # Everything is fine, let's return the principal.
871+ pass
872+ alsoProvides(request, IOAuthSignedRequest)
873+ principal = getUtility(IPlacelessLoginSource).getPrincipal(
874+ token.person.account.id, access_level=token.permission,
875+ scope=token.context)
876+
877+ return principal
878
879
880 class LaunchpadWebServiceRequestTraversal(WebServiceRequestTraversal):
881
882=== modified file 'lib/canonical/launchpad/zcml/launchpad.zcml'
883--- lib/canonical/launchpad/zcml/launchpad.zcml 2010-09-09 21:09:00 +0000
884+++ lib/canonical/launchpad/zcml/launchpad.zcml 2010-09-21 16:32:05 +0000
885@@ -266,14 +266,14 @@
886 name="+authorize-token"
887 class="canonical.launchpad.browser.OAuthAuthorizeTokenView"
888 template="../templates/oauth-authorize.pt"
889- permission="zope.Public" />
890+ permission="launchpad.AnyPerson" />
891
892 <browser:page
893 for="canonical.launchpad.webapp.interfaces.ILaunchpadApplication"
894 name="+token-authorized"
895 class="canonical.launchpad.browser.OAuthTokenAuthorizedView"
896 template="../templates/token-authorized.pt"
897- permission="zope.Public" />
898+ permission="launchpad.AnyPerson" />
899
900 <browser:page
901 for="canonical.launchpad.webapp.interfaces.ILaunchpadApplication"
902
903=== modified file 'lib/lp/services/job/runner.py'
904--- lib/lp/services/job/runner.py 2010-08-20 20:31:18 +0000
905+++ lib/lp/services/job/runner.py 2010-09-21 16:32:05 +0000
906@@ -217,10 +217,8 @@
907 self.logger.exception(
908 "Failed to notify users about a failure.")
909 info = sys.exc_info()
910- self.error_utility.raising(info)
911- oops = self.error_utility.getLastOopsReport()
912 # Returning the oops says something went wrong.
913- return oops
914+ return self.error_utility.raising(info)
915
916 def _doOops(self, job, info):
917 """Report an OOPS for the provided job and info.
918@@ -229,8 +227,7 @@
919 :param info: The standard sys.exc_info() value.
920 :return: the Oops that was reported.
921 """
922- self.error_utility.raising(info)
923- oops = self.error_utility.getLastOopsReport()
924+ oops = self.error_utility.raising(info)
925 job.notifyOops(oops)
926 return oops
927
928
929=== modified file 'lib/lp/testing/__init__.py'
930--- lib/lp/testing/__init__.py 2010-09-20 12:56:53 +0000
931+++ lib/lp/testing/__init__.py 2010-09-21 16:32:05 +0000
932@@ -28,7 +28,6 @@
933 'map_branch_contents',
934 'normalize_whitespace',
935 'oauth_access_token_for',
936- 'OAuthSigningBrowser',
937 'person_logged_in',
938 'record_statements',
939 'run_with_login',
940@@ -146,7 +145,6 @@
941 launchpadlib_credentials_for,
942 launchpadlib_for,
943 oauth_access_token_for,
944- OAuthSigningBrowser,
945 )
946 from lp.testing.fixture import ZopeEventHandlerFixture
947 from lp.testing.matchers import Provides
948@@ -224,7 +222,7 @@
949
950 class StormStatementRecorder:
951 """A storm tracer to count queries.
952-
953+
954 This exposes the count and queries as lp.testing._webservice.QueryCollector
955 does permitting its use with the HasQueryCount matcher.
956
957@@ -683,7 +681,6 @@
958 def assertTextMatchesExpressionIgnoreWhitespace(self,
959 regular_expression_txt,
960 text):
961-
962 def normalise_whitespace(text):
963 return ' '.join(text.split())
964 pattern = re.compile(
965@@ -860,7 +857,6 @@
966 callable, and events are the events emitted by the callable.
967 """
968 events = []
969-
970 def on_notify(event):
971 events.append(event)
972 old_subscribers = zope.event.subscribers[:]
973
974=== modified file 'lib/lp/testing/_webservice.py'
975--- lib/lp/testing/_webservice.py 2010-09-16 15:40:56 +0000
976+++ lib/lp/testing/_webservice.py 2010-09-21 16:32:05 +0000
977@@ -9,104 +9,34 @@
978 'launchpadlib_credentials_for',
979 'launchpadlib_for',
980 'oauth_access_token_for',
981- 'OAuthSigningBrowser',
982 ]
983
984
985 import shutil
986 import tempfile
987+
988+from launchpadlib.credentials import (
989+ AccessToken,
990+ Credentials,
991+ )
992+from launchpadlib.launchpad import Launchpad
993 import transaction
994-from urllib2 import BaseHandler
995-
996-from oauth.oauth import OAuthRequest, OAuthSignatureMethod_PLAINTEXT
997-
998 from zope.app.publication.interfaces import IEndRequestEvent
999 from zope.app.testing import ztapi
1000-from zope.testbrowser.testing import Browser
1001 from zope.component import getUtility
1002 import zope.testing.cleanup
1003
1004-from launchpadlib.credentials import (
1005- AccessToken,
1006- Credentials,
1007- )
1008-from launchpadlib.launchpad import Launchpad
1009-
1010-from lp.testing._login import (
1011- login,
1012- logout,
1013- )
1014-
1015 from canonical.launchpad.interfaces import (
1016 IOAuthConsumerSet,
1017 IPersonSet,
1018- OAUTH_REALM,
1019 )
1020 from canonical.launchpad.webapp.adapter import get_request_statements
1021 from canonical.launchpad.webapp.interaction import ANONYMOUS
1022 from canonical.launchpad.webapp.interfaces import OAuthPermission
1023-
1024-
1025-class OAuthSigningHandler(BaseHandler):
1026- """A urllib2 handler that signs requests with an OAuth token."""
1027-
1028- def __init__(self, consumer, token):
1029- """Constructor
1030-
1031- :param consumer: An OAuth consumer.
1032- :param token: An OAuth token.
1033- """
1034- self.consumer = consumer
1035- self.token = token
1036-
1037- def default_open(self, req):
1038- """Set the Authorization header for the outgoing request."""
1039- signer = OAuthRequest.from_consumer_and_token(
1040- self.consumer, self.token)
1041- signer.sign_request(
1042- OAuthSignatureMethod_PLAINTEXT(), self.consumer, self.token)
1043- auth_header = signer.to_header(OAUTH_REALM)['Authorization']
1044- req.headers['Authorization'] = auth_header
1045-
1046-
1047-class UserAgentFilteringHandler(BaseHandler):
1048- """A urllib2 handler that replaces the User-Agent header.
1049-
1050- [XXX bug=638058] This is a hack to work around a bug in
1051- zope.testbrowser.
1052- """
1053- def __init__(self, user_agent):
1054- """Constructor."""
1055- self.user_agent = user_agent
1056-
1057- def default_open(self, req):
1058- """Set the User-Agent header for the outgoing request."""
1059- req.headers['User-Agent'] = self.user_agent
1060-
1061-
1062-class OAuthSigningBrowser(Browser):
1063- """A browser that signs each outgoing request with an OAuth token.
1064-
1065- This lets us simulate the behavior of the Launchpad Credentials
1066- Manager.
1067- """
1068- def __init__(self, consumer, token, user_agent=None):
1069- """Constructor.
1070-
1071- :param consumer: An OAuth consumer.
1072- :param token: An OAuth token.
1073- :param user_agent: The User-Agent string to send.
1074- """
1075- super(OAuthSigningBrowser, self).__init__()
1076- self.mech_browser.add_handler(
1077- OAuthSigningHandler(consumer, token))
1078- if user_agent is not None:
1079- self.mech_browser.add_handler(
1080- UserAgentFilteringHandler(user_agent))
1081-
1082- # This will give us tracebacks instead of unhelpful error
1083- # messages.
1084- self.handleErrors = False
1085+from lp.testing._login import (
1086+ login,
1087+ logout,
1088+ )
1089
1090
1091 def oauth_access_token_for(consumer_name, person, permission, context=None):