← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:access-token-auth into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:access-token-auth into launchpad:master.

Commit message:
Support webservice authentication using AccessTokens

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/409946

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.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:access-token-auth into launchpad:master.
diff --git a/lib/lp/services/webapp/doc/webapp-publication.txt b/lib/lp/services/webapp/doc/webapp-publication.txt
index f0c2bcf..98f1fd0 100644
--- a/lib/lp/services/webapp/doc/webapp-publication.txt
+++ b/lib/lp/services/webapp/doc/webapp-publication.txt
@@ -1129,7 +1129,7 @@ The feeds implementation always returns the anonymous user.
     True
 
 The webservice implementation returns the principal for the person
-associated with the access token specified in the request.  The
+associated with the OAuth access token specified in the request.  The
 principal's access_level and scope will match what was specified in the
 token.
 
diff --git a/lib/lp/services/webapp/interaction.py b/lib/lp/services/webapp/interaction.py
index 8aec9d0..4b60242 100644
--- a/lib/lp/services/webapp/interaction.py
+++ b/lib/lp/services/webapp/interaction.py
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Methods dealing with interactions.
@@ -179,6 +179,7 @@ class InteractionExtras:
     """Extra data attached to all interactions.  See `IInteractionExtras`."""
 
     permit_timeout_from_features = False
+    access_token = None
 
 
 def get_interaction_extras():
diff --git a/lib/lp/services/webapp/interfaces.py b/lib/lp/services/webapp/interfaces.py
index 7c71623..a895808 100644
--- a/lib/lp/services/webapp/interfaces.py
+++ b/lib/lp/services/webapp/interfaces.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 import logging
@@ -350,6 +350,9 @@ class IInteractionExtras(Interface):
         `lp.services.webapp.servers.set_permit_timeout_from_features`
         for more.""")
 
+    access_token = Attribute(
+        "The `IAccessToken` used to authenticate this interaction, if any.")
+
 
 #
 # Request
diff --git a/lib/lp/services/webapp/publication.py b/lib/lp/services/webapp/publication.py
index e7a4ec8..717cc65 100644
--- a/lib/lp/services/webapp/publication.py
+++ b/lib/lp/services/webapp/publication.py
@@ -70,6 +70,7 @@ from lp.registry.interfaces.person import (
     ITeam,
     )
 from lp.services import features
+from lp.services.auth.interfaces import IAccessTokenVerifiedRequest
 from lp.services.config import config
 from lp.services.database.interfaces import (
     IDatabasePolicy,
@@ -117,13 +118,13 @@ def maybe_block_offsite_form_post(request):
     if request.method != 'POST':
         return
     if (IOAuthSignedRequest.providedBy(request)
-        or not IBrowserRequest.providedBy(request)):
-        # We only want to check for the referrer header if we are
-        # in the middle of a request initiated by a web browser. A
-        # request to the web service (which is necessarily
-        # OAuth-signed) or a request that does not implement
-        # IBrowserRequest (such as an XML-RPC request) can do
-        # without a Referer.
+            or IAccessTokenVerifiedRequest.providedBy(request)
+            or not IBrowserRequest.providedBy(request)):
+        # We only want to check for the referrer header if we are in the
+        # middle of a request initiated by a web browser. A request to the
+        # web service (which is necessarily OAuth-signed or verified by an
+        # access token) or a request that does not implement IBrowserRequest
+        # (such as an XML-RPC request) can do without a Referer.
         return
     if request['PATH_INFO'] in OFFSITE_POST_WHITELIST:
         # XXX: jamesh 2007-11-23 bug=124421:
diff --git a/lib/lp/services/webapp/servers.py b/lib/lp/services/webapp/servers.py
index 8707437..2350b1a 100644
--- a/lib/lp/services/webapp/servers.py
+++ b/lib/lp/services/webapp/servers.py
@@ -57,6 +57,10 @@ from zope.session.interfaces import ISession
 from lp.app import versioninfo
 from lp.app.errors import UnexpectedFormData
 import lp.layers
+from lp.services.auth.interfaces import (
+    IAccessTokenSet,
+    IAccessTokenVerifiedRequest,
+    )
 from lp.services.config import config
 from lp.services.encoding import wsgi_native_string
 from lp.services.features import get_relevant_feature_controller
@@ -80,6 +84,7 @@ from lp.services.webapp.authorization import (
     LAUNCHPAD_SECURITY_POLICY_CACHE_UNAUTH_KEY,
     )
 from lp.services.webapp.errorlog import ErrorReportRequest
+from lp.services.webapp.interaction import get_interaction_extras
 from lp.services.webapp.interfaces import (
     IBasicLaunchpadRequest,
     IBrowserFormNG,
@@ -1191,6 +1196,29 @@ class WebServicePublication(WebServicePublicationMixin,
         if request_path.startswith("/%s" % web_service_config.path_override):
             return super(WebServicePublication, self).getPrincipal(request)
 
+        if request._auth is not None and request._auth.startswith("Token "):
+            access_token = removeSecurityProxy(
+                getUtility(IAccessTokenSet).getBySecret(
+                    request._auth[len("Token "):]))
+            if access_token is None:
+                raise TokenException("Unknown access token.")
+            elif access_token.is_expired:
+                raise TokenException("Expired access token.")
+            elif access_token.owner.account_status != AccountStatus.ACTIVE:
+                raise TokenException("Inactive account.")
+            access_token.updateLastUsed()
+            # GET requests will be rolled back, as will unsuccessful ones.
+            # Commit so that the last-used date is updated anyway.
+            transaction.commit()
+            logging_context.push(
+                access_token_id=access_token.id,
+                access_token_scopes=" ".join(
+                    scope.title for scope in access_token.scopes))
+            alsoProvides(request, IAccessTokenVerifiedRequest)
+            get_interaction_extras().access_token = access_token
+            return getUtility(IPlacelessLoginSource).getPrincipal(
+                access_token.owner.accountID)
+
         # Fetch OAuth authorization information from the request.
         try:
             form = get_oauth_authorization(request)
diff --git a/lib/lp/services/webapp/tests/test_publication.py b/lib/lp/services/webapp/tests/test_publication.py
index 332db55..57d63a9 100644
--- a/lib/lp/services/webapp/tests/test_publication.py
+++ b/lib/lp/services/webapp/tests/test_publication.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2017 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests publication.py"""
@@ -28,6 +28,7 @@ from zope.security.management import (
     thread_local as zope_security_thread_local,
     )
 
+from lp.services.auth.interfaces import IAccessTokenVerifiedRequest
 from lp.services.database.interfaces import IMasterStore
 from lp.services.identity.model.emailaddress import EmailAddress
 from lp.services.oauth.interfaces import (
@@ -124,7 +125,7 @@ class TestWebServicePublication(TestCaseWithFactory):
         person = self.factory.makePerson()
         self.assertNotEqual(person.id, person.account.id)
 
-        # Create an access token for our new person.
+        # Create an OAuth access token for our new person.
         consumer = getUtility(IOAuthConsumerSet).new(u'test-consumer')
         request_token, _ = consumer.newRequestToken()
         request_token.review(
@@ -244,6 +245,14 @@ class TestBlockingOffsitePosts(TestCase):
         # this call shouldn't raise an exception
         maybe_block_offsite_form_post(request)
 
+    def test_access_token_verified_requests(self):
+        # Requests that are verified with an access token are allowed.
+        request = LaunchpadTestRequest(
+            method='POST', environ=dict(PATH_INFO='/'))
+        directlyProvides(request, IAccessTokenVerifiedRequest)
+        # this call shouldn't raise an exception
+        maybe_block_offsite_form_post(request)
+
     def test_nonbrowser_requests(self):
         # Requests that are from non-browsers are allowed.
         class FakeNonBrowserRequest:
diff --git a/lib/lp/services/webapp/tests/test_servers.py b/lib/lp/services/webapp/tests/test_servers.py
index 50f48aa..a551f78 100644
--- a/lib/lp/services/webapp/tests/test_servers.py
+++ b/lib/lp/services/webapp/tests/test_servers.py
@@ -1,6 +1,10 @@
 # Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
+from datetime import (
+    datetime,
+    timedelta,
+    )
 from doctest import (
     DocTestSuite,
     ELLIPSIS,
@@ -19,8 +23,14 @@ from lazr.restful.testing.webservice import (
     IGenericEntry,
     WebServiceTestCase,
     )
+import pytz
 from talisker.context import Context
 from talisker.logs import logging_context
+from testtools.matchers import (
+    ContainsDict,
+    Equals,
+    )
+import transaction
 from zope.component import (
     getGlobalSiteManager,
     getUtility,
@@ -29,8 +39,15 @@ from zope.interface import (
     implementer,
     Interface,
     )
+from zope.security.interfaces import Unauthorized
+from zope.security.management import newInteraction
+from zope.security.proxy import removeSecurityProxy
 
 from lp.app import versioninfo
+from lp.services.auth.enums import AccessTokenScope
+from lp.services.identity.interfaces.account import AccountStatus
+from lp.services.oauth.interfaces import TokenException
+from lp.services.webapp.interaction import get_interaction_extras
 from lp.services.webapp.interfaces import IFinishReadOnlyRequestEvent
 from lp.services.webapp.publication import LaunchpadBrowserPublication
 from lp.services.webapp.servers import (
@@ -50,9 +67,16 @@ from lp.services.webapp.servers import (
     )
 from lp.testing import (
     EventRecorder,
+    logout,
+    person_logged_in,
     TestCase,
+    TestCaseWithFactory,
     )
-from lp.testing.layers import FunctionalLayer
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    FunctionalLayer,
+    )
+from lp.testing.publication import get_request_and_publication
 
 
 class SetInWSGIEnvironmentTestCase(TestCase):
@@ -781,6 +805,138 @@ class TestFinishReadOnlyRequest(TestCase):
         self._test_publication(publication, ["ABORT"])
 
 
+class TestWebServiceAccessTokens(TestCaseWithFactory):
+    """Test personal access tokens for the webservice.
+
+    These are bearer tokens with an owner, a context, and some scopes.  We
+    can authenticate using one of these, and it will be recorded in the
+    interaction extras.
+    """
+
+    layer = DatabaseFunctionalLayer
+
+    def test_valid(self):
+        owner = self.factory.makePerson()
+        secret, token = self.factory.makeAccessToken(
+            owner=owner, scopes=[AccessTokenScope.REPOSITORY_BUILD_STATUS])
+        self.assertIsNone(removeSecurityProxy(token).date_last_used)
+        transaction.commit()
+        logout()
+
+        request, publication = get_request_and_publication(
+            "api.launchpad.test", "POST",
+            extra_environment={"HTTP_AUTHORIZATION": "Token %s" % secret})
+        newInteraction(request)
+        principal = publication.getPrincipal(request)
+        request.setPrincipal(principal)
+        self.assertEqual(owner, principal.person)
+        self.assertEqual(token, get_interaction_extras().access_token)
+        self.assertIsNotNone(token.date_last_used)
+        self.assertThat(logging_context.flat, ContainsDict({
+            "access_token_id": Equals(removeSecurityProxy(token).id),
+            "access_token_scopes": Equals("repository:build_status"),
+            }))
+
+        # token.date_last_used is still up to date even if the transaction
+        # is rolled back.
+        date_last_used = token.date_last_used
+        transaction.abort()
+        self.assertEqual(date_last_used, token.date_last_used)
+
+    def test_expired(self):
+        owner = self.factory.makePerson()
+        secret, token = self.factory.makeAccessToken(owner=owner)
+        with person_logged_in(owner):
+            token.date_expires = datetime.now(pytz.UTC) - timedelta(days=1)
+        transaction.commit()
+
+        request, publication = get_request_and_publication(
+            "api.launchpad.test", "POST",
+            extra_environment={"HTTP_AUTHORIZATION": "Token %s" % secret})
+        self.assertRaisesWithContent(
+            TokenException, "Expired access token.",
+            publication.getPrincipal, request)
+
+    def test_unknown(self):
+        request, publication = get_request_and_publication(
+            "api.launchpad.test", "POST",
+            extra_environment={"HTTP_AUTHORIZATION": "Token nonexistent"})
+        self.assertRaisesWithContent(
+            TokenException, "Unknown access token.",
+            publication.getPrincipal, request)
+
+    def test_inactive_account(self):
+        owner = self.factory.makePerson(account_status=AccountStatus.SUSPENDED)
+        secret, token = self.factory.makeAccessToken(owner=owner)
+        transaction.commit()
+
+        request, publication = get_request_and_publication(
+            "api.launchpad.test", "POST",
+            extra_environment={"HTTP_AUTHORIZATION": "Token %s" % secret})
+        self.assertRaisesWithContent(
+            TokenException, "Inactive account.",
+            publication.getPrincipal, request)
+
+    def _makeAccessTokenVerifiedRequest(self, **kwargs):
+        secret, token = self.factory.makeAccessToken(**kwargs)
+        transaction.commit()
+        logout()
+
+        request, publication = get_request_and_publication(
+            "api.launchpad.test", "POST",
+            extra_environment={"HTTP_AUTHORIZATION": "Token %s" % secret})
+        newInteraction(request)
+        principal = publication.getPrincipal(request)
+        request.setPrincipal(principal)
+
+    def test_checkRequest_valid(self):
+        repository = self.factory.makeGitRepository()
+        self._makeAccessTokenVerifiedRequest(
+            context=repository,
+            scopes=[AccessTokenScope.REPOSITORY_BUILD_STATUS])
+        getUtility(IWebServiceConfiguration).checkRequest(
+            repository,
+            ["repository:build_status", "repository:another_scope"])
+
+    def test_checkRequest_bad_context(self):
+        repository = self.factory.makeGitRepository()
+        self._makeAccessTokenVerifiedRequest(
+            context=repository,
+            scopes=[AccessTokenScope.REPOSITORY_BUILD_STATUS])
+        self.assertRaisesWithContent(
+            Unauthorized,
+            "Current authentication does not allow access to this object.",
+            getUtility(IWebServiceConfiguration).checkRequest,
+            self.factory.makeGitRepository(), ["repository:build_status"])
+
+    def test_checkRequest_unscoped_method(self):
+        repository = self.factory.makeGitRepository()
+        self._makeAccessTokenVerifiedRequest(
+            context=repository,
+            scopes=[AccessTokenScope.REPOSITORY_BUILD_STATUS])
+        self.assertRaisesWithContent(
+            Unauthorized,
+            "Current authentication only allows calling scoped methods.",
+            getUtility(IWebServiceConfiguration).checkRequest,
+            repository, None)
+
+    def test_checkRequest_wrong_scope(self):
+        repository = self.factory.makeGitRepository()
+        self._makeAccessTokenVerifiedRequest(
+            context=repository,
+            scopes=[
+                AccessTokenScope.REPOSITORY_BUILD_STATUS,
+                AccessTokenScope.REPOSITORY_PUSH,
+                ])
+        self.assertRaisesWithContent(
+            Unauthorized,
+            "Current authentication does not allow calling this method "
+            "(one of these scopes is required: "
+            "'repository:scope_1', 'repository:scope_2').",
+            getUtility(IWebServiceConfiguration).checkRequest,
+            repository, ["repository:scope_1", "repository:scope_2"])
+
+
 def test_suite():
     suite = unittest.TestSuite()
     suite.addTest(DocTestSuite(
diff --git a/lib/lp/services/webservice/configuration.py b/lib/lp/services/webservice/configuration.py
index 549e4fd..f52918a 100644
--- a/lib/lp/services/webservice/configuration.py
+++ b/lib/lp/services/webservice/configuration.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2021 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """A configuration class describing the Launchpad web service."""
@@ -10,9 +10,12 @@ __all__ = [
 from lazr.restful.simple import BaseWebServiceConfiguration
 import six
 from zope.component import getUtility
+from zope.security.interfaces import Unauthorized
 
 from lp.app import versioninfo
 from lp.services.config import config
+from lp.services.database.sqlbase import block_implicit_flushes
+from lp.services.webapp.interaction import get_interaction_extras
 from lp.services.webapp.interfaces import ILaunchBag
 from lp.services.webapp.servers import (
     WebServiceClientRequest,
@@ -92,3 +95,23 @@ class LaunchpadWebServiceConfiguration(BaseWebServiceConfiguration):
     def get_request_user(self):
         """See `IWebServiceConfiguration`."""
         return getUtility(ILaunchBag).user
+
+    @block_implicit_flushes
+    def checkRequest(self, context, required_scopes):
+        """See `IWebServiceConfiguration`."""
+        access_token = get_interaction_extras().access_token
+        if access_token is None:
+            return
+        if access_token.context != context:
+            raise Unauthorized(
+                "Current authentication does not allow access to this object.")
+        if not required_scopes:
+            raise Unauthorized(
+                "Current authentication only allows calling scoped methods.")
+        elif not any(
+                scope.title in required_scopes
+                for scope in access_token.scopes):
+            raise Unauthorized(
+                "Current authentication does not allow calling this method "
+                "(one of these scopes is required: %s)."
+                % ", ".join("'%s'" % scope for scope in required_scopes))