Merge lp:~leonardr/launchpad/automatically-calculate-request-token-expire-time into lp:launchpad/db-devel
- automatically-calculate-request-token-expire-time
- Merge into db-devel
Status: | Merged |
---|---|
Approved by: | Leonard Richardson |
Approved revision: | no longer in the source branch. |
Merge reported by: | Leonard Richardson |
Merged at revision: | not available |
Proposed branch: | lp:~leonardr/launchpad/automatically-calculate-request-token-expire-time |
Merge into: | lp:launchpad/db-devel |
Diff against target: |
1405 lines (+672/-275) 17 files modified
lib/canonical/launchpad/browser/oauth.py (+106/-27) lib/canonical/launchpad/database/oauth.py (+79/-10) lib/canonical/launchpad/doc/oauth.txt (+39/-0) lib/canonical/launchpad/doc/webapp-authorization.txt (+5/-13) lib/canonical/launchpad/interfaces/oauth.py (+44/-9) lib/canonical/launchpad/pagetests/oauth/authorize-token.txt (+146/-60) lib/canonical/launchpad/templates/oauth-authorize.pt (+63/-22) lib/canonical/launchpad/tests/test_oauth_tokens.py (+47/-0) lib/canonical/launchpad/webapp/authorization.py (+4/-2) lib/canonical/launchpad/webapp/interfaces.py (+6/-7) lib/canonical/launchpad/webapp/servers.py (+1/-3) lib/lp/archiveuploader/nascentuploadfile.py (+7/-33) lib/lp/archiveuploader/tests/nascentupload-epoch-handling.txt (+8/-3) lib/lp/archiveuploader/tests/nascentupload.txt (+11/-85) lib/lp/archiveuploader/tests/test_buildduploads.py (+8/-0) lib/lp/archiveuploader/tests/test_nascentuploadfile.py (+69/-1) lib/lp/testing/factory.py (+29/-0) |
To merge this branch: | bzr merge lp:~leonardr/launchpad/automatically-calculate-request-token-expire-time |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Paul Hummer (community) | Approve | ||
Review via email: mp+38423@code.launchpad.net |
Commit message
Description of the change
Our use of the 'date_expires' field in IOAuthToken is a mess, but it didn't matter much up to this point, because we never really used it. I'm about to start using it, so this branch is cleanup to make it something I can use.
I plan to move the doctests in lib/canonical/
The design behind this branch is salgado-approved.
Here are the problems with our current use of date_expires:
1. Request tokens always expire after REQUEST_
2. Request tokens may pass their expiration date, but we never do anything to expired tokens, or check whether they have expired. So, effectively, request tokens never expire. A request token can be three years old and you'll still be able to review it and exchange it for an access token. This opens up our users to attacks when an intermediary acquires the key to a reviewed and forgotten request token.
3. Before a request token is exchanged for an access token, there is no way to distinguish between the date on which a request token expires, and the date on which the end-user wants the not-yet-created access token to expire. This isn't a problem now because the end-user is never given the choice of creating an access token that will expire. But I'm about to give them that choice.
I also noticed a smaller, less serious problem:
4. REQUEST_
My solution to problem #4 is to reduce REQUEST_
First, I am no longer storing the expiration date of a request token in date_expires. I am calculating it from date_created and REQUEST_
This is the code tested by my unit tests, and this means that request tokens will in fact become unusable after REQUEST_
Second, I am taking date_expires out of IOAuthToken, and introducing it separately into the subclasses IOAuthRequestToken and IOAuthAccessToken. IOAuthRequestTo
This is by direct analogy to IOAuthRequestTo
I also split out IOAuthToken.
Preview Diff
1 | === modified file 'lib/canonical/launchpad/browser/oauth.py' |
2 | --- lib/canonical/launchpad/browser/oauth.py 2010-09-20 16:45:03 +0000 |
3 | +++ lib/canonical/launchpad/browser/oauth.py 2010-10-19 14:17:46 +0000 |
4 | @@ -16,6 +16,7 @@ |
5 | Action, |
6 | Actions, |
7 | ) |
8 | +from zope.security.interfaces import Unauthorized |
9 | |
10 | from canonical.launchpad.interfaces.oauth import ( |
11 | IOAuthConsumerSet, |
12 | @@ -88,11 +89,13 @@ |
13 | |
14 | token = consumer.newRequestToken() |
15 | if self.request.headers.get('Accept') == HTTPResource.JSON_TYPE: |
16 | - # Don't show the client the GRANT_PERMISSIONS access |
17 | + # Don't show the client the DESKTOP_INTEGRATION access |
18 | # level. If they have a legitimate need to use it, they'll |
19 | # already know about it. |
20 | - permissions = [permission for permission in OAuthPermission.items |
21 | - if permission != OAuthPermission.GRANT_PERMISSIONS] |
22 | + permissions = [ |
23 | + permission for permission in OAuthPermission.items |
24 | + if (permission != OAuthPermission.DESKTOP_INTEGRATION) |
25 | + ] |
26 | return self.getJSONRepresentation( |
27 | permissions, token, include_secret=True) |
28 | return u'oauth_token=%s&oauth_token_secret=%s' % ( |
29 | @@ -102,26 +105,36 @@ |
30 | return form.token is not None and not form.token.is_reviewed |
31 | |
32 | |
33 | +def token_review_success(form, action, data): |
34 | + """The success callback for a button to approve a token.""" |
35 | + form.reviewToken(action.permission) |
36 | + |
37 | + |
38 | def create_oauth_permission_actions(): |
39 | - """Return a list of `Action`s for each possible `OAuthPermission`.""" |
40 | - actions = Actions() |
41 | - actions_excluding_grant_permissions = Actions() |
42 | - def success(form, action, data): |
43 | - form.reviewToken(action.permission) |
44 | + """Return two `Actions` objects containing each possible `OAuthPermission`. |
45 | + |
46 | + The first `Actions` object contains every action supported by the |
47 | + OAuthAuthorizeTokenView. The second list contains a good default |
48 | + set of actions, omitting special permissions like DESKTOP_INTEGRATION. |
49 | + """ |
50 | + all_actions = Actions() |
51 | + ordinary_actions = Actions() |
52 | for permission in OAuthPermission.items: |
53 | action = Action( |
54 | - permission.title, name=permission.name, success=success, |
55 | + permission.title, name=permission.name, |
56 | + success=token_review_success, |
57 | condition=token_exists_and_is_not_reviewed) |
58 | action.permission = permission |
59 | - actions.append(action) |
60 | - if permission != OAuthPermission.GRANT_PERMISSIONS: |
61 | - actions_excluding_grant_permissions.append(action) |
62 | - return actions, actions_excluding_grant_permissions |
63 | + all_actions.append(action) |
64 | + if permission != OAuthPermission.DESKTOP_INTEGRATION: |
65 | + ordinary_actions.append(action) |
66 | + return all_actions, ordinary_actions |
67 | + |
68 | |
69 | class OAuthAuthorizeTokenView(LaunchpadFormView, JSONTokenMixin): |
70 | """Where users authorize consumers to access Launchpad on their behalf.""" |
71 | |
72 | - actions, actions_excluding_grant_permissions = ( |
73 | + actions, actions_excluding_special_permissions = ( |
74 | create_oauth_permission_actions()) |
75 | label = "Authorize application to access Launchpad on your behalf" |
76 | schema = IOAuthRequestToken |
77 | @@ -130,7 +143,7 @@ |
78 | |
79 | @property |
80 | def visible_actions(self): |
81 | - """Restrict the actions to the subset the client can make use of. |
82 | + """Restrict the actions to a subset to be presented to the client. |
83 | |
84 | Not all client programs can function with all levels of |
85 | access. For instance, a client that needs to modify the |
86 | @@ -150,7 +163,7 @@ |
87 | |
88 | allowed_permissions = self.request.form_ng.getAll('allow_permission') |
89 | if len(allowed_permissions) == 0: |
90 | - return self.actions_excluding_grant_permissions |
91 | + return self.actions_excluding_special_permissions |
92 | actions = Actions() |
93 | |
94 | # UNAUTHORIZED is always one of the options. If the client |
95 | @@ -159,18 +172,59 @@ |
96 | if OAuthPermission.UNAUTHORIZED.name in allowed_permissions: |
97 | allowed_permissions.remove(OAuthPermission.UNAUTHORIZED.name) |
98 | |
99 | - # GRANT_PERMISSIONS cannot be requested as one of several |
100 | + # DESKTOP_INTEGRATION cannot be requested as one of several |
101 | # options--it must be the only option (other than |
102 | - # UNAUTHORIZED). If GRANT_PERMISSIONS is one of several |
103 | + # UNAUTHORIZED). If DESKTOP_INTEGRATION is one of several |
104 | # options, remove it from the list. |
105 | - if (OAuthPermission.GRANT_PERMISSIONS.name in allowed_permissions |
106 | + desktop_permission = OAuthPermission.DESKTOP_INTEGRATION |
107 | + if (desktop_permission.name in allowed_permissions |
108 | and len(allowed_permissions) > 1): |
109 | - allowed_permissions.remove(OAuthPermission.GRANT_PERMISSIONS.name) |
110 | - |
111 | - for action in self.actions: |
112 | - if (action.permission.name in allowed_permissions |
113 | - or action.permission is OAuthPermission.UNAUTHORIZED): |
114 | - actions.append(action) |
115 | + allowed_permissions.remove(desktop_permission.name) |
116 | + |
117 | + if desktop_permission.name in allowed_permissions: |
118 | + if not self.token.consumer.is_integrated_desktop: |
119 | + # Consumers may only ask for desktop integration if |
120 | + # they give a desktop type (eg. "Ubuntu") and a |
121 | + # user-recognizable desktop name (eg. the hostname). |
122 | + raise Unauthorized( |
123 | + ('Consumer "%s" asked for desktop integration, ' |
124 | + "but didn't say what kind of desktop it is, or name " |
125 | + "the computer being integrated." |
126 | + % self.token.consumer.key)) |
127 | + |
128 | + # We're going for desktop integration. The only two |
129 | + # possibilities are "allow" and "deny". We'll customize |
130 | + # the "allow" message using the hostname provided by the |
131 | + # desktop. |
132 | + # |
133 | + # Since self.actions is a descriptor that returns copies |
134 | + # of Action objects, we can modify the actions we get |
135 | + # in-place without ruining the Action objects for everyone |
136 | + # else. |
137 | + desktop_name = self.token.consumer.integrated_desktop_name |
138 | + label = ( |
139 | + 'Give all programs running on "%s" access ' |
140 | + 'to my Launchpad account.') |
141 | + allow_action = [ |
142 | + action for action in self.actions |
143 | + if action.name == desktop_permission.name][0] |
144 | + allow_action.label = label % desktop_name |
145 | + actions.append(allow_action) |
146 | + |
147 | + # We'll customize the "deny" message as well. |
148 | + label = "No, thanks, I don't trust "%s"." |
149 | + deny_action = [ |
150 | + action for action in self.actions |
151 | + if action.name == OAuthPermission.UNAUTHORIZED.name][0] |
152 | + deny_action.label = label % desktop_name |
153 | + actions.append(deny_action) |
154 | + |
155 | + else: |
156 | + # We're going for web-based integration. |
157 | + for action in self.actions_excluding_special_permissions: |
158 | + if (action.permission.name in allowed_permissions |
159 | + or action.permission is OAuthPermission.UNAUTHORIZED): |
160 | + actions.append(action) |
161 | |
162 | if len(list(actions)) == 1: |
163 | # The only visible action is UNAUTHORIZED. That means the |
164 | @@ -179,8 +233,8 @@ |
165 | # UNAUTHORIZED). Rather than present the end-user with an |
166 | # impossible situation where their only option is to deny |
167 | # access, we'll present the full range of actions (except |
168 | - # for GRANT_PERMISSIONS). |
169 | - return self.actions_excluding_grant_permissions |
170 | + # for special permissions like DESKTOP_INTEGRATION). |
171 | + return self.actions_excluding_special_permissions |
172 | return actions |
173 | |
174 | def initialize(self): |
175 | @@ -189,6 +243,31 @@ |
176 | key = form.get('oauth_token') |
177 | if key: |
178 | self.token = getUtility(IOAuthRequestTokenSet).getByKey(key) |
179 | + |
180 | + |
181 | + callback = self.request.form.get('oauth_callback') |
182 | + if (self.token is not None |
183 | + and self.token.consumer.is_integrated_desktop): |
184 | + # Nip problems in the bud by appling special rules about |
185 | + # what desktop integrations are allowed to do. |
186 | + if callback is not None: |
187 | + # A desktop integration is not allowed to specify a callback. |
188 | + raise Unauthorized( |
189 | + "A desktop integration may not specify an " |
190 | + "OAuth callback URL.") |
191 | + # A desktop integration token can only have one of two |
192 | + # permission levels: "Desktop Integration" and |
193 | + # "Unauthorized". It shouldn't even be able to ask for any |
194 | + # other level. |
195 | + for action in self.visible_actions: |
196 | + if action.permission not in ( |
197 | + OAuthPermission.DESKTOP_INTEGRATION, |
198 | + OAuthPermission.UNAUTHORIZED): |
199 | + raise Unauthorized( |
200 | + ("Desktop integration token requested a permission " |
201 | + '("%s") not supported for desktop-wide use.') |
202 | + % action.label) |
203 | + |
204 | super(OAuthAuthorizeTokenView, self).initialize() |
205 | |
206 | def render(self): |
207 | |
208 | === modified file 'lib/canonical/launchpad/database/oauth.py' |
209 | --- lib/canonical/launchpad/database/oauth.py 2010-10-03 15:30:06 +0000 |
210 | +++ lib/canonical/launchpad/database/oauth.py 2010-10-19 14:17:46 +0000 |
211 | @@ -15,6 +15,7 @@ |
212 | timedelta, |
213 | ) |
214 | |
215 | +import re |
216 | import pytz |
217 | from sqlobject import ( |
218 | BoolCol, |
219 | @@ -59,7 +60,7 @@ |
220 | from lp.registry.interfaces.projectgroup import IProjectGroup |
221 | |
222 | # How many hours should a request token be valid for? |
223 | -REQUEST_TOKEN_VALIDITY = 12 |
224 | +REQUEST_TOKEN_VALIDITY = 2 |
225 | # The OAuth Core 1.0 spec (http://oauth.net/core/1.0/#nonce) says that a |
226 | # timestamp "MUST be equal or greater than the timestamp used in previous |
227 | # requests," but this is likely to cause problems if the client does request |
228 | @@ -93,6 +94,7 @@ |
229 | |
230 | getStore = _get_store |
231 | |
232 | + |
233 | class OAuthConsumer(OAuthBase): |
234 | """See `IOAuthConsumer`.""" |
235 | implements(IOAuthConsumer) |
236 | @@ -102,13 +104,56 @@ |
237 | key = StringCol(notNull=True) |
238 | secret = StringCol(notNull=False, default='') |
239 | |
240 | + # This regular expression singles out a consumer key that |
241 | + # represents any and all apps running on a specific computer |
242 | + # (usually a desktop). For instance: |
243 | + # |
244 | + # System-wide: Ubuntu desktop (hostname1) |
245 | + # - An Ubuntu desktop called "hostname1" |
246 | + # System-wide: Windows desktop (Computer Name) |
247 | + # - A Windows desktop called "Computer Name" |
248 | + # System-wide: Mac OS desktop (hostname2) |
249 | + # - A Macintosh desktop called "hostname2" |
250 | + # System-wide Android phone (Bob's Phone) |
251 | + # - An Android phone called "Bob's Phone" |
252 | + integrated_desktop_re = re.compile("^System-wide: (.*) \(([^)]*)\)$") |
253 | + |
254 | + def _integrated_desktop_match_group(self, position): |
255 | + """Return information about a desktop integration token. |
256 | + |
257 | + A convenience method that runs the desktop integration regular |
258 | + expression against the consumer key. |
259 | + |
260 | + :param position: The match group to return if the regular |
261 | + expression matches. |
262 | + |
263 | + :return: The value of one of the match groups, or None. |
264 | + """ |
265 | + match = self.integrated_desktop_re.match(self.key) |
266 | + if match is None: |
267 | + return None |
268 | + return match.groups()[position] |
269 | + |
270 | + @property |
271 | + def is_integrated_desktop(self): |
272 | + """See `IOAuthConsumer`.""" |
273 | + return self.integrated_desktop_re.match(self.key) is not None |
274 | + |
275 | + @property |
276 | + def integrated_desktop_type(self): |
277 | + """See `IOAuthConsumer`.""" |
278 | + return self._integrated_desktop_match_group(0) |
279 | + |
280 | + @property |
281 | + def integrated_desktop_name(self): |
282 | + """See `IOAuthConsumer`.""" |
283 | + return self._integrated_desktop_match_group(1) |
284 | + |
285 | def newRequestToken(self): |
286 | """See `IOAuthConsumer`.""" |
287 | key, secret = create_token_key_and_secret(table=OAuthRequestToken) |
288 | - date_expires = (datetime.now(pytz.timezone('UTC')) |
289 | - + timedelta(hours=REQUEST_TOKEN_VALIDITY)) |
290 | return OAuthRequestToken( |
291 | - consumer=self, key=key, secret=secret, date_expires=date_expires) |
292 | + consumer=self, key=key, secret=secret) |
293 | |
294 | def getAccessToken(self, key): |
295 | """See `IOAuthConsumer`.""" |
296 | @@ -177,6 +222,11 @@ |
297 | else: |
298 | return None |
299 | |
300 | + @property |
301 | + def is_expired(self): |
302 | + now = datetime.now(pytz.timezone('UTC')) |
303 | + return self.date_expires is not None and self.date_expires <= now |
304 | + |
305 | def checkNonceAndTimestamp(self, nonce, timestamp): |
306 | """See `IOAuthAccessToken`.""" |
307 | timestamp = float(timestamp) |
308 | @@ -254,10 +304,21 @@ |
309 | else: |
310 | return None |
311 | |
312 | + @property |
313 | + def is_expired(self): |
314 | + now = datetime.now(pytz.timezone('UTC')) |
315 | + expires = self.date_created + timedelta(hours=REQUEST_TOKEN_VALIDITY) |
316 | + return expires <= now |
317 | + |
318 | def review(self, user, permission, context=None): |
319 | """See `IOAuthRequestToken`.""" |
320 | - assert not self.is_reviewed, ( |
321 | - "Request tokens can be reviewed only once.") |
322 | + if self.is_reviewed: |
323 | + raise AssertionError( |
324 | + "Request tokens can be reviewed only once.") |
325 | + if self.is_expired: |
326 | + raise AssertionError( |
327 | + 'This request token has expired and can no longer be ' |
328 | + 'reviewed.') |
329 | self.date_reviewed = datetime.now(pytz.timezone('UTC')) |
330 | self.person = user |
331 | self.permission = permission |
332 | @@ -275,10 +336,18 @@ |
333 | |
334 | def createAccessToken(self): |
335 | """See `IOAuthRequestToken`.""" |
336 | - assert self.is_reviewed, ( |
337 | - 'Cannot create an access token from an unreviewed request token.') |
338 | - assert self.permission != OAuthPermission.UNAUTHORIZED, ( |
339 | - 'The user did not grant access to this consumer.') |
340 | + if not self.is_reviewed: |
341 | + raise AssertionError( |
342 | + 'Cannot create an access token from an unreviewed request ' |
343 | + 'token.') |
344 | + if self.permission == OAuthPermission.UNAUTHORIZED: |
345 | + raise AssertionError( |
346 | + 'The user did not grant access to this consumer.') |
347 | + if self.is_expired: |
348 | + raise AssertionError( |
349 | + 'This request token has expired and can no longer be ' |
350 | + 'exchanged for an access token.') |
351 | + |
352 | key, secret = create_token_key_and_secret(table=OAuthAccessToken) |
353 | access_level = AccessLevel.items[self.permission.name] |
354 | access_token = OAuthAccessToken( |
355 | |
356 | === modified file 'lib/canonical/launchpad/doc/oauth.txt' |
357 | --- lib/canonical/launchpad/doc/oauth.txt 2010-10-09 16:36:22 +0000 |
358 | +++ lib/canonical/launchpad/doc/oauth.txt 2010-10-19 14:17:46 +0000 |
359 | @@ -38,6 +38,45 @@ |
360 | ... |
361 | AssertionError: ... |
362 | |
363 | +Desktop consumers |
364 | +================= |
365 | + |
366 | +In a web context, each application is represented by a unique consumer |
367 | +key. But a typical user sitting at a typical desktop (or other |
368 | +personal computer), using multiple desktop applications that integrate |
369 | +with Launchpad, is represented by a single consumer key. The user's |
370 | +session as a whole is a single "consumer", and the consumer key is |
371 | +expected to contain structured information: the type of system |
372 | +(usually the operating system plus the word "desktop") and a string |
373 | +that the end-user would recognize as identifying their computer. |
374 | + |
375 | + >>> desktop_key = consumer_set.new( |
376 | + ... "System-wide: Ubuntu desktop (hostname)") |
377 | + >>> desktop_key.is_integrated_desktop |
378 | + True |
379 | + >>> print desktop_key.integrated_desktop_type |
380 | + Ubuntu desktop |
381 | + >>> print desktop_key.integrated_desktop_name |
382 | + hostname |
383 | + |
384 | + >>> desktop_key = consumer_set.new( |
385 | + ... "System-wide: Android phone (My Phone)") |
386 | + >>> desktop_key.is_integrated_desktop |
387 | + True |
388 | + >>> print desktop_key.integrated_desktop_type |
389 | + Android phone |
390 | + >>> print desktop_key.integrated_desktop_name |
391 | + My Phone |
392 | + |
393 | +A normal OAuth consumer does not have this information. |
394 | + |
395 | + >>> ordinary_key = consumer_set.new("Not a desktop at all.") |
396 | + >>> ordinary_key.is_integrated_desktop |
397 | + False |
398 | + >>> print ordinary_key.integrated_desktop_type |
399 | + None |
400 | + >>> print ordinary_key.integrated_desktop_name |
401 | + None |
402 | |
403 | Request tokens |
404 | ============== |
405 | |
406 | === modified file 'lib/canonical/launchpad/doc/webapp-authorization.txt' |
407 | --- lib/canonical/launchpad/doc/webapp-authorization.txt 2010-10-09 16:36:22 +0000 |
408 | +++ lib/canonical/launchpad/doc/webapp-authorization.txt 2010-10-19 14:17:46 +0000 |
409 | @@ -79,24 +79,16 @@ |
410 | >>> check_permission('launchpad.View', bug_1) |
411 | False |
412 | |
413 | -Now consider a principal authorized to create OAuth tokens. Whenever |
414 | -it's not creating OAuth tokens, it has a level of permission |
415 | -equivalent to READ_PUBLIC. |
416 | +A token used for desktop integration has a level of permission |
417 | +equivalent to WRITE_PUBLIC. |
418 | |
419 | - >>> principal.access_level = AccessLevel.GRANT_PERMISSIONS |
420 | + >>> principal.access_level = AccessLevel.DESKTOP_INTEGRATION |
421 | >>> setupInteraction(principal) |
422 | >>> check_permission('launchpad.View', bug_1) |
423 | - False |
424 | + True |
425 | |
426 | >>> check_permission('launchpad.Edit', sample_person) |
427 | - False |
428 | - |
429 | -This may seem useless from a security standpoint, since once a |
430 | -malicious client is authorized to create OAuth tokens, it can escalate |
431 | -its privileges at any time by creating a new token for itself. The |
432 | -security benefit is more subtle: by discouraging feature creep in |
433 | -clients that have this super-access level, we reduce the risk that a |
434 | -bug in a _trusted_ client will enable privilege escalation attacks. |
435 | + True |
436 | |
437 | Users logged in through the web application have full access, which |
438 | means they can read/change any object they have access to. |
439 | |
440 | === modified file 'lib/canonical/launchpad/interfaces/oauth.py' |
441 | --- lib/canonical/launchpad/interfaces/oauth.py 2010-08-20 20:31:18 +0000 |
442 | +++ lib/canonical/launchpad/interfaces/oauth.py 2010-10-19 14:17:46 +0000 |
443 | @@ -64,12 +64,26 @@ |
444 | description=_('The secret which, if not empty, should be used by the ' |
445 | 'consumer to sign its requests.')) |
446 | |
447 | + is_integrated_desktop = Attribute( |
448 | + """This attribute is true if the consumer corresponds to a |
449 | + user account on a personal computer or similar device.""") |
450 | + |
451 | + integrated_desktop_name = Attribute( |
452 | + """If the consumer corresponds to a user account on a personal |
453 | + computer or similar device, this is the self-reported name of |
454 | + the computer. If the consumer is a specific web or desktop |
455 | + application, this is None.""") |
456 | + |
457 | + integrated_desktop_type = Attribute( |
458 | + """If the consumer corresponds to a user account on a personal |
459 | + computer or similar device, this is the self-reported type of |
460 | + that computer (usually the operating system plus the word |
461 | + "desktop"). If the consumer is a specific web or desktop |
462 | + application, this is None.""") |
463 | + |
464 | def newRequestToken(): |
465 | """Return a new `IOAuthRequestToken` with a random key and secret. |
466 | |
467 | - Also sets the token's date_expires to `REQUEST_TOKEN_VALIDITY` hours |
468 | - from the creation date (now). |
469 | - |
470 | The other attributes of the token are supposed to be set whenever the |
471 | user logs into Launchpad and grants (or not) access to this consumer. |
472 | """ |
473 | @@ -131,12 +145,6 @@ |
474 | person = Object( |
475 | schema=IPerson, title=_('Person'), required=False, readonly=False, |
476 | description=_('The user on whose behalf the consumer is accessing.')) |
477 | - date_created = Datetime( |
478 | - title=_('Date created'), required=True, readonly=True) |
479 | - date_expires = Datetime( |
480 | - title=_('Date expires'), required=False, readonly=False, |
481 | - description=_('From this date onwards this token can not be used ' |
482 | - 'by the consumer to access protected resources.')) |
483 | key = TextLine( |
484 | title=_('Key'), required=True, readonly=True, |
485 | description=_('The key used to identify this token. It is included ' |
486 | @@ -154,6 +162,12 @@ |
487 | title=_("Distribution"), required=False, vocabulary='Distribution') |
488 | context = Attribute("FIXME") |
489 | |
490 | + is_expired = Bool( |
491 | + title=_("Whether or not this token has expired."), |
492 | + required=False, readonly=True, |
493 | + description=_("A token may only be usable for a limited time, " |
494 | + "after which it will expire.")) |
495 | + |
496 | |
497 | class IOAuthAccessToken(IOAuthToken): |
498 | """A token used by a consumer to access protected resources in LP. |
499 | @@ -168,6 +182,16 @@ |
500 | description=_('The level of access given to the application acting ' |
501 | 'on your behalf.')) |
502 | |
503 | + date_created = Datetime( |
504 | + title=_('Date created'), required=True, readonly=True, |
505 | + description=_('The date some request token was exchanged for ' |
506 | + 'this token.')) |
507 | + |
508 | + date_expires = Datetime( |
509 | + title=_('Date expires'), required=False, readonly=False, |
510 | + description=_('From this date onwards this token can not be used ' |
511 | + 'by the consumer to access protected resources.')) |
512 | + |
513 | def checkNonceAndTimestamp(nonce, timestamp): |
514 | """Verify the nonce and timestamp. |
515 | |
516 | @@ -202,11 +226,22 @@ |
517 | vocabulary=OAuthPermission, |
518 | description=_('The permission you give to the application which may ' |
519 | 'act on your behalf.')) |
520 | + date_created = Datetime( |
521 | + title=_('Date created'), required=True, readonly=True, |
522 | + description=_('The date the token was created. The request token ' |
523 | + 'will be good for a limited time after this date.')) |
524 | + |
525 | + date_expires = Datetime( |
526 | + title=_('Date expires'), required=False, readonly=False, |
527 | + description=_('The expiration date for the permission you give to ' |
528 | + 'the application which may act on your behalf.')) |
529 | + |
530 | date_reviewed = Datetime( |
531 | title=_('Date reviewed'), required=True, readonly=True, |
532 | description=_('The date in which the user authorized (or not) the ' |
533 | 'consumer to access his protected resources on ' |
534 | 'Launchpad.')) |
535 | + |
536 | is_reviewed = Bool( |
537 | title=_('Has this token been reviewed?'), |
538 | required=False, readonly=True, |
539 | |
540 | === modified file 'lib/canonical/launchpad/pagetests/oauth/authorize-token.txt' |
541 | --- lib/canonical/launchpad/pagetests/oauth/authorize-token.txt 2010-10-09 16:36:22 +0000 |
542 | +++ lib/canonical/launchpad/pagetests/oauth/authorize-token.txt 2010-10-19 14:17:46 +0000 |
543 | @@ -39,32 +39,29 @@ |
544 | |
545 | >>> main_content = find_tag_by_id(browser.contents, 'maincontent') |
546 | >>> print extract_text(main_content) |
547 | + Integrating foobar123451432 into your Launchpad account |
548 | The application identified as foobar123451432 wants to access Launchpad on |
549 | your behalf. What level of access do you want to grant? |
550 | ... |
551 | See all applications authorized to access Launchpad on your behalf. |
552 | |
553 | This page contains one submit button for each item of OAuthPermission, |
554 | -except for 'Grant Permissions', which must be specifically requested. |
555 | - |
556 | - >>> browser.getControl('No Access') |
557 | - <SubmitControl... |
558 | - >>> browser.getControl('Read Non-Private Data') |
559 | - <SubmitControl... |
560 | - >>> browser.getControl('Change Non-Private Data') |
561 | - <SubmitControl... |
562 | - >>> browser.getControl('Read Anything') |
563 | - <SubmitControl... |
564 | - >>> browser.getControl('Change Anything') |
565 | - <SubmitControl... |
566 | - |
567 | - >>> browser.getControl('Grant Permissions') |
568 | - Traceback (most recent call last): |
569 | - ... |
570 | - LookupError: label 'Grant Permissions' |
571 | - |
572 | +except for 'Desktop Integration', which must be specifically requested. |
573 | + |
574 | + >>> def print_access_levels(main_content): |
575 | + ... actions = main_content.findAll('input', attrs={'type': 'submit'}) |
576 | + ... for action in actions: |
577 | + ... print action['value'] |
578 | + |
579 | + >>> print_access_levels(main_content) |
580 | + No Access |
581 | + Read Non-Private Data |
582 | + Change Non-Private Data |
583 | + Read Anything |
584 | + Change Anything |
585 | + |
586 | + >>> from canonical.launchpad.webapp.interfaces import OAuthPermission |
587 | >>> actions = main_content.findAll('input', attrs={'type': 'submit'}) |
588 | - >>> from canonical.launchpad.webapp.interfaces import OAuthPermission |
589 | >>> len(actions) == len(OAuthPermission.items) - 1 |
590 | True |
591 | |
592 | @@ -74,57 +71,53 @@ |
593 | that isn't enough for the application. The user always has the option |
594 | to deny permission altogether. |
595 | |
596 | - >>> def print_access_levels(allow_permission): |
597 | + >>> def authorize_token_main_content(allow_permission): |
598 | ... browser.open( |
599 | ... "http://launchpad.dev/+authorize-token?%s&%s" |
600 | ... % (urlencode(params), allow_permission)) |
601 | - ... main_content = find_tag_by_id(browser.contents, 'maincontent') |
602 | - ... actions = main_content.findAll('input', attrs={'type': 'submit'}) |
603 | - ... for action in actions: |
604 | - ... print action['value'] |
605 | - |
606 | - >>> print_access_levels( |
607 | + ... return find_tag_by_id(browser.contents, 'maincontent') |
608 | + |
609 | + >>> def print_access_levels_for(allow_permission): |
610 | + ... main_content = authorize_token_main_content(allow_permission) |
611 | + ... print_access_levels(main_content) |
612 | + |
613 | + >>> print_access_levels_for( |
614 | ... 'allow_permission=WRITE_PUBLIC&allow_permission=WRITE_PRIVATE') |
615 | No Access |
616 | Change Non-Private Data |
617 | Change Anything |
618 | |
619 | -The only time the 'Grant Permissions' permission shows up in this list |
620 | -is if the client specifically requests it, and no other |
621 | -permission. (Also requesting UNAUTHORIZED is okay--it will show up |
622 | -anyway.) |
623 | - |
624 | - >>> print_access_levels('allow_permission=GRANT_PERMISSIONS') |
625 | - No Access |
626 | - Grant Permissions |
627 | - |
628 | - >>> print_access_levels( |
629 | - ... 'allow_permission=GRANT_PERMISSIONS&allow_permission=UNAUTHORIZED') |
630 | - No Access |
631 | - Grant Permissions |
632 | - |
633 | - >>> print_access_levels( |
634 | - ... 'allow_permission=WRITE_PUBLIC&allow_permission=GRANT_PERMISSIONS') |
635 | - No Access |
636 | - Change Non-Private Data |
637 | - |
638 | If an application doesn't specify any valid access levels, or only |
639 | specifies the UNAUTHORIZED access level, Launchpad will show all the |
640 | -access levels, except for GRANT_PERMISSIONS. |
641 | - |
642 | - >>> print_access_levels('') |
643 | - No Access |
644 | - Read Non-Private Data |
645 | - Change Non-Private Data |
646 | - Read Anything |
647 | - Change Anything |
648 | - |
649 | - >>> print_access_levels('allow_permission=UNAUTHORIZED') |
650 | - No Access |
651 | - Read Non-Private Data |
652 | - Change Non-Private Data |
653 | - Read Anything |
654 | - Change Anything |
655 | +access levels, except for DESKTOP_INTEGRATION. |
656 | + |
657 | + >>> print_access_levels_for('') |
658 | + No Access |
659 | + Read Non-Private Data |
660 | + Change Non-Private Data |
661 | + Read Anything |
662 | + Change Anything |
663 | + |
664 | + >>> print_access_levels_for('allow_permission=UNAUTHORIZED') |
665 | + No Access |
666 | + Read Non-Private Data |
667 | + Change Non-Private Data |
668 | + Read Anything |
669 | + Change Anything |
670 | + |
671 | +An application may not request the DESKTOP_INTEGRATION access level |
672 | +unless its consumer key matches a certain pattern. (Successful desktop |
673 | +integration has its own section, below.) |
674 | + |
675 | + >>> allow_permission = "allow_permission=DESKTOP_INTEGRATION" |
676 | + >>> browser.open( |
677 | + ... "http://launchpad.dev/+authorize-token?%s&%s" |
678 | + ... % (urlencode(params), allow_permission)) |
679 | + Traceback (most recent call last): |
680 | + ... |
681 | + Unauthorized: Consumer "foobar123451432" asked for desktop |
682 | + integration, but didn't say what kind of desktop it is, or name |
683 | + the computer being integrated. |
684 | |
685 | An application may also specify a context, so that the access granted |
686 | by the user is restricted to things related to that context. |
687 | @@ -136,6 +129,7 @@ |
688 | ... % urlencode(params_with_context)) |
689 | >>> main_content = find_tag_by_id(browser.contents, 'maincontent') |
690 | >>> print extract_text(main_content) |
691 | + Integrating foobar123451432 into your Launchpad account |
692 | The application...wants to access things related to Mozilla Firefox... |
693 | |
694 | A client other than a web browser may request a JSON representation of |
695 | @@ -263,3 +257,95 @@ |
696 | This request for accessing Launchpad on your behalf has been |
697 | reviewed ... ago. |
698 | See all applications authorized to access Launchpad on your behalf. |
699 | + |
700 | +Desktop integration |
701 | +=================== |
702 | + |
703 | +The test case given above shows how to integrate a single application |
704 | +or website into Launchpad. But it's also possible to integrate an |
705 | +entire desktop environment into Launchpad. |
706 | + |
707 | +The desktop integration option is only available for OAuth consumers |
708 | +that say what kind of desktop they are (eg. Ubuntu) and give a name |
709 | +that a user can identify with their computer (eg. the hostname). Here, |
710 | +we'll create such a token. |
711 | + |
712 | + >>> login('salgado@ubuntu.com') |
713 | + >>> desktop_key = "System-wide: Ubuntu desktop (mycomputer)" |
714 | + >>> consumer = getUtility(IOAuthConsumerSet).new(desktop_key) |
715 | + >>> token = consumer.newRequestToken() |
716 | + >>> logout() |
717 | + |
718 | +When a desktop tries to integrate with Launchpad, the user gets a |
719 | +special warning about giving access to every program running on their |
720 | +desktop. |
721 | + |
722 | + >>> params = dict(oauth_token=token.key) |
723 | + >>> print extract_text( |
724 | + ... authorize_token_main_content( |
725 | + ... 'allow_permission=DESKTOP_INTEGRATION')) |
726 | + Integrating mycomputer into your Launchpad account |
727 | + The Ubuntu desktop called mycomputer wants access to your |
728 | + Launchpad account. If you allow the integration, all applications |
729 | + running on mycomputer will have read-write access to your |
730 | + Launchpad account, including to your private data. |
731 | + If you're using a public computer, if mycomputer is not the |
732 | + computer you're using right now, or if something just doesn't feel |
733 | + right about this situation, you should choose "No, thanks, I don't |
734 | + trust 'mycomputer'", or close this window now. You can always try |
735 | + again later. |
736 | + Even if you decide to allow the integration, you can change your |
737 | + mind later. |
738 | + See all applications authorized to access Launchpad on your behalf. |
739 | + |
740 | + |
741 | +The only time the 'Desktop Integration' permission shows up in the |
742 | +list of permissions is if the client specifically requests it, and no |
743 | +other permission. (Also requesting UNAUTHORIZED is okay--it will show |
744 | +up anyway.) |
745 | + |
746 | + >>> print_access_levels_for('allow_permission=DESKTOP_INTEGRATION') |
747 | + Give all programs running on "mycomputer" access to my Launchpad account. |
748 | + No, thanks, I don't trust "mycomputer". |
749 | + |
750 | + >>> print_access_levels_for( |
751 | + ... 'allow_permission=DESKTOP_INTEGRATION&allow_permission=UNAUTHORIZED') |
752 | + Give all programs running on "mycomputer" access to my Launchpad account. |
753 | + No, thanks, I don't trust "mycomputer". |
754 | + |
755 | +A desktop may not request a level of access other than |
756 | +DESKTOP_INTEGRATION, since the whole point is to have a permission |
757 | +level that specifically applies across the entire desktop. |
758 | + |
759 | + >>> print_access_levels_for('allow_permission=WRITE_PRIVATE') |
760 | + Traceback (most recent call last): |
761 | + ... |
762 | + Unauthorized: Desktop integration token requested a permission |
763 | + ("Change Anything") not supported for desktop-wide use. |
764 | + |
765 | + >>> print_access_levels_for( |
766 | + ... 'allow_permission=WRITE_PUBLIC&allow_permission=DESKTOP_INTEGRATION') |
767 | + Traceback (most recent call last): |
768 | + ... |
769 | + Unauthorized: Desktop integration token requested a permission |
770 | + ("Change Non-Private Data") not supported for desktop-wide use. |
771 | + |
772 | +You can't specify a callback URL when authorizing a desktop-wide |
773 | +token, since callback URLs should only be used when integrating |
774 | +websites into Launchpad. |
775 | + |
776 | + >>> params['oauth_callback'] = 'http://launchpad.dev/bzr' |
777 | + >>> print_access_levels_for('allow_permission=DESKTOP_INTEGRATION') |
778 | + Traceback (most recent call last): |
779 | + ... |
780 | + Unauthorized: A desktop integration may not specify an OAuth |
781 | + callback URL. |
782 | + |
783 | +This is true even if the desktop token isn't asking for the |
784 | +DESKTOP_INTEGRATION permission. |
785 | + |
786 | + >>> print_access_levels_for('allow_permission=WRITE_PRIVATE') |
787 | + Traceback (most recent call last): |
788 | + ... |
789 | + Unauthorized: A desktop integration may not specify an OAuth |
790 | + callback URL. |
791 | |
792 | === modified file 'lib/canonical/launchpad/templates/oauth-authorize.pt' |
793 | --- lib/canonical/launchpad/templates/oauth-authorize.pt 2009-07-17 17:59:07 +0000 |
794 | +++ lib/canonical/launchpad/templates/oauth-authorize.pt 2010-10-19 14:17:46 +0000 |
795 | @@ -21,28 +21,69 @@ |
796 | <tal:token-not-reviewed condition="not:token/is_reviewed"> |
797 | <div metal:use-macro="context/@@launchpad_form/form"> |
798 | <div metal:fill-slot="extra_top"> |
799 | - <p>The application identified as |
800 | - <strong tal:content="token/consumer/key">consumer</strong> |
801 | - wants to access |
802 | - <tal:has-context condition="view/token_context"> |
803 | - things related to |
804 | - <strong tal:content="view/token_context/title">Context</strong> |
805 | - in |
806 | - </tal:has-context> |
807 | - Launchpad on your behalf. What level of access |
808 | - do you want to grant?</p> |
809 | - |
810 | - <table> |
811 | - <tr tal:repeat="action view/visible_actions"> |
812 | - <td style="text-align: right"> |
813 | - <tal:action replace="structure action/render" /> |
814 | - </td> |
815 | - <td> |
816 | - <span class="lesser" |
817 | - tal:content="action/permission/description" /> |
818 | - </td> |
819 | - </tr> |
820 | - </table> |
821 | + |
822 | + <tal:desktop-integration-token condition="token/consumer/is_integrated_desktop"> |
823 | + <h1>Integrating |
824 | + <tal:hostname replace="structure |
825 | + token/consumer/integrated_desktop_name" /> |
826 | + into your Launchpad account</h1> |
827 | + <p>The |
828 | + <tal:desktop replace="structure |
829 | + token/consumer/integrated_desktop_type" /> |
830 | + called |
831 | + <strong tal:content="token/consumer/integrated_desktop_name">hostname</strong> |
832 | + wants access to your Launchpad account. If you allow the |
833 | + integration, all applications running |
834 | + on <strong tal:content="token/consumer/integrated_desktop_name">hostname</strong> |
835 | + will have read-write access to your Launchpad account, |
836 | + including to your private data.</p> |
837 | + |
838 | + <p>If you're using a public computer, if |
839 | + <strong tal:content="token/consumer/integrated_desktop_name">hostname</strong> |
840 | + is not the computer you're using right now, or if |
841 | + something just doesn't feel right about this situation, |
842 | + you should choose "No, thanks, I don't trust |
843 | + '<tal:hostname replace="structure |
844 | + token/consumer/integrated_desktop_name" />'", |
845 | + or close this window now. You can always try |
846 | + again later.</p> |
847 | + |
848 | + <p>Even if you decide to allow the integration, you can |
849 | + change your mind later.</p> |
850 | + </tal:desktop-integration-token> |
851 | + |
852 | + <tal:web-integration-token condition="not:token/consumer/is_integrated_desktop"> |
853 | + <h1>Integrating |
854 | + <tal:hostname replace="structure token/consumer/key" /> |
855 | + into your Launchpad account</h1> |
856 | + |
857 | + <p>The application identified as |
858 | + <strong tal:content="token/consumer/key">consumer</strong> |
859 | + wants to access |
860 | + <tal:has-context condition="view/token_context"> |
861 | + things related to |
862 | + <strong tal:content="view/token_context/title">Context</strong> |
863 | + in |
864 | + </tal:has-context> |
865 | + Launchpad on your behalf. What level of access |
866 | + do you want to grant?</p> |
867 | + </tal:web-integration-token> |
868 | + |
869 | + <table> |
870 | + <tr tal:repeat="action view/visible_actions"> |
871 | + <td style="text-align: right"> |
872 | + <tal:action replace="structure action/render" /> |
873 | + </td> |
874 | + |
875 | + <tal:web-integration-token |
876 | + condition="not:token/consumer/is_integrated_desktop"> |
877 | + <td> |
878 | + <span class="lesser" |
879 | + tal:content="action/permission/description" /> |
880 | + </td> |
881 | + </tal:web-integration-token> |
882 | + </tr> |
883 | + </table> |
884 | </div> |
885 | |
886 | <div metal:fill-slot="extra_bottom"> |
887 | |
888 | === added file 'lib/canonical/launchpad/tests/test_oauth_tokens.py' |
889 | --- lib/canonical/launchpad/tests/test_oauth_tokens.py 1970-01-01 00:00:00 +0000 |
890 | +++ lib/canonical/launchpad/tests/test_oauth_tokens.py 2010-10-19 14:17:46 +0000 |
891 | @@ -0,0 +1,47 @@ |
892 | +# Copyright 2010 Canonical Ltd. This software is licensed under the |
893 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
894 | + |
895 | +from datetime import ( |
896 | + datetime, |
897 | + timedelta |
898 | + ) |
899 | +import pytz |
900 | + |
901 | +from canonical.launchpad.webapp.interfaces import OAuthPermission |
902 | +from canonical.testing.layers import DatabaseFunctionalLayer |
903 | + |
904 | +from lp.testing import ( |
905 | + TestCaseWithFactory, |
906 | + ) |
907 | + |
908 | + |
909 | +class TestRequestTokens(TestCaseWithFactory): |
910 | + |
911 | + layer = DatabaseFunctionalLayer |
912 | + |
913 | + def setUp(self): |
914 | + """Set up a dummy person and OAuth consumer.""" |
915 | + super(TestRequestTokens, self).setUp() |
916 | + |
917 | + self.person = self.factory.makePerson() |
918 | + self.consumer = self.factory.makeOAuthConsumer() |
919 | + |
920 | + now = datetime.now(pytz.timezone('UTC')) |
921 | + self.a_long_time_ago = now - timedelta(hours=1000) |
922 | + |
923 | + def testExpiredRequestTokenCantBeReviewed(self): |
924 | + """An expired request token can't be reviewed.""" |
925 | + token = self.factory.makeOAuthRequestToken( |
926 | + date_created=self.a_long_time_ago) |
927 | + self.assertRaises( |
928 | + AssertionError, token.review, self.person, |
929 | + OAuthPermission.WRITE_PUBLIC) |
930 | + |
931 | + def testExpiredRequestTokenCantBeExchanged(self): |
932 | + """An expired request token can't be exchanged for an access token. |
933 | + |
934 | + This can only happen if the token was reviewed before it expired. |
935 | + """ |
936 | + token = self.factory.makeOAuthRequestToken( |
937 | + date_created=self.a_long_time_ago, reviewed_by=self.person) |
938 | + self.assertRaises(AssertionError, token.createAccessToken) |
939 | |
940 | === modified file 'lib/canonical/launchpad/webapp/authorization.py' |
941 | --- lib/canonical/launchpad/webapp/authorization.py 2010-08-20 20:31:18 +0000 |
942 | +++ lib/canonical/launchpad/webapp/authorization.py 2010-10-19 14:17:46 +0000 |
943 | @@ -61,7 +61,8 @@ |
944 | lp_permission = getUtility(ILaunchpadPermission, permission) |
945 | if lp_permission.access_level == "write": |
946 | required_access_level = [ |
947 | - AccessLevel.WRITE_PUBLIC, AccessLevel.WRITE_PRIVATE] |
948 | + AccessLevel.WRITE_PUBLIC, AccessLevel.WRITE_PRIVATE, |
949 | + AccessLevel.DESKTOP_INTEGRATION] |
950 | if access_level not in required_access_level: |
951 | return False |
952 | elif lp_permission.access_level == "read": |
953 | @@ -80,7 +81,8 @@ |
954 | access to private objects, return False. Return True otherwise. |
955 | """ |
956 | private_access_levels = [ |
957 | - AccessLevel.READ_PRIVATE, AccessLevel.WRITE_PRIVATE] |
958 | + AccessLevel.READ_PRIVATE, AccessLevel.WRITE_PRIVATE, |
959 | + AccessLevel.DESKTOP_INTEGRATION] |
960 | if access_level in private_access_levels: |
961 | # The user has access to private objects. Return early, |
962 | # before checking whether the object is private, since |
963 | |
964 | === modified file 'lib/canonical/launchpad/webapp/interfaces.py' |
965 | --- lib/canonical/launchpad/webapp/interfaces.py 2010-09-24 09:42:01 +0000 |
966 | +++ lib/canonical/launchpad/webapp/interfaces.py 2010-10-19 14:17:46 +0000 |
967 | @@ -531,14 +531,13 @@ |
968 | for reading and changing anything, including private data. |
969 | """) |
970 | |
971 | - GRANT_PERMISSIONS = DBItem(60, """ |
972 | - Grant Permissions |
973 | + DESKTOP_INTEGRATION = DBItem(60, """ |
974 | + Desktop Integration |
975 | |
976 | - The application will be able to grant access to your Launchpad |
977 | - account to any other application. This is a very powerful |
978 | - level of access. You should not grant this level of access to |
979 | - any application except the official Launchpad credential |
980 | - manager. |
981 | + Every application running on your desktop will have read-write |
982 | + access to your Launchpad account, including to your private |
983 | + data. You should not allow this unless you trust the computer |
984 | + you're using right now. |
985 | """) |
986 | |
987 | class AccessLevel(DBEnumeratedType): |
988 | |
989 | === modified file 'lib/canonical/launchpad/webapp/servers.py' |
990 | --- lib/canonical/launchpad/webapp/servers.py 2010-09-28 07:00:56 +0000 |
991 | +++ lib/canonical/launchpad/webapp/servers.py 2010-10-19 14:17:46 +0000 |
992 | @@ -8,7 +8,6 @@ |
993 | __metaclass__ = type |
994 | |
995 | import cgi |
996 | -from datetime import datetime |
997 | import threading |
998 | import xmlrpclib |
999 | |
1000 | @@ -1277,10 +1276,9 @@ |
1001 | token.checkNonceAndTimestamp(nonce, timestamp) |
1002 | except (NonceAlreadyUsed, TimestampOrderingError, ClockSkew), e: |
1003 | raise Unauthorized('Invalid nonce/timestamp: %s' % e) |
1004 | - now = datetime.now(pytz.timezone('UTC')) |
1005 | if token.permission == OAuthPermission.UNAUTHORIZED: |
1006 | raise Unauthorized('Unauthorized token (%s).' % token.key) |
1007 | - elif token.date_expires is not None and token.date_expires <= now: |
1008 | + elif token.is_expired: |
1009 | raise Unauthorized('Expired token (%s).' % token.key) |
1010 | elif not check_oauth_signature(request, consumer, token): |
1011 | raise Unauthorized('Invalid signature.') |
1012 | |
1013 | === modified file 'lib/lp/archiveuploader/nascentuploadfile.py' |
1014 | --- lib/lp/archiveuploader/nascentuploadfile.py 2010-10-02 11:41:43 +0000 |
1015 | +++ lib/lp/archiveuploader/nascentuploadfile.py 2010-10-19 14:17:46 +0000 |
1016 | @@ -50,8 +50,8 @@ |
1017 | from lp.soyuz.enums import ( |
1018 | BinaryPackageFormat, |
1019 | PackagePublishingPriority, |
1020 | + PackagePublishingStatus, |
1021 | PackageUploadCustomFormat, |
1022 | - PackageUploadStatus, |
1023 | ) |
1024 | from lp.soyuz.interfaces.binarypackagename import IBinaryPackageNameSet |
1025 | from lp.soyuz.interfaces.component import IComponentSet |
1026 | @@ -781,11 +781,9 @@ |
1027 | # Database relationship methods |
1028 | # |
1029 | def findSourcePackageRelease(self): |
1030 | - """Return the respective ISourcePackagRelease for this binary upload. |
1031 | + """Return the respective ISourcePackageRelease for this binary upload. |
1032 | |
1033 | - It inspect publication in the targeted DistroSeries and also the |
1034 | - ACCEPTED queue for sources matching stored |
1035 | - (source_name, source_version). |
1036 | + It inspect publication in the targeted DistroSeries. |
1037 | |
1038 | It raises UploadError if the source was not found. |
1039 | |
1040 | @@ -793,43 +791,19 @@ |
1041 | mixed_uploads (source + binary) we do not have the source stored |
1042 | in DB yet (see verifySourcepackagerelease). |
1043 | """ |
1044 | + assert self.source_name is not None |
1045 | + assert self.source_version is not None |
1046 | distroseries = self.policy.distroseries |
1047 | spphs = distroseries.getPublishedSources( |
1048 | self.source_name, version=self.source_version, |
1049 | include_pending=True, archive=self.policy.archive) |
1050 | |
1051 | - sourcepackagerelease = None |
1052 | - if spphs: |
1053 | - # We know there's only going to be one release because |
1054 | - # version is unique. |
1055 | - assert spphs.count() == 1, "Duplicated ancestry" |
1056 | - sourcepackagerelease = spphs[0].sourcepackagerelease |
1057 | - else: |
1058 | - # XXX cprov 2006-08-09 bug=55774: Building from ACCEPTED is |
1059 | - # special condition, not really used in production. We should |
1060 | - # remove the support for this use case. |
1061 | - self.logger.debug( |
1062 | - "No source published, checking the ACCEPTED queue") |
1063 | - |
1064 | - queue_candidates = distroseries.getQueueItems( |
1065 | - status=PackageUploadStatus.ACCEPTED, |
1066 | - name=self.source_name, version=self.source_version, |
1067 | - archive=self.policy.archive, exact_match=True) |
1068 | - |
1069 | - for queue_item in queue_candidates: |
1070 | - if queue_item.sources.count(): |
1071 | - sourcepackagerelease = queue_item.sourcepackagerelease |
1072 | - |
1073 | - if sourcepackagerelease is None: |
1074 | - # At this point, we can't really do much more to try |
1075 | - # building this package. If we look in the NEW queue it is |
1076 | - # possible that multiple versions of the package exist there |
1077 | - # and we know how bad that can be. Time to give up! |
1078 | + if spphs.count() == 0: |
1079 | raise UploadError( |
1080 | "Unable to find source package %s/%s in %s" % ( |
1081 | self.source_name, self.source_version, distroseries.name)) |
1082 | |
1083 | - return sourcepackagerelease |
1084 | + return spphs[0].sourcepackagerelease |
1085 | |
1086 | def verifySourcePackageRelease(self, sourcepackagerelease): |
1087 | """Check if the given ISourcePackageRelease matches the context.""" |
1088 | |
1089 | === modified file 'lib/lp/archiveuploader/tests/nascentupload-epoch-handling.txt' |
1090 | --- lib/lp/archiveuploader/tests/nascentupload-epoch-handling.txt 2009-04-17 10:32:16 +0000 |
1091 | +++ lib/lp/archiveuploader/tests/nascentupload-epoch-handling.txt 2010-10-19 14:17:46 +0000 |
1092 | @@ -208,6 +208,14 @@ |
1093 | >>> bar_spr.version |
1094 | u'1:1.0-9' |
1095 | |
1096 | + >>> from lp.registry.interfaces.pocket import PackagePublishingPocket |
1097 | + >>> from lp.soyuz.interfaces.publishing import IPublishingSet |
1098 | + >>> getUtility(IPublishingSet).newSourcePublication( |
1099 | + ... bar_src_upload.policy.distro.main_archive, bar_spr, |
1100 | + ... bar_src_upload.policy.distroseries, bar_spr.component, |
1101 | + ... bar_spr.section, PackagePublishingPocket.RELEASE) |
1102 | + <SourcePackagePublishingHistory at ...> |
1103 | + |
1104 | Let's accept the source and claim 'build from accepted' to process the |
1105 | respective binary: |
1106 | |
1107 | @@ -217,9 +225,6 @@ |
1108 | >>> bar_src_queue.status.name |
1109 | 'ACCEPTED' |
1110 | |
1111 | - >>> from canonical.launchpad.ftests import syncUpdate |
1112 | - >>> syncUpdate(bar_src_queue) |
1113 | - |
1114 | For a binary upload we expect the same, a BinaryPackageRelease |
1115 | 'version' that includes 'epoch': |
1116 | |
1117 | |
1118 | === modified file 'lib/lp/archiveuploader/tests/nascentupload.txt' |
1119 | --- lib/lp/archiveuploader/tests/nascentupload.txt 2010-10-14 18:42:19 +0000 |
1120 | +++ lib/lp/archiveuploader/tests/nascentupload.txt 2010-10-19 14:17:46 +0000 |
1121 | @@ -484,86 +484,6 @@ |
1122 | 1 |
1123 | |
1124 | |
1125 | -=== Building From ACCEPTED queue === |
1126 | - |
1127 | -XXX cprov 20060728: Building from ACCEPTED is special condition, not |
1128 | -really used in production. We should remove the support for this use |
1129 | -case, see further info in bug #55774. |
1130 | - |
1131 | -Next we send in the binaries, since the source should be in ACCEPTED the |
1132 | -binary should go straight there too. Note that we are not specifying |
1133 | -any build, so it should be created appropriately, it is what happens |
1134 | -with staged security uploads: |
1135 | - |
1136 | -XXX cprov 20070404: we are using a modified sync policy because we |
1137 | -need unsigned changes and binaries uploads (same as security, but also |
1138 | -accepts non-security uploads) |
1139 | - |
1140 | - >>> from lp.archiveuploader.uploadpolicy import ArchiveUploadType |
1141 | - >>> modified_sync_policy = getPolicy( |
1142 | - ... name='sync', distro='ubuntu', distroseries='hoary') |
1143 | - >>> modified_sync_policy.accepted_type = ArchiveUploadType.BINARY_ONLY |
1144 | - |
1145 | - >>> ed_bin = NascentUpload.from_changesfile_path( |
1146 | - ... datadir('split-upload-test/ed_0.2-20_i386.changes'), |
1147 | - ... modified_sync_policy, mock_logger_quiet) |
1148 | - |
1149 | - >>> ed_bin.process() |
1150 | - >>> ed_bin.is_rejected |
1151 | - False |
1152 | - |
1153 | - >>> success = ed_bin.do_accept() |
1154 | - |
1155 | - >>> ed_bin.queue_root.status.name |
1156 | - 'NEW' |
1157 | - |
1158 | -A build was created to represent the relationship between ed_src |
1159 | -(waiting in ACCEPTED queue) and the just uploaded ed_bin: |
1160 | - |
1161 | - >>> ed_build = ed_bin.queue_root.builds[0].build |
1162 | - |
1163 | - >>> ed_spr.id == ed_build.source_package_release.id |
1164 | - True |
1165 | - |
1166 | - >>> ed_build.title |
1167 | - u'i386 build of ed 0.2-20 in ubuntu hoary RELEASE' |
1168 | - |
1169 | - >>> ed_build.status.name |
1170 | - 'FULLYBUILT' |
1171 | - |
1172 | -Binary control file attributes are stored as text in the |
1173 | -BinaryPackageRelease record. |
1174 | - |
1175 | - >>> ed_bpr = ed_build.binarypackages[0] |
1176 | - |
1177 | - >>> ed_bpr.depends |
1178 | - u'libc6 (>= 2.6-1)' |
1179 | - |
1180 | - >>> ed_bpr.suggests |
1181 | - u'emacs' |
1182 | - |
1183 | - >>> ed_bpr.conflicts |
1184 | - u'vi' |
1185 | - |
1186 | - >>> ed_bpr.replaces |
1187 | - u'vim' |
1188 | - |
1189 | - >>> ed_bpr.provides |
1190 | - u'ed' |
1191 | - |
1192 | - >>> ed_bpr.essential |
1193 | - False |
1194 | - |
1195 | - >>> ed_bpr.pre_depends |
1196 | - u'dpkg' |
1197 | - |
1198 | - >>> ed_bpr.enhances |
1199 | - u'bash' |
1200 | - |
1201 | - >>> ed_bpr.breaks |
1202 | - u'emacs' |
1203 | - |
1204 | - |
1205 | === Staged Source and Binary upload with multiple binaries === |
1206 | |
1207 | As we could see both, sources and binaries, get into Launchpad via |
1208 | @@ -613,12 +533,18 @@ |
1209 | >>> multibar_src_queue.status.name |
1210 | 'ACCEPTED' |
1211 | |
1212 | -We can just assume the source was published by step 3 and 4 for |
1213 | -simplicity and claim 'build from ACCEPTED' feature. |
1214 | +Then the source gets accepted and published, step 3 and 4: |
1215 | + |
1216 | + >>> from lp.registry.interfaces.pocket import PackagePublishingPocket |
1217 | + >>> from lp.soyuz.interfaces.publishing import IPublishingSet |
1218 | + >>> getUtility(IPublishingSet).newSourcePublication( |
1219 | + ... multibar_src_queue.archive, multibar_spr, |
1220 | + ... sync_policy.distroseries, multibar_spr.component, |
1221 | + ... multibar_spr.section, PackagePublishingPocket.RELEASE) |
1222 | + <SourcePackagePublishingHistory at ...> |
1223 | |
1224 | Build creation is done based on the SourcePackageRelease object, step 5: |
1225 | |
1226 | - >>> from lp.registry.interfaces.pocket import PackagePublishingPocket |
1227 | >>> multibar_build = multibar_spr.createBuild( |
1228 | ... hoary['i386'], PackagePublishingPocket.RELEASE, |
1229 | ... multibar_src_queue.archive) |
1230 | @@ -636,8 +562,8 @@ |
1231 | and the collection of DEB files produced. |
1232 | |
1233 | At this point slave-scanner moves the upload to the appropriate path |
1234 | -(/srv/launchpad.net/builddmaster) and invokes process-upload.py with |
1235 | -the 'buildd' upload policy and the build record id. |
1236 | +(/srv/launchpad.net/builddmaster). A cron job invokes process-upload.py |
1237 | +with the 'buildd' upload policy and processes all files in that directory. |
1238 | |
1239 | >>> buildd_policy = getPolicy( |
1240 | ... name='buildd', distro='ubuntu', distroseries='hoary') |
1241 | |
1242 | === modified file 'lib/lp/archiveuploader/tests/test_buildduploads.py' |
1243 | --- lib/lp/archiveuploader/tests/test_buildduploads.py 2010-10-06 11:46:51 +0000 |
1244 | +++ lib/lp/archiveuploader/tests/test_buildduploads.py 2010-10-19 14:17:46 +0000 |
1245 | @@ -20,6 +20,7 @@ |
1246 | ) |
1247 | from lp.registry.interfaces.distribution import IDistributionSet |
1248 | from lp.registry.interfaces.pocket import PackagePublishingPocket |
1249 | +from lp.soyuz.interfaces.publishing import IPublishingSet |
1250 | from lp.soyuz.model.binarypackagebuild import BinaryPackageBuild |
1251 | |
1252 | |
1253 | @@ -195,6 +196,13 @@ |
1254 | real_policy = self.policy |
1255 | self.policy = 'insecure' |
1256 | super(TestBuilddUploads, self).setUp() |
1257 | + # Publish the source package release so it can be found by |
1258 | + # NascentUploadFile.findSourcePackageRelease(). |
1259 | + spr = self.source_queue.sources[0].sourcepackagerelease |
1260 | + getUtility(IPublishingSet).newSourcePublication( |
1261 | + self.distroseries.main_archive, spr, |
1262 | + self.distroseries, spr.component, |
1263 | + spr.section, PackagePublishingPocket.RELEASE) |
1264 | self.policy = real_policy |
1265 | |
1266 | def _publishBuildQueueItem(self, queue_item): |
1267 | |
1268 | === modified file 'lib/lp/archiveuploader/tests/test_nascentuploadfile.py' |
1269 | --- lib/lp/archiveuploader/tests/test_nascentuploadfile.py 2010-10-06 11:46:51 +0000 |
1270 | +++ lib/lp/archiveuploader/tests/test_nascentuploadfile.py 2010-10-19 14:17:46 +0000 |
1271 | @@ -25,7 +25,10 @@ |
1272 | from lp.registry.interfaces.pocket import PackagePublishingPocket |
1273 | from lp.archiveuploader.tests import AbsolutelyAnythingGoesUploadPolicy |
1274 | from lp.buildmaster.enums import BuildStatus |
1275 | -from lp.soyuz.enums import PackageUploadCustomFormat |
1276 | +from lp.soyuz.enums import ( |
1277 | + PackageUploadCustomFormat, |
1278 | + PackagePublishingStatus, |
1279 | + ) |
1280 | from lp.testing import TestCaseWithFactory |
1281 | |
1282 | |
1283 | @@ -387,3 +390,68 @@ |
1284 | "foo_0.42_i386.deb", "main/python", "unknown", "mypkg", "0.42", |
1285 | None) |
1286 | self.assertRaises(UploadError, uploadfile.checkBuild, build) |
1287 | + |
1288 | + def test_findSourcePackageRelease(self): |
1289 | + # findSourcePackageRelease finds the matching SourcePackageRelease. |
1290 | + das = self.factory.makeDistroArchSeries( |
1291 | + distroseries=self.policy.distroseries, architecturetag="i386") |
1292 | + build = self.factory.makeBinaryPackageBuild( |
1293 | + distroarchseries=das, |
1294 | + archive=self.policy.archive) |
1295 | + uploadfile = self.createDebBinaryUploadFile( |
1296 | + "foo_0.42_i386.deb", "main/python", "unknown", "mypkg", "0.42", |
1297 | + None) |
1298 | + spph = self.factory.makeSourcePackagePublishingHistory( |
1299 | + sourcepackagename=self.factory.makeSourcePackageName("foo"), |
1300 | + distroseries=self.policy.distroseries, |
1301 | + version="0.42", archive=self.policy.archive) |
1302 | + control = self.getBaseControl() |
1303 | + control["Source"] = "foo" |
1304 | + uploadfile.parseControl(control) |
1305 | + self.assertEquals( |
1306 | + spph.sourcepackagerelease, uploadfile.findSourcePackageRelease()) |
1307 | + |
1308 | + def test_findSourcePackageRelease_no_spph(self): |
1309 | + # findSourcePackageRelease raises UploadError if there is no |
1310 | + # SourcePackageRelease. |
1311 | + das = self.factory.makeDistroArchSeries( |
1312 | + distroseries=self.policy.distroseries, architecturetag="i386") |
1313 | + build = self.factory.makeBinaryPackageBuild( |
1314 | + distroarchseries=das, |
1315 | + archive=self.policy.archive) |
1316 | + uploadfile = self.createDebBinaryUploadFile( |
1317 | + "foo_0.42_i386.deb", "main/python", "unknown", "mypkg", "0.42", |
1318 | + None) |
1319 | + control = self.getBaseControl() |
1320 | + control["Source"] = "foo" |
1321 | + uploadfile.parseControl(control) |
1322 | + self.assertRaises(UploadError, uploadfile.findSourcePackageRelease) |
1323 | + |
1324 | + def test_findSourcePackageRelease_multiple_sprs(self): |
1325 | + # findSourcePackageRelease finds the last uploaded |
1326 | + # SourcePackageRelease and can deal with multiple pending source |
1327 | + # package releases. |
1328 | + das = self.factory.makeDistroArchSeries( |
1329 | + distroseries=self.policy.distroseries, architecturetag="i386") |
1330 | + build = self.factory.makeBinaryPackageBuild( |
1331 | + distroarchseries=das, |
1332 | + archive=self.policy.archive) |
1333 | + uploadfile = self.createDebBinaryUploadFile( |
1334 | + "foo_0.42_i386.deb", "main/python", "unknown", "mypkg", "0.42", |
1335 | + None) |
1336 | + spn = self.factory.makeSourcePackageName("foo") |
1337 | + spph1 = self.factory.makeSourcePackagePublishingHistory( |
1338 | + sourcepackagename=spn, |
1339 | + distroseries=self.policy.distroseries, |
1340 | + version="0.42", archive=self.policy.archive, |
1341 | + status=PackagePublishingStatus.PUBLISHED) |
1342 | + spph2 = self.factory.makeSourcePackagePublishingHistory( |
1343 | + sourcepackagename=spn, |
1344 | + distroseries=self.policy.distroseries, |
1345 | + version="0.42", archive=self.policy.archive, |
1346 | + status=PackagePublishingStatus.PENDING) |
1347 | + control = self.getBaseControl() |
1348 | + control["Source"] = "foo" |
1349 | + uploadfile.parseControl(control) |
1350 | + self.assertEquals( |
1351 | + spph2.sourcepackagerelease, uploadfile.findSourcePackageRelease()) |
1352 | |
1353 | === modified file 'lib/lp/testing/factory.py' |
1354 | --- lib/lp/testing/factory.py 2010-10-18 21:18:03 +0000 |
1355 | +++ lib/lp/testing/factory.py 2010-10-19 14:17:46 +0000 |
1356 | @@ -79,6 +79,7 @@ |
1357 | EmailAddressStatus, |
1358 | IEmailAddressSet, |
1359 | ) |
1360 | +from canonical.launchpad.interfaces.oauth import IOAuthConsumerSet |
1361 | from canonical.launchpad.interfaces.gpghandler import IGPGHandler |
1362 | from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities |
1363 | from canonical.launchpad.interfaces.librarian import ILibraryFileAliasSet |
1364 | @@ -91,6 +92,7 @@ |
1365 | DEFAULT_FLAVOR, |
1366 | IStoreSelector, |
1367 | MAIN_STORE, |
1368 | + OAuthPermission, |
1369 | ) |
1370 | from lp.app.enums import ServiceUsage |
1371 | from lp.archiveuploader.dscfile import DSCFile |
1372 | @@ -3165,6 +3167,33 @@ |
1373 | to_source=to_source, date_fulfilled=date_fulfilled, |
1374 | status=status, diff_content=lfa)) |
1375 | |
1376 | + # Factory methods for OAuth tokens. |
1377 | + def makeOAuthConsumer(self, key=None, secret=None): |
1378 | + if key is None: |
1379 | + key = self.getUniqueString("oauthconsumerkey") |
1380 | + if secret is None: |
1381 | + secret = '' |
1382 | + return getUtility(IOAuthConsumerSet).new(key, secret) |
1383 | + |
1384 | + def makeOAuthRequestToken( |
1385 | + self, consumer=None, date_created=None, reviewed_by=None, |
1386 | + access_level=OAuthPermission.READ_PUBLIC): |
1387 | + """Create a (possibly reviewed) OAuth request token.""" |
1388 | + if consumer is None: |
1389 | + consumer = self.makeOAuthConsumer() |
1390 | + token = consumer.newRequestToken() |
1391 | + |
1392 | + if reviewed_by is not None: |
1393 | + # Review the token before modifying the date_created, |
1394 | + # since the date_created can be used to simulate an |
1395 | + # expired token. |
1396 | + token.review(reviewed_by, access_level) |
1397 | + |
1398 | + if date_created is not None: |
1399 | + unwrapped_token = removeSecurityProxy(token) |
1400 | + unwrapped_token.date_created = date_created |
1401 | + return token |
1402 | + |
1403 | |
1404 | # Some factory methods return simple Python types. We don't add |
1405 | # security wrappers for them, as well as for objects created by |
37 @@ -254,10 +257,19 @@ now(pytz. timezone( 'UTC')) hours=REQUEST_ TOKEN_VALIDITY) oken`." "" now(pytz. timezone( 'UTC')) .UNAUTHORIZED, (
38 else:
39 return None
40
41 + @property
42 + def is_expired(self):
43 + now = datetime.
44 + expires = self.date_created + timedelta(
45 + return expires <= now
46 +
47 def review(self, user, permission, context=None):
48 """See `IOAuthRequestT
49 assert not self.is_reviewed, (
50 "Request tokens can be reviewed only once.")
51 + assert not self.is_expired, (
52 + 'This request token has expired and can no longer be reviewed.'
53 + )
54 self.date_reviewed = datetime.
55 self.person = user
56 self.permission = permission
57 @@ -279,6 +291,11 @@
58 'Cannot create an access token from an unreviewed request token.')
59 assert self.permission != OAuthPermission
60 'The user did not grant access to this consumer.')
61 + assert not self.is_expired, (
62 + 'This request token has expired and can no longer be exchanged '
63 + 'for an access token.'
64 + )
65 +
I think it's better to raise AssertionError with your text than it is to use the assert command. At least, that's the policy we've been trying to enforce.
Other than that, I think this branch is good.