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

Proposed by Colin Watson on 2018-05-10
Status: Merged
Merged at revision: 18725
Proposed branch: lp:~cjwatson/launchpad/authserver-macaroon
Merge into: lp:launchpad
Diff against target: 197 lines (+123/-5)
3 files modified
lib/lp/services/authserver/interfaces.py (+12/-1)
lib/lp/services/authserver/tests/test_authserver.py (+90/-2)
lib/lp/services/authserver/xmlrpc.py (+21/-2)
To merge this branch: bzr merge lp:~cjwatson/launchpad/authserver-macaroon
Reviewer Review Type Date Requested Status
William Grant code 2018-05-10 Approve on 2018-07-12
Review via email: mp+345354@code.launchpad.net

Commit message

Add an authserver endpoint to verify macaroons.

Description of the change

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.

To post a comment you must log in.
William Grant (wgrant) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/services/authserver/interfaces.py'
2--- lib/lp/services/authserver/interfaces.py 2015-10-14 15:22:01 +0000
3+++ lib/lp/services/authserver/interfaces.py 2018-05-10 10:45:27 +0000
4@@ -1,4 +1,4 @@
5-# Copyright 2009 Canonical Ltd. This software is licensed under the
6+# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
7 # GNU Affero General Public License version 3 (see the file LICENSE).
8
9 """Interface for the XML-RPC authentication server."""
10@@ -27,6 +27,17 @@
11 person with the given name.
12 """
13
14+ def verifyMacaroon(macaroon_raw, context):
15+ """Verify that `macaroon_raw` grants access to `context`.
16+
17+ :param macaroon_raw: A serialised macaroon.
18+ :param context: The context to check. Note that this is passed over
19+ XML-RPC, so it should be plain data (e.g. an ID) rather than a
20+ database object.
21+ :return: True if the macaroon grants access to `context`, otherwise
22+ an `Unauthorized` fault.
23+ """
24+
25
26 class IAuthServerApplication(ILaunchpadApplication):
27 """Launchpad legacy AuthServer application root."""
28
29=== modified file 'lib/lp/services/authserver/tests/test_authserver.py'
30--- lib/lp/services/authserver/tests/test_authserver.py 2012-06-14 05:18:22 +0000
31+++ lib/lp/services/authserver/tests/test_authserver.py 2018-05-10 10:45:27 +0000
32@@ -1,19 +1,32 @@
33-# Copyright 2009 Canonical Ltd. This software is licensed under the
34+# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
35 # GNU Affero General Public License version 3 (see the file LICENSE).
36
37 """Tests for the internal codehosting API."""
38
39 __metaclass__ = type
40
41+from pymacaroons import (
42+ Macaroon,
43+ Verifier,
44+ )
45+from testtools.matchers import Is
46 from zope.component import getUtility
47+from zope.interface import implementer
48 from zope.publisher.xmlrpc import TestRequest
49
50 from lp.services.authserver.xmlrpc import AuthServerAPIView
51+from lp.services.config import config
52+from lp.services.macaroons.interfaces import IMacaroonIssuer
53 from lp.testing import (
54 person_logged_in,
55+ TestCase,
56 TestCaseWithFactory,
57 )
58-from lp.testing.layers import DatabaseFunctionalLayer
59+from lp.testing.fixture import ZopeUtilityFixture
60+from lp.testing.layers import (
61+ DatabaseFunctionalLayer,
62+ ZopelessLayer,
63+ )
64 from lp.xmlrpc import faults
65 from lp.xmlrpc.interfaces import IPrivateApplication
66
67@@ -57,3 +70,78 @@
68 dict(id=new_person.id, name=new_person.name,
69 keys=[(key.keytype.title, key.keytext)]),
70 self.authserver.getUserAndSSHKeys(new_person.name))
71+
72+
73+@implementer(IMacaroonIssuer)
74+class DummyMacaroonIssuer:
75+
76+ _root_secret = 'test'
77+
78+ def issueMacaroon(self, context):
79+ """See `IMacaroonIssuer`."""
80+ macaroon = Macaroon(
81+ location=config.vhost.mainsite.hostname, identifier='test',
82+ key=self._root_secret)
83+ macaroon.add_first_party_caveat('test %s' % context)
84+ return macaroon
85+
86+ def checkMacaroonIssuer(self, macaroon):
87+ """See `IMacaroonIssuer`."""
88+ if macaroon.location != config.vhost.mainsite.hostname:
89+ return False
90+ try:
91+ verifier = Verifier()
92+ verifier.satisfy_general(
93+ lambda caveat: caveat.startswith('test '))
94+ return verifier.verify(macaroon, self._root_secret)
95+ except Exception:
96+ return False
97+
98+ def verifyMacaroon(self, macaroon, context):
99+ """See `IMacaroonIssuer`."""
100+ if not self.checkMacaroonIssuer(macaroon):
101+ return False
102+ try:
103+ verifier = Verifier()
104+ verifier.satisfy_exact('test %s' % context)
105+ return verifier.verify(macaroon, self._root_secret)
106+ except Exception:
107+ return False
108+
109+
110+class VerifyMacaroonTests(TestCase):
111+
112+ layer = ZopelessLayer
113+
114+ def setUp(self):
115+ super(VerifyMacaroonTests, self).setUp()
116+ self.issuer = DummyMacaroonIssuer()
117+ self.useFixture(ZopeUtilityFixture(
118+ self.issuer, IMacaroonIssuer, name='test'))
119+ private_root = getUtility(IPrivateApplication)
120+ self.authserver = AuthServerAPIView(
121+ private_root.authserver, TestRequest())
122+
123+ def test_nonsense_macaroon(self):
124+ self.assertEqual(
125+ faults.Unauthorized(),
126+ self.authserver.verifyMacaroon('nonsense', 0))
127+
128+ def test_unknown_issuer(self):
129+ macaroon = Macaroon(
130+ location=config.vhost.mainsite.hostname,
131+ identifier='unknown-issuer', key='test')
132+ self.assertEqual(
133+ faults.Unauthorized(),
134+ self.authserver.verifyMacaroon(macaroon.serialize(), 0))
135+
136+ def test_wrong_context(self):
137+ macaroon = self.issuer.issueMacaroon(0)
138+ self.assertEqual(
139+ faults.Unauthorized(),
140+ self.authserver.verifyMacaroon(macaroon.serialize(), 1))
141+
142+ def test_success(self):
143+ macaroon = self.issuer.issueMacaroon(0)
144+ self.assertThat(
145+ self.authserver.verifyMacaroon(macaroon.serialize(), 0), Is(True))
146
147=== modified file 'lib/lp/services/authserver/xmlrpc.py'
148--- lib/lp/services/authserver/xmlrpc.py 2015-10-14 15:22:01 +0000
149+++ lib/lp/services/authserver/xmlrpc.py 2018-05-10 10:45:27 +0000
150@@ -1,4 +1,4 @@
151-# Copyright 2009 Canonical Ltd. This software is licensed under the
152+# Copyright 2009-2018 Canonical Ltd. This software is licensed under the
153 # GNU Affero General Public License version 3 (see the file LICENSE).
154
155 """Auth-Server XML-RPC API ."""
156@@ -10,7 +10,11 @@
157 'AuthServerAPIView',
158 ]
159
160-from zope.component import getUtility
161+from pymacaroons import Macaroon
162+from zope.component import (
163+ ComponentLookupError,
164+ getUtility,
165+ )
166 from zope.interface import implementer
167
168 from lp.registry.interfaces.person import IPersonSet
169@@ -18,6 +22,7 @@
170 IAuthServer,
171 IAuthServerApplication,
172 )
173+from lp.services.macaroons.interfaces import IMacaroonIssuer
174 from lp.services.webapp import LaunchpadXMLRPCView
175 from lp.xmlrpc import faults
176
177@@ -38,6 +43,20 @@
178 for key in person.sshkeys],
179 }
180
181+ def verifyMacaroon(self, macaroon_raw, context):
182+ """See `IAuthServer.verifyMacaroon`."""
183+ try:
184+ macaroon = Macaroon.deserialize(macaroon_raw)
185+ except Exception:
186+ return faults.Unauthorized()
187+ try:
188+ issuer = getUtility(IMacaroonIssuer, macaroon.identifier)
189+ except ComponentLookupError:
190+ return faults.Unauthorized()
191+ if not issuer.verifyMacaroon(macaroon, context):
192+ return faults.Unauthorized()
193+ return True
194+
195
196 @implementer(IAuthServerApplication)
197 class AuthServerApplication: