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
=== modified file '.bzrignore'
--- .bzrignore 2015-10-09 16:03:29 +0000
+++ .bzrignore 2016-03-11 17:07:30 +0000
@@ -8,3 +8,4 @@
8staticfiles8staticfiles
9lib/versioninfo.py9lib/versioninfo.py
10settings.py10settings.py
11rsakeys/*
1112
=== modified file 'README'
--- README 2016-03-03 20:01:53 +0000
+++ README 2016-03-11 17:07:30 +0000
@@ -45,6 +45,10 @@
452. Install system dependencies452. Install system dependencies
46::46::
4747
48 # Needed for libsodium package
49 $ sudo apt-get install -y software-properties-common
50 $ sudo add-apt-repository ppa:facundo/salty
51
48 $ sudo apt-get update52 $ sudo apt-get update
49 $ cat dependencies.txt | sudo xargs apt-get install -y --no-install-recommends53 $ cat dependencies.txt | sudo xargs apt-get install -y --no-install-recommends
50 $ cat dependencies-devel.txt | sudo xargs apt-get install -y --no-install-recommends54 $ cat dependencies-devel.txt | sudo xargs apt-get install -y --no-install-recommends
@@ -207,6 +211,21 @@
207 lxc with no internet access at all.211 lxc with no internet access at all.
208 212
209213
21413. (Optional) Set up private/public keys to make macaroons work between
215 projects
216
217 For some endpoints to work correctly, the system needs to decrypt keys
218 that were encrypted from other services (you'll need to use this
219 instructions, or the corresponding one in the other projects).
220
221 So, create a pair of keys for this project:
222
223 ssh-keygen -t rsa -N "" -f project_id_rsa
224
225 This will leave you with two files, move the private one into the
226 ``rsakeys`` directory, and the .pub one into this same dir in the
227 other projects.
228
210229
211BAZAAR230BAZAAR
212------231------
213232
=== modified file 'dependencies.txt'
--- dependencies.txt 2016-03-01 19:13:46 +0000
+++ dependencies.txt 2016-03-11 17:07:30 +0000
@@ -1,6 +1,7 @@
1bzr1bzr
2geoip-database2geoip-database
3libgeoip13libgeoip1
4libsodium13
4python-amqplib5python-amqplib
5python-bcrypt6python-bcrypt
6python-beautifulsoup7python-beautifulsoup
78
=== modified file 'django_project/settings_base.py'
--- django_project/settings_base.py 2016-03-01 19:13:46 +0000
+++ django_project/settings_base.py 2016-03-11 17:07:30 +0000
@@ -3,6 +3,8 @@
3import random3import random
4from urlparse import urlparse4from urlparse import urlparse
55
6from Crypto.PublicKey import RSA
7
6from versioninfo import version_info8from versioninfo import version_info
79
8# /srv/login.staging.ubuntu.com/staging/canonical-identity-provider10# /srv/login.staging.ubuntu.com/staging/canonical-identity-provider
@@ -68,6 +70,16 @@
68COMBINE = True70COMBINE = True
69COMBO_URL = '/combo/'71COMBO_URL = '/combo/'
70COMMENTS_ALLOW_PROFANITIES = False72COMMENTS_ALLOW_PROFANITIES = False
73_fpath = os.path.join(
74 os.path.abspath(os.getenv('SCA_RSAKEYS_DIR', '')), 'sso_id_rsa')
75try:
76 with open(_fpath) as fh:
77 raw = fh.read()
78 CRYPTO_SSO_PRIVKEY = RSA.importKey(raw)
79except:
80 # this catch-all-and-set-None will be removed after first successful
81 # deployments having the proper key in place
82 CRYPTO_SSO_PRIVKEY = None
71CSRF_COOKIE_DOMAIN = None83CSRF_COOKIE_DOMAIN = None
72CSRF_COOKIE_HTTPONLY = True84CSRF_COOKIE_HTTPONLY = True
73CSRF_COOKIE_NAME = 'csrftoken'85CSRF_COOKIE_NAME = 'csrftoken'
@@ -368,6 +380,8 @@
368LOGIN_REDIRECT_URL = '/'380LOGIN_REDIRECT_URL = '/'
369LOGIN_URL = '/+login'381LOGIN_URL = '/+login'
370LOGOUT_URL = '/accounts/logout/'382LOGOUT_URL = '/accounts/logout/'
383MACAROON_TTL = 365 * 24 * 3600 # seconds
384MACAROON_SERVICE_LOCATION = HOSTNAME
371MANAGERS = []385MANAGERS = []
372MAX_FAILED_LOGIN_ATTEMPTS = 20386MAX_FAILED_LOGIN_ATTEMPTS = 20
373MAX_PASSWORD_RESET_TOKENS = 5387MAX_PASSWORD_RESET_TOKENS = 5
374388
=== modified file 'django_project/settings_devel.py'
--- django_project/settings_devel.py 2015-12-18 12:21:25 +0000
+++ django_project/settings_devel.py 2016-03-11 17:07:30 +0000
@@ -1,8 +1,8 @@
1import os1import os
22
3os.environ.setdefault(3BASE_DIR = os.path.dirname(os.path.dirname(__file__))
4 'SSO_LOGS_DIR',4os.environ.setdefault('SSO_LOGS_DIR', os.path.join(BASE_DIR, 'logs'))
5 os.path.join(os.path.dirname(os.path.dirname(__file__)), 'logs'))5os.environ.setdefault('SCA_RSAKEYS_DIR', os.path.join(BASE_DIR, 'rsakeys'))
6os.environ.setdefault('SSO_ROOT_URL', 'http://0.0.0.0:8000')6os.environ.setdefault('SSO_ROOT_URL', 'http://0.0.0.0:8000')
77
8from django_project.settings_base import * # noqa8from django_project.settings_base import * # noqa
99
=== modified file 'requirements.txt'
--- requirements.txt 2016-02-01 15:30:36 +0000
+++ requirements.txt 2016-03-11 17:07:30 +0000
@@ -12,6 +12,7 @@
12gargoyle==0.11.012gargoyle==0.11.0
13gunicorn==19.3.013gunicorn==19.3.0
14lazr.authentication==0.1.314lazr.authentication==0.1.3
15libnacl==1.3.6
15nexus==0.3.116nexus==0.3.1
16oath==1.4.017oath==1.4.0
17oauthlib==0.7.218oauthlib==0.7.2
@@ -22,8 +23,10 @@
22oops-timeline==0.0.223oops-timeline==0.0.2
23oops-wsgi==0.0.1124oops-wsgi==0.0.11
24paste==2.0.125paste==2.0.1
26pymacaroons==0.9.1
25raven==5.6.027raven==5.6.0
26requests-oauthlib==0.4.228requests-oauthlib==0.4.2
29six==1.10.0
27statsd==3.130statsd==3.1
28testresources==0.2.731testresources==0.2.7
29timeline==0.0.432timeline==0.0.4
3033
=== modified file 'requirements_docs.txt'
--- requirements_docs.txt 2015-10-27 12:55:27 +0000
+++ requirements_docs.txt 2016-03-11 17:07:30 +0000
@@ -7,7 +7,6 @@
7pygments==2.0.27pygments==2.0.2
8pytz==2014.108pytz==2014.10
9setuptools==14.09setuptools==14.0
10six==1.9.0
11snowballstemmer==1.2.010snowballstemmer==1.2.0
12sphinx==1.311sphinx==1.3
13sphinx-bootstrap-theme==0.4.512sphinx-bootstrap-theme==0.4.5
1413
=== added directory 'rsakeys'
=== modified file 'src/api/v20/handlers.py'
--- src/api/v20/handlers.py 2015-10-26 21:53:48 +0000
+++ src/api/v20/handlers.py 2016-03-11 17:07:30 +0000
@@ -1,4 +1,4 @@
1# Copyright 2010-2013 Canonical Ltd. This software is licensed under the1# Copyright 2010-2016 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4import logging4import logging
@@ -406,6 +406,79 @@
406 return response406 return response
407407
408408
409class MacaroonHandler(BaseHandler):
410 allowed_methods = ('POST',)
411
412 @require_mime('json')
413 @throttle(extra_callback=get_email_from_request_data)
414 def create(self, request):
415 if settings.CRYPTO_SSO_PRIVKEY is None:
416 return errors.RESOURCE_NOT_FOUND()
417
418 data = request.data
419 try:
420 email = data['email']
421 password = data['password']
422 root_macaroon_raw = data['macaroon']
423 except KeyError:
424 expected = set(('email', 'password', 'macaroon'))
425 missing = dict((k, [FIELD_REQUIRED]) for k in expected - set(data))
426 return errors.INVALID_DATA(**missing)
427
428 account = None
429 response = None
430 try:
431 account = authenticate_user(email, password, request=request)
432 except AccountSuspended:
433 response = errors.ACCOUNT_SUSPENDED()
434 except AccountDeactivated:
435 response = errors.ACCOUNT_DEACTIVATED()
436 except EmailInvalidated:
437 response = errors.EMAIL_INVALIDATED()
438 except PasswordPolicyError as e:
439 root_url = request.build_absolute_uri('/')
440 response = errors.PASSWORD_POLICY_ERROR(
441 reason=unicode(e), location=root_url)
442 except AuthenticationError:
443 response = errors.INVALID_CREDENTIALS()
444
445 if account is not None:
446 otp = data.get('otp')
447 if otp is not None:
448 try:
449 twofactor.authenticate(account, otp)
450 except AuthenticationError:
451 account = None
452 response = errors.TWOFACTOR_FAILURE()
453 elif account.twofactor_required:
454 response = errors.TWOFACTOR_REQUIRED()
455
456 if account is None:
457 # track failed login attempt
458 login_failed.send(sender=self, request=request,
459 credentials=dict(email=email, password=password),
460 authlogtype=AuthLogType.API_FAIL)
461
462 if response is not None:
463 return response
464
465 try:
466 discharge = auth.build_discharge_macaroon(
467 account, root_macaroon_raw)
468 except ValidationError:
469 return errors.INVALID_DATA()
470 except AuthenticationError:
471 return errors.INVALID_CREDENTIALS()
472
473 response = rc.ALL_OK
474 response.content = dict(discharge_macaroon=discharge.serialize())
475
476 log_type = AuthLogType.API_MACAROON_DISCHARGE_NEW
477 login_succeeded.send(sender=self, user=account, request=request,
478 authlogtype=log_type, email=email)
479 return response
480
481
409class EmailsHandler(BaseHandler):482class EmailsHandler(BaseHandler):
410 allowed_methods = ('DELETE', 'GET')483 allowed_methods = ('DELETE', 'GET')
411 model = EmailAddress484 model = EmailAddress
412485
=== modified file 'src/api/v20/tests/test_handlers.py'
--- src/api/v20/tests/test_handlers.py 2016-01-07 16:01:28 +0000
+++ src/api/v20/tests/test_handlers.py 2016-03-11 17:07:30 +0000
@@ -2,7 +2,10 @@
22
3from __future__ import unicode_literals3from __future__ import unicode_literals
44
5import base64
6import binascii
5import json7import json
8import os
6import re9import re
7import time10import time
811
@@ -10,8 +13,10 @@
10from urllib import quote, urlencode13from urllib import quote, urlencode
11from urlparse import parse_qsl, urlparse, urlunparse14from urlparse import parse_qsl, urlparse, urlunparse
1215
16from Crypto import Random
17from Crypto.PublicKey import RSA
13from django.conf import settings18from django.conf import settings
14from django.contrib.auth.models import AnonymousUser19from django.contrib.auth.models import AnonymousUser, make_password
15from django.core import mail20from django.core import mail
16from django.core.serializers.json import DjangoJSONEncoder21from django.core.serializers.json import DjangoJSONEncoder
17from django.core.urlresolvers import NoReverseMatch, reverse22from django.core.urlresolvers import NoReverseMatch, reverse
@@ -20,6 +25,7 @@
20from django.utils.timezone import now25from django.utils.timezone import now
21from gargoyle.testutils import switches26from gargoyle.testutils import switches
22from mock import Mock, patch27from mock import Mock, patch
28from pymacaroons import Macaroon, Verifier
23from timeline import Timeline29from timeline import Timeline
2430
25from api.v20 import handlers, whitelist31from api.v20 import handlers, whitelist
@@ -34,15 +40,17 @@
34from identityprovider.models.const import (40from identityprovider.models.const import (
35 AccountCreationRationale,41 AccountCreationRationale,
36 AccountStatus,42 AccountStatus,
43 AuthLogType,
37 AuthTokenType,44 AuthTokenType,
38 EmailStatus,45 EmailStatus,
39 TokenScope,46 TokenScope,
40)47)
48from identityprovider.signals import login_failed
41from identityprovider.tests import DEFAULT_USER_PASSWORD49from identityprovider.tests import DEFAULT_USER_PASSWORD
42from identityprovider.tests.utils import SSOBaseTestCase50from identityprovider.tests.test_auth import AuthLogTestCaseMixin
51from identityprovider.tests.utils import SSOBaseTestCase, TimelineActionMixin
43from identityprovider.utils import redirection_url_for_token52from identityprovider.utils import redirection_url_for_token
4453
45
46OVERRIDES = dict(54OVERRIDES = dict(
47 EMAIL_WHITELIST_REGEXP_LIST=['^canonicaltest(?:\+.+)?@gmail\.com$']55 EMAIL_WHITELIST_REGEXP_LIST=['^canonicaltest(?:\+.+)?@gmail\.com$']
48)56)
@@ -2022,3 +2030,251 @@
2022 mock_cache.get.return_value = (0, time.time() + 42.1)2030 mock_cache.get.return_value = (0, time.time() + 42.1)
2023 self.get_response(request.path, method=request.method)2031 self.get_response(request.path, method=request.method)
2024 self.assertEqual([], called_metrics)2032 self.assertEqual([], called_metrics)
2033
2034
2035class MacaroonHandlerBaseTestCase(SSOBaseTestCase):
2036
2037 url = reverse('api-macaroon-discharge')
2038
2039 def track_failed_logins(self, **kw):
2040 self.login_failed_calls.append(kw)
2041
2042 def build_macaroon(self, service_location=None):
2043 """Create a Macaron with the proper third party caveat."""
2044 if service_location is None:
2045 service_location = settings.MACAROON_SERVICE_LOCATION
2046
2047 # pair of keys to encrypt/decrypt
2048 Random.atfork()
2049 test_rsa_priv_key = RSA.generate(1024)
2050 test_rsa_pub_key = test_rsa_priv_key.publickey()
2051 p = self.settings(CRYPTO_SSO_PRIVKEY=test_rsa_priv_key)
2052 p.enable()
2053 self.addCleanup(p.disable)
2054
2055 macaroon_random_key = binascii.hexlify(os.urandom(32))
2056 root_macaroon = Macaroon(
2057 location='The store ;)',
2058 key=macaroon_random_key,
2059 identifier='A test macaroon',
2060 )
2061 random_key = binascii.hexlify(os.urandom(32))
2062 random_key_encrypted = base64.b64encode(
2063 test_rsa_pub_key.encrypt(random_key, 32)[0])
2064 root_macaroon.add_third_party_caveat(
2065 service_location, random_key, random_key_encrypted)
2066 return root_macaroon, macaroon_random_key
2067
2068 def setUp(self):
2069 super(MacaroonHandlerBaseTestCase, self).setUp()
2070
2071 self.login_failed_calls = []
2072 login_failed.connect(self.track_failed_logins, dispatch_uid=self.id())
2073
2074 self.root_macaroon, self.macaroon_random_key = self.build_macaroon()
2075 self.data = dict(
2076 email='foo@bar.com', password='foobar123',
2077 macaroon=self.root_macaroon.serialize())
2078 self.account = self.factory.make_account(
2079 email=self.data['email'], password=self.data['password'])
2080
2081
2082class MacaroonHandlerTestCase(MacaroonHandlerBaseTestCase,
2083 AuthLogTestCaseMixin):
2084
2085 def do_post(self, data=None, expected_status_code=200,
2086 content_type='application/json', **kwargs):
2087 if data is None:
2088 data = self.data
2089
2090 response = self.client.post(self.url, data=json.dumps(data),
2091 content_type=content_type, **kwargs)
2092
2093 self.assertEqual(
2094 response.status_code, expected_status_code,
2095 "Bad status code! expected={} got={} response={}".format(
2096 expected_status_code, response.status_code, response))
2097 self.assertEqual(response['Content-type'],
2098 'application/json; charset=utf-8',
2099 response)
2100 return json.loads(response.content)
2101
2102 def assert_response_correct(self, json_body, account):
2103 """Check we received a good discharge macaroon for the root sent.
2104
2105 Proper verification of the received macaroon internals happens in
2106 the tests of the macaroon builder.
2107 """
2108 discharge_macaroon_raw = json_body['discharge_macaroon']
2109 discharge_macaroon = Macaroon.deserialize(discharge_macaroon_raw)
2110 v = Verifier()
2111 v.satisfy_general(lambda c: True)
2112 v.verify(
2113 self.root_macaroon, self.macaroon_random_key, [discharge_macaroon])
2114
2115 def assert_failed_login(self, code, data, extra=None,
2116 expected_status_code=403, check_login_failed=True):
2117 if extra is None:
2118 extra = {}
2119
2120 json_body = self.do_post(expected_status_code=expected_status_code,
2121 data=data)
2122 self.assertEqual(json_body['code'], code)
2123 self.assertEqual(json_body['extra'], extra)
2124
2125 if check_login_failed:
2126 self.assertEqual(len(self.login_failed_calls), 1)
2127 failed = self.login_failed_calls[0]
2128 self.assertEqual(
2129 failed['credentials'],
2130 {'email': data['email'], 'password': data['password']})
2131
2132 return json_body
2133
2134 def test_login_required_parameters(self):
2135 json_body = self.do_post(expected_status_code=400, data={})
2136
2137 self.assertEqual(json_body, {
2138 'code': 'INVALID_DATA',
2139 'extra': {'email': [handlers.FIELD_REQUIRED]},
2140 'message': 'Invalid request data'},
2141 )
2142 self.assertEqual(self.login_failed_calls, [])
2143
2144 def test_account_suspended(self):
2145 self.account.suspend()
2146 self.assert_failed_login('ACCOUNT_SUSPENDED', self.data)
2147
2148 def test_account_deactivated(self):
2149 self.account.deactivate()
2150 self.assert_failed_login('ACCOUNT_DEACTIVATED', self.data)
2151
2152 def test_failed_login(self):
2153 self.account.set_password('other-thing')
2154 self.account.save()
2155 self.assert_failed_login('INVALID_CREDENTIALS',
2156 self.data, expected_status_code=401)
2157
2158 def test_email_invalidated(self):
2159 self.account.preferredemail.invalidate()
2160 self.assert_failed_login('EMAIL_INVALIDATED', self.data)
2161
2162 def test_password_policy(self):
2163 self.account.accountpassword.password = make_password('short')
2164 self.account.accountpassword.save()
2165
2166 self.data['password'] = 'short'
2167 extra = {
2168 'reason': 'Password must be at least 8 characters long',
2169 'location': 'http://testserver/'}
2170 self.assert_failed_login(
2171 'PASSWORD_POLICY_ERROR', data=self.data, extra=extra)
2172
2173 def test_twofactor_required(self):
2174 self.account.twofactor_required = True
2175 self.account.save()
2176
2177 json_body = self.do_post(expected_status_code=401)
2178 self.assertEqual(json_body['code'], 'TWOFACTOR_REQUIRED')
2179 self.assertEqual(self.login_failed_calls, [])
2180
2181 def test_twofactor(self):
2182 device = self.factory.make_device(account=self.account)
2183 self.account.twofactor_required = True
2184 self.account.save()
2185
2186 self.data['otp'] = self.factory.make_next_otp(device)
2187 json_body = self.do_post(data=self.data)
2188
2189 self.assert_response_correct(json_body, self.account)
2190
2191 def test_twofactor_wrong_otp(self):
2192 device = self.factory.make_device(account=self.account)
2193 self.account.twofactor_required = True
2194 self.account.save()
2195
2196 self.data['otp'] = str(int(self.factory.make_next_otp(device)) - 1)
2197 self.assert_failed_login('TWOFACTOR_FAILURE', self.data)
2198
2199 def test_failed_login_creates_authlog(self):
2200 self.account.set_password('other-thing')
2201 self.account.save()
2202 expected_values = {
2203 'login_email': self.data['email'],
2204 'log_type': AuthLogType.API_FAIL,
2205 'referer': '',
2206 'user_agent': '',
2207 'remote_ip': '127.0.0.1',
2208 'account': None
2209 }
2210 with self.assert_authlog_matches(expected_values):
2211 self.assert_failed_login('INVALID_CREDENTIALS',
2212 self.data, expected_status_code=401)
2213
2214 def test_login_creates_authlog(self):
2215 expected_values = {
2216 'login_email': self.data['email'],
2217 'log_type': AuthLogType.API_MACAROON_DISCHARGE_NEW,
2218 'referer': '',
2219 'user_agent': '',
2220 'remote_ip': '127.0.0.1',
2221 'account': self.account.id
2222 }
2223 # This context manager magically checks when_created values
2224 with self.assert_authlog_matches(expected_values):
2225 self.do_post()
2226
2227 def test_macaroon_created(self):
2228 json_body = self.do_post()
2229 self.assert_response_correct(json_body, self.account)
2230
2231 def test_root_macaroon_corrupt(self):
2232 data = dict(
2233 email='foo@bar.com', password='foobar123',
2234 macaroon="I'm a seriously corrupted macaroon")
2235 self.assert_failed_login('INVALID_DATA', data,
2236 expected_status_code=400,
2237 check_login_failed=False)
2238
2239 def test_root_macaroon_not_for_sso(self):
2240 macaroon, _ = self.build_macaroon(service_location="other service")
2241 data = dict(email='foo@bar.com', password='foobar123',
2242 macaroon=macaroon.serialize())
2243 self.assert_failed_login('INVALID_CREDENTIALS', data,
2244 expected_status_code=401,
2245 check_login_failed=False)
2246
2247
2248class MacaroonHandlerTimelineTestCase(MacaroonHandlerBaseTestCase,
2249 TimelineActionMixin):
2250
2251 def test_login_timeline_records_password_checking(self):
2252 # Prepare and inject a timeline in the client's POST.
2253 empty_timeline = Timeline()
2254 response = self.client.post(
2255 self.url, data=json.dumps(self.data),
2256 content_type='application/json',
2257 **{'timeline.timeline': empty_timeline})
2258 self.assert_timeline_contains(request=response.wsgi_request,
2259 category='django-password-checking',
2260 detail=str(self.account.id))
2261
2262 def test_login_ignore_weird_timeline(self):
2263 """A weird object passed as timeline should be ignored/not used."""
2264 weird_timeline = []
2265 response = self.client.post(
2266 self.url, data=json.dumps(self.data),
2267 content_type='application/json',
2268 **{'timeline.timeline': weird_timeline})
2269
2270 # If the timeline is a weird object we shouldn't have touched it
2271 self.assertEqual([], response.wsgi_request.META['timeline.timeline'])
2272
2273 def test_login_no_timeline(self):
2274 """Shouldn't barf if there's no timeline in the request at all."""
2275 response = self.client.post(
2276 self.url, data=json.dumps(self.data),
2277 content_type='application/json')
2278
2279 self.assertEqual(response.status_code, 200)
2280 self.assertNotIn('timeline.timeline', response.wsgi_request.META)
20252281
=== modified file 'src/api/v20/urls.py'
--- src/api/v20/urls.py 2014-12-05 18:09:03 +0000
+++ src/api/v20/urls.py 2016-03-11 17:07:30 +0000
@@ -15,6 +15,7 @@
15 AccountRegistrationHandler,15 AccountRegistrationHandler,
16 AccountsHandler,16 AccountsHandler,
17 EmailsHandler,17 EmailsHandler,
18 MacaroonHandler,
18 PasswordResetTokenHandler,19 PasswordResetTokenHandler,
19 RequestsHandler,20 RequestsHandler,
20 TokensHandler,21 TokensHandler,
@@ -26,6 +27,7 @@
26v2emails = ApiResource(27v2emails = ApiResource(
27 handler=EmailsHandler, authentication=ApiEmailsAuthentication())28 handler=EmailsHandler, authentication=ApiEmailsAuthentication())
28v2login = ApiResource(handler=AccountLoginHandler)29v2login = ApiResource(handler=AccountLoginHandler)
30v2macaroon = ApiResource(handler=MacaroonHandler)
29v2password_reset = ApiResource(handler=PasswordResetTokenHandler)31v2password_reset = ApiResource(handler=PasswordResetTokenHandler)
30v2registration = ApiResource(32v2registration = ApiResource(
31 handler=AccountRegistrationHandler,33 handler=AccountRegistrationHandler,
@@ -41,6 +43,7 @@
41 url(r'^accounts/(?P<openid>\w+)$', v2accounts, name='api-account'),43 url(r'^accounts/(?P<openid>\w+)$', v2accounts, name='api-account'),
42 url(r'^emails/(?P<email>.+)$', v2emails, name='api-email'),44 url(r'^emails/(?P<email>.+)$', v2emails, name='api-email'),
43 url(r'^requests/validate$', v2requests, name='api-requests'),45 url(r'^requests/validate$', v2requests, name='api-requests'),
46 url(r'^tokens/discharge$', v2macaroon, name='api-macaroon-discharge'),
44 url(r'^tokens/oauth$', v2login, name='api-login'),47 url(r'^tokens/oauth$', v2login, name='api-login'),
45 url(r'^tokens/oauth/(?P<token>.+)$', v2tokens, name='api-token'),48 url(r'^tokens/oauth/(?P<token>.+)$', v2tokens, name='api-token'),
46 url(r'^tokens/password$', v2password_reset, name='api-password-reset'),49 url(r'^tokens/password$', v2password_reset, name='api-password-reset'),
4750
=== modified file 'src/identityprovider/auth.py'
--- src/identityprovider/auth.py 2015-11-18 17:21:12 +0000
+++ src/identityprovider/auth.py 2016-03-11 17:07:30 +0000
@@ -1,10 +1,13 @@
1# -*- coding: utf-8 -*-1# -*- coding: utf-8 -*-
2#2#
3# Copyright 2010 Canonical Ltd. This software is licensed under the3# Copyright 2010-2016 Canonical Ltd. This software is licensed under the
4# GNU Affero General Public License version 3 (see the file LICENSE).4# GNU Affero General Public License version 3 (see the file LICENSE).
55
6from __future__ import unicode_literals6from __future__ import unicode_literals
77
8import base64
9import datetime
10import json
8import logging11import logging
912
10from django.conf import settings13from django.conf import settings
@@ -21,7 +24,9 @@
21from django.utils.translation import ugettext_lazy as _24from django.utils.translation import ugettext_lazy as _
22from django.utils.timezone import now25from django.utils.timezone import now
23from oauthlib.oauth1 import RequestValidator, ResourceEndpoint26from oauthlib.oauth1 import RequestValidator, ResourceEndpoint
27from pymacaroons import Macaroon
2428
29from identityprovider.login import AuthenticationError
25from identityprovider.models import (30from identityprovider.models import (
26 Account,31 Account,
27 AccountPassword,32 AccountPassword,
@@ -488,3 +493,55 @@
488 response = HttpResponse("Authorization Required", status=401)493 response = HttpResponse("Authorization Required", status=401)
489 response['WWW-Authenticate'] = 'OAuth realm="%s"' % self.realm494 response['WWW-Authenticate'] = 'OAuth realm="%s"' % self.realm
490 return response495 return response
496
497
498def build_discharge_macaroon(account, root_macaroon_raw):
499 """Build a discharge macaroon from a root one."""
500 service_location = settings.MACAROON_SERVICE_LOCATION
501
502 try:
503 root_macaroon = Macaroon.deserialize(root_macaroon_raw)
504 except:
505 raise ValidationError("The received Macaroon is corrupt")
506
507 try:
508 # isolate 3rd-party caveat that concerns SSO (hinted by 'location')
509 (sso_caveat,) = [c for c in root_macaroon.third_party_caveats()
510 if c.location == service_location]
511 except:
512 # the macaroon doesn't have a location for this service
513 raise AuthenticationError("The received macaroon is not for ours")
514
515 # get the only-for-this-project random key
516 original_random_key = settings.CRYPTO_SSO_PRIVKEY.decrypt(
517 base64.b64decode(sso_caveat.caveat_id))
518
519 # create a discharge macaroon with same location, key and
520 # identifier than it's original 3rd-party caveat (so they can
521 # be matched and verified)
522 d = Macaroon(
523 location=service_location,
524 key=original_random_key,
525 identifier=sso_caveat.caveat_id,
526 )
527
528 # add the account info
529 account_info = base64.b64encode(json.dumps({
530 'openid': account.openid_identifier,
531 'email': account.preferredemail.email,
532 'displayname': account.displayname,
533 }).encode("utf8"))
534 d.add_first_party_caveat(service_location + '|account|' + account_info)
535
536 # add timestamp values
537 now_string = now().strftime('%Y-%m-%dT%H:%M:%S.%f')
538 d.add_first_party_caveat(service_location + '|valid_since|' + now_string)
539 d.add_first_party_caveat(service_location + '|last_auth|' + now_string)
540 expire = now() + datetime.timedelta(seconds=settings.MACAROON_TTL)
541 d.add_first_party_caveat(
542 service_location + '|expires|' +
543 expire.strftime('%Y-%m-%dT%H:%M:%S.%f'))
544
545 # return the properly prepared discharge macaroon
546 discharge = root_macaroon.prepare_for_request(d)
547 return discharge
491548
=== added file 'src/identityprovider/migrations/0007_auto_20160310_2013.py'
--- src/identityprovider/migrations/0007_auto_20160310_2013.py 1970-01-01 00:00:00 +0000
+++ src/identityprovider/migrations/0007_auto_20160310_2013.py 2016-03-11 17:07:30 +0000
@@ -0,0 +1,19 @@
1# -*- coding: utf-8 -*-
2from __future__ import unicode_literals
3
4from django.db import migrations, models
5
6
7class Migration(migrations.Migration):
8
9 dependencies = [
10 ('identityprovider', '0006_auto_20151019_1515'),
11 ]
12
13 operations = [
14 migrations.AlterField(
15 model_name='authlog',
16 name='log_type',
17 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')]),
18 ),
19 ]
020
=== modified file 'src/identityprovider/models/const.py'
--- src/identityprovider/models/const.py 2015-09-09 19:14:52 +0000
+++ src/identityprovider/models/const.py 2016-03-11 17:07:30 +0000
@@ -114,6 +114,7 @@
114 API_FAIL = 4114 API_FAIL = 4
115 API_NEW = 5 # A new oauth token created for the account115 API_NEW = 5 # A new oauth token created for the account
116 API_EXISTING = 6 # Existing oauth token retrieved116 API_EXISTING = 6 # Existing oauth token retrieved
117 API_MACAROON_DISCHARGE_NEW = 7 # First time a macaroon was discharged
117118
118 _verbose = {119 _verbose = {
119 OPENID_FAIL: _("Failed web login"),120 OPENID_FAIL: _("Failed web login"),
@@ -121,7 +122,8 @@
121 OPENID_EXISTING: _("Existing web login"),122 OPENID_EXISTING: _("Existing web login"),
122 API_FAIL: _("Failed API login"),123 API_FAIL: _("Failed API login"),
123 API_NEW: _("New API login"),124 API_NEW: _("New API login"),
124 API_EXISTING: _("Existing API login")125 API_EXISTING: _("Existing API login"),
126 API_MACAROON_DISCHARGE_NEW: _("First time discharge for the macaroon"),
125 }127 }
126128
127 @classmethod129 @classmethod
128130
=== modified file 'src/identityprovider/tests/test_auth.py'
--- src/identityprovider/tests/test_auth.py 2016-01-05 20:13:30 +0000
+++ src/identityprovider/tests/test_auth.py 2016-03-11 17:07:30 +0000
@@ -1,10 +1,18 @@
1# encoding: utf-81# encoding: utf-8
2# Copyright 2010 Canonical Ltd. This software is licensed under the2# Copyright 2010-2016 Canonical Ltd. This software is licensed under the
3# GNU Affero General Public License version 3 (see the file LICENSE).3# GNU Affero General Public License version 3 (see the file LICENSE).
44
5import base64
6import binascii
7import json
8import os
9
5from contextlib import contextmanager10from contextlib import contextmanager
11from datetime import datetime, timedelta
6from uuid import uuid412from uuid import uuid4
713
14from Crypto import Random
15from Crypto.PublicKey import RSA
8from django.conf import settings16from django.conf import settings
9from django.contrib.auth.hashers import get_hasher17from django.contrib.auth.hashers import get_hasher
10from django.contrib.auth.models import (18from django.contrib.auth.models import (
@@ -12,18 +20,22 @@
12 is_password_usable,20 is_password_usable,
13 make_password,21 make_password,
14)22)
23from django.core.exceptions import ValidationError
15from django.db import connection24from django.db import connection
16from django.test.utils import override_settings25from django.test.utils import override_settings
17from django.utils.timezone import now26from django.utils.timezone import now
18from mock import call, Mock, patch27from mock import call, Mock, patch
28from pymacaroons import Macaroon, Verifier
1929
20from identityprovider.auth import (30from identityprovider.auth import (
21 LaunchpadBackend,31 LaunchpadBackend,
22 SSOOAuthAuthentication,32 SSOOAuthAuthentication,
23 SSORequestValidator,33 SSORequestValidator,
24 basic_authenticate,34 basic_authenticate,
35 build_discharge_macaroon,
25 validate_oauth_signature,36 validate_oauth_signature,
26)37)
38from identityprovider.login import AuthenticationError
27from identityprovider.models import (39from identityprovider.models import (
28 Account,40 Account,
29 AccountPassword,41 AccountPassword,
@@ -940,3 +952,95 @@
940 expected_detail="(direct password comparison)",952 expected_detail="(direct password comparison)",
941 hashed_password=make_password(DEFAULT_USER_PASSWORD),953 hashed_password=make_password(DEFAULT_USER_PASSWORD),
942 timer=self.dummy_timer)954 timer=self.dummy_timer)
955
956
957class BuildMacaroonDischargeTestCase(SSOBaseTestCase):
958
959 def build_macaroon(self, service_location=None):
960 if service_location is None:
961 service_location = settings.MACAROON_SERVICE_LOCATION
962
963 # pair of keys to encrypt/decrypt
964 Random.atfork()
965 test_rsa_priv_key = RSA.generate(1024)
966 test_rsa_pub_key = test_rsa_priv_key.publickey()
967 p = self.settings(CRYPTO_SSO_PRIVKEY=test_rsa_priv_key)
968 p.enable()
969 self.addCleanup(p.disable)
970
971 # create a Macaron with the proper third party caveat
972 macaroon_random_key = binascii.hexlify(os.urandom(32))
973 root_macaroon = Macaroon(
974 location='The store ;)',
975 key=macaroon_random_key,
976 identifier='A test macaroon',
977 )
978 random_key = binascii.hexlify(os.urandom(32))
979 random_key_encrypted = base64.b64encode(
980 test_rsa_pub_key.encrypt(random_key, 32)[0])
981 root_macaroon.add_third_party_caveat(
982 service_location, random_key, random_key_encrypted)
983 return root_macaroon, macaroon_random_key
984
985 def test_root_macaroon_corrupt(self):
986 self.assertRaises(ValidationError, build_discharge_macaroon,
987 "fake account", "I'm a seriously corrupted macaroon")
988
989 def test_root_macaroon_not_for_sso(self):
990 macaroon, _ = self.build_macaroon(service_location="other service")
991 self.assertRaises(AuthenticationError, build_discharge_macaroon,
992 "fake account", macaroon.serialize())
993
994 def test_proper_discharging(self):
995 # build the input and call
996 root_macaroon, random_key_used = self.build_macaroon()
997 real_account = self.factory.make_account()
998 before = now()
999 discharge_macaroon = build_discharge_macaroon(
1000 real_account, root_macaroon.serialize())
1001 after = now()
1002
1003 # test
1004 def checker(caveat):
1005 """Assure all caveats inside the discharged macaroon are ok."""
1006 source, key, value = caveat.split("|", 2)
1007 if source != settings.MACAROON_SERVICE_LOCATION:
1008 # just checking the discharge one, not the root
1009 return True
1010
1011 if key == 'valid_since':
1012 valid_since = datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f')
1013 self.assertGreater(valid_since, before)
1014 self.assertGreater(after, valid_since)
1015 return True
1016
1017 if key == 'last_auth':
1018 last_auth = datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f')
1019 self.assertGreater(last_auth, before)
1020 self.assertGreater(after, last_auth)
1021 return True
1022
1023 if key == 'account':
1024 acc = json.loads(base64.b64decode(value).decode("utf8"))
1025 self.assertEqual(acc['openid'], real_account.openid_identifier)
1026 self.assertEqual(acc['email'],
1027 real_account.preferredemail.email)
1028 self.assertEqual(acc['displayname'], real_account.displayname)
1029 return True
1030
1031 if key == 'expires':
1032 expires = datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f')
1033 before_plus_ttl = before + timedelta(
1034 seconds=settings.MACAROON_TTL)
1035 after_plus_ttl = after + timedelta(
1036 seconds=settings.MACAROON_TTL)
1037 self.assertGreater(expires, before_plus_ttl)
1038 self.assertGreater(after_plus_ttl, expires)
1039 return True
1040
1041 # we're not validating an SSO from the discharged macaroon, fail!
1042 return False
1043
1044 v = Verifier()
1045 v.satisfy_general(checker)
1046 v.verify(root_macaroon, random_key_used, [discharge_macaroon])
9431047
=== modified file 'uci-vms.conf'
--- uci-vms.conf 2016-01-21 15:45:34 +0000
+++ uci-vms.conf 2016-03-11 17:07:30 +0000
@@ -15,7 +15,7 @@
15vm.update = True15vm.update = True
16vm.packages = {sso.dependencies}, python-uci-vms, python-novaclient16vm.packages = {sso.dependencies}, python-uci-vms, python-novaclient
17vm.bind_home = True17vm.bind_home = True
18vm.apt_sources = deb https://{ppa.u1_hackers.user_pass}@private-ppa.launchpad.net/ubuntuone/hackers/ubuntu {vm.release} main|4BD0ECAE 18vm.apt_sources = ppa:facundo/salty, deb https://{ppa.u1_hackers.user_pass}@private-ppa.launchpad.net/ubuntuone/hackers/ubuntu {vm.release} main|4BD0ECAE
1919
20# vms named sso-worker-0n are built from here20# vms named sso-worker-0n are built from here
21[sso-worker]21[sso-worker]
@@ -49,5 +49,5 @@
49# /!\ 'ppa.u1_hackers.user_pass = <USER>:<PASSWORD>' should be defined in49# /!\ 'ppa.u1_hackers.user_pass = <USER>:<PASSWORD>' should be defined in
50# ~/uci-vms.conf, see https://launchpad.net/~/+archivesubscriptions to find50# ~/uci-vms.conf, see https://launchpad.net/~/+archivesubscriptions to find
51# your password51# your password
52vm.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 52vm.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
53vm.packages = {sso.dependencies}, python3-uci-vms, lxc, lxc-templates, debootstrap53vm.packages = {sso.dependencies}, python3-uci-vms, lxc, lxc-templates, debootstrap