Merge lp:~facundo/canonical-identity-provider/refresh-macaroons into lp:canonical-identity-provider/release

Proposed by Facundo Batista
Status: Merged
Approved by: Facundo Batista
Approved revision: no longer in the source branch.
Merged at revision: 1416
Proposed branch: lp:~facundo/canonical-identity-provider/refresh-macaroons
Merge into: lp:canonical-identity-provider/release
Diff against target: 691 lines (+450/-56)
5 files modified
src/api/v20/handlers.py (+33/-1)
src/api/v20/tests/test_handlers.py (+110/-9)
src/api/v20/urls.py (+7/-3)
src/identityprovider/auth.py (+104/-26)
src/identityprovider/tests/test_auth.py (+196/-17)
To merge this branch: bzr merge lp:~facundo/canonical-identity-provider/refresh-macaroons
Reviewer Review Type Date Requested Status
Matias Bordese (community) Approve
Review via email: mp+289798@code.launchpad.net

Commit message

We can refresh macaroons now.

Description of the change

We can refresh macaroons now.

To post a comment you must log in.
Revision history for this message
Matias Bordese (matiasb) wrote :

Looks good, thanks!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/api/v20/handlers.py'
2--- src/api/v20/handlers.py 2016-03-18 18:40:31 +0000
3+++ src/api/v20/handlers.py 2016-03-22 19:47:49 +0000
4@@ -425,7 +425,7 @@
5 return response
6
7
8-class MacaroonHandler(BaseHandler):
9+class MacaroonDischargeHandler(BaseHandler):
10 allowed_methods = ('POST',)
11
12 @require_mime('json')
13@@ -498,6 +498,38 @@
14 return response
15
16
17+class MacaroonRefreshHandler(BaseHandler):
18+ allowed_methods = ('POST',)
19+
20+ @require_mime('json')
21+ def create(self, request):
22+ if settings.CRYPTO_SSO_PRIVKEY is None:
23+ return errors.RESOURCE_NOT_FOUND()
24+
25+ data = request.data
26+ try:
27+ root_macaroon_raw = data['root_macaroon']
28+ discharge_macaroon_raw = data['discharge_macaroon']
29+ except KeyError:
30+ expected = {'root_macaroon', 'discharge_macaroon'}
31+ missing = dict((k, [FIELD_REQUIRED]) for k in expected - set(data))
32+ return errors.INVALID_DATA(**missing)
33+
34+ try:
35+ new_discharge = auth.refresh_macaroons(root_macaroon_raw,
36+ discharge_macaroon_raw)
37+ except ValidationError:
38+ return errors.INVALID_DATA()
39+ except AccountDeactivated:
40+ return errors.ACCOUNT_DEACTIVATED()
41+ except AuthenticationError:
42+ return errors.INVALID_CREDENTIALS()
43+
44+ response = rc.ALL_OK
45+ response.content = dict(discharge_macaroon=new_discharge.serialize())
46+ return response
47+
48+
49 class EmailsHandler(BaseHandler):
50 allowed_methods = ('DELETE', 'GET', 'PATCH')
51 model = EmailAddress
52
53=== modified file 'src/api/v20/tests/test_handlers.py'
54--- src/api/v20/tests/test_handlers.py 2016-03-18 20:27:55 +0000
55+++ src/api/v20/tests/test_handlers.py 2016-03-22 19:47:49 +0000
56@@ -29,6 +29,7 @@
57 from timeline import Timeline
58
59 from api.v20 import handlers, whitelist
60+from identityprovider.auth import build_discharge_macaroon
61 from identityprovider.login import PasswordPolicyError
62 from identityprovider.models import (
63 Account,
64@@ -2164,7 +2165,15 @@
65
66 class MacaroonHandlerBaseTestCase(SSOBaseTestCase):
67
68- url = reverse('api-macaroon-discharge')
69+ def build_key_pair(self, _cache=[]):
70+ """Build the key pairs (cached, as it takes quite some time now!)."""
71+ if not _cache:
72+ Random.atfork()
73+ test_rsa_priv_key = RSA.generate(2048)
74+ test_rsa_pub_key = test_rsa_priv_key.publickey()
75+ _cache.append(test_rsa_priv_key)
76+ _cache.append(test_rsa_pub_key)
77+ return _cache
78
79 def track_failed_logins(self, **kw):
80 self.login_failed_calls.append(kw)
81@@ -2175,9 +2184,7 @@
82 service_location = settings.MACAROON_SERVICE_LOCATION
83
84 # pair of keys to encrypt/decrypt
85- Random.atfork()
86- test_rsa_priv_key = RSA.generate(1024)
87- test_rsa_pub_key = test_rsa_priv_key.publickey()
88+ test_rsa_priv_key, test_rsa_pub_key = self.build_key_pair()
89 p = self.settings(CRYPTO_SSO_PRIVKEY=test_rsa_priv_key)
90 p.enable()
91 self.addCleanup(p.disable)
92@@ -2189,10 +2196,14 @@
93 identifier='A test macaroon',
94 )
95 random_key = binascii.hexlify(os.urandom(32))
96- random_key_encrypted = base64.b64encode(
97- test_rsa_pub_key.encrypt(random_key, 32)[0])
98+ info = {
99+ 'roothash': macaroon_random_key,
100+ '3rdparty': random_key,
101+ }
102+ info_encrypted = base64.b64encode(
103+ test_rsa_pub_key.encrypt(json.dumps(info), 32)[0])
104 root_macaroon.add_third_party_caveat(
105- service_location, random_key, random_key_encrypted)
106+ service_location, random_key, info_encrypted)
107 return root_macaroon, macaroon_random_key
108
109 def setUp(self):
110@@ -2209,8 +2220,10 @@
111 email=self.data['email'], password=self.data['password'])
112
113
114-class MacaroonHandlerTestCase(MacaroonHandlerBaseTestCase,
115- AuthLogTestCaseMixin):
116+class MacaroonDischargeHandlerTestCase(MacaroonHandlerBaseTestCase,
117+ AuthLogTestCaseMixin):
118+
119+ url = reverse('api-macaroon-discharge')
120
121 def do_post(self, data=None, expected_status_code=200,
122 content_type='application/json', **kwargs):
123@@ -2378,6 +2391,8 @@
124 class MacaroonHandlerTimelineTestCase(MacaroonHandlerBaseTestCase,
125 TimelineActionMixin):
126
127+ url = reverse('api-macaroon-discharge')
128+
129 def test_login_timeline_records_password_checking(self):
130 # Prepare and inject a timeline in the client's POST.
131 empty_timeline = Timeline()
132@@ -2408,3 +2423,89 @@
133
134 self.assertEqual(response.status_code, 200)
135 self.assertNotIn('timeline.timeline', response.wsgi_request.META)
136+
137+
138+class MacaroonRefreshHandlerTestCase(MacaroonHandlerBaseTestCase):
139+
140+ url = reverse('api-macaroon-refresh')
141+
142+ def setUp(self):
143+ super(MacaroonRefreshHandlerTestCase, self).setUp()
144+
145+ # discharge the test macaroon
146+ root_macaroon_raw = self.root_macaroon.serialize()
147+ self.discharge_macaroon = build_discharge_macaroon(
148+ self.account, root_macaroon_raw)
149+
150+ self.data = {'root_macaroon': root_macaroon_raw,
151+ 'discharge_macaroon': self.discharge_macaroon.serialize()}
152+
153+ def do_post(self, data=None, expected_status_code=200,
154+ content_type='application/json', **kwargs):
155+ if data is None:
156+ data = self.data
157+
158+ response = self.client.post(self.url, data=json.dumps(data),
159+ content_type=content_type, **kwargs)
160+
161+ self.assertEqual(
162+ response.status_code, expected_status_code,
163+ "Bad status code! expected={} got={} response={}".format(
164+ expected_status_code, response.status_code, response))
165+ self.assertEqual(response['Content-type'],
166+ 'application/json; charset=utf-8',
167+ response)
168+ return json.loads(response.content)
169+
170+ def assert_failed_login(self, code, data, extra=None,
171+ expected_status_code=403, check_login_failed=True):
172+ if extra is None:
173+ extra = {}
174+
175+ json_body = self.do_post(expected_status_code=expected_status_code,
176+ data=data)
177+ self.assertEqual(json_body['code'], code)
178+ self.assertEqual(json_body['extra'], extra)
179+ return json_body
180+
181+ def test_required_parameters(self):
182+ json_body = self.do_post(expected_status_code=400, data={})
183+
184+ self.assertEqual(json_body, {
185+ 'code': 'INVALID_DATA',
186+ 'extra': {'root_macaroon': [handlers.FIELD_REQUIRED],
187+ 'discharge_macaroon': [handlers.FIELD_REQUIRED]},
188+ 'message': 'Invalid request data'},
189+ )
190+ self.assertEqual(self.login_failed_calls, [])
191+
192+ def test_account_missing(self):
193+ self.account.deactivate()
194+ self.assert_failed_login('ACCOUNT_DEACTIVATED', self.data)
195+
196+ def test_root_macaroon_corrupt(self):
197+ data = dict(root_macaroon="I'm a seriously corrupted macaroon",
198+ discharge_macaroon="Also broken")
199+ self.assert_failed_login('INVALID_DATA', data,
200+ expected_status_code=400,
201+ check_login_failed=False)
202+
203+ def test_macaroon_bad_authinfo(self):
204+ macaroon, _ = self.build_macaroon(service_location="other service")
205+ data = dict(root_macaroon=macaroon.serialize(),
206+ discharge_macaroon=self.discharge_macaroon.serialize())
207+ self.assert_failed_login('INVALID_CREDENTIALS', data,
208+ expected_status_code=401,
209+ check_login_failed=False)
210+
211+ def test_macaroon_refreshed(self):
212+ json_body = self.do_post()
213+
214+ # get new discharge macaroon and verify with old root (its internals
215+ # are verified by the tests of the 'refresh_macaroons' function itself)
216+ discharge_macaroon_raw = json_body['discharge_macaroon']
217+ discharge_macaroon = Macaroon.deserialize(discharge_macaroon_raw)
218+ v = Verifier()
219+ v.satisfy_general(lambda c: True)
220+ v.verify(
221+ self.root_macaroon, self.macaroon_random_key, [discharge_macaroon])
222
223=== modified file 'src/api/v20/urls.py'
224--- src/api/v20/urls.py 2016-03-04 18:19:34 +0000
225+++ src/api/v20/urls.py 2016-03-22 19:47:49 +0000
226@@ -15,7 +15,8 @@
227 AccountRegistrationHandler,
228 AccountsHandler,
229 EmailsHandler,
230- MacaroonHandler,
231+ MacaroonDischargeHandler,
232+ MacaroonRefreshHandler,
233 PasswordResetTokenHandler,
234 RequestsHandler,
235 TokensHandler,
236@@ -27,7 +28,8 @@
237 v2emails = ApiResource(
238 handler=EmailsHandler, authentication=ApiEmailsAuthentication())
239 v2login = ApiResource(handler=AccountLoginHandler)
240-v2macaroon = ApiResource(handler=MacaroonHandler)
241+v2macaroon_discharge = ApiResource(handler=MacaroonDischargeHandler)
242+v2macaroon_refresh = ApiResource(handler=MacaroonRefreshHandler)
243 v2password_reset = ApiResource(handler=PasswordResetTokenHandler)
244 v2registration = ApiResource(
245 handler=AccountRegistrationHandler,
246@@ -43,7 +45,9 @@
247 url(r'^accounts/(?P<openid>\w+)$', v2accounts, name='api-account'),
248 url(r'^emails/(?P<email>.+)$', v2emails, name='api-email'),
249 url(r'^requests/validate$', v2requests, name='api-requests'),
250- url(r'^tokens/discharge$', v2macaroon, name='api-macaroon-discharge'),
251+ url(r'^tokens/discharge$', v2macaroon_discharge,
252+ name='api-macaroon-discharge'),
253+ url(r'^tokens/refresh$', v2macaroon_refresh, name='api-macaroon-refresh'),
254 url(r'^tokens/oauth$', v2login, name='api-login'),
255 url(r'^tokens/oauth/(?P<token>.+)$', v2tokens, name='api-token'),
256 url(r'^tokens/password$', v2password_reset, name='api-password-reset'),
257
258=== modified file 'src/identityprovider/auth.py'
259--- src/identityprovider/auth.py 2016-03-16 16:25:36 +0000
260+++ src/identityprovider/auth.py 2016-03-22 19:47:49 +0000
261@@ -24,9 +24,9 @@
262 from django.utils.translation import ugettext_lazy as _
263 from django.utils.timezone import now
264 from oauthlib.oauth1 import RequestValidator, ResourceEndpoint
265-from pymacaroons import Macaroon
266+from pymacaroons import Macaroon, Verifier
267
268-from identityprovider.login import AuthenticationError
269+from identityprovider.login import AuthenticationError, AccountDeactivated
270 from identityprovider.models import (
271 Account,
272 AccountPassword,
273@@ -495,34 +495,40 @@
274 return response
275
276
277-def build_discharge_macaroon(account, root_macaroon_raw):
278- """Build a discharge macaroon from a root one."""
279- service_location = settings.MACAROON_SERVICE_LOCATION
280-
281- try:
282- root_macaroon = Macaroon.deserialize(root_macaroon_raw)
283- except:
284- raise ValidationError("The received Macaroon is corrupt")
285-
286- try:
287- # isolate 3rd-party caveat that concerns SSO (hinted by 'location')
288- (sso_caveat,) = [c for c in root_macaroon.third_party_caveats()
289- if c.location == service_location]
290+def _get_own_caveat(macaroon):
291+ """Get a caveat for this service from the Macaroon."""
292+ try:
293+ # isolate 3rd-party caveat that concerns to us (hinted by 'location')
294+ (sso_caveat,) = [c for c in macaroon.third_party_caveats()
295+ if c.location == settings.MACAROON_SERVICE_LOCATION]
296 except:
297 # the macaroon doesn't have a location for this service
298 raise AuthenticationError("The received macaroon is not for ours")
299
300 # get the only-for-this-project random key
301- original_random_key = settings.CRYPTO_SSO_PRIVKEY.decrypt(
302- base64.b64decode(sso_caveat.caveat_id))
303-
304- # create a discharge macaroon with same location, key and
305+ try:
306+ caveat_info_raw = settings.CRYPTO_SSO_PRIVKEY.decrypt(
307+ base64.b64decode(sso_caveat.caveat_id))
308+ caveat_info = json.loads(caveat_info_raw)
309+ except:
310+ # not properly encrypted information inside
311+ raise AuthenticationError("Bad info in the caveat_id")
312+
313+ return sso_caveat.caveat_id, caveat_info
314+
315+
316+def _build_discharge(macaroon_key, caveat_id, account,
317+ last_auth=None, expires=None):
318+ """Build a typical discharge macaroon."""
319+ service_location = settings.MACAROON_SERVICE_LOCATION
320+
321+ # create the new discharge macaroon with same location, key and
322 # identifier than it's original 3rd-party caveat (so they can
323 # be matched and verified)
324 d = Macaroon(
325 location=service_location,
326- key=original_random_key,
327- identifier=sso_caveat.caveat_id,
328+ key=macaroon_key,
329+ identifier=caveat_id,
330 )
331
332 # add the account info
333@@ -536,12 +542,84 @@
334
335 # add timestamp values
336 now_string = now().strftime('%Y-%m-%dT%H:%M:%S.%f')
337+ if last_auth is None:
338+ last_auth = now_string
339+ if expires is None:
340+ expires_ts = now() + datetime.timedelta(seconds=settings.MACAROON_TTL)
341+ expires = expires_ts.strftime('%Y-%m-%dT%H:%M:%S.%f')
342+
343 d.add_first_party_caveat(service_location + '|valid_since|' + now_string)
344- d.add_first_party_caveat(service_location + '|last_auth|' + now_string)
345- expire = now() + datetime.timedelta(seconds=settings.MACAROON_TTL)
346- d.add_first_party_caveat(
347- service_location + '|expires|' +
348- expire.strftime('%Y-%m-%dT%H:%M:%S.%f'))
349+ d.add_first_party_caveat(service_location + '|last_auth|' + last_auth)
350+ d.add_first_party_caveat(service_location + '|expires|' + expires)
351+ return d
352+
353+
354+def build_discharge_macaroon(account, root_macaroon_raw):
355+ """Build a discharge macaroon from a root one."""
356+ try:
357+ root_macaroon = Macaroon.deserialize(root_macaroon_raw)
358+ except:
359+ raise ValidationError("The received Macaroon is corrupt")
360+
361+ # get the raw caveat id and the decrypted deserialized info
362+ raw_caveat_id, caveat_info = _get_own_caveat(root_macaroon)
363+
364+ # create a discharge macaroon with same location, key and
365+ # identifier than it's original 3rd-party caveat (so they can
366+ # be matched and verified)
367+ d = _build_discharge(caveat_info['3rdparty'], raw_caveat_id, account)
368+
369+ # return the properly prepared discharge macaroon
370+ discharge = root_macaroon.prepare_for_request(d)
371+ return discharge
372+
373+
374+def refresh_macaroons(root_macaroon_raw, discharge_macaroon_raw):
375+ """Refresh a root/discharge pair with a new discharge macaroon."""
376+ service_location = settings.MACAROON_SERVICE_LOCATION
377+
378+ try:
379+ root_macaroon = Macaroon.deserialize(root_macaroon_raw)
380+ discharge_macaroon = Macaroon.deserialize(discharge_macaroon_raw)
381+ except:
382+ raise ValidationError("The received Macaroons are corrupt")
383+
384+ # get the raw caveat id and the decrypted deserialized info
385+ raw_caveat_id, caveat_info = _get_own_caveat(root_macaroon)
386+ info_holder = {}
387+
388+ def checker(caveat):
389+ """Extract the openid."""
390+ source, key, value = caveat.split("|", 2)
391+ if source == service_location and key == 'account':
392+ acc = json.loads(base64.b64decode(value).decode("utf8"))
393+ info_holder['openid'] = acc['openid']
394+ elif source == service_location and key == 'last_auth':
395+ info_holder['last_auth'] = value
396+ elif source == service_location and key == 'expires':
397+ info_holder['expires'] = value
398+ return True
399+
400+ # assure that the received pair is safe
401+ v = Verifier()
402+ v.satisfy_general(checker)
403+ try:
404+ v.verify(root_macaroon, caveat_info['roothash'], [discharge_macaroon])
405+ except:
406+ raise AuthenticationError("Not verifying macaroons")
407+
408+ # verify that the account is still fine
409+ account = Account.objects.active_by_openid(info_holder['openid'])
410+ if account is None:
411+ raise AccountDeactivated()
412+
413+ # create the new discharge macaroon with same location, key and
414+ # identifier than it's original 3rd-party caveat (so they can
415+ # be matched and verified), and keeping the last_auth and expires from
416+ # the original discharge macaroon
417+ d = _build_discharge(caveat_info['3rdparty'], raw_caveat_id, account,
418+ last_auth=info_holder['last_auth'],
419+ expires=info_holder['expires'])
420
421 # return the properly prepared discharge macaroon
422 discharge = root_macaroon.prepare_for_request(d)
423
424=== modified file 'src/identityprovider/tests/test_auth.py'
425--- src/identityprovider/tests/test_auth.py 2016-03-16 16:25:36 +0000
426+++ src/identityprovider/tests/test_auth.py 2016-03-22 19:47:49 +0000
427@@ -31,18 +31,24 @@
428 LaunchpadBackend,
429 SSOOAuthAuthentication,
430 SSORequestValidator,
431+ _get_own_caveat,
432 basic_authenticate,
433 build_discharge_macaroon,
434+ refresh_macaroons,
435 validate_oauth_signature,
436 )
437-from identityprovider.login import AuthenticationError
438+from identityprovider.login import AuthenticationError, AccountDeactivated
439 from identityprovider.models import (
440 Account,
441 AccountPassword,
442 AuthLog,
443 EmailAddress,
444 )
445-from identityprovider.models.const import AccountStatus, TokenScope
446+from identityprovider.models.const import (
447+ AccountStatus,
448+ EmailStatus,
449+ TokenScope,
450+)
451 from identityprovider.readonly import ReadOnlyManager
452 from identityprovider.tests import DEFAULT_API_PASSWORD, DEFAULT_USER_PASSWORD
453 from identityprovider.tests.utils import (
454@@ -954,19 +960,30 @@
455 timer=self.dummy_timer)
456
457
458-class BuildMacaroonDischargeTestCase(SSOBaseTestCase):
459+class BaseMacaroonTestCase(SSOBaseTestCase):
460+
461+ def setup_key_pair(self, _cache=[]):
462+ """Build the key pairs (cached, as it takes quite some time now!)."""
463+ # get or build the keys
464+ if _cache:
465+ test_rsa_priv_key, test_rsa_pub_key = _cache
466+ else:
467+ Random.atfork()
468+ test_rsa_priv_key = RSA.generate(2048)
469+ test_rsa_pub_key = test_rsa_priv_key.publickey()
470+ _cache.append(test_rsa_priv_key)
471+ _cache.append(test_rsa_pub_key)
472+
473+ # set up config for priv key
474+ p = self.settings(CRYPTO_SSO_PRIVKEY=test_rsa_priv_key)
475+ p.enable()
476+ self.addCleanup(p.disable)
477+ return test_rsa_priv_key, test_rsa_pub_key
478
479 def build_macaroon(self, service_location=None):
480 if service_location is None:
481 service_location = settings.MACAROON_SERVICE_LOCATION
482-
483- # pair of keys to encrypt/decrypt
484- Random.atfork()
485- test_rsa_priv_key = RSA.generate(1024)
486- test_rsa_pub_key = test_rsa_priv_key.publickey()
487- p = self.settings(CRYPTO_SSO_PRIVKEY=test_rsa_priv_key)
488- p.enable()
489- self.addCleanup(p.disable)
490+ test_rsa_priv_key, test_rsa_pub_key = self.setup_key_pair()
491
492 # create a Macaron with the proper third party caveat
493 macaroon_random_key = binascii.hexlify(os.urandom(32))
494@@ -976,12 +993,23 @@
495 identifier='A test macaroon',
496 )
497 random_key = binascii.hexlify(os.urandom(32))
498- random_key_encrypted = base64.b64encode(
499- test_rsa_pub_key.encrypt(random_key, 32)[0])
500+ info = {
501+ 'roothash': macaroon_random_key,
502+ '3rdparty': random_key,
503+ }
504+ info_encrypted = base64.b64encode(
505+ test_rsa_pub_key.encrypt(json.dumps(info), 32)[0])
506 root_macaroon.add_third_party_caveat(
507- service_location, random_key, random_key_encrypted)
508+ service_location, random_key, info_encrypted)
509 return root_macaroon, macaroon_random_key
510
511+
512+class BuildMacaroonDischargeTestCase(BaseMacaroonTestCase):
513+
514+ def setUp(self):
515+ super(BaseMacaroonTestCase, self).setUp()
516+ self.root_macaroon, self.macaroon_random_key = self.build_macaroon()
517+
518 def test_root_macaroon_corrupt(self):
519 self.assertRaises(ValidationError, build_discharge_macaroon,
520 "fake account", "I'm a seriously corrupted macaroon")
521@@ -993,11 +1021,10 @@
522
523 def test_proper_discharging(self):
524 # build the input and call
525- root_macaroon, random_key_used = self.build_macaroon()
526 real_account = self.factory.make_account()
527 before = now()
528 discharge_macaroon = build_discharge_macaroon(
529- real_account, root_macaroon.serialize())
530+ real_account, self.root_macaroon.serialize())
531 after = now()
532
533 # test
534@@ -1044,4 +1071,156 @@
535
536 v = Verifier()
537 v.satisfy_general(checker)
538- v.verify(root_macaroon, random_key_used, [discharge_macaroon])
539+ v.verify(self.root_macaroon, self.macaroon_random_key,
540+ [discharge_macaroon])
541+
542+
543+class MacaroonHelpersTestCase(BaseMacaroonTestCase):
544+
545+ def test_get_caveat_ok(self):
546+ test_rsa_priv_key, test_rsa_pub_key = self.setup_key_pair()
547+
548+ # create a Macaron with the proper third party caveat
549+ macaroon_random_key = binascii.hexlify(os.urandom(32))
550+ root_macaroon = Macaroon(
551+ location='The store ;)',
552+ key=macaroon_random_key,
553+ identifier='A test macaroon',
554+ )
555+ random_key = binascii.hexlify(os.urandom(32))
556+ info = {
557+ 'some stuff': 'foo',
558+ 'more stuff': 'bar',
559+ }
560+ info_encrypted = base64.b64encode(
561+ test_rsa_pub_key.encrypt(json.dumps(info), 32)[0])
562+ root_macaroon.add_third_party_caveat(
563+ settings.MACAROON_SERVICE_LOCATION, random_key, info_encrypted)
564+
565+ # check
566+ raw_caveat_id, caveat_info = _get_own_caveat(root_macaroon)
567+ self.assertEqual(raw_caveat_id, info_encrypted)
568+ self.assertEqual(caveat_info, info)
569+
570+ def test_get_caveat_not_for_sso(self):
571+ macaroon, _ = self.build_macaroon(service_location="other service")
572+ self.assertRaises(AuthenticationError, _get_own_caveat, macaroon)
573+
574+ def test_get_caveat_badly_encrypted(self):
575+ test_rsa_priv_key, test_rsa_pub_key = self.setup_key_pair()
576+
577+ # create a Macaron with the proper third party caveat
578+ macaroon_random_key = binascii.hexlify(os.urandom(32))
579+ root_macaroon = Macaroon(
580+ location='The store ;)',
581+ key=macaroon_random_key,
582+ identifier='A test macaroon',
583+ )
584+ random_key = binascii.hexlify(os.urandom(32))
585+ root_macaroon.add_third_party_caveat(
586+ settings.MACAROON_SERVICE_LOCATION, random_key,
587+ b"not really well encrypted stuff")
588+
589+ # check
590+ self.assertRaises(AuthenticationError, _get_own_caveat, root_macaroon)
591+
592+
593+class MacaroonRefreshTestCase(BaseMacaroonTestCase):
594+
595+ def setUp(self):
596+ super(MacaroonRefreshTestCase, self).setUp()
597+ self.root_macaroon, self.macaroon_random_key = self.build_macaroon()
598+
599+ # discharge the test macaroon
600+ self.account = self.factory.make_account()
601+ self.discharge_macaroon = build_discharge_macaroon(
602+ self.account, self.root_macaroon.serialize())
603+
604+ def test_root_macaroon_corrupt(self):
605+ self.assertRaises(
606+ ValidationError, refresh_macaroons,
607+ "Corrupted macaroon", self.discharge_macaroon.serialize())
608+
609+ def test_discharge_macaroon_corrupt(self):
610+ self.assertRaises(
611+ ValidationError, refresh_macaroons,
612+ self.root_macaroon.serialize(), "Seriously corrupted macaroon")
613+
614+ def test_macaroons_dont_verify_ok(self):
615+ # just get *another* discharge so it's not for the same root macaroon
616+ other_root, _ = self.build_macaroon()
617+ other_discharge = build_discharge_macaroon(
618+ self.account, other_root.serialize())
619+ self.assertRaises(AuthenticationError, refresh_macaroons,
620+ self.root_macaroon.serialize(),
621+ other_discharge.serialize())
622+
623+ def test_deactivated_account(self):
624+ self.account.deactivate()
625+ self.assertRaises(AccountDeactivated, refresh_macaroons,
626+ self.root_macaroon.serialize(),
627+ self.discharge_macaroon.serialize())
628+
629+ def test_proper_refreshing(self):
630+ old_discharge = self.discharge_macaroon # just rename for readability
631+ service_location = settings.MACAROON_SERVICE_LOCATION
632+
633+ def get_value(search_key):
634+ for caveat in old_discharge.first_party_caveats():
635+ source, key, value = caveat.caveat_id.split("|", 2)
636+ if source == service_location and key == search_key:
637+ return value
638+
639+ # get old values from the macaroon and also change the account to see
640+ # that reflected
641+ old_last_auth = get_value('last_auth')
642+ old_expires = get_value('expires')
643+ new_mail = self.factory.make_email_for_account(
644+ self.account, status=EmailStatus.PREFERRED)
645+ self.account.displayname = "New test display name"
646+ self.account.save()
647+
648+ # call!
649+ before = now()
650+ new_discharge = refresh_macaroons(self.root_macaroon.serialize(),
651+ old_discharge.serialize())
652+ after = now()
653+
654+ # test
655+ def checker(caveat):
656+ """Assure all caveats inside the discharged macaroon are ok."""
657+ source, key, value = caveat.split("|", 2)
658+ if source != service_location:
659+ # just checking the NEW discharge one, not the root
660+ return True
661+
662+ if key == 'valid_since':
663+ valid_since = datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f')
664+ self.assertGreater(valid_since, before)
665+ self.assertGreater(after, valid_since)
666+ return True
667+
668+ if key == 'last_auth':
669+ self.assertEqual(value, old_last_auth)
670+ return True
671+
672+ if key == 'account':
673+ acc = json.loads(base64.b64decode(value).decode("utf8"))
674+ self.assertEqual(acc['openid'], self.account.openid_identifier)
675+ self.assertEqual(acc['email'], new_mail.email)
676+ self.assertEqual(acc['displayname'], "New test display name")
677+ self.assertEqual(acc['is_verified'], self.account.is_verified)
678+ return True
679+
680+ if key == 'expires':
681+ self.assertEqual(value, old_expires)
682+ return True
683+
684+ # we're not validating an SSO from the discharged macaroon, fail!
685+ return False
686+
687+ # verify using the NEW discharge macaroon
688+ v = Verifier()
689+ v.satisfy_general(checker)
690+ v.verify(self.root_macaroon, self.macaroon_random_key,
691+ [new_discharge])