Merge lp:~facundo/canonical-identity-provider/refresh-macaroons into lp:canonical-identity-provider/release
- refresh-macaroons
- Merge into trunk
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Matias Bordese (community) | Approve | ||
Review via email:
|
Commit message
We can refresh macaroons now.
Description of the change
We can refresh macaroons now.
To post a comment you must log in.
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]) |
Looks good, thanks!