Merge lp:~facundo/canonical-identity-provider/macaroon-discharging-2 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: 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
Reviewer Review Type Date Requested Status
Facundo Batista (community) Approve
Review via email: mp+288684@code.launchpad.net

Commit message

Discharge the received macaroon if user authenticates ok.

To post a comment you must log in.
Revision history for this message
Facundo Batista (facundo) wrote :

Approve it as formal review and everything happened in the other MP.

review: Approve
Revision history for this message
Ubuntu One Auto Pilot (otto-pilot) wrote :
Download full text (25.2 KiB)

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/cache/canonical-identity-provider/merges/trunk/env
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/cache/canonical-identity-provider/merges/trunk/env/bin
New python executable in /mnt/tarmac/cache/canonical-identity-provider/merges/trunk/env/bin/python
Installing setuptools, pip...done.
/usr/lib/config-manager/cm.py update config-manager.txt
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/cache/canonical-identity-provider/merges/trunk/env/bin/python /mnt/tarmac/cache/canonical-identity-provider/merges/trunk/env/bin/pip install --find-links=branches/wheels --no-index -r requirements.txt
Ignoring indexes: https://pypi.python.org/simple/
Downloading/unpacking bson==0.3.3 (from -r requirements.txt (line 1))
Downloading/unpacking canonical-raven==0.0.3 (from -r requirements.txt (line 2))
Downloading/unpacking convoy==0.4.1 (from -r requirements.txt (line 3))
Downloading/unpacking django==1.8.7 (from -r requirements.txt (line 4))
Downloading/unpacking django-honeypot==0.4.0 (from -r requirements.txt (line 5))
Downloading/unpacking django-jsonfield==0.9.13 (from -r requirements.txt (line 6))
Downloading/unpacking django-model-utils==2.2 (from -r requirements.txt (line 7))
Downloading/unpacking django-modeldict==1.4.1 (from -r requirements.txt (line 8))
Downloading/unpacking django-preflight==0.1.5 (from -r requirements.txt (line 9))
Downloading/unpacking django-secure==1.0.1 (from -r requirements.txt (line 10))
Downloading/unpacking django-statsd-mozilla==0.3.15 (from -r requirements.txt (line 11))
Downloading/unpacking gargoyle==0.11.0 (from -r requirements.txt (line 12))
Downloading/unpacking gunicorn==19.3.0 (from -r requirements.txt (line 13))
Downloading/unpacking lazr.authentication==0.1.3 (from -r requirements.txt (line 14))
Downloading/unpacking libnacl==1.3.6 (from -r requirements.txt (line 15))
Downloading/unpacking nexus==0.3.1 (from -r requirements.txt (line 16))
Downloading/unpacking oath==1.4.0 (from -r requirements.txt (line 17))
Downloading/unpacking oauthlib==0.7.2 (from -r requirements.txt (line 18))
Requirement already satisfied (use --upgrade to upgrade): oops==0.0.13 in /usr/lib/pymodules/python2.7 (from -r requirements.txt (line 19))
Downloading/unpacking oops-amqp==0.0.8b1 (from -r requirements.txt (line 20))
Downloading/unpacking oops-datedir-repo==0.0.23 (from -r requirements.txt (line 21))
Downloading/unpacking oops-dictconfig==0.0.6 (from -r requirements.txt (line 22))
Downloading/unpacking oops-timeline==0.0.2 (from -r requirements.txt (line 23))
Downloading/unpacking oops-wsgi==0.0.11 (from -r requirements.txt (line 24))
Downloading/unpacking paste==2.0.1 (from -r requirements.txt (line 25))
Downloading/unpac...

Revision history for this message
Facundo Batista (facundo) wrote :

Let's see now

review: Approve
Revision history for this message
Ubuntu One Auto Pilot (otto-pilot) wrote :

Attempt to merge into lp:canonical-identity-provider failed due to conflicts:

text conflict in dependencies.txt

Revision history for this message
Facundo Batista (facundo) wrote :

Argggggg, let's try again

review: Approve
Revision history for this message
Ubuntu One Auto Pilot (otto-pilot) wrote :
Download full text (35.0 KiB)

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/cache/canonical-identity-provider/merges/trunk/env
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/cache/canonical-identity-provider/merges/trunk/env/bin
New python executable in /mnt/tarmac/cache/canonical-identity-provider/merges/trunk/env/bin/python
Installing setuptools, pip...done.
/usr/lib/config-manager/cm.py update config-manager.txt
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/cache/canonical-identity-provider/merges/trunk/env/bin/python /mnt/tarmac/cache/canonical-identity-provider/merges/trunk/env/bin/pip install --find-links=branches/wheels --no-index -r requirements.txt
Ignoring indexes: https://pypi.python.org/simple/
Downloading/unpacking bson==0.3.3 (from -r requirements.txt (line 1))
Downloading/unpacking canonical-raven==0.0.3 (from -r requirements.txt (line 2))
Downloading/unpacking convoy==0.4.1 (from -r requirements.txt (line 3))
Downloading/unpacking django==1.8.7 (from -r requirements.txt (line 4))
Downloading/unpacking django-honeypot==0.4.0 (from -r requirements.txt (line 5))
Downloading/unpacking django-jsonfield==0.9.13 (from -r requirements.txt (line 6))
Downloading/unpacking django-model-utils==2.2 (from -r requirements.txt (line 7))
Downloading/unpacking django-modeldict==1.4.1 (from -r requirements.txt (line 8))
Downloading/unpacking django-preflight==0.1.5 (from -r requirements.txt (line 9))
Downloading/unpacking django-secure==1.0.1 (from -r requirements.txt (line 10))
Downloading/unpacking django-statsd-mozilla==0.3.15 (from -r requirements.txt (line 11))
Downloading/unpacking gargoyle==0.11.0 (from -r requirements.txt (line 12))
Downloading/unpacking gunicorn==19.3.0 (from -r requirements.txt (line 13))
Downloading/unpacking lazr.authentication==0.1.3 (from -r requirements.txt (line 14))
Downloading/unpacking libnacl==1.3.6 (from -r requirements.txt (line 15))
Downloading/unpacking nexus==0.3.1 (from -r requirements.txt (line 16))
Downloading/unpacking oath==1.4.0 (from -r requirements.txt (line 17))
Downloading/unpacking oauthlib==0.7.2 (from -r requirements.txt (line 18))
Requirement already satisfied (use --upgrade to upgrade): oops==0.0.13 in /usr/lib/pymodules/python2.7 (from -r requirements.txt (line 19))
Downloading/unpacking oops-amqp==0.0.8b1 (from -r requirements.txt (line 20))
Downloading/unpacking oops-datedir-repo==0.0.23 (from -r requirements.txt (line 21))
Downloading/unpacking oops-dictconfig==0.0.6 (from -r requirements.txt (line 22))
Downloading/unpacking oops-timeline==0.0.2 (from -r requirements.txt (line 23))
Downloading/unpacking oops-wsgi==0.0.11 (from -r requirements.txt (line 24))
Downloading/unpacking paste==2.0.1 (from -r requirements.txt (line 25))
Downloading/unpac...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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