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
=== modified file 'lib/canonical/launchpad/browser/oauth.py'
--- lib/canonical/launchpad/browser/oauth.py 2010-09-15 20:06:13 +0000
+++ lib/canonical/launchpad/browser/oauth.py 2010-09-22 19:29:46 +0000
@@ -11,7 +11,6 @@
1111
12from lazr.restful import HTTPResource12from lazr.restful import HTTPResource
13import simplejson13import simplejson
14from zope.authentication.interfaces import IUnauthenticatedPrincipal
15from zope.component import getUtility14from zope.component import getUtility
16from zope.formlib.form import (15from zope.formlib.form import (
17 Action,16 Action,
@@ -31,15 +30,9 @@
31 )30 )
32from canonical.launchpad.webapp.authentication import (31from canonical.launchpad.webapp.authentication import (
33 check_oauth_signature,32 check_oauth_signature,
34 extract_oauth_access_token,
35 get_oauth_authorization,33 get_oauth_authorization,
36 get_oauth_principal34 )
37 )35from canonical.launchpad.webapp.interfaces import OAuthPermission
38from canonical.launchpad.webapp.interfaces import (
39 AccessLevel,
40 ILaunchBag,
41 OAuthPermission,
42 )
43from lp.app.errors import UnexpectedFormData36from lp.app.errors import UnexpectedFormData
44from lp.registry.interfaces.distribution import IDistributionSet37from lp.registry.interfaces.distribution import IDistributionSet
45from lp.registry.interfaces.pillar import IPillarNameSet38from lp.registry.interfaces.pillar import IPillarNameSet
@@ -96,122 +89,52 @@
9689
97 token = consumer.newRequestToken()90 token = consumer.newRequestToken()
98 if self.request.headers.get('Accept') == HTTPResource.JSON_TYPE:91 if self.request.headers.get('Accept') == HTTPResource.JSON_TYPE:
99 # Don't show the client the GRANT_PERMISSIONS access92 # Don't show the client the DESKTOP_INTEGRATION access
100 # level. If they have a legitimate need to use it, they'll93 # level. If they have a legitimate need to use it, they'll
101 # already know about it.94 # already know about it.
102 permissions = [permission for permission in OAuthPermission.items95 permissions = [
103 if permission != OAuthPermission.GRANT_PERMISSIONS]96 permission for permission in OAuthPermission.items
97 if (permission != OAuthPermission.DESKTOP_INTEGRATION)
98 ]
104 return self.getJSONRepresentation(99 return self.getJSONRepresentation(
105 permissions, token, include_secret=True)100 permissions, token, include_secret=True)
106 return u'oauth_token=%s&oauth_token_secret=%s' % (101 return u'oauth_token=%s&oauth_token_secret=%s' % (
107 token.key, token.secret)102 token.key, token.secret)
108103
109
110def token_exists_and_is_not_reviewed(form, action):104def token_exists_and_is_not_reviewed(form, action):
111 return form.token is not None and not form.token.is_reviewed105 return form.token is not None and not form.token.is_reviewed
112106
113107
108def token_review_success(form, action, data):
109 """The success callback for a button to approve a token."""
110 form.reviewToken(action.permission)
111
112
114def create_oauth_permission_actions():113def create_oauth_permission_actions():
115 """Return a list of `Action`s for each possible `OAuthPermission`."""114 """Return two `Actions` objects containing each possible `OAuthPermission`.
116 actions = Actions()115
117 actions_excluding_grant_permissions = Actions()116 The first `Actions` object contains every action supported by the
118117 OAuthAuthorizeTokenView. The second list contains a good default
119 def success(form, action, data):118 set of actions, omitting special permissions like DESKTOP_INTEGRATION.
120 form.reviewToken(action.permission)119 """
121120 all_actions = Actions()
121 ordinary_actions = Actions()
122 for permission in OAuthPermission.items:122 for permission in OAuthPermission.items:
123 action = Action(123 action = Action(
124 permission.title, name=permission.name, success=success,124 permission.title, name=permission.name,
125 success=token_review_success,
125 condition=token_exists_and_is_not_reviewed)126 condition=token_exists_and_is_not_reviewed)
126 action.permission = permission127 action.permission = permission
127 actions.append(action)128 all_actions.append(action)
128 if permission != OAuthPermission.GRANT_PERMISSIONS:129 if permission != OAuthPermission.DESKTOP_INTEGRATION:
129 actions_excluding_grant_permissions.append(action)130 ordinary_actions.append(action)
130 return actions, actions_excluding_grant_permissions131 return all_actions, ordinary_actions
131132
132133
133class CredentialManagerAwareMixin:134class OAuthAuthorizeTokenView(LaunchpadFormView, JSONTokenMixin):
134 """A view for which a browser may authenticate with an OAuth token.
135
136 The OAuth token must be signed with a token that has the
137 GRANT_PERMISSIONS access level, and the browser must present
138 itself as the Launchpad Credentials Manager.
139 """
140 # A prefix identifying the Launchpad Credential Manager's
141 # User-Agent string.
142 GRANT_PERMISSIONS_USER_AGENT_PREFIX = "Launchpad Credentials Manager"
143
144 def ensureRequestIsAuthorizedOrSigned(self):
145 """Find the user who initiated the request.
146
147 This property is used by a view that wants to reject access
148 unless the end-user is authenticated with cookie auth, HTTP
149 Basic Auth, *or* a properly authorized OAuth token.
150
151 If the user is logged in with cookie auth or HTTP Basic, then
152 other parts of Launchpad have taken care of the login and we
153 don't have to do anything. But if the user's browser has
154 signed the request with an OAuth token, other parts of
155 Launchpad won't recognize that as an attempt to authorize the
156 request.
157
158 This method does the OAuth part of the work. It checks that
159 the OAuth token is valid, that it's got the correct access
160 level, and that the User-Agent is one that's allowed to sign
161 requests with OAuth tokens.
162
163 :return: The user who Launchpad identifies as the principal.
164 Or, if Launchpad identifies no one as the principal, the user
165 whose valid GRANT_PERMISSIONS OAuth token was used to sign
166 the request.
167
168 :raise Unauthorized: If the request is unauthorized and
169 unsigned, improperly signed, anonymously signed, or signed
170 with a token that does not have the right access level.
171 """
172 user = getUtility(ILaunchBag).user
173 if user is not None:
174 return user
175 # The normal Launchpad code was not able to identify any
176 # user, but we're going to try a little harder before
177 # concluding that no one's logged in. If the incoming
178 # request is signed by an OAuth access token with the
179 # GRANT_PERMISSIONS access level, we will force a
180 # temporary login with the user whose access token this
181 # is.
182 token = extract_oauth_access_token(self.request)
183 if token is None:
184 # The request is not OAuth-signed. The normal Launchpad
185 # code had it right: no one is authenticated.
186 raise Unauthorized("Anonymous access is not allowed.")
187 principal = get_oauth_principal(self.request)
188 if IUnauthenticatedPrincipal.providedBy(principal):
189 # The request is OAuth-signed, but as the anonymous
190 # user.
191 raise Unauthorized("Anonymous access is not allowed.")
192 if token.permission != AccessLevel.GRANT_PERMISSIONS:
193 # The request is OAuth-signed, but the token has
194 # the wrong access level.
195 raise Unauthorized("OAuth token has insufficient access level.")
196
197 # Both the consumer key and the User-Agent must identify the
198 # Launchpad Credentials Manager.
199 must_start_with_prefix = [
200 token.consumer.key, self.request.getHeader("User-Agent")]
201 for string in must_start_with_prefix:
202 if not string.startswith(
203 self.GRANT_PERMISSIONS_USER_AGENT_PREFIX):
204 raise Unauthorized(
205 "Only the Launchpad Credentials Manager can access this "
206 "page by signing requests with an OAuth token.")
207 return principal.person
208
209
210class OAuthAuthorizeTokenView(
211 LaunchpadFormView, JSONTokenMixin, CredentialManagerAwareMixin):
212 """Where users authorize consumers to access Launchpad on their behalf."""135 """Where users authorize consumers to access Launchpad on their behalf."""
213136
214 actions, actions_excluding_grant_permissions = (137 actions, actions_excluding_special_permissions = (
215 create_oauth_permission_actions())138 create_oauth_permission_actions())
216 label = "Authorize application to access Launchpad on your behalf"139 label = "Authorize application to access Launchpad on your behalf"
217 schema = IOAuthRequestToken140 schema = IOAuthRequestToken
@@ -220,7 +143,7 @@
220143
221 @property144 @property
222 def visible_actions(self):145 def visible_actions(self):
223 """Restrict the actions to the subset the client can make use of.146 """Restrict the actions to a subset to be presented to the client.
224147
225 Not all client programs can function with all levels of148 Not all client programs can function with all levels of
226 access. For instance, a client that needs to modify the149 access. For instance, a client that needs to modify the
@@ -240,7 +163,7 @@
240163
241 allowed_permissions = self.request.form_ng.getAll('allow_permission')164 allowed_permissions = self.request.form_ng.getAll('allow_permission')
242 if len(allowed_permissions) == 0:165 if len(allowed_permissions) == 0:
243 return self.actions_excluding_grant_permissions166 return self.actions_excluding_special_permissions
244 actions = Actions()167 actions = Actions()
245168
246 # UNAUTHORIZED is always one of the options. If the client169 # UNAUTHORIZED is always one of the options. If the client
@@ -249,24 +172,53 @@
249 if OAuthPermission.UNAUTHORIZED.name in allowed_permissions:172 if OAuthPermission.UNAUTHORIZED.name in allowed_permissions:
250 allowed_permissions.remove(OAuthPermission.UNAUTHORIZED.name)173 allowed_permissions.remove(OAuthPermission.UNAUTHORIZED.name)
251174
252 # GRANT_PERMISSIONS cannot be requested as one of several175 # DESKTOP_INTEGRATION cannot be requested as one of several
253 # options--it must be the only option (other than176 # options--it must be the only option (other than
254 # UNAUTHORIZED). If GRANT_PERMISSIONS is one of several177 # UNAUTHORIZED). If DESKTOP_INTEGRATION is one of several
255 # options, remove it from the list.178 # options, remove it from the list.
256 if (OAuthPermission.GRANT_PERMISSIONS.name in allowed_permissions179 desktop_permission = OAuthPermission.DESKTOP_INTEGRATION
180 if (desktop_permission.name in allowed_permissions
257 and len(allowed_permissions) > 1):181 and len(allowed_permissions) > 1):
258 allowed_permissions.remove(OAuthPermission.GRANT_PERMISSIONS.name)182 allowed_permissions.remove(desktop_permission.name)
259183
260 # GRANT_PERMISSIONS may only be requested by a specific User-Agent.184 if desktop_permission.name in allowed_permissions:
261 if (OAuthPermission.GRANT_PERMISSIONS.name in allowed_permissions185 if not self.token.consumer.is_integrated_desktop:
262 and not self.request.getHeader("User-Agent").startswith(186 # Consumers may only ask for desktop integration if
263 self.GRANT_PERMISSIONS_USER_AGENT_PREFIX)):187 # they give a desktop type (eg. "Ubuntu") and a
264 allowed_permissions.remove(OAuthPermission.GRANT_PERMISSIONS.name)188 # user-recognizable desktop name (eg. the hostname).
265189 raise Unauthorized(
266 for action in self.actions:190 ('Consumer "%s" asked for desktop integration, '
267 if (action.permission.name in allowed_permissions191 "but didn't say what kind of desktop it is, or name "
268 or action.permission is OAuthPermission.UNAUTHORIZED):192 "the computer being integrated."
269 actions.append(action)193 % self.token.consumer.key))
194
195 # We're going for desktop integration. The only two
196 # possibilities are "allow" and "deny". We'll customize
197 # the "allow" message using the hostname provided by the
198 # desktop.
199 label = (
200 'Give all programs running on "%s" access '
201 'to my Launchpad account.')
202 allow_action = [
203 action for action in self.actions
204 if action.name == desktop_permission.name][0]
205 allow_action.label = (
206 label % self.token.consumer.integrated_desktop_name)
207 actions.append(allow_action)
208
209 # We'll customize the "deny" message as well.
210 deny_action = [
211 action for action in self.actions
212 if action.name == OAuthPermission.UNAUTHORIZED.name][0]
213 deny_action.label = "No, thanks, I don't trust this computer."
214 actions.append(deny_action)
215
216 else:
217 # We're going for web-based integration.
218 for action in self.actions_excluding_special_permissions:
219 if (action.permission.name in allowed_permissions
220 or action.permission is OAuthPermission.UNAUTHORIZED):
221 actions.append(action)
270222
271 if len(list(actions)) == 1:223 if len(list(actions)) == 1:
272 # The only visible action is UNAUTHORIZED. That means the224 # The only visible action is UNAUTHORIZED. That means the
@@ -275,17 +227,41 @@
275 # UNAUTHORIZED). Rather than present the end-user with an227 # UNAUTHORIZED). Rather than present the end-user with an
276 # impossible situation where their only option is to deny228 # impossible situation where their only option is to deny
277 # access, we'll present the full range of actions (except229 # access, we'll present the full range of actions (except
278 # for GRANT_PERMISSIONS).230 # for special permissions like DESKTOP_INTEGRATION).
279 return self.actions_excluding_grant_permissions231 return self.actions_excluding_special_permissions
280 return actions232 return actions
281233
282 def initialize(self):234 def initialize(self):
283 self.oauth_authorized_user = self.ensureRequestIsAuthorizedOrSigned()
284 self.storeTokenContext()235 self.storeTokenContext()
285236 form = get_oauth_authorization(self.request)
286 key = self.request.form.get('oauth_token')237 key = form.get('oauth_token')
287 if key:238 if key:
288 self.token = getUtility(IOAuthRequestTokenSet).getByKey(key)239 self.token = getUtility(IOAuthRequestTokenSet).getByKey(key)
240
241
242 callback = self.request.form.get('oauth_callback')
243 if (self.token is not None
244 and self.token.consumer.is_integrated_desktop):
245 # Nip problems in the bud by appling special rules about
246 # what desktop integrations are allowed to do.
247 if callback is not None:
248 # A desktop integration is not allowed to specify a callback.
249 raise Unauthorized(
250 "A desktop integration may not specify an "
251 "OAuth callback URL.")
252 # A desktop integration token can only have one of two
253 # permission levels: "Desktop Integration" and
254 # "Unauthorized". It shouldn't even be able to ask for any
255 # other level.
256 for action in self.visible_actions:
257 if action.permission not in (
258 OAuthPermission.DESKTOP_INTEGRATION,
259 OAuthPermission.UNAUTHORIZED):
260 raise Unauthorized(
261 ("Desktop integration token requested a permission "
262 '("%s") not supported for desktop-wide use.')
263 % action.label)
264
289 super(OAuthAuthorizeTokenView, self).initialize()265 super(OAuthAuthorizeTokenView, self).initialize()
290266
291 def render(self):267 def render(self):
@@ -314,8 +290,7 @@
314 self.token_context = context290 self.token_context = context
315291
316 def reviewToken(self, permission):292 def reviewToken(self, permission):
317 self.token.review(self.user or self.oauth_authorized_user,293 self.token.review(self.user, permission, self.token_context)
318 permission, self.token_context)
319 callback = self.request.form.get('oauth_callback')294 callback = self.request.form.get('oauth_callback')
320 if callback:295 if callback:
321 self.next_url = callback296 self.next_url = callback
@@ -343,7 +318,7 @@
343 return context318 return context
344319
345320
346class OAuthTokenAuthorizedView(LaunchpadView, CredentialManagerAwareMixin):321class OAuthTokenAuthorizedView(LaunchpadView):
347 """Where users who reviewed tokens may get redirected to.322 """Where users who reviewed tokens may get redirected to.
348323
349 If the consumer didn't include an oauth_callback when sending the user to324 If the consumer didn't include an oauth_callback when sending the user to
@@ -352,7 +327,6 @@
352 """327 """
353328
354 def initialize(self):329 def initialize(self):
355 authorized_user = self.ensureRequestIsAuthorizedOrSigned()
356 key = self.request.form.get('oauth_token')330 key = self.request.form.get('oauth_token')
357 self.token = getUtility(IOAuthRequestTokenSet).getByKey(key)331 self.token = getUtility(IOAuthRequestTokenSet).getByKey(key)
358 assert self.token.is_reviewed, (332 assert self.token.is_reviewed, (
359333
=== modified file 'lib/canonical/launchpad/database/oauth.py'
--- lib/canonical/launchpad/database/oauth.py 2010-09-15 20:55:03 +0000
+++ lib/canonical/launchpad/database/oauth.py 2010-09-22 19:29:46 +0000
@@ -15,6 +15,7 @@
15 timedelta,15 timedelta,
16 )16 )
1717
18import re
18import pytz19import pytz
19from sqlobject import (20from sqlobject import (
20 BoolCol,21 BoolCol,
@@ -60,14 +61,14 @@
6061
61# How many hours should a request token be valid for?62# How many hours should a request token be valid for?
62REQUEST_TOKEN_VALIDITY = 1263REQUEST_TOKEN_VALIDITY = 12
63# The OAuth Core 1.0 spec (http://oauth.net/core/1.0/#nonce) says that64# The OAuth Core 1.0 spec (http://oauth.net/core/1.0/#nonce) says that a
64# a timestamp "MUST be equal or greater than the timestamp used in65# timestamp "MUST be equal or greater than the timestamp used in previous
65# previous requests," but this is likely to cause problems if the66# requests," but this is likely to cause problems if the client does request
66# client does request pipelining, so we use a time window (relative to67# pipelining, so we use a time window (relative to the timestamp of the
67# the timestamp of the existing OAuthNonce) to check if the timestamp68# existing OAuthNonce) to check if the timestamp can is acceptable. As
68# can is acceptable. As suggested by Robert, we use a window which is69# suggested by Robert, we use a window which is at least twice the size of our
69# at least twice the size of our hard time out. This is a safe bet70# hard time out. This is a safe bet since no requests should take more than
70# since no requests should take more than one hard time out.71# one hard time out.
71TIMESTAMP_ACCEPTANCE_WINDOW = 60 # seconds72TIMESTAMP_ACCEPTANCE_WINDOW = 60 # seconds
72# If the timestamp is far in the future because of a client's clock skew,73# If the timestamp is far in the future because of a client's clock skew,
73# it will effectively invalidate the authentication tokens when the clock is74# it will effectively invalidate the authentication tokens when the clock is
@@ -77,7 +78,6 @@
77# amount.78# amount.
78TIMESTAMP_SKEW_WINDOW = 60*60 # seconds, +/-79TIMESTAMP_SKEW_WINDOW = 60*60 # seconds, +/-
7980
80
81class OAuthBase(SQLBase):81class OAuthBase(SQLBase):
82 """Base class for all OAuth database classes."""82 """Base class for all OAuth database classes."""
8383
@@ -104,6 +104,50 @@
104 key = StringCol(notNull=True)104 key = StringCol(notNull=True)
105 secret = StringCol(notNull=False, default='')105 secret = StringCol(notNull=False, default='')
106106
107 # This regular expression singles out a consumer key that represents
108 # any and all apps running on a specific computer. For instance:
109 #
110 # Ubuntu desktop (hostname1)
111 # - An Ubuntu desktop called "hostname1"
112 # Windows desktop (Computer Name)
113 # - A Windows desktop called "Computer Name"
114 # Mac OS desktop (hostname2)
115 # - A Macintosh desktop called "hostname2"
116 # Android desktop (Bob's Phone)
117 # - An Android phone called "Bob's Phone"
118 integrated_desktop_re = re.compile("^(.*) desktop \(([^)]*)\)$")
119
120 def _integrated_desktop_match_group(self, position):
121 """Return information about a desktop integration token.
122
123 A convenience method that runs the desktop integration regular
124 expression against the consumer key.
125
126 :param position: The match group to return if the regular
127 expression matches.
128
129 :return: The value of one of the match groups, or None.
130 """
131 match = self.integrated_desktop_re.match(self.key)
132 if match is None:
133 return None
134 return match.groups()[position]
135
136 @property
137 def is_integrated_desktop(self):
138 """See `IOAuthConsumer`."""
139 return self.integrated_desktop_re.match(self.key) is not None
140
141 @property
142 def integrated_desktop_type(self):
143 """See `IOAuthConsumer`."""
144 return self._integrated_desktop_match_group(0)
145
146 @property
147 def integrated_desktop_name(self):
148 """See `IOAuthConsumer`."""
149 return self._integrated_desktop_match_group(1)
150
107 def newRequestToken(self):151 def newRequestToken(self):
108 """See `IOAuthConsumer`."""152 """See `IOAuthConsumer`."""
109 key, secret = create_token_key_and_secret(table=OAuthRequestToken)153 key, secret = create_token_key_and_secret(table=OAuthRequestToken)
@@ -325,7 +369,6 @@
325 The key will have a length of 20 and we'll make sure it's not yet in the369 The key will have a length of 20 and we'll make sure it's not yet in the
326 given table. The secret will have a length of 80.370 given table. The secret will have a length of 80.
327 """371 """
328
329 key_length = 20372 key_length = 20
330 key = create_unique_token_for_table(key_length, getattr(table, "key"))373 key = create_unique_token_for_table(key_length, getattr(table, "key"))
331 secret_length = 80374 secret_length = 80
332375
=== modified file 'lib/canonical/launchpad/doc/oauth.txt'
--- lib/canonical/launchpad/doc/oauth.txt 2010-04-16 15:06:55 +0000
+++ lib/canonical/launchpad/doc/oauth.txt 2010-09-22 19:29:46 +0000
@@ -38,6 +38,43 @@
38 ...38 ...
39 AssertionError: ...39 AssertionError: ...
4040
41Desktop consumers
42=================
43
44In a web context, each application is represented by a unique consumer
45key. But a typical user sitting at a typical desktop, using multiple
46desktop applications that integrate with Launchpad, is represented by
47a single consumer key. The user's session as a whole is a single
48"consumer", and the consumer key is expected to contain structured
49information: the type of desktop (usually the operating system) and a
50string that the end-user would recognize as identifying their
51computer.
52
53 >>> desktop_key = consumer_set.new("Ubuntu desktop (hostname)")
54 >>> desktop_key.is_integrated_desktop
55 True
56 >>> print desktop_key.integrated_desktop_type
57 Ubuntu
58 >>> print desktop_key.integrated_desktop_name
59 hostname
60
61 >>> desktop_key = consumer_set.new("Windows desktop (My Computer)")
62 >>> desktop_key.is_integrated_desktop
63 True
64 >>> print desktop_key.integrated_desktop_type
65 Windows
66 >>> print desktop_key.integrated_desktop_name
67 My Computer
68
69A normal OAuth consumer does not have this information.
70
71 >>> ordinary_key = consumer_set.new("Not a desktop at all.")
72 >>> ordinary_key.is_integrated_desktop
73 False
74 >>> print ordinary_key.integrated_desktop_type
75 None
76 >>> print ordinary_key.integrated_desktop_name
77 None
4178
42Request tokens79Request tokens
43==============80==============
4481
=== modified file 'lib/canonical/launchpad/doc/webapp-authorization.txt'
--- lib/canonical/launchpad/doc/webapp-authorization.txt 2010-08-24 16:44:42 +0000
+++ lib/canonical/launchpad/doc/webapp-authorization.txt 2010-09-22 19:29:46 +0000
@@ -79,24 +79,16 @@
79 >>> check_permission('launchpad.View', bug_1)79 >>> check_permission('launchpad.View', bug_1)
80 False80 False
8181
82Now consider a principal authorized to create OAuth tokens. Whenever82A token used for desktop integration has a level of permission
83it's not creating OAuth tokens, it has a level of permission83equivalent to WRITE_PUBLIC.
84equivalent to READ_PUBLIC.
8584
86 >>> principal.access_level = AccessLevel.GRANT_PERMISSIONS85 >>> principal.access_level = AccessLevel.DESKTOP_INTEGRATION
87 >>> setupInteraction(principal)86 >>> setupInteraction(principal)
88 >>> check_permission('launchpad.View', bug_1)87 >>> check_permission('launchpad.View', bug_1)
89 False88 True
9089
91 >>> check_permission('launchpad.Edit', sample_person)90 >>> check_permission('launchpad.Edit', sample_person)
92 False91 True
93
94This may seem useless from a security standpoint, since once a
95malicious client is authorized to create OAuth tokens, it can escalate
96its privileges at any time by creating a new token for itself. The
97security benefit is more subtle: by discouraging feature creep in
98clients that have this super-access level, we reduce the risk that a
99bug in a _trusted_ client will enable privilege escalation attacks.
10092
101Users logged in through the web application have full access, which93Users logged in through the web application have full access, which
102means they can read/change any object they have access to.94means they can read/change any object they have access to.
10395
=== modified file 'lib/canonical/launchpad/interfaces/oauth.py'
--- lib/canonical/launchpad/interfaces/oauth.py 2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/interfaces/oauth.py 2010-09-22 19:29:46 +0000
@@ -64,6 +64,22 @@
64 description=_('The secret which, if not empty, should be used by the '64 description=_('The secret which, if not empty, should be used by the '
65 'consumer to sign its requests.'))65 'consumer to sign its requests.'))
6666
67 is_integrated_desktop = Attribute(
68 """This attribute is true if the consumer corresponds to a
69 user account on a personal computer.""")
70
71 integrated_desktop_name = Attribute(
72 """If the consumer corresponds to a user account on a personal
73 computer, this is the self-reported name of that computer. If
74 the consumer is a specific web or desktop application, this is
75 None.""")
76
77 integrated_desktop_type = Attribute(
78 """If the consumer corresponds to a user account on a personal
79 computer, this is the self-reported type of that computer
80 (usually the operating system). If the consumer is a specific
81 web or desktop application, this is None.""")
82
67 def newRequestToken():83 def newRequestToken():
68 """Return a new `IOAuthRequestToken` with a random key and secret.84 """Return a new `IOAuthRequestToken` with a random key and secret.
6985
7086
=== modified file 'lib/canonical/launchpad/pagetests/oauth/authorize-token.txt'
--- lib/canonical/launchpad/pagetests/oauth/authorize-token.txt 2010-09-16 21:34:31 +0000
+++ lib/canonical/launchpad/pagetests/oauth/authorize-token.txt 2010-09-22 19:29:46 +0000
@@ -1,6 +1,4 @@
1***************************1= Authorizing a request token =
2Authorizing a request token
3***************************
42
5Once the consumer gets a request token, it must send the user to3Once the consumer gets a request token, it must send the user to
6Launchpad's +authorize-token page in order for the user to authenticate4Launchpad's +authorize-token page in order for the user to authenticate
@@ -21,10 +19,9 @@
21The oauth_token parameter, on the other hand, is required in the19The oauth_token parameter, on the other hand, is required in the
22Launchpad implementation.20Launchpad implementation.
2321
24Access to the page22The +authorize-token page is restricted to logged in users, so users will
25==================23first be asked to log in. (We won't show the actual login process because
2624it involves OpenID, which would complicate this test quite a bit.)
27The +authorize-token page is restricted to authenticated users.
2825
29 >>> from urllib import urlencode26 >>> from urllib import urlencode
30 >>> params = dict(27 >>> params = dict(
@@ -33,18 +30,7 @@
33 >>> browser.open(url)30 >>> browser.open(url)
34 Traceback (most recent call last):31 Traceback (most recent call last):
35 ...32 ...
36 Unauthorized: Anonymous access is not allowed.33 Unauthorized:...
37
38However, the details of the authentication are different than from any
39other part of Launchpad. Unlike with other pages, a user can authorize
40an OAuth token by signing their outgoing requests with an _existing_
41OAuth token. This makes it possible for a desktop client to retrieve
42this page without knowing the end-user's username and password, or
43making them navigate the arbitrarily complex OpenID login procedure.
44
45But, let's deal with that a little later. First let's show how the
46process works through HTTP Basic Auth (the testing equivalent of a
47regular username-and-password login).
4834
49 >>> browser = setupBrowser(auth='Basic no-priv@canonical.com:test')35 >>> browser = setupBrowser(auth='Basic no-priv@canonical.com:test')
50 >>> browser.open(url)36 >>> browser.open(url)
@@ -58,12 +44,8 @@
58 ...44 ...
59 See all applications authorized to access Launchpad on your behalf.45 See all applications authorized to access Launchpad on your behalf.
6046
61
62Using the page
63==============
64
65This page contains one submit button for each item of OAuthPermission,47This page contains one submit button for each item of OAuthPermission,
66except for 'Grant Permissions', which must be specifically requested.48except for 'Desktop Integration', which must be specifically requested.
6749
68 >>> browser.getControl('No Access')50 >>> browser.getControl('No Access')
69 <SubmitControl...51 <SubmitControl...
@@ -76,6 +58,7 @@
76 >>> browser.getControl('Change Anything')58 >>> browser.getControl('Change Anything')
77 <SubmitControl...59 <SubmitControl...
7860
61 # XXX FIXME
79 >>> browser.getControl('Grant Permissions')62 >>> browser.getControl('Grant Permissions')
80 Traceback (most recent call last):63 Traceback (most recent call last):
81 ...64 ...
@@ -92,48 +75,17 @@
92that isn't enough for the application. The user always has the option75that isn't enough for the application. The user always has the option
93to deny permission altogether.76to deny permission altogether.
9477
95 >>> def filter_user_agent(key, value, new_value):78 >>> def authorize_token_main_content(allow_permission):
96 ... """A filter to replace the User-Agent header in a list of headers.
97 ...
98 ... [XXX bug=638058] This is a hack to work around a bug in
99 ... zope.testbrowser.
100 ... """
101 ...
102 ... if key.lower() == "user-agent":
103 ... return (key, new_value)
104 ... return (key, value)
105
106 >>> def print_access_levels(allow_permission, user_agent=None):
107 ... if user_agent is not None:
108 ... # [XXX bug=638058] This is a hack to work around a bug in
109 ... # zope.testbrowser which prevents browser.addHeader
110 ... # from working with User-Agent.
111 ... mech_browser = browser.mech_browser
112 ... # Store the original User-Agent for later.
113 ... old_user_agent = [
114 ... value for key, value in mech_browser.addheaders
115 ... if key.lower() == "user-agent"][0]
116 ... # Replace the User-Agent with the value passed into this
117 ... # function.
118 ... mech_browser.addheaders = [
119 ... filter_user_agent(key, value, user_agent)
120 ... for key, value in mech_browser.addheaders]
121 ...
122 ... # Okay, now we can make the request.
123 ... browser.open(79 ... browser.open(
124 ... "http://launchpad.dev/+authorize-token?%s&%s"80 ... "http://launchpad.dev/+authorize-token?%s&%s"
125 ... % (urlencode(params), allow_permission))81 ... % (urlencode(params), allow_permission))
126 ... main_content = find_tag_by_id(browser.contents, 'maincontent')82 ... return find_tag_by_id(browser.contents, 'maincontent')
83
84 >>> def print_access_levels(allow_permission):
85 ... main_content = authorize_token_main_content(allow_permission)
127 ... actions = main_content.findAll('input', attrs={'type': 'submit'})86 ... actions = main_content.findAll('input', attrs={'type': 'submit'})
128 ... for action in actions:87 ... for action in actions:
129 ... print action['value']88 ... print action['value']
130 ...
131 ... if user_agent is not None:
132 ... # Finally, restore the old User-Agent.
133 ... mech_browser.addheaders = [
134 ... filter_user_agent(key, value, old_user_agent)
135 ... for key, value in mech_browser.addheaders]
136
13789
138 >>> print_access_levels(90 >>> print_access_levels(
139 ... 'allow_permission=WRITE_PUBLIC&allow_permission=WRITE_PRIVATE')91 ... 'allow_permission=WRITE_PUBLIC&allow_permission=WRITE_PRIVATE')
@@ -141,43 +93,9 @@
141 Change Non-Private Data93 Change Non-Private Data
142 Change Anything94 Change Anything
14395
144The only time the 'Grant Permissions' permission shows up in this list
145is if a client identifying itself as the Launchpad Credentials Manager
146specifically requests it, and no other permission. (Also requesting
147UNAUTHORIZED is okay--it will show up anyway.)
148
149 >>> USER_AGENT = "Launchpad Credentials Manager v1.0"
150 >>> print_access_levels(
151 ... 'allow_permission=GRANT_PERMISSIONS', USER_AGENT)
152 No Access
153 Grant Permissions
154
155 >>> print_access_levels(
156 ... ('allow_permission=GRANT_PERMISSIONS&'
157 ... 'allow_permission=UNAUTHORIZED'),
158 ... USER_AGENT)
159 No Access
160 Grant Permissions
161
162 >>> print_access_levels(
163 ... ('allow_permission=WRITE_PUBLIC&'
164 ... 'allow_permission=GRANT_PERMISSIONS'))
165 No Access
166 Change Non-Private Data
167
168If a client asks for GRANT_PERMISSIONS but doesn't claim to be the
169Launchpad Credentials Manager, Launchpad will not show GRANT_PERMISSIONS.
170
171 >>> print_access_levels('allow_permission=GRANT_PERMISSIONS')
172 No Access
173 Read Non-Private Data
174 Change Non-Private Data
175 Read Anything
176 Change Anything
177
178If an application doesn't specify any valid access levels, or only96If an application doesn't specify any valid access levels, or only
179specifies the UNAUTHORIZED access level, Launchpad will show all the97specifies the UNAUTHORIZED access level, Launchpad will show all the
180access levels, except for GRANT_PERMISSIONS.98access levels, except for DESKTOP_INTEGRATION.
18199
182 >>> print_access_levels('')100 >>> print_access_levels('')
183 No Access101 No Access
@@ -193,6 +111,20 @@
193 Read Anything111 Read Anything
194 Change Anything112 Change Anything
195113
114An application may not request the DESKTOP_INTEGRATION access level
115unless its consumer key matches a certain pattern. (Successful desktop
116integration has its own section, below.)
117
118 >>> allow_permission = "allow_permission=DESKTOP_INTEGRATION"
119 >>> browser.open(
120 ... "http://launchpad.dev/+authorize-token?%s&%s"
121 ... % (urlencode(params), allow_permission))
122 Traceback (most recent call last):
123 ...
124 Unauthorized: Consumer "foobar123451432" asked for desktop
125 integration, but didn't say what kind of desktop it is, or name
126 the computer being integrated.
127
196An application may also specify a context, so that the access granted128An application may also specify a context, so that the access granted
197by the user is restricted to things related to that context.129by the user is restricted to things related to that context.
198130
@@ -331,123 +263,90 @@
331 reviewed ... ago.263 reviewed ... ago.
332 See all applications authorized to access Launchpad on your behalf.264 See all applications authorized to access Launchpad on your behalf.
333265
334Access through OAuth266Desktop integration
335====================267===================
336268
337Now it's time to show how to go through the same process without269The test case given above shows how to integrate a single application
338knowing the end-user's username and password. All you need is an OAuth270or website into Launchpad. But it's also possible to integrate an
339token issued with the GRANT_PERMISSIONS access level, in the name of271entire desktop environment into Launchpad.
340the Launchpad Credentials Manager.272
341273The desktop integration option is only available for OAuth consumers
342Let's go through the approval process again, without ever sending the274that say what kind of desktop they are (eg. Ubuntu) and give a name
343user's username or password over HTTP. First we'll create a new user,275that a user can identify with their computer (eg. the hostname). Here,
344and a GRANT_PERMISSIONS access token that they can use to sign276we'll create such a token.
345requests.277
346278 >>> login('salgado@ubuntu.com')
347 >>> login(ANONYMOUS)279 >>> desktop_key = "Ubuntu desktop (mycomputer)"
348 >>> user = factory.makePerson(name="test-user", password="never-used")280 >>> consumer = getUtility(IOAuthConsumerSet).new(desktop_key)
349 >>> logout()281 >>> token = consumer.newRequestToken()
350282 >>> logout()
351 >>> from oauth.oauth import OAuthConsumer283
352 >>> manager_consumer = OAuthConsumer("Launchpad Credentials Manager", "")284When a desktop tries to integrate with Launchpad, the user gets a
353285special warning about giving access to every program running on their
354 >>> from lp.testing import oauth_access_token_for286desktop.
355 >>> login_person(user)287
356 >>> grant_permissions_token = oauth_access_token_for(288 >>> params = dict(oauth_token=token.key)
357 ... manager_consumer.key, user, "GRANT_PERMISSIONS")289 >>> print extract_text(
358 >>> logout()290 ... authorize_token_main_content(
359291 ... 'allow_permission=DESKTOP_INTEGRATION'))
360Next, we'll give the new user an OAuth request token that needs to be292 The Ubuntu computer identified as mycomputer wants access to your
361approved using a web browser.293 Launchpad account. If you allow the integration, all applications
362294 running on mycomputer will have read-write access to your
363 >>> login_person(user)295 Launchpad account, including to your private data.
364 >>> consumer = getUtility(IOAuthConsumerSet).getByKey('foobar123451432')296 If mycomputer is not the computer you're using right now, or if
365 >>> request_token = consumer.newRequestToken()297 you don't trust this computer, you should click "No, thanks, I
366 >>> logout()298 don't trust this computer", or close this window now.
367299 Even if you decide to allow the integration, you can
368 >>> params = dict(oauth_token=request_token.key)300 change your mind later.
369 >>> url = "http://launchpad.dev/+authorize-token?%s" % urlencode(params)301 See all applications authorized to access Launchpad on your behalf.
370302
371Next, we'll create a browser object that knows how to sign requests303The only time the 'Desktop Integration' permission shows up in the
372with the new user's existing access token.304list of permissions is if the client specifically requests it, and no
373305other permission. (Also requesting UNAUTHORIZED is okay--it will show
374 >>> from lp.testing import OAuthSigningBrowser306up anyway.)
375 >>> browser = OAuthSigningBrowser(307
376 ... manager_consumer, grant_permissions_token, USER_AGENT)308 >>> print_access_levels('allow_permission=DESKTOP_INTEGRATION')
377 >>> browser.open(url)309 Give all programs running on "mycomputer" access to my Launchpad account.
378 >>> print browser.title310 No, thanks, I don't trust this computer.
379 Authorize application to access Launchpad on your behalf311
380312 >>> print_access_levels(
381The browser object can approve the request and see the appropriate313 ... 'allow_permission=DESKTOP_INTEGRATION&allow_permission=UNAUTHORIZED')
382messages, even though we never gave it the user's password.314 Give all programs running on "mycomputer" access to my Launchpad account.
383315 No, thanks, I don't trust this computer.
384 >>> browser.getControl('Read Anything').click()316
385317A desktop may not request a level of access other than
386 >>> browser.url318DESKTOP_INTEGRATION, since the whole point is to have a permission
387 'http://launchpad.dev/+token-authorized?...'319level that specifically applies across the entire desktop.
388 >>> print extract_text(find_tag_by_id(browser.contents, 'maincontent'))320
389 Almost finished ...321 >>> print_access_levels('allow_permission=WRITE_PRIVATE')
390 To finish authorizing the application identified as foobar123451432 to322 Traceback (most recent call last):
391 access Launchpad on your behalf you should go back to the application323 ...
392 window in which you started the process and inform it that you have done324 Unauthorized: Desktop integration token requested a permission
393 your part of the process.325 ("Change Anything") not supported for desktop-wide use.
394326
395OAuth error conditions327 >>> print_access_levels(
396----------------------328 ... 'allow_permission=WRITE_PUBLIC&allow_permission=DESKTOP_INTEGRATION')
397329 Traceback (most recent call last):
398The OAuth token used to sign the requests must have the330 ...
399GRANT_PERMISSIONS access level; no other access level will work.331 Unauthorized: Desktop integration token requested a permission
400332 ("Change Non-Private Data") not supported for desktop-wide use.
401 >>> login(ANONYMOUS)333
402 >>> insufficient_token = oauth_access_token_for(334You can't specify a callback URL when authorizing a desktop-wide
403 ... manager_consumer.key, user, "WRITE_PRIVATE")335token, since callback URLs should only be used when integrating
404 >>> logout()336websites into Launchpad.
405337
406 >>> browser = OAuthSigningBrowser(338 >>> params['oauth_callback'] = 'http://launchpad.dev/bzr'
407 ... manager_consumer, insufficient_token, USER_AGENT)339 >>> print_access_levels('allow_permission=DESKTOP_INTEGRATION')
408 >>> browser.open(url)340 Traceback (most recent call last):
409 Traceback (most recent call last):341 ...
410 ...342 Unauthorized: A desktop integration may not specify an OAuth
411 Unauthorized: OAuth token has insufficient access level.343 callback URL.
412344
413The OAuth token must be for the Launchpad Credentials Manager, or it345This is true even if the desktop token isn't asking for the
414cannot be used. (Launchpad shouldn't even _issue_ a GRANT_PERMISSIONS346DESKTOP_INTEGRATION permission.
415token for any other consumer, but even if it somehow does, that token347
416can't be used for this.)348 >>> print_access_levels('allow_permission=WRITE_PRIVATE')
417349 Traceback (most recent call last):
418 >>> login(ANONYMOUS)350 ...
419 >>> wrong_consumer = OAuthConsumer(351 Unauthorized: A desktop integration may not specify an OAuth
420 ... "Not the Launchpad Credentials Manager", "")352 callback URL.
421 >>> wrong_consumer_token = oauth_access_token_for(
422 ... wrong_consumer.key, user, "GRANT_PERMISSIONS")
423 >>> logout()
424
425 >>> browser = OAuthSigningBrowser(wrong_consumer, wrong_consumer_token)
426 >>> browser.open(url)
427 Traceback (most recent call last):
428 ...
429 Unauthorized: Only the Launchpad Credentials Manager can access
430 this page by signing requests with an OAuth token.
431
432Signing with an anonymous token will also not work.
433
434 >>> from oauth.oauth import OAuthToken
435 >>> anonymous_token = OAuthToken(key="", secret="")
436 >>> browser = OAuthSigningBrowser(manager_consumer, anonymous_token)
437 >>> browser.open(url)
438 Traceback (most recent call last):
439 ...
440 Unauthorized: Anonymous access is not allowed.
441
442Even if it presents the right token, the user agent sending the signed
443request must *also* identify *itself* as the Launchpad Credentials
444Manager.
445
446 >>> browser = OAuthSigningBrowser(
447 ... manager_consumer, grant_permissions_token,
448 ... "Not the Launchpad Credentials Manager")
449 >>> browser.open(url)
450 Traceback (most recent call last):
451 ...
452 Unauthorized: Only the Launchpad Credentials Manager can access
453 this page by signing requests with an OAuth token.
454353
=== modified file 'lib/canonical/launchpad/templates/oauth-authorize.pt'
--- lib/canonical/launchpad/templates/oauth-authorize.pt 2009-07-17 17:59:07 +0000
+++ lib/canonical/launchpad/templates/oauth-authorize.pt 2010-09-22 19:29:46 +0000
@@ -21,28 +21,57 @@
21 <tal:token-not-reviewed condition="not:token/is_reviewed">21 <tal:token-not-reviewed condition="not:token/is_reviewed">
22 <div metal:use-macro="context/@@launchpad_form/form">22 <div metal:use-macro="context/@@launchpad_form/form">
23 <div metal:fill-slot="extra_top">23 <div metal:fill-slot="extra_top">
24 <p>The application identified as24
25 <strong tal:content="token/consumer/key">consumer</strong>25 <tal:desktop-integration-token condition="token/consumer/is_integrated_desktop">
26 wants to access26 <p>The
27 <tal:has-context condition="view/token_context">27 <tal:desktop replace="structure
28 things related to28 token/consumer/integrated_desktop_type" />
29 <strong tal:content="view/token_context/title">Context</strong>29 computer identified
30 in30 as <strong tal:content="token/consumer/integrated_desktop_name">hostname</strong>
31 </tal:has-context>31 wants access to your Launchpad account. If you allow the
32 Launchpad on your behalf. What level of access32 integration, all applications running
33 do you want to grant?</p>33 on <strong tal:content="token/consumer/integrated_desktop_name">hostname</strong>
3434 will have read-write access to your Launchpad account,
35 <table>35 including to your private data.</p>
36 <tr tal:repeat="action view/visible_actions">36
37 <td style="text-align: right">37 <p>If
38 <tal:action replace="structure action/render" />38 <strong tal:content="token/consumer/integrated_desktop_name">hostname</strong>
39 </td>39 is not the computer you're using right now, or if you
40 <td>40 don't trust this computer, you should click "No, thanks,
41 <span class="lesser"41 I don't trust this computer", or close this window now.</p>
42 tal:content="action/permission/description" />42
43 </td>43 <p>Even if you decide to allow the integration, you can
44 </tr>44 change your mind later.</p>
45 </table>45 </tal:desktop-integration-token>
46
47 <tal:web-integration-token condition="not:token/consumer/is_integrated_desktop">
48 <p>The application identified as
49 <strong tal:content="token/consumer/key">consumer</strong>
50 wants to access
51 <tal:has-context condition="view/token_context">
52 things related to
53 <strong tal:content="view/token_context/title">Context</strong>
54 in
55 </tal:has-context>
56 Launchpad on your behalf. What level of access
57 do you want to grant?</p>
58 </tal:web-integration-token>
59
60 <table>
61 <tr tal:repeat="action view/visible_actions">
62 <td style="text-align: right">
63 <tal:action replace="structure action/render" />
64 </td>
65
66 <tal:web-integration-token
67 condition="not:token/consumer/is_integrated_desktop">
68 <td>
69 <span class="lesser"
70 tal:content="action/permission/description" />
71 </td>
72 </tal:web-integration-token>
73 </tr>
74 </table>
46 </div>75 </div>
4776
48 <div metal:fill-slot="extra_bottom">77 <div metal:fill-slot="extra_bottom">
4978
=== modified file 'lib/canonical/launchpad/webapp/authentication.py'
--- lib/canonical/launchpad/webapp/authentication.py 2010-09-16 15:40:56 +0000
+++ lib/canonical/launchpad/webapp/authentication.py 2010-09-22 19:29:46 +0000
@@ -5,21 +5,16 @@
55
6__all__ = [6__all__ = [
7 'check_oauth_signature',7 'check_oauth_signature',
8 'extract_oauth_access_token',
9 'get_oauth_principal',
10 'get_oauth_authorization',8 'get_oauth_authorization',
11 'LaunchpadLoginSource',9 'LaunchpadLoginSource',
12 'LaunchpadPrincipal',10 'LaunchpadPrincipal',
13 'OAuthSignedRequest',
14 'PlacelessAuthUtility',11 'PlacelessAuthUtility',
15 'SSHADigestEncryptor',12 'SSHADigestEncryptor',
16 ]13 ]
1714
1815
19import binascii16import binascii
20from datetime import datetime
21import hashlib17import hashlib
22import pytz
23import random18import random
24from UserDict import UserDict19from UserDict import UserDict
2520
@@ -28,18 +23,13 @@
28from zope.app.security.interfaces import ILoginPassword23from zope.app.security.interfaces import ILoginPassword
29from zope.app.security.principalregistry import UnauthenticatedPrincipal24from zope.app.security.principalregistry import UnauthenticatedPrincipal
30from zope.authentication.interfaces import IUnauthenticatedPrincipal25from zope.authentication.interfaces import IUnauthenticatedPrincipal
31
32from zope.component import (26from zope.component import (
33 adapts,27 adapts,
34 getUtility,28 getUtility,
35 )29 )
36from zope.event import notify30from zope.event import notify
37from zope.interface import (31from zope.interface import implements
38 alsoProvides,
39 implements,
40 )
41from zope.preference.interfaces import IPreferenceGroup32from zope.preference.interfaces import IPreferenceGroup
42from zope.security.interfaces import Unauthorized
43from zope.security.proxy import removeSecurityProxy33from zope.security.proxy import removeSecurityProxy
44from zope.session.interfaces import ISession34from zope.session.interfaces import ISession
4535
@@ -54,14 +44,6 @@
54 ILaunchpadPrincipal,44 ILaunchpadPrincipal,
55 IPlacelessAuthUtility,45 IPlacelessAuthUtility,
56 IPlacelessLoginSource,46 IPlacelessLoginSource,
57 OAuthPermission,
58 )
59from canonical.launchpad.interfaces.oauth import (
60 ClockSkew,
61 IOAuthConsumerSet,
62 IOAuthSignedRequest,
63 NonceAlreadyUsed,
64 TimestampOrderingError,
65 )47 )
66from lp.registry.interfaces.person import (48from lp.registry.interfaces.person import (
67 IPerson,49 IPerson,
@@ -69,113 +51,6 @@
69 )51 )
7052
7153
72def extract_oauth_access_token(request):
73 """Find the OAuth access token that signed the given request.
74
75 :param request: An incoming request.
76
77 :return: an IOAuthAccessToken, or None if the request is not
78 signed at all.
79
80 :raise Unauthorized: If the token is invalid or the request is an
81 anonymously-signed request that doesn't meet our requirements.
82 """
83 # Fetch OAuth authorization information from the request.
84 form = get_oauth_authorization(request)
85
86 consumer_key = form.get('oauth_consumer_key')
87 consumers = getUtility(IOAuthConsumerSet)
88 consumer = consumers.getByKey(consumer_key)
89 token_key = form.get('oauth_token')
90 anonymous_request = (token_key == '')
91
92 if consumer_key is None:
93 # Either the client's OAuth implementation is broken, or
94 # the user is trying to make an unauthenticated request
95 # using wget or another OAuth-ignorant application.
96 # Try to retrieve a consumer based on the User-Agent
97 # header.
98 anonymous_request = True
99 consumer_key = request.getHeader('User-Agent', '')
100 if consumer_key == '':
101 raise Unauthorized(
102 'Anonymous requests must provide a User-Agent.')
103 consumer = consumers.getByKey(consumer_key)
104
105 if consumer is None:
106 if anonymous_request:
107 # This is the first time anyone has tried to make an
108 # anonymous request using this consumer name (or user
109 # agent). Dynamically create the consumer.
110 #
111 # In the normal website this wouldn't be possible
112 # because GET requests have their transactions rolled
113 # back. But webservice requests always have their
114 # transactions committed so that we can keep track of
115 # the OAuth nonces and prevent replay attacks.
116 if consumer_key == '' or consumer_key is None:
117 raise Unauthorized("No consumer key specified.")
118 consumer = consumers.new(consumer_key, '')
119 else:
120 # An unknown consumer can never make a non-anonymous
121 # request, because access tokens are registered with a
122 # specific, known consumer.
123 raise Unauthorized('Unknown consumer (%s).' % consumer_key)
124 if anonymous_request:
125 # Skip the OAuth verification step and let the user access the
126 # web service as an unauthenticated user.
127 #
128 # XXX leonardr 2009-12-15 bug=496964: Ideally we'd be
129 # auto-creating a token for the anonymous user the first
130 # time, passing it through the OAuth verification step,
131 # and using it on all subsequent anonymous requests.
132 return None
133
134 token = consumer.getAccessToken(token_key)
135 if token is None:
136 raise Unauthorized('Unknown access token (%s).' % token_key)
137 return token
138
139
140def get_oauth_principal(request):
141 """Find the principal to use for this OAuth-signed request.
142
143 :param request: An incoming request.
144 :return: An ILaunchpadPrincipal with the appropriate access level.
145 """
146 token = extract_oauth_access_token(request)
147
148 if token is None:
149 # The consumer is making an anonymous request. If there was a
150 # problem with the access token, extract_oauth_access_token
151 # would have raised Unauthorized.
152 alsoProvides(request, IOAuthSignedRequest)
153 auth_utility = getUtility(IPlacelessAuthUtility)
154 return auth_utility.unauthenticatedPrincipal()
155
156 form = get_oauth_authorization(request)
157 nonce = form.get('oauth_nonce')
158 timestamp = form.get('oauth_timestamp')
159 try:
160 token.checkNonceAndTimestamp(nonce, timestamp)
161 except (NonceAlreadyUsed, TimestampOrderingError, ClockSkew), e:
162 raise Unauthorized('Invalid nonce/timestamp: %s' % e)
163 now = datetime.now(pytz.timezone('UTC'))
164 if token.permission == OAuthPermission.UNAUTHORIZED:
165 raise Unauthorized('Unauthorized token (%s).' % token.key)
166 elif token.date_expires is not None and token.date_expires <= now:
167 raise Unauthorized('Expired token (%s).' % token.key)
168 elif not check_oauth_signature(request, token.consumer, token):
169 raise Unauthorized('Invalid signature.')
170 else:
171 # Everything is fine, let's return the principal.
172 pass
173 alsoProvides(request, IOAuthSignedRequest)
174 return getUtility(IPlacelessLoginSource).getPrincipal(
175 token.person.account.id, access_level=token.permission,
176 scope=token.context)
177
178
179class PlacelessAuthUtility:54class PlacelessAuthUtility:
180 """An authentication service which holds no state aside from its55 """An authentication service which holds no state aside from its
181 ZCML configuration, implemented as a utility.56 ZCML configuration, implemented as a utility.
@@ -200,8 +75,9 @@
200 # as the login form is never visited for BasicAuth.75 # as the login form is never visited for BasicAuth.
201 # This we treat each request as a separate76 # This we treat each request as a separate
202 # login/logout.77 # login/logout.
203 notify(78 notify(BasicAuthLoggedInEvent(
204 BasicAuthLoggedInEvent(request, login, principal))79 request, login, principal
80 ))
205 return principal81 return principal
20682
207 def _authenticateUsingCookieAuth(self, request):83 def _authenticateUsingCookieAuth(self, request):
@@ -314,8 +190,7 @@
314 plaintext = str(plaintext)190 plaintext = str(plaintext)
315 if salt is None:191 if salt is None:
316 salt = self.generate_salt()192 salt = self.generate_salt()
317 v = binascii.b2a_base64(193 v = binascii.b2a_base64(hashlib.sha1(plaintext + salt).digest() + salt)
318 hashlib.sha1(plaintext + salt).digest() + salt)
319 return v[:-1]194 return v[:-1]
320195
321 def validate(self, plaintext, encrypted):196 def validate(self, plaintext, encrypted):
@@ -459,7 +334,6 @@
459334
460# zope.app.apidoc expects our principals to be adaptable into IAnnotations, so335# zope.app.apidoc expects our principals to be adaptable into IAnnotations, so
461# we use these dummy adapters here just to make that code not OOPS.336# we use these dummy adapters here just to make that code not OOPS.
462
463class TemporaryPrincipalAnnotations(UserDict):337class TemporaryPrincipalAnnotations(UserDict):
464 implements(IAnnotations)338 implements(IAnnotations)
465 adapts(ILaunchpadPrincipal, IPreferenceGroup)339 adapts(ILaunchpadPrincipal, IPreferenceGroup)
466340
=== modified file 'lib/canonical/launchpad/webapp/authorization.py'
--- lib/canonical/launchpad/webapp/authorization.py 2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/webapp/authorization.py 2010-09-22 19:29:46 +0000
@@ -61,7 +61,8 @@
61 lp_permission = getUtility(ILaunchpadPermission, permission)61 lp_permission = getUtility(ILaunchpadPermission, permission)
62 if lp_permission.access_level == "write":62 if lp_permission.access_level == "write":
63 required_access_level = [63 required_access_level = [
64 AccessLevel.WRITE_PUBLIC, AccessLevel.WRITE_PRIVATE]64 AccessLevel.WRITE_PUBLIC, AccessLevel.WRITE_PRIVATE,
65 AccessLevel.DESKTOP_INTEGRATION]
65 if access_level not in required_access_level:66 if access_level not in required_access_level:
66 return False67 return False
67 elif lp_permission.access_level == "read":68 elif lp_permission.access_level == "read":
@@ -80,7 +81,8 @@
80 access to private objects, return False. Return True otherwise.81 access to private objects, return False. Return True otherwise.
81 """82 """
82 private_access_levels = [83 private_access_levels = [
83 AccessLevel.READ_PRIVATE, AccessLevel.WRITE_PRIVATE]84 AccessLevel.READ_PRIVATE, AccessLevel.WRITE_PRIVATE,
85 AccessLevel.DESKTOP_INTEGRATION]
84 if access_level in private_access_levels:86 if access_level in private_access_levels:
85 # The user has access to private objects. Return early,87 # The user has access to private objects. Return early,
86 # before checking whether the object is private, since88 # before checking whether the object is private, since
8789
=== modified file 'lib/canonical/launchpad/webapp/interfaces.py'
--- lib/canonical/launchpad/webapp/interfaces.py 2010-09-12 11:43:36 +0000
+++ lib/canonical/launchpad/webapp/interfaces.py 2010-09-22 19:29:46 +0000
@@ -527,14 +527,13 @@
527 for reading and changing anything, including private data.527 for reading and changing anything, including private data.
528 """)528 """)
529529
530 GRANT_PERMISSIONS = DBItem(60, """530 DESKTOP_INTEGRATION = DBItem(60, """
531 Grant Permissions531 Desktop Integration
532532
533 The application will be able to grant access to your Launchpad533 Every application running on your desktop will have read-write
534 account to any other application. This is a very powerful534 access to your Launchpad account, including to your private
535 level of access. You should not grant this level of access to535 data. You should not allow this unless you trust the computer
536 any application except the official Launchpad credential536 you're using right now.
537 manager.
538 """)537 """)
539538
540class AccessLevel(DBEnumeratedType):539class AccessLevel(DBEnumeratedType):
541540
=== modified file 'lib/canonical/launchpad/webapp/servers.py'
--- lib/canonical/launchpad/webapp/servers.py 2010-09-21 04:21:16 +0000
+++ lib/canonical/launchpad/webapp/servers.py 2010-09-22 19:29:46 +0000
@@ -8,6 +8,7 @@
8__metaclass__ = type8__metaclass__ = type
99
10import cgi10import cgi
11from datetime import datetime
11import threading12import threading
12import xmlrpclib13import xmlrpclib
1314
@@ -21,6 +22,7 @@
21 WebServiceRequestTraversal,22 WebServiceRequestTraversal,
22 )23 )
23from lazr.uri import URI24from lazr.uri import URI
25import pytz
24import transaction26import transaction
25from transaction.interfaces import ISynchronizer27from transaction.interfaces import ISynchronizer
26from zc.zservertracelog.tracelog import Server as ZServerTracelogServer28from zc.zservertracelog.tracelog import Server as ZServerTracelogServer
@@ -48,7 +50,10 @@
48 XMLRPCRequest,50 XMLRPCRequest,
49 XMLRPCResponse,51 XMLRPCResponse,
50 )52 )
51from zope.security.interfaces import IParticipation53from zope.security.interfaces import (
54 IParticipation,
55 Unauthorized,
56 )
52from zope.security.proxy import (57from zope.security.proxy import (
53 isinstance as zope_isinstance,58 isinstance as zope_isinstance,
54 removeSecurityProxy,59 removeSecurityProxy,
@@ -63,9 +68,17 @@
63 IPrivateApplication,68 IPrivateApplication,
64 IWebServiceApplication,69 IWebServiceApplication,
65 )70 )
71from canonical.launchpad.interfaces.oauth import (
72 ClockSkew,
73 IOAuthConsumerSet,
74 IOAuthSignedRequest,
75 NonceAlreadyUsed,
76 TimestampOrderingError,
77 )
66import canonical.launchpad.layers78import canonical.launchpad.layers
67from canonical.launchpad.webapp.authentication import (79from canonical.launchpad.webapp.authentication import (
68 get_oauth_principal,80 check_oauth_signature,
81 get_oauth_authorization,
69 )82 )
70from canonical.launchpad.webapp.authorization import (83from canonical.launchpad.webapp.authorization import (
71 LAUNCHPAD_SECURITY_POLICY_CACHE_KEY,84 LAUNCHPAD_SECURITY_POLICY_CACHE_KEY,
@@ -80,6 +93,8 @@
80 INotificationRequest,93 INotificationRequest,
81 INotificationResponse,94 INotificationResponse,
82 IPlacelessAuthUtility,95 IPlacelessAuthUtility,
96 IPlacelessLoginSource,
97 OAuthPermission,
83 )98 )
84from canonical.launchpad.webapp.notifications import (99from canonical.launchpad.webapp.notifications import (
85 NotificationList,100 NotificationList,
@@ -1197,7 +1212,83 @@
1197 if request_path.startswith("/%s" % web_service_config.path_override):1212 if request_path.startswith("/%s" % web_service_config.path_override):
1198 return super(WebServicePublication, self).getPrincipal(request)1213 return super(WebServicePublication, self).getPrincipal(request)
11991214
1200 return get_oauth_principal(request)1215 # Fetch OAuth authorization information from the request.
1216 form = get_oauth_authorization(request)
1217
1218 consumer_key = form.get('oauth_consumer_key')
1219 consumers = getUtility(IOAuthConsumerSet)
1220 consumer = consumers.getByKey(consumer_key)
1221 token_key = form.get('oauth_token')
1222 anonymous_request = (token_key == '')
1223
1224 if consumer_key is None:
1225 # Either the client's OAuth implementation is broken, or
1226 # the user is trying to make an unauthenticated request
1227 # using wget or another OAuth-ignorant application.
1228 # Try to retrieve a consumer based on the User-Agent
1229 # header.
1230 anonymous_request = True
1231 consumer_key = request.getHeader('User-Agent', '')
1232 if consumer_key == '':
1233 raise Unauthorized(
1234 'Anonymous requests must provide a User-Agent.')
1235 consumer = consumers.getByKey(consumer_key)
1236
1237 if consumer is None:
1238 if anonymous_request:
1239 # This is the first time anyone has tried to make an
1240 # anonymous request using this consumer name (or user
1241 # agent). Dynamically create the consumer.
1242 #
1243 # In the normal website this wouldn't be possible
1244 # because GET requests have their transactions rolled
1245 # back. But webservice requests always have their
1246 # transactions committed so that we can keep track of
1247 # the OAuth nonces and prevent replay attacks.
1248 if consumer_key == '' or consumer_key is None:
1249 raise Unauthorized("No consumer key specified.")
1250 consumer = consumers.new(consumer_key, '')
1251 else:
1252 # An unknown consumer can never make a non-anonymous
1253 # request, because access tokens are registered with a
1254 # specific, known consumer.
1255 raise Unauthorized('Unknown consumer (%s).' % consumer_key)
1256 if anonymous_request:
1257 # Skip the OAuth verification step and let the user access the
1258 # web service as an unauthenticated user.
1259 #
1260 # XXX leonardr 2009-12-15 bug=496964: Ideally we'd be
1261 # auto-creating a token for the anonymous user the first
1262 # time, passing it through the OAuth verification step,
1263 # and using it on all subsequent anonymous requests.
1264 alsoProvides(request, IOAuthSignedRequest)
1265 auth_utility = getUtility(IPlacelessAuthUtility)
1266 return auth_utility.unauthenticatedPrincipal()
1267 token = consumer.getAccessToken(token_key)
1268 if token is None:
1269 raise Unauthorized('Unknown access token (%s).' % token_key)
1270 nonce = form.get('oauth_nonce')
1271 timestamp = form.get('oauth_timestamp')
1272 try:
1273 token.checkNonceAndTimestamp(nonce, timestamp)
1274 except (NonceAlreadyUsed, TimestampOrderingError, ClockSkew), e:
1275 raise Unauthorized('Invalid nonce/timestamp: %s' % e)
1276 now = datetime.now(pytz.timezone('UTC'))
1277 if token.permission == OAuthPermission.UNAUTHORIZED:
1278 raise Unauthorized('Unauthorized token (%s).' % token.key)
1279 elif token.date_expires is not None and token.date_expires <= now:
1280 raise Unauthorized('Expired token (%s).' % token.key)
1281 elif not check_oauth_signature(request, consumer, token):
1282 raise Unauthorized('Invalid signature.')
1283 else:
1284 # Everything is fine, let's return the principal.
1285 pass
1286 alsoProvides(request, IOAuthSignedRequest)
1287 principal = getUtility(IPlacelessLoginSource).getPrincipal(
1288 token.person.account.id, access_level=token.permission,
1289 scope=token.context)
1290
1291 return principal
12011292
12021293
1203class LaunchpadWebServiceRequestTraversal(WebServiceRequestTraversal):1294class LaunchpadWebServiceRequestTraversal(WebServiceRequestTraversal):
12041295
=== modified file 'lib/canonical/launchpad/zcml/launchpad.zcml'
--- lib/canonical/launchpad/zcml/launchpad.zcml 2010-09-09 21:09:00 +0000
+++ lib/canonical/launchpad/zcml/launchpad.zcml 2010-09-22 19:29:46 +0000
@@ -266,14 +266,14 @@
266 name="+authorize-token"266 name="+authorize-token"
267 class="canonical.launchpad.browser.OAuthAuthorizeTokenView"267 class="canonical.launchpad.browser.OAuthAuthorizeTokenView"
268 template="../templates/oauth-authorize.pt"268 template="../templates/oauth-authorize.pt"
269 permission="zope.Public" />269 permission="launchpad.AnyPerson" />
270270
271 <browser:page271 <browser:page
272 for="canonical.launchpad.webapp.interfaces.ILaunchpadApplication"272 for="canonical.launchpad.webapp.interfaces.ILaunchpadApplication"
273 name="+token-authorized"273 name="+token-authorized"
274 class="canonical.launchpad.browser.OAuthTokenAuthorizedView"274 class="canonical.launchpad.browser.OAuthTokenAuthorizedView"
275 template="../templates/token-authorized.pt"275 template="../templates/token-authorized.pt"
276 permission="zope.Public" />276 permission="launchpad.AnyPerson" />
277277
278 <browser:page278 <browser:page
279 for="canonical.launchpad.webapp.interfaces.ILaunchpadApplication"279 for="canonical.launchpad.webapp.interfaces.ILaunchpadApplication"
280280
=== modified file 'lib/lp/testing/__init__.py'
--- lib/lp/testing/__init__.py 2010-09-20 12:56:53 +0000
+++ lib/lp/testing/__init__.py 2010-09-22 19:29:46 +0000
@@ -28,7 +28,6 @@
28 'map_branch_contents',28 'map_branch_contents',
29 'normalize_whitespace',29 'normalize_whitespace',
30 'oauth_access_token_for',30 'oauth_access_token_for',
31 'OAuthSigningBrowser',
32 'person_logged_in',31 'person_logged_in',
33 'record_statements',32 'record_statements',
34 'run_with_login',33 'run_with_login',
@@ -146,7 +145,6 @@
146 launchpadlib_credentials_for,145 launchpadlib_credentials_for,
147 launchpadlib_for,146 launchpadlib_for,
148 oauth_access_token_for,147 oauth_access_token_for,
149 OAuthSigningBrowser,
150 )148 )
151from lp.testing.fixture import ZopeEventHandlerFixture149from lp.testing.fixture import ZopeEventHandlerFixture
152from lp.testing.matchers import Provides150from lp.testing.matchers import Provides
@@ -224,7 +222,7 @@
224222
225class StormStatementRecorder:223class StormStatementRecorder:
226 """A storm tracer to count queries.224 """A storm tracer to count queries.
227225
228 This exposes the count and queries as lp.testing._webservice.QueryCollector226 This exposes the count and queries as lp.testing._webservice.QueryCollector
229 does permitting its use with the HasQueryCount matcher.227 does permitting its use with the HasQueryCount matcher.
230228
@@ -683,7 +681,6 @@
683 def assertTextMatchesExpressionIgnoreWhitespace(self,681 def assertTextMatchesExpressionIgnoreWhitespace(self,
684 regular_expression_txt,682 regular_expression_txt,
685 text):683 text):
686
687 def normalise_whitespace(text):684 def normalise_whitespace(text):
688 return ' '.join(text.split())685 return ' '.join(text.split())
689 pattern = re.compile(686 pattern = re.compile(
@@ -860,7 +857,6 @@
860 callable, and events are the events emitted by the callable.857 callable, and events are the events emitted by the callable.
861 """858 """
862 events = []859 events = []
863
864 def on_notify(event):860 def on_notify(event):
865 events.append(event)861 events.append(event)
866 old_subscribers = zope.event.subscribers[:]862 old_subscribers = zope.event.subscribers[:]
867863
=== modified file 'lib/lp/testing/_webservice.py'
--- lib/lp/testing/_webservice.py 2010-09-16 15:40:56 +0000
+++ lib/lp/testing/_webservice.py 2010-09-22 19:29:46 +0000
@@ -9,104 +9,34 @@
9 'launchpadlib_credentials_for',9 'launchpadlib_credentials_for',
10 'launchpadlib_for',10 'launchpadlib_for',
11 'oauth_access_token_for',11 'oauth_access_token_for',
12 'OAuthSigningBrowser',
13 ]12 ]
1413
1514
16import shutil15import shutil
17import tempfile16import tempfile
17
18from launchpadlib.credentials import (
19 AccessToken,
20 Credentials,
21 )
22from launchpadlib.launchpad import Launchpad
18import transaction23import transaction
19from urllib2 import BaseHandler
20
21from oauth.oauth import OAuthRequest, OAuthSignatureMethod_PLAINTEXT
22
23from zope.app.publication.interfaces import IEndRequestEvent24from zope.app.publication.interfaces import IEndRequestEvent
24from zope.app.testing import ztapi25from zope.app.testing import ztapi
25from zope.testbrowser.testing import Browser
26from zope.component import getUtility26from zope.component import getUtility
27import zope.testing.cleanup27import zope.testing.cleanup
2828
29from launchpadlib.credentials import (
30 AccessToken,
31 Credentials,
32 )
33from launchpadlib.launchpad import Launchpad
34
35from lp.testing._login import (
36 login,
37 logout,
38 )
39
40from canonical.launchpad.interfaces import (29from canonical.launchpad.interfaces import (
41 IOAuthConsumerSet,30 IOAuthConsumerSet,
42 IPersonSet,31 IPersonSet,
43 OAUTH_REALM,
44 )32 )
45from canonical.launchpad.webapp.adapter import get_request_statements33from canonical.launchpad.webapp.adapter import get_request_statements
46from canonical.launchpad.webapp.interaction import ANONYMOUS34from canonical.launchpad.webapp.interaction import ANONYMOUS
47from canonical.launchpad.webapp.interfaces import OAuthPermission35from canonical.launchpad.webapp.interfaces import OAuthPermission
4836from lp.testing._login import (
4937 login,
50class OAuthSigningHandler(BaseHandler):38 logout,
51 """A urllib2 handler that signs requests with an OAuth token."""39 )
52
53 def __init__(self, consumer, token):
54 """Constructor
55
56 :param consumer: An OAuth consumer.
57 :param token: An OAuth token.
58 """
59 self.consumer = consumer
60 self.token = token
61
62 def default_open(self, req):
63 """Set the Authorization header for the outgoing request."""
64 signer = OAuthRequest.from_consumer_and_token(
65 self.consumer, self.token)
66 signer.sign_request(
67 OAuthSignatureMethod_PLAINTEXT(), self.consumer, self.token)
68 auth_header = signer.to_header(OAUTH_REALM)['Authorization']
69 req.headers['Authorization'] = auth_header
70
71
72class UserAgentFilteringHandler(BaseHandler):
73 """A urllib2 handler that replaces the User-Agent header.
74
75 [XXX bug=638058] This is a hack to work around a bug in
76 zope.testbrowser.
77 """
78 def __init__(self, user_agent):
79 """Constructor."""
80 self.user_agent = user_agent
81
82 def default_open(self, req):
83 """Set the User-Agent header for the outgoing request."""
84 req.headers['User-Agent'] = self.user_agent
85
86
87class OAuthSigningBrowser(Browser):
88 """A browser that signs each outgoing request with an OAuth token.
89
90 This lets us simulate the behavior of the Launchpad Credentials
91 Manager.
92 """
93 def __init__(self, consumer, token, user_agent=None):
94 """Constructor.
95
96 :param consumer: An OAuth consumer.
97 :param token: An OAuth token.
98 :param user_agent: The User-Agent string to send.
99 """
100 super(OAuthSigningBrowser, self).__init__()
101 self.mech_browser.add_handler(
102 OAuthSigningHandler(consumer, token))
103 if user_agent is not None:
104 self.mech_browser.add_handler(
105 UserAgentFilteringHandler(user_agent))
106
107 # This will give us tracebacks instead of unhelpful error
108 # messages.
109 self.handleErrors = False
11040
11141
112def oauth_access_token_for(consumer_name, person, permission, context=None):42def oauth_access_token_for(consumer_name, person, permission, context=None):

Subscribers

People subscribed via source and target branches

to status/vote changes: