Merge ~cjwatson/launchpad:access-token-auth into launchpad: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)
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
1diff --git a/lib/lp/services/webapp/doc/webapp-publication.txt b/lib/lp/services/webapp/doc/webapp-publication.txt
2index 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
14diff --git a/lib/lp/services/webapp/interaction.py b/lib/lp/services/webapp/interaction.py
15index 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():
32diff --git a/lib/lp/services/webapp/interfaces.py b/lib/lp/services/webapp/interfaces.py
33index 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
52diff --git a/lib/lp/services/webapp/publication.py b/lib/lp/services/webapp/publication.py
53index 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:
85diff --git a/lib/lp/services/webapp/servers.py b/lib/lp/services/webapp/servers.py
86index 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)
199diff --git a/lib/lp/services/webapp/tests/test_publication.py b/lib/lp/services/webapp/tests/test_publication.py
200index 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:
241diff --git a/lib/lp/services/webapp/tests/test_servers.py b/lib/lp/services/webapp/tests/test_servers.py
242index 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(
444diff --git a/lib/lp/services/webservice/configuration.py b/lib/lp/services/webservice/configuration.py
445index 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))

Subscribers

People subscribed via source and target branches

to status/vote changes: