Merge lp:~leonardr/launchpad/accept-oauth-signatures into lp:launchpad/db-devel

Proposed by Leonard Richardson
Status: Merged
Approved by: Curtis Hovey
Approved revision: no longer in the source branch.
Merged at revision: 9804
Proposed branch: lp:~leonardr/launchpad/accept-oauth-signatures
Merge into: lp:launchpad/db-devel
Diff against target: 1065 lines (+545/-146)
8 files modified
lib/canonical/launchpad/browser/oauth.py (+106/-7)
lib/canonical/launchpad/database/oauth.py (+11/-8)
lib/canonical/launchpad/pagetests/oauth/authorize-token.txt (+207/-19)
lib/canonical/launchpad/webapp/authentication.py (+131/-5)
lib/canonical/launchpad/webapp/servers.py (+3/-94)
lib/canonical/launchpad/zcml/launchpad.zcml (+2/-2)
lib/lp/testing/__init__.py (+5/-1)
lib/lp/testing/_webservice.py (+80/-10)
To merge this branch: bzr merge lp:~leonardr/launchpad/accept-oauth-signatures
Reviewer Review Type Date Requested Status
Curtis Hovey (community) Approve
Review via email: mp+35697@code.launchpad.net

Description of the change

Backstory: the only way to get (non-anonymous or read-write) access to the Launchpad web service is to associate an OAuth access token with your account, and have your web service client sign all outgoing requests with that token. The level of access you get to the web service depends on the permission level associated with the token: READ_PRIVATE, WRITE_PUBLIC, etc.

Currently, a third-party desktop application obtains one of these OAuth tokens by opening a web-browser to the /+authorize-token page on Launchpad. The end-user logs into Launchpad and authorizes the token. Subsequently the desktop application can sign requests with the new token.

The problem is the "open a browser/log into Launchpad" step, which confuses users. We're working on a desktop application that wraps a web browser to eliminate confusion and reduce the number of times the end-user has to log into Launchpad. This branch changes Launchpad to make a portion of the _web site_ a little more like the _web service_. If you have the right OAuth access token, you can sign outdoing HTTP requests to the _web site_ and not have to go through the login procedure.

Recall that the whole point of an OAuth token is to authenticate against Launchpad without knowing a username/password combination. The desktop application will obtain an OAuth token with the special GRANT_PERMISSIONS access level. Then, when it needs to obtain some lesser access token for another application, it'll use that GRANT_PERMISSIONS token to skip the login step and take the end-user straight to /+authorize-token.

If you take a look at the "Access through OAuth" section of authorize-token.txt, you'll see this in action. Earlier in that test we run through the token authorization process using a Mechanize browser programmed with HTTP Basic Auth credentials. In "Access through OAuth", we go through the same process using a Mechanize browser that signs outgoing requests with a GRANT_PERMISSIONS token. Launchpad--specifically, the +authorize-token and +token-authorized views--will accept either.

To implement this, I removed the access controls on +authorize-token and +token-authorized, and implemented my own (ensureRequestIsAuthorizedOrSigned). The new controls delegate to the normal Launchpad publication controls, but will also allow access if the incoming request is signed with the right OAuth token. This let me keep all the special code near the two views that use it, and saved me from having to create a brand new publication (or alter the existing publication) just for those two views.

The other major code in this branch is a refactoring of WebServicePublication.get_principal. I moved the code for extracting OAuth information from a request, and validating a request's OAuth signature, into helper functions in webapp/authentication.py. It's called from get_principal, and also from ensureRequestIsAuthorizedOrSigned.

There's one more things I'd like to address in a follow-up branch (this branch is already huge, partly due to delinting), or possibly in this branch once it's been reviewed. There's no way to get access to any other page on Launchpad by signing a request with an OAuth token, but I'd like to add a test to prove it.

To post a comment you must log in.
Revision history for this message
Curtis Hovey (sinzui) wrote :

This as a fun read. I suggest you use
   >>> user = factory.makePerson(name='test-user')
instead of sample data to work with the user. You can also create an email address with the email= arg. You can also use
login_person(user)

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/canonical/launchpad/browser/oauth.py'
--- lib/canonical/launchpad/browser/oauth.py 2010-08-24 16:44:42 +0000
+++ lib/canonical/launchpad/browser/oauth.py 2010-09-20 13:21:06 +0000
@@ -11,11 +11,13 @@
1111
12from lazr.restful import HTTPResource12from lazr.restful import HTTPResource
13import simplejson13import simplejson
14from zope.authentication.interfaces import IUnauthenticatedPrincipal
14from zope.component import getUtility15from zope.component import getUtility
15from zope.formlib.form import (16from zope.formlib.form import (
16 Action,17 Action,
17 Actions,18 Actions,
18 )19 )
20from zope.security.interfaces import Unauthorized
1921
20from canonical.launchpad.interfaces.oauth import (22from canonical.launchpad.interfaces.oauth import (
21 IOAuthConsumerSet,23 IOAuthConsumerSet,
@@ -29,9 +31,15 @@
29 )31 )
30from canonical.launchpad.webapp.authentication import (32from canonical.launchpad.webapp.authentication import (
31 check_oauth_signature,33 check_oauth_signature,
34 extract_oauth_access_token,
32 get_oauth_authorization,35 get_oauth_authorization,
33 )36 get_oauth_principal
34from canonical.launchpad.webapp.interfaces import OAuthPermission37 )
38from canonical.launchpad.webapp.interfaces import (
39 AccessLevel,
40 ILaunchBag,
41 OAuthPermission,
42 )
35from lp.app.errors import UnexpectedFormData43from lp.app.errors import UnexpectedFormData
36from lp.registry.interfaces.distribution import IDistributionSet44from lp.registry.interfaces.distribution import IDistributionSet
37from lp.registry.interfaces.pillar import IPillarNameSet45from lp.registry.interfaces.pillar import IPillarNameSet
@@ -98,6 +106,7 @@
98 return u'oauth_token=%s&oauth_token_secret=%s' % (106 return u'oauth_token=%s&oauth_token_secret=%s' % (
99 token.key, token.secret)107 token.key, token.secret)
100108
109
101def token_exists_and_is_not_reviewed(form, action):110def token_exists_and_is_not_reviewed(form, action):
102 return form.token is not None and not form.token.is_reviewed111 return form.token is not None and not form.token.is_reviewed
103112
@@ -106,8 +115,10 @@
106 """Return a list of `Action`s for each possible `OAuthPermission`."""115 """Return a list of `Action`s for each possible `OAuthPermission`."""
107 actions = Actions()116 actions = Actions()
108 actions_excluding_grant_permissions = Actions()117 actions_excluding_grant_permissions = Actions()
118
109 def success(form, action, data):119 def success(form, action, data):
110 form.reviewToken(action.permission)120 form.reviewToken(action.permission)
121
111 for permission in OAuthPermission.items:122 for permission in OAuthPermission.items:
112 action = Action(123 action = Action(
113 permission.title, name=permission.name, success=success,124 permission.title, name=permission.name, success=success,
@@ -118,7 +129,86 @@
118 actions_excluding_grant_permissions.append(action)129 actions_excluding_grant_permissions.append(action)
119 return actions, actions_excluding_grant_permissions130 return actions, actions_excluding_grant_permissions
120131
121class OAuthAuthorizeTokenView(LaunchpadFormView, JSONTokenMixin):132
133class CredentialManagerAwareMixin:
134 """A view for which a browser may authenticate with an OAuth token.
135
136 The OAuth token must be signed with a token that has the
137 GRANT_PERMISSIONS access level, and the browser must present
138 itself as the Launchpad Credentials Manager.
139 """
140 # A prefix identifying the Launchpad Credential Manager's
141 # User-Agent string.
142 GRANT_PERMISSIONS_USER_AGENT_PREFIX = "Launchpad Credentials Manager"
143
144 def ensureRequestIsAuthorizedOrSigned(self):
145 """Find the user who initiated the request.
146
147 This property is used by a view that wants to reject access
148 unless the end-user is authenticated with cookie auth, HTTP
149 Basic Auth, *or* a properly authorized OAuth token.
150
151 If the user is logged in with cookie auth or HTTP Basic, then
152 other parts of Launchpad have taken care of the login and we
153 don't have to do anything. But if the user's browser has
154 signed the request with an OAuth token, other parts of
155 Launchpad won't recognize that as an attempt to authorize the
156 request.
157
158 This method does the OAuth part of the work. It checks that
159 the OAuth token is valid, that it's got the correct access
160 level, and that the User-Agent is one that's allowed to sign
161 requests with OAuth tokens.
162
163 :return: The user who Launchpad identifies as the principal.
164 Or, if Launchpad identifies no one as the principal, the user
165 whose valid GRANT_PERMISSIONS OAuth token was used to sign
166 the request.
167
168 :raise Unauthorized: If the request is unauthorized and
169 unsigned, improperly signed, anonymously signed, or signed
170 with a token that does not have the right access level.
171 """
172 user = getUtility(ILaunchBag).user
173 if user is not None:
174 return user
175 # The normal Launchpad code was not able to identify any
176 # user, but we're going to try a little harder before
177 # concluding that no one's logged in. If the incoming
178 # request is signed by an OAuth access token with the
179 # GRANT_PERMISSIONS access level, we will force a
180 # temporary login with the user whose access token this
181 # is.
182 token = extract_oauth_access_token(self.request)
183 if token is None:
184 # The request is not OAuth-signed. The normal Launchpad
185 # code had it right: no one is authenticated.
186 raise Unauthorized("Anonymous access is not allowed.")
187 principal = get_oauth_principal(self.request)
188 if IUnauthenticatedPrincipal.providedBy(principal):
189 # The request is OAuth-signed, but as the anonymous
190 # user.
191 raise Unauthorized("Anonymous access is not allowed.")
192 if token.permission != AccessLevel.GRANT_PERMISSIONS:
193 # The request is OAuth-signed, but the token has
194 # the wrong access level.
195 raise Unauthorized("OAuth token has insufficient access level.")
196
197 # Both the consumer key and the User-Agent must identify the
198 # Launchpad Credentials Manager.
199 must_start_with_prefix = [
200 token.consumer.key, self.request.getHeader("User-Agent")]
201 for string in must_start_with_prefix:
202 if not string.startswith(
203 self.GRANT_PERMISSIONS_USER_AGENT_PREFIX):
204 raise Unauthorized(
205 "Only the Launchpad Credentials Manager can access this "
206 "page by signing requests with an OAuth token.")
207 return principal.person
208
209
210class OAuthAuthorizeTokenView(
211 LaunchpadFormView, JSONTokenMixin, CredentialManagerAwareMixin):
122 """Where users authorize consumers to access Launchpad on their behalf."""212 """Where users authorize consumers to access Launchpad on their behalf."""
123213
124 actions, actions_excluding_grant_permissions = (214 actions, actions_excluding_grant_permissions = (
@@ -167,6 +257,12 @@
167 and len(allowed_permissions) > 1):257 and len(allowed_permissions) > 1):
168 allowed_permissions.remove(OAuthPermission.GRANT_PERMISSIONS.name)258 allowed_permissions.remove(OAuthPermission.GRANT_PERMISSIONS.name)
169259
260 # GRANT_PERMISSIONS may only be requested by a specific User-Agent.
261 if (OAuthPermission.GRANT_PERMISSIONS.name in allowed_permissions
262 and not self.request.getHeader("User-Agent").startswith(
263 self.GRANT_PERMISSIONS_USER_AGENT_PREFIX)):
264 allowed_permissions.remove(OAuthPermission.GRANT_PERMISSIONS.name)
265
170 for action in self.actions:266 for action in self.actions:
171 if (action.permission.name in allowed_permissions267 if (action.permission.name in allowed_permissions
172 or action.permission is OAuthPermission.UNAUTHORIZED):268 or action.permission is OAuthPermission.UNAUTHORIZED):
@@ -184,9 +280,10 @@
184 return actions280 return actions
185281
186 def initialize(self):282 def initialize(self):
283 self.oauth_authorized_user = self.ensureRequestIsAuthorizedOrSigned()
187 self.storeTokenContext()284 self.storeTokenContext()
188 form = get_oauth_authorization(self.request)285
189 key = form.get('oauth_token')286 key = self.request.form.get('oauth_token')
190 if key:287 if key:
191 self.token = getUtility(IOAuthRequestTokenSet).getByKey(key)288 self.token = getUtility(IOAuthRequestTokenSet).getByKey(key)
192 super(OAuthAuthorizeTokenView, self).initialize()289 super(OAuthAuthorizeTokenView, self).initialize()
@@ -217,7 +314,8 @@
217 self.token_context = context314 self.token_context = context
218315
219 def reviewToken(self, permission):316 def reviewToken(self, permission):
220 self.token.review(self.user, permission, self.token_context)317 self.token.review(self.user or self.oauth_authorized_user,
318 permission, self.token_context)
221 callback = self.request.form.get('oauth_callback')319 callback = self.request.form.get('oauth_callback')
222 if callback:320 if callback:
223 self.next_url = callback321 self.next_url = callback
@@ -245,7 +343,7 @@
245 return context343 return context
246344
247345
248class OAuthTokenAuthorizedView(LaunchpadView):346class OAuthTokenAuthorizedView(LaunchpadView, CredentialManagerAwareMixin):
249 """Where users who reviewed tokens may get redirected to.347 """Where users who reviewed tokens may get redirected to.
250348
251 If the consumer didn't include an oauth_callback when sending the user to349 If the consumer didn't include an oauth_callback when sending the user to
@@ -254,6 +352,7 @@
254 """352 """
255353
256 def initialize(self):354 def initialize(self):
355 authorized_user = self.ensureRequestIsAuthorizedOrSigned()
257 key = self.request.form.get('oauth_token')356 key = self.request.form.get('oauth_token')
258 self.token = getUtility(IOAuthRequestTokenSet).getByKey(key)357 self.token = getUtility(IOAuthRequestTokenSet).getByKey(key)
259 assert self.token.is_reviewed, (358 assert self.token.is_reviewed, (
260359
=== modified file 'lib/canonical/launchpad/database/oauth.py'
--- lib/canonical/launchpad/database/oauth.py 2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/database/oauth.py 2010-09-20 13:21:06 +0000
@@ -60,14 +60,14 @@
6060
61# How many hours should a request token be valid for?61# How many hours should a request token be valid for?
62REQUEST_TOKEN_VALIDITY = 1262REQUEST_TOKEN_VALIDITY = 12
63# The OAuth Core 1.0 spec (http://oauth.net/core/1.0/#nonce) says that a63# The OAuth Core 1.0 spec (http://oauth.net/core/1.0/#nonce) says that
64# timestamp "MUST be equal or greater than the timestamp used in previous64# a timestamp "MUST be equal or greater than the timestamp used in
65# requests," but this is likely to cause problems if the client does request65# previous requests," but this is likely to cause problems if the
66# pipelining, so we use a time window (relative to the timestamp of the66# client does request pipelining, so we use a time window (relative to
67# existing OAuthNonce) to check if the timestamp can is acceptable. As67# the timestamp of the existing OAuthNonce) to check if the timestamp
68# suggested by Robert, we use a window which is at least twice the size of our68# can is acceptable. As suggested by Robert, we use a window which is
69# hard time out. This is a safe bet since no requests should take more than69# at least twice the size of our hard time out. This is a safe bet
70# one hard time out.70# since no requests should take more than one hard time out.
71TIMESTAMP_ACCEPTANCE_WINDOW = 60 # seconds71TIMESTAMP_ACCEPTANCE_WINDOW = 60 # seconds
72# If the timestamp is far in the future because of a client's clock skew,72# If the timestamp is far in the future because of a client's clock skew,
73# it will effectively invalidate the authentication tokens when the clock is73# it will effectively invalidate the authentication tokens when the clock is
@@ -77,6 +77,7 @@
77# amount.77# amount.
78TIMESTAMP_SKEW_WINDOW = 60*60 # seconds, +/-78TIMESTAMP_SKEW_WINDOW = 60*60 # seconds, +/-
7979
80
80class OAuthBase(SQLBase):81class OAuthBase(SQLBase):
81 """Base class for all OAuth database classes."""82 """Base class for all OAuth database classes."""
8283
@@ -93,6 +94,7 @@
9394
94 getStore = _get_store95 getStore = _get_store
9596
97
96class OAuthConsumer(OAuthBase):98class OAuthConsumer(OAuthBase):
97 """See `IOAuthConsumer`."""99 """See `IOAuthConsumer`."""
98 implements(IOAuthConsumer)100 implements(IOAuthConsumer)
@@ -323,6 +325,7 @@
323 The key will have a length of 20 and we'll make sure it's not yet in the325 The key will have a length of 20 and we'll make sure it's not yet in the
324 given table. The secret will have a length of 80.326 given table. The secret will have a length of 80.
325 """327 """
328
326 key_length = 20329 key_length = 20
327 key = create_unique_token_for_table(key_length, getattr(table, "key"))330 key = create_unique_token_for_table(key_length, getattr(table, "key"))
328 secret_length = 80331 secret_length = 80
329332
=== modified file 'lib/canonical/launchpad/pagetests/oauth/authorize-token.txt'
--- lib/canonical/launchpad/pagetests/oauth/authorize-token.txt 2010-08-24 16:44:42 +0000
+++ lib/canonical/launchpad/pagetests/oauth/authorize-token.txt 2010-09-20 13:21:06 +0000
@@ -1,4 +1,6 @@
1= Authorizing a request token =1***************************
2Authorizing a request token
3***************************
24
3Once the consumer gets a request token, it must send the user to5Once the consumer gets a request token, it must send the user to
4Launchpad's +authorize-token page in order for the user to authenticate6Launchpad's +authorize-token page in order for the user to authenticate
@@ -19,9 +21,10 @@
19The oauth_token parameter, on the other hand, is required in the21The oauth_token parameter, on the other hand, is required in the
20Launchpad implementation.22Launchpad implementation.
2123
22The +authorize-token page is restricted to logged in users, so users will24Access to the page
23first be asked to log in. (We won't show the actual login process because25==================
24it involves OpenID, which would complicate this test quite a bit.)26
27The +authorize-token page is restricted to authenticated users.
2528
26 >>> from urllib import urlencode29 >>> from urllib import urlencode
27 >>> params = dict(30 >>> params = dict(
@@ -30,7 +33,18 @@
30 >>> browser.open(url)33 >>> browser.open(url)
31 Traceback (most recent call last):34 Traceback (most recent call last):
32 ...35 ...
33 Unauthorized:...36 Unauthorized: Anonymous access is not allowed.
37
38However, the details of the authentication are different than from any
39other part of Launchpad. Unlike with other pages, a user can authorize
40an OAuth token by signing their outgoing requests with an _existing_
41OAuth token. This makes it possible for a desktop client to retrieve
42this page without knowing the end-user's username and password, or
43making them navigate the arbitrarily complex OpenID login procedure.
44
45But, let's deal with that a little later. First let's show how the
46process works through HTTP Basic Auth (the testing equivalent of a
47regular username-and-password login).
3448
35 >>> browser = setupBrowser(auth='Basic no-priv@canonical.com:test')49 >>> browser = setupBrowser(auth='Basic no-priv@canonical.com:test')
36 >>> browser.open(url)50 >>> browser.open(url)
@@ -44,6 +58,10 @@
44 ...58 ...
45 See all applications authorized to access Launchpad on your behalf.59 See all applications authorized to access Launchpad on your behalf.
4660
61
62Using the page
63==============
64
47This page contains one submit button for each item of OAuthPermission,65This page contains one submit button for each item of OAuthPermission,
48except for 'Grant Permissions', which must be specifically requested.66except for 'Grant Permissions', which must be specifically requested.
4967
@@ -74,7 +92,34 @@
74that isn't enough for the application. The user always has the option92that isn't enough for the application. The user always has the option
75to deny permission altogether.93to deny permission altogether.
7694
77 >>> def print_access_levels(allow_permission):95 >>> def filter_user_agent(key, value, new_value):
96 ... """A filter to replace the User-Agent header in a list of headers.
97 ...
98 ... [XXX bug=638058] This is a hack to work around a bug in
99 ... zope.testbrowser.
100 ... """
101 ...
102 ... if key.lower() == "user-agent":
103 ... return (key, new_value)
104 ... return (key, value)
105
106 >>> def print_access_levels(allow_permission, user_agent=None):
107 ... if user_agent is not None:
108 ... # [XXX bug=638058] This is a hack to work around a bug in
109 ... # zope.testbrowser which prevents browser.addHeader
110 ... # from working with User-Agent.
111 ... mech_browser = browser.mech_browser
112 ... # Store the original User-Agent for later.
113 ... old_user_agent = [
114 ... value for key, value in mech_browser.addheaders
115 ... if key.lower() == "user-agent"][0]
116 ... # Replace the User-Agent with the value passed into this
117 ... # function.
118 ... mech_browser.addheaders = [
119 ... filter_user_agent(key, value, user_agent)
120 ... for key, value in mech_browser.addheaders]
121 ...
122 ... # Okay, now we can make the request.
78 ... browser.open(123 ... browser.open(
79 ... "http://launchpad.dev/+authorize-token?%s&%s"124 ... "http://launchpad.dev/+authorize-token?%s&%s"
80 ... % (urlencode(params), allow_permission))125 ... % (urlencode(params), allow_permission))
@@ -82,6 +127,13 @@
82 ... actions = main_content.findAll('input', attrs={'type': 'submit'})127 ... actions = main_content.findAll('input', attrs={'type': 'submit'})
83 ... for action in actions:128 ... for action in actions:
84 ... print action['value']129 ... print action['value']
130 ...
131 ... if user_agent is not None:
132 ... # Finally, restore the old User-Agent.
133 ... mech_browser.addheaders = [
134 ... filter_user_agent(key, value, old_user_agent)
135 ... for key, value in mech_browser.addheaders]
136
85137
86 >>> print_access_levels(138 >>> print_access_levels(
87 ... 'allow_permission=WRITE_PUBLIC&allow_permission=WRITE_PRIVATE')139 ... 'allow_permission=WRITE_PUBLIC&allow_permission=WRITE_PRIVATE')
@@ -90,23 +142,38 @@
90 Change Anything142 Change Anything
91143
92The only time the 'Grant Permissions' permission shows up in this list144The only time the 'Grant Permissions' permission shows up in this list
93is if the client specifically requests it, and no other145is if a client identifying itself as the Launchpad Credentials Manager
94permission. (Also requesting UNAUTHORIZED is okay--it will show up146specifically requests it, and no other permission. (Also requesting
95anyway.)147UNAUTHORIZED is okay--it will show up anyway.)
148
149 >>> USER_AGENT = "Launchpad Credentials Manager v1.0"
150 >>> print_access_levels(
151 ... 'allow_permission=GRANT_PERMISSIONS', USER_AGENT)
152 No Access
153 Grant Permissions
154
155 >>> print_access_levels(
156 ... ('allow_permission=GRANT_PERMISSIONS&'
157 ... 'allow_permission=UNAUTHORIZED'),
158 ... USER_AGENT)
159 No Access
160 Grant Permissions
161
162 >>> print_access_levels(
163 ... ('allow_permission=WRITE_PUBLIC&'
164 ... 'allow_permission=GRANT_PERMISSIONS'))
165 No Access
166 Change Non-Private Data
167
168If a client asks for GRANT_PERMISSIONS but doesn't claim to be the
169Launchpad Credentials Manager, Launchpad will not show GRANT_PERMISSIONS.
96170
97 >>> print_access_levels('allow_permission=GRANT_PERMISSIONS')171 >>> print_access_levels('allow_permission=GRANT_PERMISSIONS')
98 No Access172 No Access
99 Grant Permissions173 Read Non-Private Data
100
101 >>> print_access_levels(
102 ... 'allow_permission=GRANT_PERMISSIONS&allow_permission=UNAUTHORIZED')
103 No Access
104 Grant Permissions
105
106 >>> print_access_levels(
107 ... 'allow_permission=WRITE_PUBLIC&allow_permission=GRANT_PERMISSIONS')
108 No Access
109 Change Non-Private Data174 Change Non-Private Data
175 Read Anything
176 Change Anything
110177
111If an application doesn't specify any valid access levels, or only178If an application doesn't specify any valid access levels, or only
112specifies the UNAUTHORIZED access level, Launchpad will show all the179specifies the UNAUTHORIZED access level, Launchpad will show all the
@@ -263,3 +330,124 @@
263 This request for accessing Launchpad on your behalf has been330 This request for accessing Launchpad on your behalf has been
264 reviewed ... ago.331 reviewed ... ago.
265 See all applications authorized to access Launchpad on your behalf.332 See all applications authorized to access Launchpad on your behalf.
333
334Access through OAuth
335====================
336
337Now it's time to show how to go through the same process without
338knowing the end-user's username and password. All you need is an OAuth
339token issued with the GRANT_PERMISSIONS access level, in the name of
340the Launchpad Credentials Manager.
341
342Let's go through the approval process again, without ever sending the
343user's username or password over HTTP. First we'll create a new user,
344and a GRANT_PERMISSIONS access token that they can use to sign
345requests.
346
347 >>> login(ANONYMOUS)
348 >>> user = factory.makePerson(name="test-user", password="never-used")
349 >>> logout()
350
351 >>> from oauth.oauth import OAuthConsumer
352 >>> manager_consumer = OAuthConsumer("Launchpad Credentials Manager", "")
353
354 >>> from lp.testing import oauth_access_token_for
355 >>> login_person(user)
356 >>> grant_permissions_token = oauth_access_token_for(
357 ... manager_consumer.key, user, "GRANT_PERMISSIONS")
358 >>> logout()
359
360Next, we'll give the new user an OAuth request token that needs to be
361approved using a web browser.
362
363 >>> login_person(user)
364 >>> consumer = getUtility(IOAuthConsumerSet).getByKey('foobar123451432')
365 >>> request_token = consumer.newRequestToken()
366 >>> logout()
367
368 >>> params = dict(oauth_token=request_token.key)
369 >>> url = "http://launchpad.dev/+authorize-token?%s" % urlencode(params)
370
371Next, we'll create a browser object that knows how to sign requests
372with the new user's existing access token.
373
374 >>> from lp.testing import OAuthSigningBrowser
375 >>> browser = OAuthSigningBrowser(
376 ... manager_consumer, grant_permissions_token, USER_AGENT)
377 >>> browser.open(url)
378 >>> print browser.title
379 Authorize application to access Launchpad on your behalf
380
381The browser object can approve the request and see the appropriate
382messages, even though we never gave it the user's password.
383
384 >>> browser.getControl('Read Anything').click()
385
386 >>> browser.url
387 'http://launchpad.dev/+token-authorized?...'
388 >>> print extract_text(find_tag_by_id(browser.contents, 'maincontent'))
389 Almost finished ...
390 To finish authorizing the application identified as foobar123451432 to
391 access Launchpad on your behalf you should go back to the application
392 window in which you started the process and inform it that you have done
393 your part of the process.
394
395OAuth error conditions
396----------------------
397
398The OAuth token used to sign the requests must have the
399GRANT_PERMISSIONS access level; no other access level will work.
400
401 >>> login(ANONYMOUS)
402 >>> insufficient_token = oauth_access_token_for(
403 ... manager_consumer.key, user, "WRITE_PRIVATE")
404 >>> logout()
405
406 >>> browser = OAuthSigningBrowser(
407 ... manager_consumer, insufficient_token, USER_AGENT)
408 >>> browser.open(url)
409 Traceback (most recent call last):
410 ...
411 Unauthorized: OAuth token has insufficient access level.
412
413The OAuth token must be for the Launchpad Credentials Manager, or it
414cannot be used. (Launchpad shouldn't even _issue_ a GRANT_PERMISSIONS
415token for any other consumer, but even if it somehow does, that token
416can't be used for this.)
417
418 >>> login(ANONYMOUS)
419 >>> wrong_consumer = OAuthConsumer(
420 ... "Not the Launchpad Credentials Manager", "")
421 >>> wrong_consumer_token = oauth_access_token_for(
422 ... wrong_consumer.key, user, "GRANT_PERMISSIONS")
423 >>> logout()
424
425 >>> browser = OAuthSigningBrowser(wrong_consumer, wrong_consumer_token)
426 >>> browser.open(url)
427 Traceback (most recent call last):
428 ...
429 Unauthorized: Only the Launchpad Credentials Manager can access
430 this page by signing requests with an OAuth token.
431
432Signing with an anonymous token will also not work.
433
434 >>> from oauth.oauth import OAuthToken
435 >>> anonymous_token = OAuthToken(key="", secret="")
436 >>> browser = OAuthSigningBrowser(manager_consumer, anonymous_token)
437 >>> browser.open(url)
438 Traceback (most recent call last):
439 ...
440 Unauthorized: Anonymous access is not allowed.
441
442Even if it presents the right token, the user agent sending the signed
443request must *also* identify *itself* as the Launchpad Credentials
444Manager.
445
446 >>> browser = OAuthSigningBrowser(
447 ... manager_consumer, grant_permissions_token,
448 ... "Not the Launchpad Credentials Manager")
449 >>> browser.open(url)
450 Traceback (most recent call last):
451 ...
452 Unauthorized: Only the Launchpad Credentials Manager can access
453 this page by signing requests with an OAuth token.
266454
=== modified file 'lib/canonical/launchpad/webapp/authentication.py'
--- lib/canonical/launchpad/webapp/authentication.py 2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/webapp/authentication.py 2010-09-20 13:21:06 +0000
@@ -5,16 +5,21 @@
55
6__all__ = [6__all__ = [
7 'check_oauth_signature',7 'check_oauth_signature',
8 'extract_oauth_access_token',
9 'get_oauth_principal',
8 'get_oauth_authorization',10 'get_oauth_authorization',
9 'LaunchpadLoginSource',11 'LaunchpadLoginSource',
10 'LaunchpadPrincipal',12 'LaunchpadPrincipal',
13 'OAuthSignedRequest',
11 'PlacelessAuthUtility',14 'PlacelessAuthUtility',
12 'SSHADigestEncryptor',15 'SSHADigestEncryptor',
13 ]16 ]
1417
1518
16import binascii19import binascii
20from datetime import datetime
17import hashlib21import hashlib
22import pytz
18import random23import random
19from UserDict import UserDict24from UserDict import UserDict
2025
@@ -23,13 +28,18 @@
23from zope.app.security.interfaces import ILoginPassword28from zope.app.security.interfaces import ILoginPassword
24from zope.app.security.principalregistry import UnauthenticatedPrincipal29from zope.app.security.principalregistry import UnauthenticatedPrincipal
25from zope.authentication.interfaces import IUnauthenticatedPrincipal30from zope.authentication.interfaces import IUnauthenticatedPrincipal
31
26from zope.component import (32from zope.component import (
27 adapts,33 adapts,
28 getUtility,34 getUtility,
29 )35 )
30from zope.event import notify36from zope.event import notify
31from zope.interface import implements37from zope.interface import (
38 alsoProvides,
39 implements,
40 )
32from zope.preference.interfaces import IPreferenceGroup41from zope.preference.interfaces import IPreferenceGroup
42from zope.security.interfaces import Unauthorized
33from zope.security.proxy import removeSecurityProxy43from zope.security.proxy import removeSecurityProxy
34from zope.session.interfaces import ISession44from zope.session.interfaces import ISession
3545
@@ -44,6 +54,14 @@
44 ILaunchpadPrincipal,54 ILaunchpadPrincipal,
45 IPlacelessAuthUtility,55 IPlacelessAuthUtility,
46 IPlacelessLoginSource,56 IPlacelessLoginSource,
57 OAuthPermission,
58 )
59from canonical.launchpad.interfaces.oauth import (
60 ClockSkew,
61 IOAuthConsumerSet,
62 IOAuthSignedRequest,
63 NonceAlreadyUsed,
64 TimestampOrderingError,
47 )65 )
48from lp.registry.interfaces.person import (66from lp.registry.interfaces.person import (
49 IPerson,67 IPerson,
@@ -51,6 +69,113 @@
51 )69 )
5270
5371
72def extract_oauth_access_token(request):
73 """Find the OAuth access token that signed the given request.
74
75 :param request: An incoming request.
76
77 :return: an IOAuthAccessToken, or None if the request is not
78 signed at all.
79
80 :raise Unauthorized: If the token is invalid or the request is an
81 anonymously-signed request that doesn't meet our requirements.
82 """
83 # Fetch OAuth authorization information from the request.
84 form = get_oauth_authorization(request)
85
86 consumer_key = form.get('oauth_consumer_key')
87 consumers = getUtility(IOAuthConsumerSet)
88 consumer = consumers.getByKey(consumer_key)
89 token_key = form.get('oauth_token')
90 anonymous_request = (token_key == '')
91
92 if consumer_key is None:
93 # Either the client's OAuth implementation is broken, or
94 # the user is trying to make an unauthenticated request
95 # using wget or another OAuth-ignorant application.
96 # Try to retrieve a consumer based on the User-Agent
97 # header.
98 anonymous_request = True
99 consumer_key = request.getHeader('User-Agent', '')
100 if consumer_key == '':
101 raise Unauthorized(
102 'Anonymous requests must provide a User-Agent.')
103 consumer = consumers.getByKey(consumer_key)
104
105 if consumer is None:
106 if anonymous_request:
107 # This is the first time anyone has tried to make an
108 # anonymous request using this consumer name (or user
109 # agent). Dynamically create the consumer.
110 #
111 # In the normal website this wouldn't be possible
112 # because GET requests have their transactions rolled
113 # back. But webservice requests always have their
114 # transactions committed so that we can keep track of
115 # the OAuth nonces and prevent replay attacks.
116 if consumer_key == '' or consumer_key is None:
117 raise Unauthorized("No consumer key specified.")
118 consumer = consumers.new(consumer_key, '')
119 else:
120 # An unknown consumer can never make a non-anonymous
121 # request, because access tokens are registered with a
122 # specific, known consumer.
123 raise Unauthorized('Unknown consumer (%s).' % consumer_key)
124 if anonymous_request:
125 # Skip the OAuth verification step and let the user access the
126 # web service as an unauthenticated user.
127 #
128 # XXX leonardr 2009-12-15 bug=496964: Ideally we'd be
129 # auto-creating a token for the anonymous user the first
130 # time, passing it through the OAuth verification step,
131 # and using it on all subsequent anonymous requests.
132 return None
133
134 token = consumer.getAccessToken(token_key)
135 if token is None:
136 raise Unauthorized('Unknown access token (%s).' % token_key)
137 return token
138
139
140def get_oauth_principal(request):
141 """Find the principal to use for this OAuth-signed request.
142
143 :param request: An incoming request.
144 :return: An ILaunchpadPrincipal with the appropriate access level.
145 """
146 token = extract_oauth_access_token(request)
147
148 if token is None:
149 # The consumer is making an anonymous request. If there was a
150 # problem with the access token, extract_oauth_access_token
151 # would have raised Unauthorized.
152 alsoProvides(request, IOAuthSignedRequest)
153 auth_utility = getUtility(IPlacelessAuthUtility)
154 return auth_utility.unauthenticatedPrincipal()
155
156 form = get_oauth_authorization(request)
157 nonce = form.get('oauth_nonce')
158 timestamp = form.get('oauth_timestamp')
159 try:
160 token.checkNonceAndTimestamp(nonce, timestamp)
161 except (NonceAlreadyUsed, TimestampOrderingError, ClockSkew), e:
162 raise Unauthorized('Invalid nonce/timestamp: %s' % e)
163 now = datetime.now(pytz.timezone('UTC'))
164 if token.permission == OAuthPermission.UNAUTHORIZED:
165 raise Unauthorized('Unauthorized token (%s).' % token.key)
166 elif token.date_expires is not None and token.date_expires <= now:
167 raise Unauthorized('Expired token (%s).' % token.key)
168 elif not check_oauth_signature(request, token.consumer, token):
169 raise Unauthorized('Invalid signature.')
170 else:
171 # Everything is fine, let's return the principal.
172 pass
173 alsoProvides(request, IOAuthSignedRequest)
174 return getUtility(IPlacelessLoginSource).getPrincipal(
175 token.person.account.id, access_level=token.permission,
176 scope=token.context)
177
178
54class PlacelessAuthUtility:179class PlacelessAuthUtility:
55 """An authentication service which holds no state aside from its180 """An authentication service which holds no state aside from its
56 ZCML configuration, implemented as a utility.181 ZCML configuration, implemented as a utility.
@@ -75,9 +200,8 @@
75 # as the login form is never visited for BasicAuth.200 # as the login form is never visited for BasicAuth.
76 # This we treat each request as a separate201 # This we treat each request as a separate
77 # login/logout.202 # login/logout.
78 notify(BasicAuthLoggedInEvent(203 notify(
79 request, login, principal204 BasicAuthLoggedInEvent(request, login, principal))
80 ))
81 return principal205 return principal
82206
83 def _authenticateUsingCookieAuth(self, request):207 def _authenticateUsingCookieAuth(self, request):
@@ -190,7 +314,8 @@
190 plaintext = str(plaintext)314 plaintext = str(plaintext)
191 if salt is None:315 if salt is None:
192 salt = self.generate_salt()316 salt = self.generate_salt()
193 v = binascii.b2a_base64(hashlib.sha1(plaintext + salt).digest() + salt)317 v = binascii.b2a_base64(
318 hashlib.sha1(plaintext + salt).digest() + salt)
194 return v[:-1]319 return v[:-1]
195320
196 def validate(self, plaintext, encrypted):321 def validate(self, plaintext, encrypted):
@@ -334,6 +459,7 @@
334459
335# zope.app.apidoc expects our principals to be adaptable into IAnnotations, so460# zope.app.apidoc expects our principals to be adaptable into IAnnotations, so
336# we use these dummy adapters here just to make that code not OOPS.461# we use these dummy adapters here just to make that code not OOPS.
462
337class TemporaryPrincipalAnnotations(UserDict):463class TemporaryPrincipalAnnotations(UserDict):
338 implements(IAnnotations)464 implements(IAnnotations)
339 adapts(ILaunchpadPrincipal, IPreferenceGroup)465 adapts(ILaunchpadPrincipal, IPreferenceGroup)
340466
=== modified file 'lib/canonical/launchpad/webapp/servers.py'
--- lib/canonical/launchpad/webapp/servers.py 2010-09-10 06:38:15 +0000
+++ lib/canonical/launchpad/webapp/servers.py 2010-09-20 13:21:06 +0000
@@ -8,7 +8,6 @@
8__metaclass__ = type8__metaclass__ = type
99
10import cgi10import cgi
11from datetime import datetime
12import threading11import threading
13import xmlrpclib12import xmlrpclib
1413
@@ -22,7 +21,6 @@
22 WebServiceRequestTraversal,21 WebServiceRequestTraversal,
23 )22 )
24from lazr.uri import URI23from lazr.uri import URI
25import pytz
26import transaction24import transaction
27from transaction.interfaces import ISynchronizer25from transaction.interfaces import ISynchronizer
28from zc.zservertracelog.tracelog import Server as ZServerTracelogServer26from zc.zservertracelog.tracelog import Server as ZServerTracelogServer
@@ -50,10 +48,7 @@
50 XMLRPCRequest,48 XMLRPCRequest,
51 XMLRPCResponse,49 XMLRPCResponse,
52 )50 )
53from zope.security.interfaces import (51from zope.security.interfaces import IParticipation
54 IParticipation,
55 Unauthorized,
56 )
57from zope.security.proxy import (52from zope.security.proxy import (
58 isinstance as zope_isinstance,53 isinstance as zope_isinstance,
59 removeSecurityProxy,54 removeSecurityProxy,
@@ -68,17 +63,9 @@
68 IPrivateApplication,63 IPrivateApplication,
69 IWebServiceApplication,64 IWebServiceApplication,
70 )65 )
71from canonical.launchpad.interfaces.oauth import (
72 ClockSkew,
73 IOAuthConsumerSet,
74 IOAuthSignedRequest,
75 NonceAlreadyUsed,
76 TimestampOrderingError,
77 )
78import canonical.launchpad.layers66import canonical.launchpad.layers
79from canonical.launchpad.webapp.authentication import (67from canonical.launchpad.webapp.authentication import (
80 check_oauth_signature,68 get_oauth_principal,
81 get_oauth_authorization,
82 )69 )
83from canonical.launchpad.webapp.authorization import (70from canonical.launchpad.webapp.authorization import (
84 LAUNCHPAD_SECURITY_POLICY_CACHE_KEY,71 LAUNCHPAD_SECURITY_POLICY_CACHE_KEY,
@@ -93,8 +80,6 @@
93 INotificationRequest,80 INotificationRequest,
94 INotificationResponse,81 INotificationResponse,
95 IPlacelessAuthUtility,82 IPlacelessAuthUtility,
96 IPlacelessLoginSource,
97 OAuthPermission,
98 )83 )
99from canonical.launchpad.webapp.notifications import (84from canonical.launchpad.webapp.notifications import (
100 NotificationList,85 NotificationList,
@@ -1216,83 +1201,7 @@
1216 if request_path.startswith("/%s" % web_service_config.path_override):1201 if request_path.startswith("/%s" % web_service_config.path_override):
1217 return super(WebServicePublication, self).getPrincipal(request)1202 return super(WebServicePublication, self).getPrincipal(request)
12181203
1219 # Fetch OAuth authorization information from the request.1204 return get_oauth_principal(request)
1220 form = get_oauth_authorization(request)
1221
1222 consumer_key = form.get('oauth_consumer_key')
1223 consumers = getUtility(IOAuthConsumerSet)
1224 consumer = consumers.getByKey(consumer_key)
1225 token_key = form.get('oauth_token')
1226 anonymous_request = (token_key == '')
1227
1228 if consumer_key is None:
1229 # Either the client's OAuth implementation is broken, or
1230 # the user is trying to make an unauthenticated request
1231 # using wget or another OAuth-ignorant application.
1232 # Try to retrieve a consumer based on the User-Agent
1233 # header.
1234 anonymous_request = True
1235 consumer_key = request.getHeader('User-Agent', '')
1236 if consumer_key == '':
1237 raise Unauthorized(
1238 'Anonymous requests must provide a User-Agent.')
1239 consumer = consumers.getByKey(consumer_key)
1240
1241 if consumer is None:
1242 if anonymous_request:
1243 # This is the first time anyone has tried to make an
1244 # anonymous request using this consumer name (or user
1245 # agent). Dynamically create the consumer.
1246 #
1247 # In the normal website this wouldn't be possible
1248 # because GET requests have their transactions rolled
1249 # back. But webservice requests always have their
1250 # transactions committed so that we can keep track of
1251 # the OAuth nonces and prevent replay attacks.
1252 if consumer_key == '' or consumer_key is None:
1253 raise Unauthorized("No consumer key specified.")
1254 consumer = consumers.new(consumer_key, '')
1255 else:
1256 # An unknown consumer can never make a non-anonymous
1257 # request, because access tokens are registered with a
1258 # specific, known consumer.
1259 raise Unauthorized('Unknown consumer (%s).' % consumer_key)
1260 if anonymous_request:
1261 # Skip the OAuth verification step and let the user access the
1262 # web service as an unauthenticated user.
1263 #
1264 # XXX leonardr 2009-12-15 bug=496964: Ideally we'd be
1265 # auto-creating a token for the anonymous user the first
1266 # time, passing it through the OAuth verification step,
1267 # and using it on all subsequent anonymous requests.
1268 alsoProvides(request, IOAuthSignedRequest)
1269 auth_utility = getUtility(IPlacelessAuthUtility)
1270 return auth_utility.unauthenticatedPrincipal()
1271 token = consumer.getAccessToken(token_key)
1272 if token is None:
1273 raise Unauthorized('Unknown access token (%s).' % token_key)
1274 nonce = form.get('oauth_nonce')
1275 timestamp = form.get('oauth_timestamp')
1276 try:
1277 token.checkNonceAndTimestamp(nonce, timestamp)
1278 except (NonceAlreadyUsed, TimestampOrderingError, ClockSkew), e:
1279 raise Unauthorized('Invalid nonce/timestamp: %s' % e)
1280 now = datetime.now(pytz.timezone('UTC'))
1281 if token.permission == OAuthPermission.UNAUTHORIZED:
1282 raise Unauthorized('Unauthorized token (%s).' % token.key)
1283 elif token.date_expires is not None and token.date_expires <= now:
1284 raise Unauthorized('Expired token (%s).' % token.key)
1285 elif not check_oauth_signature(request, consumer, token):
1286 raise Unauthorized('Invalid signature.')
1287 else:
1288 # Everything is fine, let's return the principal.
1289 pass
1290 alsoProvides(request, IOAuthSignedRequest)
1291 principal = getUtility(IPlacelessLoginSource).getPrincipal(
1292 token.person.account.id, access_level=token.permission,
1293 scope=token.context)
1294
1295 return principal
12961205
12971206
1298class LaunchpadWebServiceRequestTraversal(WebServiceRequestTraversal):1207class LaunchpadWebServiceRequestTraversal(WebServiceRequestTraversal):
12991208
=== modified file 'lib/canonical/launchpad/zcml/launchpad.zcml'
--- lib/canonical/launchpad/zcml/launchpad.zcml 2010-08-02 02:23:26 +0000
+++ lib/canonical/launchpad/zcml/launchpad.zcml 2010-09-20 13:21:06 +0000
@@ -266,14 +266,14 @@
266 name="+authorize-token"266 name="+authorize-token"
267 class="canonical.launchpad.browser.OAuthAuthorizeTokenView"267 class="canonical.launchpad.browser.OAuthAuthorizeTokenView"
268 template="../templates/oauth-authorize.pt"268 template="../templates/oauth-authorize.pt"
269 permission="launchpad.AnyPerson" />269 permission="zope.Public" />
270270
271 <browser:page271 <browser:page
272 for="canonical.launchpad.webapp.interfaces.ILaunchpadApplication"272 for="canonical.launchpad.webapp.interfaces.ILaunchpadApplication"
273 name="+token-authorized"273 name="+token-authorized"
274 class="canonical.launchpad.browser.OAuthTokenAuthorizedView"274 class="canonical.launchpad.browser.OAuthTokenAuthorizedView"
275 template="../templates/token-authorized.pt"275 template="../templates/token-authorized.pt"
276 permission="launchpad.AnyPerson" />276 permission="zope.Public" />
277277
278 <browser:page278 <browser:page
279 for="canonical.launchpad.webapp.interfaces.ILaunchpadApplication"279 for="canonical.launchpad.webapp.interfaces.ILaunchpadApplication"
280280
=== modified file 'lib/lp/testing/__init__.py'
--- lib/lp/testing/__init__.py 2010-09-18 08:00:27 +0000
+++ lib/lp/testing/__init__.py 2010-09-20 13:21:06 +0000
@@ -28,6 +28,7 @@
28 'map_branch_contents',28 'map_branch_contents',
29 'normalize_whitespace',29 'normalize_whitespace',
30 'oauth_access_token_for',30 'oauth_access_token_for',
31 'OAuthSigningBrowser',
31 'person_logged_in',32 'person_logged_in',
32 'record_statements',33 'record_statements',
33 'run_with_login',34 'run_with_login',
@@ -145,6 +146,7 @@
145 launchpadlib_credentials_for,146 launchpadlib_credentials_for,
146 launchpadlib_for,147 launchpadlib_for,
147 oauth_access_token_for,148 oauth_access_token_for,
149 OAuthSigningBrowser,
148 )150 )
149from lp.testing.fixture import ZopeEventHandlerFixture151from lp.testing.fixture import ZopeEventHandlerFixture
150from lp.testing.matchers import Provides152from lp.testing.matchers import Provides
@@ -222,7 +224,7 @@
222224
223class StormStatementRecorder:225class StormStatementRecorder:
224 """A storm tracer to count queries.226 """A storm tracer to count queries.
225 227
226 This exposes the count and queries as lp.testing._webservice.QueryCollector228 This exposes the count and queries as lp.testing._webservice.QueryCollector
227 does permitting its use with the HasQueryCount matcher.229 does permitting its use with the HasQueryCount matcher.
228230
@@ -681,6 +683,7 @@
681 def assertTextMatchesExpressionIgnoreWhitespace(self,683 def assertTextMatchesExpressionIgnoreWhitespace(self,
682 regular_expression_txt,684 regular_expression_txt,
683 text):685 text):
686
684 def normalise_whitespace(text):687 def normalise_whitespace(text):
685 return ' '.join(text.split())688 return ' '.join(text.split())
686 pattern = re.compile(689 pattern = re.compile(
@@ -857,6 +860,7 @@
857 callable, and events are the events emitted by the callable.860 callable, and events are the events emitted by the callable.
858 """861 """
859 events = []862 events = []
863
860 def on_notify(event):864 def on_notify(event):
861 events.append(event)865 events.append(event)
862 old_subscribers = zope.event.subscribers[:]866 old_subscribers = zope.event.subscribers[:]
863867
=== modified file 'lib/lp/testing/_webservice.py'
--- lib/lp/testing/_webservice.py 2010-08-20 20:31:18 +0000
+++ lib/lp/testing/_webservice.py 2010-09-20 13:21:06 +0000
@@ -9,34 +9,104 @@
9 'launchpadlib_credentials_for',9 'launchpadlib_credentials_for',
10 'launchpadlib_for',10 'launchpadlib_for',
11 'oauth_access_token_for',11 'oauth_access_token_for',
12 'OAuthSigningBrowser',
12 ]13 ]
1314
1415
15import shutil16import shutil
16import tempfile17import tempfile
17
18from launchpadlib.credentials import (
19 AccessToken,
20 Credentials,
21 )
22from launchpadlib.launchpad import Launchpad
23import transaction18import transaction
19from urllib2 import BaseHandler
20
21from oauth.oauth import OAuthRequest, OAuthSignatureMethod_PLAINTEXT
22
24from zope.app.publication.interfaces import IEndRequestEvent23from zope.app.publication.interfaces import IEndRequestEvent
25from zope.app.testing import ztapi24from zope.app.testing import ztapi
25from zope.testbrowser.testing import Browser
26from zope.component import getUtility26from zope.component import getUtility
27import zope.testing.cleanup27import zope.testing.cleanup
2828
29from launchpadlib.credentials import (
30 AccessToken,
31 Credentials,
32 )
33from launchpadlib.launchpad import Launchpad
34
35from lp.testing._login import (
36 login,
37 logout,
38 )
39
29from canonical.launchpad.interfaces import (40from canonical.launchpad.interfaces import (
30 IOAuthConsumerSet,41 IOAuthConsumerSet,
31 IPersonSet,42 IPersonSet,
43 OAUTH_REALM,
32 )44 )
33from canonical.launchpad.webapp.adapter import get_request_statements45from canonical.launchpad.webapp.adapter import get_request_statements
34from canonical.launchpad.webapp.interaction import ANONYMOUS46from canonical.launchpad.webapp.interaction import ANONYMOUS
35from canonical.launchpad.webapp.interfaces import OAuthPermission47from canonical.launchpad.webapp.interfaces import OAuthPermission
36from lp.testing._login import (48
37 login,49
38 logout,50class OAuthSigningHandler(BaseHandler):
39 )51 """A urllib2 handler that signs requests with an OAuth token."""
52
53 def __init__(self, consumer, token):
54 """Constructor
55
56 :param consumer: An OAuth consumer.
57 :param token: An OAuth token.
58 """
59 self.consumer = consumer
60 self.token = token
61
62 def default_open(self, req):
63 """Set the Authorization header for the outgoing request."""
64 signer = OAuthRequest.from_consumer_and_token(
65 self.consumer, self.token)
66 signer.sign_request(
67 OAuthSignatureMethod_PLAINTEXT(), self.consumer, self.token)
68 auth_header = signer.to_header(OAUTH_REALM)['Authorization']
69 req.headers['Authorization'] = auth_header
70
71
72class UserAgentFilteringHandler(BaseHandler):
73 """A urllib2 handler that replaces the User-Agent header.
74
75 [XXX bug=638058] This is a hack to work around a bug in
76 zope.testbrowser.
77 """
78 def __init__(self, user_agent):
79 """Constructor."""
80 self.user_agent = user_agent
81
82 def default_open(self, req):
83 """Set the User-Agent header for the outgoing request."""
84 req.headers['User-Agent'] = self.user_agent
85
86
87class OAuthSigningBrowser(Browser):
88 """A browser that signs each outgoing request with an OAuth token.
89
90 This lets us simulate the behavior of the Launchpad Credentials
91 Manager.
92 """
93 def __init__(self, consumer, token, user_agent=None):
94 """Constructor.
95
96 :param consumer: An OAuth consumer.
97 :param token: An OAuth token.
98 :param user_agent: The User-Agent string to send.
99 """
100 super(OAuthSigningBrowser, self).__init__()
101 self.mech_browser.add_handler(
102 OAuthSigningHandler(consumer, token))
103 if user_agent is not None:
104 self.mech_browser.add_handler(
105 UserAgentFilteringHandler(user_agent))
106
107 # This will give us tracebacks instead of unhelpful error
108 # messages.
109 self.handleErrors = False
40110
41111
42def oauth_access_token_for(consumer_name, person, permission, context=None):112def oauth_access_token_for(consumer_name, person, permission, context=None):

Subscribers

People subscribed via source and target branches

to status/vote changes: