Merge lp:~leonardr/launchpad/publish-tokens-2 into lp:launchpad/db-devel

Proposed by Leonard Richardson
Status: Rejected
Rejected by: Aaron Bentley
Proposed branch: lp:~leonardr/launchpad/publish-tokens-2
Merge into: lp:launchpad/db-devel
Diff against target: 988 lines (+380/-120)
21 files modified
lib/canonical/launchpad/browser/oauth.py (+3/-9)
lib/canonical/launchpad/database/oauth.py (+58/-6)
lib/canonical/launchpad/doc/oauth.txt (+32/-8)
lib/canonical/launchpad/doc/webapp-authorization.txt (+3/-11)
lib/canonical/launchpad/doc/webapp-publication.txt (+7/-1)
lib/canonical/launchpad/interfaces/_schema_circular_imports.py (+3/-0)
lib/canonical/launchpad/interfaces/oauth.py (+80/-30)
lib/canonical/launchpad/security.py (+37/-7)
lib/canonical/launchpad/testing/pages.py (+15/-5)
lib/canonical/launchpad/tests/test_webservice_oauth.py (+82/-0)
lib/canonical/launchpad/webapp/authentication.py (+1/-6)
lib/canonical/launchpad/webapp/authorization.py (+2/-1)
lib/canonical/launchpad/webapp/servers.py (+7/-23)
lib/canonical/launchpad/webapp/tests/test_publication.py (+6/-1)
lib/canonical/launchpad/zcml/oauth.zcml (+6/-1)
lib/lp/bugs/tests/test_bugs_webservice.py (+8/-0)
lib/lp/registry/browser/person.py (+9/-0)
lib/lp/registry/interfaces/person.py (+5/-1)
lib/lp/registry/stories/webservice/xx-person.txt (+2/-0)
lib/lp/testing/_webservice.py (+13/-9)
versions.cfg (+1/-1)
To merge this branch: bzr merge lp:~leonardr/launchpad/publish-tokens-2
Reviewer Review Type Date Requested Status
Guilherme Salgado (community) code Approve
Aaron Bentley (community) Needs Fixing
Review via email: mp+34123@code.launchpad.net

Description of the change

This branch publishes OAuth access tokens through the web service. This meant adding traversal rules an canonical URL data for OAuthAccessToken objects.

A user can only view their own access tokens, and only by using a client that's been given the GRANT_PERMISSIONS access level. The old rules apply when accessing access tokens through the website: a user can always access their own tokens, and admins can access other peoples'.

Because I changed the rules for permission checks on OAuth access tokens to check the current request, I've added removeSecurityProxy calls in test helper classes like oauth_token_for_user and tests in which there is no current request. Please check this code to make sure I haven't opened a security hole.

This branch takes advantage of a new version of lazr.restful so that the read-write 'permission' field of IOAuthAccessToken can be published as read-only in the web service. This code is tested in lazr.restful, but I can add a Launchpad test as well.

To post a comment you must log in.
Revision history for this message
Aaron Bentley (abentley) wrote :

lib/canonical/launchpad/pagetests/webservice/oauth.txt and most of the changes to lib/canonical/launchpad/doc/oauth.txt look like their primary purpose is to provide test coverage, not to provide documentation, so please write them as UnitTests.

A lot of the formatting of line-wraps doesn't match our official style: https://dev.launchpad.net/PythonStyleGuide#Whitespace%20and%20Wrapping and often matches the "Do not indent your code like this" style. Please fix.

It appears that the removeSecurityProxy in lib/canonical/launchpad/webapp/servers.py could be eliminated by moving some of the code into a method on OAuthAccessToken. Please try this, and if it's not possible, provide a follow-up comment explaining this.

review: Needs Fixing
Revision history for this message
Guilherme Salgado (salgado) wrote :

Leonard asked me to do a follow up review of http://pastebin.ubuntu.com/486435/:

The signature of check_oauth_signature() was changed and I see only one callsite changed, so if we indeed have only one callsite I think it might make sense to make the new argument (token_secret) required as it's always provided in that callsite.

Also, it'd be nice to follow the naming scheme we use for other interfaces that are divided into multiple ones based on the permissions of the attributes/methods (e.g. IFooPublic, IFooEditRestricted)?

review: Approve (code)
Revision history for this message
Guilherme Salgado (salgado) wrote :

And here's another follow up review: http://pastebin.ubuntu.com/486932/

+class OAuthTokenBase(OAuthBase):
+ """Base implementation of code to check an OAuth-signed request."""

I'd change that docstring to say just that this is a base class for OAuthToken classes, as we may want to add code not related to signature checking here in the future.

+
+ def checkSignature(self, request):
+ return check_oauth_signature(request, self.consumer, self.secret)

And here you should add a docstring pointing to the interface where the method is defined.

-Now consider a principal authorized to create OAuth tokens. Whenever
-it's not creating OAuth tokens, it has a level of permission
-equivalent to READ_PUBLIC.
+A principal with the GRANT_PERMISSIONS authorization level has a of
+permission equivalent to WRITE_PRIVATE.

Why is the permission changing in this incremental diff? Also, s/of//?

- >>> access_token = token.createAccessToken()
+ >>> access_token = removeSecurityProxy(token.createAccessToken())

Why do you need to remove the security proxy now?

review: Needs Information (code)
Revision history for this message
Leonard Richardson (leonardr) wrote :

> -Now consider a principal authorized to create OAuth tokens. Whenever
> -it's not creating OAuth tokens, it has a level of permission
> -equivalent to READ_PUBLIC.
> +A principal with the GRANT_PERMISSIONS authorization level has a of
> +permission equivalent to WRITE_PRIVATE.
>
> Why is the permission changing in this incremental diff? Also, s/of//?

The permission changed last time, after we decided there was no easy way to make GRANT_PERMISSIONS have a 'write' level of access for OAuth tokens and a 'read' level for everything else. I'm just fixing the test.

> - >>> access_token = token.createAccessToken()
> + >>> access_token = removeSecurityProxy(token.createAccessToken())
>
> Why do you need to remove the security proxy now?

Again, I'm just fixing a test failure. The security proxy started breaking this test as soon as I introduced the security checker that assumes there's a current request.

Revision history for this message
Guilherme Salgado (salgado) :
review: Approve (code)

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-02 21:17:47 +0000
@@ -83,7 +83,7 @@
83 if consumer is None:83 if consumer is None:
84 consumer = consumer_set.new(key=consumer_key)84 consumer = consumer_set.new(key=consumer_key)
8585
86 if not check_oauth_signature(self.request, consumer, None):86 if not check_oauth_signature(self.request, consumer, ''):
87 return u''87 return u''
8888
89 token = consumer.newRequestToken()89 token = consumer.newRequestToken()
@@ -284,13 +284,12 @@
284 self.request.unauthorized(OAUTH_CHALLENGE)284 self.request.unauthorized(OAUTH_CHALLENGE)
285 return u'No request token specified.'285 return u'No request token specified.'
286286
287 if not check_oauth_signature(self.request, consumer, token):287 if not token.checkSignature(self.request):
288 return u'Invalid OAuth signature.'288 return u'Invalid OAuth signature.'
289289
290 if not token.is_reviewed:290 if not token.is_reviewed:
291 self.request.unauthorized(OAUTH_CHALLENGE)291 self.request.unauthorized(OAUTH_CHALLENGE)
292 return u'Request token has not yet been reviewed. Try again later.'292 return u'Request token has not yet been reviewed. Try again later.'
293
294 if token.permission == OAuthPermission.UNAUTHORIZED:293 if token.permission == OAuthPermission.UNAUTHORIZED:
295 # The end-user explicitly refused to authorize this294 # The end-user explicitly refused to authorize this
296 # token. We send 403 ("Forbidden") instead of 401295 # token. We send 403 ("Forbidden") instead of 401
@@ -301,9 +300,4 @@
301 return u'End-user refused to authorize request token.'300 return u'End-user refused to authorize request token.'
302301
303 access_token = token.createAccessToken()302 access_token = token.createAccessToken()
304 context_name = None303 return access_token.form_encoded
305 if access_token.context is not None:
306 context_name = access_token.context.name
307 body = u'oauth_token=%s&oauth_token_secret=%s&lp.context=%s' % (
308 access_token.key, access_token.secret, context_name)
309 return body
310304
=== 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-02 21:17:47 +0000
@@ -24,6 +24,7 @@
24from storm.expr import And24from storm.expr import And
25from zope.component import getUtility25from zope.component import getUtility
26from zope.interface import implements26from zope.interface import implements
27from zope.security.interfaces import Unauthorized
2728
28from canonical.database.constants import UTC_NOW29from canonical.database.constants import UTC_NOW
29from canonical.database.datetimecol import UtcDateTimeCol30from canonical.database.datetimecol import UtcDateTimeCol
@@ -36,21 +37,25 @@
36from canonical.launchpad.interfaces import (37from canonical.launchpad.interfaces import (
37 ClockSkew,38 ClockSkew,
38 IOAuthAccessToken,39 IOAuthAccessToken,
40 IOAuthAccessTokenPublic,
39 IOAuthConsumer,41 IOAuthConsumer,
40 IOAuthConsumerSet,42 IOAuthConsumerSet,
41 IOAuthNonce,43 IOAuthNonce,
42 IOAuthRequestToken,44 IOAuthRequestToken,
43 IOAuthRequestTokenSet,45 IOAuthRequestTokenSet,
46 IOAuthTokenPublic,
44 NonceAlreadyUsed,47 NonceAlreadyUsed,
45 TimestampOrderingError,48 TimestampOrderingError,
46 )49 )
47from canonical.launchpad.webapp.interfaces import (50from canonical.launchpad.webapp.interfaces import (
48 AccessLevel,51 AccessLevel,
52 ICanonicalUrlData,
49 IStoreSelector,53 IStoreSelector,
50 MAIN_STORE,54 MAIN_STORE,
51 MASTER_FLAVOR,55 MASTER_FLAVOR,
52 OAuthPermission,56 OAuthPermission,
53 )57 )
58from canonical.launchpad.webapp.authentication import check_oauth_signature
54from lp.registry.interfaces.distribution import IDistribution59from lp.registry.interfaces.distribution import IDistribution
55from lp.registry.interfaces.distributionsourcepackage import (60from lp.registry.interfaces.distributionsourcepackage import (
56 IDistributionSourcePackage,61 IDistributionSourcePackage,
@@ -134,9 +139,28 @@
134 return OAuthConsumer.selectOneBy(key=key)139 return OAuthConsumer.selectOneBy(key=key)
135140
136141
137class OAuthAccessToken(OAuthBase):142class OAuthTokenBase(OAuthBase):
143 """Base implementation of code for OAuth tokens."""
144
145 def checkSignature(self, request):
146 """See `IOAuthTokenPublic`."""
147 return check_oauth_signature(request, self.consumer, self.secret)
148
149
150class OAuthAccessToken(OAuthTokenBase):
138 """See `IOAuthAccessToken`."""151 """See `IOAuthAccessToken`."""
139 implements(IOAuthAccessToken)152 implements(
153 IOAuthAccessToken, IOAuthAccessTokenPublic, ICanonicalUrlData)
154
155 @property
156 def inside(self):
157 return self.person
158
159 @property
160 def path(self):
161 return "+oauth-access-token/" + self.key
162
163 rootsite = 'api'
140164
141 consumer = ForeignKey(165 consumer = ForeignKey(
142 dbName='consumer', foreignKey='OAuthConsumer', notNull=True)166 dbName='consumer', foreignKey='OAuthConsumer', notNull=True)
@@ -178,7 +202,7 @@
178 return None202 return None
179203
180 def checkNonceAndTimestamp(self, nonce, timestamp):204 def checkNonceAndTimestamp(self, nonce, timestamp):
181 """See `IOAuthAccessToken`."""205 """See `IOAuthAccessTokenPublic`."""
182 timestamp = float(timestamp)206 timestamp = float(timestamp)
183 date = datetime.fromtimestamp(timestamp, pytz.UTC)207 date = datetime.fromtimestamp(timestamp, pytz.UTC)
184 # Determine if the timestamp is too far off from now.208 # Determine if the timestamp is too far off from now.
@@ -209,10 +233,38 @@
209 return OAuthNonce(233 return OAuthNonce(
210 access_token=self, nonce=nonce, request_timestamp=date)234 access_token=self, nonce=nonce, request_timestamp=date)
211235
212236 def authorize(self, request, nonce, timestamp):
213class OAuthRequestToken(OAuthBase):237 """See `IOAuthAccessTokenPublic`."""
238 try:
239 self.checkNonceAndTimestamp(nonce, timestamp)
240 except (NonceAlreadyUsed, TimestampOrderingError, ClockSkew), e:
241 raise Unauthorized('Invalid nonce/timestamp: %s' % e)
242 now = datetime.now(pytz.timezone('UTC'))
243 if self.permission == OAuthPermission.UNAUTHORIZED:
244 raise Unauthorized('Unauthorized token (%s).' % self.key)
245 elif self.date_expires is not None and self.date_expires <= now:
246 raise Unauthorized('Expired token (%s).' % self.key)
247 elif not self.checkSignature(request):
248 raise Unauthorized('Invalid signature.')
249 else:
250 # Everything is fine, let's return the principal.
251 pass
252 return (self.person.account.id, self.permission, self.context)
253
254 @property
255 def form_encoded(self):
256 """See `IOAuthAccessTokenPublic`."""
257 context_name = None
258 if self.context is not None:
259 context_name = self.context.name
260 body = u'oauth_token=%s&oauth_token_secret=%s&lp.context=%s' % (
261 self.key, self.secret, context_name)
262 return body
263
264
265class OAuthRequestToken(OAuthTokenBase):
214 """See `IOAuthRequestToken`."""266 """See `IOAuthRequestToken`."""
215 implements(IOAuthRequestToken)267 implements(IOAuthRequestToken, IOAuthTokenPublic)
216268
217 consumer = ForeignKey(269 consumer = ForeignKey(
218 dbName='consumer', foreignKey='OAuthConsumer', notNull=True)270 dbName='consumer', foreignKey='OAuthConsumer', notNull=True)
219271
=== modified file 'lib/canonical/launchpad/doc/oauth.txt'
--- lib/canonical/launchpad/doc/oauth.txt 2010-04-16 15:06:55 +0000
+++ lib/canonical/launchpad/doc/oauth.txt 2010-09-02 21:17:47 +0000
@@ -186,6 +186,13 @@
186 True186 True
187 >>> request_token.permission187 >>> request_token.permission
188 <DBItem OAuthPermission.WRITE_PUBLIC...188 <DBItem OAuthPermission.WRITE_PUBLIC...
189
190Because an access token can only be viewed or edited by its owner or
191an admin, we need to log in at this point.
192
193 >>> token_owner = request_token.person
194 >>> login_person(token_owner)
195
189 >>> access_token = request_token.createAccessToken()196 >>> access_token = request_token.createAccessToken()
190 >>> verifyObject(IOAuthAccessToken, access_token)197 >>> verifyObject(IOAuthAccessToken, access_token)
191 True198 True
@@ -245,14 +252,30 @@
245 >>> consumer.getAccessToken(access_token.key) == access_token252 >>> consumer.getAccessToken(access_token.key) == access_token
246 True253 True
247254
248An access token can only be changed by the person associated with it.255In general, one user cannot edit another user's access token.
249256
250 >>> access_token.permission = OAuthPermission.WRITE_PUBLIC257 >>> login('no-priv@canonical.com')
251 Traceback (most recent call last):258 >>> access_token.permission
252 ...259 Traceback (most recent call last):
253 Unauthorized:...260 ...
254 >>> login_person(access_token.person)261 Unauthorized:...
255 >>> access_token.permission = AccessLevel.WRITE_PUBLIC262
263 >>> access_token.permission = AccessLevel.WRITE_PUBLIC
264 Traceback (most recent call last):
265 ...
266 Unauthorized:...
267
268An admin can view and edit someone else's access token.
269
270 >>> login("foo.bar@canonical.com")
271 >>> print access_token.permission.name
272 WRITE_PUBLIC
273 >>> access_token.permission = AccessLevel.WRITE_PUBLIC
274
275And a user can edit their own access tokens.
276
277 >>> login_person(token_owner)
278 >>> access_token.permission = AccessLevel.WRITE_PRIVATE
256279
257From any given person it's possible to retrieve his non-expired access280From any given person it's possible to retrieve his non-expired access
258tokens.281tokens.
@@ -305,6 +328,7 @@
305328
306- We can use a nonce.329- We can use a nonce.
307330
331 >>> login_person(token_owner)
308 >>> import time332 >>> import time
309 >>> now = time.time() - 1333 >>> now = time.time() - 1
310 >>> nonce1 = access_token.checkNonceAndTimestamp('boo', now)334 >>> nonce1 = access_token.checkNonceAndTimestamp('boo', now)
311335
=== modified file 'lib/canonical/launchpad/doc/webapp-authorization.txt'
--- lib/canonical/launchpad/doc/webapp-authorization.txt 2010-08-24 16:44:42 +0000
+++ lib/canonical/launchpad/doc/webapp-authorization.txt 2010-09-02 21:17:47 +0000
@@ -79,9 +79,8 @@
79 >>> check_permission('launchpad.View', bug_1)79 >>> check_permission('launchpad.View', bug_1)
80 False80 False
8181
82Now consider a principal authorized to create OAuth tokens. Whenever82A principal with the GRANT_PERMISSIONS authorization level has a level
83it's not creating OAuth tokens, it has a level of permission83of permission equivalent to WRITE_PUBLIC.
84equivalent to READ_PUBLIC.
8584
86 >>> principal.access_level = AccessLevel.GRANT_PERMISSIONS85 >>> principal.access_level = AccessLevel.GRANT_PERMISSIONS
87 >>> setupInteraction(principal)86 >>> setupInteraction(principal)
@@ -89,14 +88,7 @@
89 False88 False
9089
91 >>> check_permission('launchpad.Edit', sample_person)90 >>> check_permission('launchpad.Edit', sample_person)
92 False91 True
93
94This may seem useless from a security standpoint, since once a
95malicious client is authorized to create OAuth tokens, it can escalate
96its privileges at any time by creating a new token for itself. The
97security benefit is more subtle: by discouraging feature creep in
98clients that have this super-access level, we reduce the risk that a
99bug in a _trusted_ client will enable privilege escalation attacks.
10092
101Users logged in through the web application have full access, which93Users logged in through the web application have full access, which
102means they can read/change any object they have access to.94means they can read/change any object they have access to.
10395
=== modified file 'lib/canonical/launchpad/doc/webapp-publication.txt'
--- lib/canonical/launchpad/doc/webapp-publication.txt 2010-08-05 13:23:52 +0000
+++ lib/canonical/launchpad/doc/webapp-publication.txt 2010-09-02 21:17:47 +0000
@@ -1156,14 +1156,20 @@
1156principal's access_level and scope will match what was specified in the1156principal's access_level and scope will match what was specified in the
1157token.1157token.
11581158
1159First let's create a token.
1160
1159 >>> from lp.registry.interfaces.product import IProductSet1161 >>> from lp.registry.interfaces.product import IProductSet
1162 >>> from zope.security.proxy import removeSecurityProxy
1160 >>> getUtility(IStoreSelector).push(MasterDatabasePolicy())1163 >>> getUtility(IStoreSelector).push(MasterDatabasePolicy())
1161 >>> salgado = getUtility(IPersonSet).getByName('salgado')1164 >>> salgado = getUtility(IPersonSet).getByName('salgado')
1162 >>> consumer = getUtility(IOAuthConsumerSet).getByKey('foobar123451432')1165 >>> consumer = getUtility(IOAuthConsumerSet).getByKey('foobar123451432')
1163 >>> token = consumer.newRequestToken()1166 >>> token = consumer.newRequestToken()
1164 >>> firefox = getUtility(IProductSet)['firefox']1167 >>> firefox = getUtility(IProductSet)['firefox']
1165 >>> token.review(salgado, OAuthPermission.WRITE_PUBLIC, context=firefox)1168 >>> token.review(salgado, OAuthPermission.WRITE_PUBLIC, context=firefox)
1166 >>> access_token = token.createAccessToken()1169 >>> access_token = removeSecurityProxy(token.createAccessToken())
1170
1171Now let's use it to make a request.
1172
1167 >>> form = dict(1173 >>> form = dict(
1168 ... oauth_consumer_key='foobar123451432',1174 ... oauth_consumer_key='foobar123451432',
1169 ... oauth_token=access_token.key,1175 ... oauth_token=access_token.key,
11701176
=== modified file 'lib/canonical/launchpad/interfaces/_schema_circular_imports.py'
--- lib/canonical/launchpad/interfaces/_schema_circular_imports.py 2010-08-27 11:19:54 +0000
+++ lib/canonical/launchpad/interfaces/_schema_circular_imports.py 2010-09-02 21:17:47 +0000
@@ -32,6 +32,7 @@
32 IMessage,32 IMessage,
33 IUserToUserEmail,33 IUserToUserEmail,
34 )34 )
35from canonical.launchpad.interfaces.oauth import IOAuthToken
35from lp.blueprints.interfaces.specification import ISpecification36from lp.blueprints.interfaces.specification import ISpecification
36from lp.blueprints.interfaces.specificationbranch import ISpecificationBranch37from lp.blueprints.interfaces.specificationbranch import ISpecificationBranch
37from lp.bugs.interfaces.bug import (38from lp.bugs.interfaces.bug import (
@@ -196,6 +197,8 @@
196patch_choice_parameter_type(197patch_choice_parameter_type(
197 IHasBugs, 'searchTasks', 'hardware_bus', HWBus)198 IHasBugs, 'searchTasks', 'hardware_bus', HWBus)
198199
200IOAuthToken['person'].schema = IPerson
201
199IPreviewDiff['branch_merge_proposal'].schema = IBranchMergeProposal202IPreviewDiff['branch_merge_proposal'].schema = IBranchMergeProposal
200203
201patch_reference_property(IPersonPublic, 'archive', IArchive)204patch_reference_property(IPersonPublic, 'archive', IArchive)
202205
=== modified file 'lib/canonical/launchpad/interfaces/oauth.py'
--- lib/canonical/launchpad/interfaces/oauth.py 2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/interfaces/oauth.py 2010-09-02 21:17:47 +0000
@@ -11,12 +11,15 @@
11 'OAUTH_REALM',11 'OAUTH_REALM',
12 'OAUTH_CHALLENGE',12 'OAUTH_CHALLENGE',
13 'IOAuthAccessToken',13 'IOAuthAccessToken',
14 'IOAuthAccessTokenPublic',
14 'IOAuthConsumer',15 'IOAuthConsumer',
15 'IOAuthConsumerSet',16 'IOAuthConsumerSet',
17 'IOAuthToken',
16 'IOAuthNonce',18 'IOAuthNonce',
17 'IOAuthRequestToken',19 'IOAuthRequestToken',
18 'IOAuthRequestTokenSet',20 'IOAuthRequestTokenSet',
19 'IOAuthSignedRequest',21 'IOAuthSignedRequest',
22 'IOAuthTokenPublic',
20 'NonceAlreadyUsed',23 'NonceAlreadyUsed',
21 'TimestampOrderingError',24 'TimestampOrderingError',
22 'ClockSkew',25 'ClockSkew',
@@ -34,12 +37,14 @@
34 TextLine,37 TextLine,
35 )38 )
3639
40from lazr.restful.declarations import (
41 export_as_webservice_entry, exported)
42
37from canonical.launchpad import _43from canonical.launchpad import _
38from canonical.launchpad.webapp.interfaces import (44from canonical.launchpad.webapp.interfaces import (
39 AccessLevel,45 AccessLevel,
40 OAuthPermission,46 OAuthPermission,
41 )47 )
42from lp.registry.interfaces.person import IPerson
4348
44# The challenge included in responses with a 401 status.49# The challenge included in responses with a 401 status.
45OAUTH_REALM = 'https://api.launchpad.net'50OAUTH_REALM = 'https://api.launchpad.net'
@@ -128,23 +133,28 @@
128 schema=IOAuthConsumer, title=_('The consumer.'),133 schema=IOAuthConsumer, title=_('The consumer.'),
129 description=_("The consumer which will access Launchpad on the "134 description=_("The consumer which will access Launchpad on the "
130 "user's behalf."))135 "user's behalf."))
131 person = Object(136 person = exported(
132 schema=IPerson, title=_('Person'), required=False, readonly=False,137 Object(schema=Interface, title=_('Person'), required=False,
133 description=_('The user on whose behalf the consumer is accessing.'))138 readonly=False, description=_(
134 date_created = Datetime(139 'The user on whose behalf the consumer is accessing.')),
135 title=_('Date created'), required=True, readonly=True)140 readonly=True)
136 date_expires = Datetime(141 date_created = exported(
137 title=_('Date expires'), required=False, readonly=False,142 Datetime(title=_('Date created'), required=True, readonly=True))
138 description=_('From this date onwards this token can not be used '143 date_expires = exported(
139 'by the consumer to access protected resources.'))144 Datetime(
140 key = TextLine(145 title=_('Date expires'), required=False, readonly=False,
141 title=_('Key'), required=True, readonly=True,146 description=_('From this date onwards this token can not be used '
142 description=_('The key used to identify this token. It is included '147 'by the consumer to access protected resources.')))
143 'by the consumer in each request.'))148 key = exported(
144 secret = TextLine(149 TextLine(
145 title=_('Secret'), required=True, readonly=True,150 title=_('Key'), required=True, readonly=True,
146 description=_('The secret associated with this token. It is used '151 description=_('The key used to identify this token. It is '
147 'by the consumer to sign its requests.'))152 'included by the consumer in each request.')))
153 secret = exported(
154 TextLine(
155 title=_('Secret'), required=True, readonly=True,
156 description=_('The secret associated with this token. It is used '
157 'by the consumer to sign its requests.')))
148 product = Choice(title=_('Project'), required=False, vocabulary='Product')158 product = Choice(title=_('Project'), required=False, vocabulary='Product')
149 project = Choice(159 project = Choice(
150 title=_('Project'), required=False, vocabulary='ProjectGroup')160 title=_('Project'), required=False, vocabulary='ProjectGroup')
@@ -155,18 +165,22 @@
155 context = Attribute("FIXME")165 context = Attribute("FIXME")
156166
157167
158class IOAuthAccessToken(IOAuthToken):168class IOAuthTokenPublic(Interface):
159 """A token used by a consumer to access protected resources in LP.169 """Public access to methods necessary to authenticate OAuth tokens."""
160170
161 It's created automatically once a user logs in and grants access to a171 def checkSignature(request):
162 consumer. The consumer then exchanges an `IOAuthRequestToken` for it.172 """Check the signature of an incoming request.
163 """173
164174 :param request: A request that's supposedly signed with this
165 permission = Choice(175 token's secret.
166 title=_('Access level'), required=True, readonly=False,176 :return: True if the request is correctly signed, False otherwise.
167 vocabulary=AccessLevel,177 """
168 description=_('The level of access given to the application acting '178
169 'on your behalf.'))179
180class IOAuthAccessTokenPublic(IOAuthTokenPublic):
181 """Public access to methods necessary to authenticate access tokens."""
182
183 form_encoded = Attribute("Form-encoded description of the token.")
170184
171 def checkNonceAndTimestamp(nonce, timestamp):185 def checkNonceAndTimestamp(nonce, timestamp):
172 """Verify the nonce and timestamp.186 """Verify the nonce and timestamp.
@@ -189,6 +203,42 @@
189 and associated with this token.203 and associated with this token.
190 """204 """
191205
206 def authorize(request, nonce, timestamp):
207 """Authorize an incoming request against an OAuth token.
208
209 :param request: A request that's supposedly signed with this
210 token's secret.
211 :param nonce: An OAuth nonce provided with the request.
212 :param timestamp: An OAuth timestamp provided with the request.
213
214 :raise Unauthorized: If the request is not correctly signed
215 with this token's secret, if the nonce or timestamp are
216 bad, if the token has expired, if the token does not
217 actually grant access, or if for any other reason the
218 request cannot be authorized.
219
220 :return: A 3-tuple (account_id, access_level, context)
221 containing all the information necessary to identify the
222 authenticated principal.
223 """
224
225
226class IOAuthAccessToken(IOAuthToken):
227 """A token used by a consumer to access protected resources in LP.
228
229 It's created automatically once a user logs in and grants access to a
230 consumer. The consumer then exchanges an `IOAuthRequestToken` for it.
231 """
232 export_as_webservice_entry()
233
234 permission = exported(
235 Choice(
236 title=_('Access level'), required=True, readonly=False,
237 vocabulary=AccessLevel,
238 description=_('The level of access given to the application acting '
239 'on your behalf.')),
240 readonly=True)
241
192242
193class IOAuthRequestToken(IOAuthToken):243class IOAuthRequestToken(IOAuthToken):
194 """A token used by a consumer to ask the user to authenticate on LP.244 """A token used by a consumer to ask the user to authenticate on LP.
195245
=== modified file 'lib/canonical/launchpad/security.py'
--- lib/canonical/launchpad/security.py 2010-08-31 20:12:22 +0000
+++ lib/canonical/launchpad/security.py 2010-09-02 21:17:47 +0000
@@ -33,12 +33,15 @@
33from canonical.launchpad.interfaces.oauth import (33from canonical.launchpad.interfaces.oauth import (
34 IOAuthAccessToken,34 IOAuthAccessToken,
35 IOAuthRequestToken,35 IOAuthRequestToken,
36 IOAuthSignedRequest,
36 )37 )
37from canonical.launchpad.webapp.authorization import check_permission38from canonical.launchpad.webapp.authorization import check_permission
38from canonical.launchpad.webapp.interfaces import (39from canonical.launchpad.webapp.interfaces import (
40 AccessLevel,
39 IAuthorization,41 IAuthorization,
40 ILaunchpadRoot,42 ILaunchpadRoot,
41 )43 )
44from canonical.lazr.utils import get_current_browser_request
42from lp.answers.interfaces.faq import IFAQ45from lp.answers.interfaces.faq import IFAQ
43from lp.answers.interfaces.faqtarget import IFAQTarget46from lp.answers.interfaces.faqtarget import IFAQTarget
44from lp.answers.interfaces.question import IQuestion47from lp.answers.interfaces.question import IQuestion
@@ -373,15 +376,42 @@
373 return user.in_admin or user.in_registry_experts376 return user.in_admin or user.in_registry_experts
374377
375378
376class EditOAuthAccessToken(AuthorizationBase):379class OAuthToken(AuthorizationBase):
377 permission = 'launchpad.Edit'380 """Special rules for access to OAuth tokens.
378 usedfor = IOAuthAccessToken381
382 Through the website, a person can always access their own OAuth
383 tokens, and an admin can access someone else's OAuth tokens.
384
385 To minimize the risk of privilege escalation attacks, much greater
386 restrictions apply through the web service. A person can only
387 access their own OAuth tokens, and they must be using a client
388 authorized at the GRANT_PERMISSIONS access level.
389 """
379390
380 def checkAuthenticated(self, user):391 def checkAuthenticated(self, user):
381 return self.obj.person == user.person or user.in_admin392 request = get_current_browser_request()
382393 if IOAuthSignedRequest.providedBy(request):
383394 # This is a web service request.
384class EditOAuthRequestToken(EditOAuthAccessToken):395 if self.obj.person != user.person:
396 return False
397 return (request.principal.access_level ==
398 AccessLevel.GRANT_PERMISSIONS)
399 else:
400 # This is a website request.
401 return self.obj.person == user.person or user.in_admin
402
403
404class ViewOAuthAccessToken(OAuthToken):
405 permission = 'launchpad.View'
406 usedfor = IOAuthAccessToken
407
408
409class EditOAuthAccessToken(OAuthToken):
410 permission = 'launchpad.Edit'
411 usedfor = IOAuthAccessToken
412
413
414class EditOAuthRequestToken(OAuthToken):
385 permission = 'launchpad.Edit'415 permission = 'launchpad.Edit'
386 usedfor = IOAuthRequestToken416 usedfor = IOAuthRequestToken
387417
388418
=== modified file 'lib/canonical/launchpad/testing/pages.py'
--- lib/canonical/launchpad/testing/pages.py 2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/testing/pages.py 2010-09-02 21:17:47 +0000
@@ -153,10 +153,16 @@
153 self.consumer = OAuthConsumer(oauth_consumer_key, '')153 self.consumer = OAuthConsumer(oauth_consumer_key, '')
154 self.access_token = OAuthToken(oauth_access_key, '')154 self.access_token = OAuthToken(oauth_access_key, '')
155 else:155 else:
156 # The client wants to make an authorized request156 # The client wants to make authorized requests
157 # using a recognized consumer key.157 # using a recognized consumer key. The security
158 self.access_token = self.consumer.getAccessToken(158 # policy for IOAuthAccessToken assumes the request
159 oauth_access_key)159 # is being processed on the server side and has
160 # already been signed with some access token.
161 #
162 # Here, the requests haven't even gone out yet,
163 # so it's okay to strip the security proxy.
164 self.access_token = removeSecurityProxy(
165 self.consumer.getAccessToken(oauth_access_key))
160 logout()166 logout()
161 else:167 else:
162 self.consumer = None168 self.consumer = None
@@ -694,7 +700,11 @@
694 consumer = oacs.new(consumer_key)700 consumer = oacs.new(consumer_key)
695 request_token = consumer.newRequestToken()701 request_token = consumer.newRequestToken()
696 request_token.review(person, permission, context)702 request_token.review(person, permission, context)
697 access_token = request_token.createAccessToken()703 # The security proxy assumes that an OAuth token has been
704 # associated with the current HTTP request. Since we're using an
705 # OAuth token to construct the HTTP requests in the first place,
706 # we need to strip the security proxy.
707 access_token = removeSecurityProxy(request_token.createAccessToken())
698 logout()708 logout()
699 return LaunchpadWebServiceCaller(consumer_key, access_token.key)709 return LaunchpadWebServiceCaller(consumer_key, access_token.key)
700710
701711
=== added file 'lib/canonical/launchpad/tests/test_webservice_oauth.py'
--- lib/canonical/launchpad/tests/test_webservice_oauth.py 1970-01-01 00:00:00 +0000
+++ lib/canonical/launchpad/tests/test_webservice_oauth.py 2010-09-02 21:17:47 +0000
@@ -0,0 +1,82 @@
1# Copyright 2010 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4__metaclass__ = type
5
6from datetime import date
7import simplejson
8
9from canonical.launchpad.testing.pages import LaunchpadWebServiceCaller
10from canonical.testing import LaunchpadFunctionalLayer
11from lp.testing import (
12 launchpadlib_for,
13 TestCase,
14)
15
16class TestWebServiceAccessToOAuth(TestCase):
17 """Tests of OAuth tokens as published in the web service."""
18
19 layer = LaunchpadFunctionalLayer
20
21 def setUp(self):
22 """Setup."""
23 super(TestWebServiceAccessToOAuth, self).setUp()
24 self.launchpad = launchpadlib_for(
25 'Token management test', 'salgado', 'GRANT_PERMISSIONS')
26
27 def test_authorized_read(self):
28 """You can read tokens if your client has the right access level."""
29 # The person 'salgado' has three associated tokens: two
30 # created as part of sample data, and one ("Grant
31 # Permissions") created in the launchpadlib_for() call
32 # in setUp().
33 my_tokens = [token.permission for token in
34 self.launchpad.me.oauth_access_tokens]
35 self.assertEquals(
36 sorted(my_tokens), [u'Change Anything', u'Grant Permissions',
37 u'Read Non-Private Data'])
38
39 def test_authorized_write(self):
40 """You can write tokens if your client has the right access level."""
41 # Change a token's expiration date.
42 token = [token for token in self.launchpad.me.oauth_access_tokens
43 if token.permission == "Read Non-Private Data"][0]
44 old_date_expires = token.date_expires
45 new_date_expires = str(date(9000, 1, 1))
46 token.date_expires = new_date_expires
47 token.lp_save()
48
49 # Change it back.
50 token.date_expires = old_date_expires
51 token.lp_save()
52
53 def test_others_tokens_are_invisible(self):
54 """No one else can see your tokens."""
55 someone_else = launchpadlib_for(
56 'Token management test', 'name12', 'GRANT_PERMISSIONS')
57 tokens = [token for token in
58 someone_else.people['salgado'].oauth_access_tokens]
59 self.assertEquals(tokens, [])
60
61 def test_insufficient_access_level(self):
62 """A client can't read your tokens without GRANT_PERMISSIONS."""
63 write_private = launchpadlib_for(
64 'Token management test', 'salgado', 'WRITE_PRIVATE')
65 my_tokens = [token for token in
66 write_private.me.oauth_access_tokens]
67 self.assertEquals(my_tokens, [])
68
69 def test_url_hacking_doesnt_work(self):
70 """You can't access tokens by URL hacking."""
71 webservice = LaunchpadWebServiceCaller(
72 'launchpad-library', 'salgado-change-anything')
73
74 # A read request gets a 401.
75 url = '/~salgado/+oauth-access-token/salgado-read-nonprivate'
76 response = webservice.get(url)
77 self.assertEqual(response.status, 401)
78
79 # A write request gets a 401.
80 body = simplejson.dumps(dict(date_expires=str(date(9000, 1, 1))))
81 response = webservice.patch(url, 'application/json', body)
82 self.assertEqual(response.status, 401)
083
=== 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-02 21:17:47 +0000
@@ -363,24 +363,19 @@
363 return request.form363 return request.form
364364
365365
366def check_oauth_signature(request, consumer, token):366def check_oauth_signature(request, consumer, token_secret):
367 """Check that the given OAuth request is correctly signed.367 """Check that the given OAuth request is correctly signed.
368368
369 If the signature is incorrect or its method is not supported, set the369 If the signature is incorrect or its method is not supported, set the
370 appropriate status in the request's response and return False.370 appropriate status in the request's response and return False.
371 """371 """
372 authorization = get_oauth_authorization(request)372 authorization = get_oauth_authorization(request)
373
374 if authorization.get('oauth_signature_method') != 'PLAINTEXT':373 if authorization.get('oauth_signature_method') != 'PLAINTEXT':
375 # XXX: 2008-03-04, salgado: Only the PLAINTEXT method is supported374 # XXX: 2008-03-04, salgado: Only the PLAINTEXT method is supported
376 # now. Others will be implemented later.375 # now. Others will be implemented later.
377 request.response.setStatus(400)376 request.response.setStatus(400)
378 return False377 return False
379378
380 if token is not None:
381 token_secret = token.secret
382 else:
383 token_secret = ''
384 expected_signature = "&".join([consumer.secret, token_secret])379 expected_signature = "&".join([consumer.secret, token_secret])
385 if expected_signature != authorization.get('oauth_signature'):380 if expected_signature != authorization.get('oauth_signature'):
386 request.unauthorized(OAUTH_CHALLENGE)381 request.unauthorized(OAUTH_CHALLENGE)
387382
=== modified file 'lib/canonical/launchpad/webapp/authorization.py'
--- lib/canonical/launchpad/webapp/authorization.py 2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/webapp/authorization.py 2010-09-02 21:17:47 +0000
@@ -61,7 +61,8 @@
61 lp_permission = getUtility(ILaunchpadPermission, permission)61 lp_permission = getUtility(ILaunchpadPermission, permission)
62 if lp_permission.access_level == "write":62 if lp_permission.access_level == "write":
63 required_access_level = [63 required_access_level = [
64 AccessLevel.WRITE_PUBLIC, AccessLevel.WRITE_PRIVATE]64 AccessLevel.WRITE_PUBLIC, AccessLevel.WRITE_PRIVATE,
65 AccessLevel.GRANT_PERMISSIONS]
65 if access_level not in required_access_level:66 if access_level not in required_access_level:
66 return False67 return False
67 elif lp_permission.access_level == "read":68 elif lp_permission.access_level == "read":
6869
=== modified file 'lib/canonical/launchpad/webapp/servers.py'
--- lib/canonical/launchpad/webapp/servers.py 2010-08-28 03:23:19 +0000
+++ lib/canonical/launchpad/webapp/servers.py 2010-09-02 21:17:47 +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
@@ -70,11 +69,8 @@
70 IWebServiceApplication,69 IWebServiceApplication,
71 )70 )
72from canonical.launchpad.interfaces.oauth import (71from canonical.launchpad.interfaces.oauth import (
73 ClockSkew,
74 IOAuthConsumerSet,72 IOAuthConsumerSet,
75 IOAuthSignedRequest,73 IOAuthSignedRequest,
76 NonceAlreadyUsed,
77 TimestampOrderingError,
78 )74 )
79import canonical.launchpad.layers75import canonical.launchpad.layers
80from canonical.launchpad.webapp.adapter import (76from canonical.launchpad.webapp.adapter import (
@@ -82,7 +78,6 @@
82 RequestExpired,78 RequestExpired,
83 )79 )
84from canonical.launchpad.webapp.authentication import (80from canonical.launchpad.webapp.authentication import (
85 check_oauth_signature,
86 get_oauth_authorization,81 get_oauth_authorization,
87 )82 )
88from canonical.launchpad.webapp.authorization import (83from canonical.launchpad.webapp.authorization import (
@@ -1276,27 +1271,16 @@
1276 token = consumer.getAccessToken(token_key)1271 token = consumer.getAccessToken(token_key)
1277 if token is None:1272 if token is None:
1278 raise Unauthorized('Unknown access token (%s).' % token_key)1273 raise Unauthorized('Unknown access token (%s).' % token_key)
1274
1279 nonce = form.get('oauth_nonce')1275 nonce = form.get('oauth_nonce')
1280 timestamp = form.get('oauth_timestamp')1276 timestamp = form.get('oauth_timestamp')
1281 try:1277 # If there's a problem with the token, authorize() will raise
1282 token.checkNonceAndTimestamp(nonce, timestamp)1278 # Unauthorized.
1283 except (NonceAlreadyUsed, TimestampOrderingError, ClockSkew), e:1279 (account_id, access_level, scope) = token.authorize(
1284 raise Unauthorized('Invalid nonce/timestamp: %s' % e)1280 request, nonce, timestamp)
1285 now = datetime.now(pytz.timezone('UTC'))1281 principal = getUtility(IPlacelessLoginSource).getPrincipal(
1286 if token.permission == OAuthPermission.UNAUTHORIZED:1282 account_id, access_level=access_level, scope=scope)
1287 raise Unauthorized('Unauthorized token (%s).' % token.key)
1288 elif token.date_expires is not None and token.date_expires <= now:
1289 raise Unauthorized('Expired token (%s).' % token.key)
1290 elif not check_oauth_signature(request, consumer, token):
1291 raise Unauthorized('Invalid signature.')
1292 else:
1293 # Everything is fine, let's return the principal.
1294 pass
1295 alsoProvides(request, IOAuthSignedRequest)1283 alsoProvides(request, IOAuthSignedRequest)
1296 principal = getUtility(IPlacelessLoginSource).getPrincipal(
1297 token.person.account.id, access_level=token.permission,
1298 scope=token.context)
1299
1300 return principal1284 return principal
13011285
13021286
13031287
=== modified file 'lib/canonical/launchpad/webapp/tests/test_publication.py'
--- lib/canonical/launchpad/webapp/tests/test_publication.py 2010-08-20 20:31:18 +0000
+++ lib/canonical/launchpad/webapp/tests/test_publication.py 2010-09-02 21:17:47 +0000
@@ -27,6 +27,7 @@
27 )27 )
28from zope.publisher.interfaces import Retry28from zope.publisher.interfaces import Retry
29from zope.publisher.interfaces.browser import IBrowserRequest29from zope.publisher.interfaces.browser import IBrowserRequest
30from zope.security.proxy import removeSecurityProxy
3031
31from canonical.config import dbconfig32from canonical.config import dbconfig
32from canonical.launchpad.database.emailaddress import EmailAddress33from canonical.launchpad.database.emailaddress import EmailAddress
@@ -254,7 +255,11 @@
254 request_token = consumer.newRequestToken()255 request_token = consumer.newRequestToken()
255 request_token.review(256 request_token.review(
256 person, permission=OAuthPermission.READ_PUBLIC, context=None)257 person, permission=OAuthPermission.READ_PUBLIC, context=None)
257 access_token = request_token.createAccessToken()258 # The security proxy protects against unauthorized requests to
259 # access the OAuth token data. We're using the OAuth token
260 # data to _create_ a request, so we can strip the security
261 # proxy.
262 access_token = removeSecurityProxy(request_token.createAccessToken())
258263
259 # Use oauth.OAuthRequest just to generate a dictionary containing all264 # Use oauth.OAuthRequest just to generate a dictionary containing all
260 # the parameters we need to use in a valid OAuth request, using the265 # the parameters we need to use in a valid OAuth request, using the
261266
=== modified file 'lib/canonical/launchpad/zcml/oauth.zcml'
--- lib/canonical/launchpad/zcml/oauth.zcml 2009-07-13 18:15:02 +0000
+++ lib/canonical/launchpad/zcml/oauth.zcml 2010-09-02 21:17:47 +0000
@@ -26,6 +26,7 @@
2626
27 <class class="canonical.launchpad.database.OAuthRequestToken">27 <class class="canonical.launchpad.database.OAuthRequestToken">
28 <allow interface="canonical.launchpad.interfaces.IOAuthRequestToken"/>28 <allow interface="canonical.launchpad.interfaces.IOAuthRequestToken"/>
29 <allow interface="canonical.launchpad.interfaces.IOAuthTokenPublic" />
29 <require30 <require
30 permission="launchpad.Edit"31 permission="launchpad.Edit"
31 set_schema="canonical.launchpad.interfaces.IOAuthRequestToken"/>32 set_schema="canonical.launchpad.interfaces.IOAuthRequestToken"/>
@@ -39,7 +40,11 @@
39 </securedutility>40 </securedutility>
4041
41 <class class="canonical.launchpad.database.OAuthAccessToken">42 <class class="canonical.launchpad.database.OAuthAccessToken">
42 <allow interface="canonical.launchpad.interfaces.IOAuthAccessToken"/>43 <allow interface="canonical.launchpad.webapp.interfaces.ICanonicalUrlData" />
44 <allow interface="canonical.launchpad.interfaces.IOAuthAccessTokenPublic"/>
45 <require
46 permission="launchpad.View"
47 interface="canonical.launchpad.interfaces.IOAuthAccessToken"/>
43 <require48 <require
44 permission="launchpad.Edit"49 permission="launchpad.Edit"
45 set_schema="canonical.launchpad.interfaces.IOAuthAccessToken"/>50 set_schema="canonical.launchpad.interfaces.IOAuthAccessToken"/>
4651
=== modified file 'lib/lp/bugs/tests/test_bugs_webservice.py'
--- lib/lp/bugs/tests/test_bugs_webservice.py 2010-08-30 05:19:46 +0000
+++ lib/lp/bugs/tests/test_bugs_webservice.py 2010-09-02 21:17:47 +0000
@@ -10,6 +10,7 @@
10from BeautifulSoup import BeautifulSoup10from BeautifulSoup import BeautifulSoup
11from lazr.lifecycle.interfaces import IDoNotSnapshot11from lazr.lifecycle.interfaces import IDoNotSnapshot
12from simplejson import dumps12from simplejson import dumps
13from storm.store import Store
13from testtools.matchers import (14from testtools.matchers import (
14 Equals,15 Equals,
15 LessThan,16 LessThan,
@@ -149,6 +150,7 @@
149 def test_attachments_query_counts_constant(self):150 def test_attachments_query_counts_constant(self):
150 login(USER_EMAIL)151 login(USER_EMAIL)
151 self.bug = self.factory.makeBug()152 self.bug = self.factory.makeBug()
153 store = Store.of(self.bug)
152 self.factory.makeBugAttachment(self.bug)154 self.factory.makeBugAttachment(self.bug)
153 self.factory.makeBugAttachment(self.bug)155 self.factory.makeBugAttachment(self.bug)
154 webservice = LaunchpadWebServiceCaller(156 webservice = LaunchpadWebServiceCaller(
@@ -157,6 +159,9 @@
157 collector.register()159 collector.register()
158 self.addCleanup(collector.unregister)160 self.addCleanup(collector.unregister)
159 url = '/bugs/%d/attachments' % self.bug.id161 url = '/bugs/%d/attachments' % self.bug.id
162 # XXX [bug=619017] First request.
163 store.flush()
164 store.reset()
160 response = webservice.get(url)165 response = webservice.get(url)
161 self.assertThat(collector, HasQueryCount(LessThan(24)))166 self.assertThat(collector, HasQueryCount(LessThan(24)))
162 with_2_count = collector.count167 with_2_count = collector.count
@@ -164,6 +169,9 @@
164 login(USER_EMAIL)169 login(USER_EMAIL)
165 self.factory.makeBugAttachment(self.bug)170 self.factory.makeBugAttachment(self.bug)
166 logout()171 logout()
172 # XXX [bug=619017] Second request.
173 store.flush()
174 store.reset()
167 response = webservice.get(url)175 response = webservice.get(url)
168 # XXX: Permit the second call to be == or less, because storm176 # XXX: Permit the second call to be == or less, because storm
169 # caching bugs (such as) https://bugs.launchpad.net/storm/+bug/619017177 # caching bugs (such as) https://bugs.launchpad.net/storm/+bug/619017
170178
=== modified file 'lib/lp/registry/browser/person.py'
--- lib/lp/registry/browser/person.py 2010-08-31 14:00:54 +0000
+++ lib/lp/registry/browser/person.py 2010-09-02 21:17:47 +0000
@@ -492,6 +492,15 @@
492 return None492 return None
493 return email493 return email
494494
495 @stepthrough('+oauth-access-token')
496 def traverse_access_token(self, key):
497 """Traverse to an OAuth access token on the webservice layer."""
498 matches = [token for token in self.context.oauth_access_tokens
499 if token.key == key]
500 if len(matches) > 0:
501 return matches[0]
502 return None
503
495 @stepthrough('+wikiname')504 @stepthrough('+wikiname')
496 def traverse_wikiname(self, id):505 def traverse_wikiname(self, id):
497 """Traverse to this person's WikiNames on the webservice layer."""506 """Traverse to this person's WikiNames on the webservice layer."""
498507
=== modified file 'lib/lp/registry/interfaces/person.py'
--- lib/lp/registry/interfaces/person.py 2010-08-22 03:09:51 +0000
+++ lib/lp/registry/interfaces/person.py 2010-09-02 21:17:47 +0000
@@ -103,6 +103,7 @@
103 IHasMugshot,103 IHasMugshot,
104 IPrivacy,104 IPrivacy,
105 )105 )
106from canonical.launchpad.interfaces.oauth import IOAuthAccessToken
106from canonical.launchpad.interfaces.validation import validate_new_team_email107from canonical.launchpad.interfaces.validation import validate_new_team_email
107from canonical.launchpad.validators import LaunchpadValidationError108from canonical.launchpad.validators import LaunchpadValidationError
108from canonical.launchpad.validators.email import email_validator109from canonical.launchpad.validators.email import email_validator
@@ -596,7 +597,10 @@
596 # apart from here.597 # apart from here.
597 registrant = Attribute('The user who created this profile.')598 registrant = Attribute('The user who created this profile.')
598599
599 oauth_access_tokens = Attribute(_("Non-expired access tokens"))600 oauth_access_tokens = exported(
601 CollectionField(
602 title=u"Non-expired access tokens",
603 value_type=Reference(schema=IOAuthAccessToken)))
600604
601 oauth_request_tokens = Attribute(_("Non-expired request tokens"))605 oauth_request_tokens = Attribute(_("Non-expired request tokens"))
602606
603607
=== modified file 'lib/lp/registry/stories/webservice/xx-person.txt'
--- lib/lp/registry/stories/webservice/xx-person.txt 2010-07-13 15:29:08 +0000
+++ lib/lp/registry/stories/webservice/xx-person.txt 2010-09-02 21:17:47 +0000
@@ -37,6 +37,7 @@
37 memberships_details_collection_link: u'http://.../~salgado/memberships_details'37 memberships_details_collection_link: u'http://.../~salgado/memberships_details'
38 mugshot_link: u'http://.../~salgado/mugshot'38 mugshot_link: u'http://.../~salgado/mugshot'
39 name: u'salgado'39 name: u'salgado'
40 oauth_access_tokens_collection_link: u'http://.../~salgado/oauth_access_tokens'
40 open_membership_invitations_collection_link: u'http://.../~salgado/open_membership_invitations'41 open_membership_invitations_collection_link: u'http://.../~salgado/open_membership_invitations'
41 participants_collection_link: u'http://.../~salgado/participants'42 participants_collection_link: u'http://.../~salgado/participants'
42 ppas_collection_link: u'http://.../~salgado/ppas'43 ppas_collection_link: u'http://.../~salgado/ppas'
@@ -86,6 +87,7 @@
86 memberships_details_collection_link: u'http://.../~ubuntu-team/memberships_details'87 memberships_details_collection_link: u'http://.../~ubuntu-team/memberships_details'
87 mugshot_link: u'http://.../~ubuntu-team/mugshot'88 mugshot_link: u'http://.../~ubuntu-team/mugshot'
88 name: u'ubuntu-team'89 name: u'ubuntu-team'
90 oauth_access_tokens_collection_link: u'http://.../~ubuntu-team/oauth_access_tokens'
89 open_membership_invitations_collection_link: u'http://.../~ubuntu-team/open_membership_invitations'91 open_membership_invitations_collection_link: u'http://.../~ubuntu-team/open_membership_invitations'
90 participants_collection_link: u'http://.../~ubuntu-team/participants'92 participants_collection_link: u'http://.../~ubuntu-team/participants'
91 ppas_collection_link: u'http://.../~ubuntu-team/ppas'93 ppas_collection_link: u'http://.../~ubuntu-team/ppas'
9294
=== 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-02 21:17:47 +0000
@@ -24,6 +24,7 @@
24from zope.app.publication.interfaces import IEndRequestEvent24from zope.app.publication.interfaces import IEndRequestEvent
25from zope.app.testing import ztapi25from zope.app.testing import ztapi
26from zope.component import getUtility26from zope.component import getUtility
27from zope.security.proxy import removeSecurityProxy
27import zope.testing.cleanup28import zope.testing.cleanup
2829
29from canonical.launchpad.interfaces import (30from canonical.launchpad.interfaces import (
@@ -72,19 +73,23 @@
72 # We didn't have to create the consumer. Maybe this user73 # We didn't have to create the consumer. Maybe this user
73 # already has an access token for this74 # already has an access token for this
74 # consumer+person+permission?75 # consumer+person+permission?
75 existing_token = [token for token in person.oauth_access_tokens76 for token in person.oauth_access_tokens:
76 if (token.consumer == consumer77 # The security proxy on OAuthAccessToken assumes an access
77 and token.permission == permission78 # token has already been associated with the current
78 and token.context == context)]79 # request. Since there is no current request, it doesn't
79 if len(existing_token) >= 1:80 # make sense to run those security checks.
80 return existing_token[0]81 token = removeSecurityProxy(token)
82 if (token.consumer == consumer
83 and token.permission == permission
84 and token.context == context):
85 return token
8186
82 # There is no existing access token for this87 # There is no existing access token for this
83 # consumer+person+permission+context. Create one and review it.88 # consumer+person+permission+context. Create one and review it.
84 request_token = consumer.newRequestToken()89 request_token = consumer.newRequestToken()
85 request_token.review(person, permission, context)90 request_token.review(person, permission, context)
86 access_token = request_token.createAccessToken()91 access_token = request_token.createAccessToken()
87 return access_token92 return removeSecurityProxy(access_token)
8893
8994
90def launchpadlib_credentials_for(95def launchpadlib_credentials_for(
@@ -109,8 +114,7 @@
109 access_token = oauth_access_token_for(114 access_token = oauth_access_token_for(
110 consumer_name, person, permission, context)115 consumer_name, person, permission, context)
111 logout()116 logout()
112 launchpadlib_token = AccessToken(117 launchpadlib_token = AccessToken(access_token.key, access_token.secret)
113 access_token.key, access_token.secret)
114 return Credentials(consumer_name=consumer_name,118 return Credentials(consumer_name=consumer_name,
115 access_token=launchpadlib_token)119 access_token=launchpadlib_token)
116120
117121
=== modified file 'versions.cfg'
--- versions.cfg 2010-09-01 08:41:40 +0000
+++ versions.cfg 2010-09-02 21:17:47 +0000
@@ -31,7 +31,7 @@
31lazr.delegates = 1.2.031lazr.delegates = 1.2.0
32lazr.enum = 1.1.232lazr.enum = 1.1.2
33lazr.lifecycle = 1.133lazr.lifecycle = 1.1
34lazr.restful = 0.11.334lazr.restful = 0.12.0
35lazr.restfulclient = 0.10.035lazr.restfulclient = 0.10.0
36lazr.smtptest = 1.136lazr.smtptest = 1.1
37lazr.testing = 0.1.137lazr.testing = 0.1.1

Subscribers

People subscribed via source and target branches

to status/vote changes: