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