Merge lp:~leonardr/launchpad/rename-grant-permissions into lp:launchpad/db-devel

Proposed by Leonard Richardson
Status: Rejected
Rejected by: Leonard Richardson
Proposed branch: lp:~leonardr/launchpad/rename-grant-permissions
Merge into: lp:launchpad/db-devel
Diff against target: 1619 lines (+505/-623)
14 files modified
lib/canonical/launchpad/browser/oauth.py (+107/-133)
lib/canonical/launchpad/database/oauth.py (+53/-10)
lib/canonical/launchpad/doc/oauth.txt (+37/-0)
lib/canonical/launchpad/doc/webapp-authorization.txt (+5/-13)
lib/canonical/launchpad/interfaces/oauth.py (+16/-0)
lib/canonical/launchpad/pagetests/oauth/authorize-token.txt (+114/-215)
lib/canonical/launchpad/templates/oauth-authorize.pt (+51/-22)
lib/canonical/launchpad/webapp/authentication.py (+5/-131)
lib/canonical/launchpad/webapp/authorization.py (+4/-2)
lib/canonical/launchpad/webapp/interfaces.py (+6/-7)
lib/canonical/launchpad/webapp/servers.py (+94/-3)
lib/canonical/launchpad/zcml/launchpad.zcml (+2/-2)
lib/lp/testing/__init__.py (+1/-5)
lib/lp/testing/_webservice.py (+10/-80)
To merge this branch: bzr merge lp:~leonardr/launchpad/rename-grant-permissions
Reviewer Review Type Date Requested Status
Robert Collins (community) Needs Fixing
Edwin Grubbs (community) ui* Approve
Registry Administrators ui Pending
Review via email: mp+36363@code.launchpad.net

Description of the change

This branch creates a new path for authorizing OAuth tokens. Now you can authorize your entire desktop with a single token, instead of authorizing individual applications (apport, Ground Control, etc.). Since the entire GNOME desktop forms a single security context, authorizing individual applications within it was aggravating users without providing any security benefit.

In the near future, this path will be the default path for launchpadlib desktop clients. The existing permission levels (READ_PUBLIC, etc.) will only be used when integrating a third-party website into Launchpad, or in desktop environments that have more fine-grained security policies than GNOME.

I took the GRANT_PERMISSIONS permission level, which was never used for anything, and repurposed it into the DESKTOP_INTEGRATION permission level. To get the GRANT_PERMISSIONS permission level, your OAuth consumer key must fit a specific format, giving the type of the desktop (eg. Ubuntu) and the human-readable name of the computer (eg. the hostname). Launchpad uses this information to present the end-user with a special message about integrating their entire desktop into Launchpad. As always, the end-user has the choice to grant or deny access.

There are a few edge cases: DESKTOP_INTEGRATION tokens can't accept any other permission level as a substitute, and you can't specify a callback URL with a DESKTOP_INTEGRATION token, because callback URLs are intended for integrating third-party websites into Launchpad, not desktop apps.

---

I would like a UI review of the new message and buttons presented during DESKTOP_INTEGRATION token signing. To see the changed UI, start up Launchpad locally, then run the following launchpadlib code in a terminal:

from launchpadlib.launchpad import Launchpad
l = Launchpad.login_with("Ubuntu desktop (Bob's Computer)", service_root="dev", allow_access_levels=["DESKTOP_INTEGRATION"])

A browser window will pop up and show you a warning about integrating your Ubuntu desktop with Launchpad.

To post a comment you must log in.
Revision history for this message
Edwin Grubbs (edwin-grubbs) wrote :

Hi Leonard,

This is a cool feature. Here are my comments on the +authorize-token page's text.

https://launchpad.dev/+authorize-token says:
>The Ubuntu computer identified as Edwin's Computer wants access to your
>Launchpad account. If you allow the integration, all applications
>running on Edwin's Computer will have read-write access to your
>Launchpad account, including to your private data.

I like this paragraph.

>If Edwin's Computer is not the computer you're using right now, or if
>you don't trust this computer, you should click "No, thanks, I don't
>trust this computer", or close this window now.

I think this feature will increase the likelihood that less
computer-savvy users will be using apps that need API tokens, and I
think that the concept of whether they "trust this computer" could be
confusing. How about this alternative:
"... or if untrusted users can access Edwin's Computer, ..."

>Even if you decide to allow the integration, you can change your mind
>later.
>
> [Give all programs running on "Edwin's Computer" access to my Launchpad account.]

This sentence is hard to parse and it is difficult to rewrite since "give to" and
"access to" make the "to" preposition ambiguous. How about:
[Allow all programs running on "Edwin's Computer" to access my Launchpad account.]

> [No, thanks, I don't trust this computer.]

Just in case the user is confused about wheter "Edwin's Computer" is the same as
"this computer", it might be better to say:
[No, thanks, I don't trust "Edwin's Computer"]

Even though I complained above about the concept of trusting a computer,
I think it is reasonable here since the above comments explain the criteria
for determing that. Otherwise, the button will get even more verbose.

Since I'm not a graduated UI reviewer, you will still need one more UI
review, and you might want to see if they disagree with any of my
suggestions before you make any changes.

-Edwin

review: Approve (ui*)
Revision history for this message
Robert Collins (lifeless) wrote :

Say what?

"Since the entire GNOME desktop forms a single security context,
authorizing individual applications within it was aggravating users
without providing any security benefit."

This strikes me as stretching the truth at best.

I humbly suggest that this needs:
 - discussion on the dev list
 - at least one security experts review (I can think of several nasty
things this would permit right off hand).

-Rob

Revision history for this message
Robert Collins (lifeless) wrote :

I'm rejecting this for clarity: Once a comprehensive discussion has been had, *if* this is the right approach, I'll happily and joyfully revert my vote.

It may be that this patch is capability, not policy, in which case it may be ok to land as is - I have merely read the overview which was sufficiently worrying for me to highlight the need for more eyeballs on this.

review: Needs Fixing
Revision history for this message
Leonard Richardson (leonardr) wrote :

I've posted a public explanation of this branch on launchpad-dev:

https://lists.launchpad.net/launchpad-dev/msg04746.html

Revision history for this message
Leonard Richardson (leonardr) wrote :

After a thorough discussion on launchpad-dev I'm putting this back into "Needs Review". I've written an LEP summarizing the feature: https://dev.launchpad.net/LEP/DesktopWideLaunchpadIntegration

Revision history for this message
Leonard Richardson (leonardr) wrote :

I've also made some minor changes in response to Edwin's review. Note this especially: Internally we refer to the "desktop" integration, because anything else makes the code very confusing, but the wording of the consumer key no longer has to include the string "desktop". You can now integrate a phone with the consumer key "System-wide: iPhone (Bob's iPhone)" or a headless server with "System-wide: Ubuntu server (pegasus)" or whatever. The consumer key now refers to a "system-wide" integration.

Revision history for this message
Leonard Richardson (leonardr) wrote :

I just noticed I proposed this branch for merging into db-devel instead of devel, so I've created a new merge proposal and am moving the work over there: https://code.edge.launchpad.net/~leonardr/launchpad/rename-grant-permissions/+merge/37590

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-22 19:29:46 +0000
4@@ -11,7 +11,6 @@
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@@ -31,15 +30,9 @@
13 )
14 from canonical.launchpad.webapp.authentication import (
15 check_oauth_signature,
16- extract_oauth_access_token,
17 get_oauth_authorization,
18- get_oauth_principal
19- )
20-from canonical.launchpad.webapp.interfaces import (
21- AccessLevel,
22- ILaunchBag,
23- OAuthPermission,
24- )
25+ )
26+from canonical.launchpad.webapp.interfaces import OAuthPermission
27 from lp.app.errors import UnexpectedFormData
28 from lp.registry.interfaces.distribution import IDistributionSet
29 from lp.registry.interfaces.pillar import IPillarNameSet
30@@ -96,122 +89,52 @@
31
32 token = consumer.newRequestToken()
33 if self.request.headers.get('Accept') == HTTPResource.JSON_TYPE:
34- # Don't show the client the GRANT_PERMISSIONS access
35+ # Don't show the client the DESKTOP_INTEGRATION access
36 # level. If they have a legitimate need to use it, they'll
37 # already know about it.
38- permissions = [permission for permission in OAuthPermission.items
39- if permission != OAuthPermission.GRANT_PERMISSIONS]
40+ permissions = [
41+ permission for permission in OAuthPermission.items
42+ if (permission != OAuthPermission.DESKTOP_INTEGRATION)
43+ ]
44 return self.getJSONRepresentation(
45 permissions, token, include_secret=True)
46 return u'oauth_token=%s&oauth_token_secret=%s' % (
47 token.key, token.secret)
48
49-
50 def token_exists_and_is_not_reviewed(form, action):
51 return form.token is not None and not form.token.is_reviewed
52
53
54+def token_review_success(form, action, data):
55+ """The success callback for a button to approve a token."""
56+ form.reviewToken(action.permission)
57+
58+
59 def create_oauth_permission_actions():
60- """Return a list of `Action`s for each possible `OAuthPermission`."""
61- actions = Actions()
62- actions_excluding_grant_permissions = Actions()
63-
64- def success(form, action, data):
65- form.reviewToken(action.permission)
66-
67+ """Return two `Actions` objects containing each possible `OAuthPermission`.
68+
69+ The first `Actions` object contains every action supported by the
70+ OAuthAuthorizeTokenView. The second list contains a good default
71+ set of actions, omitting special permissions like DESKTOP_INTEGRATION.
72+ """
73+ all_actions = Actions()
74+ ordinary_actions = Actions()
75 for permission in OAuthPermission.items:
76 action = Action(
77- permission.title, name=permission.name, success=success,
78+ permission.title, name=permission.name,
79+ success=token_review_success,
80 condition=token_exists_and_is_not_reviewed)
81 action.permission = permission
82- actions.append(action)
83- if permission != OAuthPermission.GRANT_PERMISSIONS:
84- actions_excluding_grant_permissions.append(action)
85- return actions, actions_excluding_grant_permissions
86-
87-
88-class CredentialManagerAwareMixin:
89- """A view for which a browser may authenticate with an OAuth token.
90-
91- The OAuth token must be signed with a token that has the
92- GRANT_PERMISSIONS access level, and the browser must present
93- itself as the Launchpad Credentials Manager.
94- """
95- # A prefix identifying the Launchpad Credential Manager's
96- # User-Agent string.
97- GRANT_PERMISSIONS_USER_AGENT_PREFIX = "Launchpad Credentials Manager"
98-
99- def ensureRequestIsAuthorizedOrSigned(self):
100- """Find the user who initiated the request.
101-
102- This property is used by a view that wants to reject access
103- unless the end-user is authenticated with cookie auth, HTTP
104- Basic Auth, *or* a properly authorized OAuth token.
105-
106- If the user is logged in with cookie auth or HTTP Basic, then
107- other parts of Launchpad have taken care of the login and we
108- don't have to do anything. But if the user's browser has
109- signed the request with an OAuth token, other parts of
110- Launchpad won't recognize that as an attempt to authorize the
111- request.
112-
113- This method does the OAuth part of the work. It checks that
114- the OAuth token is valid, that it's got the correct access
115- level, and that the User-Agent is one that's allowed to sign
116- requests with OAuth tokens.
117-
118- :return: The user who Launchpad identifies as the principal.
119- Or, if Launchpad identifies no one as the principal, the user
120- whose valid GRANT_PERMISSIONS OAuth token was used to sign
121- the request.
122-
123- :raise Unauthorized: If the request is unauthorized and
124- unsigned, improperly signed, anonymously signed, or signed
125- with a token that does not have the right access level.
126- """
127- user = getUtility(ILaunchBag).user
128- if user is not None:
129- return user
130- # The normal Launchpad code was not able to identify any
131- # user, but we're going to try a little harder before
132- # concluding that no one's logged in. If the incoming
133- # request is signed by an OAuth access token with the
134- # GRANT_PERMISSIONS access level, we will force a
135- # temporary login with the user whose access token this
136- # is.
137- token = extract_oauth_access_token(self.request)
138- if token is None:
139- # The request is not OAuth-signed. The normal Launchpad
140- # code had it right: no one is authenticated.
141- raise Unauthorized("Anonymous access is not allowed.")
142- principal = get_oauth_principal(self.request)
143- if IUnauthenticatedPrincipal.providedBy(principal):
144- # The request is OAuth-signed, but as the anonymous
145- # user.
146- raise Unauthorized("Anonymous access is not allowed.")
147- if token.permission != AccessLevel.GRANT_PERMISSIONS:
148- # The request is OAuth-signed, but the token has
149- # the wrong access level.
150- raise Unauthorized("OAuth token has insufficient access level.")
151-
152- # Both the consumer key and the User-Agent must identify the
153- # Launchpad Credentials Manager.
154- must_start_with_prefix = [
155- token.consumer.key, self.request.getHeader("User-Agent")]
156- for string in must_start_with_prefix:
157- if not string.startswith(
158- self.GRANT_PERMISSIONS_USER_AGENT_PREFIX):
159- raise Unauthorized(
160- "Only the Launchpad Credentials Manager can access this "
161- "page by signing requests with an OAuth token.")
162- return principal.person
163-
164-
165-class OAuthAuthorizeTokenView(
166- LaunchpadFormView, JSONTokenMixin, CredentialManagerAwareMixin):
167+ all_actions.append(action)
168+ if permission != OAuthPermission.DESKTOP_INTEGRATION:
169+ ordinary_actions.append(action)
170+ return all_actions, ordinary_actions
171+
172+
173+class OAuthAuthorizeTokenView(LaunchpadFormView, JSONTokenMixin):
174 """Where users authorize consumers to access Launchpad on their behalf."""
175
176- actions, actions_excluding_grant_permissions = (
177+ actions, actions_excluding_special_permissions = (
178 create_oauth_permission_actions())
179 label = "Authorize application to access Launchpad on your behalf"
180 schema = IOAuthRequestToken
181@@ -220,7 +143,7 @@
182
183 @property
184 def visible_actions(self):
185- """Restrict the actions to the subset the client can make use of.
186+ """Restrict the actions to a subset to be presented to the client.
187
188 Not all client programs can function with all levels of
189 access. For instance, a client that needs to modify the
190@@ -240,7 +163,7 @@
191
192 allowed_permissions = self.request.form_ng.getAll('allow_permission')
193 if len(allowed_permissions) == 0:
194- return self.actions_excluding_grant_permissions
195+ return self.actions_excluding_special_permissions
196 actions = Actions()
197
198 # UNAUTHORIZED is always one of the options. If the client
199@@ -249,24 +172,53 @@
200 if OAuthPermission.UNAUTHORIZED.name in allowed_permissions:
201 allowed_permissions.remove(OAuthPermission.UNAUTHORIZED.name)
202
203- # GRANT_PERMISSIONS cannot be requested as one of several
204+ # DESKTOP_INTEGRATION cannot be requested as one of several
205 # options--it must be the only option (other than
206- # UNAUTHORIZED). If GRANT_PERMISSIONS is one of several
207+ # UNAUTHORIZED). If DESKTOP_INTEGRATION is one of several
208 # options, remove it from the list.
209- if (OAuthPermission.GRANT_PERMISSIONS.name in allowed_permissions
210+ desktop_permission = OAuthPermission.DESKTOP_INTEGRATION
211+ if (desktop_permission.name in allowed_permissions
212 and len(allowed_permissions) > 1):
213- allowed_permissions.remove(OAuthPermission.GRANT_PERMISSIONS.name)
214-
215- # GRANT_PERMISSIONS may only be requested by a specific User-Agent.
216- if (OAuthPermission.GRANT_PERMISSIONS.name in allowed_permissions
217- and not self.request.getHeader("User-Agent").startswith(
218- self.GRANT_PERMISSIONS_USER_AGENT_PREFIX)):
219- allowed_permissions.remove(OAuthPermission.GRANT_PERMISSIONS.name)
220-
221- for action in self.actions:
222- if (action.permission.name in allowed_permissions
223- or action.permission is OAuthPermission.UNAUTHORIZED):
224- actions.append(action)
225+ allowed_permissions.remove(desktop_permission.name)
226+
227+ if desktop_permission.name in allowed_permissions:
228+ if not self.token.consumer.is_integrated_desktop:
229+ # Consumers may only ask for desktop integration if
230+ # they give a desktop type (eg. "Ubuntu") and a
231+ # user-recognizable desktop name (eg. the hostname).
232+ raise Unauthorized(
233+ ('Consumer "%s" asked for desktop integration, '
234+ "but didn't say what kind of desktop it is, or name "
235+ "the computer being integrated."
236+ % self.token.consumer.key))
237+
238+ # We're going for desktop integration. The only two
239+ # possibilities are "allow" and "deny". We'll customize
240+ # the "allow" message using the hostname provided by the
241+ # desktop.
242+ label = (
243+ 'Give all programs running on "%s" access '
244+ 'to my Launchpad account.')
245+ allow_action = [
246+ action for action in self.actions
247+ if action.name == desktop_permission.name][0]
248+ allow_action.label = (
249+ label % self.token.consumer.integrated_desktop_name)
250+ actions.append(allow_action)
251+
252+ # We'll customize the "deny" message as well.
253+ deny_action = [
254+ action for action in self.actions
255+ if action.name == OAuthPermission.UNAUTHORIZED.name][0]
256+ deny_action.label = "No, thanks, I don't trust this computer."
257+ actions.append(deny_action)
258+
259+ else:
260+ # We're going for web-based integration.
261+ for action in self.actions_excluding_special_permissions:
262+ if (action.permission.name in allowed_permissions
263+ or action.permission is OAuthPermission.UNAUTHORIZED):
264+ actions.append(action)
265
266 if len(list(actions)) == 1:
267 # The only visible action is UNAUTHORIZED. That means the
268@@ -275,17 +227,41 @@
269 # UNAUTHORIZED). Rather than present the end-user with an
270 # impossible situation where their only option is to deny
271 # access, we'll present the full range of actions (except
272- # for GRANT_PERMISSIONS).
273- return self.actions_excluding_grant_permissions
274+ # for special permissions like DESKTOP_INTEGRATION).
275+ return self.actions_excluding_special_permissions
276 return actions
277
278 def initialize(self):
279- self.oauth_authorized_user = self.ensureRequestIsAuthorizedOrSigned()
280 self.storeTokenContext()
281-
282- key = self.request.form.get('oauth_token')
283+ form = get_oauth_authorization(self.request)
284+ key = form.get('oauth_token')
285 if key:
286 self.token = getUtility(IOAuthRequestTokenSet).getByKey(key)
287+
288+
289+ callback = self.request.form.get('oauth_callback')
290+ if (self.token is not None
291+ and self.token.consumer.is_integrated_desktop):
292+ # Nip problems in the bud by appling special rules about
293+ # what desktop integrations are allowed to do.
294+ if callback is not None:
295+ # A desktop integration is not allowed to specify a callback.
296+ raise Unauthorized(
297+ "A desktop integration may not specify an "
298+ "OAuth callback URL.")
299+ # A desktop integration token can only have one of two
300+ # permission levels: "Desktop Integration" and
301+ # "Unauthorized". It shouldn't even be able to ask for any
302+ # other level.
303+ for action in self.visible_actions:
304+ if action.permission not in (
305+ OAuthPermission.DESKTOP_INTEGRATION,
306+ OAuthPermission.UNAUTHORIZED):
307+ raise Unauthorized(
308+ ("Desktop integration token requested a permission "
309+ '("%s") not supported for desktop-wide use.')
310+ % action.label)
311+
312 super(OAuthAuthorizeTokenView, self).initialize()
313
314 def render(self):
315@@ -314,8 +290,7 @@
316 self.token_context = context
317
318 def reviewToken(self, permission):
319- self.token.review(self.user or self.oauth_authorized_user,
320- permission, self.token_context)
321+ self.token.review(self.user, permission, self.token_context)
322 callback = self.request.form.get('oauth_callback')
323 if callback:
324 self.next_url = callback
325@@ -343,7 +318,7 @@
326 return context
327
328
329-class OAuthTokenAuthorizedView(LaunchpadView, CredentialManagerAwareMixin):
330+class OAuthTokenAuthorizedView(LaunchpadView):
331 """Where users who reviewed tokens may get redirected to.
332
333 If the consumer didn't include an oauth_callback when sending the user to
334@@ -352,7 +327,6 @@
335 """
336
337 def initialize(self):
338- authorized_user = self.ensureRequestIsAuthorizedOrSigned()
339 key = self.request.form.get('oauth_token')
340 self.token = getUtility(IOAuthRequestTokenSet).getByKey(key)
341 assert self.token.is_reviewed, (
342
343=== modified file 'lib/canonical/launchpad/database/oauth.py'
344--- lib/canonical/launchpad/database/oauth.py 2010-09-15 20:55:03 +0000
345+++ lib/canonical/launchpad/database/oauth.py 2010-09-22 19:29:46 +0000
346@@ -15,6 +15,7 @@
347 timedelta,
348 )
349
350+import re
351 import pytz
352 from sqlobject import (
353 BoolCol,
354@@ -60,14 +61,14 @@
355
356 # How many hours should a request token be valid for?
357 REQUEST_TOKEN_VALIDITY = 12
358-# The OAuth Core 1.0 spec (http://oauth.net/core/1.0/#nonce) says that
359-# a timestamp "MUST be equal or greater than the timestamp used in
360-# previous requests," but this is likely to cause problems if the
361-# client does request pipelining, so we use a time window (relative to
362-# the timestamp of the existing OAuthNonce) to check if the timestamp
363-# can is acceptable. As suggested by Robert, we use a window which is
364-# at least twice the size of our hard time out. This is a safe bet
365-# since no requests should take more than one hard time out.
366+# The OAuth Core 1.0 spec (http://oauth.net/core/1.0/#nonce) says that a
367+# timestamp "MUST be equal or greater than the timestamp used in previous
368+# requests," but this is likely to cause problems if the client does request
369+# pipelining, so we use a time window (relative to the timestamp of the
370+# existing OAuthNonce) to check if the timestamp can is acceptable. As
371+# suggested by Robert, we use a window which is at least twice the size of our
372+# hard time out. This is a safe bet since no requests should take more than
373+# one hard time out.
374 TIMESTAMP_ACCEPTANCE_WINDOW = 60 # seconds
375 # If the timestamp is far in the future because of a client's clock skew,
376 # it will effectively invalidate the authentication tokens when the clock is
377@@ -77,7 +78,6 @@
378 # amount.
379 TIMESTAMP_SKEW_WINDOW = 60*60 # seconds, +/-
380
381-
382 class OAuthBase(SQLBase):
383 """Base class for all OAuth database classes."""
384
385@@ -104,6 +104,50 @@
386 key = StringCol(notNull=True)
387 secret = StringCol(notNull=False, default='')
388
389+ # This regular expression singles out a consumer key that represents
390+ # any and all apps running on a specific computer. For instance:
391+ #
392+ # Ubuntu desktop (hostname1)
393+ # - An Ubuntu desktop called "hostname1"
394+ # Windows desktop (Computer Name)
395+ # - A Windows desktop called "Computer Name"
396+ # Mac OS desktop (hostname2)
397+ # - A Macintosh desktop called "hostname2"
398+ # Android desktop (Bob's Phone)
399+ # - An Android phone called "Bob's Phone"
400+ integrated_desktop_re = re.compile("^(.*) desktop \(([^)]*)\)$")
401+
402+ def _integrated_desktop_match_group(self, position):
403+ """Return information about a desktop integration token.
404+
405+ A convenience method that runs the desktop integration regular
406+ expression against the consumer key.
407+
408+ :param position: The match group to return if the regular
409+ expression matches.
410+
411+ :return: The value of one of the match groups, or None.
412+ """
413+ match = self.integrated_desktop_re.match(self.key)
414+ if match is None:
415+ return None
416+ return match.groups()[position]
417+
418+ @property
419+ def is_integrated_desktop(self):
420+ """See `IOAuthConsumer`."""
421+ return self.integrated_desktop_re.match(self.key) is not None
422+
423+ @property
424+ def integrated_desktop_type(self):
425+ """See `IOAuthConsumer`."""
426+ return self._integrated_desktop_match_group(0)
427+
428+ @property
429+ def integrated_desktop_name(self):
430+ """See `IOAuthConsumer`."""
431+ return self._integrated_desktop_match_group(1)
432+
433 def newRequestToken(self):
434 """See `IOAuthConsumer`."""
435 key, secret = create_token_key_and_secret(table=OAuthRequestToken)
436@@ -325,7 +369,6 @@
437 The key will have a length of 20 and we'll make sure it's not yet in the
438 given table. The secret will have a length of 80.
439 """
440-
441 key_length = 20
442 key = create_unique_token_for_table(key_length, getattr(table, "key"))
443 secret_length = 80
444
445=== modified file 'lib/canonical/launchpad/doc/oauth.txt'
446--- lib/canonical/launchpad/doc/oauth.txt 2010-04-16 15:06:55 +0000
447+++ lib/canonical/launchpad/doc/oauth.txt 2010-09-22 19:29:46 +0000
448@@ -38,6 +38,43 @@
449 ...
450 AssertionError: ...
451
452+Desktop consumers
453+=================
454+
455+In a web context, each application is represented by a unique consumer
456+key. But a typical user sitting at a typical desktop, using multiple
457+desktop applications that integrate with Launchpad, is represented by
458+a single consumer key. The user's session as a whole is a single
459+"consumer", and the consumer key is expected to contain structured
460+information: the type of desktop (usually the operating system) and a
461+string that the end-user would recognize as identifying their
462+computer.
463+
464+ >>> desktop_key = consumer_set.new("Ubuntu desktop (hostname)")
465+ >>> desktop_key.is_integrated_desktop
466+ True
467+ >>> print desktop_key.integrated_desktop_type
468+ Ubuntu
469+ >>> print desktop_key.integrated_desktop_name
470+ hostname
471+
472+ >>> desktop_key = consumer_set.new("Windows desktop (My Computer)")
473+ >>> desktop_key.is_integrated_desktop
474+ True
475+ >>> print desktop_key.integrated_desktop_type
476+ Windows
477+ >>> print desktop_key.integrated_desktop_name
478+ My Computer
479+
480+A normal OAuth consumer does not have this information.
481+
482+ >>> ordinary_key = consumer_set.new("Not a desktop at all.")
483+ >>> ordinary_key.is_integrated_desktop
484+ False
485+ >>> print ordinary_key.integrated_desktop_type
486+ None
487+ >>> print ordinary_key.integrated_desktop_name
488+ None
489
490 Request tokens
491 ==============
492
493=== modified file 'lib/canonical/launchpad/doc/webapp-authorization.txt'
494--- lib/canonical/launchpad/doc/webapp-authorization.txt 2010-08-24 16:44:42 +0000
495+++ lib/canonical/launchpad/doc/webapp-authorization.txt 2010-09-22 19:29:46 +0000
496@@ -79,24 +79,16 @@
497 >>> check_permission('launchpad.View', bug_1)
498 False
499
500-Now consider a principal authorized to create OAuth tokens. Whenever
501-it's not creating OAuth tokens, it has a level of permission
502-equivalent to READ_PUBLIC.
503+A token used for desktop integration has a level of permission
504+equivalent to WRITE_PUBLIC.
505
506- >>> principal.access_level = AccessLevel.GRANT_PERMISSIONS
507+ >>> principal.access_level = AccessLevel.DESKTOP_INTEGRATION
508 >>> setupInteraction(principal)
509 >>> check_permission('launchpad.View', bug_1)
510- False
511+ True
512
513 >>> check_permission('launchpad.Edit', sample_person)
514- False
515-
516-This may seem useless from a security standpoint, since once a
517-malicious client is authorized to create OAuth tokens, it can escalate
518-its privileges at any time by creating a new token for itself. The
519-security benefit is more subtle: by discouraging feature creep in
520-clients that have this super-access level, we reduce the risk that a
521-bug in a _trusted_ client will enable privilege escalation attacks.
522+ True
523
524 Users logged in through the web application have full access, which
525 means they can read/change any object they have access to.
526
527=== modified file 'lib/canonical/launchpad/interfaces/oauth.py'
528--- lib/canonical/launchpad/interfaces/oauth.py 2010-08-20 20:31:18 +0000
529+++ lib/canonical/launchpad/interfaces/oauth.py 2010-09-22 19:29:46 +0000
530@@ -64,6 +64,22 @@
531 description=_('The secret which, if not empty, should be used by the '
532 'consumer to sign its requests.'))
533
534+ is_integrated_desktop = Attribute(
535+ """This attribute is true if the consumer corresponds to a
536+ user account on a personal computer.""")
537+
538+ integrated_desktop_name = Attribute(
539+ """If the consumer corresponds to a user account on a personal
540+ computer, this is the self-reported name of that computer. If
541+ the consumer is a specific web or desktop application, this is
542+ None.""")
543+
544+ integrated_desktop_type = Attribute(
545+ """If the consumer corresponds to a user account on a personal
546+ computer, this is the self-reported type of that computer
547+ (usually the operating system). If the consumer is a specific
548+ web or desktop application, this is None.""")
549+
550 def newRequestToken():
551 """Return a new `IOAuthRequestToken` with a random key and secret.
552
553
554=== modified file 'lib/canonical/launchpad/pagetests/oauth/authorize-token.txt'
555--- lib/canonical/launchpad/pagetests/oauth/authorize-token.txt 2010-09-16 21:34:31 +0000
556+++ lib/canonical/launchpad/pagetests/oauth/authorize-token.txt 2010-09-22 19:29:46 +0000
557@@ -1,6 +1,4 @@
558-***************************
559-Authorizing a request token
560-***************************
561+= Authorizing a request token =
562
563 Once the consumer gets a request token, it must send the user to
564 Launchpad's +authorize-token page in order for the user to authenticate
565@@ -21,10 +19,9 @@
566 The oauth_token parameter, on the other hand, is required in the
567 Launchpad implementation.
568
569-Access to the page
570-==================
571-
572-The +authorize-token page is restricted to authenticated users.
573+The +authorize-token page is restricted to logged in users, so users will
574+first be asked to log in. (We won't show the actual login process because
575+it involves OpenID, which would complicate this test quite a bit.)
576
577 >>> from urllib import urlencode
578 >>> params = dict(
579@@ -33,18 +30,7 @@
580 >>> browser.open(url)
581 Traceback (most recent call last):
582 ...
583- Unauthorized: Anonymous access is not allowed.
584-
585-However, the details of the authentication are different than from any
586-other part of Launchpad. Unlike with other pages, a user can authorize
587-an OAuth token by signing their outgoing requests with an _existing_
588-OAuth token. This makes it possible for a desktop client to retrieve
589-this page without knowing the end-user's username and password, or
590-making them navigate the arbitrarily complex OpenID login procedure.
591-
592-But, let's deal with that a little later. First let's show how the
593-process works through HTTP Basic Auth (the testing equivalent of a
594-regular username-and-password login).
595+ Unauthorized:...
596
597 >>> browser = setupBrowser(auth='Basic no-priv@canonical.com:test')
598 >>> browser.open(url)
599@@ -58,12 +44,8 @@
600 ...
601 See all applications authorized to access Launchpad on your behalf.
602
603-
604-Using the page
605-==============
606-
607 This page contains one submit button for each item of OAuthPermission,
608-except for 'Grant Permissions', which must be specifically requested.
609+except for 'Desktop Integration', which must be specifically requested.
610
611 >>> browser.getControl('No Access')
612 <SubmitControl...
613@@ -76,6 +58,7 @@
614 >>> browser.getControl('Change Anything')
615 <SubmitControl...
616
617+ # XXX FIXME
618 >>> browser.getControl('Grant Permissions')
619 Traceback (most recent call last):
620 ...
621@@ -92,48 +75,17 @@
622 that isn't enough for the application. The user always has the option
623 to deny permission altogether.
624
625- >>> def filter_user_agent(key, value, new_value):
626- ... """A filter to replace the User-Agent header in a list of headers.
627- ...
628- ... [XXX bug=638058] This is a hack to work around a bug in
629- ... zope.testbrowser.
630- ... """
631- ...
632- ... if key.lower() == "user-agent":
633- ... return (key, new_value)
634- ... return (key, value)
635-
636- >>> def print_access_levels(allow_permission, user_agent=None):
637- ... if user_agent is not None:
638- ... # [XXX bug=638058] This is a hack to work around a bug in
639- ... # zope.testbrowser which prevents browser.addHeader
640- ... # from working with User-Agent.
641- ... mech_browser = browser.mech_browser
642- ... # Store the original User-Agent for later.
643- ... old_user_agent = [
644- ... value for key, value in mech_browser.addheaders
645- ... if key.lower() == "user-agent"][0]
646- ... # Replace the User-Agent with the value passed into this
647- ... # function.
648- ... mech_browser.addheaders = [
649- ... filter_user_agent(key, value, user_agent)
650- ... for key, value in mech_browser.addheaders]
651- ...
652- ... # Okay, now we can make the request.
653+ >>> def authorize_token_main_content(allow_permission):
654 ... browser.open(
655 ... "http://launchpad.dev/+authorize-token?%s&%s"
656 ... % (urlencode(params), allow_permission))
657- ... main_content = find_tag_by_id(browser.contents, 'maincontent')
658+ ... return find_tag_by_id(browser.contents, 'maincontent')
659+
660+ >>> def print_access_levels(allow_permission):
661+ ... main_content = authorize_token_main_content(allow_permission)
662 ... actions = main_content.findAll('input', attrs={'type': 'submit'})
663 ... for action in actions:
664 ... print action['value']
665- ...
666- ... if user_agent is not None:
667- ... # Finally, restore the old User-Agent.
668- ... mech_browser.addheaders = [
669- ... filter_user_agent(key, value, old_user_agent)
670- ... for key, value in mech_browser.addheaders]
671-
672
673 >>> print_access_levels(
674 ... 'allow_permission=WRITE_PUBLIC&allow_permission=WRITE_PRIVATE')
675@@ -141,43 +93,9 @@
676 Change Non-Private Data
677 Change Anything
678
679-The only time the 'Grant Permissions' permission shows up in this list
680-is if a client identifying itself as the Launchpad Credentials Manager
681-specifically requests it, and no other permission. (Also requesting
682-UNAUTHORIZED is okay--it will show up anyway.)
683-
684- >>> USER_AGENT = "Launchpad Credentials Manager v1.0"
685- >>> print_access_levels(
686- ... 'allow_permission=GRANT_PERMISSIONS', USER_AGENT)
687- No Access
688- Grant Permissions
689-
690- >>> print_access_levels(
691- ... ('allow_permission=GRANT_PERMISSIONS&'
692- ... 'allow_permission=UNAUTHORIZED'),
693- ... USER_AGENT)
694- No Access
695- Grant Permissions
696-
697- >>> print_access_levels(
698- ... ('allow_permission=WRITE_PUBLIC&'
699- ... 'allow_permission=GRANT_PERMISSIONS'))
700- No Access
701- Change Non-Private Data
702-
703-If a client asks for GRANT_PERMISSIONS but doesn't claim to be the
704-Launchpad Credentials Manager, Launchpad will not show GRANT_PERMISSIONS.
705-
706- >>> print_access_levels('allow_permission=GRANT_PERMISSIONS')
707- No Access
708- Read Non-Private Data
709- Change Non-Private Data
710- Read Anything
711- Change Anything
712-
713 If an application doesn't specify any valid access levels, or only
714 specifies the UNAUTHORIZED access level, Launchpad will show all the
715-access levels, except for GRANT_PERMISSIONS.
716+access levels, except for DESKTOP_INTEGRATION.
717
718 >>> print_access_levels('')
719 No Access
720@@ -193,6 +111,20 @@
721 Read Anything
722 Change Anything
723
724+An application may not request the DESKTOP_INTEGRATION access level
725+unless its consumer key matches a certain pattern. (Successful desktop
726+integration has its own section, below.)
727+
728+ >>> allow_permission = "allow_permission=DESKTOP_INTEGRATION"
729+ >>> browser.open(
730+ ... "http://launchpad.dev/+authorize-token?%s&%s"
731+ ... % (urlencode(params), allow_permission))
732+ Traceback (most recent call last):
733+ ...
734+ Unauthorized: Consumer "foobar123451432" asked for desktop
735+ integration, but didn't say what kind of desktop it is, or name
736+ the computer being integrated.
737+
738 An application may also specify a context, so that the access granted
739 by the user is restricted to things related to that context.
740
741@@ -331,123 +263,90 @@
742 reviewed ... ago.
743 See all applications authorized to access Launchpad on your behalf.
744
745-Access through OAuth
746-====================
747-
748-Now it's time to show how to go through the same process without
749-knowing the end-user's username and password. All you need is an OAuth
750-token issued with the GRANT_PERMISSIONS access level, in the name of
751-the Launchpad Credentials Manager.
752-
753-Let's go through the approval process again, without ever sending the
754-user's username or password over HTTP. First we'll create a new user,
755-and a GRANT_PERMISSIONS access token that they can use to sign
756-requests.
757-
758- >>> login(ANONYMOUS)
759- >>> user = factory.makePerson(name="test-user", password="never-used")
760- >>> logout()
761-
762- >>> from oauth.oauth import OAuthConsumer
763- >>> manager_consumer = OAuthConsumer("Launchpad Credentials Manager", "")
764-
765- >>> from lp.testing import oauth_access_token_for
766- >>> login_person(user)
767- >>> grant_permissions_token = oauth_access_token_for(
768- ... manager_consumer.key, user, "GRANT_PERMISSIONS")
769- >>> logout()
770-
771-Next, we'll give the new user an OAuth request token that needs to be
772-approved using a web browser.
773-
774- >>> login_person(user)
775- >>> consumer = getUtility(IOAuthConsumerSet).getByKey('foobar123451432')
776- >>> request_token = consumer.newRequestToken()
777- >>> logout()
778-
779- >>> params = dict(oauth_token=request_token.key)
780- >>> url = "http://launchpad.dev/+authorize-token?%s" % urlencode(params)
781-
782-Next, we'll create a browser object that knows how to sign requests
783-with the new user's existing access token.
784-
785- >>> from lp.testing import OAuthSigningBrowser
786- >>> browser = OAuthSigningBrowser(
787- ... manager_consumer, grant_permissions_token, USER_AGENT)
788- >>> browser.open(url)
789- >>> print browser.title
790- Authorize application to access Launchpad on your behalf
791-
792-The browser object can approve the request and see the appropriate
793-messages, even though we never gave it the user's password.
794-
795- >>> browser.getControl('Read Anything').click()
796-
797- >>> browser.url
798- 'http://launchpad.dev/+token-authorized?...'
799- >>> print extract_text(find_tag_by_id(browser.contents, 'maincontent'))
800- Almost finished ...
801- To finish authorizing the application identified as foobar123451432 to
802- access Launchpad on your behalf you should go back to the application
803- window in which you started the process and inform it that you have done
804- your part of the process.
805-
806-OAuth error conditions
807-----------------------
808-
809-The OAuth token used to sign the requests must have the
810-GRANT_PERMISSIONS access level; no other access level will work.
811-
812- >>> login(ANONYMOUS)
813- >>> insufficient_token = oauth_access_token_for(
814- ... manager_consumer.key, user, "WRITE_PRIVATE")
815- >>> logout()
816-
817- >>> browser = OAuthSigningBrowser(
818- ... manager_consumer, insufficient_token, USER_AGENT)
819- >>> browser.open(url)
820- Traceback (most recent call last):
821- ...
822- Unauthorized: OAuth token has insufficient access level.
823-
824-The OAuth token must be for the Launchpad Credentials Manager, or it
825-cannot be used. (Launchpad shouldn't even _issue_ a GRANT_PERMISSIONS
826-token for any other consumer, but even if it somehow does, that token
827-can't be used for this.)
828-
829- >>> login(ANONYMOUS)
830- >>> wrong_consumer = OAuthConsumer(
831- ... "Not the Launchpad Credentials Manager", "")
832- >>> wrong_consumer_token = oauth_access_token_for(
833- ... wrong_consumer.key, user, "GRANT_PERMISSIONS")
834- >>> logout()
835-
836- >>> browser = OAuthSigningBrowser(wrong_consumer, wrong_consumer_token)
837- >>> browser.open(url)
838- Traceback (most recent call last):
839- ...
840- Unauthorized: Only the Launchpad Credentials Manager can access
841- this page by signing requests with an OAuth token.
842-
843-Signing with an anonymous token will also not work.
844-
845- >>> from oauth.oauth import OAuthToken
846- >>> anonymous_token = OAuthToken(key="", secret="")
847- >>> browser = OAuthSigningBrowser(manager_consumer, anonymous_token)
848- >>> browser.open(url)
849- Traceback (most recent call last):
850- ...
851- Unauthorized: Anonymous access is not allowed.
852-
853-Even if it presents the right token, the user agent sending the signed
854-request must *also* identify *itself* as the Launchpad Credentials
855-Manager.
856-
857- >>> browser = OAuthSigningBrowser(
858- ... manager_consumer, grant_permissions_token,
859- ... "Not the Launchpad Credentials Manager")
860- >>> browser.open(url)
861- Traceback (most recent call last):
862- ...
863- Unauthorized: Only the Launchpad Credentials Manager can access
864- this page by signing requests with an OAuth token.
865+Desktop integration
866+===================
867+
868+The test case given above shows how to integrate a single application
869+or website into Launchpad. But it's also possible to integrate an
870+entire desktop environment into Launchpad.
871+
872+The desktop integration option is only available for OAuth consumers
873+that say what kind of desktop they are (eg. Ubuntu) and give a name
874+that a user can identify with their computer (eg. the hostname). Here,
875+we'll create such a token.
876+
877+ >>> login('salgado@ubuntu.com')
878+ >>> desktop_key = "Ubuntu desktop (mycomputer)"
879+ >>> consumer = getUtility(IOAuthConsumerSet).new(desktop_key)
880+ >>> token = consumer.newRequestToken()
881+ >>> logout()
882+
883+When a desktop tries to integrate with Launchpad, the user gets a
884+special warning about giving access to every program running on their
885+desktop.
886+
887+ >>> params = dict(oauth_token=token.key)
888+ >>> print extract_text(
889+ ... authorize_token_main_content(
890+ ... 'allow_permission=DESKTOP_INTEGRATION'))
891+ The Ubuntu computer identified as mycomputer wants access to your
892+ Launchpad account. If you allow the integration, all applications
893+ running on mycomputer will have read-write access to your
894+ Launchpad account, including to your private data.
895+ If mycomputer is not the computer you're using right now, or if
896+ you don't trust this computer, you should click "No, thanks, I
897+ don't trust this computer", or close this window now.
898+ Even if you decide to allow the integration, you can
899+ change your mind later.
900+ See all applications authorized to access Launchpad on your behalf.
901+
902+The only time the 'Desktop Integration' permission shows up in the
903+list of permissions is if the client specifically requests it, and no
904+other permission. (Also requesting UNAUTHORIZED is okay--it will show
905+up anyway.)
906+
907+ >>> print_access_levels('allow_permission=DESKTOP_INTEGRATION')
908+ Give all programs running on "mycomputer" access to my Launchpad account.
909+ No, thanks, I don't trust this computer.
910+
911+ >>> print_access_levels(
912+ ... 'allow_permission=DESKTOP_INTEGRATION&allow_permission=UNAUTHORIZED')
913+ Give all programs running on "mycomputer" access to my Launchpad account.
914+ No, thanks, I don't trust this computer.
915+
916+A desktop may not request a level of access other than
917+DESKTOP_INTEGRATION, since the whole point is to have a permission
918+level that specifically applies across the entire desktop.
919+
920+ >>> print_access_levels('allow_permission=WRITE_PRIVATE')
921+ Traceback (most recent call last):
922+ ...
923+ Unauthorized: Desktop integration token requested a permission
924+ ("Change Anything") not supported for desktop-wide use.
925+
926+ >>> print_access_levels(
927+ ... 'allow_permission=WRITE_PUBLIC&allow_permission=DESKTOP_INTEGRATION')
928+ Traceback (most recent call last):
929+ ...
930+ Unauthorized: Desktop integration token requested a permission
931+ ("Change Non-Private Data") not supported for desktop-wide use.
932+
933+You can't specify a callback URL when authorizing a desktop-wide
934+token, since callback URLs should only be used when integrating
935+websites into Launchpad.
936+
937+ >>> params['oauth_callback'] = 'http://launchpad.dev/bzr'
938+ >>> print_access_levels('allow_permission=DESKTOP_INTEGRATION')
939+ Traceback (most recent call last):
940+ ...
941+ Unauthorized: A desktop integration may not specify an OAuth
942+ callback URL.
943+
944+This is true even if the desktop token isn't asking for the
945+DESKTOP_INTEGRATION permission.
946+
947+ >>> print_access_levels('allow_permission=WRITE_PRIVATE')
948+ Traceback (most recent call last):
949+ ...
950+ Unauthorized: A desktop integration may not specify an OAuth
951+ callback URL.
952
953=== modified file 'lib/canonical/launchpad/templates/oauth-authorize.pt'
954--- lib/canonical/launchpad/templates/oauth-authorize.pt 2009-07-17 17:59:07 +0000
955+++ lib/canonical/launchpad/templates/oauth-authorize.pt 2010-09-22 19:29:46 +0000
956@@ -21,28 +21,57 @@
957 <tal:token-not-reviewed condition="not:token/is_reviewed">
958 <div metal:use-macro="context/@@launchpad_form/form">
959 <div metal:fill-slot="extra_top">
960- <p>The application identified as
961- <strong tal:content="token/consumer/key">consumer</strong>
962- wants to access
963- <tal:has-context condition="view/token_context">
964- things related to
965- <strong tal:content="view/token_context/title">Context</strong>
966- in
967- </tal:has-context>
968- Launchpad on your behalf. What level of access
969- do you want to grant?</p>
970-
971- <table>
972- <tr tal:repeat="action view/visible_actions">
973- <td style="text-align: right">
974- <tal:action replace="structure action/render" />
975- </td>
976- <td>
977- <span class="lesser"
978- tal:content="action/permission/description" />
979- </td>
980- </tr>
981- </table>
982+
983+ <tal:desktop-integration-token condition="token/consumer/is_integrated_desktop">
984+ <p>The
985+ <tal:desktop replace="structure
986+ token/consumer/integrated_desktop_type" />
987+ computer identified
988+ as <strong tal:content="token/consumer/integrated_desktop_name">hostname</strong>
989+ wants access to your Launchpad account. If you allow the
990+ integration, all applications running
991+ on <strong tal:content="token/consumer/integrated_desktop_name">hostname</strong>
992+ will have read-write access to your Launchpad account,
993+ including to your private data.</p>
994+
995+ <p>If
996+ <strong tal:content="token/consumer/integrated_desktop_name">hostname</strong>
997+ is not the computer you're using right now, or if you
998+ don't trust this computer, you should click "No, thanks,
999+ I don't trust this computer", or close this window now.</p>
1000+
1001+ <p>Even if you decide to allow the integration, you can
1002+ change your mind later.</p>
1003+ </tal:desktop-integration-token>
1004+
1005+ <tal:web-integration-token condition="not:token/consumer/is_integrated_desktop">
1006+ <p>The application identified as
1007+ <strong tal:content="token/consumer/key">consumer</strong>
1008+ wants to access
1009+ <tal:has-context condition="view/token_context">
1010+ things related to
1011+ <strong tal:content="view/token_context/title">Context</strong>
1012+ in
1013+ </tal:has-context>
1014+ Launchpad on your behalf. What level of access
1015+ do you want to grant?</p>
1016+ </tal:web-integration-token>
1017+
1018+ <table>
1019+ <tr tal:repeat="action view/visible_actions">
1020+ <td style="text-align: right">
1021+ <tal:action replace="structure action/render" />
1022+ </td>
1023+
1024+ <tal:web-integration-token
1025+ condition="not:token/consumer/is_integrated_desktop">
1026+ <td>
1027+ <span class="lesser"
1028+ tal:content="action/permission/description" />
1029+ </td>
1030+ </tal:web-integration-token>
1031+ </tr>
1032+ </table>
1033 </div>
1034
1035 <div metal:fill-slot="extra_bottom">
1036
1037=== modified file 'lib/canonical/launchpad/webapp/authentication.py'
1038--- lib/canonical/launchpad/webapp/authentication.py 2010-09-16 15:40:56 +0000
1039+++ lib/canonical/launchpad/webapp/authentication.py 2010-09-22 19:29:46 +0000
1040@@ -5,21 +5,16 @@
1041
1042 __all__ = [
1043 'check_oauth_signature',
1044- 'extract_oauth_access_token',
1045- 'get_oauth_principal',
1046 'get_oauth_authorization',
1047 'LaunchpadLoginSource',
1048 'LaunchpadPrincipal',
1049- 'OAuthSignedRequest',
1050 'PlacelessAuthUtility',
1051 'SSHADigestEncryptor',
1052 ]
1053
1054
1055 import binascii
1056-from datetime import datetime
1057 import hashlib
1058-import pytz
1059 import random
1060 from UserDict import UserDict
1061
1062@@ -28,18 +23,13 @@
1063 from zope.app.security.interfaces import ILoginPassword
1064 from zope.app.security.principalregistry import UnauthenticatedPrincipal
1065 from zope.authentication.interfaces import IUnauthenticatedPrincipal
1066-
1067 from zope.component import (
1068 adapts,
1069 getUtility,
1070 )
1071 from zope.event import notify
1072-from zope.interface import (
1073- alsoProvides,
1074- implements,
1075- )
1076+from zope.interface import implements
1077 from zope.preference.interfaces import IPreferenceGroup
1078-from zope.security.interfaces import Unauthorized
1079 from zope.security.proxy import removeSecurityProxy
1080 from zope.session.interfaces import ISession
1081
1082@@ -54,14 +44,6 @@
1083 ILaunchpadPrincipal,
1084 IPlacelessAuthUtility,
1085 IPlacelessLoginSource,
1086- OAuthPermission,
1087- )
1088-from canonical.launchpad.interfaces.oauth import (
1089- ClockSkew,
1090- IOAuthConsumerSet,
1091- IOAuthSignedRequest,
1092- NonceAlreadyUsed,
1093- TimestampOrderingError,
1094 )
1095 from lp.registry.interfaces.person import (
1096 IPerson,
1097@@ -69,113 +51,6 @@
1098 )
1099
1100
1101-def extract_oauth_access_token(request):
1102- """Find the OAuth access token that signed the given request.
1103-
1104- :param request: An incoming request.
1105-
1106- :return: an IOAuthAccessToken, or None if the request is not
1107- signed at all.
1108-
1109- :raise Unauthorized: If the token is invalid or the request is an
1110- anonymously-signed request that doesn't meet our requirements.
1111- """
1112- # Fetch OAuth authorization information from the request.
1113- form = get_oauth_authorization(request)
1114-
1115- consumer_key = form.get('oauth_consumer_key')
1116- consumers = getUtility(IOAuthConsumerSet)
1117- consumer = consumers.getByKey(consumer_key)
1118- token_key = form.get('oauth_token')
1119- anonymous_request = (token_key == '')
1120-
1121- if consumer_key is None:
1122- # Either the client's OAuth implementation is broken, or
1123- # the user is trying to make an unauthenticated request
1124- # using wget or another OAuth-ignorant application.
1125- # Try to retrieve a consumer based on the User-Agent
1126- # header.
1127- anonymous_request = True
1128- consumer_key = request.getHeader('User-Agent', '')
1129- if consumer_key == '':
1130- raise Unauthorized(
1131- 'Anonymous requests must provide a User-Agent.')
1132- consumer = consumers.getByKey(consumer_key)
1133-
1134- if consumer is None:
1135- if anonymous_request:
1136- # This is the first time anyone has tried to make an
1137- # anonymous request using this consumer name (or user
1138- # agent). Dynamically create the consumer.
1139- #
1140- # In the normal website this wouldn't be possible
1141- # because GET requests have their transactions rolled
1142- # back. But webservice requests always have their
1143- # transactions committed so that we can keep track of
1144- # the OAuth nonces and prevent replay attacks.
1145- if consumer_key == '' or consumer_key is None:
1146- raise Unauthorized("No consumer key specified.")
1147- consumer = consumers.new(consumer_key, '')
1148- else:
1149- # An unknown consumer can never make a non-anonymous
1150- # request, because access tokens are registered with a
1151- # specific, known consumer.
1152- raise Unauthorized('Unknown consumer (%s).' % consumer_key)
1153- if anonymous_request:
1154- # Skip the OAuth verification step and let the user access the
1155- # web service as an unauthenticated user.
1156- #
1157- # XXX leonardr 2009-12-15 bug=496964: Ideally we'd be
1158- # auto-creating a token for the anonymous user the first
1159- # time, passing it through the OAuth verification step,
1160- # and using it on all subsequent anonymous requests.
1161- return None
1162-
1163- token = consumer.getAccessToken(token_key)
1164- if token is None:
1165- raise Unauthorized('Unknown access token (%s).' % token_key)
1166- return token
1167-
1168-
1169-def get_oauth_principal(request):
1170- """Find the principal to use for this OAuth-signed request.
1171-
1172- :param request: An incoming request.
1173- :return: An ILaunchpadPrincipal with the appropriate access level.
1174- """
1175- token = extract_oauth_access_token(request)
1176-
1177- if token is None:
1178- # The consumer is making an anonymous request. If there was a
1179- # problem with the access token, extract_oauth_access_token
1180- # would have raised Unauthorized.
1181- alsoProvides(request, IOAuthSignedRequest)
1182- auth_utility = getUtility(IPlacelessAuthUtility)
1183- return auth_utility.unauthenticatedPrincipal()
1184-
1185- form = get_oauth_authorization(request)
1186- nonce = form.get('oauth_nonce')
1187- timestamp = form.get('oauth_timestamp')
1188- try:
1189- token.checkNonceAndTimestamp(nonce, timestamp)
1190- except (NonceAlreadyUsed, TimestampOrderingError, ClockSkew), e:
1191- raise Unauthorized('Invalid nonce/timestamp: %s' % e)
1192- now = datetime.now(pytz.timezone('UTC'))
1193- if token.permission == OAuthPermission.UNAUTHORIZED:
1194- raise Unauthorized('Unauthorized token (%s).' % token.key)
1195- elif token.date_expires is not None and token.date_expires <= now:
1196- raise Unauthorized('Expired token (%s).' % token.key)
1197- elif not check_oauth_signature(request, token.consumer, token):
1198- raise Unauthorized('Invalid signature.')
1199- else:
1200- # Everything is fine, let's return the principal.
1201- pass
1202- alsoProvides(request, IOAuthSignedRequest)
1203- return getUtility(IPlacelessLoginSource).getPrincipal(
1204- token.person.account.id, access_level=token.permission,
1205- scope=token.context)
1206-
1207-
1208 class PlacelessAuthUtility:
1209 """An authentication service which holds no state aside from its
1210 ZCML configuration, implemented as a utility.
1211@@ -200,8 +75,9 @@
1212 # as the login form is never visited for BasicAuth.
1213 # This we treat each request as a separate
1214 # login/logout.
1215- notify(
1216- BasicAuthLoggedInEvent(request, login, principal))
1217+ notify(BasicAuthLoggedInEvent(
1218+ request, login, principal
1219+ ))
1220 return principal
1221
1222 def _authenticateUsingCookieAuth(self, request):
1223@@ -314,8 +190,7 @@
1224 plaintext = str(plaintext)
1225 if salt is None:
1226 salt = self.generate_salt()
1227- v = binascii.b2a_base64(
1228- hashlib.sha1(plaintext + salt).digest() + salt)
1229+ v = binascii.b2a_base64(hashlib.sha1(plaintext + salt).digest() + salt)
1230 return v[:-1]
1231
1232 def validate(self, plaintext, encrypted):
1233@@ -459,7 +334,6 @@
1234
1235 # zope.app.apidoc expects our principals to be adaptable into IAnnotations, so
1236 # we use these dummy adapters here just to make that code not OOPS.
1237-
1238 class TemporaryPrincipalAnnotations(UserDict):
1239 implements(IAnnotations)
1240 adapts(ILaunchpadPrincipal, IPreferenceGroup)
1241
1242=== modified file 'lib/canonical/launchpad/webapp/authorization.py'
1243--- lib/canonical/launchpad/webapp/authorization.py 2010-08-20 20:31:18 +0000
1244+++ lib/canonical/launchpad/webapp/authorization.py 2010-09-22 19:29:46 +0000
1245@@ -61,7 +61,8 @@
1246 lp_permission = getUtility(ILaunchpadPermission, permission)
1247 if lp_permission.access_level == "write":
1248 required_access_level = [
1249- AccessLevel.WRITE_PUBLIC, AccessLevel.WRITE_PRIVATE]
1250+ AccessLevel.WRITE_PUBLIC, AccessLevel.WRITE_PRIVATE,
1251+ AccessLevel.DESKTOP_INTEGRATION]
1252 if access_level not in required_access_level:
1253 return False
1254 elif lp_permission.access_level == "read":
1255@@ -80,7 +81,8 @@
1256 access to private objects, return False. Return True otherwise.
1257 """
1258 private_access_levels = [
1259- AccessLevel.READ_PRIVATE, AccessLevel.WRITE_PRIVATE]
1260+ AccessLevel.READ_PRIVATE, AccessLevel.WRITE_PRIVATE,
1261+ AccessLevel.DESKTOP_INTEGRATION]
1262 if access_level in private_access_levels:
1263 # The user has access to private objects. Return early,
1264 # before checking whether the object is private, since
1265
1266=== modified file 'lib/canonical/launchpad/webapp/interfaces.py'
1267--- lib/canonical/launchpad/webapp/interfaces.py 2010-09-12 11:43:36 +0000
1268+++ lib/canonical/launchpad/webapp/interfaces.py 2010-09-22 19:29:46 +0000
1269@@ -527,14 +527,13 @@
1270 for reading and changing anything, including private data.
1271 """)
1272
1273- GRANT_PERMISSIONS = DBItem(60, """
1274- Grant Permissions
1275+ DESKTOP_INTEGRATION = DBItem(60, """
1276+ Desktop Integration
1277
1278- The application will be able to grant access to your Launchpad
1279- account to any other application. This is a very powerful
1280- level of access. You should not grant this level of access to
1281- any application except the official Launchpad credential
1282- manager.
1283+ Every application running on your desktop will have read-write
1284+ access to your Launchpad account, including to your private
1285+ data. You should not allow this unless you trust the computer
1286+ you're using right now.
1287 """)
1288
1289 class AccessLevel(DBEnumeratedType):
1290
1291=== modified file 'lib/canonical/launchpad/webapp/servers.py'
1292--- lib/canonical/launchpad/webapp/servers.py 2010-09-21 04:21:16 +0000
1293+++ lib/canonical/launchpad/webapp/servers.py 2010-09-22 19:29:46 +0000
1294@@ -8,6 +8,7 @@
1295 __metaclass__ = type
1296
1297 import cgi
1298+from datetime import datetime
1299 import threading
1300 import xmlrpclib
1301
1302@@ -21,6 +22,7 @@
1303 WebServiceRequestTraversal,
1304 )
1305 from lazr.uri import URI
1306+import pytz
1307 import transaction
1308 from transaction.interfaces import ISynchronizer
1309 from zc.zservertracelog.tracelog import Server as ZServerTracelogServer
1310@@ -48,7 +50,10 @@
1311 XMLRPCRequest,
1312 XMLRPCResponse,
1313 )
1314-from zope.security.interfaces import IParticipation
1315+from zope.security.interfaces import (
1316+ IParticipation,
1317+ Unauthorized,
1318+ )
1319 from zope.security.proxy import (
1320 isinstance as zope_isinstance,
1321 removeSecurityProxy,
1322@@ -63,9 +68,17 @@
1323 IPrivateApplication,
1324 IWebServiceApplication,
1325 )
1326+from canonical.launchpad.interfaces.oauth import (
1327+ ClockSkew,
1328+ IOAuthConsumerSet,
1329+ IOAuthSignedRequest,
1330+ NonceAlreadyUsed,
1331+ TimestampOrderingError,
1332+ )
1333 import canonical.launchpad.layers
1334 from canonical.launchpad.webapp.authentication import (
1335- get_oauth_principal,
1336+ check_oauth_signature,
1337+ get_oauth_authorization,
1338 )
1339 from canonical.launchpad.webapp.authorization import (
1340 LAUNCHPAD_SECURITY_POLICY_CACHE_KEY,
1341@@ -80,6 +93,8 @@
1342 INotificationRequest,
1343 INotificationResponse,
1344 IPlacelessAuthUtility,
1345+ IPlacelessLoginSource,
1346+ OAuthPermission,
1347 )
1348 from canonical.launchpad.webapp.notifications import (
1349 NotificationList,
1350@@ -1197,7 +1212,83 @@
1351 if request_path.startswith("/%s" % web_service_config.path_override):
1352 return super(WebServicePublication, self).getPrincipal(request)
1353
1354- return get_oauth_principal(request)
1355+ # Fetch OAuth authorization information from the request.
1356+ form = get_oauth_authorization(request)
1357+
1358+ consumer_key = form.get('oauth_consumer_key')
1359+ consumers = getUtility(IOAuthConsumerSet)
1360+ consumer = consumers.getByKey(consumer_key)
1361+ token_key = form.get('oauth_token')
1362+ anonymous_request = (token_key == '')
1363+
1364+ if consumer_key is None:
1365+ # Either the client's OAuth implementation is broken, or
1366+ # the user is trying to make an unauthenticated request
1367+ # using wget or another OAuth-ignorant application.
1368+ # Try to retrieve a consumer based on the User-Agent
1369+ # header.
1370+ anonymous_request = True
1371+ consumer_key = request.getHeader('User-Agent', '')
1372+ if consumer_key == '':
1373+ raise Unauthorized(
1374+ 'Anonymous requests must provide a User-Agent.')
1375+ consumer = consumers.getByKey(consumer_key)
1376+
1377+ if consumer is None:
1378+ if anonymous_request:
1379+ # This is the first time anyone has tried to make an
1380+ # anonymous request using this consumer name (or user
1381+ # agent). Dynamically create the consumer.
1382+ #
1383+ # In the normal website this wouldn't be possible
1384+ # because GET requests have their transactions rolled
1385+ # back. But webservice requests always have their
1386+ # transactions committed so that we can keep track of
1387+ # the OAuth nonces and prevent replay attacks.
1388+ if consumer_key == '' or consumer_key is None:
1389+ raise Unauthorized("No consumer key specified.")
1390+ consumer = consumers.new(consumer_key, '')
1391+ else:
1392+ # An unknown consumer can never make a non-anonymous
1393+ # request, because access tokens are registered with a
1394+ # specific, known consumer.
1395+ raise Unauthorized('Unknown consumer (%s).' % consumer_key)
1396+ if anonymous_request:
1397+ # Skip the OAuth verification step and let the user access the
1398+ # web service as an unauthenticated user.
1399+ #
1400+ # XXX leonardr 2009-12-15 bug=496964: Ideally we'd be
1401+ # auto-creating a token for the anonymous user the first
1402+ # time, passing it through the OAuth verification step,
1403+ # and using it on all subsequent anonymous requests.
1404+ alsoProvides(request, IOAuthSignedRequest)
1405+ auth_utility = getUtility(IPlacelessAuthUtility)
1406+ return auth_utility.unauthenticatedPrincipal()
1407+ token = consumer.getAccessToken(token_key)
1408+ if token is None:
1409+ raise Unauthorized('Unknown access token (%s).' % token_key)
1410+ nonce = form.get('oauth_nonce')
1411+ timestamp = form.get('oauth_timestamp')
1412+ try:
1413+ token.checkNonceAndTimestamp(nonce, timestamp)
1414+ except (NonceAlreadyUsed, TimestampOrderingError, ClockSkew), e:
1415+ raise Unauthorized('Invalid nonce/timestamp: %s' % e)
1416+ now = datetime.now(pytz.timezone('UTC'))
1417+ if token.permission == OAuthPermission.UNAUTHORIZED:
1418+ raise Unauthorized('Unauthorized token (%s).' % token.key)
1419+ elif token.date_expires is not None and token.date_expires <= now:
1420+ raise Unauthorized('Expired token (%s).' % token.key)
1421+ elif not check_oauth_signature(request, consumer, token):
1422+ raise Unauthorized('Invalid signature.')
1423+ else:
1424+ # Everything is fine, let's return the principal.
1425+ pass
1426+ alsoProvides(request, IOAuthSignedRequest)
1427+ principal = getUtility(IPlacelessLoginSource).getPrincipal(
1428+ token.person.account.id, access_level=token.permission,
1429+ scope=token.context)
1430+
1431+ return principal
1432
1433
1434 class LaunchpadWebServiceRequestTraversal(WebServiceRequestTraversal):
1435
1436=== modified file 'lib/canonical/launchpad/zcml/launchpad.zcml'
1437--- lib/canonical/launchpad/zcml/launchpad.zcml 2010-09-09 21:09:00 +0000
1438+++ lib/canonical/launchpad/zcml/launchpad.zcml 2010-09-22 19:29:46 +0000
1439@@ -266,14 +266,14 @@
1440 name="+authorize-token"
1441 class="canonical.launchpad.browser.OAuthAuthorizeTokenView"
1442 template="../templates/oauth-authorize.pt"
1443- permission="zope.Public" />
1444+ permission="launchpad.AnyPerson" />
1445
1446 <browser:page
1447 for="canonical.launchpad.webapp.interfaces.ILaunchpadApplication"
1448 name="+token-authorized"
1449 class="canonical.launchpad.browser.OAuthTokenAuthorizedView"
1450 template="../templates/token-authorized.pt"
1451- permission="zope.Public" />
1452+ permission="launchpad.AnyPerson" />
1453
1454 <browser:page
1455 for="canonical.launchpad.webapp.interfaces.ILaunchpadApplication"
1456
1457=== modified file 'lib/lp/testing/__init__.py'
1458--- lib/lp/testing/__init__.py 2010-09-20 12:56:53 +0000
1459+++ lib/lp/testing/__init__.py 2010-09-22 19:29:46 +0000
1460@@ -28,7 +28,6 @@
1461 'map_branch_contents',
1462 'normalize_whitespace',
1463 'oauth_access_token_for',
1464- 'OAuthSigningBrowser',
1465 'person_logged_in',
1466 'record_statements',
1467 'run_with_login',
1468@@ -146,7 +145,6 @@
1469 launchpadlib_credentials_for,
1470 launchpadlib_for,
1471 oauth_access_token_for,
1472- OAuthSigningBrowser,
1473 )
1474 from lp.testing.fixture import ZopeEventHandlerFixture
1475 from lp.testing.matchers import Provides
1476@@ -224,7 +222,7 @@
1477
1478 class StormStatementRecorder:
1479 """A storm tracer to count queries.
1480-
1481+
1482 This exposes the count and queries as lp.testing._webservice.QueryCollector
1483 does permitting its use with the HasQueryCount matcher.
1484
1485@@ -683,7 +681,6 @@
1486 def assertTextMatchesExpressionIgnoreWhitespace(self,
1487 regular_expression_txt,
1488 text):
1489-
1490 def normalise_whitespace(text):
1491 return ' '.join(text.split())
1492 pattern = re.compile(
1493@@ -860,7 +857,6 @@
1494 callable, and events are the events emitted by the callable.
1495 """
1496 events = []
1497-
1498 def on_notify(event):
1499 events.append(event)
1500 old_subscribers = zope.event.subscribers[:]
1501
1502=== modified file 'lib/lp/testing/_webservice.py'
1503--- lib/lp/testing/_webservice.py 2010-09-16 15:40:56 +0000
1504+++ lib/lp/testing/_webservice.py 2010-09-22 19:29:46 +0000
1505@@ -9,104 +9,34 @@
1506 'launchpadlib_credentials_for',
1507 'launchpadlib_for',
1508 'oauth_access_token_for',
1509- 'OAuthSigningBrowser',
1510 ]
1511
1512
1513 import shutil
1514 import tempfile
1515+
1516+from launchpadlib.credentials import (
1517+ AccessToken,
1518+ Credentials,
1519+ )
1520+from launchpadlib.launchpad import Launchpad
1521 import transaction
1522-from urllib2 import BaseHandler
1523-
1524-from oauth.oauth import OAuthRequest, OAuthSignatureMethod_PLAINTEXT
1525-
1526 from zope.app.publication.interfaces import IEndRequestEvent
1527 from zope.app.testing import ztapi
1528-from zope.testbrowser.testing import Browser
1529 from zope.component import getUtility
1530 import zope.testing.cleanup
1531
1532-from launchpadlib.credentials import (
1533- AccessToken,
1534- Credentials,
1535- )
1536-from launchpadlib.launchpad import Launchpad
1537-
1538-from lp.testing._login import (
1539- login,
1540- logout,
1541- )
1542-
1543 from canonical.launchpad.interfaces import (
1544 IOAuthConsumerSet,
1545 IPersonSet,
1546- OAUTH_REALM,
1547 )
1548 from canonical.launchpad.webapp.adapter import get_request_statements
1549 from canonical.launchpad.webapp.interaction import ANONYMOUS
1550 from canonical.launchpad.webapp.interfaces import OAuthPermission
1551-
1552-
1553-class OAuthSigningHandler(BaseHandler):
1554- """A urllib2 handler that signs requests with an OAuth token."""
1555-
1556- def __init__(self, consumer, token):
1557- """Constructor
1558-
1559- :param consumer: An OAuth consumer.
1560- :param token: An OAuth token.
1561- """
1562- self.consumer = consumer
1563- self.token = token
1564-
1565- def default_open(self, req):
1566- """Set the Authorization header for the outgoing request."""
1567- signer = OAuthRequest.from_consumer_and_token(
1568- self.consumer, self.token)
1569- signer.sign_request(
1570- OAuthSignatureMethod_PLAINTEXT(), self.consumer, self.token)
1571- auth_header = signer.to_header(OAUTH_REALM)['Authorization']
1572- req.headers['Authorization'] = auth_header
1573-
1574-
1575-class UserAgentFilteringHandler(BaseHandler):
1576- """A urllib2 handler that replaces the User-Agent header.
1577-
1578- [XXX bug=638058] This is a hack to work around a bug in
1579- zope.testbrowser.
1580- """
1581- def __init__(self, user_agent):
1582- """Constructor."""
1583- self.user_agent = user_agent
1584-
1585- def default_open(self, req):
1586- """Set the User-Agent header for the outgoing request."""
1587- req.headers['User-Agent'] = self.user_agent
1588-
1589-
1590-class OAuthSigningBrowser(Browser):
1591- """A browser that signs each outgoing request with an OAuth token.
1592-
1593- This lets us simulate the behavior of the Launchpad Credentials
1594- Manager.
1595- """
1596- def __init__(self, consumer, token, user_agent=None):
1597- """Constructor.
1598-
1599- :param consumer: An OAuth consumer.
1600- :param token: An OAuth token.
1601- :param user_agent: The User-Agent string to send.
1602- """
1603- super(OAuthSigningBrowser, self).__init__()
1604- self.mech_browser.add_handler(
1605- OAuthSigningHandler(consumer, token))
1606- if user_agent is not None:
1607- self.mech_browser.add_handler(
1608- UserAgentFilteringHandler(user_agent))
1609-
1610- # This will give us tracebacks instead of unhelpful error
1611- # messages.
1612- self.handleErrors = False
1613+from lp.testing._login import (
1614+ login,
1615+ logout,
1616+ )
1617
1618
1619 def oauth_access_token_for(consumer_name, person, permission, context=None):

Subscribers

People subscribed via source and target branches

to status/vote changes: