Merge lp:~leonardr/launchpad/automatically-calculate-request-token-expire-time into lp:launchpad/db-devel

Proposed by Leonard Richardson
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
Reviewer Review Type Date Requested Status
Paul Hummer (community) Approve
Review via email: mp+38423@code.launchpad.net

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/launchpad/doc/oauth.txt into unit tests in the new test_oauth.py, but since this branch is subtle on its own I would rather do that in a separate branch.

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_TOKEN_VALIDITY hours. This is a site-wide constant, so there's no need to manually set the date that a request token expires--you can calculate it from REQUEST_TOKEN_VALIDITY and date_created.

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_TOKEN_VALIDITY is twelve hours. That's way too long. Users will either exchange a request token for an access token within a few minutes, or forget about it altogether.

My solution to problem #4 is to reduce REQUEST_TOKEN_VALIDITY to two hours. My solution to the first three problems is a little more complex.

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_TOKEN_VALIDITY. I have introduced an is_expired attribute to IOAuthToken, and separate implementations for OAuthRequestToken and OAuthAccessToken. I check token.is_expired whenever an access token is used to sign a request, and whenever an attempt is made to review a request token or exchange one for an access token.

This is the code tested by my unit tests, and this means that request tokens will in fact become unusable after REQUEST_TOKEN_VALIDITY hours. I do not do anything special to clean up expired tokens, but we weren't doing anything special before.

Second, I am taking date_expires out of IOAuthToken, and introducing it separately into the subclasses IOAuthRequestToken and IOAuthAccessToken. IOAuthRequestToken.date_expires is now the expiration date of the access token that will eventually be created from the request token--not anything to do with the request token itself. When the request token is exchanged for an access token, the IOAuthRequestToken.date_expires field is copied into IOAuthAccessToken.date_expires.

This is by direct analogy to IOAuthRequestToken.permission, which is the access level of the access token that will eventually be created from the request token--not anything to do with the request token itself. When a request token is exchanged for an access token, IOAuthRequestToken.permission becomes IOAuthAccessToken.permission.

I also split out IOAuthToken.date_created into the IOAuthRequestToken and IOAuthAccessToken, though the only difference is the description. It looked a little awkward to have the .date_created description say that .date_created is the date that a request token was created *or* the date an access token was created from some preexisting request token. It's easier to explain that separately for request and access tokens.

To post a comment you must log in.
Revision history for this message
Paul Hummer (rockstar) wrote :

37 @@ -254,10 +257,19 @@
38 else:
39 return None
40
41 + @property
42 + def is_expired(self):
43 + now = datetime.now(pytz.timezone('UTC'))
44 + expires = self.date_created + timedelta(hours=REQUEST_TOKEN_VALIDITY)
45 + return expires <= now
46 +
47 def review(self, user, permission, context=None):
48 """See `IOAuthRequestToken`."""
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.now(pytz.timezone('UTC'))
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.UNAUTHORIZED, (
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.

review: Approve

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-20 16:45:03 +0000
+++ lib/canonical/launchpad/browser/oauth.py 2010-10-19 14:17:46 +0000
@@ -16,6 +16,7 @@
16 Action,16 Action,
17 Actions,17 Actions,
18 )18 )
19from zope.security.interfaces import Unauthorized
1920
20from canonical.launchpad.interfaces.oauth import (21from canonical.launchpad.interfaces.oauth import (
21 IOAuthConsumerSet,22 IOAuthConsumerSet,
@@ -88,11 +89,13 @@
8889
89 token = consumer.newRequestToken()90 token = consumer.newRequestToken()
90 if self.request.headers.get('Accept') == HTTPResource.JSON_TYPE:91 if self.request.headers.get('Accept') == HTTPResource.JSON_TYPE:
91 # Don't show the client the GRANT_PERMISSIONS access92 # Don't show the client the DESKTOP_INTEGRATION access
92 # level. If they have a legitimate need to use it, they'll93 # level. If they have a legitimate need to use it, they'll
93 # already know about it.94 # already know about it.
94 permissions = [permission for permission in OAuthPermission.items95 permissions = [
95 if permission != OAuthPermission.GRANT_PERMISSIONS]96 permission for permission in OAuthPermission.items
97 if (permission != OAuthPermission.DESKTOP_INTEGRATION)
98 ]
96 return self.getJSONRepresentation(99 return self.getJSONRepresentation(
97 permissions, token, include_secret=True)100 permissions, token, include_secret=True)
98 return u'oauth_token=%s&oauth_token_secret=%s' % (101 return u'oauth_token=%s&oauth_token_secret=%s' % (
@@ -102,26 +105,36 @@
102 return form.token is not None and not form.token.is_reviewed105 return form.token is not None and not form.token.is_reviewed
103106
104107
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
105def create_oauth_permission_actions():113def create_oauth_permission_actions():
106 """Return a list of `Action`s for each possible `OAuthPermission`."""114 """Return two `Actions` objects containing each possible `OAuthPermission`.
107 actions = Actions()115
108 actions_excluding_grant_permissions = Actions()116 The first `Actions` object contains every action supported by the
109 def success(form, action, data):117 OAuthAuthorizeTokenView. The second list contains a good default
110 form.reviewToken(action.permission)118 set of actions, omitting special permissions like DESKTOP_INTEGRATION.
119 """
120 all_actions = Actions()
121 ordinary_actions = Actions()
111 for permission in OAuthPermission.items:122 for permission in OAuthPermission.items:
112 action = Action(123 action = Action(
113 permission.title, name=permission.name, success=success,124 permission.title, name=permission.name,
125 success=token_review_success,
114 condition=token_exists_and_is_not_reviewed)126 condition=token_exists_and_is_not_reviewed)
115 action.permission = permission127 action.permission = permission
116 actions.append(action)128 all_actions.append(action)
117 if permission != OAuthPermission.GRANT_PERMISSIONS:129 if permission != OAuthPermission.DESKTOP_INTEGRATION:
118 actions_excluding_grant_permissions.append(action)130 ordinary_actions.append(action)
119 return actions, actions_excluding_grant_permissions131 return all_actions, ordinary_actions
132
120133
121class OAuthAuthorizeTokenView(LaunchpadFormView, JSONTokenMixin):134class OAuthAuthorizeTokenView(LaunchpadFormView, JSONTokenMixin):
122 """Where users authorize consumers to access Launchpad on their behalf."""135 """Where users authorize consumers to access Launchpad on their behalf."""
123136
124 actions, actions_excluding_grant_permissions = (137 actions, actions_excluding_special_permissions = (
125 create_oauth_permission_actions())138 create_oauth_permission_actions())
126 label = "Authorize application to access Launchpad on your behalf"139 label = "Authorize application to access Launchpad on your behalf"
127 schema = IOAuthRequestToken140 schema = IOAuthRequestToken
@@ -130,7 +143,7 @@
130143
131 @property144 @property
132 def visible_actions(self):145 def visible_actions(self):
133 """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.
134147
135 Not all client programs can function with all levels of148 Not all client programs can function with all levels of
136 access. For instance, a client that needs to modify the149 access. For instance, a client that needs to modify the
@@ -150,7 +163,7 @@
150163
151 allowed_permissions = self.request.form_ng.getAll('allow_permission')164 allowed_permissions = self.request.form_ng.getAll('allow_permission')
152 if len(allowed_permissions) == 0:165 if len(allowed_permissions) == 0:
153 return self.actions_excluding_grant_permissions166 return self.actions_excluding_special_permissions
154 actions = Actions()167 actions = Actions()
155168
156 # UNAUTHORIZED is always one of the options. If the client169 # UNAUTHORIZED is always one of the options. If the client
@@ -159,18 +172,59 @@
159 if OAuthPermission.UNAUTHORIZED.name in allowed_permissions:172 if OAuthPermission.UNAUTHORIZED.name in allowed_permissions:
160 allowed_permissions.remove(OAuthPermission.UNAUTHORIZED.name)173 allowed_permissions.remove(OAuthPermission.UNAUTHORIZED.name)
161174
162 # GRANT_PERMISSIONS cannot be requested as one of several175 # DESKTOP_INTEGRATION cannot be requested as one of several
163 # options--it must be the only option (other than176 # options--it must be the only option (other than
164 # UNAUTHORIZED). If GRANT_PERMISSIONS is one of several177 # UNAUTHORIZED). If DESKTOP_INTEGRATION is one of several
165 # options, remove it from the list.178 # options, remove it from the list.
166 if (OAuthPermission.GRANT_PERMISSIONS.name in allowed_permissions179 desktop_permission = OAuthPermission.DESKTOP_INTEGRATION
180 if (desktop_permission.name in allowed_permissions
167 and len(allowed_permissions) > 1):181 and len(allowed_permissions) > 1):
168 allowed_permissions.remove(OAuthPermission.GRANT_PERMISSIONS.name)182 allowed_permissions.remove(desktop_permission.name)
169183
170 for action in self.actions:184 if desktop_permission.name in allowed_permissions:
171 if (action.permission.name in allowed_permissions185 if not self.token.consumer.is_integrated_desktop:
172 or action.permission is OAuthPermission.UNAUTHORIZED):186 # Consumers may only ask for desktop integration if
173 actions.append(action)187 # they give a desktop type (eg. "Ubuntu") and a
188 # user-recognizable desktop name (eg. the hostname).
189 raise Unauthorized(
190 ('Consumer "%s" asked for desktop integration, '
191 "but didn't say what kind of desktop it is, or name "
192 "the computer being integrated."
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 #
200 # Since self.actions is a descriptor that returns copies
201 # of Action objects, we can modify the actions we get
202 # in-place without ruining the Action objects for everyone
203 # else.
204 desktop_name = self.token.consumer.integrated_desktop_name
205 label = (
206 'Give all programs running on &quot;%s&quot; access '
207 'to my Launchpad account.')
208 allow_action = [
209 action for action in self.actions
210 if action.name == desktop_permission.name][0]
211 allow_action.label = label % desktop_name
212 actions.append(allow_action)
213
214 # We'll customize the "deny" message as well.
215 label = "No, thanks, I don't trust &quot;%s&quot;."
216 deny_action = [
217 action for action in self.actions
218 if action.name == OAuthPermission.UNAUTHORIZED.name][0]
219 deny_action.label = label % desktop_name
220 actions.append(deny_action)
221
222 else:
223 # We're going for web-based integration.
224 for action in self.actions_excluding_special_permissions:
225 if (action.permission.name in allowed_permissions
226 or action.permission is OAuthPermission.UNAUTHORIZED):
227 actions.append(action)
174228
175 if len(list(actions)) == 1:229 if len(list(actions)) == 1:
176 # The only visible action is UNAUTHORIZED. That means the230 # The only visible action is UNAUTHORIZED. That means the
@@ -179,8 +233,8 @@
179 # UNAUTHORIZED). Rather than present the end-user with an233 # UNAUTHORIZED). Rather than present the end-user with an
180 # impossible situation where their only option is to deny234 # impossible situation where their only option is to deny
181 # access, we'll present the full range of actions (except235 # access, we'll present the full range of actions (except
182 # for GRANT_PERMISSIONS).236 # for special permissions like DESKTOP_INTEGRATION).
183 return self.actions_excluding_grant_permissions237 return self.actions_excluding_special_permissions
184 return actions238 return actions
185239
186 def initialize(self):240 def initialize(self):
@@ -189,6 +243,31 @@
189 key = form.get('oauth_token')243 key = form.get('oauth_token')
190 if key:244 if key:
191 self.token = getUtility(IOAuthRequestTokenSet).getByKey(key)245 self.token = getUtility(IOAuthRequestTokenSet).getByKey(key)
246
247
248 callback = self.request.form.get('oauth_callback')
249 if (self.token is not None
250 and self.token.consumer.is_integrated_desktop):
251 # Nip problems in the bud by appling special rules about
252 # what desktop integrations are allowed to do.
253 if callback is not None:
254 # A desktop integration is not allowed to specify a callback.
255 raise Unauthorized(
256 "A desktop integration may not specify an "
257 "OAuth callback URL.")
258 # A desktop integration token can only have one of two
259 # permission levels: "Desktop Integration" and
260 # "Unauthorized". It shouldn't even be able to ask for any
261 # other level.
262 for action in self.visible_actions:
263 if action.permission not in (
264 OAuthPermission.DESKTOP_INTEGRATION,
265 OAuthPermission.UNAUTHORIZED):
266 raise Unauthorized(
267 ("Desktop integration token requested a permission "
268 '("%s") not supported for desktop-wide use.')
269 % action.label)
270
192 super(OAuthAuthorizeTokenView, self).initialize()271 super(OAuthAuthorizeTokenView, self).initialize()
193272
194 def render(self):273 def render(self):
195274
=== modified file 'lib/canonical/launchpad/database/oauth.py'
--- lib/canonical/launchpad/database/oauth.py 2010-10-03 15:30:06 +0000
+++ lib/canonical/launchpad/database/oauth.py 2010-10-19 14:17: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,
@@ -59,7 +60,7 @@
59from lp.registry.interfaces.projectgroup import IProjectGroup60from lp.registry.interfaces.projectgroup import IProjectGroup
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 = 2
63# The OAuth Core 1.0 spec (http://oauth.net/core/1.0/#nonce) says that a64# The OAuth Core 1.0 spec (http://oauth.net/core/1.0/#nonce) says that a
64# timestamp "MUST be equal or greater than the timestamp used in previous65# timestamp "MUST be equal or greater than the timestamp used in previous
65# requests," but this is likely to cause problems if the client does request66# requests," but this is likely to cause problems if the client does request
@@ -93,6 +94,7 @@
9394
94 getStore = _get_store95 getStore = _get_store
9596
97
96class OAuthConsumer(OAuthBase):98class OAuthConsumer(OAuthBase):
97 """See `IOAuthConsumer`."""99 """See `IOAuthConsumer`."""
98 implements(IOAuthConsumer)100 implements(IOAuthConsumer)
@@ -102,13 +104,56 @@
102 key = StringCol(notNull=True)104 key = StringCol(notNull=True)
103 secret = StringCol(notNull=False, default='')105 secret = StringCol(notNull=False, default='')
104106
107 # This regular expression singles out a consumer key that
108 # represents any and all apps running on a specific computer
109 # (usually a desktop). For instance:
110 #
111 # System-wide: Ubuntu desktop (hostname1)
112 # - An Ubuntu desktop called "hostname1"
113 # System-wide: Windows desktop (Computer Name)
114 # - A Windows desktop called "Computer Name"
115 # System-wide: Mac OS desktop (hostname2)
116 # - A Macintosh desktop called "hostname2"
117 # System-wide Android phone (Bob's Phone)
118 # - An Android phone called "Bob's Phone"
119 integrated_desktop_re = re.compile("^System-wide: (.*) \(([^)]*)\)$")
120
121 def _integrated_desktop_match_group(self, position):
122 """Return information about a desktop integration token.
123
124 A convenience method that runs the desktop integration regular
125 expression against the consumer key.
126
127 :param position: The match group to return if the regular
128 expression matches.
129
130 :return: The value of one of the match groups, or None.
131 """
132 match = self.integrated_desktop_re.match(self.key)
133 if match is None:
134 return None
135 return match.groups()[position]
136
137 @property
138 def is_integrated_desktop(self):
139 """See `IOAuthConsumer`."""
140 return self.integrated_desktop_re.match(self.key) is not None
141
142 @property
143 def integrated_desktop_type(self):
144 """See `IOAuthConsumer`."""
145 return self._integrated_desktop_match_group(0)
146
147 @property
148 def integrated_desktop_name(self):
149 """See `IOAuthConsumer`."""
150 return self._integrated_desktop_match_group(1)
151
105 def newRequestToken(self):152 def newRequestToken(self):
106 """See `IOAuthConsumer`."""153 """See `IOAuthConsumer`."""
107 key, secret = create_token_key_and_secret(table=OAuthRequestToken)154 key, secret = create_token_key_and_secret(table=OAuthRequestToken)
108 date_expires = (datetime.now(pytz.timezone('UTC'))
109 + timedelta(hours=REQUEST_TOKEN_VALIDITY))
110 return OAuthRequestToken(155 return OAuthRequestToken(
111 consumer=self, key=key, secret=secret, date_expires=date_expires)156 consumer=self, key=key, secret=secret)
112157
113 def getAccessToken(self, key):158 def getAccessToken(self, key):
114 """See `IOAuthConsumer`."""159 """See `IOAuthConsumer`."""
@@ -177,6 +222,11 @@
177 else:222 else:
178 return None223 return None
179224
225 @property
226 def is_expired(self):
227 now = datetime.now(pytz.timezone('UTC'))
228 return self.date_expires is not None and self.date_expires <= now
229
180 def checkNonceAndTimestamp(self, nonce, timestamp):230 def checkNonceAndTimestamp(self, nonce, timestamp):
181 """See `IOAuthAccessToken`."""231 """See `IOAuthAccessToken`."""
182 timestamp = float(timestamp)232 timestamp = float(timestamp)
@@ -254,10 +304,21 @@
254 else:304 else:
255 return None305 return None
256306
307 @property
308 def is_expired(self):
309 now = datetime.now(pytz.timezone('UTC'))
310 expires = self.date_created + timedelta(hours=REQUEST_TOKEN_VALIDITY)
311 return expires <= now
312
257 def review(self, user, permission, context=None):313 def review(self, user, permission, context=None):
258 """See `IOAuthRequestToken`."""314 """See `IOAuthRequestToken`."""
259 assert not self.is_reviewed, (315 if self.is_reviewed:
260 "Request tokens can be reviewed only once.")316 raise AssertionError(
317 "Request tokens can be reviewed only once.")
318 if self.is_expired:
319 raise AssertionError(
320 'This request token has expired and can no longer be '
321 'reviewed.')
261 self.date_reviewed = datetime.now(pytz.timezone('UTC'))322 self.date_reviewed = datetime.now(pytz.timezone('UTC'))
262 self.person = user323 self.person = user
263 self.permission = permission324 self.permission = permission
@@ -275,10 +336,18 @@
275336
276 def createAccessToken(self):337 def createAccessToken(self):
277 """See `IOAuthRequestToken`."""338 """See `IOAuthRequestToken`."""
278 assert self.is_reviewed, (339 if not self.is_reviewed:
279 'Cannot create an access token from an unreviewed request token.')340 raise AssertionError(
280 assert self.permission != OAuthPermission.UNAUTHORIZED, (341 'Cannot create an access token from an unreviewed request '
281 'The user did not grant access to this consumer.')342 'token.')
343 if self.permission == OAuthPermission.UNAUTHORIZED:
344 raise AssertionError(
345 'The user did not grant access to this consumer.')
346 if self.is_expired:
347 raise AssertionError(
348 'This request token has expired and can no longer be '
349 'exchanged for an access token.')
350
282 key, secret = create_token_key_and_secret(table=OAuthAccessToken)351 key, secret = create_token_key_and_secret(table=OAuthAccessToken)
283 access_level = AccessLevel.items[self.permission.name]352 access_level = AccessLevel.items[self.permission.name]
284 access_token = OAuthAccessToken(353 access_token = OAuthAccessToken(
285354
=== modified file 'lib/canonical/launchpad/doc/oauth.txt'
--- lib/canonical/launchpad/doc/oauth.txt 2010-10-09 16:36:22 +0000
+++ lib/canonical/launchpad/doc/oauth.txt 2010-10-19 14:17:46 +0000
@@ -38,6 +38,45 @@
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 (or other
46personal computer), using multiple desktop applications that integrate
47with Launchpad, is represented by a single consumer key. The user's
48session as a whole is a single "consumer", and the consumer key is
49expected to contain structured information: the type of system
50(usually the operating system plus the word "desktop") and a string
51that the end-user would recognize as identifying their computer.
52
53 >>> desktop_key = consumer_set.new(
54 ... "System-wide: Ubuntu desktop (hostname)")
55 >>> desktop_key.is_integrated_desktop
56 True
57 >>> print desktop_key.integrated_desktop_type
58 Ubuntu desktop
59 >>> print desktop_key.integrated_desktop_name
60 hostname
61
62 >>> desktop_key = consumer_set.new(
63 ... "System-wide: Android phone (My Phone)")
64 >>> desktop_key.is_integrated_desktop
65 True
66 >>> print desktop_key.integrated_desktop_type
67 Android phone
68 >>> print desktop_key.integrated_desktop_name
69 My Phone
70
71A normal OAuth consumer does not have this information.
72
73 >>> ordinary_key = consumer_set.new("Not a desktop at all.")
74 >>> ordinary_key.is_integrated_desktop
75 False
76 >>> print ordinary_key.integrated_desktop_type
77 None
78 >>> print ordinary_key.integrated_desktop_name
79 None
4180
42Request tokens81Request tokens
43==============82==============
4483
=== modified file 'lib/canonical/launchpad/doc/webapp-authorization.txt'
--- lib/canonical/launchpad/doc/webapp-authorization.txt 2010-10-09 16:36:22 +0000
+++ lib/canonical/launchpad/doc/webapp-authorization.txt 2010-10-19 14:17: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-10-19 14:17:46 +0000
@@ -64,12 +64,26 @@
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 or similar device.""")
70
71 integrated_desktop_name = Attribute(
72 """If the consumer corresponds to a user account on a personal
73 computer or similar device, this is the self-reported name of
74 the computer. If the consumer is a specific web or desktop
75 application, this is None.""")
76
77 integrated_desktop_type = Attribute(
78 """If the consumer corresponds to a user account on a personal
79 computer or similar device, this is the self-reported type of
80 that computer (usually the operating system plus the word
81 "desktop"). If the consumer is a specific web or desktop
82 application, this is None.""")
83
67 def newRequestToken():84 def newRequestToken():
68 """Return a new `IOAuthRequestToken` with a random key and secret.85 """Return a new `IOAuthRequestToken` with a random key and secret.
6986
70 Also sets the token's date_expires to `REQUEST_TOKEN_VALIDITY` hours
71 from the creation date (now).
72
73 The other attributes of the token are supposed to be set whenever the87 The other attributes of the token are supposed to be set whenever the
74 user logs into Launchpad and grants (or not) access to this consumer.88 user logs into Launchpad and grants (or not) access to this consumer.
75 """89 """
@@ -131,12 +145,6 @@
131 person = Object(145 person = Object(
132 schema=IPerson, title=_('Person'), required=False, readonly=False,146 schema=IPerson, title=_('Person'), required=False, readonly=False,
133 description=_('The user on whose behalf the consumer is accessing.'))147 description=_('The user on whose behalf the consumer is accessing.'))
134 date_created = Datetime(
135 title=_('Date created'), required=True, readonly=True)
136 date_expires = Datetime(
137 title=_('Date expires'), required=False, readonly=False,
138 description=_('From this date onwards this token can not be used '
139 'by the consumer to access protected resources.'))
140 key = TextLine(148 key = TextLine(
141 title=_('Key'), required=True, readonly=True,149 title=_('Key'), required=True, readonly=True,
142 description=_('The key used to identify this token. It is included '150 description=_('The key used to identify this token. It is included '
@@ -154,6 +162,12 @@
154 title=_("Distribution"), required=False, vocabulary='Distribution')162 title=_("Distribution"), required=False, vocabulary='Distribution')
155 context = Attribute("FIXME")163 context = Attribute("FIXME")
156164
165 is_expired = Bool(
166 title=_("Whether or not this token has expired."),
167 required=False, readonly=True,
168 description=_("A token may only be usable for a limited time, "
169 "after which it will expire."))
170
157171
158class IOAuthAccessToken(IOAuthToken):172class IOAuthAccessToken(IOAuthToken):
159 """A token used by a consumer to access protected resources in LP.173 """A token used by a consumer to access protected resources in LP.
@@ -168,6 +182,16 @@
168 description=_('The level of access given to the application acting '182 description=_('The level of access given to the application acting '
169 'on your behalf.'))183 'on your behalf.'))
170184
185 date_created = Datetime(
186 title=_('Date created'), required=True, readonly=True,
187 description=_('The date some request token was exchanged for '
188 'this token.'))
189
190 date_expires = Datetime(
191 title=_('Date expires'), required=False, readonly=False,
192 description=_('From this date onwards this token can not be used '
193 'by the consumer to access protected resources.'))
194
171 def checkNonceAndTimestamp(nonce, timestamp):195 def checkNonceAndTimestamp(nonce, timestamp):
172 """Verify the nonce and timestamp.196 """Verify the nonce and timestamp.
173197
@@ -202,11 +226,22 @@
202 vocabulary=OAuthPermission,226 vocabulary=OAuthPermission,
203 description=_('The permission you give to the application which may '227 description=_('The permission you give to the application which may '
204 'act on your behalf.'))228 'act on your behalf.'))
229 date_created = Datetime(
230 title=_('Date created'), required=True, readonly=True,
231 description=_('The date the token was created. The request token '
232 'will be good for a limited time after this date.'))
233
234 date_expires = Datetime(
235 title=_('Date expires'), required=False, readonly=False,
236 description=_('The expiration date for the permission you give to '
237 'the application which may act on your behalf.'))
238
205 date_reviewed = Datetime(239 date_reviewed = Datetime(
206 title=_('Date reviewed'), required=True, readonly=True,240 title=_('Date reviewed'), required=True, readonly=True,
207 description=_('The date in which the user authorized (or not) the '241 description=_('The date in which the user authorized (or not) the '
208 'consumer to access his protected resources on '242 'consumer to access his protected resources on '
209 'Launchpad.'))243 'Launchpad.'))
244
210 is_reviewed = Bool(245 is_reviewed = Bool(
211 title=_('Has this token been reviewed?'),246 title=_('Has this token been reviewed?'),
212 required=False, readonly=True,247 required=False, readonly=True,
213248
=== modified file 'lib/canonical/launchpad/pagetests/oauth/authorize-token.txt'
--- lib/canonical/launchpad/pagetests/oauth/authorize-token.txt 2010-10-09 16:36:22 +0000
+++ lib/canonical/launchpad/pagetests/oauth/authorize-token.txt 2010-10-19 14:17:46 +0000
@@ -39,32 +39,29 @@
3939
40 >>> main_content = find_tag_by_id(browser.contents, 'maincontent')40 >>> main_content = find_tag_by_id(browser.contents, 'maincontent')
41 >>> print extract_text(main_content)41 >>> print extract_text(main_content)
42 Integrating foobar123451432 into your Launchpad account
42 The application identified as foobar123451432 wants to access Launchpad on43 The application identified as foobar123451432 wants to access Launchpad on
43 your behalf. What level of access do you want to grant?44 your behalf. What level of access do you want to grant?
44 ...45 ...
45 See all applications authorized to access Launchpad on your behalf.46 See all applications authorized to access Launchpad on your behalf.
4647
47This page contains one submit button for each item of OAuthPermission,48This page contains one submit button for each item of OAuthPermission,
48except for 'Grant Permissions', which must be specifically requested.49except for 'Desktop Integration', which must be specifically requested.
4950
50 >>> browser.getControl('No Access')51 >>> def print_access_levels(main_content):
51 <SubmitControl...52 ... actions = main_content.findAll('input', attrs={'type': 'submit'})
52 >>> browser.getControl('Read Non-Private Data')53 ... for action in actions:
53 <SubmitControl...54 ... print action['value']
54 >>> browser.getControl('Change Non-Private Data')55
55 <SubmitControl...56 >>> print_access_levels(main_content)
56 >>> browser.getControl('Read Anything')57 No Access
57 <SubmitControl...58 Read Non-Private Data
58 >>> browser.getControl('Change Anything')59 Change Non-Private Data
59 <SubmitControl...60 Read Anything
6061 Change Anything
61 >>> browser.getControl('Grant Permissions')62
62 Traceback (most recent call last):63 >>> from canonical.launchpad.webapp.interfaces import OAuthPermission
63 ...
64 LookupError: label 'Grant Permissions'
65
66 >>> actions = main_content.findAll('input', attrs={'type': 'submit'})64 >>> actions = main_content.findAll('input', attrs={'type': 'submit'})
67 >>> from canonical.launchpad.webapp.interfaces import OAuthPermission
68 >>> len(actions) == len(OAuthPermission.items) - 165 >>> len(actions) == len(OAuthPermission.items) - 1
69 True66 True
7067
@@ -74,57 +71,53 @@
74that isn't enough for the application. The user always has the option71that isn't enough for the application. The user always has the option
75to deny permission altogether.72to deny permission altogether.
7673
77 >>> def print_access_levels(allow_permission):74 >>> def authorize_token_main_content(allow_permission):
78 ... browser.open(75 ... browser.open(
79 ... "http://launchpad.dev/+authorize-token?%s&%s"76 ... "http://launchpad.dev/+authorize-token?%s&%s"
80 ... % (urlencode(params), allow_permission))77 ... % (urlencode(params), allow_permission))
81 ... main_content = find_tag_by_id(browser.contents, 'maincontent')78 ... return find_tag_by_id(browser.contents, 'maincontent')
82 ... actions = main_content.findAll('input', attrs={'type': 'submit'})79
83 ... for action in actions:80 >>> def print_access_levels_for(allow_permission):
84 ... print action['value']81 ... main_content = authorize_token_main_content(allow_permission)
8582 ... print_access_levels(main_content)
86 >>> print_access_levels(83
84 >>> print_access_levels_for(
87 ... 'allow_permission=WRITE_PUBLIC&allow_permission=WRITE_PRIVATE')85 ... 'allow_permission=WRITE_PUBLIC&allow_permission=WRITE_PRIVATE')
88 No Access86 No Access
89 Change Non-Private Data87 Change Non-Private Data
90 Change Anything88 Change Anything
9189
92The only time the 'Grant Permissions' permission shows up in this list
93is if the client specifically requests it, and no other
94permission. (Also requesting UNAUTHORIZED is okay--it will show up
95anyway.)
96
97 >>> print_access_levels('allow_permission=GRANT_PERMISSIONS')
98 No Access
99 Grant Permissions
100
101 >>> print_access_levels(
102 ... 'allow_permission=GRANT_PERMISSIONS&allow_permission=UNAUTHORIZED')
103 No Access
104 Grant Permissions
105
106 >>> print_access_levels(
107 ... 'allow_permission=WRITE_PUBLIC&allow_permission=GRANT_PERMISSIONS')
108 No Access
109 Change Non-Private Data
110
111If an application doesn't specify any valid access levels, or only90If an application doesn't specify any valid access levels, or only
112specifies the UNAUTHORIZED access level, Launchpad will show all the91specifies the UNAUTHORIZED access level, Launchpad will show all the
113access levels, except for GRANT_PERMISSIONS.92access levels, except for DESKTOP_INTEGRATION.
11493
115 >>> print_access_levels('')94 >>> print_access_levels_for('')
116 No Access95 No Access
117 Read Non-Private Data96 Read Non-Private Data
118 Change Non-Private Data97 Change Non-Private Data
119 Read Anything98 Read Anything
120 Change Anything99 Change Anything
121100
122 >>> print_access_levels('allow_permission=UNAUTHORIZED')101 >>> print_access_levels_for('allow_permission=UNAUTHORIZED')
123 No Access102 No Access
124 Read Non-Private Data103 Read Non-Private Data
125 Change Non-Private Data104 Change Non-Private Data
126 Read Anything105 Read Anything
127 Change Anything106 Change Anything
107
108An application may not request the DESKTOP_INTEGRATION access level
109unless its consumer key matches a certain pattern. (Successful desktop
110integration has its own section, below.)
111
112 >>> allow_permission = "allow_permission=DESKTOP_INTEGRATION"
113 >>> browser.open(
114 ... "http://launchpad.dev/+authorize-token?%s&%s"
115 ... % (urlencode(params), allow_permission))
116 Traceback (most recent call last):
117 ...
118 Unauthorized: Consumer "foobar123451432" asked for desktop
119 integration, but didn't say what kind of desktop it is, or name
120 the computer being integrated.
128121
129An application may also specify a context, so that the access granted122An application may also specify a context, so that the access granted
130by the user is restricted to things related to that context.123by the user is restricted to things related to that context.
@@ -136,6 +129,7 @@
136 ... % urlencode(params_with_context))129 ... % urlencode(params_with_context))
137 >>> main_content = find_tag_by_id(browser.contents, 'maincontent')130 >>> main_content = find_tag_by_id(browser.contents, 'maincontent')
138 >>> print extract_text(main_content)131 >>> print extract_text(main_content)
132 Integrating foobar123451432 into your Launchpad account
139 The application...wants to access things related to Mozilla Firefox...133 The application...wants to access things related to Mozilla Firefox...
140134
141A client other than a web browser may request a JSON representation of135A client other than a web browser may request a JSON representation of
@@ -263,3 +257,95 @@
263 This request for accessing Launchpad on your behalf has been257 This request for accessing Launchpad on your behalf has been
264 reviewed ... ago.258 reviewed ... ago.
265 See all applications authorized to access Launchpad on your behalf.259 See all applications authorized to access Launchpad on your behalf.
260
261Desktop integration
262===================
263
264The test case given above shows how to integrate a single application
265or website into Launchpad. But it's also possible to integrate an
266entire desktop environment into Launchpad.
267
268The desktop integration option is only available for OAuth consumers
269that say what kind of desktop they are (eg. Ubuntu) and give a name
270that a user can identify with their computer (eg. the hostname). Here,
271we'll create such a token.
272
273 >>> login('salgado@ubuntu.com')
274 >>> desktop_key = "System-wide: Ubuntu desktop (mycomputer)"
275 >>> consumer = getUtility(IOAuthConsumerSet).new(desktop_key)
276 >>> token = consumer.newRequestToken()
277 >>> logout()
278
279When a desktop tries to integrate with Launchpad, the user gets a
280special warning about giving access to every program running on their
281desktop.
282
283 >>> params = dict(oauth_token=token.key)
284 >>> print extract_text(
285 ... authorize_token_main_content(
286 ... 'allow_permission=DESKTOP_INTEGRATION'))
287 Integrating mycomputer into your Launchpad account
288 The Ubuntu desktop called mycomputer wants access to your
289 Launchpad account. If you allow the integration, all applications
290 running on mycomputer will have read-write access to your
291 Launchpad account, including to your private data.
292 If you're using a public computer, if mycomputer is not the
293 computer you're using right now, or if something just doesn't feel
294 right about this situation, you should choose "No, thanks, I don't
295 trust 'mycomputer'", or close this window now. You can always try
296 again later.
297 Even if you decide to allow the integration, you can change your
298 mind later.
299 See all applications authorized to access Launchpad on your behalf.
300
301
302The only time the 'Desktop Integration' permission shows up in the
303list of permissions is if the client specifically requests it, and no
304other permission. (Also requesting UNAUTHORIZED is okay--it will show
305up anyway.)
306
307 >>> print_access_levels_for('allow_permission=DESKTOP_INTEGRATION')
308 Give all programs running on "mycomputer" access to my Launchpad account.
309 No, thanks, I don't trust "mycomputer".
310
311 >>> print_access_levels_for(
312 ... 'allow_permission=DESKTOP_INTEGRATION&allow_permission=UNAUTHORIZED')
313 Give all programs running on "mycomputer" access to my Launchpad account.
314 No, thanks, I don't trust "mycomputer".
315
316A desktop may not request a level of access other than
317DESKTOP_INTEGRATION, since the whole point is to have a permission
318level that specifically applies across the entire desktop.
319
320 >>> print_access_levels_for('allow_permission=WRITE_PRIVATE')
321 Traceback (most recent call last):
322 ...
323 Unauthorized: Desktop integration token requested a permission
324 ("Change Anything") not supported for desktop-wide use.
325
326 >>> print_access_levels_for(
327 ... 'allow_permission=WRITE_PUBLIC&allow_permission=DESKTOP_INTEGRATION')
328 Traceback (most recent call last):
329 ...
330 Unauthorized: Desktop integration token requested a permission
331 ("Change Non-Private Data") not supported for desktop-wide use.
332
333You can't specify a callback URL when authorizing a desktop-wide
334token, since callback URLs should only be used when integrating
335websites into Launchpad.
336
337 >>> params['oauth_callback'] = 'http://launchpad.dev/bzr'
338 >>> print_access_levels_for('allow_permission=DESKTOP_INTEGRATION')
339 Traceback (most recent call last):
340 ...
341 Unauthorized: A desktop integration may not specify an OAuth
342 callback URL.
343
344This is true even if the desktop token isn't asking for the
345DESKTOP_INTEGRATION permission.
346
347 >>> print_access_levels_for('allow_permission=WRITE_PRIVATE')
348 Traceback (most recent call last):
349 ...
350 Unauthorized: A desktop integration may not specify an OAuth
351 callback URL.
266352
=== 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-10-19 14:17:46 +0000
@@ -21,28 +21,69 @@
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 <h1>Integrating
27 <tal:has-context condition="view/token_context">27 <tal:hostname replace="structure
28 things related to28 token/consumer/integrated_desktop_name" />
29 <strong tal:content="view/token_context/title">Context</strong>29 into your Launchpad account</h1>
30 in30 <p>The
31 </tal:has-context>31 <tal:desktop replace="structure
32 Launchpad on your behalf. What level of access32 token/consumer/integrated_desktop_type" />
33 do you want to grant?</p>33 called
3434 <strong tal:content="token/consumer/integrated_desktop_name">hostname</strong>
35 <table>35 wants access to your Launchpad account. If you allow the
36 <tr tal:repeat="action view/visible_actions">36 integration, all applications running
37 <td style="text-align: right">37 on <strong tal:content="token/consumer/integrated_desktop_name">hostname</strong>
38 <tal:action replace="structure action/render" />38 will have read-write access to your Launchpad account,
39 </td>39 including to your private data.</p>
40 <td>40
41 <span class="lesser"41 <p>If you're using a public computer, if
42 tal:content="action/permission/description" />42 <strong tal:content="token/consumer/integrated_desktop_name">hostname</strong>
43 </td>43 is not the computer you're using right now, or if
44 </tr>44 something just doesn't feel right about this situation,
45 </table>45 you should choose "No, thanks, I don't trust
46 '<tal:hostname replace="structure
47 token/consumer/integrated_desktop_name" />'",
48 or close this window now. You can always try
49 again later.</p>
50
51 <p>Even if you decide to allow the integration, you can
52 change your mind later.</p>
53 </tal:desktop-integration-token>
54
55 <tal:web-integration-token condition="not:token/consumer/is_integrated_desktop">
56 <h1>Integrating
57 <tal:hostname replace="structure token/consumer/key" />
58 into your Launchpad account</h1>
59
60 <p>The application identified as
61 <strong tal:content="token/consumer/key">consumer</strong>
62 wants to access
63 <tal:has-context condition="view/token_context">
64 things related to
65 <strong tal:content="view/token_context/title">Context</strong>
66 in
67 </tal:has-context>
68 Launchpad on your behalf. What level of access
69 do you want to grant?</p>
70 </tal:web-integration-token>
71
72 <table>
73 <tr tal:repeat="action view/visible_actions">
74 <td style="text-align: right">
75 <tal:action replace="structure action/render" />
76 </td>
77
78 <tal:web-integration-token
79 condition="not:token/consumer/is_integrated_desktop">
80 <td>
81 <span class="lesser"
82 tal:content="action/permission/description" />
83 </td>
84 </tal:web-integration-token>
85 </tr>
86 </table>
46 </div>87 </div>
4788
48 <div metal:fill-slot="extra_bottom">89 <div metal:fill-slot="extra_bottom">
4990
=== added file 'lib/canonical/launchpad/tests/test_oauth_tokens.py'
--- lib/canonical/launchpad/tests/test_oauth_tokens.py 1970-01-01 00:00:00 +0000
+++ lib/canonical/launchpad/tests/test_oauth_tokens.py 2010-10-19 14:17:46 +0000
@@ -0,0 +1,47 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4from datetime import (
5 datetime,
6 timedelta
7 )
8import pytz
9
10from canonical.launchpad.webapp.interfaces import OAuthPermission
11from canonical.testing.layers import DatabaseFunctionalLayer
12
13from lp.testing import (
14 TestCaseWithFactory,
15 )
16
17
18class TestRequestTokens(TestCaseWithFactory):
19
20 layer = DatabaseFunctionalLayer
21
22 def setUp(self):
23 """Set up a dummy person and OAuth consumer."""
24 super(TestRequestTokens, self).setUp()
25
26 self.person = self.factory.makePerson()
27 self.consumer = self.factory.makeOAuthConsumer()
28
29 now = datetime.now(pytz.timezone('UTC'))
30 self.a_long_time_ago = now - timedelta(hours=1000)
31
32 def testExpiredRequestTokenCantBeReviewed(self):
33 """An expired request token can't be reviewed."""
34 token = self.factory.makeOAuthRequestToken(
35 date_created=self.a_long_time_ago)
36 self.assertRaises(
37 AssertionError, token.review, self.person,
38 OAuthPermission.WRITE_PUBLIC)
39
40 def testExpiredRequestTokenCantBeExchanged(self):
41 """An expired request token can't be exchanged for an access token.
42
43 This can only happen if the token was reviewed before it expired.
44 """
45 token = self.factory.makeOAuthRequestToken(
46 date_created=self.a_long_time_ago, reviewed_by=self.person)
47 self.assertRaises(AssertionError, token.createAccessToken)
048
=== 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-10-19 14:17: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-24 09:42:01 +0000
+++ lib/canonical/launchpad/webapp/interfaces.py 2010-10-19 14:17:46 +0000
@@ -531,14 +531,13 @@
531 for reading and changing anything, including private data.531 for reading and changing anything, including private data.
532 """)532 """)
533533
534 GRANT_PERMISSIONS = DBItem(60, """534 DESKTOP_INTEGRATION = DBItem(60, """
535 Grant Permissions535 Desktop Integration
536536
537 The application will be able to grant access to your Launchpad537 Every application running on your desktop will have read-write
538 account to any other application. This is a very powerful538 access to your Launchpad account, including to your private
539 level of access. You should not grant this level of access to539 data. You should not allow this unless you trust the computer
540 any application except the official Launchpad credential540 you're using right now.
541 manager.
542 """)541 """)
543542
544class AccessLevel(DBEnumeratedType):543class AccessLevel(DBEnumeratedType):
545544
=== modified file 'lib/canonical/launchpad/webapp/servers.py'
--- lib/canonical/launchpad/webapp/servers.py 2010-09-28 07:00:56 +0000
+++ lib/canonical/launchpad/webapp/servers.py 2010-10-19 14:17:46 +0000
@@ -8,7 +8,6 @@
8__metaclass__ = type8__metaclass__ = type
99
10import cgi10import cgi
11from datetime import datetime
12import threading11import threading
13import xmlrpclib12import xmlrpclib
1413
@@ -1277,10 +1276,9 @@
1277 token.checkNonceAndTimestamp(nonce, timestamp)1276 token.checkNonceAndTimestamp(nonce, timestamp)
1278 except (NonceAlreadyUsed, TimestampOrderingError, ClockSkew), e:1277 except (NonceAlreadyUsed, TimestampOrderingError, ClockSkew), e:
1279 raise Unauthorized('Invalid nonce/timestamp: %s' % e)1278 raise Unauthorized('Invalid nonce/timestamp: %s' % e)
1280 now = datetime.now(pytz.timezone('UTC'))
1281 if token.permission == OAuthPermission.UNAUTHORIZED:1279 if token.permission == OAuthPermission.UNAUTHORIZED:
1282 raise Unauthorized('Unauthorized token (%s).' % token.key)1280 raise Unauthorized('Unauthorized token (%s).' % token.key)
1283 elif token.date_expires is not None and token.date_expires <= now:1281 elif token.is_expired:
1284 raise Unauthorized('Expired token (%s).' % token.key)1282 raise Unauthorized('Expired token (%s).' % token.key)
1285 elif not check_oauth_signature(request, consumer, token):1283 elif not check_oauth_signature(request, consumer, token):
1286 raise Unauthorized('Invalid signature.')1284 raise Unauthorized('Invalid signature.')
12871285
=== modified file 'lib/lp/archiveuploader/nascentuploadfile.py'
--- lib/lp/archiveuploader/nascentuploadfile.py 2010-10-02 11:41:43 +0000
+++ lib/lp/archiveuploader/nascentuploadfile.py 2010-10-19 14:17:46 +0000
@@ -50,8 +50,8 @@
50from lp.soyuz.enums import (50from lp.soyuz.enums import (
51 BinaryPackageFormat,51 BinaryPackageFormat,
52 PackagePublishingPriority,52 PackagePublishingPriority,
53 PackagePublishingStatus,
53 PackageUploadCustomFormat,54 PackageUploadCustomFormat,
54 PackageUploadStatus,
55 )55 )
56from lp.soyuz.interfaces.binarypackagename import IBinaryPackageNameSet56from lp.soyuz.interfaces.binarypackagename import IBinaryPackageNameSet
57from lp.soyuz.interfaces.component import IComponentSet57from lp.soyuz.interfaces.component import IComponentSet
@@ -781,11 +781,9 @@
781 # Database relationship methods781 # Database relationship methods
782 #782 #
783 def findSourcePackageRelease(self):783 def findSourcePackageRelease(self):
784 """Return the respective ISourcePackagRelease for this binary upload.784 """Return the respective ISourcePackageRelease for this binary upload.
785785
786 It inspect publication in the targeted DistroSeries and also the786 It inspect publication in the targeted DistroSeries.
787 ACCEPTED queue for sources matching stored
788 (source_name, source_version).
789787
790 It raises UploadError if the source was not found.788 It raises UploadError if the source was not found.
791789
@@ -793,43 +791,19 @@
793 mixed_uploads (source + binary) we do not have the source stored791 mixed_uploads (source + binary) we do not have the source stored
794 in DB yet (see verifySourcepackagerelease).792 in DB yet (see verifySourcepackagerelease).
795 """793 """
794 assert self.source_name is not None
795 assert self.source_version is not None
796 distroseries = self.policy.distroseries796 distroseries = self.policy.distroseries
797 spphs = distroseries.getPublishedSources(797 spphs = distroseries.getPublishedSources(
798 self.source_name, version=self.source_version,798 self.source_name, version=self.source_version,
799 include_pending=True, archive=self.policy.archive)799 include_pending=True, archive=self.policy.archive)
800800
801 sourcepackagerelease = None801 if spphs.count() == 0:
802 if spphs:
803 # We know there's only going to be one release because
804 # version is unique.
805 assert spphs.count() == 1, "Duplicated ancestry"
806 sourcepackagerelease = spphs[0].sourcepackagerelease
807 else:
808 # XXX cprov 2006-08-09 bug=55774: Building from ACCEPTED is
809 # special condition, not really used in production. We should
810 # remove the support for this use case.
811 self.logger.debug(
812 "No source published, checking the ACCEPTED queue")
813
814 queue_candidates = distroseries.getQueueItems(
815 status=PackageUploadStatus.ACCEPTED,
816 name=self.source_name, version=self.source_version,
817 archive=self.policy.archive, exact_match=True)
818
819 for queue_item in queue_candidates:
820 if queue_item.sources.count():
821 sourcepackagerelease = queue_item.sourcepackagerelease
822
823 if sourcepackagerelease is None:
824 # At this point, we can't really do much more to try
825 # building this package. If we look in the NEW queue it is
826 # possible that multiple versions of the package exist there
827 # and we know how bad that can be. Time to give up!
828 raise UploadError(802 raise UploadError(
829 "Unable to find source package %s/%s in %s" % (803 "Unable to find source package %s/%s in %s" % (
830 self.source_name, self.source_version, distroseries.name))804 self.source_name, self.source_version, distroseries.name))
831805
832 return sourcepackagerelease806 return spphs[0].sourcepackagerelease
833807
834 def verifySourcePackageRelease(self, sourcepackagerelease):808 def verifySourcePackageRelease(self, sourcepackagerelease):
835 """Check if the given ISourcePackageRelease matches the context."""809 """Check if the given ISourcePackageRelease matches the context."""
836810
=== modified file 'lib/lp/archiveuploader/tests/nascentupload-epoch-handling.txt'
--- lib/lp/archiveuploader/tests/nascentupload-epoch-handling.txt 2009-04-17 10:32:16 +0000
+++ lib/lp/archiveuploader/tests/nascentupload-epoch-handling.txt 2010-10-19 14:17:46 +0000
@@ -208,6 +208,14 @@
208 >>> bar_spr.version208 >>> bar_spr.version
209 u'1:1.0-9'209 u'1:1.0-9'
210210
211 >>> from lp.registry.interfaces.pocket import PackagePublishingPocket
212 >>> from lp.soyuz.interfaces.publishing import IPublishingSet
213 >>> getUtility(IPublishingSet).newSourcePublication(
214 ... bar_src_upload.policy.distro.main_archive, bar_spr,
215 ... bar_src_upload.policy.distroseries, bar_spr.component,
216 ... bar_spr.section, PackagePublishingPocket.RELEASE)
217 <SourcePackagePublishingHistory at ...>
218
211Let's accept the source and claim 'build from accepted' to process the219Let's accept the source and claim 'build from accepted' to process the
212respective binary:220respective binary:
213221
@@ -217,9 +225,6 @@
217 >>> bar_src_queue.status.name225 >>> bar_src_queue.status.name
218 'ACCEPTED'226 'ACCEPTED'
219227
220 >>> from canonical.launchpad.ftests import syncUpdate
221 >>> syncUpdate(bar_src_queue)
222
223For a binary upload we expect the same, a BinaryPackageRelease228For a binary upload we expect the same, a BinaryPackageRelease
224'version' that includes 'epoch':229'version' that includes 'epoch':
225230
226231
=== modified file 'lib/lp/archiveuploader/tests/nascentupload.txt'
--- lib/lp/archiveuploader/tests/nascentupload.txt 2010-10-14 18:42:19 +0000
+++ lib/lp/archiveuploader/tests/nascentupload.txt 2010-10-19 14:17:46 +0000
@@ -484,86 +484,6 @@
484 1484 1
485485
486486
487=== Building From ACCEPTED queue ===
488
489XXX cprov 20060728: Building from ACCEPTED is special condition, not
490really used in production. We should remove the support for this use
491case, see further info in bug #55774.
492
493Next we send in the binaries, since the source should be in ACCEPTED the
494binary should go straight there too. Note that we are not specifying
495any build, so it should be created appropriately, it is what happens
496with staged security uploads:
497
498XXX cprov 20070404: we are using a modified sync policy because we
499need unsigned changes and binaries uploads (same as security, but also
500accepts non-security uploads)
501
502 >>> from lp.archiveuploader.uploadpolicy import ArchiveUploadType
503 >>> modified_sync_policy = getPolicy(
504 ... name='sync', distro='ubuntu', distroseries='hoary')
505 >>> modified_sync_policy.accepted_type = ArchiveUploadType.BINARY_ONLY
506
507 >>> ed_bin = NascentUpload.from_changesfile_path(
508 ... datadir('split-upload-test/ed_0.2-20_i386.changes'),
509 ... modified_sync_policy, mock_logger_quiet)
510
511 >>> ed_bin.process()
512 >>> ed_bin.is_rejected
513 False
514
515 >>> success = ed_bin.do_accept()
516
517 >>> ed_bin.queue_root.status.name
518 'NEW'
519
520A build was created to represent the relationship between ed_src
521(waiting in ACCEPTED queue) and the just uploaded ed_bin:
522
523 >>> ed_build = ed_bin.queue_root.builds[0].build
524
525 >>> ed_spr.id == ed_build.source_package_release.id
526 True
527
528 >>> ed_build.title
529 u'i386 build of ed 0.2-20 in ubuntu hoary RELEASE'
530
531 >>> ed_build.status.name
532 'FULLYBUILT'
533
534Binary control file attributes are stored as text in the
535BinaryPackageRelease record.
536
537 >>> ed_bpr = ed_build.binarypackages[0]
538
539 >>> ed_bpr.depends
540 u'libc6 (>= 2.6-1)'
541
542 >>> ed_bpr.suggests
543 u'emacs'
544
545 >>> ed_bpr.conflicts
546 u'vi'
547
548 >>> ed_bpr.replaces
549 u'vim'
550
551 >>> ed_bpr.provides
552 u'ed'
553
554 >>> ed_bpr.essential
555 False
556
557 >>> ed_bpr.pre_depends
558 u'dpkg'
559
560 >>> ed_bpr.enhances
561 u'bash'
562
563 >>> ed_bpr.breaks
564 u'emacs'
565
566
567=== Staged Source and Binary upload with multiple binaries ===487=== Staged Source and Binary upload with multiple binaries ===
568488
569As we could see both, sources and binaries, get into Launchpad via489As we could see both, sources and binaries, get into Launchpad via
@@ -613,12 +533,18 @@
613 >>> multibar_src_queue.status.name533 >>> multibar_src_queue.status.name
614 'ACCEPTED'534 'ACCEPTED'
615535
616We can just assume the source was published by step 3 and 4 for536Then the source gets accepted and published, step 3 and 4:
617simplicity and claim 'build from ACCEPTED' feature.537
538 >>> from lp.registry.interfaces.pocket import PackagePublishingPocket
539 >>> from lp.soyuz.interfaces.publishing import IPublishingSet
540 >>> getUtility(IPublishingSet).newSourcePublication(
541 ... multibar_src_queue.archive, multibar_spr,
542 ... sync_policy.distroseries, multibar_spr.component,
543 ... multibar_spr.section, PackagePublishingPocket.RELEASE)
544 <SourcePackagePublishingHistory at ...>
618545
619Build creation is done based on the SourcePackageRelease object, step 5:546Build creation is done based on the SourcePackageRelease object, step 5:
620547
621 >>> from lp.registry.interfaces.pocket import PackagePublishingPocket
622 >>> multibar_build = multibar_spr.createBuild(548 >>> multibar_build = multibar_spr.createBuild(
623 ... hoary['i386'], PackagePublishingPocket.RELEASE,549 ... hoary['i386'], PackagePublishingPocket.RELEASE,
624 ... multibar_src_queue.archive)550 ... multibar_src_queue.archive)
@@ -636,8 +562,8 @@
636and the collection of DEB files produced.562and the collection of DEB files produced.
637563
638At this point slave-scanner moves the upload to the appropriate path564At this point slave-scanner moves the upload to the appropriate path
639(/srv/launchpad.net/builddmaster) and invokes process-upload.py with565(/srv/launchpad.net/builddmaster). A cron job invokes process-upload.py
640the 'buildd' upload policy and the build record id.566with the 'buildd' upload policy and processes all files in that directory.
641567
642 >>> buildd_policy = getPolicy(568 >>> buildd_policy = getPolicy(
643 ... name='buildd', distro='ubuntu', distroseries='hoary')569 ... name='buildd', distro='ubuntu', distroseries='hoary')
644570
=== modified file 'lib/lp/archiveuploader/tests/test_buildduploads.py'
--- lib/lp/archiveuploader/tests/test_buildduploads.py 2010-10-06 11:46:51 +0000
+++ lib/lp/archiveuploader/tests/test_buildduploads.py 2010-10-19 14:17:46 +0000
@@ -20,6 +20,7 @@
20 )20 )
21from lp.registry.interfaces.distribution import IDistributionSet21from lp.registry.interfaces.distribution import IDistributionSet
22from lp.registry.interfaces.pocket import PackagePublishingPocket22from lp.registry.interfaces.pocket import PackagePublishingPocket
23from lp.soyuz.interfaces.publishing import IPublishingSet
23from lp.soyuz.model.binarypackagebuild import BinaryPackageBuild24from lp.soyuz.model.binarypackagebuild import BinaryPackageBuild
2425
2526
@@ -195,6 +196,13 @@
195 real_policy = self.policy196 real_policy = self.policy
196 self.policy = 'insecure'197 self.policy = 'insecure'
197 super(TestBuilddUploads, self).setUp()198 super(TestBuilddUploads, self).setUp()
199 # Publish the source package release so it can be found by
200 # NascentUploadFile.findSourcePackageRelease().
201 spr = self.source_queue.sources[0].sourcepackagerelease
202 getUtility(IPublishingSet).newSourcePublication(
203 self.distroseries.main_archive, spr,
204 self.distroseries, spr.component,
205 spr.section, PackagePublishingPocket.RELEASE)
198 self.policy = real_policy206 self.policy = real_policy
199207
200 def _publishBuildQueueItem(self, queue_item):208 def _publishBuildQueueItem(self, queue_item):
201209
=== modified file 'lib/lp/archiveuploader/tests/test_nascentuploadfile.py'
--- lib/lp/archiveuploader/tests/test_nascentuploadfile.py 2010-10-06 11:46:51 +0000
+++ lib/lp/archiveuploader/tests/test_nascentuploadfile.py 2010-10-19 14:17:46 +0000
@@ -25,7 +25,10 @@
25from lp.registry.interfaces.pocket import PackagePublishingPocket25from lp.registry.interfaces.pocket import PackagePublishingPocket
26from lp.archiveuploader.tests import AbsolutelyAnythingGoesUploadPolicy26from lp.archiveuploader.tests import AbsolutelyAnythingGoesUploadPolicy
27from lp.buildmaster.enums import BuildStatus27from lp.buildmaster.enums import BuildStatus
28from lp.soyuz.enums import PackageUploadCustomFormat28from lp.soyuz.enums import (
29 PackageUploadCustomFormat,
30 PackagePublishingStatus,
31 )
29from lp.testing import TestCaseWithFactory32from lp.testing import TestCaseWithFactory
3033
3134
@@ -387,3 +390,68 @@
387 "foo_0.42_i386.deb", "main/python", "unknown", "mypkg", "0.42",390 "foo_0.42_i386.deb", "main/python", "unknown", "mypkg", "0.42",
388 None)391 None)
389 self.assertRaises(UploadError, uploadfile.checkBuild, build)392 self.assertRaises(UploadError, uploadfile.checkBuild, build)
393
394 def test_findSourcePackageRelease(self):
395 # findSourcePackageRelease finds the matching SourcePackageRelease.
396 das = self.factory.makeDistroArchSeries(
397 distroseries=self.policy.distroseries, architecturetag="i386")
398 build = self.factory.makeBinaryPackageBuild(
399 distroarchseries=das,
400 archive=self.policy.archive)
401 uploadfile = self.createDebBinaryUploadFile(
402 "foo_0.42_i386.deb", "main/python", "unknown", "mypkg", "0.42",
403 None)
404 spph = self.factory.makeSourcePackagePublishingHistory(
405 sourcepackagename=self.factory.makeSourcePackageName("foo"),
406 distroseries=self.policy.distroseries,
407 version="0.42", archive=self.policy.archive)
408 control = self.getBaseControl()
409 control["Source"] = "foo"
410 uploadfile.parseControl(control)
411 self.assertEquals(
412 spph.sourcepackagerelease, uploadfile.findSourcePackageRelease())
413
414 def test_findSourcePackageRelease_no_spph(self):
415 # findSourcePackageRelease raises UploadError if there is no
416 # SourcePackageRelease.
417 das = self.factory.makeDistroArchSeries(
418 distroseries=self.policy.distroseries, architecturetag="i386")
419 build = self.factory.makeBinaryPackageBuild(
420 distroarchseries=das,
421 archive=self.policy.archive)
422 uploadfile = self.createDebBinaryUploadFile(
423 "foo_0.42_i386.deb", "main/python", "unknown", "mypkg", "0.42",
424 None)
425 control = self.getBaseControl()
426 control["Source"] = "foo"
427 uploadfile.parseControl(control)
428 self.assertRaises(UploadError, uploadfile.findSourcePackageRelease)
429
430 def test_findSourcePackageRelease_multiple_sprs(self):
431 # findSourcePackageRelease finds the last uploaded
432 # SourcePackageRelease and can deal with multiple pending source
433 # package releases.
434 das = self.factory.makeDistroArchSeries(
435 distroseries=self.policy.distroseries, architecturetag="i386")
436 build = self.factory.makeBinaryPackageBuild(
437 distroarchseries=das,
438 archive=self.policy.archive)
439 uploadfile = self.createDebBinaryUploadFile(
440 "foo_0.42_i386.deb", "main/python", "unknown", "mypkg", "0.42",
441 None)
442 spn = self.factory.makeSourcePackageName("foo")
443 spph1 = self.factory.makeSourcePackagePublishingHistory(
444 sourcepackagename=spn,
445 distroseries=self.policy.distroseries,
446 version="0.42", archive=self.policy.archive,
447 status=PackagePublishingStatus.PUBLISHED)
448 spph2 = self.factory.makeSourcePackagePublishingHistory(
449 sourcepackagename=spn,
450 distroseries=self.policy.distroseries,
451 version="0.42", archive=self.policy.archive,
452 status=PackagePublishingStatus.PENDING)
453 control = self.getBaseControl()
454 control["Source"] = "foo"
455 uploadfile.parseControl(control)
456 self.assertEquals(
457 spph2.sourcepackagerelease, uploadfile.findSourcePackageRelease())
390458
=== modified file 'lib/lp/testing/factory.py'
--- lib/lp/testing/factory.py 2010-10-18 21:18:03 +0000
+++ lib/lp/testing/factory.py 2010-10-19 14:17:46 +0000
@@ -79,6 +79,7 @@
79 EmailAddressStatus,79 EmailAddressStatus,
80 IEmailAddressSet,80 IEmailAddressSet,
81 )81 )
82from canonical.launchpad.interfaces.oauth import IOAuthConsumerSet
82from canonical.launchpad.interfaces.gpghandler import IGPGHandler83from canonical.launchpad.interfaces.gpghandler import IGPGHandler
83from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities84from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
84from canonical.launchpad.interfaces.librarian import ILibraryFileAliasSet85from canonical.launchpad.interfaces.librarian import ILibraryFileAliasSet
@@ -91,6 +92,7 @@
91 DEFAULT_FLAVOR,92 DEFAULT_FLAVOR,
92 IStoreSelector,93 IStoreSelector,
93 MAIN_STORE,94 MAIN_STORE,
95 OAuthPermission,
94 )96 )
95from lp.app.enums import ServiceUsage97from lp.app.enums import ServiceUsage
96from lp.archiveuploader.dscfile import DSCFile98from lp.archiveuploader.dscfile import DSCFile
@@ -3165,6 +3167,33 @@
3165 to_source=to_source, date_fulfilled=date_fulfilled,3167 to_source=to_source, date_fulfilled=date_fulfilled,
3166 status=status, diff_content=lfa))3168 status=status, diff_content=lfa))
31673169
3170 # Factory methods for OAuth tokens.
3171 def makeOAuthConsumer(self, key=None, secret=None):
3172 if key is None:
3173 key = self.getUniqueString("oauthconsumerkey")
3174 if secret is None:
3175 secret = ''
3176 return getUtility(IOAuthConsumerSet).new(key, secret)
3177
3178 def makeOAuthRequestToken(
3179 self, consumer=None, date_created=None, reviewed_by=None,
3180 access_level=OAuthPermission.READ_PUBLIC):
3181 """Create a (possibly reviewed) OAuth request token."""
3182 if consumer is None:
3183 consumer = self.makeOAuthConsumer()
3184 token = consumer.newRequestToken()
3185
3186 if reviewed_by is not None:
3187 # Review the token before modifying the date_created,
3188 # since the date_created can be used to simulate an
3189 # expired token.
3190 token.review(reviewed_by, access_level)
3191
3192 if date_created is not None:
3193 unwrapped_token = removeSecurityProxy(token)
3194 unwrapped_token.date_created = date_created
3195 return token
3196
31683197
3169# Some factory methods return simple Python types. We don't add3198# Some factory methods return simple Python types. We don't add
3170# security wrappers for them, as well as for objects created by3199# security wrappers for them, as well as for objects created by

Subscribers

People subscribed via source and target branches

to status/vote changes: