Merge ~cjwatson/launchpad:access-token-auth into launchpad:master
- Git
- lp:~cjwatson/launchpad
- access-token-auth
- Merge into master
Proposed by
Colin Watson
Status: | Merged |
---|---|
Approved by: | Colin Watson |
Approved revision: | eebdc1a5a5a26711cc50e8c99c4d50860a366547 |
Merge reported by: | Otto Co-Pilot |
Merged at revision: | not available |
Proposed branch: | ~cjwatson/launchpad:access-token-auth |
Merge into: | launchpad:master |
Diff against target: |
490 lines (+264/-36) 8 files modified
lib/lp/services/webapp/doc/webapp-publication.txt (+1/-1) lib/lp/services/webapp/interaction.py (+2/-1) lib/lp/services/webapp/interfaces.py (+4/-1) lib/lp/services/webapp/publication.py (+8/-7) lib/lp/services/webapp/servers.py (+57/-22) lib/lp/services/webapp/tests/test_publication.py (+11/-2) lib/lp/services/webapp/tests/test_servers.py (+157/-1) lib/lp/services/webservice/configuration.py (+24/-1) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
William Grant | code | Approve | |
Ioana Lasc (community) | Approve | ||
Review via email: mp+409946@code.launchpad.net |
Commit message
Support webservice authentication using AccessTokens
Description of the change
This only works with webservice methods that have appropriate `@scoped` decorators, of which there are none at present; but this lays some more of the groundwork.
To post a comment you must log in.
Revision history for this message
Ioana Lasc (ilasc) : | # |
review:
Approve
Revision history for this message
William Grant (wgrant) : | # |
review:
Approve
(code)
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/lib/lp/services/webapp/doc/webapp-publication.txt b/lib/lp/services/webapp/doc/webapp-publication.txt |
2 | index f0c2bcf..98f1fd0 100644 |
3 | --- a/lib/lp/services/webapp/doc/webapp-publication.txt |
4 | +++ b/lib/lp/services/webapp/doc/webapp-publication.txt |
5 | @@ -1129,7 +1129,7 @@ The feeds implementation always returns the anonymous user. |
6 | True |
7 | |
8 | The webservice implementation returns the principal for the person |
9 | -associated with the access token specified in the request. The |
10 | +associated with the OAuth access token specified in the request. The |
11 | principal's access_level and scope will match what was specified in the |
12 | token. |
13 | |
14 | diff --git a/lib/lp/services/webapp/interaction.py b/lib/lp/services/webapp/interaction.py |
15 | index 8aec9d0..4b60242 100644 |
16 | --- a/lib/lp/services/webapp/interaction.py |
17 | +++ b/lib/lp/services/webapp/interaction.py |
18 | @@ -1,4 +1,4 @@ |
19 | -# Copyright 2009 Canonical Ltd. This software is licensed under the |
20 | +# Copyright 2009-2021 Canonical Ltd. This software is licensed under the |
21 | # GNU Affero General Public License version 3 (see the file LICENSE). |
22 | |
23 | """Methods dealing with interactions. |
24 | @@ -179,6 +179,7 @@ class InteractionExtras: |
25 | """Extra data attached to all interactions. See `IInteractionExtras`.""" |
26 | |
27 | permit_timeout_from_features = False |
28 | + access_token = None |
29 | |
30 | |
31 | def get_interaction_extras(): |
32 | diff --git a/lib/lp/services/webapp/interfaces.py b/lib/lp/services/webapp/interfaces.py |
33 | index 7c71623..a895808 100644 |
34 | --- a/lib/lp/services/webapp/interfaces.py |
35 | +++ b/lib/lp/services/webapp/interfaces.py |
36 | @@ -1,4 +1,4 @@ |
37 | -# Copyright 2009-2016 Canonical Ltd. This software is licensed under the |
38 | +# Copyright 2009-2021 Canonical Ltd. This software is licensed under the |
39 | # GNU Affero General Public License version 3 (see the file LICENSE). |
40 | |
41 | import logging |
42 | @@ -350,6 +350,9 @@ class IInteractionExtras(Interface): |
43 | `lp.services.webapp.servers.set_permit_timeout_from_features` |
44 | for more.""") |
45 | |
46 | + access_token = Attribute( |
47 | + "The `IAccessToken` used to authenticate this interaction, if any.") |
48 | + |
49 | |
50 | # |
51 | # Request |
52 | diff --git a/lib/lp/services/webapp/publication.py b/lib/lp/services/webapp/publication.py |
53 | index e7a4ec8..717cc65 100644 |
54 | --- a/lib/lp/services/webapp/publication.py |
55 | +++ b/lib/lp/services/webapp/publication.py |
56 | @@ -70,6 +70,7 @@ from lp.registry.interfaces.person import ( |
57 | ITeam, |
58 | ) |
59 | from lp.services import features |
60 | +from lp.services.auth.interfaces import IAccessTokenVerifiedRequest |
61 | from lp.services.config import config |
62 | from lp.services.database.interfaces import ( |
63 | IDatabasePolicy, |
64 | @@ -117,13 +118,13 @@ def maybe_block_offsite_form_post(request): |
65 | if request.method != 'POST': |
66 | return |
67 | if (IOAuthSignedRequest.providedBy(request) |
68 | - or not IBrowserRequest.providedBy(request)): |
69 | - # We only want to check for the referrer header if we are |
70 | - # in the middle of a request initiated by a web browser. A |
71 | - # request to the web service (which is necessarily |
72 | - # OAuth-signed) or a request that does not implement |
73 | - # IBrowserRequest (such as an XML-RPC request) can do |
74 | - # without a Referer. |
75 | + or IAccessTokenVerifiedRequest.providedBy(request) |
76 | + or not IBrowserRequest.providedBy(request)): |
77 | + # We only want to check for the referrer header if we are in the |
78 | + # middle of a request initiated by a web browser. A request to the |
79 | + # web service (which is necessarily OAuth-signed or verified by an |
80 | + # access token) or a request that does not implement IBrowserRequest |
81 | + # (such as an XML-RPC request) can do without a Referer. |
82 | return |
83 | if request['PATH_INFO'] in OFFSITE_POST_WHITELIST: |
84 | # XXX: jamesh 2007-11-23 bug=124421: |
85 | diff --git a/lib/lp/services/webapp/servers.py b/lib/lp/services/webapp/servers.py |
86 | index 8707437..31afcbf 100644 |
87 | --- a/lib/lp/services/webapp/servers.py |
88 | +++ b/lib/lp/services/webapp/servers.py |
89 | @@ -57,6 +57,10 @@ from zope.session.interfaces import ISession |
90 | from lp.app import versioninfo |
91 | from lp.app.errors import UnexpectedFormData |
92 | import lp.layers |
93 | +from lp.services.auth.interfaces import ( |
94 | + IAccessTokenSet, |
95 | + IAccessTokenVerifiedRequest, |
96 | + ) |
97 | from lp.services.config import config |
98 | from lp.services.encoding import wsgi_native_string |
99 | from lp.services.features import get_relevant_feature_controller |
100 | @@ -80,6 +84,7 @@ from lp.services.webapp.authorization import ( |
101 | LAUNCHPAD_SECURITY_POLICY_CACHE_UNAUTH_KEY, |
102 | ) |
103 | from lp.services.webapp.errorlog import ErrorReportRequest |
104 | +from lp.services.webapp.interaction import get_interaction_extras |
105 | from lp.services.webapp.interfaces import ( |
106 | IBasicLaunchpadRequest, |
107 | IBrowserFormNG, |
108 | @@ -1171,26 +1176,32 @@ class WebServicePublication(WebServicePublicationMixin, |
109 | else: |
110 | return super(WebServicePublication, self).getResource(request, ob) |
111 | |
112 | - def getPrincipal(self, request): |
113 | - """See `LaunchpadBrowserPublication`. |
114 | - |
115 | - Web service requests are authenticated using OAuth, except for the |
116 | - one made using (presumably) JavaScript on the /api override path. |
117 | - |
118 | - Raises TokenException which has a webservice error status of |
119 | - Unauthorized - 401. |
120 | - |
121 | - Raises Unauthorized directly in the case where the consumer is None |
122 | - for a non-anonymous request as it may represent a server error. |
123 | - """ |
124 | - # Use the regular HTTP authentication, when the request is not |
125 | - # on the API virtual host but comes through the path_override on |
126 | - # the other regular virtual hosts. |
127 | - request_path = request.get('PATH_INFO', '') |
128 | - web_service_config = getUtility(IWebServiceConfiguration) |
129 | - if request_path.startswith("/%s" % web_service_config.path_override): |
130 | - return super(WebServicePublication, self).getPrincipal(request) |
131 | - |
132 | + def _getPrincipalFromAccessToken(self, request): |
133 | + """Authenticate a request using a personal access token.""" |
134 | + access_token = removeSecurityProxy( |
135 | + getUtility(IAccessTokenSet).getBySecret( |
136 | + request._auth[len("Token "):])) |
137 | + if access_token is None: |
138 | + raise TokenException("Unknown access token.") |
139 | + elif access_token.is_expired: |
140 | + raise TokenException("Expired access token.") |
141 | + elif access_token.owner.account_status != AccountStatus.ACTIVE: |
142 | + raise TokenException("Inactive account.") |
143 | + access_token.updateLastUsed() |
144 | + # GET requests will be rolled back, as will unsuccessful ones. |
145 | + # Commit so that the last-used date is updated anyway. |
146 | + transaction.commit() |
147 | + logging_context.push( |
148 | + access_token_id=access_token.id, |
149 | + access_token_scopes=" ".join( |
150 | + scope.title for scope in access_token.scopes)) |
151 | + alsoProvides(request, IAccessTokenVerifiedRequest) |
152 | + get_interaction_extras().access_token = access_token |
153 | + return getUtility(IPlacelessLoginSource).getPrincipal( |
154 | + access_token.owner.accountID) |
155 | + |
156 | + def _getPrincipalFromOAuth(self, request): |
157 | + """Authenticate a request using OAuth.""" |
158 | # Fetch OAuth authorization information from the request. |
159 | try: |
160 | form = get_oauth_authorization(request) |
161 | @@ -1259,11 +1270,35 @@ class WebServicePublication(WebServicePublicationMixin, |
162 | scope_url = canonical_url(token.context, force_local_path=True) |
163 | else: |
164 | scope_url = None |
165 | - principal = getUtility(IPlacelessLoginSource).getPrincipal( |
166 | + return getUtility(IPlacelessLoginSource).getPrincipal( |
167 | token.person.account.id, access_level=token.permission, |
168 | scope_url=scope_url) |
169 | |
170 | - return principal |
171 | + def getPrincipal(self, request): |
172 | + """See `LaunchpadBrowserPublication`. |
173 | + |
174 | + Web service requests are authenticated using OAuth or personal |
175 | + access tokens, except for the one made using (presumably) JavaScript |
176 | + on the /api override path. |
177 | + |
178 | + Raises TokenException which has a webservice error status of |
179 | + Unauthorized - 401. |
180 | + |
181 | + Raises Unauthorized directly in the case where the consumer is None |
182 | + for a non-anonymous request as it may represent a server error. |
183 | + """ |
184 | + # Use the regular HTTP authentication, when the request is not |
185 | + # on the API virtual host but comes through the path_override on |
186 | + # the other regular virtual hosts. |
187 | + request_path = request.get('PATH_INFO', '') |
188 | + web_service_config = getUtility(IWebServiceConfiguration) |
189 | + if request_path.startswith("/%s" % web_service_config.path_override): |
190 | + return super(WebServicePublication, self).getPrincipal(request) |
191 | + |
192 | + if request._auth is not None and request._auth.startswith("Token "): |
193 | + return self._getPrincipalFromAccessToken(request) |
194 | + else: |
195 | + return self._getPrincipalFromOAuth(request) |
196 | |
197 | |
198 | @implementer(lp.layers.WebServiceLayer) |
199 | diff --git a/lib/lp/services/webapp/tests/test_publication.py b/lib/lp/services/webapp/tests/test_publication.py |
200 | index 332db55..57d63a9 100644 |
201 | --- a/lib/lp/services/webapp/tests/test_publication.py |
202 | +++ b/lib/lp/services/webapp/tests/test_publication.py |
203 | @@ -1,4 +1,4 @@ |
204 | -# Copyright 2009-2017 Canonical Ltd. This software is licensed under the |
205 | +# Copyright 2009-2021 Canonical Ltd. This software is licensed under the |
206 | # GNU Affero General Public License version 3 (see the file LICENSE). |
207 | |
208 | """Tests publication.py""" |
209 | @@ -28,6 +28,7 @@ from zope.security.management import ( |
210 | thread_local as zope_security_thread_local, |
211 | ) |
212 | |
213 | +from lp.services.auth.interfaces import IAccessTokenVerifiedRequest |
214 | from lp.services.database.interfaces import IMasterStore |
215 | from lp.services.identity.model.emailaddress import EmailAddress |
216 | from lp.services.oauth.interfaces import ( |
217 | @@ -124,7 +125,7 @@ class TestWebServicePublication(TestCaseWithFactory): |
218 | person = self.factory.makePerson() |
219 | self.assertNotEqual(person.id, person.account.id) |
220 | |
221 | - # Create an access token for our new person. |
222 | + # Create an OAuth access token for our new person. |
223 | consumer = getUtility(IOAuthConsumerSet).new(u'test-consumer') |
224 | request_token, _ = consumer.newRequestToken() |
225 | request_token.review( |
226 | @@ -244,6 +245,14 @@ class TestBlockingOffsitePosts(TestCase): |
227 | # this call shouldn't raise an exception |
228 | maybe_block_offsite_form_post(request) |
229 | |
230 | + def test_access_token_verified_requests(self): |
231 | + # Requests that are verified with an access token are allowed. |
232 | + request = LaunchpadTestRequest( |
233 | + method='POST', environ=dict(PATH_INFO='/')) |
234 | + directlyProvides(request, IAccessTokenVerifiedRequest) |
235 | + # this call shouldn't raise an exception |
236 | + maybe_block_offsite_form_post(request) |
237 | + |
238 | def test_nonbrowser_requests(self): |
239 | # Requests that are from non-browsers are allowed. |
240 | class FakeNonBrowserRequest: |
241 | diff --git a/lib/lp/services/webapp/tests/test_servers.py b/lib/lp/services/webapp/tests/test_servers.py |
242 | index 50f48aa..a551f78 100644 |
243 | --- a/lib/lp/services/webapp/tests/test_servers.py |
244 | +++ b/lib/lp/services/webapp/tests/test_servers.py |
245 | @@ -1,6 +1,10 @@ |
246 | # Copyright 2009-2021 Canonical Ltd. This software is licensed under the |
247 | # GNU Affero General Public License version 3 (see the file LICENSE). |
248 | |
249 | +from datetime import ( |
250 | + datetime, |
251 | + timedelta, |
252 | + ) |
253 | from doctest import ( |
254 | DocTestSuite, |
255 | ELLIPSIS, |
256 | @@ -19,8 +23,14 @@ from lazr.restful.testing.webservice import ( |
257 | IGenericEntry, |
258 | WebServiceTestCase, |
259 | ) |
260 | +import pytz |
261 | from talisker.context import Context |
262 | from talisker.logs import logging_context |
263 | +from testtools.matchers import ( |
264 | + ContainsDict, |
265 | + Equals, |
266 | + ) |
267 | +import transaction |
268 | from zope.component import ( |
269 | getGlobalSiteManager, |
270 | getUtility, |
271 | @@ -29,8 +39,15 @@ from zope.interface import ( |
272 | implementer, |
273 | Interface, |
274 | ) |
275 | +from zope.security.interfaces import Unauthorized |
276 | +from zope.security.management import newInteraction |
277 | +from zope.security.proxy import removeSecurityProxy |
278 | |
279 | from lp.app import versioninfo |
280 | +from lp.services.auth.enums import AccessTokenScope |
281 | +from lp.services.identity.interfaces.account import AccountStatus |
282 | +from lp.services.oauth.interfaces import TokenException |
283 | +from lp.services.webapp.interaction import get_interaction_extras |
284 | from lp.services.webapp.interfaces import IFinishReadOnlyRequestEvent |
285 | from lp.services.webapp.publication import LaunchpadBrowserPublication |
286 | from lp.services.webapp.servers import ( |
287 | @@ -50,9 +67,16 @@ from lp.services.webapp.servers import ( |
288 | ) |
289 | from lp.testing import ( |
290 | EventRecorder, |
291 | + logout, |
292 | + person_logged_in, |
293 | TestCase, |
294 | + TestCaseWithFactory, |
295 | ) |
296 | -from lp.testing.layers import FunctionalLayer |
297 | +from lp.testing.layers import ( |
298 | + DatabaseFunctionalLayer, |
299 | + FunctionalLayer, |
300 | + ) |
301 | +from lp.testing.publication import get_request_and_publication |
302 | |
303 | |
304 | class SetInWSGIEnvironmentTestCase(TestCase): |
305 | @@ -781,6 +805,138 @@ class TestFinishReadOnlyRequest(TestCase): |
306 | self._test_publication(publication, ["ABORT"]) |
307 | |
308 | |
309 | +class TestWebServiceAccessTokens(TestCaseWithFactory): |
310 | + """Test personal access tokens for the webservice. |
311 | + |
312 | + These are bearer tokens with an owner, a context, and some scopes. We |
313 | + can authenticate using one of these, and it will be recorded in the |
314 | + interaction extras. |
315 | + """ |
316 | + |
317 | + layer = DatabaseFunctionalLayer |
318 | + |
319 | + def test_valid(self): |
320 | + owner = self.factory.makePerson() |
321 | + secret, token = self.factory.makeAccessToken( |
322 | + owner=owner, scopes=[AccessTokenScope.REPOSITORY_BUILD_STATUS]) |
323 | + self.assertIsNone(removeSecurityProxy(token).date_last_used) |
324 | + transaction.commit() |
325 | + logout() |
326 | + |
327 | + request, publication = get_request_and_publication( |
328 | + "api.launchpad.test", "POST", |
329 | + extra_environment={"HTTP_AUTHORIZATION": "Token %s" % secret}) |
330 | + newInteraction(request) |
331 | + principal = publication.getPrincipal(request) |
332 | + request.setPrincipal(principal) |
333 | + self.assertEqual(owner, principal.person) |
334 | + self.assertEqual(token, get_interaction_extras().access_token) |
335 | + self.assertIsNotNone(token.date_last_used) |
336 | + self.assertThat(logging_context.flat, ContainsDict({ |
337 | + "access_token_id": Equals(removeSecurityProxy(token).id), |
338 | + "access_token_scopes": Equals("repository:build_status"), |
339 | + })) |
340 | + |
341 | + # token.date_last_used is still up to date even if the transaction |
342 | + # is rolled back. |
343 | + date_last_used = token.date_last_used |
344 | + transaction.abort() |
345 | + self.assertEqual(date_last_used, token.date_last_used) |
346 | + |
347 | + def test_expired(self): |
348 | + owner = self.factory.makePerson() |
349 | + secret, token = self.factory.makeAccessToken(owner=owner) |
350 | + with person_logged_in(owner): |
351 | + token.date_expires = datetime.now(pytz.UTC) - timedelta(days=1) |
352 | + transaction.commit() |
353 | + |
354 | + request, publication = get_request_and_publication( |
355 | + "api.launchpad.test", "POST", |
356 | + extra_environment={"HTTP_AUTHORIZATION": "Token %s" % secret}) |
357 | + self.assertRaisesWithContent( |
358 | + TokenException, "Expired access token.", |
359 | + publication.getPrincipal, request) |
360 | + |
361 | + def test_unknown(self): |
362 | + request, publication = get_request_and_publication( |
363 | + "api.launchpad.test", "POST", |
364 | + extra_environment={"HTTP_AUTHORIZATION": "Token nonexistent"}) |
365 | + self.assertRaisesWithContent( |
366 | + TokenException, "Unknown access token.", |
367 | + publication.getPrincipal, request) |
368 | + |
369 | + def test_inactive_account(self): |
370 | + owner = self.factory.makePerson(account_status=AccountStatus.SUSPENDED) |
371 | + secret, token = self.factory.makeAccessToken(owner=owner) |
372 | + transaction.commit() |
373 | + |
374 | + request, publication = get_request_and_publication( |
375 | + "api.launchpad.test", "POST", |
376 | + extra_environment={"HTTP_AUTHORIZATION": "Token %s" % secret}) |
377 | + self.assertRaisesWithContent( |
378 | + TokenException, "Inactive account.", |
379 | + publication.getPrincipal, request) |
380 | + |
381 | + def _makeAccessTokenVerifiedRequest(self, **kwargs): |
382 | + secret, token = self.factory.makeAccessToken(**kwargs) |
383 | + transaction.commit() |
384 | + logout() |
385 | + |
386 | + request, publication = get_request_and_publication( |
387 | + "api.launchpad.test", "POST", |
388 | + extra_environment={"HTTP_AUTHORIZATION": "Token %s" % secret}) |
389 | + newInteraction(request) |
390 | + principal = publication.getPrincipal(request) |
391 | + request.setPrincipal(principal) |
392 | + |
393 | + def test_checkRequest_valid(self): |
394 | + repository = self.factory.makeGitRepository() |
395 | + self._makeAccessTokenVerifiedRequest( |
396 | + context=repository, |
397 | + scopes=[AccessTokenScope.REPOSITORY_BUILD_STATUS]) |
398 | + getUtility(IWebServiceConfiguration).checkRequest( |
399 | + repository, |
400 | + ["repository:build_status", "repository:another_scope"]) |
401 | + |
402 | + def test_checkRequest_bad_context(self): |
403 | + repository = self.factory.makeGitRepository() |
404 | + self._makeAccessTokenVerifiedRequest( |
405 | + context=repository, |
406 | + scopes=[AccessTokenScope.REPOSITORY_BUILD_STATUS]) |
407 | + self.assertRaisesWithContent( |
408 | + Unauthorized, |
409 | + "Current authentication does not allow access to this object.", |
410 | + getUtility(IWebServiceConfiguration).checkRequest, |
411 | + self.factory.makeGitRepository(), ["repository:build_status"]) |
412 | + |
413 | + def test_checkRequest_unscoped_method(self): |
414 | + repository = self.factory.makeGitRepository() |
415 | + self._makeAccessTokenVerifiedRequest( |
416 | + context=repository, |
417 | + scopes=[AccessTokenScope.REPOSITORY_BUILD_STATUS]) |
418 | + self.assertRaisesWithContent( |
419 | + Unauthorized, |
420 | + "Current authentication only allows calling scoped methods.", |
421 | + getUtility(IWebServiceConfiguration).checkRequest, |
422 | + repository, None) |
423 | + |
424 | + def test_checkRequest_wrong_scope(self): |
425 | + repository = self.factory.makeGitRepository() |
426 | + self._makeAccessTokenVerifiedRequest( |
427 | + context=repository, |
428 | + scopes=[ |
429 | + AccessTokenScope.REPOSITORY_BUILD_STATUS, |
430 | + AccessTokenScope.REPOSITORY_PUSH, |
431 | + ]) |
432 | + self.assertRaisesWithContent( |
433 | + Unauthorized, |
434 | + "Current authentication does not allow calling this method " |
435 | + "(one of these scopes is required: " |
436 | + "'repository:scope_1', 'repository:scope_2').", |
437 | + getUtility(IWebServiceConfiguration).checkRequest, |
438 | + repository, ["repository:scope_1", "repository:scope_2"]) |
439 | + |
440 | + |
441 | def test_suite(): |
442 | suite = unittest.TestSuite() |
443 | suite.addTest(DocTestSuite( |
444 | diff --git a/lib/lp/services/webservice/configuration.py b/lib/lp/services/webservice/configuration.py |
445 | index 549e4fd..f52918a 100644 |
446 | --- a/lib/lp/services/webservice/configuration.py |
447 | +++ b/lib/lp/services/webservice/configuration.py |
448 | @@ -1,4 +1,4 @@ |
449 | -# Copyright 2009-2018 Canonical Ltd. This software is licensed under the |
450 | +# Copyright 2009-2021 Canonical Ltd. This software is licensed under the |
451 | # GNU Affero General Public License version 3 (see the file LICENSE). |
452 | |
453 | """A configuration class describing the Launchpad web service.""" |
454 | @@ -10,9 +10,12 @@ __all__ = [ |
455 | from lazr.restful.simple import BaseWebServiceConfiguration |
456 | import six |
457 | from zope.component import getUtility |
458 | +from zope.security.interfaces import Unauthorized |
459 | |
460 | from lp.app import versioninfo |
461 | from lp.services.config import config |
462 | +from lp.services.database.sqlbase import block_implicit_flushes |
463 | +from lp.services.webapp.interaction import get_interaction_extras |
464 | from lp.services.webapp.interfaces import ILaunchBag |
465 | from lp.services.webapp.servers import ( |
466 | WebServiceClientRequest, |
467 | @@ -92,3 +95,23 @@ class LaunchpadWebServiceConfiguration(BaseWebServiceConfiguration): |
468 | def get_request_user(self): |
469 | """See `IWebServiceConfiguration`.""" |
470 | return getUtility(ILaunchBag).user |
471 | + |
472 | + @block_implicit_flushes |
473 | + def checkRequest(self, context, required_scopes): |
474 | + """See `IWebServiceConfiguration`.""" |
475 | + access_token = get_interaction_extras().access_token |
476 | + if access_token is None: |
477 | + return |
478 | + if access_token.context != context: |
479 | + raise Unauthorized( |
480 | + "Current authentication does not allow access to this object.") |
481 | + if not required_scopes: |
482 | + raise Unauthorized( |
483 | + "Current authentication only allows calling scoped methods.") |
484 | + elif not any( |
485 | + scope.title in required_scopes |
486 | + for scope in access_token.scopes): |
487 | + raise Unauthorized( |
488 | + "Current authentication does not allow calling this method " |
489 | + "(one of these scopes is required: %s)." |
490 | + % ", ".join("'%s'" % scope for scope in required_scopes)) |