Merge lp:~leonardr/launchpad/accept-oauth-signatures into lp:launchpad/db-devel

Proposed by Leonard Richardson
Status: Merged
Approved by: Curtis Hovey
Approved revision: no longer in the source branch.
Merged at revision: 9804
Proposed branch: lp:~leonardr/launchpad/accept-oauth-signatures
Merge into: lp:launchpad/db-devel
Diff against target: 1065 lines (+545/-146)
8 files modified
lib/canonical/launchpad/browser/oauth.py (+106/-7)
lib/canonical/launchpad/database/oauth.py (+11/-8)
lib/canonical/launchpad/pagetests/oauth/authorize-token.txt (+207/-19)
lib/canonical/launchpad/webapp/authentication.py (+131/-5)
lib/canonical/launchpad/webapp/servers.py (+3/-94)
lib/canonical/launchpad/zcml/launchpad.zcml (+2/-2)
lib/lp/testing/__init__.py (+5/-1)
lib/lp/testing/_webservice.py (+80/-10)
To merge this branch: bzr merge lp:~leonardr/launchpad/accept-oauth-signatures
Reviewer Review Type Date Requested Status
Curtis Hovey (community) Approve
Review via email: mp+35697@code.launchpad.net

Description of the change

Backstory: the only way to get (non-anonymous or read-write) access to the Launchpad web service is to associate an OAuth access token with your account, and have your web service client sign all outgoing requests with that token. The level of access you get to the web service depends on the permission level associated with the token: READ_PRIVATE, WRITE_PUBLIC, etc.

Currently, a third-party desktop application obtains one of these OAuth tokens by opening a web-browser to the /+authorize-token page on Launchpad. The end-user logs into Launchpad and authorizes the token. Subsequently the desktop application can sign requests with the new token.

The problem is the "open a browser/log into Launchpad" step, which confuses users. We're working on a desktop application that wraps a web browser to eliminate confusion and reduce the number of times the end-user has to log into Launchpad. This branch changes Launchpad to make a portion of the _web site_ a little more like the _web service_. If you have the right OAuth access token, you can sign outdoing HTTP requests to the _web site_ and not have to go through the login procedure.

Recall that the whole point of an OAuth token is to authenticate against Launchpad without knowing a username/password combination. The desktop application will obtain an OAuth token with the special GRANT_PERMISSIONS access level. Then, when it needs to obtain some lesser access token for another application, it'll use that GRANT_PERMISSIONS token to skip the login step and take the end-user straight to /+authorize-token.

If you take a look at the "Access through OAuth" section of authorize-token.txt, you'll see this in action. Earlier in that test we run through the token authorization process using a Mechanize browser programmed with HTTP Basic Auth credentials. In "Access through OAuth", we go through the same process using a Mechanize browser that signs outgoing requests with a GRANT_PERMISSIONS token. Launchpad--specifically, the +authorize-token and +token-authorized views--will accept either.

To implement this, I removed the access controls on +authorize-token and +token-authorized, and implemented my own (ensureRequestIsAuthorizedOrSigned). The new controls delegate to the normal Launchpad publication controls, but will also allow access if the incoming request is signed with the right OAuth token. This let me keep all the special code near the two views that use it, and saved me from having to create a brand new publication (or alter the existing publication) just for those two views.

The other major code in this branch is a refactoring of WebServicePublication.get_principal. I moved the code for extracting OAuth information from a request, and validating a request's OAuth signature, into helper functions in webapp/authentication.py. It's called from get_principal, and also from ensureRequestIsAuthorizedOrSigned.

There's one more things I'd like to address in a follow-up branch (this branch is already huge, partly due to delinting), or possibly in this branch once it's been reviewed. There's no way to get access to any other page on Launchpad by signing a request with an OAuth token, but I'd like to add a test to prove it.

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

This as a fun read. I suggest you use
   >>> user = factory.makePerson(name='test-user')
instead of sample data to work with the user. You can also create an email address with the email= arg. You can also use
login_person(user)

review: Approve

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-08-24 16:44:42 +0000
3+++ lib/canonical/launchpad/browser/oauth.py 2010-09-20 13:21:06 +0000
4@@ -11,11 +11,13 @@
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@@ -29,9 +31,15 @@
19 )
20 from canonical.launchpad.webapp.authentication import (
21 check_oauth_signature,
22+ extract_oauth_access_token,
23 get_oauth_authorization,
24- )
25-from canonical.launchpad.webapp.interfaces import OAuthPermission
26+ get_oauth_principal
27+ )
28+from canonical.launchpad.webapp.interfaces import (
29+ AccessLevel,
30+ ILaunchBag,
31+ OAuthPermission,
32+ )
33 from lp.app.errors import UnexpectedFormData
34 from lp.registry.interfaces.distribution import IDistributionSet
35 from lp.registry.interfaces.pillar import IPillarNameSet
36@@ -98,6 +106,7 @@
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@@ -106,8 +115,10 @@
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@@ -118,7 +129,86 @@
56 actions_excluding_grant_permissions.append(action)
57 return actions, actions_excluding_grant_permissions
58
59-class OAuthAuthorizeTokenView(LaunchpadFormView, JSONTokenMixin):
60+
61+class CredentialManagerAwareMixin:
62+ """A view for which a browser may authenticate with an OAuth token.
63+
64+ The OAuth token must be signed with a token that has the
65+ GRANT_PERMISSIONS access level, and the browser must present
66+ itself as the Launchpad Credentials Manager.
67+ """
68+ # A prefix identifying the Launchpad Credential Manager's
69+ # User-Agent string.
70+ GRANT_PERMISSIONS_USER_AGENT_PREFIX = "Launchpad Credentials Manager"
71+
72+ def ensureRequestIsAuthorizedOrSigned(self):
73+ """Find the user who initiated the request.
74+
75+ This property is used by a view that wants to reject access
76+ unless the end-user is authenticated with cookie auth, HTTP
77+ Basic Auth, *or* a properly authorized OAuth token.
78+
79+ If the user is logged in with cookie auth or HTTP Basic, then
80+ other parts of Launchpad have taken care of the login and we
81+ don't have to do anything. But if the user's browser has
82+ signed the request with an OAuth token, other parts of
83+ Launchpad won't recognize that as an attempt to authorize the
84+ request.
85+
86+ This method does the OAuth part of the work. It checks that
87+ the OAuth token is valid, that it's got the correct access
88+ level, and that the User-Agent is one that's allowed to sign
89+ requests with OAuth tokens.
90+
91+ :return: The user who Launchpad identifies as the principal.
92+ Or, if Launchpad identifies no one as the principal, the user
93+ whose valid GRANT_PERMISSIONS OAuth token was used to sign
94+ the request.
95+
96+ :raise Unauthorized: If the request is unauthorized and
97+ unsigned, improperly signed, anonymously signed, or signed
98+ with a token that does not have the right access level.
99+ """
100+ user = getUtility(ILaunchBag).user
101+ if user is not None:
102+ return user
103+ # The normal Launchpad code was not able to identify any
104+ # user, but we're going to try a little harder before
105+ # concluding that no one's logged in. If the incoming
106+ # request is signed by an OAuth access token with the
107+ # GRANT_PERMISSIONS access level, we will force a
108+ # temporary login with the user whose access token this
109+ # is.
110+ token = extract_oauth_access_token(self.request)
111+ if token is None:
112+ # The request is not OAuth-signed. The normal Launchpad
113+ # code had it right: no one is authenticated.
114+ raise Unauthorized("Anonymous access is not allowed.")
115+ principal = get_oauth_principal(self.request)
116+ if IUnauthenticatedPrincipal.providedBy(principal):
117+ # The request is OAuth-signed, but as the anonymous
118+ # user.
119+ raise Unauthorized("Anonymous access is not allowed.")
120+ if token.permission != AccessLevel.GRANT_PERMISSIONS:
121+ # The request is OAuth-signed, but the token has
122+ # the wrong access level.
123+ raise Unauthorized("OAuth token has insufficient access level.")
124+
125+ # Both the consumer key and the User-Agent must identify the
126+ # Launchpad Credentials Manager.
127+ must_start_with_prefix = [
128+ token.consumer.key, self.request.getHeader("User-Agent")]
129+ for string in must_start_with_prefix:
130+ if not string.startswith(
131+ self.GRANT_PERMISSIONS_USER_AGENT_PREFIX):
132+ raise Unauthorized(
133+ "Only the Launchpad Credentials Manager can access this "
134+ "page by signing requests with an OAuth token.")
135+ return principal.person
136+
137+
138+class OAuthAuthorizeTokenView(
139+ LaunchpadFormView, JSONTokenMixin, CredentialManagerAwareMixin):
140 """Where users authorize consumers to access Launchpad on their behalf."""
141
142 actions, actions_excluding_grant_permissions = (
143@@ -167,6 +257,12 @@
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@@ -184,9 +280,10 @@
157 return actions
158
159 def initialize(self):
160+ self.oauth_authorized_user = self.ensureRequestIsAuthorizedOrSigned()
161 self.storeTokenContext()
162- form = get_oauth_authorization(self.request)
163- key = form.get('oauth_token')
164+
165+ key = self.request.form.get('oauth_token')
166 if key:
167 self.token = getUtility(IOAuthRequestTokenSet).getByKey(key)
168 super(OAuthAuthorizeTokenView, self).initialize()
169@@ -217,7 +314,8 @@
170 self.token_context = context
171
172 def reviewToken(self, permission):
173- self.token.review(self.user, permission, self.token_context)
174+ self.token.review(self.user or self.oauth_authorized_user,
175+ permission, self.token_context)
176 callback = self.request.form.get('oauth_callback')
177 if callback:
178 self.next_url = callback
179@@ -245,7 +343,7 @@
180 return context
181
182
183-class OAuthTokenAuthorizedView(LaunchpadView):
184+class OAuthTokenAuthorizedView(LaunchpadView, CredentialManagerAwareMixin):
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@@ -254,6 +352,7 @@
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-08-20 20:31:18 +0000
199+++ lib/canonical/launchpad/database/oauth.py 2010-09-20 13:21:06 +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 a
205-# timestamp "MUST be equal or greater than the timestamp used in previous
206-# requests," but this is likely to cause problems if the client does request
207-# pipelining, so we use a time window (relative to the timestamp of the
208-# existing OAuthNonce) to check if the timestamp can is acceptable. As
209-# suggested by Robert, we use a window which is at least twice the size of our
210-# hard time out. This is a safe bet since no requests should take more than
211-# one hard time out.
212+# The OAuth Core 1.0 spec (http://oauth.net/core/1.0/#nonce) says that
213+# a timestamp "MUST be equal or greater than the timestamp used in
214+# previous requests," but this is likely to cause problems if the
215+# client does request pipelining, so we use a time window (relative to
216+# the timestamp of the existing OAuthNonce) to check if the timestamp
217+# can is acceptable. As suggested by Robert, we use a window which is
218+# at least twice the size of our hard time out. This is a safe bet
219+# since no requests should take more than 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,6 +77,7 @@
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@@ -93,6 +94,7 @@
232
233 getStore = _get_store
234
235+
236 class OAuthConsumer(OAuthBase):
237 """See `IOAuthConsumer`."""
238 implements(IOAuthConsumer)
239@@ -323,6 +325,7 @@
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-08-24 16:44:42 +0000
250+++ lib/canonical/launchpad/pagetests/oauth/authorize-token.txt 2010-09-20 13:21:06 +0000
251@@ -1,4 +1,6 @@
252-= Authorizing a request token =
253+***************************
254+Authorizing a request token
255+***************************
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@@ -19,9 +21,10 @@
260 The oauth_token parameter, on the other hand, is required in the
261 Launchpad implementation.
262
263-The +authorize-token page is restricted to logged in users, so users will
264-first be asked to log in. (We won't show the actual login process because
265-it involves OpenID, which would complicate this test quite a bit.)
266+Access to the page
267+==================
268+
269+The +authorize-token page is restricted to authenticated users.
270
271 >>> from urllib import urlencode
272 >>> params = dict(
273@@ -30,7 +33,18 @@
274 >>> browser.open(url)
275 Traceback (most recent call last):
276 ...
277- Unauthorized:...
278+ Unauthorized: Anonymous access is not allowed.
279+
280+However, the details of the authentication are different than from any
281+other part of Launchpad. Unlike with other pages, a user can authorize
282+an OAuth token by signing their outgoing requests with an _existing_
283+OAuth token. This makes it possible for a desktop client to retrieve
284+this page without knowing the end-user's username and password, or
285+making them navigate the arbitrarily complex OpenID login procedure.
286+
287+But, let's deal with that a little later. First let's show how the
288+process works through HTTP Basic Auth (the testing equivalent of a
289+regular username-and-password login).
290
291 >>> browser = setupBrowser(auth='Basic no-priv@canonical.com:test')
292 >>> browser.open(url)
293@@ -44,6 +58,10 @@
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@@ -74,7 +92,34 @@
305 that isn't enough for the application. The user always has the option
306 to deny permission altogether.
307
308- >>> def print_access_levels(allow_permission):
309+ >>> def filter_user_agent(key, value, new_value):
310+ ... """A filter to replace the User-Agent header in a list of headers.
311+ ...
312+ ... [XXX bug=638058] This is a hack to work around a bug in
313+ ... zope.testbrowser.
314+ ... """
315+ ...
316+ ... if key.lower() == "user-agent":
317+ ... return (key, new_value)
318+ ... return (key, value)
319+
320+ >>> def print_access_levels(allow_permission, user_agent=None):
321+ ... if user_agent is not None:
322+ ... # [XXX bug=638058] This is a hack to work around a bug in
323+ ... # zope.testbrowser which prevents browser.addHeader
324+ ... # from working with User-Agent.
325+ ... mech_browser = browser.mech_browser
326+ ... # Store the original User-Agent for later.
327+ ... old_user_agent = [
328+ ... value for key, value in mech_browser.addheaders
329+ ... if key.lower() == "user-agent"][0]
330+ ... # Replace the User-Agent with the value passed into this
331+ ... # function.
332+ ... mech_browser.addheaders = [
333+ ... filter_user_agent(key, value, user_agent)
334+ ... for key, value in mech_browser.addheaders]
335+ ...
336+ ... # Okay, now we can make the request.
337 ... browser.open(
338 ... "http://launchpad.dev/+authorize-token?%s&%s"
339 ... % (urlencode(params), allow_permission))
340@@ -82,6 +127,13 @@
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@@ -90,23 +142,38 @@
355 Change Anything
356
357 The only time the 'Grant Permissions' permission shows up in this list
358-is if the client specifically requests it, and no other
359-permission. (Also requesting UNAUTHORIZED is okay--it will show up
360-anyway.)
361+is if a client identifying itself as the Launchpad Credentials Manager
362+specifically requests it, and no other permission. (Also requesting
363+UNAUTHORIZED is okay--it will show up anyway.)
364+
365+ >>> USER_AGENT = "Launchpad Credentials Manager v1.0"
366+ >>> print_access_levels(
367+ ... 'allow_permission=GRANT_PERMISSIONS', USER_AGENT)
368+ No Access
369+ Grant Permissions
370+
371+ >>> print_access_levels(
372+ ... ('allow_permission=GRANT_PERMISSIONS&'
373+ ... 'allow_permission=UNAUTHORIZED'),
374+ ... USER_AGENT)
375+ No Access
376+ Grant Permissions
377+
378+ >>> print_access_levels(
379+ ... ('allow_permission=WRITE_PUBLIC&'
380+ ... 'allow_permission=GRANT_PERMISSIONS'))
381+ No Access
382+ Change Non-Private Data
383+
384+If a client asks for GRANT_PERMISSIONS but doesn't claim to be the
385+Launchpad Credentials Manager, Launchpad will not show GRANT_PERMISSIONS.
386
387 >>> print_access_levels('allow_permission=GRANT_PERMISSIONS')
388 No Access
389- Grant Permissions
390-
391- >>> print_access_levels(
392- ... 'allow_permission=GRANT_PERMISSIONS&allow_permission=UNAUTHORIZED')
393- No Access
394- Grant Permissions
395-
396- >>> print_access_levels(
397- ... 'allow_permission=WRITE_PUBLIC&allow_permission=GRANT_PERMISSIONS')
398- No Access
399+ Read Non-Private Data
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@@ -263,3 +330,124 @@
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-08-20 20:31:18 +0000
534+++ lib/canonical/launchpad/webapp/authentication.py 2010-09-20 13:21:06 +0000
535@@ -5,16 +5,21 @@
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@@ -23,13 +28,18 @@
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 implements
568+from zope.interface import (
569+ alsoProvides,
570+ implements,
571+ )
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@@ -44,6 +54,14 @@
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@@ -51,6 +69,113 @@
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@@ -75,9 +200,8 @@
707 # as the login form is never visited for BasicAuth.
708 # This we treat each request as a separate
709 # login/logout.
710- notify(BasicAuthLoggedInEvent(
711- request, login, principal
712- ))
713+ notify(
714+ BasicAuthLoggedInEvent(request, login, principal))
715 return principal
716
717 def _authenticateUsingCookieAuth(self, request):
718@@ -190,7 +314,8 @@
719 plaintext = str(plaintext)
720 if salt is None:
721 salt = self.generate_salt()
722- v = binascii.b2a_base64(hashlib.sha1(plaintext + salt).digest() + salt)
723+ v = binascii.b2a_base64(
724+ hashlib.sha1(plaintext + salt).digest() + salt)
725 return v[:-1]
726
727 def validate(self, plaintext, encrypted):
728@@ -334,6 +459,7 @@
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-10 06:38:15 +0000
739+++ lib/canonical/launchpad/webapp/servers.py 2010-09-20 13:21:06 +0000
740@@ -8,7 +8,6 @@
741 __metaclass__ = type
742
743 import cgi
744-from datetime import datetime
745 import threading
746 import xmlrpclib
747
748@@ -22,7 +21,6 @@
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@@ -50,10 +48,7 @@
757 XMLRPCRequest,
758 XMLRPCResponse,
759 )
760-from zope.security.interfaces import (
761- IParticipation,
762- Unauthorized,
763- )
764+from zope.security.interfaces import IParticipation
765 from zope.security.proxy import (
766 isinstance as zope_isinstance,
767 removeSecurityProxy,
768@@ -68,17 +63,9 @@
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- check_oauth_signature,
782- get_oauth_authorization,
783+ get_oauth_principal,
784 )
785 from canonical.launchpad.webapp.authorization import (
786 LAUNCHPAD_SECURITY_POLICY_CACHE_KEY,
787@@ -93,8 +80,6 @@
788 INotificationRequest,
789 INotificationResponse,
790 IPlacelessAuthUtility,
791- IPlacelessLoginSource,
792- OAuthPermission,
793 )
794 from canonical.launchpad.webapp.notifications import (
795 NotificationList,
796@@ -1216,83 +1201,7 @@
797 if request_path.startswith("/%s" % web_service_config.path_override):
798 return super(WebServicePublication, self).getPrincipal(request)
799
800- # Fetch OAuth authorization information from the request.
801- form = get_oauth_authorization(request)
802-
803- consumer_key = form.get('oauth_consumer_key')
804- consumers = getUtility(IOAuthConsumerSet)
805- consumer = consumers.getByKey(consumer_key)
806- token_key = form.get('oauth_token')
807- anonymous_request = (token_key == '')
808-
809- if consumer_key is None:
810- # Either the client's OAuth implementation is broken, or
811- # the user is trying to make an unauthenticated request
812- # using wget or another OAuth-ignorant application.
813- # Try to retrieve a consumer based on the User-Agent
814- # header.
815- anonymous_request = True
816- consumer_key = request.getHeader('User-Agent', '')
817- if consumer_key == '':
818- raise Unauthorized(
819- 'Anonymous requests must provide a User-Agent.')
820- consumer = consumers.getByKey(consumer_key)
821-
822- if consumer is None:
823- if anonymous_request:
824- # This is the first time anyone has tried to make an
825- # anonymous request using this consumer name (or user
826- # agent). Dynamically create the consumer.
827- #
828- # In the normal website this wouldn't be possible
829- # because GET requests have their transactions rolled
830- # back. But webservice requests always have their
831- # transactions committed so that we can keep track of
832- # the OAuth nonces and prevent replay attacks.
833- if consumer_key == '' or consumer_key is None:
834- raise Unauthorized("No consumer key specified.")
835- consumer = consumers.new(consumer_key, '')
836- else:
837- # An unknown consumer can never make a non-anonymous
838- # request, because access tokens are registered with a
839- # specific, known consumer.
840- raise Unauthorized('Unknown consumer (%s).' % consumer_key)
841- if anonymous_request:
842- # Skip the OAuth verification step and let the user access the
843- # web service as an unauthenticated user.
844- #
845- # XXX leonardr 2009-12-15 bug=496964: Ideally we'd be
846- # auto-creating a token for the anonymous user the first
847- # time, passing it through the OAuth verification step,
848- # and using it on all subsequent anonymous requests.
849- alsoProvides(request, IOAuthSignedRequest)
850- auth_utility = getUtility(IPlacelessAuthUtility)
851- return auth_utility.unauthenticatedPrincipal()
852- token = consumer.getAccessToken(token_key)
853- if token is None:
854- raise Unauthorized('Unknown access token (%s).' % token_key)
855- nonce = form.get('oauth_nonce')
856- timestamp = form.get('oauth_timestamp')
857- try:
858- token.checkNonceAndTimestamp(nonce, timestamp)
859- except (NonceAlreadyUsed, TimestampOrderingError, ClockSkew), e:
860- raise Unauthorized('Invalid nonce/timestamp: %s' % e)
861- now = datetime.now(pytz.timezone('UTC'))
862- if token.permission == OAuthPermission.UNAUTHORIZED:
863- raise Unauthorized('Unauthorized token (%s).' % token.key)
864- elif token.date_expires is not None and token.date_expires <= now:
865- raise Unauthorized('Expired token (%s).' % token.key)
866- elif not check_oauth_signature(request, consumer, token):
867- raise Unauthorized('Invalid signature.')
868- else:
869- # Everything is fine, let's return the principal.
870- pass
871- alsoProvides(request, IOAuthSignedRequest)
872- principal = getUtility(IPlacelessLoginSource).getPrincipal(
873- token.person.account.id, access_level=token.permission,
874- scope=token.context)
875-
876- return principal
877+ return get_oauth_principal(request)
878
879
880 class LaunchpadWebServiceRequestTraversal(WebServiceRequestTraversal):
881
882=== modified file 'lib/canonical/launchpad/zcml/launchpad.zcml'
883--- lib/canonical/launchpad/zcml/launchpad.zcml 2010-08-02 02:23:26 +0000
884+++ lib/canonical/launchpad/zcml/launchpad.zcml 2010-09-20 13:21:06 +0000
885@@ -266,14 +266,14 @@
886 name="+authorize-token"
887 class="canonical.launchpad.browser.OAuthAuthorizeTokenView"
888 template="../templates/oauth-authorize.pt"
889- permission="launchpad.AnyPerson" />
890+ permission="zope.Public" />
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="launchpad.AnyPerson" />
898+ permission="zope.Public" />
899
900 <browser:page
901 for="canonical.launchpad.webapp.interfaces.ILaunchpadApplication"
902
903=== modified file 'lib/lp/testing/__init__.py'
904--- lib/lp/testing/__init__.py 2010-09-18 08:00:27 +0000
905+++ lib/lp/testing/__init__.py 2010-09-20 13:21:06 +0000
906@@ -28,6 +28,7 @@
907 'map_branch_contents',
908 'normalize_whitespace',
909 'oauth_access_token_for',
910+ 'OAuthSigningBrowser',
911 'person_logged_in',
912 'record_statements',
913 'run_with_login',
914@@ -145,6 +146,7 @@
915 launchpadlib_credentials_for,
916 launchpadlib_for,
917 oauth_access_token_for,
918+ OAuthSigningBrowser,
919 )
920 from lp.testing.fixture import ZopeEventHandlerFixture
921 from lp.testing.matchers import Provides
922@@ -222,7 +224,7 @@
923
924 class StormStatementRecorder:
925 """A storm tracer to count queries.
926-
927+
928 This exposes the count and queries as lp.testing._webservice.QueryCollector
929 does permitting its use with the HasQueryCount matcher.
930
931@@ -681,6 +683,7 @@
932 def assertTextMatchesExpressionIgnoreWhitespace(self,
933 regular_expression_txt,
934 text):
935+
936 def normalise_whitespace(text):
937 return ' '.join(text.split())
938 pattern = re.compile(
939@@ -857,6 +860,7 @@
940 callable, and events are the events emitted by the callable.
941 """
942 events = []
943+
944 def on_notify(event):
945 events.append(event)
946 old_subscribers = zope.event.subscribers[:]
947
948=== modified file 'lib/lp/testing/_webservice.py'
949--- lib/lp/testing/_webservice.py 2010-08-20 20:31:18 +0000
950+++ lib/lp/testing/_webservice.py 2010-09-20 13:21:06 +0000
951@@ -9,34 +9,104 @@
952 'launchpadlib_credentials_for',
953 'launchpadlib_for',
954 'oauth_access_token_for',
955+ 'OAuthSigningBrowser',
956 ]
957
958
959 import shutil
960 import tempfile
961-
962-from launchpadlib.credentials import (
963- AccessToken,
964- Credentials,
965- )
966-from launchpadlib.launchpad import Launchpad
967 import transaction
968+from urllib2 import BaseHandler
969+
970+from oauth.oauth import OAuthRequest, OAuthSignatureMethod_PLAINTEXT
971+
972 from zope.app.publication.interfaces import IEndRequestEvent
973 from zope.app.testing import ztapi
974+from zope.testbrowser.testing import Browser
975 from zope.component import getUtility
976 import zope.testing.cleanup
977
978+from launchpadlib.credentials import (
979+ AccessToken,
980+ Credentials,
981+ )
982+from launchpadlib.launchpad import Launchpad
983+
984+from lp.testing._login import (
985+ login,
986+ logout,
987+ )
988+
989 from canonical.launchpad.interfaces import (
990 IOAuthConsumerSet,
991 IPersonSet,
992+ OAUTH_REALM,
993 )
994 from canonical.launchpad.webapp.adapter import get_request_statements
995 from canonical.launchpad.webapp.interaction import ANONYMOUS
996 from canonical.launchpad.webapp.interfaces import OAuthPermission
997-from lp.testing._login import (
998- login,
999- logout,
1000- )
1001+
1002+
1003+class OAuthSigningHandler(BaseHandler):
1004+ """A urllib2 handler that signs requests with an OAuth token."""
1005+
1006+ def __init__(self, consumer, token):
1007+ """Constructor
1008+
1009+ :param consumer: An OAuth consumer.
1010+ :param token: An OAuth token.
1011+ """
1012+ self.consumer = consumer
1013+ self.token = token
1014+
1015+ def default_open(self, req):
1016+ """Set the Authorization header for the outgoing request."""
1017+ signer = OAuthRequest.from_consumer_and_token(
1018+ self.consumer, self.token)
1019+ signer.sign_request(
1020+ OAuthSignatureMethod_PLAINTEXT(), self.consumer, self.token)
1021+ auth_header = signer.to_header(OAUTH_REALM)['Authorization']
1022+ req.headers['Authorization'] = auth_header
1023+
1024+
1025+class UserAgentFilteringHandler(BaseHandler):
1026+ """A urllib2 handler that replaces the User-Agent header.
1027+
1028+ [XXX bug=638058] This is a hack to work around a bug in
1029+ zope.testbrowser.
1030+ """
1031+ def __init__(self, user_agent):
1032+ """Constructor."""
1033+ self.user_agent = user_agent
1034+
1035+ def default_open(self, req):
1036+ """Set the User-Agent header for the outgoing request."""
1037+ req.headers['User-Agent'] = self.user_agent
1038+
1039+
1040+class OAuthSigningBrowser(Browser):
1041+ """A browser that signs each outgoing request with an OAuth token.
1042+
1043+ This lets us simulate the behavior of the Launchpad Credentials
1044+ Manager.
1045+ """
1046+ def __init__(self, consumer, token, user_agent=None):
1047+ """Constructor.
1048+
1049+ :param consumer: An OAuth consumer.
1050+ :param token: An OAuth token.
1051+ :param user_agent: The User-Agent string to send.
1052+ """
1053+ super(OAuthSigningBrowser, self).__init__()
1054+ self.mech_browser.add_handler(
1055+ OAuthSigningHandler(consumer, token))
1056+ if user_agent is not None:
1057+ self.mech_browser.add_handler(
1058+ UserAgentFilteringHandler(user_agent))
1059+
1060+ # This will give us tracebacks instead of unhelpful error
1061+ # messages.
1062+ self.handleErrors = False
1063
1064
1065 def oauth_access_token_for(consumer_name, person, permission, context=None):

Subscribers

People subscribed via source and target branches

to status/vote changes: