Merge lp:~facundo/canonical-identity-provider/macaroon-discharging-2 into lp:canonical-identity-provider/release
- macaroon-discharging-2
- Merge into trunk
Status: | Merged |
---|---|
Approved by: | Facundo Batista |
Approved revision: | no longer in the source branch. |
Merged at revision: | 1408 |
Proposed branch: | lp:~facundo/canonical-identity-provider/macaroon-discharging-2 |
Merge into: | lp:canonical-identity-provider/release |
Diff against target: |
867 lines (+564/-13) 15 files modified
.bzrignore (+1/-0) README (+19/-0) dependencies.txt (+1/-0) django_project/settings_base.py (+14/-0) django_project/settings_devel.py (+3/-3) requirements.txt (+3/-0) requirements_docs.txt (+0/-1) src/api/v20/handlers.py (+74/-1) src/api/v20/tests/test_handlers.py (+259/-3) src/api/v20/urls.py (+3/-0) src/identityprovider/auth.py (+58/-1) src/identityprovider/migrations/0007_auto_20160310_2013.py (+19/-0) src/identityprovider/models/const.py (+3/-1) src/identityprovider/tests/test_auth.py (+105/-1) uci-vms.conf (+2/-2) |
To merge this branch: | bzr merge lp:~facundo/canonical-identity-provider/macaroon-discharging-2 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Facundo Batista (community) | Approve | ||
Review via email:
|
Commit message
Discharge the received macaroon if user authenticates ok.
Description of the change
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Ubuntu One Auto Pilot (otto-pilot) wrote : | # |
The attempt to merge lp:~facundo/canonical-identity-provider/macaroon-discharging-2 into lp:canonical-identity-provider failed. Below is the output from the failed tests.
Bootstrapping...
rm -rf /mnt/tarmac/
rm -rf branches/wheels
rm -rf branches/*
rm -rf staticfiles
rm -f lib/versioninfo.py
find -name '*.pyc' -delete
find -name '*.~*' -delete
Not deleting /mnt/tarmac/
New python executable in /mnt/tarmac/
Installing setuptools, pip...done.
/usr/lib/
touch branches/last_build
[ -d branches/wheels ] && (cd branches/wheels && bzr pull) || (bzr branch lp:~ubuntuone-pqm-team/canonical-identity-provider/dependencies branches/wheels)
bzr version-info --format=python > lib/versioninfo.py
/mnt/tarmac/
Ignoring indexes: https:/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Requirement already satisfied (use --upgrade to upgrade): oops==0.0.13 in /usr/lib/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Ubuntu One Auto Pilot (otto-pilot) wrote : | # |
Attempt to merge into lp:canonical-identity-provider failed due to conflicts:
text conflict in dependencies.txt
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Facundo Batista (facundo) wrote : | # |
Argggggg, let's try again
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Ubuntu One Auto Pilot (otto-pilot) wrote : | # |
The attempt to merge lp:~facundo/canonical-identity-provider/macaroon-discharging-2 into lp:canonical-identity-provider failed. Below is the output from the failed tests.
Bootstrapping...
rm -rf /mnt/tarmac/
rm -rf branches/wheels
rm -rf branches/*
rm -rf staticfiles
rm -f lib/versioninfo.py
find -name '*.pyc' -delete
find -name '*.~*' -delete
Not deleting /mnt/tarmac/
New python executable in /mnt/tarmac/
Installing setuptools, pip...done.
/usr/lib/
touch branches/last_build
[ -d branches/wheels ] && (cd branches/wheels && bzr pull) || (bzr branch lp:~ubuntuone-pqm-team/canonical-identity-provider/dependencies branches/wheels)
bzr version-info --format=python > lib/versioninfo.py
/mnt/tarmac/
Ignoring indexes: https:/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Requirement already satisfied (use --upgrade to upgrade): oops==0.0.13 in /usr/lib/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Downloading/
Preview Diff
1 | === modified file '.bzrignore' |
2 | --- .bzrignore 2015-10-09 16:03:29 +0000 |
3 | +++ .bzrignore 2016-03-11 17:07:30 +0000 |
4 | @@ -8,3 +8,4 @@ |
5 | staticfiles |
6 | lib/versioninfo.py |
7 | settings.py |
8 | +rsakeys/* |
9 | |
10 | === modified file 'README' |
11 | --- README 2016-03-03 20:01:53 +0000 |
12 | +++ README 2016-03-11 17:07:30 +0000 |
13 | @@ -45,6 +45,10 @@ |
14 | 2. Install system dependencies |
15 | :: |
16 | |
17 | + # Needed for libsodium package |
18 | + $ sudo apt-get install -y software-properties-common |
19 | + $ sudo add-apt-repository ppa:facundo/salty |
20 | + |
21 | $ sudo apt-get update |
22 | $ cat dependencies.txt | sudo xargs apt-get install -y --no-install-recommends |
23 | $ cat dependencies-devel.txt | sudo xargs apt-get install -y --no-install-recommends |
24 | @@ -207,6 +211,21 @@ |
25 | lxc with no internet access at all. |
26 | |
27 | |
28 | +13. (Optional) Set up private/public keys to make macaroons work between |
29 | + projects |
30 | + |
31 | + For some endpoints to work correctly, the system needs to decrypt keys |
32 | + that were encrypted from other services (you'll need to use this |
33 | + instructions, or the corresponding one in the other projects). |
34 | + |
35 | + So, create a pair of keys for this project: |
36 | + |
37 | + ssh-keygen -t rsa -N "" -f project_id_rsa |
38 | + |
39 | + This will leave you with two files, move the private one into the |
40 | + ``rsakeys`` directory, and the .pub one into this same dir in the |
41 | + other projects. |
42 | + |
43 | |
44 | BAZAAR |
45 | ------ |
46 | |
47 | === modified file 'dependencies.txt' |
48 | --- dependencies.txt 2016-03-01 19:13:46 +0000 |
49 | +++ dependencies.txt 2016-03-11 17:07:30 +0000 |
50 | @@ -1,6 +1,7 @@ |
51 | bzr |
52 | geoip-database |
53 | libgeoip1 |
54 | +libsodium13 |
55 | python-amqplib |
56 | python-bcrypt |
57 | python-beautifulsoup |
58 | |
59 | === modified file 'django_project/settings_base.py' |
60 | --- django_project/settings_base.py 2016-03-01 19:13:46 +0000 |
61 | +++ django_project/settings_base.py 2016-03-11 17:07:30 +0000 |
62 | @@ -3,6 +3,8 @@ |
63 | import random |
64 | from urlparse import urlparse |
65 | |
66 | +from Crypto.PublicKey import RSA |
67 | + |
68 | from versioninfo import version_info |
69 | |
70 | # /srv/login.staging.ubuntu.com/staging/canonical-identity-provider |
71 | @@ -68,6 +70,16 @@ |
72 | COMBINE = True |
73 | COMBO_URL = '/combo/' |
74 | COMMENTS_ALLOW_PROFANITIES = False |
75 | +_fpath = os.path.join( |
76 | + os.path.abspath(os.getenv('SCA_RSAKEYS_DIR', '')), 'sso_id_rsa') |
77 | +try: |
78 | + with open(_fpath) as fh: |
79 | + raw = fh.read() |
80 | + CRYPTO_SSO_PRIVKEY = RSA.importKey(raw) |
81 | +except: |
82 | + # this catch-all-and-set-None will be removed after first successful |
83 | + # deployments having the proper key in place |
84 | + CRYPTO_SSO_PRIVKEY = None |
85 | CSRF_COOKIE_DOMAIN = None |
86 | CSRF_COOKIE_HTTPONLY = True |
87 | CSRF_COOKIE_NAME = 'csrftoken' |
88 | @@ -368,6 +380,8 @@ |
89 | LOGIN_REDIRECT_URL = '/' |
90 | LOGIN_URL = '/+login' |
91 | LOGOUT_URL = '/accounts/logout/' |
92 | +MACAROON_TTL = 365 * 24 * 3600 # seconds |
93 | +MACAROON_SERVICE_LOCATION = HOSTNAME |
94 | MANAGERS = [] |
95 | MAX_FAILED_LOGIN_ATTEMPTS = 20 |
96 | MAX_PASSWORD_RESET_TOKENS = 5 |
97 | |
98 | === modified file 'django_project/settings_devel.py' |
99 | --- django_project/settings_devel.py 2015-12-18 12:21:25 +0000 |
100 | +++ django_project/settings_devel.py 2016-03-11 17:07:30 +0000 |
101 | @@ -1,8 +1,8 @@ |
102 | import os |
103 | |
104 | -os.environ.setdefault( |
105 | - 'SSO_LOGS_DIR', |
106 | - os.path.join(os.path.dirname(os.path.dirname(__file__)), 'logs')) |
107 | +BASE_DIR = os.path.dirname(os.path.dirname(__file__)) |
108 | +os.environ.setdefault('SSO_LOGS_DIR', os.path.join(BASE_DIR, 'logs')) |
109 | +os.environ.setdefault('SCA_RSAKEYS_DIR', os.path.join(BASE_DIR, 'rsakeys')) |
110 | os.environ.setdefault('SSO_ROOT_URL', 'http://0.0.0.0:8000') |
111 | |
112 | from django_project.settings_base import * # noqa |
113 | |
114 | === modified file 'requirements.txt' |
115 | --- requirements.txt 2016-02-01 15:30:36 +0000 |
116 | +++ requirements.txt 2016-03-11 17:07:30 +0000 |
117 | @@ -12,6 +12,7 @@ |
118 | gargoyle==0.11.0 |
119 | gunicorn==19.3.0 |
120 | lazr.authentication==0.1.3 |
121 | +libnacl==1.3.6 |
122 | nexus==0.3.1 |
123 | oath==1.4.0 |
124 | oauthlib==0.7.2 |
125 | @@ -22,8 +23,10 @@ |
126 | oops-timeline==0.0.2 |
127 | oops-wsgi==0.0.11 |
128 | paste==2.0.1 |
129 | +pymacaroons==0.9.1 |
130 | raven==5.6.0 |
131 | requests-oauthlib==0.4.2 |
132 | +six==1.10.0 |
133 | statsd==3.1 |
134 | testresources==0.2.7 |
135 | timeline==0.0.4 |
136 | |
137 | === modified file 'requirements_docs.txt' |
138 | --- requirements_docs.txt 2015-10-27 12:55:27 +0000 |
139 | +++ requirements_docs.txt 2016-03-11 17:07:30 +0000 |
140 | @@ -7,7 +7,6 @@ |
141 | pygments==2.0.2 |
142 | pytz==2014.10 |
143 | setuptools==14.0 |
144 | -six==1.9.0 |
145 | snowballstemmer==1.2.0 |
146 | sphinx==1.3 |
147 | sphinx-bootstrap-theme==0.4.5 |
148 | |
149 | === added directory 'rsakeys' |
150 | === modified file 'src/api/v20/handlers.py' |
151 | --- src/api/v20/handlers.py 2015-10-26 21:53:48 +0000 |
152 | +++ src/api/v20/handlers.py 2016-03-11 17:07:30 +0000 |
153 | @@ -1,4 +1,4 @@ |
154 | -# Copyright 2010-2013 Canonical Ltd. This software is licensed under the |
155 | +# Copyright 2010-2016 Canonical Ltd. This software is licensed under the |
156 | # GNU Affero General Public License version 3 (see the file LICENSE). |
157 | |
158 | import logging |
159 | @@ -406,6 +406,79 @@ |
160 | return response |
161 | |
162 | |
163 | +class MacaroonHandler(BaseHandler): |
164 | + allowed_methods = ('POST',) |
165 | + |
166 | + @require_mime('json') |
167 | + @throttle(extra_callback=get_email_from_request_data) |
168 | + def create(self, request): |
169 | + if settings.CRYPTO_SSO_PRIVKEY is None: |
170 | + return errors.RESOURCE_NOT_FOUND() |
171 | + |
172 | + data = request.data |
173 | + try: |
174 | + email = data['email'] |
175 | + password = data['password'] |
176 | + root_macaroon_raw = data['macaroon'] |
177 | + except KeyError: |
178 | + expected = set(('email', 'password', 'macaroon')) |
179 | + missing = dict((k, [FIELD_REQUIRED]) for k in expected - set(data)) |
180 | + return errors.INVALID_DATA(**missing) |
181 | + |
182 | + account = None |
183 | + response = None |
184 | + try: |
185 | + account = authenticate_user(email, password, request=request) |
186 | + except AccountSuspended: |
187 | + response = errors.ACCOUNT_SUSPENDED() |
188 | + except AccountDeactivated: |
189 | + response = errors.ACCOUNT_DEACTIVATED() |
190 | + except EmailInvalidated: |
191 | + response = errors.EMAIL_INVALIDATED() |
192 | + except PasswordPolicyError as e: |
193 | + root_url = request.build_absolute_uri('/') |
194 | + response = errors.PASSWORD_POLICY_ERROR( |
195 | + reason=unicode(e), location=root_url) |
196 | + except AuthenticationError: |
197 | + response = errors.INVALID_CREDENTIALS() |
198 | + |
199 | + if account is not None: |
200 | + otp = data.get('otp') |
201 | + if otp is not None: |
202 | + try: |
203 | + twofactor.authenticate(account, otp) |
204 | + except AuthenticationError: |
205 | + account = None |
206 | + response = errors.TWOFACTOR_FAILURE() |
207 | + elif account.twofactor_required: |
208 | + response = errors.TWOFACTOR_REQUIRED() |
209 | + |
210 | + if account is None: |
211 | + # track failed login attempt |
212 | + login_failed.send(sender=self, request=request, |
213 | + credentials=dict(email=email, password=password), |
214 | + authlogtype=AuthLogType.API_FAIL) |
215 | + |
216 | + if response is not None: |
217 | + return response |
218 | + |
219 | + try: |
220 | + discharge = auth.build_discharge_macaroon( |
221 | + account, root_macaroon_raw) |
222 | + except ValidationError: |
223 | + return errors.INVALID_DATA() |
224 | + except AuthenticationError: |
225 | + return errors.INVALID_CREDENTIALS() |
226 | + |
227 | + response = rc.ALL_OK |
228 | + response.content = dict(discharge_macaroon=discharge.serialize()) |
229 | + |
230 | + log_type = AuthLogType.API_MACAROON_DISCHARGE_NEW |
231 | + login_succeeded.send(sender=self, user=account, request=request, |
232 | + authlogtype=log_type, email=email) |
233 | + return response |
234 | + |
235 | + |
236 | class EmailsHandler(BaseHandler): |
237 | allowed_methods = ('DELETE', 'GET') |
238 | model = EmailAddress |
239 | |
240 | === modified file 'src/api/v20/tests/test_handlers.py' |
241 | --- src/api/v20/tests/test_handlers.py 2016-01-07 16:01:28 +0000 |
242 | +++ src/api/v20/tests/test_handlers.py 2016-03-11 17:07:30 +0000 |
243 | @@ -2,7 +2,10 @@ |
244 | |
245 | from __future__ import unicode_literals |
246 | |
247 | +import base64 |
248 | +import binascii |
249 | import json |
250 | +import os |
251 | import re |
252 | import time |
253 | |
254 | @@ -10,8 +13,10 @@ |
255 | from urllib import quote, urlencode |
256 | from urlparse import parse_qsl, urlparse, urlunparse |
257 | |
258 | +from Crypto import Random |
259 | +from Crypto.PublicKey import RSA |
260 | from django.conf import settings |
261 | -from django.contrib.auth.models import AnonymousUser |
262 | +from django.contrib.auth.models import AnonymousUser, make_password |
263 | from django.core import mail |
264 | from django.core.serializers.json import DjangoJSONEncoder |
265 | from django.core.urlresolvers import NoReverseMatch, reverse |
266 | @@ -20,6 +25,7 @@ |
267 | from django.utils.timezone import now |
268 | from gargoyle.testutils import switches |
269 | from mock import Mock, patch |
270 | +from pymacaroons import Macaroon, Verifier |
271 | from timeline import Timeline |
272 | |
273 | from api.v20 import handlers, whitelist |
274 | @@ -34,15 +40,17 @@ |
275 | from identityprovider.models.const import ( |
276 | AccountCreationRationale, |
277 | AccountStatus, |
278 | + AuthLogType, |
279 | AuthTokenType, |
280 | EmailStatus, |
281 | TokenScope, |
282 | ) |
283 | +from identityprovider.signals import login_failed |
284 | from identityprovider.tests import DEFAULT_USER_PASSWORD |
285 | -from identityprovider.tests.utils import SSOBaseTestCase |
286 | +from identityprovider.tests.test_auth import AuthLogTestCaseMixin |
287 | +from identityprovider.tests.utils import SSOBaseTestCase, TimelineActionMixin |
288 | from identityprovider.utils import redirection_url_for_token |
289 | |
290 | - |
291 | OVERRIDES = dict( |
292 | EMAIL_WHITELIST_REGEXP_LIST=['^canonicaltest(?:\+.+)?@gmail\.com$'] |
293 | ) |
294 | @@ -2022,3 +2030,251 @@ |
295 | mock_cache.get.return_value = (0, time.time() + 42.1) |
296 | self.get_response(request.path, method=request.method) |
297 | self.assertEqual([], called_metrics) |
298 | + |
299 | + |
300 | +class MacaroonHandlerBaseTestCase(SSOBaseTestCase): |
301 | + |
302 | + url = reverse('api-macaroon-discharge') |
303 | + |
304 | + def track_failed_logins(self, **kw): |
305 | + self.login_failed_calls.append(kw) |
306 | + |
307 | + def build_macaroon(self, service_location=None): |
308 | + """Create a Macaron with the proper third party caveat.""" |
309 | + if service_location is None: |
310 | + service_location = settings.MACAROON_SERVICE_LOCATION |
311 | + |
312 | + # pair of keys to encrypt/decrypt |
313 | + Random.atfork() |
314 | + test_rsa_priv_key = RSA.generate(1024) |
315 | + test_rsa_pub_key = test_rsa_priv_key.publickey() |
316 | + p = self.settings(CRYPTO_SSO_PRIVKEY=test_rsa_priv_key) |
317 | + p.enable() |
318 | + self.addCleanup(p.disable) |
319 | + |
320 | + macaroon_random_key = binascii.hexlify(os.urandom(32)) |
321 | + root_macaroon = Macaroon( |
322 | + location='The store ;)', |
323 | + key=macaroon_random_key, |
324 | + identifier='A test macaroon', |
325 | + ) |
326 | + random_key = binascii.hexlify(os.urandom(32)) |
327 | + random_key_encrypted = base64.b64encode( |
328 | + test_rsa_pub_key.encrypt(random_key, 32)[0]) |
329 | + root_macaroon.add_third_party_caveat( |
330 | + service_location, random_key, random_key_encrypted) |
331 | + return root_macaroon, macaroon_random_key |
332 | + |
333 | + def setUp(self): |
334 | + super(MacaroonHandlerBaseTestCase, self).setUp() |
335 | + |
336 | + self.login_failed_calls = [] |
337 | + login_failed.connect(self.track_failed_logins, dispatch_uid=self.id()) |
338 | + |
339 | + self.root_macaroon, self.macaroon_random_key = self.build_macaroon() |
340 | + self.data = dict( |
341 | + email='foo@bar.com', password='foobar123', |
342 | + macaroon=self.root_macaroon.serialize()) |
343 | + self.account = self.factory.make_account( |
344 | + email=self.data['email'], password=self.data['password']) |
345 | + |
346 | + |
347 | +class MacaroonHandlerTestCase(MacaroonHandlerBaseTestCase, |
348 | + AuthLogTestCaseMixin): |
349 | + |
350 | + def do_post(self, data=None, expected_status_code=200, |
351 | + content_type='application/json', **kwargs): |
352 | + if data is None: |
353 | + data = self.data |
354 | + |
355 | + response = self.client.post(self.url, data=json.dumps(data), |
356 | + content_type=content_type, **kwargs) |
357 | + |
358 | + self.assertEqual( |
359 | + response.status_code, expected_status_code, |
360 | + "Bad status code! expected={} got={} response={}".format( |
361 | + expected_status_code, response.status_code, response)) |
362 | + self.assertEqual(response['Content-type'], |
363 | + 'application/json; charset=utf-8', |
364 | + response) |
365 | + return json.loads(response.content) |
366 | + |
367 | + def assert_response_correct(self, json_body, account): |
368 | + """Check we received a good discharge macaroon for the root sent. |
369 | + |
370 | + Proper verification of the received macaroon internals happens in |
371 | + the tests of the macaroon builder. |
372 | + """ |
373 | + discharge_macaroon_raw = json_body['discharge_macaroon'] |
374 | + discharge_macaroon = Macaroon.deserialize(discharge_macaroon_raw) |
375 | + v = Verifier() |
376 | + v.satisfy_general(lambda c: True) |
377 | + v.verify( |
378 | + self.root_macaroon, self.macaroon_random_key, [discharge_macaroon]) |
379 | + |
380 | + def assert_failed_login(self, code, data, extra=None, |
381 | + expected_status_code=403, check_login_failed=True): |
382 | + if extra is None: |
383 | + extra = {} |
384 | + |
385 | + json_body = self.do_post(expected_status_code=expected_status_code, |
386 | + data=data) |
387 | + self.assertEqual(json_body['code'], code) |
388 | + self.assertEqual(json_body['extra'], extra) |
389 | + |
390 | + if check_login_failed: |
391 | + self.assertEqual(len(self.login_failed_calls), 1) |
392 | + failed = self.login_failed_calls[0] |
393 | + self.assertEqual( |
394 | + failed['credentials'], |
395 | + {'email': data['email'], 'password': data['password']}) |
396 | + |
397 | + return json_body |
398 | + |
399 | + def test_login_required_parameters(self): |
400 | + json_body = self.do_post(expected_status_code=400, data={}) |
401 | + |
402 | + self.assertEqual(json_body, { |
403 | + 'code': 'INVALID_DATA', |
404 | + 'extra': {'email': [handlers.FIELD_REQUIRED]}, |
405 | + 'message': 'Invalid request data'}, |
406 | + ) |
407 | + self.assertEqual(self.login_failed_calls, []) |
408 | + |
409 | + def test_account_suspended(self): |
410 | + self.account.suspend() |
411 | + self.assert_failed_login('ACCOUNT_SUSPENDED', self.data) |
412 | + |
413 | + def test_account_deactivated(self): |
414 | + self.account.deactivate() |
415 | + self.assert_failed_login('ACCOUNT_DEACTIVATED', self.data) |
416 | + |
417 | + def test_failed_login(self): |
418 | + self.account.set_password('other-thing') |
419 | + self.account.save() |
420 | + self.assert_failed_login('INVALID_CREDENTIALS', |
421 | + self.data, expected_status_code=401) |
422 | + |
423 | + def test_email_invalidated(self): |
424 | + self.account.preferredemail.invalidate() |
425 | + self.assert_failed_login('EMAIL_INVALIDATED', self.data) |
426 | + |
427 | + def test_password_policy(self): |
428 | + self.account.accountpassword.password = make_password('short') |
429 | + self.account.accountpassword.save() |
430 | + |
431 | + self.data['password'] = 'short' |
432 | + extra = { |
433 | + 'reason': 'Password must be at least 8 characters long', |
434 | + 'location': 'http://testserver/'} |
435 | + self.assert_failed_login( |
436 | + 'PASSWORD_POLICY_ERROR', data=self.data, extra=extra) |
437 | + |
438 | + def test_twofactor_required(self): |
439 | + self.account.twofactor_required = True |
440 | + self.account.save() |
441 | + |
442 | + json_body = self.do_post(expected_status_code=401) |
443 | + self.assertEqual(json_body['code'], 'TWOFACTOR_REQUIRED') |
444 | + self.assertEqual(self.login_failed_calls, []) |
445 | + |
446 | + def test_twofactor(self): |
447 | + device = self.factory.make_device(account=self.account) |
448 | + self.account.twofactor_required = True |
449 | + self.account.save() |
450 | + |
451 | + self.data['otp'] = self.factory.make_next_otp(device) |
452 | + json_body = self.do_post(data=self.data) |
453 | + |
454 | + self.assert_response_correct(json_body, self.account) |
455 | + |
456 | + def test_twofactor_wrong_otp(self): |
457 | + device = self.factory.make_device(account=self.account) |
458 | + self.account.twofactor_required = True |
459 | + self.account.save() |
460 | + |
461 | + self.data['otp'] = str(int(self.factory.make_next_otp(device)) - 1) |
462 | + self.assert_failed_login('TWOFACTOR_FAILURE', self.data) |
463 | + |
464 | + def test_failed_login_creates_authlog(self): |
465 | + self.account.set_password('other-thing') |
466 | + self.account.save() |
467 | + expected_values = { |
468 | + 'login_email': self.data['email'], |
469 | + 'log_type': AuthLogType.API_FAIL, |
470 | + 'referer': '', |
471 | + 'user_agent': '', |
472 | + 'remote_ip': '127.0.0.1', |
473 | + 'account': None |
474 | + } |
475 | + with self.assert_authlog_matches(expected_values): |
476 | + self.assert_failed_login('INVALID_CREDENTIALS', |
477 | + self.data, expected_status_code=401) |
478 | + |
479 | + def test_login_creates_authlog(self): |
480 | + expected_values = { |
481 | + 'login_email': self.data['email'], |
482 | + 'log_type': AuthLogType.API_MACAROON_DISCHARGE_NEW, |
483 | + 'referer': '', |
484 | + 'user_agent': '', |
485 | + 'remote_ip': '127.0.0.1', |
486 | + 'account': self.account.id |
487 | + } |
488 | + # This context manager magically checks when_created values |
489 | + with self.assert_authlog_matches(expected_values): |
490 | + self.do_post() |
491 | + |
492 | + def test_macaroon_created(self): |
493 | + json_body = self.do_post() |
494 | + self.assert_response_correct(json_body, self.account) |
495 | + |
496 | + def test_root_macaroon_corrupt(self): |
497 | + data = dict( |
498 | + email='foo@bar.com', password='foobar123', |
499 | + macaroon="I'm a seriously corrupted macaroon") |
500 | + self.assert_failed_login('INVALID_DATA', data, |
501 | + expected_status_code=400, |
502 | + check_login_failed=False) |
503 | + |
504 | + def test_root_macaroon_not_for_sso(self): |
505 | + macaroon, _ = self.build_macaroon(service_location="other service") |
506 | + data = dict(email='foo@bar.com', password='foobar123', |
507 | + macaroon=macaroon.serialize()) |
508 | + self.assert_failed_login('INVALID_CREDENTIALS', data, |
509 | + expected_status_code=401, |
510 | + check_login_failed=False) |
511 | + |
512 | + |
513 | +class MacaroonHandlerTimelineTestCase(MacaroonHandlerBaseTestCase, |
514 | + TimelineActionMixin): |
515 | + |
516 | + def test_login_timeline_records_password_checking(self): |
517 | + # Prepare and inject a timeline in the client's POST. |
518 | + empty_timeline = Timeline() |
519 | + response = self.client.post( |
520 | + self.url, data=json.dumps(self.data), |
521 | + content_type='application/json', |
522 | + **{'timeline.timeline': empty_timeline}) |
523 | + self.assert_timeline_contains(request=response.wsgi_request, |
524 | + category='django-password-checking', |
525 | + detail=str(self.account.id)) |
526 | + |
527 | + def test_login_ignore_weird_timeline(self): |
528 | + """A weird object passed as timeline should be ignored/not used.""" |
529 | + weird_timeline = [] |
530 | + response = self.client.post( |
531 | + self.url, data=json.dumps(self.data), |
532 | + content_type='application/json', |
533 | + **{'timeline.timeline': weird_timeline}) |
534 | + |
535 | + # If the timeline is a weird object we shouldn't have touched it |
536 | + self.assertEqual([], response.wsgi_request.META['timeline.timeline']) |
537 | + |
538 | + def test_login_no_timeline(self): |
539 | + """Shouldn't barf if there's no timeline in the request at all.""" |
540 | + response = self.client.post( |
541 | + self.url, data=json.dumps(self.data), |
542 | + content_type='application/json') |
543 | + |
544 | + self.assertEqual(response.status_code, 200) |
545 | + self.assertNotIn('timeline.timeline', response.wsgi_request.META) |
546 | |
547 | === modified file 'src/api/v20/urls.py' |
548 | --- src/api/v20/urls.py 2014-12-05 18:09:03 +0000 |
549 | +++ src/api/v20/urls.py 2016-03-11 17:07:30 +0000 |
550 | @@ -15,6 +15,7 @@ |
551 | AccountRegistrationHandler, |
552 | AccountsHandler, |
553 | EmailsHandler, |
554 | + MacaroonHandler, |
555 | PasswordResetTokenHandler, |
556 | RequestsHandler, |
557 | TokensHandler, |
558 | @@ -26,6 +27,7 @@ |
559 | v2emails = ApiResource( |
560 | handler=EmailsHandler, authentication=ApiEmailsAuthentication()) |
561 | v2login = ApiResource(handler=AccountLoginHandler) |
562 | +v2macaroon = ApiResource(handler=MacaroonHandler) |
563 | v2password_reset = ApiResource(handler=PasswordResetTokenHandler) |
564 | v2registration = ApiResource( |
565 | handler=AccountRegistrationHandler, |
566 | @@ -41,6 +43,7 @@ |
567 | url(r'^accounts/(?P<openid>\w+)$', v2accounts, name='api-account'), |
568 | url(r'^emails/(?P<email>.+)$', v2emails, name='api-email'), |
569 | url(r'^requests/validate$', v2requests, name='api-requests'), |
570 | + url(r'^tokens/discharge$', v2macaroon, name='api-macaroon-discharge'), |
571 | url(r'^tokens/oauth$', v2login, name='api-login'), |
572 | url(r'^tokens/oauth/(?P<token>.+)$', v2tokens, name='api-token'), |
573 | url(r'^tokens/password$', v2password_reset, name='api-password-reset'), |
574 | |
575 | === modified file 'src/identityprovider/auth.py' |
576 | --- src/identityprovider/auth.py 2015-11-18 17:21:12 +0000 |
577 | +++ src/identityprovider/auth.py 2016-03-11 17:07:30 +0000 |
578 | @@ -1,10 +1,13 @@ |
579 | # -*- coding: utf-8 -*- |
580 | # |
581 | -# Copyright 2010 Canonical Ltd. This software is licensed under the |
582 | +# Copyright 2010-2016 Canonical Ltd. This software is licensed under the |
583 | # GNU Affero General Public License version 3 (see the file LICENSE). |
584 | |
585 | from __future__ import unicode_literals |
586 | |
587 | +import base64 |
588 | +import datetime |
589 | +import json |
590 | import logging |
591 | |
592 | from django.conf import settings |
593 | @@ -21,7 +24,9 @@ |
594 | from django.utils.translation import ugettext_lazy as _ |
595 | from django.utils.timezone import now |
596 | from oauthlib.oauth1 import RequestValidator, ResourceEndpoint |
597 | +from pymacaroons import Macaroon |
598 | |
599 | +from identityprovider.login import AuthenticationError |
600 | from identityprovider.models import ( |
601 | Account, |
602 | AccountPassword, |
603 | @@ -488,3 +493,55 @@ |
604 | response = HttpResponse("Authorization Required", status=401) |
605 | response['WWW-Authenticate'] = 'OAuth realm="%s"' % self.realm |
606 | return response |
607 | + |
608 | + |
609 | +def build_discharge_macaroon(account, root_macaroon_raw): |
610 | + """Build a discharge macaroon from a root one.""" |
611 | + service_location = settings.MACAROON_SERVICE_LOCATION |
612 | + |
613 | + try: |
614 | + root_macaroon = Macaroon.deserialize(root_macaroon_raw) |
615 | + except: |
616 | + raise ValidationError("The received Macaroon is corrupt") |
617 | + |
618 | + try: |
619 | + # isolate 3rd-party caveat that concerns SSO (hinted by 'location') |
620 | + (sso_caveat,) = [c for c in root_macaroon.third_party_caveats() |
621 | + if c.location == service_location] |
622 | + except: |
623 | + # the macaroon doesn't have a location for this service |
624 | + raise AuthenticationError("The received macaroon is not for ours") |
625 | + |
626 | + # get the only-for-this-project random key |
627 | + original_random_key = settings.CRYPTO_SSO_PRIVKEY.decrypt( |
628 | + base64.b64decode(sso_caveat.caveat_id)) |
629 | + |
630 | + # create a discharge macaroon with same location, key and |
631 | + # identifier than it's original 3rd-party caveat (so they can |
632 | + # be matched and verified) |
633 | + d = Macaroon( |
634 | + location=service_location, |
635 | + key=original_random_key, |
636 | + identifier=sso_caveat.caveat_id, |
637 | + ) |
638 | + |
639 | + # add the account info |
640 | + account_info = base64.b64encode(json.dumps({ |
641 | + 'openid': account.openid_identifier, |
642 | + 'email': account.preferredemail.email, |
643 | + 'displayname': account.displayname, |
644 | + }).encode("utf8")) |
645 | + d.add_first_party_caveat(service_location + '|account|' + account_info) |
646 | + |
647 | + # add timestamp values |
648 | + now_string = now().strftime('%Y-%m-%dT%H:%M:%S.%f') |
649 | + d.add_first_party_caveat(service_location + '|valid_since|' + now_string) |
650 | + d.add_first_party_caveat(service_location + '|last_auth|' + now_string) |
651 | + expire = now() + datetime.timedelta(seconds=settings.MACAROON_TTL) |
652 | + d.add_first_party_caveat( |
653 | + service_location + '|expires|' + |
654 | + expire.strftime('%Y-%m-%dT%H:%M:%S.%f')) |
655 | + |
656 | + # return the properly prepared discharge macaroon |
657 | + discharge = root_macaroon.prepare_for_request(d) |
658 | + return discharge |
659 | |
660 | === added file 'src/identityprovider/migrations/0007_auto_20160310_2013.py' |
661 | --- src/identityprovider/migrations/0007_auto_20160310_2013.py 1970-01-01 00:00:00 +0000 |
662 | +++ src/identityprovider/migrations/0007_auto_20160310_2013.py 2016-03-11 17:07:30 +0000 |
663 | @@ -0,0 +1,19 @@ |
664 | +# -*- coding: utf-8 -*- |
665 | +from __future__ import unicode_literals |
666 | + |
667 | +from django.db import migrations, models |
668 | + |
669 | + |
670 | +class Migration(migrations.Migration): |
671 | + |
672 | + dependencies = [ |
673 | + ('identityprovider', '0006_auto_20151019_1515'), |
674 | + ] |
675 | + |
676 | + operations = [ |
677 | + migrations.AlterField( |
678 | + model_name='authlog', |
679 | + name='log_type', |
680 | + field=models.IntegerField(choices=[(1, b'OPENID_FAIL'), (2, b'OPENID_NEW'), (3, b'OPENID_EXISTING'), (4, b'API_FAIL'), (5, b'API_NEW'), (6, b'API_EXISTING'), (7, b'API_MACAROON_DISCHARGE_NEW')]), |
681 | + ), |
682 | + ] |
683 | |
684 | === modified file 'src/identityprovider/models/const.py' |
685 | --- src/identityprovider/models/const.py 2015-09-09 19:14:52 +0000 |
686 | +++ src/identityprovider/models/const.py 2016-03-11 17:07:30 +0000 |
687 | @@ -114,6 +114,7 @@ |
688 | API_FAIL = 4 |
689 | API_NEW = 5 # A new oauth token created for the account |
690 | API_EXISTING = 6 # Existing oauth token retrieved |
691 | + API_MACAROON_DISCHARGE_NEW = 7 # First time a macaroon was discharged |
692 | |
693 | _verbose = { |
694 | OPENID_FAIL: _("Failed web login"), |
695 | @@ -121,7 +122,8 @@ |
696 | OPENID_EXISTING: _("Existing web login"), |
697 | API_FAIL: _("Failed API login"), |
698 | API_NEW: _("New API login"), |
699 | - API_EXISTING: _("Existing API login") |
700 | + API_EXISTING: _("Existing API login"), |
701 | + API_MACAROON_DISCHARGE_NEW: _("First time discharge for the macaroon"), |
702 | } |
703 | |
704 | @classmethod |
705 | |
706 | === modified file 'src/identityprovider/tests/test_auth.py' |
707 | --- src/identityprovider/tests/test_auth.py 2016-01-05 20:13:30 +0000 |
708 | +++ src/identityprovider/tests/test_auth.py 2016-03-11 17:07:30 +0000 |
709 | @@ -1,10 +1,18 @@ |
710 | # encoding: utf-8 |
711 | -# Copyright 2010 Canonical Ltd. This software is licensed under the |
712 | +# Copyright 2010-2016 Canonical Ltd. This software is licensed under the |
713 | # GNU Affero General Public License version 3 (see the file LICENSE). |
714 | |
715 | +import base64 |
716 | +import binascii |
717 | +import json |
718 | +import os |
719 | + |
720 | from contextlib import contextmanager |
721 | +from datetime import datetime, timedelta |
722 | from uuid import uuid4 |
723 | |
724 | +from Crypto import Random |
725 | +from Crypto.PublicKey import RSA |
726 | from django.conf import settings |
727 | from django.contrib.auth.hashers import get_hasher |
728 | from django.contrib.auth.models import ( |
729 | @@ -12,18 +20,22 @@ |
730 | is_password_usable, |
731 | make_password, |
732 | ) |
733 | +from django.core.exceptions import ValidationError |
734 | from django.db import connection |
735 | from django.test.utils import override_settings |
736 | from django.utils.timezone import now |
737 | from mock import call, Mock, patch |
738 | +from pymacaroons import Macaroon, Verifier |
739 | |
740 | from identityprovider.auth import ( |
741 | LaunchpadBackend, |
742 | SSOOAuthAuthentication, |
743 | SSORequestValidator, |
744 | basic_authenticate, |
745 | + build_discharge_macaroon, |
746 | validate_oauth_signature, |
747 | ) |
748 | +from identityprovider.login import AuthenticationError |
749 | from identityprovider.models import ( |
750 | Account, |
751 | AccountPassword, |
752 | @@ -940,3 +952,95 @@ |
753 | expected_detail="(direct password comparison)", |
754 | hashed_password=make_password(DEFAULT_USER_PASSWORD), |
755 | timer=self.dummy_timer) |
756 | + |
757 | + |
758 | +class BuildMacaroonDischargeTestCase(SSOBaseTestCase): |
759 | + |
760 | + def build_macaroon(self, service_location=None): |
761 | + if service_location is None: |
762 | + service_location = settings.MACAROON_SERVICE_LOCATION |
763 | + |
764 | + # pair of keys to encrypt/decrypt |
765 | + Random.atfork() |
766 | + test_rsa_priv_key = RSA.generate(1024) |
767 | + test_rsa_pub_key = test_rsa_priv_key.publickey() |
768 | + p = self.settings(CRYPTO_SSO_PRIVKEY=test_rsa_priv_key) |
769 | + p.enable() |
770 | + self.addCleanup(p.disable) |
771 | + |
772 | + # create a Macaron with the proper third party caveat |
773 | + macaroon_random_key = binascii.hexlify(os.urandom(32)) |
774 | + root_macaroon = Macaroon( |
775 | + location='The store ;)', |
776 | + key=macaroon_random_key, |
777 | + identifier='A test macaroon', |
778 | + ) |
779 | + random_key = binascii.hexlify(os.urandom(32)) |
780 | + random_key_encrypted = base64.b64encode( |
781 | + test_rsa_pub_key.encrypt(random_key, 32)[0]) |
782 | + root_macaroon.add_third_party_caveat( |
783 | + service_location, random_key, random_key_encrypted) |
784 | + return root_macaroon, macaroon_random_key |
785 | + |
786 | + def test_root_macaroon_corrupt(self): |
787 | + self.assertRaises(ValidationError, build_discharge_macaroon, |
788 | + "fake account", "I'm a seriously corrupted macaroon") |
789 | + |
790 | + def test_root_macaroon_not_for_sso(self): |
791 | + macaroon, _ = self.build_macaroon(service_location="other service") |
792 | + self.assertRaises(AuthenticationError, build_discharge_macaroon, |
793 | + "fake account", macaroon.serialize()) |
794 | + |
795 | + def test_proper_discharging(self): |
796 | + # build the input and call |
797 | + root_macaroon, random_key_used = self.build_macaroon() |
798 | + real_account = self.factory.make_account() |
799 | + before = now() |
800 | + discharge_macaroon = build_discharge_macaroon( |
801 | + real_account, root_macaroon.serialize()) |
802 | + after = now() |
803 | + |
804 | + # test |
805 | + def checker(caveat): |
806 | + """Assure all caveats inside the discharged macaroon are ok.""" |
807 | + source, key, value = caveat.split("|", 2) |
808 | + if source != settings.MACAROON_SERVICE_LOCATION: |
809 | + # just checking the discharge one, not the root |
810 | + return True |
811 | + |
812 | + if key == 'valid_since': |
813 | + valid_since = datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') |
814 | + self.assertGreater(valid_since, before) |
815 | + self.assertGreater(after, valid_since) |
816 | + return True |
817 | + |
818 | + if key == 'last_auth': |
819 | + last_auth = datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') |
820 | + self.assertGreater(last_auth, before) |
821 | + self.assertGreater(after, last_auth) |
822 | + return True |
823 | + |
824 | + if key == 'account': |
825 | + acc = json.loads(base64.b64decode(value).decode("utf8")) |
826 | + self.assertEqual(acc['openid'], real_account.openid_identifier) |
827 | + self.assertEqual(acc['email'], |
828 | + real_account.preferredemail.email) |
829 | + self.assertEqual(acc['displayname'], real_account.displayname) |
830 | + return True |
831 | + |
832 | + if key == 'expires': |
833 | + expires = datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') |
834 | + before_plus_ttl = before + timedelta( |
835 | + seconds=settings.MACAROON_TTL) |
836 | + after_plus_ttl = after + timedelta( |
837 | + seconds=settings.MACAROON_TTL) |
838 | + self.assertGreater(expires, before_plus_ttl) |
839 | + self.assertGreater(after_plus_ttl, expires) |
840 | + return True |
841 | + |
842 | + # we're not validating an SSO from the discharged macaroon, fail! |
843 | + return False |
844 | + |
845 | + v = Verifier() |
846 | + v.satisfy_general(checker) |
847 | + v.verify(root_macaroon, random_key_used, [discharge_macaroon]) |
848 | |
849 | === modified file 'uci-vms.conf' |
850 | --- uci-vms.conf 2016-01-21 15:45:34 +0000 |
851 | +++ uci-vms.conf 2016-03-11 17:07:30 +0000 |
852 | @@ -15,7 +15,7 @@ |
853 | vm.update = True |
854 | vm.packages = {sso.dependencies}, python-uci-vms, python-novaclient |
855 | vm.bind_home = True |
856 | -vm.apt_sources = deb https://{ppa.u1_hackers.user_pass}@private-ppa.launchpad.net/ubuntuone/hackers/ubuntu {vm.release} main|4BD0ECAE |
857 | +vm.apt_sources = ppa:facundo/salty, deb https://{ppa.u1_hackers.user_pass}@private-ppa.launchpad.net/ubuntuone/hackers/ubuntu {vm.release} main|4BD0ECAE |
858 | |
859 | # vms named sso-worker-0n are built from here |
860 | [sso-worker] |
861 | @@ -49,5 +49,5 @@ |
862 | # /!\ 'ppa.u1_hackers.user_pass = <USER>:<PASSWORD>' should be defined in |
863 | # ~/uci-vms.conf, see https://launchpad.net/~/+archivesubscriptions to find |
864 | # your password |
865 | -vm.apt_sources = ppa:ubuntu-lxc/lxd-stable, deb https://{ppa.u1_hackers.user_pass}@private-ppa.launchpad.net/ubuntuone/hackers/ubuntu {vm.release} main|4BD0ECAE |
866 | +vm.apt_sources = ppa:facundo/salty, ppa:ubuntu-lxc/lxd-stable, deb https://{ppa.u1_hackers.user_pass}@private-ppa.launchpad.net/ubuntuone/hackers/ubuntu {vm.release} main|4BD0ECAE |
867 | vm.packages = {sso.dependencies}, python3-uci-vms, lxc, lxc-templates, debootstrap |
Approve it as formal review and everything happened in the other MP.