← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/authserver-macaroon into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/authserver-macaroon into lp:launchpad.

Commit message:
Add an authserver endpoint to verify macaroons.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/authserver-macaroon/+merge/345354

This is intended to support a reworking of https://code.launchpad.net/~cjwatson/launchpad/librarian-accept-macaroon/+merge/345079.

"Returns True or a fault" is a bit of a weird interface, but there isn't really anything else to return, None is annoying in XML-RPC, and I think this is a bit more idiomatic for our XML-RPC endpoints than "returns True or False" would be.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/authserver-macaroon into lp:launchpad.
=== modified file 'lib/lp/services/authserver/interfaces.py'
--- lib/lp/services/authserver/interfaces.py	2015-10-14 15:22:01 +0000
+++ lib/lp/services/authserver/interfaces.py	2018-05-10 10:45:27 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Interface for the XML-RPC authentication server."""
@@ -27,6 +27,17 @@
             person with the given name.
         """
 
+    def verifyMacaroon(macaroon_raw, context):
+        """Verify that `macaroon_raw` grants access to `context`.
+
+        :param macaroon_raw: A serialised macaroon.
+        :param context: The context to check.  Note that this is passed over
+            XML-RPC, so it should be plain data (e.g. an ID) rather than a
+            database object.
+        :return: True if the macaroon grants access to `context`, otherwise
+            an `Unauthorized` fault.
+        """
+
 
 class IAuthServerApplication(ILaunchpadApplication):
     """Launchpad legacy AuthServer application root."""

=== modified file 'lib/lp/services/authserver/tests/test_authserver.py'
--- lib/lp/services/authserver/tests/test_authserver.py	2012-06-14 05:18:22 +0000
+++ lib/lp/services/authserver/tests/test_authserver.py	2018-05-10 10:45:27 +0000
@@ -1,19 +1,32 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for the internal codehosting API."""
 
 __metaclass__ = type
 
+from pymacaroons import (
+    Macaroon,
+    Verifier,
+    )
+from testtools.matchers import Is
 from zope.component import getUtility
+from zope.interface import implementer
 from zope.publisher.xmlrpc import TestRequest
 
 from lp.services.authserver.xmlrpc import AuthServerAPIView
+from lp.services.config import config
+from lp.services.macaroons.interfaces import IMacaroonIssuer
 from lp.testing import (
     person_logged_in,
+    TestCase,
     TestCaseWithFactory,
     )
-from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.fixture import ZopeUtilityFixture
+from lp.testing.layers import (
+    DatabaseFunctionalLayer,
+    ZopelessLayer,
+    )
 from lp.xmlrpc import faults
 from lp.xmlrpc.interfaces import IPrivateApplication
 
@@ -57,3 +70,78 @@
                 dict(id=new_person.id, name=new_person.name,
                      keys=[(key.keytype.title, key.keytext)]),
                 self.authserver.getUserAndSSHKeys(new_person.name))
+
+
+@implementer(IMacaroonIssuer)
+class DummyMacaroonIssuer:
+
+    _root_secret = 'test'
+
+    def issueMacaroon(self, context):
+        """See `IMacaroonIssuer`."""
+        macaroon = Macaroon(
+            location=config.vhost.mainsite.hostname, identifier='test',
+            key=self._root_secret)
+        macaroon.add_first_party_caveat('test %s' % context)
+        return macaroon
+
+    def checkMacaroonIssuer(self, macaroon):
+        """See `IMacaroonIssuer`."""
+        if macaroon.location != config.vhost.mainsite.hostname:
+            return False
+        try:
+            verifier = Verifier()
+            verifier.satisfy_general(
+                lambda caveat: caveat.startswith('test '))
+            return verifier.verify(macaroon, self._root_secret)
+        except Exception:
+            return False
+
+    def verifyMacaroon(self, macaroon, context):
+        """See `IMacaroonIssuer`."""
+        if not self.checkMacaroonIssuer(macaroon):
+            return False
+        try:
+            verifier = Verifier()
+            verifier.satisfy_exact('test %s' % context)
+            return verifier.verify(macaroon, self._root_secret)
+        except Exception:
+            return False
+
+
+class VerifyMacaroonTests(TestCase):
+
+    layer = ZopelessLayer
+
+    def setUp(self):
+        super(VerifyMacaroonTests, self).setUp()
+        self.issuer = DummyMacaroonIssuer()
+        self.useFixture(ZopeUtilityFixture(
+            self.issuer, IMacaroonIssuer, name='test'))
+        private_root = getUtility(IPrivateApplication)
+        self.authserver = AuthServerAPIView(
+            private_root.authserver, TestRequest())
+
+    def test_nonsense_macaroon(self):
+        self.assertEqual(
+            faults.Unauthorized(),
+            self.authserver.verifyMacaroon('nonsense', 0))
+
+    def test_unknown_issuer(self):
+        macaroon = Macaroon(
+            location=config.vhost.mainsite.hostname,
+            identifier='unknown-issuer', key='test')
+        self.assertEqual(
+            faults.Unauthorized(),
+            self.authserver.verifyMacaroon(macaroon.serialize(), 0))
+
+    def test_wrong_context(self):
+        macaroon = self.issuer.issueMacaroon(0)
+        self.assertEqual(
+            faults.Unauthorized(),
+            self.authserver.verifyMacaroon(macaroon.serialize(), 1))
+
+    def test_success(self):
+        macaroon = self.issuer.issueMacaroon(0)
+        self.assertThat(
+            self.authserver.verifyMacaroon(macaroon.serialize(), 0), Is(True))

=== modified file 'lib/lp/services/authserver/xmlrpc.py'
--- lib/lp/services/authserver/xmlrpc.py	2015-10-14 15:22:01 +0000
+++ lib/lp/services/authserver/xmlrpc.py	2018-05-10 10:45:27 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Auth-Server XML-RPC API ."""
@@ -10,7 +10,11 @@
     'AuthServerAPIView',
     ]
 
-from zope.component import getUtility
+from pymacaroons import Macaroon
+from zope.component import (
+    ComponentLookupError,
+    getUtility,
+    )
 from zope.interface import implementer
 
 from lp.registry.interfaces.person import IPersonSet
@@ -18,6 +22,7 @@
     IAuthServer,
     IAuthServerApplication,
     )
+from lp.services.macaroons.interfaces import IMacaroonIssuer
 from lp.services.webapp import LaunchpadXMLRPCView
 from lp.xmlrpc import faults
 
@@ -38,6 +43,20 @@
                      for key in person.sshkeys],
             }
 
+    def verifyMacaroon(self, macaroon_raw, context):
+        """See `IAuthServer.verifyMacaroon`."""
+        try:
+            macaroon = Macaroon.deserialize(macaroon_raw)
+        except Exception:
+            return faults.Unauthorized()
+        try:
+            issuer = getUtility(IMacaroonIssuer, macaroon.identifier)
+        except ComponentLookupError:
+            return faults.Unauthorized()
+        if not issuer.verifyMacaroon(macaroon, context):
+            return faults.Unauthorized()
+        return True
+
 
 @implementer(IAuthServerApplication)
 class AuthServerApplication:


Follow ups